Fachhochschule für die Wirtschaft
– FHDW –
Hannover
Objektorientiertes C++ für Einsteiger
Skript zur gleichnamigen Veranstaltung
Verfasser:Christoph Schulz
E-Mail:[email protected]
Copyright © 2004-2007 Fachhochschule für die Wirtschaft, Hannover
Objektorientiertes C++ für Einsteiger
InhaltsverzeichnisAbbildungsverzeichnis.................................................................................................VTabellenverzeichnis.................................................................................................... VI1 Überblick....................................................................................................................1
1.1 Ziele....................................................................................................................11.2 Leitsätze und Konventionen...............................................................................11.3 Aufbau................................................................................................................31.4 Entwicklungssystem...........................................................................................4
2 Die ersten Schritte......................................................................................................52.1 Der Weg vom Quelltext zur ausführbaren Datei................................................52.2 Die Entwicklungsumgebung.............................................................................. 62.3 Aller Anfang ist leicht......................................................................................102.4 Wie geht es weiter?.......................................................................................... 162.5 Übungen........................................................................................................... 16
3 Grundlegende Konzepte...........................................................................................173.1 Programm-Aufbau............................................................................................17
3.1.1 Übersetzungeinheiten............................................................................... 173.1.2 Die main-Funktion....................................................................................19
3.2 Lexikalische Elemente..................................................................................... 203.2.1 Kommentare............................................................................................. 203.2.2 Bezeichner................................................................................................ 213.2.3 Schlüsselwörter.........................................................................................223.2.4 Operatoren und Interpunktionszeichen.....................................................233.2.5 Zahlen....................................................................................................... 233.2.6 Zeichen und Zeichenketten.......................................................................253.2.7 Trenner..................................................................................................... 27
3.3 Namen und Entitäten........................................................................................273.3.1 Deklarationen, Definitionen und Header-Dateien....................................283.3.2 Variablen.................................................................................................. 323.3.3 Initialisierung............................................................................................323.3.4 Gültigkeitsbereiche...................................................................................333.3.5 Sichtbarkeit...............................................................................................35
3.4 Typen und Ausdrücke...................................................................................... 373.4.1 Zuweisungen (offene und verkappte).......................................................413.4.2 Fundamentale Datentypen........................................................................ 43
3.4.2.1 Datentypen für Zeichen und Zeichenketten......................................433.4.2.2 Datentypen für Ganzzahlen.............................................................. 483.4.2.3 Datentypen für Fließkommazahlen...................................................523.4.2.4 Datentyp für Wahrheitswerte............................................................543.4.2.5 Der leere Datentyp............................................................................ 55
3.4.3 Zusammengesetzte Datentypen................................................................ 553.4.3.1 Funktionstypen................................................................................. 563.4.3.2 Referenzen........................................................................................ 573.4.3.3 Zeiger................................................................................................ 583.4.3.4 Klassen..............................................................................................60
I
Objektorientiertes C++ für Einsteiger
3.4.3.5 Felder................................................................................................ 603.4.3.6 Andere zusammengesetzte Datentypen............................................ 61
3.4.4 Konstanten................................................................................................623.4.5 Typ-Verträglichkeit und Konvertierungen............................................... 63
3.4.5.1 Typ-Verträglichkeit und implizite Typ-Konvertierungen................ 633.4.5.2 Explizite Typ-Konvertierungen (Casting)........................................ 66
3.4.6 Typ-Aliase................................................................................................ 673.5 Anweisungen....................................................................................................69
3.5.1 Ausdrücke.................................................................................................703.5.2 Deklarationen........................................................................................... 703.5.3 Fallunterscheidung und Selektion............................................................ 71
3.5.3.1 Fallunterscheidung............................................................................713.5.3.2 Selektion........................................................................................... 73
3.5.4 Schleifen................................................................................................... 743.5.4.1 Die while-Schleife............................................................................ 753.5.4.2 Die do-Schleife................................................................................. 763.5.4.3 Die for-Schleife................................................................................ 77
3.5.5 Sprünge.....................................................................................................793.5.6 Blöcke.......................................................................................................80
3.6 Funktionen........................................................................................................803.6.1 Das prozedurale Paradigma......................................................................813.6.2 (Klassische) Funktionen........................................................................... 823.6.3 Prozeduren oder Funktionen „ohne Wert“............................................... 843.6.4 Parameter und Argumente........................................................................843.6.5 Rekursion..................................................................................................88
3.7 Literaturempfehlungen..................................................................................... 913.8 Übungen........................................................................................................... 92
4 Die Welt der Objekte............................................................................................... 954.1 Grundlagen....................................................................................................... 95
4.1.1 Objekte, Nachrichten, Operationen und Methoden..................................954.1.2 Assoziationen und Aggregationen............................................................984.1.3 Gemeinsamkeiten, Schnittstellen und Polymorphie.................................984.1.4 Attribute..................................................................................................1014.1.5 Klassen................................................................................................... 1014.1.6 Erweiterung, Spezialisierung und Vererbung.........................................103
4.2 Ein erstes objektorientiertes Programm......................................................... 1054.3 UML (Unified Modeling Language)..............................................................107
4.3.1 Klassendiagramme................................................................................. 1084.3.2 Aktivitätsdiagramme.............................................................................. 109
4.4 Konkrete Datentypen: Daten und Methoden kapseln.................................... 1104.4.1 Problemstellung......................................................................................1104.4.2 Analyse-Phase........................................................................................ 111
4.4.2.1 Fachlexikon.....................................................................................1114.4.2.2 Fachklassen-Diagramm.................................................................. 111
4.4.3 Entwurfs-Phase.......................................................................................1134.4.3.1 Typen.............................................................................................. 113
II
Objektorientiertes C++ für Einsteiger
4.4.3.2 Verhalten.........................................................................................1134.4.3.3 Zustände..........................................................................................1134.4.3.4 Fehler-Situationen...........................................................................1144.4.3.5 Resultierendes Klassendiagramm................................................... 114
4.4.4 Exkurs: Attribute und kompositionale Beziehungen..............................1154.4.5 Implementierungs-Phase........................................................................ 115
4.4.5.1 Klassen und Methoden................................................................... 1154.4.5.2 Definition der Klassen und Schnittstellen...................................... 1164.4.5.3 Implementierung der Operationen.................................................. 118
4.4.6 Exkurs: Zugriffsschutz........................................................................... 1214.4.7 Exkurs: const-Operationen und const-Parameter................................... 122
4.4.7.1 const-Operationen...........................................................................1224.4.7.2 const-Parameter.............................................................................. 124
4.4.8 Test-Phase...............................................................................................1264.5 Objekte erzeugen, zerstören, und leben lassen...............................................131
4.5.1 Konstruktoren und Initialisierung...........................................................1314.5.2 Destruktoren und RAII........................................................................... 1374.5.3 Kopien und die Sache mit der Lebensdauer........................................... 142
4.5.3.1 Der Kopier-Konstruktor..................................................................1434.5.3.2 Verhindern von Kopien.................................................................. 1464.5.3.3 Zuweisungen...................................................................................147
4.5.4 Temporäre Objekte.................................................................................1494.5.5 Dynamischer Speicher............................................................................151
4.5.5.1 Speicher belegen und freigeben......................................................1514.5.5.2 Der „leere“ Verweis........................................................................1534.5.5.3 Wenn kein Speicher mehr da ist..................................................... 1544.5.5.4 Dynamische Datenstrukturen..........................................................155
4.6 Abstrakte Datentypen: Erweiterbarkeit und Flexibilität erhöhen.................. 1614.6.1 (Abstrakte) Operationen und abstrakte Klassen..................................... 1614.6.2 Implementierung von Schnittstellen.......................................................1644.6.3 Polymorphie........................................................................................... 1684.6.4 Einfachvererbung................................................................................... 171
4.6.4.1 Redefinition einer Methode............................................................ 1724.6.4.2 Aufruf der Oberklasse.....................................................................1794.6.4.3 Vererbung und Polymorphie...........................................................1794.6.4.4 Konstruktoren in Vererbungshierarchien........................................1834.6.4.5 Destruktoren in Vererbungshierarchien..........................................184
4.6.5 Mehrfachvererbung................................................................................ 1854.6.5.1 Rauten & Co................................................................................... 1884.6.5.2 Virtuelle Basisklassen.....................................................................1894.6.5.3 Redefinition einer Methode und Dominanz....................................1924.6.5.4 Konstruktoren und Destruktoren bei Mehrfachvererbung..............193
4.6.6 Korrekte Anwendung von Vererbung.................................................... 1964.6.6.1 Beispiel 1: Rechteck und Quadrat.................................................. 1964.6.6.2 Beispiel 2: Elemente und Listen derselben.....................................1984.6.6.3 Zusammenfassung.......................................................................... 201
III
Objektorientiertes C++ für Einsteiger
4.7 Literaturempfehlungen................................................................................... 2014.8 Übungen......................................................................................................... 201
5 Fehler erkennen und behandeln............................................................................. 2035.1 Grundlegendes................................................................................................2035.2 Erzeugen von Ausnahmen..............................................................................2045.3 Behandeln von Ausnahmen............................................................................2075.4 Ausnahmen während des Programmlaufs...................................................... 2095.5 Behandeln beliebiger Ausnahmen..................................................................2125.6 Erneutes Auswerfen von Ausnahmen............................................................ 2135.7 Ausnahme-Spezifikationen............................................................................ 2165.8 Ausnahme-Sicherheit und warum sie so wichtig ist...................................... 2205.9 Ausnahmen und RAII.................................................................................... 222
6 Entwurfsmuster...................................................................................................... 2276.1 Einführung......................................................................................................2276.2 Strukturelle Muster.........................................................................................227
6.2.1 Composite...............................................................................................2276.2.2 Decorator................................................................................................ 2276.2.3 Proxy.......................................................................................................227
6.3 Verhaltensmuster............................................................................................2276.3.1 Template Method....................................................................................2276.3.2 Iterator.................................................................................................... 2276.3.3 Observer................................................................................................. 2276.3.4 Strategy...................................................................................................2276.3.5 State........................................................................................................ 2276.3.6 Command............................................................................................... 227
6.4 Erzeugungsmuster.......................................................................................... 2276.4.1 Abstract Factory..................................................................................... 2286.4.2 Singleton.................................................................................................228
7 Überladung und Schablonen.................................................................................. 2297.1 Überladung..................................................................................................... 229
7.1.1 Überladung von Funktionen................................................................... 2297.1.2 Überladung von Operationen und Methoden......................................... 2297.1.3 Überladung von Operatoren................................................................... 229
7.2 Schablonen (Templates).................................................................................2298 Die Standard-Bibliothek........................................................................................ 231
8.1 Einführung......................................................................................................2318.2 Namensräume.................................................................................................2318.3 Datenstrukturen.............................................................................................. 2318.4 Algorithmen................................................................................................... 2318.5 Ein-/Ausgabe..................................................................................................231
Merksätze..................................................................................................................232Beispiele....................................................................................................................233Literaturverzeichnis.................................................................................................. 235
IV
Objektorientiertes C++ für Einsteiger
AbbildungsverzeichnisAbbildung 1: Die einzelnen Phasen in der Software-Entwicklung.............................. 6Abbildung 2: Das Hauptfenster der Entwicklungsumgebung VC++........................... 7Abbildung 3: Ein neues Projekt in VC++, Teil 1......................................................... 8Abbildung 4: Ein neues Projekt in VC++, Teil 2......................................................... 9Abbildung 5: VC++ nach dem Anlegen des hello-Projekts......................................... 9Abbildung 6: Erzeugen eines neuen Editor-Fensters..................................................10Abbildung 7: Datei einem Projekt hinzufügen........................................................... 11Abbildung 8: Speichern des Arbeitsbereichs..............................................................11Abbildung 9: Übersetzen eines C++-Programms....................................................... 15Abbildung 10: Ausführen eines C++-Programms...................................................... 15Abbildung 11: Ausgaben des VC++-Übersetzers (wenn keine Fehler vorliegen)..... 16Abbildung 12: Der erste Testlauf................................................................................16Abbildung 13: Verteilte Deklarationen und Definitionen.......................................... 32Abbildung 14: Gültigkeitsbereiche............................................................................. 34Abbildung 15: Verdecken von Namen....................................................................... 36Abbildung 16: Verlustfreie Konvertierungen in C++.................................................65Abbildung 17: Verlustfreie Konvertierungen in VC++..............................................66Abbildung 18: Rekursion am Beispiel der Berechnung von 3! (3 Fakultät).............. 90Abbildung 19: System interagierender Objekte..........................................................95Abbildung 20: Video-Beispiel: Objekte und Beziehungen........................................ 96Abbildung 21: Video-Beispiel: Klienten, Dienstleister und Nachrichten.................. 96Abbildung 22: Video-Beispiel: Operationen und Methoden...................................... 97Abbildung 23: Video-Beispiel: Schnittstellen............................................................ 99Abbildung 24: Video-Beispiel: Attribute..................................................................101Abbildung 25: Video-Beispiel: Erweiterung einer Schnittstelle.............................. 103Abbildung 26: Video-Beispiel: Spezialisierung von Schnittstellen und Klassen.....104Abbildung 27: Das Video-Beispiel als UML-Klassendiagramm............................. 107Abbildung 28: Aktivitätsdiagramm zum do-Beispiel (3.5.4.2)................................ 109Abbildung 29: Fachklassen-Diagramm zur Queue-Aufgabe....................................112Abbildung 30: Vereinfachtes Fachklassen-Diagramm zur Queue-Aufgabe............ 112Abbildung 31: Klassendiagramm nach der Entwurfsphase...................................... 114Abbildung 32: Komposition anstatt von Attributen..................................................115Abbildung 33: Ausgabe der Queue-Tests................................................................. 131Abbildung 34: Stapel-Operationen........................................................................... 156Abbildung 35: Einfach verkettete Liste mit drei Elementen.................................... 156Abbildung 36: Entwurf einer Stapel-Klasse, die auf einer verketten Liste aufbaut. 156Abbildung 37: GraphicalObject-Schnittstelle...........................................................164Abbildung 38: GraphicalObject-Hierarchie..............................................................166Abbildung 39: Verschiedene Spielarten der Vererbung........................................... 172Abbildung 40: Ursprüngliche Anordnung der Elemente.......................................... 178Abbildung 41: Mögliche Anordnung nach instabiler Sortierung............................. 178Abbildung 42: Anordnung nach stabiler Sortierung.................................................178Abbildung 43: Raute bei Mehrfachvererbung.......................................................... 189Abbildung 44: Rauten, die keine sind.......................................................................190
V
Objektorientiertes C++ für Einsteiger
Abbildung 45: Beispiel für Dominanz von Methoden..............................................192Abbildung 46: Spezialisierung zwischen Quadrat und Rechteck............................. 197Abbildung 47: Spezialisierung zwischen PointList und GraphicalObjectList......... 199
TabellenverzeichnisTabelle 1: Schlüsselwörter in C++..............................................................................23Tabelle 2: Operatoren und Interpunktionszeichen in C++..........................................24Tabelle 3: Operatoren nach ihrer Priorität sortiert......................................................39Tabelle 4: char-basierte Ein-/Ausgabeströme und ihre wchar_t-Pendanten...............45Tabelle 5: Relationale Operationen auf Zeichen........................................................ 46Tabelle 6: C++-Datentypen für Ganzzahlen............................................................... 48Tabelle 7: Rechenregeln bei ganzzahliger Division................................................... 50Tabelle 8: Rechenregeln bei ganzzahliger Division mit Rest.....................................50Tabelle 9: Vergleiche von ganzen Zahlen.................................................................. 51Tabelle 10: Garantierte und typische Wertebereiche von Fließkommazahlen........... 52Tabelle 11: Garantierte und typische Genauigkeiten von Fließkommazahlen........... 52Tabelle 12: Konvertierungen zwischen fundamentalen Datentypen.......................... 64
VI
Objektorientiertes C++ für Einsteiger Überblick
1 Überblick
1.1 ZieleDieses Skript hat es sich zum Ziel gesetzt, eine Einführung in die verbreitete Programmiersprache C++ zusammen mit einer Einführung in die objektorientierte Software-Entwicklung zu kombinieren. Nach der gründlichen Durcharbeitung des Skripts sollten Sie mit den wichtigsten Sprachmitteln von C++ sowie grundlegenden Kenntnissen in der objektorientierten Software-Entwicklung vertraut sein.
Angesprochen sind dabei Leser, die bereits grundlegende Programmiererfahrungen in einer anderen (aber nicht unbedingt objektorientierten) Programmiersprache gemacht haben und nun C++ kennen lernen wollen. Aber auch wenn Sie bereits erste Erfahrungen mit C++ gemacht haben, sollten Sie hier Interessantes vorfinden. Vorausgesetzt wird lediglich, dass Sie grundlegende Programmier-Kenntnisse mitbringen und Begriffe wie Programm, Algorithmus, Funktion, Prozedur und Variable zumindest intuitiv erklären können. Außerdem sollten Sie Ihr Computer-System bedienen und z. B. ein Programm starten oder einen Text-Editor benutzen können.
Im Folgenden wird bei Wörtern, die sowohl in einer weiblichen als auch männlichen Wortform existieren (z. B. Leserin oder Leser) die männliche Wortform verwendet. Fühlen Sie sich dadurch aber nicht ausgeschlossen, wenn Sie weiblich sind – das Skript würde sonst ziemlich unleserlich, wenn beide Formen ständig verwendet würden. (Und irgendeine Wahl muss der Autor schließlich treffen...)
1.2 Leitsätze und KonventionenEs ist immer schwierig, es sowohl Einsteigern als auch Lesern mit fortgeschrittenen Kenntnissen recht zu machen. Schließlich mögen Sie zu den Lesern gehören, die C++ bereits kennen und in einigen kleinen Projekten angewandt haben, aber mit dem OO-Paradigma1 nicht besonders gut vertraut sind. Umgekehrt programmieren Sie vielleicht seit Jahren in (anderen) objektorientierten Sprachen und möchten nun C++ kennen lernen. Um den verschiedenen Vorkenntnissen und Erwartungen der Leser gerecht zu werden, orientiert sich das Skript an folgenden Leitsätzen:
• Es lernt sich leichter an Beispielen. Neue Konzepte werden anhand von Beispielen eingeführt. Die Beispiele sind möglichst praxisnah gewählt, so dass der Nutzen vorgestellter Sprachkonzepte unmittelbar deutlich werden sollte.
• Übungen steigern den Lerneffekt. Am Ende eines jeden Kapitels finden sich Übungen, in denen Sie die dort erworbenen Kenntnisse anwenden und vertiefen können (bzw. sollten!). Dabei ist jeder Übung ein (geschätzter) Schwierigkeitsgrad zwischen (*1) und (*5) zugeordnet, wobei Übungen mit höherem Schwierigkeitsgrad schwieriger sind als jene mit niedrigerem Schwierigkeitsgrad.2 Die Skala ist aber nicht linear, sondern eher exponentiell: Wenn Sie für eine (*1)-Übung etwa zehn Minuten benötigen, so brauchen Sie für eine (*2)-Übung ungefähr eine Stunde und für eine (*3)-Übung vermutlich einen halben Tag. Natür
1) „OO“ ist eine gängige Abkürzung für „objektorientiert“, die Sie häufiger antreffen werden.2) Diese Klassifizierung der Übungen wurde [Strou00] entnommen.
1
Zielsetzung
Leitsätze
viele Beispiele
viele Übungen
Zielgruppe und Voraussetzungen
Überblick Objektorientiertes C++ für Einsteiger
lich hängt dies stark von Ihren Vorkenntnissen und anderen Umständen3 ab, so dass Sie diese Angaben nur als Richtwert sehen sollten.
• Alles zur rechten Zeit. Abschnitte, die eher für Fortgeschrittene interessant sind oder besonders wichtige Informationen beinhalten, sind mit speziellen Symbolen am Seiten- oder Absatzrand gekennzeichnet:
Ein Absatz, der mit diesem Symbol gekennzeichnet ist, bedarf zum vollständigen Verständnis fortgeschrittene C++-Kenntnisse. Insbesondere beinhalten diese Abschnitte häufig Bezüge zu späteren Abschnitten und Kapiteln. Anfänger sollten deshalb beim ersten Durcharbeiten des Skripts diese Abschnitte getrost überspringen. Fortgeschrittene Programmierer finden jedoch darin zusätzliche und wertvolle Informationen, die zu einem tiefer greifenden Verständnis der dargestellten C++-Konzepte oder -Methoden führen.
Dieses Symbol weist auf einen Abschnitt oder Absatz hin, der fortgeschrittene Techniken oder Methoden der objektorientierten Analyse, des objektorientierten Entwurfs oder der objektorientierten Programmierung darstellt. Er kann vom Anfänger beim ersten Durcharbeiten des Skripts ruhig „überlesen“ werden.
Genauso, wie die Welt „da draußen“ sich von unseren Idealen fast immer unterscheidet, gibt es auch Fallen und Tücken in der Software-Entwicklung, in die ein unbedarfter Einsteiger hineintappen kann bzw. mit denen er nicht rechnet. Das Skript versucht, auf solche Situation mit einem Ausrufezeichen hinzuweisen. Sie sollten die auf diese Weise markierten Abschnitte besonders gründlich durcharbeiten.
Durch dieses Symbol wird der Leser darauf aufmerksam gemacht, dass der solcherart markierte Abschnitt spezielle Informationen für den kundigen Java-Programmierer bereithält – seien es Ähnlichkeiten oder Unterschiede, Hilfen oder Warnungen. Sie können diese Abschnitte gefahrlos übergehen, wenn Sie keinerlei Erfahrungen in der Programmierung mit Java gemacht haben.
• Merksätze. Wichtige Erkenntnisse werden in Form von kurzen und prägnanten Merksätzen formuliert. Diese Merksätze sind durchnummeriert und am Ende des Skripts in einem kleinen Verzeichnis zusammengefasst.
Schließlich hält das Skript gewisse Konventionen bezüglich der Darstellung ein:
• Generell gilt, dass Symbole am Seitenrand für den gesamten Abschnitt, Symbole am Absatzrand hingegen nur für den jeweiligen Absatz gelten. Dieser ist dann auch in etwas kleinerer Schrift gehalten, damit er sich besser vom restlichen Text unterscheidet und Anfang und Ende des durch das Symbol markierten Textes klar hervortreten. Beispiele für derart formatierte Absätze haben Sie bereits weiter oben kennen gelernt.
• C++-Quelltexte werden generell in nicht-proportionaler Schrift gedruckt, Schlüsselwörter (3.2.3) zusätzlich zur besseren Hervorhebung fett formatiert. Kommentare (3.2.1) innerhalb von Quelltexten werden kursiv (und in proportionaler Schrift) gesetzt, um die Lesbarkeit der Code-Fragmente zu verbessern. Weiterhin sind die Zeilen aller Quelltexte durchnummeriert. Beispiel:
1 int fakultaet (int argument) // berechnet die Fakultät2 {
3) Es kann sein, dass Sie eine (*5)-Übung innerhalb einer halben Stunde lösen können, wenn Sie geeignete Werkzeuge benutzen, die Ihnen die Lösung der Aufgabe entscheidend erleichtern. Umgekehrt werden Sie an einer (*1)-Übung vielleicht einen halben Tag lang sitzen, wenn Sie sich erst in Ihre Entwicklungsumgebung einarbeiten müssen.
2
Eile mit Weile
Konventionen
Aufgepasst!
OO für Fortgeschrittene
C++ für Fortgeschrittene
Merksätze
Vergleich mit Java
Objektorientiertes C++ für Einsteiger Überblick
3 return argument == 0 ? 1 : argument * fakultaet (argument – 1);
4 }
• Eingaben und Ausgaben von Programmen werden ebenfalls in nicht-proportionaler Schriftart gesetzt. Beispiel: Press any key to continue
• Tasten und Tastenkombinationen werden in nicht-proportionaler Schrift und mit Kapitälchen gesetzt. Beispiel: F7, STRG+F5
• Dateinamen werden in nicht-proportionaler Schrift dargestellt. Beispiel: hello.cpp
• Elemente von graphischen Oberflächen wie Menüpunkte, Texte innerhalb von Dialogen (etwa auf Schaltflächen) etc. werden kursiv dargestellt. Beispiel: File → New...
1.3 AufbauDas Skript ist folgendermaßen aufgebaut:
• Kapitel 1 lesen Sie gerade und enthält grundlegende Informationen zum Skript.
• In Kapitel 2 lernen Sie die Entwicklungsumgebung Microsoft Visual C++ 6.0 kennen und schreiben Ihr erstes C++-Programm. Dabei werden Sie mit den ersten Grundkonzepten von C++ vertraut gemacht.
Sie können dieses Kapitel überspringen, falls Sie bereits erste Erfahrungen in der Programmiersprache C++ gesammelt haben.
• Kapitel 3 geht näher auf die (nicht-OO-)Grundlagen von C++ ein. Funktionen, Variablen, Typen, Anweisungen und Ausdrücke werden ausführlich besprochen und deren typische Anwendung aufgezeigt. Zusätzlich wird der Datentyp string aus der C++-Standard-Bibliothek zur Speicherung und Manipulation von Zeichenketten eingeführt.
Dieses Kapitel sollten Sie lesen, wenn Sie nicht bereits über fundierte Kenntnisse in C++ verfügen. Insbesondere sollte der C-kundige4 Leser dieses Kapitel nicht überspringen, weil viele dort vorgestellten Konzepte sich in Details von denen in C unterscheiden.
• In Kapitel 4 lernen Sie das objektorientierte Paradigma kennen. Sie werden erfahren, was Klassen, Objekte, Operationen und Methoden sind und wie Sie diese in C++ definieren und nutzen können. Sie werden über Schnittstellen, Vererbung und Delegation aufgeklärt und lernen, wie man diese Mittel nutzbringend anwendet. Sie lernen, was Konstruktoren und Destruktoren sind und wie Sie Ihre Daten und Operationen vor unberechtigter Verwendung schützen können. Weiterhin geht Kapitel 4 auch auf die (dynamische) Speicherverwaltung ein.
Da dieses Kapitel das Zentrum des Skripts darstellt und sehr viele nützliche Informationen zu objektorientierter Programmierung in C++ enthält, sollten Sie dieses Kapitel unbedingt lesen. Es baut auf den grundlegenden Konzepten aus
4) C ist eine Programmiersprache, die als Grundlage für die Entwicklung von C++ diente.
3
Aufbau
Allgemeines
das erste Programm
Grundlagen von C++
Objektorientierung und Speicherverwaltung
Überblick Objektorientiertes C++ für Einsteiger
Kapitel 3 auf, so dass Sie diese verstanden haben sollten, bevor Sie dieses Kapitel durcharbeiten.
• Kapitel 5 erläutert, wie Fehler-Situationen in C++ geeignet behandelt werden können. Es stellt das Konzept von Ausnahmen vor und erklärt, wie diese dazu genutzt werden können, Informationen von der Fehler-Quelle zum Fehler-Behandler transferieren zu können.
• Kapitel 6 führt Sie in die Welt der Entwurfsmuster ein. Sie erfahren, wie Sie mit Mustern existierende Lösungen in neuen Kontexten wiederverwenden können und bekommen die wichtigsten Entwurfsmuster vorgestellt.
Dieses Kapitel baut auf Kapitel 3 und natürlich Kapitel 4 auf und sollte erst dann durchgearbeitet werden, wenn Sie mit den dort behandelten Konzepten hinlänglich vertraut sind und zumindest die Hälfte aller dort enthaltenen Übungen erfolgreich absolviert haben.
• In Kapitel 7 lernen Sie das Überladen von Funktionen und Operationen sowie Schablonen (Templates) als weitere C++-Abstraktionskonzepte kennen. Um den Rahmen nicht zu sprengen, wird insbesondere auf letztere nur kurz eingegangen.
Dieses Kapitel baut ebenfalls auf den Grundlagen von Kapitel 3 und 4 auf.
• Kapitel 8 führt Sie in Namensräume und die C++-Standard-Bibliothek ein und erklärt Ihnen, warum es häufig nicht sinnvoll ist, das Rad ständig neu zu erfinden. Sie lernen das Ein-/Ausgabe-System von C++ kennen und erfahren, wie Container, Iteratoren und Algorithmen zusammenspielen.
Da die C++-Standard-Bibliothek so ziemlich alle Sprachmittel ausschöpft, die C++ anzubieten hat, ist es von Vorteil, wenn Sie die vorherigen Kapitel alle durchgearbeitet haben.
1.4 EntwicklungssystemZum Thema Entwicklungssystem: Das Skript kann sich naturgemäß nicht mit den verschiedenen Rechner-Typen, Betriebssystemen und C++-Entwicklungsumgebungen befassen, die zur Zeit existieren. Aus mehreren Gründen wurde in diesem Skript Microsoft Visual C++ 6.0 (im Folgenden einfach mit VC++ abgekürzt) unter dem Betriebssystem Microsoft Windows als Entwicklungssystem ausgewählt, hauptsächlich deshalb, weil dieses Produkt in der Industrie häufig eingesetzt wird5 und damit quasi einen De-facto-Standard darstellt. Entwickler, die mit einem anderen System bzw. einer anderen C++-Entwicklungsumgebung arbeiten, sollten jedoch problemlos die in dem Skript benutzten Beispiele auf „ihr“ System und „ihre“ Entwicklungswerkzeuge übertragen können. Sie können in diesem Fall die Abschnitte, die sich ausschließlich mit VC++ befassen, problemlos überspringen.
Diese Abschnitte sind zur besseren Unterscheidung mit dem nebenstehenden Symbol gekennzeichnet.
5) und zwar immer noch, obwohl inzwischen bereits der dritte Nachfolger existiert!
4
die Wahl der Entwicklungsumgebung
Entwurfsmuster
Überladung und Schablonen
Namensräume und C++-Standard-Bibliothek
Fehlerbehandlung
Objektorientiertes C++ für Einsteiger Die ersten Schritte
2 Die ersten SchritteIn diesem Kapitel lernen Sie, welche Aufgabe eine Entwicklungsumgebung hat und was der Entwicklungsprozess ist. Sie erfahren, wie Sie in VC++ ein Projekt erzeugen, den Quelltext-Editor starten und Quellen Projekten zuordnen. Schließlich schreiben Sie Ihr erstes C++-Programm, übersetzen es und führen es aus.
2.1 Der Weg vom Quelltext zur ausführbaren DateiWenn Sie vielleicht noch nie mit einer Entwicklungsumgebung gearbeitet haben, fragen Sie sich, wozu Sie diese überhaupt brauchen. Nun, eine solche Entwicklungsumgebung wird für gewöhnlich drei zentrale Funktionen in sich vereinen, die allesamt während der Entwicklung und dem Test eines Programms von Bedeutung sind:
(1) Erzeugen/Bearbeiten von Quelltexten und anderen Ressourcen: Zuerst muss in einer Programmiersprache6 das zu erstellende Programm „niedergeschrieben“ werden. Dies unterstützt eine Entwicklungsumgebung, indem sie Funktionen zum Erzeugen und Bearbeiten von Quellen zur Verfügung stellt. Als Quellen werden alle Ressourcen bezeichnet, die vom Programmierer in die Entwicklung des Programms eingebracht werden und nicht von der Entwicklungsumgebung selbstständig erzeugt werden können. Außer Programm-Quelltexten gehören hierzu beispielsweise auch Hilfe-Texte, Bilder, Videos und dergleichen mehr. Dieser Vorgang des „Niederschreibens“ wird häufig Kodieren genannt.
(2) Übersetzen der Quelltexte und Ressourcen in eine lauffähige Datei: Die in Schritt 1 erzeugten Quellen sind jedoch in den meisten Fällen nicht direkt ausführbar, d. h. es muss eine geeignete Übersetzung stattfinden. Dabei werden alle Quellen auf Korrektheit geprüft und in Maschinen-Befehle übersetzt. Schließlich werden alle übersetzten Quellen zu einem ausführbares Programm gebunden. Den Vorgang des Übersetzens nennt man auch kompilieren (= zusammenstellen), den des Bindens auch linken (engl. to link = binden, verknüpfen).
(3) Ausführen des übersetzten Programms (zu Testzwecken): Konnte das entwickelte Programm übersetzt werden, heißt das noch lange nicht, dass es auch funktioniert. In einem oder mehreren Testläufen muss geprüft werden, ob die Anforderungen an das Programm erfüllt worden sind. Eine Entwicklungsumgebung enthält in der Regel nicht nur Funktionen zum normalen Ausführen des übersetzten Programms, sondern auch zur Fehlersuche und -behebung, das gemeinhin als Debugging bezeichnet wird.
Bevor jedoch diese drei Phasen durchlaufen werden (und somit eine Entwicklungsumgebung den Programm-Entwickler unterstützt), müssen die Anforderungen an das Programm analysiert und die einzelnen Komponenten des Programms quasi „auf dem Reißbrett“ entworfen werden. Beispielhaft führen wir dies in Abschnitt 4.4 durch. Das Beispiel zeigt insbesondere, dass Software-Entwicklung aus mehr besteht als nur aus dem Eintippen von Sprach-Konstrukten in einem Editor innerhalb einer Entwicklungsumgebung.
6) Es gibt auch Programme, die in mehreren Programmiersprachen entwickelt werden. Dies wird jedoch innerhalb dieses Skriptes nicht weiter verfolgt.
5
Warum eine Entwicklungsumgebung?
Analyse und Entwurf
Lernziele
Die ersten Schritte Objektorientiertes C++ für Einsteiger
Die einzelnen Phasen des Entwicklungsprozesses veranschaulicht zusammenfassend Abbildung 1. Diese Phasen bauen aufeinander auf (= inkrementell) und werden während der Software-Entwicklung immer wieder durchlaufen (= iterativ). Deshalb spricht man auch vom iterativ-inkrementellen Entwicklungsprozess. (Wenn Sie sich fragen, warum Sie immer wieder die Analyse-Phase durchlaufen soll(t)en, dann fragen Sie sich auch mal, wie viele Programme Sie benutzen, die vielleicht Probleme lösen, aber die falschen...)
2.2 Die EntwicklungsumgebungNach der langen Vorrede geht es jetzt endlich zur Sache! Zuallererst müssen Sie Ihre Entwicklungsumgebung starten, damit Sie überhaupt in der Lage sind, C++-Programme einzugeben, zu übersetzen und laufen zu lassen.
Starten Sie also VC++ über die entsprechende Verknüpfung. Abbildung 2 zeigt das Hauptfenster von VC++, das sich Ihnen nach dem Start präsentiert. (Falls Sie über eine andere als die hier beschriebene englische Version des Programms verfügen, müssen Sie sich die Menüpunkte und Dialog-Texte geeignet „übersetzen“.)
Es folgt eine kurze Beschreibung der wichtigsten Bereiche:
• Bereich 1 ist die Titelzeile der Entwicklungsumgebung. Sie enthält (momentan nicht sichtbar) als wichtigste Information den Namen der gerade bearbeiteten Datei.
• Bereich 2 enthält die Menüleiste, über welche die meisten Aktionen vorgenommen werden.
• Bereich 3 enthält Werkzeugleisten. Diese sind bebilderte „Abkürzungen“ zu einzelnen Menüpunkten.
• Bereich 4 ist der sogenannte Arbeitsbereich. In ihm werden später die zu einem Projekt gehörenden Dateien und Ressourcen dargestellt. Momentan ist er leer, weil noch kein Projekt erzeugt oder geöffnet worden ist.
6
Aller Anfang ist leicht
die wichtigsten Bereiche
Entwicklungsprozess
Abbildung 1: Die einzelnen Phasen in der Software-Entwicklung
int do_it() {return 42;}
int main () { return do_it();}
int main () { return 0;}
0110101110100101010010011010Analyse
EntwurfRealisierung
Übersetzung
Test
Objektorientiertes C++ für Einsteiger Die ersten Schritte
• Bereich 5 zeigt Ausgaben und Meldungen an, die während der Arbeit mit VC++ anfallen. In der ersten und wichtigsten Lasche Build werden Warnungen und Fehler angezeigt, die bei der Analyse Ihrer Programme durch die Entwicklungsumgebung erkannt werden.
• Bereich 6 bietet Platz für alle anderen Fenster, insbesondere Editor-Fenster zum Bearbeiten von Quelltext-Dateien.
• Bereich 7 bildet die Statuszeile, welche alle Arten von Informationen enthält, die während der Arbeit mit der Entwicklungsumgebung anfallen. Dies könne Kontext-sensitive Informationen sein (etwa ein erläuternder Text zu einem ausgewählten Menüpunkt), das Ergebnis einer vorherigen Operation oder einfach nur eine Statusinformation (etwa „Ready“ wie in Abbildung 2).
Normalerweise besteht ein (C++-)Programm nicht lediglich aus einer Datei. Oftmals sind viele Dateien zur Entwicklung eines Programms erforderlich. In der Regel hat eine Aufteilung des Programms in mehrere Dateien folgende Vorteile:
• einzelne Programmteile lassen sich besser in anderen Programmen wiederverwenden, wenn sie in separaten Dateien existieren
• der Quelltext wird überschaubarer, weil die Dateigröße im Schnitt kleiner ist
• die einzelnen Dateien lassen sich je nach Funktion in unterschiedliche Verzeichnisse einordnen, was ebenfalls für mehr Übersichtlichkeit sorgt
7
Programme bestehen aus vielen Quellen
Abbildung 2: Das Hauptfenster der Entwicklungsumgebung VC++
Die ersten Schritte Objektorientiertes C++ für Einsteiger
• innerhalb eines Teams lassen sich Dateien und Verzeichnisse einzelnen Team-Mitgliedern zuordnen, so dass verschiedene Entwickler nicht an denselben Dateien gleichzeitig arbeiten müssen (was oftmals zu Problemen führen kann)
• einige Ressourcen lassen sich in der Regel gar nicht oder nur sehr mühsam in einen C++-Quelltext einbetten, etwa Bilder und Videos
Um für ein bestimmtes Programm alle zusammengehörigen Quellen zu verwalten, existieren in VC++ sogenannte Projekte. Ein Projekt ist eben eine solche Zusammenstellung aller für ein Programm benötigten Quellen. Um die Entwicklung eines Programms zu beginnen, müssen Sie in VC++ also zuerst ein entsprechendes Projekt anlegen. Dies erledigen Sie einfach über den Menüpunkt File → New.... Es öffnet sich ein Dialog, in dem Sie folgende Änderungen vornehmen sollten (Abbildung 3):
• Wählen Sie die Lasche Projects aus (falls es nicht schon der Fall ist).
• Wählen Sie aus der Liste an möglichen Projekt-Typen Win32 Console Application aus.
• Wählen Sie bei Location einen gültigen Pfad aus, unter dem Ihr Projekt angelegt werden soll.
• Wählen Sie hello bei Project name als Namen für Ihr Projekt. Das Projekt wird in einem eigenen Verzeichnis unterhalb des bei Location angegebenen Verzeichnisses gespeichert (der Location-Pfad wird automatisch angepasst).
• Schließen Sie den Dialog über die OK-Schaltfläche.
Sie sollten jetzt einen weiteren Dialog zu Gesicht bekommen (Abbildung 4). Bitte bestätigen Sie in diesem Dialog über die Finish-Schaltfläche die aktuelle Auswahl An empty project, um ein leeres Projekt zu erstellen. Den nachfolgenden Dialog bestätigen Sie bitte ebenfalls (er gibt Ihnen noch eine letzte Möglichkeit, die Erstellung des Projekts abzubrechen).
8
Quellen werden in Projekten zusammengefasst
Abbildung 3: Ein neues Projekt in VC++, Teil 1
Anlegen eines Projekts
Objektorientiertes C++ für Einsteiger Die ersten Schritte
Ihr Hauptfenster sollte danach in etwa so aussehen wie in Abbildung 5.
Sollten Sie im Arbeitsbereich etwas anderes angezeigt bekommen, so liegt das daran, dass die falsche Lasche ausgewählt ist. Bitte wählen Sie die Lasche FileView aus und klappen die Liste der Dateien für das hello-Projekt auf.
Wie Sie sehen, sehen Sie nichts – immerhin sind unserem Projekt noch keine Quellen zugeordnet. Das ändert sich jedoch im nächsten Abschnitt, in welchem Sie Ihr erstes C++-Programm schreiben werden. Allerdings können Sie noch nichts eintippen, weil kein Editor-Fenster existiert. Hierzu klicken Sie am einfachsten auf die ers
9
Abbildung 5: VC++ nach dem Anlegen des hello-Projekts
Abbildung 4: Ein neues Projekt in VC++, Teil 2
Anlegen einer Quelltext-Datei
Die ersten Schritte Objektorientiertes C++ für Einsteiger
te Schaltfläche in der obersten Werkzeugleiste (Abbildung 6), die ein leeres, unbenanntes Text-Fenster erzeugt, so dass Sie mit dem folgenden Schritt weitermachen können.
2.3 Aller Anfang ist leichtWenn Sie die erste Hürde gemeistert haben, haben Sie jetzt einen Texteditor o. ä. vor sich und sind in der Lage, C++-Quelltext einzugeben. Bitte tippen Sie den folgenden C++-Programmtext ein:
1 /*** Beispiel hello.cpp ***/2 #include <istream> // für die Eingabe3 #include <ostream> // für die Ausgabe4 #include <iostream> // für die Objekte cin und cout5 #include <string> // für Zeichenketten-Verarbeitung6 using namespace std;78 string liesName ()9 {
10 cout << "Bitte gib deinen Namen ein: ";11 string name;12 cin >> name;13 return name;14 }1516 void begruesse (string name)17 {18 cout << "Hallo " << name << "!" << endl;19 }2021 int main ()22 {23 cout << "Mein erstes C++-Programm" << endl;2425 string meinName = liesName ();26 begruesse (meinName);2728 return 0;29 }
Bitte speichern Sie nun den Quelltext in VC++ unter dem Dateinamen hello.cpp ab. Dazu drücken Sie am einfachsten die Tastenkombination STRG+S und geben den genannten Dateinamen ein. Danach ist der Quelltext zwar gespeichert, die Datei ist jedoch nicht automatisch dem Projekt zugeordnet. Dazu müssen Sie im Arbeitsbereich-Fenster zu hello files das Kontextmenü öffnen und dort den Menü-Eintrag Add Files to Project... wählen (Abbildung 7). Wenn Sie dann die eben erstellte hello.cpp wählen, fügt sie VC++ zu den Quellen des Projekts hinzu. Vergessen Sie danach nicht, den
10
das erste Programm in C++
Abbildung 6: Erzeugen eines neuen Editor-Fensters
Speichern und Zuordnen
Objektorientiertes C++ für Einsteiger Die ersten Schritte
Arbeitsbereich über den Menüpunkt File → Save Workspace ebenfalls zu speichern (Abbildung 8).
Analysieren wir das Programm nun Stück für Stück:
11
Abbildung 7: Datei einem Projekt hinzufügen
Abbildung 8: Speichern des Arbeitsbereichs
Die ersten Schritte Objektorientiertes C++ für Einsteiger
• Zeile 1: Diese und die nächsten vier Zeilen demonstrieren Ihnen, wie Kommentare in den Quelltext integriert werden können. Der Übersetzer ignoriert alles zwischen /* und */ sowie alles zwischen // und dem Zeilenende. Somit lässt sich in diesem Bereich insbesondere der Quelltext kommentieren und dokumentieren.
• Zeilen 2 bis 5: Hier werden über sogenannte Präprozessor-Direktiven7 Entitäten zur Ein-/Ausgabe und zur Zeichenketten-Verarbeitung aus der C++-Standard-Bibliothek (8) für das Programm verfügbar gemacht. Die C++-Standard-Bibliothek ist eine vom C++-Standard wohldefinierte Ansammlung von nützlichen Funktionen (3.6), Objekten (4.5), Konstanten (3.4.4), Variablen (3.3.2), Typen (3.4), Schablonen (7.2) und noch einigen anderen „Dingen“, auf die wir im weiteren Verlauf nicht näher eingehen (können).
• Zeile 6: Diese Namensraum-Direktive (8.2) bewirkt, dass die über die #include-Direktiven eingebundenen Elemente der C++-Standard-Bibliothek ohne weitere Qualifizierung (4.4.5.2) direkt zur Verfügung stehen. Ansonsten müssten Sie in Ihrem Programm vor jeder Verwendung eines Elements das Präfix std:: schreiben (also beispielsweise std::cout statt nur cout), was bei häufiger Benutzung sicherlich mühsam ist. Somit handelt es sich bei dieser Direktive genau genommen nur um eine Abkürzung. Sie werden noch sehen, dass C++ viele weitere Abkürzungen für „Schreibfaule“ in petto hat.
Sie sehen außerdem, dass diese Direktive mit einem Semikolon (;) beendet wird. Das Semikolon wird in C++ sehr oft gebraucht, um etwas abzuschließen, etwa hier das Ende der Direktive. Dies wird benötigt, weil C++ keine Zeilen-orientierte Programmiersprache ist. Dies meint, dass Sie alle Sprachmittel von C++ beliebig auf die Zeilen „verteilen“ können (Hauptsache, sie stehen „logisch hintereinander“). Sie könnten also im obigen Beispiel die drei Wörter using, namespace und std untereinander schreiben. Dies unterscheidet C++ von Zeilen-orientierten Sprachen wie z. B. BASIC. Damit der C++-Übersetzer aber dann erkennen kann, wo die Direktive zu Ende ist – schließlich kann er sich nicht am Ende der Zeile orientieren – benötigt er das Semikolon.
• Zeile 8: Hier wird eine Funktion mit dem Namen liesName definiert, die eine Zeichenkette zurückliefert. Funktionen sind in C++ – wie auch in anderen Programmiersprachen – im Prinzip lediglich benannte Anweisungsblöcke, die einen Wert zurückgeben. Wie Sie sehen, gibt es für die Definition einer Funktion in C++ kein explizites Schlüsselwort, anders als beispielsweise in (Visual) BASIC oder Pascal. Diese Eigenart von C++, Schlüsselwörter möglichst sparsam zu verwenden, wird Ihnen im Verlauf des Skripts noch an einigen anderen Stellen begegnen.
• Zeile 9: Hier fängt der Anweisungsblock an, der zur Funktion liesName gehört.
7) die aus Platzgründen nicht weiter in diesem Skript behandelt werden
12
Präprozessor und Kommentare
Namensräume
Definieren einer Funktion
Semikolon
Objektorientiertes C++ für Einsteiger Die ersten Schritte
• Zeile 10: Über diese Anweisung wird die Zeichenkette "Bitte gib deinen Namen ein: " auf der Konsole ausgegeben. cout ist hierbei ein (vordefiniertes) Objekt, das einen Ausgabe-Strom repräsentiert. Es gibt noch andere vordefinierte Ströme, und Sie werden in Kapitel 8 lernen, wie Sie selbst Objekte zur Benutzung von Ein- und Ausgabe-Strömen erzeugen und benutzen. Die Anweisung ist im Grunde genommen ein Ausdruck (3.4) mit zwei Operanden (dem Objekt cout und der Zeichenkette), die über den Operator << verknüpft sind.
• Zeile 11: Hier wird einfach eine Variable definiert, die eine Zeichenkette aufnehmen kann. Der Typ string, der dafür verwendet wird, wird von der C++-Standard-Bibliothek (8.3) bereitgestellt. Die Variable wird in der nächsten Zeile gebraucht und nimmt den einzulesenden Namen auf.
• Zeile 12: Die Anweisung ähnelt der in Zeile 10, bloß dass hier ein Eingabe-Strom (cin) statt eines Ausgabe-Stroms involviert ist und dass der Operator ein anderer ist (nämlich >>). Die Zeile bewirkt, dass eine Zeichenkette von der Konsole eingelesen und in die Variable name gespeichert wird.
• Zeile 13: Der eingelesene Name wird über die return-Anweisung an den Aufrufer der Funktion zurückgegeben. Eine Funktion gibt immer einen Wert zurück (es sei denn, eine Ausnahme wird ausgeworfen, siehe hierzu Kapitel 5). Die return-Anweisung gibt C++-Funktionen die Möglichkeit, eben dieses zu tun. Daraus folgt, dass (außer bei C++-Ausnahmen, siehe oben) immer mindestens eine return-Anweisung innerhalb einer Funktion existieren muss.
• Zeile 14: Hier wird der Anweisungsblock der Funktion liesName beendet. Pendant zu Zeile 9.
• Zeile 16: Hier wird eine Prozedur begruesse definiert. Prozeduren sind wie Funktionen benannte Anweisungsblöcke, nur dass sie keinen Wert zurückgeben, der vom Aufrufer weiterverarbeitet werden kann. Dies wird durch das Schlüsselwort void (3.4.2.5) angezeigt. Um dennoch einen Zweck zu erfüllen, produzieren Prozeduren Seiteneffekte. Ein Seiteneffekt ist eine Veränderung des Programm-Zustands dergestalt, dass er die Ausführung des Programms oder dessen sichtbare Ergebnisse nachhaltig beeinflusst. Im oben angegebenen Programm bewirkt ein Aufruf der Prozedur begruesse, dass ein Text auf der Konsole ausgegeben wird. Der weggefallene „Zwang“ zur Rückgabe eines Wertes drückt sich auch darin aus, dass eine C++-Prozedur (wie im obigen Beispiel) in der Regel keine return-Anweisung enthält.8
Ebenfalls anders als bei der Funktion liesName ist die Verwendung von Parametern. Wenn eine Prozedur oder Funktion Parameter in der Definition angibt, müssen diese Parameter beim Aufruf der Prozedur oder Funktion mit Werten belegt werden, die dann in der Prozedur oder Funktion verwendet werden können. Im obigen Beispiel wird der Prozedur eine Zeichenkette übergeben, die innerhalb der Prozedur über den Namen name angesprochen werden kann. Die Parameter in der Definition der Prozedur oder Funktion nennt man Formalparameter oder
8) Und wenn sie doch eine enthält, hat sie keinen Ausdruck, sieht also so aus: return;
13
Ausgabe-Ströme
Definieren einer Variable
Eingabe-Ströme
Rückgabewert einer Funktion
Definieren einer Prozedur; Seiteneffekte
Parameter und Argumente
Die ersten Schritte Objektorientiertes C++ für Einsteiger
einfach Parameter, die Werte beim Aufruf der Prozedur oder Funktion Aktualparameter oder Argumente. Formalparameter sind eigentlich nur Variablen, die beim Aufruf der Prozedur oder Funktion mit vom Aufrufer festgelegten Ausdrücken belegt werden.
Übrigens werden in C++ Prozeduren und Funktionen nicht so unterschieden, wie wir es hier im Skript der Klarheit willen halten. In C++ werden Prozeduren für gewöhnlich einfach als void-Funktionen bezeichnet. Um die ewig wiederkehrende und holprige Phrase „Prozedur oder Funktion“ zu vermeiden, werden wir ab jetzt nur von „Funktion“ sprechen und Prozeduren dabei implizit einschließen. Wenn etwas wirklich nur für Funktionen gilt, werden Sie gesondert darauf hingewiesen.
• Zeile 17: siehe Zeile 9
• Zeile 18: Hier werden – ähnlich wie in Zeile 10 – Daten in den cout-Ausgabe-Strom geschrieben. Allerdings werden hier mehrere Zeichenketten hintereinander weggeschrieben, wovon einige unveränderliche Konstanten oder Literale sind (z. B. "Hallo ") und eine einen Parameter darstellt (name). Das Objekt endl, das am Ende noch ausgegeben wird, entspricht einem Zeilenumbruch (end of line) und stellt sicher, dass zukünftige Ausgaben am Anfang der folgenden Zeile platziert werden.
• Zeile 19: siehe Zeile 14
• Zeile 21: Jedes C++-Programm hat eine Funktion main, die bei der Ausführung des Programms vom Betriebssystem9 aufgerufen wird. Sie sehen, aller C++-Code befindet sich in Funktionen, auch der für das Hauptprogramm. Die Funktion main wird immer so definiert, dass sie einen ganzzahligen (= Typ int, s. 3.4.2.2) Wert zurückgibt, der vom Betriebssystem ausgewertet werden kann. Typischerweise bedeutet der Wert 0 „alles in Ordnung“, während andere Werte für Fehler bei der Programm-Abarbeitung stehen. C++ legt die Bedeutung des von main zurückgegebenen Wertes allerdings nicht fest, so dass Sie hier völlig freie Hand haben (bzw. sich an die Richtlinien und Erwartungen Ihres Betriebssystems halten sollten!)
• Zeile 22: siehe Zeile 9
• Zeile 23: siehe Zeile 10 und Zeile 18
• Zeile 25: Hier passiert zweierlei. Zum einen wird eine Zeichenketten-Variable mit dem Namen meinName definiert und steht von diesem Zeitpunkt an zur Verfügung (zu Gültigkeitsbereichen und Sichtbarkeit siehe 3.3.4 und 3.3.5). Zum anderen wird diese Variable gleich mit einem Wert initialisiert (= vorbelegt), nämlich mit dem Ergebnis des Aufrufs der Funktion liesName. Sie sehen, dass die Funktionsdefinition und der Funktionsaufruf ziemlich ähnlich sind: Charakteristisch sind die beiden runden Klammern, die an beiden Stellen (Definition und Aufruf) dem Übersetzer mitteilen, dass eine Funktion definiert bzw. aufgerufen wird.
9) genauer: von der C++-Laufzeitbibliothek
14
Zeilenumbrüche bei der Ausgabe
main-Funktion
Initialisierung von Variablen und Aufruf von Funktionen
Objektorientiertes C++ für Einsteiger Die ersten Schritte
• Zeile 26: An dieser Stelle wird nun die Funktion begruesse aufgerufen und ihr als Argument die Zeichenkette übergeben, die vorher von der Funktion liesName zurückgeliefert wurde. Diese Zeichenkette ist innerhalb der Funktion begruesse über den Parameter name zu erreichen. Wie Sie sehen, werden etwaige Argumente innerhalb der Klammern angegeben. Wenn mehrere Argumente notwendig sind, werden diese durch Kommata getrennt. Näheres zum Aufruf von Funktionen finden Sie in Abschnitt 3.6.
Wenn Sie das Skript bis jetzt aufmerksam gelesen haben oder bereits Erfahrungen in C++ besitzen, dann werden Sie erkennen, dass Sie die beiden Zeilen 25 und 26 zusammenfassen und die Variable meinName „loswerden“ können. Sie müssen dazu nur das Ergebnis der Funktion liesName direkt an die Prozedur begruesse übergeben, etwa so:
25 begruesse (liesName ());
• Zeile 28: Wie bereits erwähnt erfordert die main-Funktion einen ganzzahligen Rückgabewert. Wir nehmen an, dass das Programm erfolgreich seinen Dienst (sprich: seine Ausgaben) getan hat und geben den Wert 0 zurück, um dem Aufrufer den Erfolg mitzuteilen.
• Zeile 29: siehe Zeile 14Mühsam haben Sie sich durch Ihr erstes Programm „gekämpft“, nun soll Ihre Mühe auch belohnt werden: Über die Tastenkombination F7 oder den Menüpunkt Build → Build hello.exe können Sie Ihr Programm übersetzen (Abbildung 9), anschließend mit STRG+F5 oder dem Menüpunkt Build → Execute hello.exe (Abbildung 10) ausführen. Sobald der Übersetzer fertig ist, können Sie seine Meldungen im Ausgabe-Fenster betrachten. Im Idealfall hat er keine Fehler gefunden und Ihr Programm anstandslos übersetzt (Abbildung 11). Abbildung 12 zeigt, wie die Ausgabe Ihres Programm nach einem ersten Testlauf aussehen kann. Übrigens wird die etwas seltsam anmutende Zeile Press any key to continue von VC++ generiert und nicht von unserem C++-Programm.
15
Übergabe von Argumenten
Übersetzung und Ausführung
Abbildung 9: Übersetzen eines C++-Programms
Abbildung 10: Ausführen eines C++-Programms
Die ersten Schritte Objektorientiertes C++ für Einsteiger
2.4 Wie geht es weiter?Sie haben beim Studieren des C++-Programms aus diesem Kapitel bereits einige Sprachkonstrukte von C++ kennen gelernt. Das folgende Kapitel beschäftigt sich näher mit den grundlegenden Sprachmitteln von C++, mit Ausnahme der objektorientierten Konstrukte, die in Kapitel 4 vorgestellt werden.
2.5 ÜbungenÜ1 (*3) Machen Sie sich mit Ihrer Entwicklungsumgebung vertraut! Finden Sie
heraus, wie Sie Quelltexte erstellen, laden, speichern und übersetzen können!
Ü2 (*1) Lernen Sie die „Sprache“ Ihres Übersetzers kennen: Provozieren Sie durch gezielte „Tippfehler“ einen Abbruch des Übersetzers und machen Sie sich mit seinen Meldungen und deren Aufbau vertraut! Prüfen Sie, ob Ihre Entwicklungsumgebung die Meldungen des Übersetzers automatisch auswertet und Ihnen z. B. durch Doppelklick o. ä. auf eine Fehlermeldung den fehlerhaften C++-Quelltext markiert!
Ü3 (*2,5) Wenn Sie bei Ihrem obigen Programm einen Namen mit Leerzeichen (etwa Vor- und Nachname, durch ein Leerzeichen getrennt) eingeben, gibt Ihnen das Programm lediglich das Wort bis zum ersten Leerzeichen als Begrüßung zurück. Dies liegt in der Arbeitsweise des verwendeten >>-Operators (Zeile 12) zum Einlesen der Daten begründet. Es gibt jedoch eine Funktion getline, die das Gewünschte leistet und alles inklusive Leerzeichen einliest. Schauen Sie in der Dokumentation Ihres Compilers nach und bauen Sie Ihr Programm so um, dass es getline statt dem >>-Operator zum Einlesen des Namens verwendet. Übersetzen und testen Sie dann das Programm, sobald es keine Syntax-Fehler mehr enthält!
16
Abbildung 11: Ausgaben des VC++-Übersetzers (wenn keine Fehler vorliegen)
Abbildung 12: Der erste Testlauf
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
3 Grundlegende KonzepteDieses Kapitel vermittelt Ihnen grundlegende C++-Konzepte, die zum Verständnis der nachfolgenden Kapitel wichtig sind.
3.1 Programm-AufbauIhr erstes C++-Programm haben Sie nun auf jeden Fall geschafft. Wir wollen uns jetzt den grundlegenden Aufbau von C++-Programmen anschauen.
3.1.1 ÜbersetzungeinheitenJedes C++-Programm besteht aus mindestens einer sogenannten Übersetzungseinheit. Eine Übersetzungseinheit ist – wie der Name suggeriert – ein „Stück Quelltext“, das der Übersetzer (oder Compiler) in einem Rutsch verarbeitet. Diese Übersetzungseinheit ist bei sehr vielen C++-Compilern – so auch bei VC++ – die Datei. Daraus folgt, dass ein C++-Programm also aus einer oder mehreren Dateien besteht, die getrennt übersetzt werden.
Natürlich müssen derart getrennt übersetzte Dateien irgendwie zu einem funktionierenden Ganzen „verbunden“ werden. Dies erledigt der Binder (oder Linker), den Vorgang nennt man binden oder linken. Diese Trennung von Übersetzer und Binder wird von C++ nicht vorgegeben, resultiert aber aus jahrelang üblicher Praxis: Der Binder ist häufig Teil des Betriebssystems und sprachunabhängig, während der Übersetzer vom Anwender zu installieren und sprachabhängig ist.
Kommen wir zurück zu C++: Wir haben geklärt, dass C++-Programme aus einer oder mehreren Übersetzungseinheiten besteht, wissen aber immer noch nicht, woraus eine Übersetzungseinheit besteht.
Im Grunde genommen gilt:
Merksatz 1: Jedes C++-Programm ist eine Folge von Deklarationen!
Jetzt werden Sie sich wahrscheinlich fragen:
(1) Was ist eine Deklaration?
(2) Waren das wirklich alles Deklarationen im vorigen C++-Beispiel?
Zum ersten Punkt: Eine Deklaration verbindet (mindestens) einen Namen und gewisse Eigenschaften zu einer Entität. Erstes Beispiel: In Zeile 11 des Beispielprogramms heißt es:
11 string name;
Hier wird der Name name mit der Eigenschaft „Variable vom Typ string“ verbunden. Man sagt: Die Variable name vom Typ string wird deklariert.
Zweites Beispiel: In Zeile 16 steht:16 void begruesse (string name)
Hier haben wir sogar zwei Deklarationen. Zum einen wird der Name begruesse mit der Eigenschaft „Prozedur mit einem Parameter name vom Typ string“ de
17
Übersetzungseinheiten
Binder
Was ist eine Deklaration?
C++-Programme bestehen aus Deklarationen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
klariert. Zum anderen ist die Einführung des Parameters name selbst eine Deklaration, denn name wird mit der Eigenschaft „Parameter vom Typ string“ verbunden.
Sie sehen also, dass Deklarationen (fast) überall anzutreffen sind.Falls Sie sich wundern, warum im Skript manchmal von Deklarationen und manchmal von Definitionen die Rede ist: Jede Definition ist auch eine Deklaration, aber nicht umgekehrt. Infolgedessen sind Definitionen eine „strengere“ Form von Deklarationen. Genaueres hierzu finden Sie in Abschnitt 3.3.1.
Zum zweiten Punkt: Nein, nicht alles waren Deklarationen. Zuerst müssen Sie wissen, dass die obige Aussage sich auf die „oberste“ Struktur von C++-Programmen bezieht. Damit ist gemeint, dass die Aussage nicht bedeutet, dass innerhalb von Deklarationen (beispielsweise innerhalb von Funktionen) wieder nur Deklarationen anzutreffen sind. Schauen wir uns das C++-Programm einmal nur auf „oberster“ Ebene an:
1 /*** Beispiel hello.cpp ***/2 #include <istream> // für die Eingabe3 #include <ostream> // für die Ausgabe4 #include <iostream> // für die Objekte cin und cout5 #include <string> // für Zeichenketten-Verarbeitung6 using namespace std;78 string liesName ()9 {
10 // ausgeblendet14 }1516 void begruesse (string name)17 {18 // ausgeblendet19 }2021 int main ()22 {23 // ausgeblendet29 }
(Das ist jetzt kein korrektes C++-Programm mehr, weil die Funktionen keine Werte zurückliefern. Für unsere Zwecke ist dies aber ausreichend; stellen Sie sich einfach vor, die ausgeblendeten Teile würden weiterhin existieren.)
Nun schauen Sie sich das Programm genauer an. In den Zeilen 8-29 stehen drei Funktionen. Da jede davon eine Deklaration darstellt, ist Regel 1 erst einmal erfüllt.
Die Namensraum-Direktive in Zeile 6 ist etwas komplizierter zu verstehen. Sie haben bereits gelernt, dass diese eine Art Abkürzung definiert, um beispielsweise cout statt std::cout schreiben zu können. Sie können sich denken, dass das Objekt cout im Namensbereich std auf irgendeine Weise deklariert ist. Nun stellen Sie sich einfach vor, dass die o. g. Namensraum-Direktive für jede Deklaration im Namensraum std eine Deklaration im „aktuellen“ Namensraum einfügt, damit sie auch ohne std-Präfix angesprochen werden kann. So gesehen stellt die Zeile 6 nicht nur eine Deklaration, sondern mehrere, ja sogar ziemlich viele Deklarationen dar. Auf jeden Fall passt Regel 1 immer noch auf das betrachtete Programm.
18
Namensraum-Direktiven sind Deklarationen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Die Zeilen 2 bis 5 hingegen sind definitiv keine Deklarationen, sondern Präprozessor-Direktiven. Trotzdem gilt Regel 1. Warum? Einfach aus dem Grund, weil die Regel 1 sich auf die C++-Sprachkonzepte bezieht, die der Übersetzer „sieht“ – und der Präprozessor entfernt immer alle Präprozessor-Direktiven, da der Übersetzer damit nichts anfangen kann.
Wozu braucht man dann Präprozessor-Direktiven, wenn sie für den Übersetzer sowieso unsichtbar sind? Nun, der Präprozessor löscht die Direktiven nicht nur, er ersetzt sie mit anderem (Programm-)Text. Man könnte den Präprozessor sozusagen als Text-Prozessor bezeichnen. Im vorliegenden Fall werden die #include-Direktiven durch den Inhalt der angegebenen Dateien ersetzt – und dieser ist für den Übersetzer natürlich wichtig.
Nachdem wir nun also die Gültigkeit der Regel 1 für unser Beispiel-Programm „bewiesen“ haben, wollen wir uns noch einer wichtigen Eigenart von C++ widmen.
3.1.2 Die main-Funktion
Wenn Sie Erfahrungen mit anderen Programmiersprachen gemacht haben, dann kennen Sie sicherlich das Konzept des Hauptprogramms. Das Hauptprogramm ist der Teil des Programms, bei dem die Ausführung des Programms beginnt. In vielen Sprachen wird das Hauptprogramm gar nicht besonders gekennzeichnet, sondern wird implizit von allen Anweisungen gebildet, die außerhalb von Funktionen, Prozeduren und anderen blockartigen Strukturen existieren.
In C++ ist dies aus zweierlei Gründen anders. Zum einen wird das Hauptprogramm deutlich gekennzeichnet: Alle Anweisungen, die in der Funktion mit dem Namen main vorhanden sind, bilden das Hauptprogramm. Daraus folgt unmittelbar, dass jedes C++-Programm irgendwo eine main-Funktion besitzen muss.10 Auch unser Beispiel-Programm hat eine main-Funktion.
Zum anderen wird auch hier wieder deutlich, dass trotz der besonderen Bedeutung der main-Funktion diese auch „nur“ eine einfache Funktion ist, mit Namen, Rückgabetyp, (leerer) Parameterliste und so weiter. Das ist typisch für C++: Immer wenn es möglich war, wurde neue Syntax für Sprachelemente vermieden, sondern altbewährtes „wiederverwendet“. Es gibt also keine eigene Syntax für die Definition des Hauptprogramms, sondern die existierende Syntax für Funktionen wurde dafür aufgegriffen. (Das ist gar nicht so abwegig wie es scheint. Immerhin kann das gesamte Programm aus der Sicht des Aufrufers auch als Funktion betrachtet werden.)
Obwohl die main-Funktion wie eine normale Funktion aussieht, ist sie es nicht ganz. Es gibt mehrere Dinge, welche die main-Funktion von anderen Funktionen unterscheidet:
• Sie dürfen sie nicht rekursiv aufrufen (3.6.5).
• Sie dürfen sie nicht static machen.
• Sie dürfen sie nicht inline machen.
• Sie dürfen ihre Adresse (3.4.3.3) nicht erfragen.
10) Wenn Ihre C++-Programme keine main-Funktion benötigen, arbeiten Sie unter Umständen mit einer sogenannten freestanding-C++-Implementierung mit „abgespecktem“ Funktionsumfang. Derartige Implementierungen werden hauptsächlich im Bereich eingebetteter Systeme benutzt und haben andere Mechanismen, um den Beginn des Programms festzulegen.
19
Präprozessor-Direktiven gehören nicht zum Kern von C++
das Hauptprogramm in C++
main-Funktion bildet den Anfang
main ist einfach eine Funktion
das Besondere an main
Wozu ein Präprozessor?
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
3.2 Lexikalische ElementeNachdem Sie sich nun mit dem grundlegenden Aufbau von C++-Programmen vertraut gemacht haben und somit die „größte“ Programmeinheit, die Übersetzungseinheit, untersucht haben, erfahren Sie in diesem Abschnitt, aus welchen „kleinsten“ Einheiten (Token genannt) ein C++-Programm besteht.
3.2.1 KommentareKommentare dienen der Programm-Dokumentation. Es gibt zweierlei Arten: die einen enden am Ende der Zeile, in welcher der Kommentar steht; die anderen können sich über mehrere Zeilen erstrecken. Die einzeiligen Kommentare beginnen mit //, die mehrzeiligen beginnen mit /* und enden mit */. Beispiel:
1 /*** Beispiel kommentar.cpp ***/2 // Dieser Kommentar endet am Ende der Zeile3 /* Dieser Kommentar beginnt in dieser Zeile...4 ...und endet in dieser Zeile */5 int main () // Das geht auch gemischt mit anderen Elementen: in einer Zeile...6 { /* ...und auch7 mehrzeilig! */ return 0;8 }
Gute Kommentare zu schreiben ist eine Kunst für sich. Generell sollten Sie die Aufgabe, Voraussetzungen und Garantien von Funktionen (3.6), Klassen und Methoden (4.4.5.1) gut dokumentieren. Dies ist wichtig, damit Sie oder andere Nutzer dieser Funktionen wissen, welche Eingaben die Funktion erwartet und welche Ausgabe sie produziert. Auch an Stellen, wo der verwendete Algorithmus nicht offensichtlich oder schwer zu verstehen ist (z. B. bei Rekursion, s. Abschnitt 3.6.5), sollten Kommentare stehen. Schlechte Kommentare sind zum Beispiel:
1) redundante Kommentare, die dasselbe ausdrücken wie der kommentierte Quelltext
2) falsche Kommentare, die das Gegenteil dessen behaupten, was der Quelltext sagt
3) zu kurze oder fehlende Kommentare, die wichtige Informationen unterschlagen
Beispiel:
1 /*** Beispiel fakultaet1.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 /*7 * Diese Funktion berechnet die Fakultät.8 * Eingabe:9 * „argument“ – das Argument der Funktion
10 * Ausgabe:11 * das Ergebnis der Fakultät von „argument“12 * Bemerkung:13 * Die Fakultät fakultaet ist rekursiv definiert als:14 * fakultaet(n) = 1 [n = 0]15 * fakultaet(n) = n * fakultaet (n – 1) [n > 0]16 */17 int fakultaet (int argument)18 {19 // pruefe ob argument Null ist20 if (argument == 0)21 // liefere Null zurück
20
einzeilige und mehrzeilige Kommentare
die „Atome“ von C++
gute und schlechte Kommentare
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
22 return 1;23 else24 return argument * fakultaet (argument - 1);25 }2627 int main ()28 {29 cout << "5! = " << fakultaet (5) << endl;30 return 0;31 }
Lassen Sie sich nicht abschrecken, wenn Sie nicht alles in diesem Programm-Auszug verstehen. Schauen wir uns in dieser Funktion die Kommentare an:
• Zeile 6-16: Dies ist ein guter Kommentar, weil er alle wichtigen Informationen zum Verständnis dieser Funktion enthält.
• Zeile 19: Dies ist ein schlechter Kommentar, weil er genau das ausdrückt, was der kommentierte Quelltext ohnehin schon tut (Punkt 1).
• Zeile 21: Dies ist ein schlechter Kommentar, da er schlichtweg falsch ist (Punkt 2).
• Zeile 24: Hier fehlt eine Bemerkung zur Rekursion (3.6.5); weil Rekursion für die meisten Menschen nicht einfach zu durchschauen ist, sollte hier ein Kommentar nicht fehlen. (Punkt 3).
Merksatz 2: Kommentiere so gut du kannst!
3.2.2 BezeichnerBezeichner sind Namen von Entitäten in C++. Jede Variable (3.3.2), jede Funktion (3.6), jede Klasse (4.4.5.1) und viele andere Entitäten benötigen Namen. Diese Namen unterstehen bestimmten Regeln:
(1) Jeder Bezeichner muss mit einem Buchstaben oder einem Unterstrich (_) beginnen.
(2) Abgesehen vom ersten Zeichen (s. o.) kann ein Bezeichner zusätzlich aus Ziffern bestehen.
(3) Gewisse reservierte Wörter (sogenannte Schlüsselwörter, s. Abschnitt 3.2.3) wie if, return oder int dürfen nicht als Bezeichner verwendet werden.
C++ versteht unter Buchstaben nur die 26 lateinischen Buchstaben (sowohl Groß- als auch Kleinbuchstaben) und keine Umlaute oder andere „Sonderzeichen“ wie Buchstaben mit Akzenten o. ä.11 Seien Sie also bei der Vergabe von Namen eher konservativ.
Beispiel: Die Bezeichner name, begruesse, main, test_1, Fakultaet und ___ sind erlaubt12, wohingegen 4gewinnt (beginnt mit Ziffer, Regel 1), Fakultät (enthält Umlaut, Regel 2) und new (ist ein Schlüsselwort, Regel 3) nicht erlaubt sind.13
11) Das ist streng genommen falsch: Seit der ISO 14882-C++-Standardisierung ist es erlaubt, viel mehr Zeichen in Bezeichnern zu verwenden. Leider gibt es nur wenige C++-Übersetzer, die dies umsetzen. Insbesondere VC++ implementiert diese Funktionalität nicht.
12) obwohl der letzte natürlich aus Gründen der Verständlichkeit völlig ungeeignet ist13) Das Schlüsselwort new lernen Sie in Abschnitt 4.5.5.1 kennen.
21
Bezeichner benennen Entitäten
Regeln für gültige Namen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
Die Groß- und Kleinschreibung ist bei allen Bezeichnern in C++ relevant. Die Bezeichner zaehler, Zaehler und ZaEhLeR sind alle unterschiedlich und können in der Tat verschiedene Entitäten benennen. Es ist jedoch davon abzuraten, sehr ähnliche Bezeichner für unterschiedliche Entitäten zu verwenden, insbesondere Entitäten gleicher Art, die im selben Gültigkeitsbereich (3.3.4) existieren (also z. B. für zwei lokale Variablen (3.3.2) in derselben Funktion.)
Ebenso wie gute Kommentare dienen auch gute Bezeichner der besseren Verständlichkeit des Quelltextes. Sie sollten sich angewöhnen, für Ihre Programme Richtlinien festzulegen, die Sie dann befolgen. Dies ist insbesondere dann unerlässlich, wenn mehrere Programmierer an demselben Programm arbeiten.
Eine mögliche Sammlung von Richtlinien könnte beispielsweise sein:
• Namen von Funktionen (3.6), Operationen (4.6.1) und Methoden (4.4.5.1) werden klein geschrieben.
• Namen von Typen (3.4) (insbesondere von Klassen (4.4.5.1)) werden mit einem Großbuchstaben begonnen.
• Namen von lokalen Variablen (3.3.2) und Parametern (3.6.4) werden klein geschrieben.
• Namen, die aus mehreren Wörtern bestehen, werden durch gemischte Groß- und Kleinschreibung strukturiert (etwa getWeekOfYear). Eine mögliche Alternative ist die Verwendung von Unterstrichen (etwa get_week_of_year).
Wenn Sie bei einem Unternehmen als Programmierer angestellt sind, werden Sie wahrscheinlich bereits Firmen-eigene Konventionen kennen, die Sie natürlich dann auch befolgen sollten.
Vielleicht ist Ihnen aufgefallen, dass in diesem Skript die Wörter Bezeichner und Name synonym verwendet werden. Korrekterweise muss erwähnt werden, dass zwischen Ihnen ein subtiler Unterschied besteht. Von Namen sprechen wir dann, wenn klar ist, welche Art von Entität benannt wird, z. B. ist der Bezeichner bei einer Funktionsdefinition der Name der Funktion. Von Bezeichnern sprechen wir dann, wenn der Kontext allgemeiner ist. Beispielsweise werden in Abschnitt 4.4.5.2 qualifizierte Bezeichner vorgestellt. Ob diese Bezeichner nun Klassen, Funktionen, Variablen etc. benennen, ist nicht bekannt und auch völlig irrelevant. In diesem Fall sprechen wir aber nicht von Namen, weil die benannte Entität unbekannt oder irrelevant ist.
Summa summarum sind also Namen und Bezeichner äquivalent, nur werden beide in unterschiedlichen Kontexten gebraucht, je nachdem welche Informationen zur Verfügung stehen.
3.2.3 SchlüsselwörterIn C++ sind einige Wörter als Schlüsselwörter reserviert, so dass sie nicht als Bezeichner für Variablen, Funktionen, Klassen o. ä. verwendet werden können. Die meisten Schlüsselwörter werden in den übrigen Kapiteln erläutert, hier soll eine vollständige Auflistung aller C++-Schlüsselwörter genügen (Tabelle 1), damit Sie diese nicht aus Unkenntnis als Namen verwenden. Schlüsselwörter werden in diesem Skript durchgehend fett gedruckt.
22
gute und schlechte Bezeichner; Konventionen
Unterschied zwischen Namen und Bezeichnern
Schlüsselwörter in C++
Groß- und Kleinschreibung von Bezeichnern
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
3.2.4 Operatoren und InterpunktionszeichenC++ kennt eine Vielzahl von Operatoren. Außerdem werden an vielen Stellen Interpunktionszeichen benötigt, z. B. Kommata zum Trennen von Parametern. Tabelle 2 gibt Ihnen einen Überblick über alle in C++ verwendeten Operatoren und Interpunktionszeichen.
Die Token in der letzten Zeile von Tabelle 2 werden von VC++ in der Version 6.0 und auch von einigen anderen C++-Übersetzern nicht erkannt. Deshalb ist anzuraten, sie nicht zu verwenden, zumal es sich nur um Alternativen für andere, besser unterstützte Operator- und Interpunktionszeichen handelt.
Beachten Sie, dass zwischen Operatoren und Interpunktionszeichen nicht streng unterschieden werden kann, da einige Zeichen je nach Kontext mal Operator und mal Interpunktionszeichen sind (etwa das Komma). Deshalb bilden sie eine gemeinsame Token-Klasse und nicht zwei.
3.2.5 ZahlenNatürlich kann in C++ auch gerechnet werden, und dabei muss man häufig mit Zahlen-Konstanten hantieren. Ganzzahlen (d. h. Zahlen ohne Nachkommastellen) werden in C++ ganz „normal“ in der Dezimalschreibweise dargestellt, wobei – und + als Vorzeichen erlaubt sind. In C++ haben Zahlen gewöhnlich den Typ int (3.4.2.2).
Beispiel: 0, 42, +1789, -1234567
23
Ganzzahlen in C++
Operatoren und Interpunktionszeichen in C++
asm auto bool breakcase catch char classconst const_cast continue defaultdelete do double dynamic_castelse enum explicit exportextern false float forfriend goto if inlineint long mutable namespacenew operator private protectedpublic register reinterpret_cast returnshort signed sizeof staticstatic_cast struct switch templatethis throw true trytypedef typeid typename unionunsigned using virtual voidvolatile wchar_t while
Tabelle 1: Schlüsselwörter in C++
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
Die Vorzeichen + und – sind übrigens nicht Teil der Zahlen, sondern Operatoren, die Sie in Abschnitt 3.4.2.2 kennen lernen werden. Dies hat insofern Auswirkungen auf die Zahlen, als dass Sie nicht unmittelbar vor der jeweiligen Zahl stehen müssen, sondern durchaus durch Freiraum getrennt sein können. - 4 ist also genauso korrekt wie -4.
Nicht alle Zahlen müssen den Typ int haben. Wenn eine Zahl im Quelltext zu groß für den Typ int aber klein genug für den Typ long ist, ist die Zahl vom Typ long. Wenn er selbst für long zu groß ist, bekommt die Zahl den Typ unsigned long zugeordnet. Sollte die Zahl jedoch sogar für den Typ unsigned long zu groß sein, liegt ein Fehler vor, und der Übersetzer bricht die Verarbeitung des C++-Programms ab.
Sie können auch den Typ von Zahlen-Konstanten explizit angeben, indem Sie die Konstante mit einem Suffix versehen: 2u (Typ unsigned int), -30000L (Typ long), 12345uL (Typ unsigned long). Bei dem Suffix ist Groß- und Kleinschreibung
24
Vorzeichen
Datentyp von Zahlen-Konstanten
Operator Bedeutung{ } Blockanfang und -ende[ ] Indizierung von Feldern# ## Präprozessor-Operatoren; Abschluss von Anweisungen und Deklarationen:: Operator zum Auflösen von Gültigkeitsbereichen( ) Klammern zum Regeln der Priorität;
Operator zum Aufrufen von Funktionen+ - * / % arithmetische Operatoren! && || logische Operatoren== != < > <= >= relationale Operatoren^ & | ~ bitweise Operatoren<< >> Bitschiebeoperatoren. -> .* ->* Operatoren zum Zugriff auf Klassen-Elemente++ -- Operatoren zum Inkrementieren und Dekrementieren? Operator zur Fallunterscheidung= += -= *= /= %= ^= &= |= <<= >>=
Zuweisungsoperatoren
, : ... Komma-Operator, Interpunktionszeichen<: :> <% %>%: %:%:not and or xorcomplbitand bitornot_eq and_eqor_eq xor_eq
alternative Token
Tabelle 2: Operatoren und Interpunktionszeichen in C++
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
möglich, allerdings sollten Sie das kleine L (‘l’) wegen der Ähnlichkeit zur Eins (‘1’) nach Möglichkeit nicht verwenden.
Zahlen mit Nachkommastellen sind in C++ auch möglich, jedoch nur als Fließkommazahlen (s. u.) Der Dezimalpunkt (.) wird verwendet, um die Vorkomma- und die Nachkommastellen voneinander zu trennen (obwohl sie Fließkommazahlen heißen!) Sie können auch die Exponential-Schreibweise verwenden, die im wissenschaftlichen Umfeld häufig verwendet wird. Sie müssen nur aufpassen, dass entweder der Dezimalpunkt oder der Exponent vorhanden ist, damit die Zahl als Fließkommazahl und nicht als Ganzzahl aufgefasst wird. Schließlich sind Vorzeichen genauso wie bei den ganzen Zahlen erlaubt. Fließkommazahlen haben in C++ normalerweise den Typ double (3.4.2.3).
Beispiel: 1.7, -2.0, +77E4 (entspricht 77×104), 0.00125, 1.25E-3 (entspricht 1,25×10-3 und ist folglich mit der vorletzten Zahl identisch)
Fließkommazahlen heißen so, weil die Anzahl der Stellen nach dem Komma davon abhängt, wie viele Stellen vor dem Komma bereits „verbraucht“ sind. Nehmen Sie einmal an, dass für Fließkommazahlen 18 Stellen zur Speicherung von Ziffern zur Verfügung stellen. Wenn Sie vor dem Komma nur eine Ziffer haben, stehen Ihnen nach dem Komma bis zu 17 Stellen zur Verfügung. Besitzt die Zahl jedoch einen großen Vorkomma-Anteil mit 17 Ziffern, steht Ihnen dann nur noch eine Stelle hinter dem Komma zur Verfügung. Das Komma „fließt“ also innerhalb der Zahl hin und her und hat keinen festen Platz. Dies ist bei den sogenannten Festkommazahlen anders, diese werden jedoch von C++ nicht direkt unterstützt.
Auch bei Fließkommazahlen gibt es Suffixe, die den Konstanten angehängt werden können, um den standardmäßig verwendeten Typ double zu ändern. Wird ein f angehängt (z. B. 0.1f), ist die Fließkommazahl vom Typ float, bei einem L (z. B. 1.123456789L) ist sie vom Typ long double.
3.2.6 Zeichen und ZeichenkettenNoch häufiger als Zahlen spielen bei der Programmierung Zeichen und Zeichenketten eine Rolle. In C++ werden Zeichen-Konstanten in einfachen Anführungsstrichen ('), Zeichenketten-Konstanten in doppelten (") eingeschlossen.
Beispiele für Zeichen-Konstanten: 'x', 'A', ' 'Beispiele für Zeichenketten-Konstanten: "Hallo", "C++ ist toll", ""Wie Sie sehen, gibt es in C++ sehr wohl die leere Zeichenkette, aber nicht das „leere“ Zeichen.14
Zeichen-Konstanten sind generell vom Typ char. Bei Zeichenketten ist das etwas schwieriger, denn Zeichenketten sind nicht automatisch vom Typ string. Dies liegt daran, dass string kein in C++ eingebauter Datentyp ist, sondern von der C++-Standard-Bibliothek bereitgestellt wird. Wie Sie mit Zeichenketten am besten umgehen, erfahren Sie in Abschnitt 3.4.2.1.
Welche Zeichen in Zeichen- und Zeichenketten-Konstanten vorkommen dürfen, erfahren Sie im Detail in Abschnitt 3.4.2.1. Hier soll erstmal der Hinweis genügen,
14) Dies macht Sinn, wenn Sie Zeichenketten als (endliche) Folgen aus dem Alphabet verfügbarer Zeichen ansehen, entsprechend dem Kleene-Stern-Operator (*) in regulären Ausdrücken.
25
Fließkommazahlen in C++
Begrenzungen von Zeichen und Zeichenketten
Fließkommazahlen und Festkommazahlen
Typ von Zeichen und Zeichenketten
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
dass alle Zeichen der C++-Syntax (also Buchstaben, Ziffern, Operatoren etc.) auch in Zeichen-und Zeichenketten-Konstanten vorkommen dürfen.
Wenn Sie Zeichen-Konstanten ein großes L voranstellen (z. B. L'x'), sind diese vom Typ wchar_t und können Zeichen aus einem wesentlich größeren Zeichenbereich enthalten. Ähnliches gilt für Zeichenketten-Konstanten. Näheres hierzu können Sie im Abschnitt 3.4.2.1 nachlesen.
In jeder Programmiersprache gibt es das Problem, dass die Begrenzungszeichen für Zeichen und Zeichenketten (also einfache und doppelte Anführungsstriche in C++) innerhalb jener nicht vorkommen dürfen. Um diese jedoch trotzdem verwenden zu können, gibt es in C++ eine besondere Schreibweise: Um einfache Anführungsstriche in Zeichen-Konstanten und doppelte Anführungsstriche in Zeichenketten-Konstanten benutzen zu können, muss ihnen ein Backslash oder umgekehrter Schrägstrich (\) als Fluchtsymbol vorangestellt werden. Dadurch werden diese Zeichen maskiert, so dass sie vom Übersetzer nicht als Zeichen- oder Zeichenketten-Begrenzungen verstanden werden.
Beispiel: '\'' , "eine \"echte\" Verbesserung"Neben der Maskierung von Begrenzungszeichen hat der Backslash auch noch eine andere Funktion: die Notation von Steuerzeichen. Beispielsweise sind Zeilenumbrüche innerhalb von Zeichen- und Zeichenketten-Konstanten nicht erlaubt. Um dennoch ein solches Zeichen zu verwenden, verwenden Sie das Steuerzeichen \n. (Das „n“ steht für „new line“ und bezeichnet den Beginn einer neuen Zeile.)
Beispiel: "Erste Zeile.\nZweite Zeile."Steuerzeichen werden so genannt, weil sie keine Zeichen-Repräsentation besitzen und somit nicht dargestellt werden können. Vielmehr haben sie Auswirkungen auf die nachfolgende Ausgabe und „steuern“ sie.
Weiterhin macht es die Sonderfunktion des Backslash notwendig, ihn selbst zu maskieren, falls er als Zeichen vorkommen soll. Das heißt im Klartext, dass jeder umgekehrte Schrägstrich in der gewünschten Zeichenkette durch zwei umgekehrte Schrägstriche im Quelltext dargestellt werden muss.
Beispiel: "Vier umgekehrte Schrägstriche: \\\\\\\\"Der Backslash wird als Fluchtsymbol bezeichnet, da er der normalen Zeichen-Verarbeitung „entflieht“ und eine besondere Verarbeitung des nachfolgenden Zeichens aktiviert. Drei der möglichen Verwendungen haben Sie bereits kennen gelernt: die Maskierung von Begrenzungszeichen, die Maskierung des Fluchtsymbols selbst und die Einführung von Steuerzeichen. Neben weiteren Steuerzeichen existieren noch weitere Modi, die jedoch eher esoterisch sind und in der täglichen Programmierung nicht benötigt werden.
Zum Schluss sollten Sie wissen, dass Zeichenketten-Konstanten, die im Quelltext nebeneinander geschrieben werden, automatisch aneinander gehängt werden. Dies ist vor allem dann sinnvoll, wenn besonders lange Zeichenketten im Quelltext dargestellt werden.
Beispiel:1 /*** Beispiel zeichenketten1.cpp ***/2 #include <ostream>
26
Maskierung von Begrenzungszeichen
Steuerzeichen
Backslash innerhalb von Konstanten
Zeichenketten werden aneinandergehängt
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
3 #include <iostream>4 using namespace std;56 int main ()7 {8 cout << "Hal"9 "lo" " Welt!" << endl;10 return 0;11 }
ist äquivalent zu:1 /*** Beispiel zeichenketten2.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int main ()7 {8 cout << "Hallo Welt!" << endl;9 return 0;10 }
3.2.7 TrennerEgal wie diese „kleinsten“ Einheiten aussehen, der Übersetzer muss sie voneinander unterscheiden können. In vielen Fällen gelingt es ihm nur, wenn zwischen ihnen etwas Freiraum (oder Whitespace) existiert. Zu diesem Freiraum gehören Leerzeichen, Tabulator-Zeichen und Zeilenumbrüche.
Nicht immer wird solcher Freiraum benötigt. Generell gilt, dass Freiraum immer dann benötigt wird, wenn ohne Freiraum etwas syntaktisch Gültiges herauskommen könnte.
Beispiel:1 intmain () // falsch: intmain ist ein gültiger Bezeichner, Freiraum zur Trennung notwendig2 {3 int ergebnis=42; // zwischen = und 42 ist kein Freiraum notwendig,4 // da "=42" als Ganzes in der C++-Syntax nicht existiert5 returnergebnis; // falsch, returnergebnis ist gültiger Bezeichner6 }
Da die Regeln insbesondere für Einsteiger nicht unbedingt offensichtlich sind, trennen Sie nach Möglichkeit zwei Token immer mit einem Leerzeichen o. ä. voneinander. Moderne Entwicklungsumgebungen können Sie jedoch in den fraglichen Fällen durch Hervorhebung von Schlüsselwörtern, Bezeichnern, Zahlen, Zeichenketten u. ä. durch spezielle Farben, Schriftarten und -effekte unterstützen.
So formatiert VC++ im obigen Beispiel int in intmain nicht farbig, so dass Sie unmittelbar erkennen können, dass int und main nicht als unterschiedliche Token vom Übersetzer erkannt werden.
3.3 Namen und EntitätenDieser Abschnitt behandelt so ziemlich alles, was Sie über Namen und benannte Entitäten in C++ wissen sollten. Unter Entitäten wird im Folgenden alles verstanden, was einen Namen hat. Sie kennen bereits Variablen (3.3.2), Funktionen (3.6) und Ty
27
Trennung von Token
besser zuviel als zuwenig trennen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
pen (3.4). Später werden Sie noch andere „Gebilde“ kennen lernen, die ebenfalls Entitäten darstellen, u. a. Aufzählungen (3.4.3.6), Namensräume (8.2) und Schablonen (7.2). Ihnen allen gemeinsam ist, dass sie über eine Deklaration oder Definition einen Namen zugeordnet bekommen, über den sie angesprochen werden können.
3.3.1 Deklarationen, Definitionen und Header-DateienJedes Objekt muss in C++ vor der Benutzung deklariert werden. Durch eine Deklaration werden der Name und bestimmte Eigenschaften der Entität wie sein Typ miteinander verbunden. Eine Definition geht noch einen Schritt weiter und erzeugt die betrachtete Entität auch gleich. Während also eine Deklaration dem Übersetzer nur mitteilt „es existiert eine Entität mit diesem Namen und diesem Typ“, sagt eine Definition „erzeuge eine Entität mit diesem Namen und diesem Typ“. Da das Erstellen die Existenz der Entität quasi beinhaltet, ist jede Definition auch eine Deklaration, während der umgekehrte Fall nicht gilt.
Deklarationen sind also als Verweise zu verstehen, während Definitionen die erzeugenden Konstrukte sind. Daraus folgt, dass es mehrere Deklarationen derselben Entität geben kann aber nur eine (und genau eine!) Definition der Entität existiert. Die letzte Erkenntnis wird in C++ die One Definition Rule (Eine-Definition-Regel) genannt:
Merksatz 3: Für jede verwendete Entität gibt es genau eine Definition!
Deklarationen sind innerhalb des Programms nur in einem bestimmten Bereich, dem sogenannten Gültigkeitsbereich, sichtbar. Auf den Gültigkeitsbereich geht der Abschnitt 3.3.4 genauer ein.
Eine Deklaration ist in C++ immer folgendermaßen aufgebaut:
Typ Deklarator [= Ausdruck];Deklarationen sind in C++ immer typisiert, ein Typ (3.4) muss immer angegeben werden. Danach folgt der Deklarator, der im einfachsten Fall (und nur der ist momentan interessant) lediglich aus einem Bezeichner besteht. Optional kann dann ein Ausdruck (3.4) als Initialwert folgen; dies macht aber nur dann Sinn, wenn eine Variable oder Konstante definiert wird. (Der Initialwert ist somit immer Teil von Definitionen, niemals von „reinen“ Deklarationen.) Mehr zum Initialisieren von Variablen (oder Konstanten) finden Sie in Abschnitt 3.3.3.
Funktionen werden nicht in diesem Sinne initialisiert, vielmehr werden zwischen zwei geschweifte Klammern die zugehörigen Anweisungen niedergeschrieben. Beispiele hierfür haben Sie bereits mehrfach im Skript gesehen. Auf Funktionen und ihre Besonderheiten geht Abschnitt 3.6 ein.
Zu diesem grundlegenden Aufbau von Deklarationen gibt es viele Ausnahmen. So kann der Deklarator beispielsweise gänzlich fehlen, wenn ein Typ (etwa eine Klasse) definiert wird. In seltenen Fällen wird der Typ weggelassen, wenn er durch den Kontext unmissverständlich ist, beispielsweise bei Konstruktoren (4.5.1). Der Initialwert macht hingegen nur bei Variablen oder Konstanten Sinn, bei Funktionen ist er in dieser Art und Weise fehl am Platz. Schließlich gibt es in C++ noch eine andere Form der Initiali
28
Deklarationen und Definitionen im Vergleich
die ODR (Eine-Definition-Regel)
Aufbau von Deklarationen und Definitionen
Gültigkeit von Deklarationen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
sierung, die aber erst verständlich wird, wenn Klassen und Konstruktoren behandelt werden (4.5).
Grau ist alle Theorie! Betrachten wir das folgende Beispiel:15
1 /*** Beispiel antwort1.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int antwortAufDasLebenDasUniversumUndAlles ()7 {8 return 42;9 }10 int main ()11 {12 int antwort = antwortAufDasLebenDasUniversumUndAlles ();13 cout << "Die Antwort ist " << antwort << endl;14 return 0;15 }
In diesem Beispiel liegen drei Deklarationen vor, die gleichzeitig Definitionen sind:
• Zeile 6: Hier wird die Funktion antwortAufDasLebenDasUniversumUndAlles definiert. Eine Definition liegt vor, weil nicht nur Name, (nicht vorhandene) Parameter und Rückgabetyp spezifiziert werden, sondern auch der „Inhalt“, sprich die enthaltenen Anweisungen (Zeile 7 bis 9).
• Zeile 10: dito, aber auf Funktion main bezogen
• Zeile 12: Hier wird die Variable antwort vom Typ int definiert. Variablen-Deklarationen sind fast immer Definitionen, insbesondere dann, wenn sie – wie auch in diesem Fall – initialisiert werden. Der Initialwert ist in dem Beispiel keine Konstante, sondern ein (zum Typ der Variable) passender Ausdruck (3.4).
Wie Sie sehen, waren in diesem Beispiel alle Deklarationen auch Definitionen. Vielleicht fragen Sie sich dann, wann denn „reine“ Deklarationen verwendet werden. Nun, Sie erinnern sich, dass jede Entität vor der Benutzung deklariert sein muss. Nicht immer wollen Sie jedoch die Entität dann auch definieren, weil sie bereits definiert ist, nur an einer anderen Stelle. Schreiben wir das obige Beispiel einmal so um, dass die Funktion antwortAufDasLebenDasUniversumUndAlles im Quelltext hinter der Funktion main steht:
1 /*** Beispiel antwort2.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int antwortAufDasLebenDasUniversumUndAlles ();7 int main ()8 {9 int antwort = antwortAufDasLebenDasUniversumUndAlles ();10 cout << "Die Antwort ist " << antwort << endl;11 return 0;12 }13 int antwortAufDasLebenDasUniversumUndAlles ()14 {
15) Douglas Adams lässt grüßen!
29
Vorwärtsreferenzen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
15 return 42;16 }
Jetzt sieht die Sache schon etwas anders aus. Um in Zeile 9 die Funktion antwortAufDasLebenDasUniversumUndAlles nutzen zu können, muss deren Existenz vorher dem Übersetzer mitgeteilt worden sein. Das geschieht in Zeile 6, in der die Funktion deklariert, aber nicht definiert wird. Die Definition finden Sie in den Zeilen 13-16.
Java-Programmierer sollten sich die Unterschiede zu Java klar machen: In C++ müssen Sie jeden Bezeichner vor seiner Verwendung in einer Übersetzungseinheit auf irgendeine Weise deklarieren.16 In Java ist dies bei globalen Entitäten (Klassen, Methoden und Attribute) nicht der Fall. Deshalb gibt es in Java auch keine reinen Deklarationen, denn sie sind nicht notwendig.
Natürlich kann man die erste Version des Beispiels verwenden und somit die Deklaration in diesem Fall einsparen, aber das geht nicht immer. Manchmal kommt man um Vorwärtsreferenzen nicht herum, insbesondere dann nicht, wenn sich zwei Funktionen gegenseitig rekursiv aufrufen (wechselseitige Rekursion, s. Abschnitt 3.6.5).
Solche Deklarationen finden Sie aber nicht nur dann vor, wenn Sie Vorwärtsbezüge in Ihrem Quelltext benötigen. Jedes Mal, wenn Sie Funktionen, Methoden, Variablen oder andere Entitäten auf mehrere Dateien aufteilen und somit in einer Datei definieren und in einer anderen Datei benutzen wollen, werden Sie gezwungen sein, Deklarationen zu verwenden. Dies geschieht in der Regel, indem die Deklarationen in einer sogenannten Header-Datei oder kurz im Header zusammengefasst werden, die in den benutzenden Dateien eingebunden wird. Die Header-Datei enthält dann die Schnittstelle der zu benutzenden Datei. Übrigens enden Header-Dateien oft mit der Erweiterung h oder hpp, um sie von anderen Quellen unterscheiden zu können.17 Dateien mit der Implementierung haben üblicherweise die Erweiterung cpp, cc oder cxx.
C++ besitzt keine Sprachkonstrukte, um Module oder Pakete, wie sie aus anderen Programmiersprachen wie Java, Pascal oder Oberon bekannt sind, abzubilden. Die Header-Datei(en) und die implementierende(n) Quellen-Datei(en) können jedoch als öffentlicher und privater Teil eines Moduls angesehen werden. Sie müssen jedoch selbst darauf achten, dass die Deklarationen in der Header-Datei und die Definitionen in der Quell-Datei übereinstimmen. Am einfachsten geht dies, indem Sie in den implementierenden Quellen die Header-Dateien ebenfalls einbinden. Dann wird der Übersetzer (häufig, aber nicht immer!) meckern, wenn die Deklarationen und Definitionen sich unterscheiden.
Um Ihnen an dieser Stelle einen Einblick in die Benutzung von Header-Dateien zu geben, wollen wir das obige Beispiel etwas umschreiben. Wir werden die Funktion antwortAufDasLebenDasUniversumUndAlles in eine eigene Datei hineintun. Da wir die Funktion aus dem Hauptprogramm heraus aufrufen müssen, brauchen
16) Es gibt nur sehr wenige Ausnahmen, etwa die Verwendung von Bezeichnern in inline-Methoden einer Klassendefinition.
17) Die Header-Dateien der C++-Standard-Bibliothek (beispielsweise iostream und string, die Sie bereits im ersten Beispiel kennen gelernt haben) bilden hier eine Ausnahme. Allerdings liegt dies weniger an der Überzeugung, dass eine Endung nicht notwendig ist, sondern eher daran, dass das C++-Standardisierungsgremium sich nicht auf eine einheitliche Endung einigen konnte...
30
Header-Dateien definieren die Schnittstelle
C++ und Module
Header in der C++-Praxis
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
wir eine Header-Datei, welche die Schnittstelle der Funktion entsprechend deklariert. Wir erhalten also die folgenden drei Dateien:
• antwort.h:1 /*** Beispiel antwort3/antwort.h ***/2 /***********************************3 * Schnittstelle des Moduls "Antwort"4 ***********************************/5 /*6 * Ermittelt die Antwort auf das Leben, das Universum und Alles.7 * Eingabe: --8 * Ausgabe: Die ANTWORT9 */10 int antwortAufDasLebenDasUniversumUndAlles ();
• antwort.cpp:1 /*** Beispiel antwort3/antwort.cpp ***/2 #include "antwort.h" // Schnittstelle des Moduls "Antwort" einbinden3 int antwortAufDasLebenDasUniversumUndAlles ()4 {5 // siehe Adams, Douglas: "Per Anhalter durch die Galaxis"6 return 42;7 }
• main.cpp:1 /*** Beispiel antwort3/main.cpp ***/2 #include "antwort.h" // Schnittstelle des Moduls "Antwort" einbinden3 #include <ostream>4 #include <iostream>5 using namespace std;67 int main ()8 {9 int antwort = antwortAufDasLebenDasUniversumUndAlles ();10 cout << "Die Antwort ist " << antwort << endl;11 return 0;12 }
Wie Sie sehen, wurde im Beispiel auch ordentlich kommentiert. Dies ist besonders beim Definieren von Schnittstellen wichtig, damit der Nutzer dieser Schnittstelle später weiß, welche Funktion, welche Klasse usw. welche Aufgabe hat. Behalten Sie immer Merksatz 2 über Kommentare in Abschnitt 3.2.1 im Gedächtnis! Und weil es gerade so schön ist, kommt gleich ein weiterer Merksatz hinzu:
Merksatz 4: Tue Schnittstellen und Implementierung in verschiedene Dateien!
Es gibt zwei Möglichkeiten, Header-Dateien über die #include-Direktive einzubinden. Der Dateiname der Header-Datei kann entweder innerhalb spitzer Klammern oder innerhalb von doppelten Anführungsstrichen stehen.
Der Unterschied liegt darin, wo der Präprozessor nach der einzufügenden Header-Datei sucht. Wenn der Name in Anführungsstrichen gesetzt ist, sucht der Präprozessor nach der einzufügenden Datei relativ zum Verzeichnis der Datei, welche die #include-Direktive enthält. Die Suche in diesem Verzeichnis fällt weg, wenn der Name in spitzen Klammern steht. Die erste Schreibweise wird deshalb häufig für Schnittstellen von (externen) Bibliotheken verwendet, die zweite für Header-Dateien, die direkt zum Pro
31
Spitze Klammern oder Anführungsstriche?
Aufteilung von Schnittstelle und Implementierung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
gramm gehören. Denn letztere liegen fast immer im selben Verzeichnis oder zumindest in Verzeichnissen, die nahe beieinander liegen.
Abbildung 13 zeigt, wie die Deklarationen und Definitionen sowie die Verwendung der jeweiligen Namen im letzten Beispiel in Verbindung stehen. Die Header-Datei ist dabei nicht abgebildet, weil sie durch den Präprozessor in die Übersetzungseinheiten eingefügt und deren Bestandteil wird.
3.3.2 VariablenVariablen sind veränderbare Speicherbereiche für Daten. In einer Variable halten Sie Informationen fest, die Sie während des Programmlaufs erhalten oder berechnen. Variablen können mit Ausnahme von Funktionstypen (3.4.3.1) und const-modifizierten Typen (3.4.4) jeden beliebigen Typ besitzen. Die Form einer Variablen-Definition haben Sie bereits im letzten Abschnitt kennen gelernt.
3.3.3 InitialisierungJede Variable, die Sie definieren, sollte explizit mit einem Ausdruck initialisiert werden. Dies ist in C++ besonders wichtig, da C++ andernfalls nicht garantiert, dass in solchen nicht initialisierten Variablen ein sinnvoller Standard-Wert steht.
Dies unterscheidet C++ von anderen Programmiersprachen wie Java, in denen nicht initialisierte Variablen auf Null oder einen ähnlichen Wert gesetzt werden. Verlassen Sie sich niemals auf den Inhalt von Variablen, die Sie nicht initialisiert haben oder denen Sie im Verlauf des Programms keinen Wert zugewiesen (3.4.1) haben! Im ungünstigsten Fall kann es bereits beim Lesen des Inhaltes einer nicht initialisierten Variable zu einem Programmfehler (sprich Programmabsturz) kommen!
32
Variablen erfordern einen Initialwert
Abbildung 13: Verteilte Deklarationen und Definitionen
Übersetzungseinheit antwort.cpp
Definition der Funktion antwortAufDasLebenDasUniversumUndAlles
Beziehung hergestellt durch ÜbersetzerBeziehung hergestellt durch Binder
Legende:
Übersetzungseinheit main.cpp
Deklaration der Funktion antwortAufDasLebenDasUniversumUndAlles
Definition der Funktion main
Verwendung der Funktion antwortAufDasLebenDasUniversumUndAlles
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Merksatz 5: Verwende niemals nicht initialisierte Variablen!
Es gibt zwei Möglichkeiten, den Initialwert anzugeben: die funktionale Schreibweise und diejenige mit einem Gleichheitszeichen. In den Fällen, in denen beide erlaubt sind, sind beide äquivalent. Beispielsweise haben
1 int antwort = 42;und
1 int antwort (42);dieselbe Bedeutung: Es wird eine Variable namens antwort definiert und mit dem Wert 42 belegt.
Die zweite Form der Initialisierung neigt jedoch manchmal zu Mehrdeutigkeiten mit anderen C++-Sprachkonstrukten und sollte lediglich für die Initialisierung von objektwertigen Variablen (4.5.1) verwendet werden. (Falls ein Objekt mehrere Argumente zur Initialisierung benötigt, ist letztere Syntax auch die einzige Möglichkeit, das Objekt angemessen zu initialisieren.)
Klammern gehören in C++ zu den am häufigsten verwendeten syntaktischen Elementen, deswegen sind Notationen zu bevorzugen, die weniger auf Klammern und mehr auf andere syntaktische Elemente (etwa das Gleichheitszeichen) setzen. Dies verbessert häufig auch die Verständlichkeit der Fehlermeldungen, wenn Syntax-Fehler im Quelltext erkannt werden.
Generell gilt, dass der Typ des initialisierenden Ausdrucks mit dem Typ der zu initialisierenden Entität übereinstimmen oder zumindest verträglich sein muss. Die Verträglichkeit von Typen wird in Abschnitt 3.4.5 diskutiert.
3.3.4 GültigkeitsbereicheWenn Sie etwas deklarieren (sei es eine Variable, eine Funktion oder ein Typ), so verbinden Sie nicht nur einen Namen mit gewissen Attributen wie dem Typ (3.4), sondern machen den Namen auch in einem bestimmten Bereich des Programms verfügbar. Dieser Bereich nennt sich Gültigkeitsbereich und stellt den maximalen Bereich dar, innerhalb dessen die deklarierte Entität verwendet werden kann. Allgemein gilt, dass der Gültigkeitsbereich eines deklarierten Namens von dessen Deklaration bis zum Ende des jeweiligen Blocks (3.5.6) reicht.
Abbildung 14 versucht, den Begriff des Gültigkeitsbereichs anhand des C++-Programms aus Kapitel 2 zu veranschaulichen.
• Zeile 8: Die hier deklarierte Funktion liesName ist laut Regel bis zum Ende des enthaltenden „Blocks“ gültig. Da die Funktion sich in keinem Block befindet, ist sie bis zum Ende der Übersetzungseinheit gültig.
• Zeile 11: Die Variable name wird hier innerhalb der Funktion liesName deklariert. Der Gültigkeitsbereich erstreckt sich bis zum Ende des enthaltenden Blocks, hier also bis zum Ende der Funktion. Außerhalb der Funktion ist die Variable nicht verfügbar.
33
zwei Formen der Initialisierung
Typen müssen verträglich sein
Namen sind nur begrenzt gültig
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
• Zeile 16: Hier stehen gleich zwei Deklarationen. Zum einen wird die Funktion begruesse deklariert, die ab diesem Punkt bis zum Ende der Übersetzungseinheit gültig ist. Zum anderen wird der Parameter name deklariert, der nur innerhalb der Funktion Gültigkeit hat.
• Zeile 21: Die an dieser Stelle deklarierte Funktion main ist bis zum Ende der Übersetzungseinheit gültig.
• Zeile 25: Hier wird eine Variable namens meinName deklariert. Sie steht innerhalb der Funktion main, somit ist sie maximal bis zum Ende dieser Funktion gültig.
Beachten Sie an dieser Stelle, dass die Variable name innerhalb der Funktion liesName und der Parameter name innerhalb der Funktion begruesse nichts, aber auch gar nichts miteinander zu tun haben und sozusagen nur zufällig gleich heißen (und vom selben Typ sind). Die Gültigkeitsbereiche beider Variablen sind disjunkt (d. h. sie überlappen sich nicht), somit sind sie auch immer zu unterschiedlichen Zeitpunkten gültig
34
Abbildung 14: Gültigkeitsbereiche
1234567891011121314151617181920212223242526
liesName
name
begruesse name
main
meinName
using namespace std;
string liesName (){ cout << "..."; string name; cin >> name; return name;}
void begruesse (string name)
#include <iostream>#include <string>
}
int main (){ cout << "..." << endl;
string meinName = liesName(); begruesse (meinName);
return 0;}
cout << "..." << endl;{
#include <ostream>#include <istream>/*** Beispiel hello.cpp ***/
272829
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
und verwendbar, und Werte in der einen Variablen beeinflussen nicht im Geringsten den Inhalt der anderen Variable (und umgekehrt).
An dieser Stelle ist ein kleiner Überblick über die verschiedenen Arten von Gültigkeitsbereichen in C++ angebracht:
(1) Lokaler oder Block-Gültigkeitsbereich:Namen, die innerhalb von Funktionen oder Methoden deklariert werden, sind lokal zu dieser Funktion und erstrecken sich bis zum Ende des sie enthaltenden Blocks. Beispiele umfassen u. a. die Variablen name und meinName der Funktionen liesName bzw. main sowie den Parameter name der Funktion begruesse.
(2) Globaler Gültigkeitsbereich oder Namensraum-Gültigkeitsbereich:Namen, die außerhalb aller Funktionen, Methoden und Typen deklariert sind, sind global oder Teil eines sogenannten benannten Namensraumes (8.2). Deren Gültigkeit erstreckt sich bis zum Ende der Übersetzungseinheit bzw. bis zum Ende des Namensraums. Beispielsweise gehören alle drei Funktionsdefinitionen aus dem obigen Beispiel zu diesem Gültigkeitsbereich.
(3) Funktionsprototyp-Gültigkeitsbereich:Parameter, die innerhalb von „reinen“ Funktionsdeklarationen eingeführt werden, sind nur innerhalb dieser Deklaration gültig. In unserem Beispiel kommt dieser Gültigkeitsbereich nicht vor; hätte aber die Deklaration der Funktion antwortAufDasLebenDasUniversumUndAlles im Abschnitt 3.3.1, „Vorwärtsreferenzen“ einen Parameter, wäre dieser nur in diesem Bereich gültig.
(4) Klassen-Gültigkeitsbereich:Namen, die innerhalb von Klassendefinitionen (4.4.5.2) deklariert werden, sind überall im Kontext dieser Klasse gültig; zu diesem Kontext gehören beispielsweise die Klassendefinition selbst sowie alle zugehörigen Methoden-Definitionen. Klassen lernen Sie in Kapitel 4 kennen.
3.3.5 SichtbarkeitWie anfangs erwähnt ist der Gültigkeitsbereich der maximale Bereich, innerhalb dessen ein Name verwendet werden kann. Es kann aber passieren, dass eine Deklaration von einer anderen mit demselben Namen verdeckt wird. In einem solchen Fall ist ein Name kurzzeitig unsichtbar (obwohl gültig), und zu einem späteren Zeitpunkt kann er wieder sichtbar und normal verwendet werden.
Betrachten wir dabei folgendes Beispiel:1 /*** Beispiel sichtbarkeit.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 string begruessung (string name)8 {
35
Arten von Gültigkeitsbereichen
sichtbar oder verdeckt?
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
9 string hallo = "Hallo ";10 string begruessung = hallo + name; // bzgl. "+" s. Abschnitt 3.4.2.111 return begruessung;12 }1314 int main ()15 {16 cout << begruessung ("Welt") << endl;17 return 0;18 }
Betrachten Sie einmal die Funktion begruessung, die in den Zeilen 7-12 definiert ist, insbesondere die Zeile 10. Hier wird eine (lokale) Variable namens begruessung definiert, deren Name derselbe ist wie der von der Funktion. Ab diesem Punkt kann die Funktion begruessung nicht mehr über ihren Namen angesprochen werden, weil der Name nun für eine lokale Variable vergeben ist. Man spricht davon, dass die Variable die Funktion verdeckt hat. Die Funktion ist kurzzeitig nicht sichtbar geworden (Abbildung 15, blau schraffierter Kasten).
Allerdings währt dieses Verdecken nicht ewig – am Ende des Gültigkeitsbereichs der Variable begruessung (in Zeile 12) kann die Funktion wieder über Ihren Namen erreicht werden, so wie in Zeile 16. Da der Gültigkeitsbereich der Variable begruessung nicht in die Funktion main hineinreicht, „weiß“ sie nichts davon, und so spielt auch das Verdecken hier keine Rolle. Die Funktion ist hier ganz normal sichtbar.
Das Verdecken von Namen taucht immer dann auf, wenn mehrere Gültigkeitsbereiche „aufeinander prallen“. Dies passiert insbesondere häufig bei der objektorientierten Programmierung, wo jede Klasse einen eigenen Gültigkeitsbereich bildet und
36
Verdecken in der Praxis
Verdecken ist begrenzt
Abbildung 15: Verdecken von Namen
12345678910111213141516
main
using namespace std;
string begruessung (string name){ string hallo = "Hallo "; string begruessung = hallo + name; return begruessung;}
#include <iostream>#include <string>
int main (){ cout << begruessung ("Welt") << endl; return 0;}
/*** Beispiel sichtbarkeit.cpp ***/
begruessung name
hallobegruessung
#include <ostream>
1718
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
über Vererbung verschiedene Gültigkeitsbereiche „aneinander gekoppelt“ werden. Mehr zu Klassen und Vererbung erfahren Sie in Abschnitt 4.1.6.
3.4 Typen und AusdrückeIn den letzten Abschnitten haben Sie Grundlegendes über den Aufbau von C++-Programmen sowie über Namen und Deklarationen erfahren. Dabei sind Sie unweigerlich über Typen gestolpert, etwa int oder string. Was ein Typ aber genau ist und welche es in C++ gibt, wissen Sie noch nicht (zumindest nicht aus diesem Skript). Dies holt dieser Abschnitt nach.
Wir wollen (Daten-)Typen zuerst anhand einer Analogie veranschaulichen. Ein Typ für eine Variable ist ungefähr wie eine Kuchenform für einen (bereits gebackenen) Kuchen. Die Kuchenform bestimmt, wie groß ein Kuchen maximal sein darf, der in die Form soll. Die Kuchenform verhindert dabei auch, dass Kuchen mit anderen Ausmaßen hineinpassen. So passt ein Sandkuchen, der in einer Kastenform gebacken wurde, normalerweise nicht in eine Biskuit-Form. Schließlich regelt die Kuchenform auch, wie mit dem darin enthaltenen Kuchen umzugehen ist. Beispielsweise schneiden Sie obigen Sandkuchen in Scheiben, während Sie einen Biskuit vierteln oder achteln.
Übertragen auf Variablen regelt ein Datentyp,
(1) welche Werte die Variable aufnehmen kann (z. B. Zeichen oder Zahlen) [Gestalt der Kuchenform],
(2) welche Größe die Werte haben dürfen [Größe der Kuchenform],
(3) wie mit den Werten in dieser Variable umgegangen werden darf (z. B. welche Rechenoperationen erlaubt sind) [Eigenschaften der Kuchenform].
Die Analogie hinkt etwas, weil z. B. ein kleiner Sandkuchen in eine große Biskuit-Form durchaus hineinpassen könnte, wenn er nur klein genug ist. In C++ jedoch sind Form und Größe generell unabhängig voneinander.
Der oben zitierte „Umgang“ mit den Werten eines Datentyps erfolgt durch bestimmte Operationen. Bei den in C++ eingebauten Datentypen existieren diese Operationen nur in Form von bestimmten Operatoren. Ein Operator verändert einen Wert oder verknüpft zwei oder mehrere Werte zu einem neuen. Operatoren sind also im Prinzip Funktionen, die einen oder mehrere Argumente auf einen neuen Wert abbilden. Der Unterschied zu „normalen“ C++-Funktionen besteht lediglich in der Schreibweise: Während die Argumente von Funktionen immer durch Kommata getrennt innerhalb runder Klammern existieren und hinter dem Funktionsnamen stehen, können Argumente von Operatoren vor und hinter (und eventuell auch zwischen) diesen stehen.
Beispiel: Sie können sich den Ausdruck1 int ergebnis = 2 + (3 * 4);
hinter dem Gleichheitszeichen auch so vorstellen, dass die Operatoren + und * Funktionen mit zwei Argumenten sind, die ganze Zahlen erwarten und auch zurückliefern:
1 // Achtung: kein C++!2 int + (int argument1, int argument2);
37
ein Typ ist wie eine Kuchenform
Operatoren und Funktionen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
3 int * (int argument1, int argument2);4 int ergebnis = + (2, * (3, 4));
Wohlgemerkt, nur vorstellen: die obige Syntax ist in C++ nicht möglich.
Jeder Operator hat eine bestimmte Priorität. Ein Operator mit höherer Priorität wird eher ausgewertet als einer mit niedrigerer Priorität, wenn keine Klammern zur Regelung der Auswertungsreihenfolge vorhanden sind. Operatoren mit gleicher Priorität werden von links nach rechts ausgewertet.
Beispiel: Beim Auswerten des Ausdrucks 3 + 4 * 5 wird zuerst 4 * 5 ausgewertet, weil * eine höhere Priorität als + hat. Erst danach wird der Operator + mit seinen Argumenten 3 und 20 ausgewertet. Will man die Auswertungsreihenfolge verändern, muss man geeignet Klammern setzen; im obigen Ausdruck also beispielsweise (3 + 4) * 5, um zuerst 3 + 4 und danach 7 * 5 auszuwerten. In dem Ausdruck 3 + 4 - 5 hingegen haben alle Operatoren die gleiche Priorität. Es sind keine Klammern notwendig, und der Ausdruck wird von links nach rechts ausgewertet (d. h. erst 3 + 4 und dann 7 – 5).
Die arithmetischen Operatoren haben Prioritäten, die in der Mathematik üblich sind: Es gilt also Punkt- vor Strichrechnung, logisches UND (&&) vor logischem ODER (||) u. s. w. Eine Auflistung aller Operatoren nach ihrer Priorität finden Sie in Tabelle 3.
Die Argumente von Operatoren (und allgemein von allen Operationen, inklusive gewöhnlicher Funktionen) sind Ausdrücke. Ein Ausdruck ist eine beliebig komplexe Aneinanderreihung von Zahlen, Variablen und Operatoren, die jedoch bestimmten Regeln gehorchen müssen. Insbesondere muss jeder Operator die jeweils passende Anzahl an Argumenten besitzen, und die Argumente müssen zueinander „passen“, d. h. Typ-gerecht sein.
Beispiel 1: Die Ausdrücke• 2• 5 + 6• alter – 18• gewicht*10000/(groesse*groesse)sind alle gültig (unter der Voraussetzung, dass groesse, gewicht und alter Variablen oder Konstanten eines Zahlen-Typs sind).
Beispiel 2: Die folgenden „Ausdrücke“ sind keine Ausdrücke oder scheitern an der geforderten Typ-Verträglichkeit:
• 2 + (der +-Operator benötigt zwei Operanden)
• 1 + (prozent / 100 (schließende Klammer fehlt)
• "Hallo" * 42 (Zeichenketten und Zahlen lassen sich nicht multiplizieren)
38
Priorität von Operatoren
Klammern setzen zum Ändern der Priorität
Ausdrücke
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
39
Priorität Operatorhöchste Konstante (Zahl, Zeichen, Zeichenkette)
this(Ausdruck) (Regelung der Auswertungsreihenfolge)Bezeichner[ ] (Feldzugriff)( ) (Funktionsaufruf / Methodenaufruf / Konstruktoraufruf)., -> (Elementzugriff)++ (Postfix-Inkrement), -- (Postfix-Dekrement)dynamic_cast, static_cast,const_cast, reinterpret_cast (explizite Typumwandlung)typeid (Zugriff auf Typ-Informationen)
* (Zeiger-Derefenzierung), & (Adress-Operator)+ (Vorzeichen), - (Vorzeichen)! (logisches NICHT), ~ (binäres NICHT)++ (Präfix-Inkrement), -- (Präfix-Dekrement)sizeof, new (Speicher-Belegung), delete (Speicher-Freigabe)
(Typ) (explizite Typumwandlung)
.*, ->* (Elementzeiger-Zugriffsoperatoren)
*, /, % (Multiplikations- und Divisions-Operatoren)
+, - (Additions- und Subtraktions-Operatoren)
<<, >> (Schiebe-Operatoren)
<, >, <=, >= (Vergleich auf Größe)
== und != (Vergleich auf Gleichheit)
& (bitweises UND)
^ (bitweises XOR)
| (bitweises ODER)
&& (logisches UND)
|| (logisches ODER)
?: (Fallunterscheidung)
= (Zuweisung) und alle zusammengesetzten Formen (+= etc.)
throw (Ausnahme auswerfen)
niedrigste , (Komma)
Tabelle 3: Operatoren nach ihrer Priorität sortiert
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
In Ausdrücken können noch weitere Operatoren und syntaktische Konstrukte vorkommen, die sie in den nächsten Abschnitten und Kapiteln kennen lernen werden.
Ausdrücke sind schön und gut, aber wozu brauchen wir sie? Ausdrücke erlauben uns, Variablen, Konstanten, Funktionsaufrufe und andere Dinge miteinander in Beziehung zu setzen und zur Laufzeit des Programms auszuwerten. Auswerten bedeutet dabei, dass aus den vielen Teilen des Ausdrucks letztlich ein einziger Wert entsteht, der das Ergebnis des Ausdrucks darstellt.
Manche Ausdrücke können bereits vom Übersetzer ausgewertet werden, manche lassen sich erst zur Laufzeit des Programms auswerten. Im obigen Beispiel gehören die ersten beiden Ausdrücke zur ersten Kategorie (2 wird zu 2, 5 + 6 zu 11 ausgewertet). Die beiden folgenden Ausdrücke gehören zur zweiten Kategorie, vorausgesetzt alter, groesse und gewicht sind Variablen, die erst zur Laufzeit gesetzt werden. Diese Ausdrücke können nicht während der Übersetzung ausgewertet werden, weil dann für diese Variablen noch keine Werte vorliegen. Zur Laufzeit hingegen werden diese Variablen mit Werten belegt, die z. B. vom Benutzer eingegeben werden. Gehen wir davon aus, dass in einem Test-Durchlauf die Werte für die Variablen alter, groesse und gewicht 18, 180 respektive 70 lauten, wird der erste der beiden Ausdrücke (alter - 18) zu 0, der zweite (gewicht*10000 / (groesse*groesse)) zu 21 gemäß der üblichen Rechenregeln (bei ganzzahliger Division wird abgerundet, s. Abschnitt 3.4.2.2) ausgewertet. Natürlich müssen diese „üblichen“ Rechenregeln für eine Programmiersprache wie C++ genau festgelegt werden. Die nächsten Abschnitte behandeln die verschiedenen Datentypen und die darauf erlaubten Operationen, so dass Sie danach verstehen können, was ein bestimmter Ausdruck in einem C++-Programm bedeutet und wie er ausgewertet wird.
Wo kommen Ausdrücke nun in C++-Programmen vor? Fast überall, möchte man meinen. Insbesondere ist die Tatsache wichtig, dass jeder (gültige) Ausdruck eine Anweisung (3.5) ist und somit im Innern einer Funktion stehen kann. Das folgende Programm ist somit gültiges C++:
1 /*** Beispiel ausdruck.cpp ***/2 #include <string>3 using namespace std;45 int main ()6 {7 2; // dies ist ein Ausdruck8 3 + 4; // dies ebenfalls9 string ("Hallo") + string ("Welt"); // auch dies
10 return 0;11 }
Allerdings tut dieses Programm nicht wirklich etwas, weil die Ausdrücke in den Zeilen 7-9 keine Seiteneffekte haben. Ausdrücke als Anweisungen haben also nur dann Sinn, wenn sie Seiteneffekte produzieren, was eigentlich nur auf Funktionsaufrufe (3.6.2) und Zuweisungen (3.4.1) zutrifft. Alles andere ist Schall und Rauch.18
18) Es gibt sogar schlaue Übersetzer, die solche „sinnlosen“ Ausdrücke wegoptimieren, d. h. gar keinen Maschinen-Code dafür generieren. Diese Vorgehensweise ist gemäß dem C++-Standard auch erlaubt, vorausgesetzt der Übersetzer kann wirklich erkennen, dass ein Ausdruck keine Seitenef
40
AusdrückeAusdrücke sind Anweisungen
Auswertung von Ausdrücken
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Allgemein gilt, dass wenn durch eine Operation der Wertebereichs des zugehörigen Typs verlassen wird, ein Fehler vorliegt. Dies kann auch bei einfachen Ausdrücken wie i + 1 geschehen, wenn i bereits den größtmöglichen Wert seines Datentyps inne hat. Seien Sie also bei Ihren Berechnungen vorsichtig, und überprüfen Sie immer, dass sie nicht aus Versehen den zulässigen Wertebereich verlassen.
Datentypen werden in C++ generell in zwei Klassen unterteilt: Fundamentale und zusammengesetzte Datentypen. Beide Klassen werden in den nächsten Abschnitten näher erläutert. Vorher jedoch betrachten wir einen ganz besonderen Operator: die Zuweisung.
3.4.1 Zuweisungen (offene und verkappte)Der Zuweisungsoperator ist so allgemein anwendbar, dass er es verdient, bereits an dieser Stelle erwähnt zu werden. Die Zuweisung ist der einzige eingebaute Operator, der Seiteneffekte produziert. Das bedeutet, dass Ausdrücke mit Zuweisungen nicht nur einen Wert wie „normale“ Funktionen zurückliefern, sondern zusätzlich die Daten des Programms verändern.
Zuweisungen haben generell den folgenden (vereinfachten) Aufbau:
Variable = Ausdruck
Wir verzichten hier auf ein Beispiel, weil es in den Beispiel-Programmen genügend Beispiele für eine Zuweisung gibt.
Zuweisungen sind nur dann erlaubt, wenn auf der linken Seite etwas steht, was verändert werden darf. Somit scheiden beispielsweise Konstanten aus. Das folgende Beispiel produziert also einen Fehler bei der Übersetzung:
1 const double Pi = 3.14;2 Pi = 3.1415; // Fehler: Kann Konstante nicht verändern!
Sie sehen deutlich, dass Initialisierung (3.3.3) und Zuweisung zwei unterschiedliche Paar Schuhe sind. Dies wird noch deutlicher bei Objekten, denn nur bei der Initialisierung wird ein sogenannter Konstruktor (4.5.1) aufgerufen. Doch dazu später mehr.
Wichtig ist, dass der Ausdruck auf der rechten Seite des Zuweisungsoperators zum Datentyp der Variable auf der linken Seite passt. Ansonsten meldet der Übersetzer eine Typ-Verletzung und bricht die Übersetzung ab. Genaueres zur Typ-Verträglichkeit von Ausdrücken finden Sie in Abschnitt 3.4.5.
Zuweisungen sind selbst wieder Ausdrücke und können somit Teil anderer Ausdrücke sein. Eine Zuweisung wird zu dem Objekt ausgewertet, das bei der Zuweisung auf der linken Seite steht, und zwar nachdem das Objekt einen neuen Wert bekommen hat. Betrachten Sie folgenden Ausdruck:
1 alter = groesse = 0;Hier werden beide Variablen auf Null gesetzt. Vollständig geklammert lautet der Ausdruck
1 (alter = (groesse = 0));und bedeutet soviel, dass zuerst groesse = 0 zu 0 (weil der Wert von groesse nach der Zuweisung 0 ist) und danach der Ausdruck alter = 0 (ebenfalls) zu 0 aus
fekte erzeugt.
41
zwei Klassen von Datentypen
Zuweisungen
Zuweisungen und Typ-Verträglichkeit
Aufbau von Zuweisungen
Verlassen des Wertebereichs
Zuweisung ist nicht gleich Initialisierung!
Zuweisungen benötigen ein veränderbares Ziel
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
gewertet wird. Generell empfiehlt es sich aber nicht, Zuweisungen zu schachteln, wie es das obige Beispiel demonstriert.
Es gibt neben normalen Zuweisungen auch „Abkürzungen“ zwischen anderen Operatoren und Zuweisungen, genannt zusammengesetzte Zuweisungen. Diese erlauben dem C++-Programmierer, Quelltext kompakter zu schreiben. Sie haben die Form:
Variable op= Ausdruck
wobei op einer der folgenden Operatoren ist: + - * / % & | ^ << >>. Ein solcher Ausdruck ist äquivalent zu:
Variable = Variable op Ausdruck
Beispiel: Wenn ergebnis eine Variable vom Typ int ist, haben die beiden Ausdrücke
1 ergebnis = ergebnis + 10
und1 ergebnis += 10
dieselbe Bedeutung.19
Schließlich gibt es zwei besondere Operatoren in C++, welche als verkappte Zuweisungen betrachtet werden können. Diese Operatoren dienen dem Inkrementieren (= Erhöhen) bzw. Dekrementieren (= Erniedrigen) um Eins und haben die Form:
++Variable
Variable++zum Inkrementieren bzw.
--Variable
Variable--zum Dekrementieren. Wenn diese Ausdrücke für sich allein stehen, entsprechen sie völlig dem Ausdruck
Variable += 1bzw.
Variable -= 1und unterscheiden sich nicht untereinander. Sie stellen also eine weitere Abkürzung dar, die besonders häufig bei for-Schleifen zur Erhöhung bzw. Erniedrigung der Schleifen-Variable gebraucht wird.
Die Ausdrücke ++Variable bzw. --Variable entsprechen jeweils vollständig dem Ausdruck
Variable += 1bzw.
19) Dies trifft nicht unbedingt bei Überladung (7.1) und Ausdrücken mit Seiteneffekten (3.4.1) zu!
42
zusammengesetzte Zuweisungen
Inkrement- und Dekrement-Operatoren
Prä-Inkrement und Post-Inkrement-Operatoren (bzw. Dekrement-Operatoren)
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Variable -= 1Die Ausdrücke Variable++ und Variable-- haben jedoch eine etwas andere Bedeutung: Während die Variante mit dem voran stehenden Operator zu dem Wert nach der Zuweisung ausgewertet wird, wird der Ausdruck mit dem nachstehenden Operator zu dem Wert vor der Zuweisung ausgewertet. Deswegen wird der erste Operator-Typ Prä-Inkrement- (bzw. Prä-Dekrement-)Operator und der zweite Post-Inkrement- (bzw. Post-Inkrement-)Operator genannt. Ein Beispiel veranschaulicht dies am besten:
1 /*** Beispiel incdec.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int main ()7 {8 int alterVonPeter = 12;9 int alterVonMarianne = 8;10 int neuesAlterVonPeter = ++alterVonPeter;11 int neuesAlterVonMarianne = alterVonMarianne++;12 cout13 << alterVonPeter << " "14 << alterVonMarianne << " "15 << neuesAlterVonPeter << " "16 << neuesAlterVonMarianne << " "17 << endl;18 return 0;19 }
Nach dem Ausführen der vier Anweisungen sind alterVonPeter und alterVonMarianne erwartungsgemäß um Eins erhöht, also 13 bzw. 9. Jedoch ist nur neuesAlterVonPeter ebenfalls 13; neuesAlterVonMarianne hat den alten Wert von alterVonMarianne zugewiesen bekommen. Die Ausgabe des Programms ist demzufolge:
13 9 13 8Die Bedeutung der Dekrement-Operatoren ist analog.
3.4.2 Fundamentale DatentypenFundamentale Datentypen sind diejenigen Datentypen, die
(1) in der Sprache C++ fest eingebaut sind und
(2) nicht mehr in einfachere Typen zerlegt werden können.
Es lassen sich fünf Arten von fundamentalen Typen bilden, die in den nächsten Abschnitten genauer untersucht werden. Zu jedem Datentyp bzw. jeder Klasse von Datentypen werden die erlaubten Wertebereiche und Operationen vorgestellt und erläutert.
3.4.2.1 Datentypen für Zeichen und Zeichenketten
3.4.2.1.1 Wertebereich
Hierunter fallen alle in C++ eingebauten Datentypen, die sich zur Speicherung und Verarbeitung von Zeichen eignen. Der wichtigste dieser Datentypen ist der Typ char. Welche Zeichen Variablen dieses Typs nun speichern können, ist nicht genau definiert; C++ garantiert allerdings, dass die folgenden Zeichen gespeichert werden
43
der Datentyp char für gewöhnliche Zeichen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
können (egal, welchen Zeichensatz die zugrunde liegende Rechner-Architektur verwendet):
• alle 26 lateinischen Buchstaben in Groß- und Kleinschreibung
• alle zehn Ziffern
• die folgenden Sonderzeichen:_ + - * / % ! ~ & | ^ < > = ? , ; . :" ' # ( ) [ ] { } \
• das Leerzeichen
• die folgenden Steuerzeichen:
Horizontaler Tabulator (\t), Zeilenumbruch (\n), Vertikaler Tabulator (\v), Formularvorschub (\f), Wagenrücklauf (\r), Alarm (\a), Rückschritt (\b)
Es ist nicht schlimm, wenn Sie mit einigen Steuerzeichen nichts anfangen können. Viele dieser Steuerzeichen sind lediglich esoterisch und haben nur in speziellen Anwendungen überhaupt eine Daseinsberechtigung. Sie sind hauptsächlich aus historischen Gründen im Sprachkern von C++ verankert. Die wichtigeren Steuerzeichen sind (horizontaler) Tabulator und Zeilenumbruch (den Sie bereits kennen gelernt haben).
Wie Sie sehen, garantiert C++ nicht, dass Umlaute und andere Sonderzeichen in char-Variablen gespeichert werden können. Wenn Sie für einen „gewöhnlichen“ PC programmieren, dann kann der Datentyp char für gewöhnlich alle 128 Zeichen des ASCII-Zeichensatzes aufnehmen und viele Zeichen aus dem ISO 8859-1-Zeichensatz, der auch die wichtigsten Umlaute enthält. Dies wird jedoch nicht von der Sprache garantiert, sondern ist von der verwendeten C++-Implementierung abhängig. Sie müssen also damit rechnen, dass ein Programm, das Umlaute und andere oben nicht aufgeführte Zeichen verwendet, auf anderen Rechner-Architekturen nicht übersetzt werden kann oder „Zeichensalat“ auftritt. Die beste Lösung zur Umgehung dieses Problems ist es, mit dem Datentyp wchar_t zu arbeiten (s. u.), der einen wesentlich größeren Zeichenbereich umfasst.
Ein weiterer wichtiger Zeichen Datentyp ist wchar_t. Dieser unterscheidet sich von char dadurch, dass er einen wesentlich größeren Wertebereich hat. Insbesondere kann er jedes Zeichen aus dem Unicode20-Zeichensatz aufnehmen. Sie sollten aber bedenken, dass die Wahl des größeren Zeichensatzes auf Ihr ganzes Programm Einfluss nimmt bzw. nehmen sollte. Nichts ist ärgerlicher, als dass der Anwender an einer Stelle Umlaute in Dialogen, Dateien u. ä. verwenden kann und an einer anderen Stelle nicht. Entscheiden Sie sich also möglichst frühzeitig, ob Sie Unicode für Ihre Anwendung benutzen wollen oder nicht. Falls Sie die Unicode-Varianten der Datentypen benutzen wollen, sollten Sie sich auch mit den Unicode-Varianten der Ein- und Ausgabe-Ströme vertraut machen (Tabelle 4). Für die Anwendungen in diesem Skript werden wir nicht weiter auf Unicode, wchar_t und wstring (s. u.) eingehen.
20) s. http://www.unicode.org/
44
wichtige und weniger wichtige Steuerzeichen
die Umlaut-Problematik
wchar_t für Unicode
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Java-Programmierer müssen etwas umdenken: Der Java-Datentyp char entspricht dem C++-Datentyp wchar_t. Einen Java-Pendant zum C++-Datentyp char gibt es hingegen nicht.
Es gibt in C++ keinen fundamentalen Datentyp für Zeichenketten. Dies unterscheidet C++ von vielen anderen Programmiersprachen. Es gibt jedoch zwei Klassen in der C++-Standard-Bibliothek, die Zeichenketten repräsentieren und Operationen zur Manipulation von Zeichenketten anbieten: die Klasse string (zur Speicherung von char-Zeichenketten) und die Klasse wstring (zur Speicherung von wchar_t-Zeichenketten); diese werden in Abschnitt 8.3 beschrieben.
Es ist jedoch wichtig, dass Zeichenketten-Konstanten wie "Hallo" oder L"Hallo" nicht automatisch den Typ string bzw. wstring besitzen. Sie sollten also stets string- bzw. wstring-Objekte definieren, wenn Sie mit Zeichenketten arbeiten wollen, etwa wenn Sie sie mit Hilfe des +-Operators aneinander hängen wollen (auch konkatenieren genannt):
1 using namespace std;2 // falsch: Sie können Felder nicht addieren, s. nächsten Einschnitt3 string ausgabe = "Hallo " + "Welt!";4 // in Ordnung, string-Objekte verstehen den +-Operator5 string abba = string("AB")+string("BA");6 // ebenfalls in Ordnung7 string hallo = "Hallo ";8 string welt = "Welt!";9 string ausgabe = hallo + welt;
Zeichenketten-Konstanten besitzen in C++ den Datentyp const char [n]. Dieser Datentyp beschreibt ein Feld von n nicht veränderbaren Zeichen, wobei n die Anzahl der in der Zeichenkette existierenden Zeichen + 1 darstellt. Das zusätzliche Zeichen wird als Abschluss-Zeichen benötigt, damit C++ das Ende der Zeichenkette finden kann, da C++-Felder nicht über die Anzahl der gespeicherten Elemente Buch führen. C++-Felder werden kurz in Abschnitt 3.4.3.5 behandelt.
3.4.2.1.2 Operationen
Die einzigen Operationen, die sich auf Zeichen sinnvoll anwenden lassen, sind Vergleiche zweier Zeichen (Tabelle 5) sowie Addition und Subtraktion von ganzen Zahlen. Bei dem Größer/Kleiner-Vergleich von Zeichen ist zu beachten, dass die zugrunde liegende Ordnung vom jeweiligen Zeichensatz abhängt. Die Sprache garantiert lediglich,
• dass alle Großbuchstaben gemäß der üblichen lexikographischen Ordnung sortiert sind:
'A' < 'B' < 'C' < ... < 'Y' < 'Z'
45
die Sache mit den Zeichenketten
Vergleich von Zeichen
char-basierter Ein-/Ausgabestrom
wchar_t-Pendant
std::cin std::wcinstd::cout std::wcoutstd::cerr std::wcerr
Tabelle 4: char-basierte Ein-/Ausgabeströme und ihre wchar_t-Pendanten
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
• dass alle Kleinbuchstaben ebenso entsprechend sortiert sind,
• dass alle Ziffern ihrem Wert nach aufsteigend sortiert sind und unmittelbar aufeinander folgen:
'0' < '1' < '2' < ... < '8' < '9'
Alles andere ist vom Zeichensatz abhängig. Insbesondere ist nicht garantiert, dass die Buchstaben unmittelbar aufeinander folgen.21
Merksatz 6: Mach dich möglichst nicht von lokalen Eigenheiten abhängig!
So gilt beispielsweise im weit verbreiteten ASCII-Zeichensatz, der auch von VC++ benutzt wird, dass die Großbuchstaben vor den Kleinbuchstaben liegen, also dass 'Z' < 'a' gilt. Diese Tatsache ist durchaus nicht intuitiv. Andere Zeichensätze haben wiederum andere Eigenarten.
Wie oben erwähnt, können Sie Zeichen und ganzzahlige Ausdrücke addieren bzw. subtrahieren. Dies ist besonders dann sinnvoll, wenn Sie über einen bestimmten Zeichenbereich iterieren möchten, d. h. jedes Zeichen aus diesem Bereich in einer Schleife verarbeiten wollen. (Schleifen lernen Sie in Abschnitt 3.5.4 kennen.)
1 /*** Beispiel ziffern.cpp ***/2 #include <ostream>3 #include <iostream>
21) De facto ist dies z. B. im EBCDIC-Zeichensatz (der auf Großrechnern heute noch Verwendung findet) nicht der Fall; dort existieren „Lücken“ zwischen den Buchstaben ‘I’ und ‘J’ sowie zwischen ‘R’ und ‘S’.
46
Bewegen innerhalb von Zeichenbereichen
Operator Bedeutung Beispiel== Vergleich auf
Gleichheit'a' == 'a' (liefert true)'x' == 'y' (liefert false)
!= Vergleich auf Ungleichheit
'a' != 'a' (liefert false)'x' != 'y' (liefert true)
< Kleiner-als-Operator
'a' < 'b' (liefert true)'b' < 'a' (liefert false)'a' < 'a' (liefert false)
> Größer-als-Operator
'a' > 'b' (liefert false)'b' > 'a' (liefert true)'a' > 'a' (liefert false)
<= Kleiner-oder-gleich-Operator
'a' <= 'b' (liefert true)'b' <= 'a' (liefert false)'a' <= 'a' (liefert true)
>= Größer-oder-gleich-Operator
'a' >= 'b' (liefert false)'b' >= 'a' (liefert true)'a' >= 'a' (liefert true)
Tabelle 5: Relationale Operationen auf Zeichen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
4 #include <string>5 using namespace std;67 int main ()8 {9 // iteriere über alle Ziffern und verbinde sie10 string ziffern;11 for (char ziffer = '0'; ziffer <= '9'; ziffer = ziffer + 1)12 {13 // füge Ziffer ans Ende der Zeichenkette an14 ziffern = ziffern + ziffer;15 }16 cout << "Ziffern von 0 bis 9: " << ziffern << endl;17 return 0;18 }
In diesem Programm sind die entscheidenden Ausdrücke in Zeile 11 enthalten:
• char ziffer = '0' initialisiert die Schleifen-Variable ziffer mit dem Zeichen '0' als Startwert.
• ziffer = ziffer + 1 weist das Programm an, bei jedem Schleifendurchlauf die Schleifen-Variable ziffer um ein Zeichen weiter zu „zählen“ (d. h. auf das nächste Zeichen im Zeichensatz zu setzen).
• ziffer <= '9' schließlich ist die Schleifen-Invariante. Wenn sie nicht mehr eingehalten werden kann (und das bedeutet in diesem Fall, dass die Variable ziffer über die '9' hinaus „gezählt“ hat), wird die Schleife verlassen.
Dass die Schleife alle Ziffern in der richtigen Reihenfolge und nur diese besucht, liegt an der obigen Garantie, dass die Ziffern ihrem Wert nach sortiert sind und unmittelbar ohne „Lücken“ aufeinander folgen.
Objekte der Typen string und wstring unterstützen eine ganze Menge von Operationen. Die wichtigsten an dieser Stelle sind Element-Zugriff, Konkatenation (Verkettung), Vergleiche und Längen-Bestimmung. Die nachfolgenden Beispiele beziehen sich auf diese Zeichenketten-Definitionen:
1 string hallo = "Hallo ";2 string halloKlein = "hallo ";3 string welt = "Welt!";
• Über den Operator [] greifen Sie auf einzelne Elemente eines string- oder wstring-Objekts zu, wobei die Position der einzelnen Zeichen mit Null anfängt.
Beispiel: hallo [1] liefert 'a', das zweite (!) Zeichen der Zeichenkette.
• Den Operator + verwenden Sie, um Zeichenketten-Objekte aneinander zu reihen.
Beispiel: hallo + welt ergibt die Zeichenkette "Hallo Welt!".
Dies funktioniert auch für die Konkatenation von Zeichenketten und einzelnen Zeichen.
Beispiel: hallo + 'X' ergibt die Zeichenkette "HalloX".
47
Operationen auf Zeichenketten
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
• Die Operatoren ==, !=, <, >, <= und >= führen Vergleiche von Zeichenketten durch. Dabei werden die Zeichenketten lexikographisch verglichen, d. h. Zeichen für Zeichen, bis ein Unterschied entdeckt wird oder das Ende einer Zeichenkette erreicht ist.
Beispiele: hallo == welt ergibt false, hallo != welt ergibt true, hallo < welt ergibt true (weil der erste Buchstabe (H) von hallo im Zeichensatz vor dem ersten Buchstaben von welt (W) liegt), hallo < (hallo + welt) ergibt true (da die hallo-Zeichenkette kürzer ist), hallo == halloKlein ergibt false (weil die Vergleiche auf Groß- und Kleinschreibung achten).
• Die Operation length gibt die Länge der betrachteten Zeichenkette zurück. (Operationen und Methoden von Klassen werden in den Abschnitten 4.6.1 resp. 4.4.5.3 eingeführt.)
Beispiele: hallo.length() ergibt 6 (das Leerzeichen wird natürlich mitgezählt!), welt.length() ergibt 5 und (hallo+welt).length() ergibt 11.
Bitte beachten Sie, dass die genannten Operationen (bis auf den Element-Zugriff) nur für Objekte des Typs string bzw. wstring existieren und nicht für „reine“ Zeichenketten-Konstanten. Sie können also nicht "Hallo " + "Welt!" schreiben – Ihr Übersetzer wird etwas wie „kann Zeiger nicht addieren“ von sich geben und die Übersetzung abbrechen.
3.4.2.2 Datentypen für Ganzzahlen
3.4.2.2.1 Wertebereich
In C++ gibt es vielfältige Datentypen, um ganze Zahlen darzustellen und mit ihnen zu rechnen. Die Datentypen unterschieden sich im Prinzip nur im unterstützten Wertebereich. Tabelle 6 gibt einen Überblick über die verschiedenen Typen. Die Werte
bereiche sind geschlossene Intervalle, d. h. die angegebenen Grenzen sind in den jeweiligen Wertebereichen mit enthalten.
48
Datentypen für ganze Zahlen
Name Garantierter Wertebereichint wie short oder longunsigned int wie unsigned short oder unsigned longshort -32767 bis +32767unsigned short 0 bis 65535long -2147483647 bis +2147483647unsigned long 0 bis +4294967295
Tabelle 6: C++-Datentypen für Ganzzahlen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Vielleicht verwundert Sie, dass der Datentyp int so ungenau spezifiziert ist. Dazu muss man verstehen, dass der Datentyp int derjenige sein soll, mit dem auf der verwendeten Rechner-Architektur am effizientesten gerechnet werden kann. Deshalb entspricht er entweder short oder long, je nach der zugrunde liegenden Architektur.
Diese Entsprechung der Datentypen wird jedoch rein intern vom Übersetzer durchgeführt und hat keinerlei Auswirkungen auf die C++-Sprachkonzepte. In C++ sind die Datentypen int und long immer verschieden, auch wenn sie der Übersetzer letztlich auf dieselbe Repräsentation (d. h. dieselbe Bitanzahl und dieselben Prozessor-Befehle) abbildet.
Der VC++-Übersetzer bildet die int- und long-Datentypem intern auf dieselbe Repräsentation ab. Der int-Datentyp hat somit einen Wertebereich, der mindestens den Bereich von -2147483647 bis +2147483647 umfasst.
Wie Sie sehen, hat jeder vorzeichenbehaftete Datentyp eine vorzeichenlose unsigned-Variante. Diese kann genauso viele Werte aufnehmen wie der korrespondierende vorzeichenbehaftete Datentyp, allerdings fängt der Wertebereich generell bei Null an. Damit sind nur positive Werte erlaubt.
Schließlich sollten Sie wissen, dass die oben angegebenen Wertebereiche Mindestgarantien sind, in verschiedenen C++-Implementierungen aber durchaus größer sein können. Auf Großrechnern ist es durchaus üblich, dem Datentyp long 64 Bit zu spendieren, was auf einen Wertebereich von -9223372036854775808 bis einschließlich +9223372036854775807 hinausläuft.
Welchen Datentyp sollten Sie nun am besten verwenden? Für gewöhnlich sollten Sie int für das Arbeiten mit ganzen Zahlen benutzen, da er für die verwendete Rechner-Architektur der „natürlichste“ Datentyp ist. Die vorzeichenlosen Varianten sollten Sie nur in Spezialfällen benutzen, etwa für Bitfelder (die in dem Skript auch gar nicht weiter behandelt werden). Haben Sie jedoch ganz bestimmte Anforderungen an Größe und Genauigkeit des Datentyps (etwa weil Sie ein Programm für kaufmännische, wissenschaftliche o. ä. Berechnungen entwickeln), sollten Sie eine speziell für diesen Zweck entwickelte Bibliothek nutzen, welche die entsprechenden Datentypen „mitbringt“.
Zahlen-Konstanten haben in C++ erst einmal den Typ int, es sei denn, die Konstante ist größer als der für int zulässige Wertebereich. Details hierzu finden Sie in Abschnitt 3.2.5. Allgemein sollten Sie vermeiden, Konstanten zu verwenden, die außerhalb des Wertebereichs für den Datentyp int liegen.
Der Datentyp char kann in C++ ebenfalls ganze Zahlen aufnehmen, allerdings ist der garantierte Wertebereich viel kleiner – er reicht von -128 bis 127 bzw. von 0 bis 256. Es ist jedoch leider nicht spezifiziert, ob char vorzeichenbehaftet oder vorzeichenlos ist, weshalb er für die meisten Rechenoperationen uninteressant ist.
3.4.2.2.2 Operationen
Folgende Operationen sind auf Ganzzahlen definiert:
49
int, ein ganz besonderer Datentyp
vorzeichenbehaftete und vorzeichenlose Typen
mehr über Wertebereiche
Welcher Datentyp für welchen Zweck?
Zahlen-Konstanten
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
(1) Arithmetische Operationen: Die Operatoren + (Addition), - (Subtraktion), * (Multiplikation), / (Division) und % (Rest bei Division) sind für alle ganzzahligen Datentypen definiert. Die Ergebnisse entsprechen den üblichen Rechenregeln, wobei zu beachten ist, dass bei der Division von zwei positiven ganzen Zahlen generell in Richtung Null gerundet wird. Ist mindestens einer der Argumente negativ, ist jedoch nicht definiert, wie gerundet wird (Tabelle 7). Dementsprechend kann der Rest bei der Division ein unterschiedliches Vorzeichen bekommen (Tabelle 8). Es wird jedoch garantiert, dass unabhängig von der Wahl der Rundung stets der folgende Zusammenhang gilt:
(Dividend / Divisor) * Divisor + (Dividend % Divisor) = DividendVC++ rundet immer in Richtung Null. In Tabelle 7 und Tabelle 8 wird also bei den Ergebnissen, bei denen eine Wahl besteht, immer das erste berechnet.
(2) Vorzeichen-Operatoren: Der Operator + „tut nichts“, er verändert sein Argument nicht. Der Operator - negiert seinen Operanden. Beide Operatoren sind unär, d. h. sie haben nur ein Argument, und stehen vor ihrem Operanden.
(3) Relationale Operationen: Sie können die Operatoren aus Tabelle 9 verwenden, um Vergleiche von Zahlen durchzuführen. Alle diese Operationen liefern einen Wahrheitswert (true oder false) vom Typ bool zurück. Mehr dazu finden Sie in Abschnitt 3.4.2.4. Die zugrunde liegende Ordnung ist die (gewöhnliche) Ordnung auf den ganzen Zahlen.
50
Rechnen mit Zahlen
Vergleiche von Zahlen
Negieren
Ausdruck Ergebnis10 / 3 311 / 3 3 (nicht 4)-10 / 3 -3 oder -4-10 / -3 3 oder 410 / -3 -3 oder -4
Tabelle 7: Rechenregeln bei ganzzahliger Division
Ausdruck Ergebnis10 % 3 111 % 3 2-10 % 3 Wenn -10/3 = -3, dann -1, sonst 2-10 % -3 Wenn -10/-3 = 3, dann -1, sonst 210 % -3 Wenn 10/-3 = -3, dann 1, sonst -2
Tabelle 8: Rechenregeln bei ganzzahliger Division mit Rest
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Das Ergebnis eines solchen Vergleichs können Sie beispielsweise in einer Fallunterscheidung nutzen (3.5.3.1):
1 /*** Beispiel if1.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 using namespace std;67 int main ()8 {9 cout << "Bitte geben Sie Ihr Alter ein: ";10 int alter;11 cin >> alter;12 if (alter < 0)13 cout << "Nicht sehr realistisch, oder?";14 else if (alter >= 0 && alter < 16)15 cout << "Hallo du da.";16 else if (alter >= 16 && alter < 18)17 cout << "Soll ich dich duzen oder Sie siezen?";18 else if (alter >= 18)19 cout << "Sie sind mir willkommen!";20 cout << endl;21 return 0;22 }
51
Operator Bedeutung Beispiel== Vergleich auf Gleichheit 5 == 7 (liefert false)
3 == 3 (liefert true)!= Vergleich auf Ungleichheit 5 != 7 (liefert true)
3 != 3 (liefert false)< Kleiner-als-Operator 5 < 7 (liefert true)
7 < 5 (liefert false)3 < 3 (liefert false)
> Größer-als-Operator 5 > 7 (liefert false)7 > 5 (liefert true)3 > 3 (liefert false)
<= Kleiner-oder-gleich-Operator
5 <= 7 (liefert true)7 <= 5 (liefert false)3 <= 3 (liefert true)
>= Größer-oder-gleich-Operator
5 >= 7 (liefert false)7 >= 5 (liefert true)3 >= 3 (liefert true)
Tabelle 9: Vergleiche von ganzen Zahlen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
(4) Bitweise Operationen: C++ unterstützt Operationen zum Manipulieren der Zahlen auf Bit-Ebene mit Hilfe der Operatoren ~ (bitweises NICHT), & (bitweises UND), | (bitweises ODER) und ^ (bitweises XOR). Weil diese Operationen aber sehr Hardware-abhängig sind und nur in speziellen Anwendungsbereichen sinnvoll sind, gehen wir nicht näher darauf ein.
(5) Schiebe-Operationen: Neben den bitweisen Operationen stellt C++ auch zwei Operatoren zur Verfügung, um die Bit-Repräsentation von Zahlen nach links (<<) bzw. nach rechts (>>) zu schieben. Da diese Operatoren ebenfalls nur in einem begrenzten Umfeld Sinn machen, gehen wir auch auf diese Operationen nicht näher ein.
3.4.2.3 Datentypen für Fließkommazahlen
3.4.2.3.1 Wertebereich
C++ bietet drei verschiedene Datentypen für Fließkommazahlen an: float, double und long double (Tabelle 10, Tabelle 11).
Die Wertebereiche der Datentypen für Fließkommazahlen sind sehr Hardware-abhängig. Generell lässt sich sagen, dass der Datentyp double genauso viele oder mehr Werte als float und long double genauso viele oder mehr Werte als double umfasst. Ebenso ist long double mindestens so genau wie double und double mindestens so genau wie float. Es empfiehlt sich, für das Rechnen mit Fließkommazahlen den Datentyp double zu benutzen, wenn nicht gerade besonders hohe Anforderungen an Genauigkeit und Wertebereich existieren.
Der Datentyp float ist insofern problematisch, als dass er eine geringere garantierte Genauigkeit als der Datentyp long besitzt, so dass es passieren kann, dass Werte vom Typ long nicht verlustfrei in einer float-Variable gespeichert werden können. Da dies für die meisten Menschen sehr ungewöhnlich ist – schließlich hat der Datentyp
52
Operationen zur Bit-Manipulation
Name Garantierter Wertebereich(circa)
Typischer Wertebereich(circa)
float -1×1037 bis +1×1037 -1×1037 bis +1×1037
double -1×1037 bis +1×1037 -1×10308 bis +1×10308
long double -1×1037 bis +1×1037 -1×104092 bis +1×104092
Tabelle 10: Garantierte und typische Wertebereiche von Fließkommazahlen
Name Garantierte Genauigkeit Typische Genauigkeitfloat 6 Stellen 6 Stellendouble 10 Stellen 15 Stellenlong double 10 Stellen 19 Stellen
Tabelle 11: Garantierte und typische Genauigkeiten von Fließkommazahlen
Datentypen für Gleitkomma-Zahlen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
float einen größeren Wertebereich als long – sollte man den Datentyp float möglichst vermeiden.
3.4.2.3.2 Operationen
Die möglichen Operatoren für Fließkommazahlen entsprechen denen auf Ganzzahlen, mit den folgenden Ausnahmen:
• Es gibt keine Division mit Rest (%) bei Fließkommazahlen.
• Es gibt keine bitweisen und Schiebe-Operatoren für Fließkommazahlen.
• Das Runden bei allen arithmetischen Operationen erfolgt auf den am genauesten darstellbaren Wert.
Vorsicht ist beim Vergleich von Fließkommazahlen geboten. Auf Grund der internen Darstellung von Fließkommazahlen können sehr schnell Ungenauigkeiten entstehen. Es ist beispielsweise nicht garantiert, dass das zehnfache Erhöhen einer Fließkomma-Variable dasselbe ergibt wie das einmalige Erhöhen mit dem zehnfachen Wert (s. u.) Durch Rundungsfehler können unterschiedliche Ergebnisse entstehen.
1 /*** Beispiel fliesskomma.cpp ***/2 #include <ostream>3 #include <iostream>45 using namespace std;6 int main ()7 {8 float a = 0.0;9 float b = 0.0;10 float increment = 0.0001f; // f-Suffix forciert float-Typ11 int loopCount = 10000;1213 // a wird direkt um (increment * loopCount) erhöht14 a += increment * loopCount;15 // b wird „loopCount“-mal um „increment“ erhöht16 for (int i = 0; i < loopCount; ++i)17 b += increment;18 // überprüfe, ob a und b denselben Wert haben19 if (a == b)20 cout << "a und b sind gleich : ";21 else22 cout << "a und b sind ungleich! : ";23 cout << "a = " << a << ", b = " << b << endl;24 return 0;25 }
Das obige Programm kann durchaus bei einigen C++-Implementierungen und Rechner-Architekturen a und b sind ungleich ausgeben, obwohl vom mathematischen Standpunkt diese Antwort schlichtweg falsch ist.22 Deshalb sollte bei Fließkommazahlen statt auf genaue Gleichheit auf eine minimale Differenz geprüft werden, etwa so:
4 #include <cstdlib> // für die Funktion abs18 // überprüfe, ob a und b denselben Wert haben19 if (abs (a - b) < 0.0001)
VC++ gibt beim obigen Programm a und b sind ungleich : a = 1, b = 1.00005 aus; nach dem Abändern des Vergleichs wie angegeben wird die Ausgabe a und b sind gleich : a = 1, b = 1.00005 erzeugt.
22) Probieren Sie das Programm ruhig mit Ihrem bevorzugten Übersetzer aus!
53
Vergleiche von Fließkommazahlen sind problematisch
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
3.4.2.4 Datentyp für Wahrheitswerte
3.4.2.4.1 Wertebereich
In C++ existiert ein Datentyp für Wahrheitswerte, genannt bool. Variablen und Konstanten von diesem Typ können nur zwei Werte annehmen, wahr und falsch. Diese werden durch die Konstanten true und false repräsentiert.
3.4.2.4.2 Operationen
Boolesche Werte lassen sich vergleichen, anordnen und mit logische Operatoren verknüpfen. Der Vergleich zweier Wahrheitswerte ist trivial; weiterhin gilt false < true; die Bedeutung der Operatoren >, <= und >= ist dann leicht abzuleiten.
Unterstützte logische Operationen sind logisches UND (& und &&), logisches ODER (| und ||) und logisches NICHT (!) mit den üblichen Bedeutungen. Der Unterschied zwischen den beiden Versionen des UND- und ODER-Operators liegt darin begründet, dass die erste Version immer beide Argumente auswertet und dann das Ergebnis ermittelt, während die zweite Version eventuell schon vorher aufhört. Letzteres tritt ein, wenn der erste Operand des &&-Operators bereits falsch ist; da das Ergebnis unabhängig vom Wert des zweiten Operanden auf jeden Fall falsch ist, wird der zweite Operand gar nicht erst auswertet. Analoges gilt für den ||-Operator, wenn der erste Operand bereits wahr ist. Bei den &&- und ||-Operatoren spricht man wegen dieser „Abkürzungen“ auch von kurzgeschlossener Auswertung, bei & und | von vollständiger Auswertung.
Der letzte und vielleicht interessanteste Operator auf Wahrheitswerten ist der ?:-Operator. Er stellt eine funktionale Variante der Fallunterscheidung (if-Anweisung, s. Abschnitt 3.5.3.1) dar und hat die Form:
Bedingung ? Wenn-Ausdruck : Sonst-Ausdruck
Der Operator funktioniert folgendermaßen: Wenn Bedingung zum Zeitpunkt der Auswertung wahr ist, liefert der Operator den Wenn-Ausdruck zurück, ansonsten den Sonst-Ausdruck. Damit das funktionieren kann, müssen diese beiden Ausdrücke Typ-verträglich sein.
Beispiel: Nach dem Ausführen des folgenden Programm-Teils1 int alter = 18;2 string anrede = alter < 18 ? "du" : "Sie";
enthält die Variable anrede die Zeichenkette "Sie" aus dem Sonst-Teil, weil die Bedingung zu false ausgewertet wurde. Am Ergebnis ändert sich nichts, wenn die letzte Zeile statt dessen
2 string anrede = alter >= 18 ? "Sie" : "du";
heißt. Hier sind lediglich der Wenn- und Sonst-Teil vertauscht und die Bedingung logisch verneint. Die Bedingung wird folglich zu true ausgewertet, und der Wenn-Teil des Operators wird ausgewählt.
54
bool, true und false
Wahrheitswerte lassen sich vergleichen und ordnen
Boolesche Operatoren; kurzgeschlossene und vollständige Auswertung
Operator zur Fallunterscheidung
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Der ?:-Operator ist der einzige ternäre Operator (Operator mit drei Argumenten) in C++.
3.4.2.5 Der leere DatentypC++ kennt auch den leeren Datentyp namens void. Hier macht eine Aufteilung in Wertebereich und Operationen keinen Sinn, weil es weder Werte noch Operationen gibt. Wozu dann dieser Datentyp, werden Sie sich fragen? Es gibt diesen Datentyp hauptsächlich deshalb, weil C++ nicht zwischen Prozeduren und Funktionen unterscheidet. Jede Funktion benötigt jedoch bei der Definition einen Rückgabetyp. Da Prozeduren aber nichts zurückgeben, muss dies geeignet angezeigt werden – eben mit Hilfe dieses Datentyps.
In vielen anderen Kontexten ist dieser Datentyp verboten; es gibt insbesondere keine Variablen oder Konstanten dieses Typs. Ausdrücke dieses Typs sind jedoch möglich; wenn eine Prozedur aufgerufen wird, ist das Ergebnis (logischerweise) vom Typ void. Dies kann ausgenutzt werden, um den Operator zur Fallunterscheidung (3.4.2.4) für Prozedur-Aufrufe zu nutzen, wie das folgende Programm zur Schau stellt:
1 /*** Beispiel void.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 #include <string>6 using namespace std;78 void begruesse (string anrede)9 {10 cout << "Hallo " << anrede << "!" << endl;11 }1213 int main ()14 {15 cout << "Alter: ";16 int alter;17 cin >> alter;18 // je nach Alter wird entweder „Sie“ oder „du“ als Argument an begruesse übergeben19 alter >= 18 ? begruesse ("Sie") : begruesse ("du");20 return 0;21 }
3.4.3 Zusammengesetzte DatentypenNeben den beschriebenen fundamentalen Datentypen existieren in C++ Konstrukte, um daraus abgeleitete Typen zu erstellen. Diese abgeleiteten Typen bestehen nicht nur aus einem (oder mehreren) Schlüsselwörtern, sondern erfordern den kombinierten Einsatz von Operatoren, Interpunktionszeichen und/oder Schlüsselwörtern. Diese deshalb zusammengesetzt genannten Datentypen werden in den nächsten Abschnitten näher erläutert.
55
der void-Datentyp
Einschränkungen und Verwendung
abgeleitete Datentypen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
3.4.3.1 FunktionstypenFunktionen haben Sie bereits kennen gelernt. Der Operator, der in Deklarationen Funktionen bzw. Funktionstypen „erzeugt“, sind die beiden runden Klammern (()). Beispiel:
1 // dies ist eine Variable vom Typ int2 int anzahl;3 // dies ist eine Funktion ohne Parameter, die einen int-Wert zurückliefert4 int gibAnzahl ();
Allgemein haben Funktionstypen den Aufbau:
Rückgabetyp ( [Parameter-Liste] )Beispiel: Die obige Funktion gibAnzahl hat den Funktionstyp int().
Jede Funktion hat einen entsprechenden Funktionstyp. Die einzige sinnvolle Operation auf Funktionen ist der Funktionsaufruf, den Sie bereits in einigen Beispiel-Programmen kennen gelernt haben. Weitere Informationen dazu und zur Definition von Funktionen finden Sie in Abschnitt 3.6.
Funktionstypen werden Sie wahrscheinlich niemals direkt benutzen. Sie werden benötigt, wenn in einem Programm Verweise auf Funktionen gespeichert, verändert und weitergegeben werden, um beispielsweise Funktionen während der Laufzeit auszutauschen. Da die objektorientierten Fähigkeiten von C++ hier dem Programmierer wesentlich bessere Werkzeuge an die Hand legen, werden Funktionstypen und Funktionszeiger (3.4.3.3) bzw. Funktionsreferenzen (3.4.3.2) in C++ wenig benutzt.
Wenn Sie neugierig sind, wie Funktionstypen verwendet werden können, schauen Sie sich das folgende Programm an:
1 /*** Beispiel funktionstyp1.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // addiert beide Operanden und gibt das Ergebnis zurück7 int add (int a, int b) {return a + b;}8 // subtrahiert beide Operanden voneinander und gibt das Ergebnis zurück9 int sub (int a, int b) {return a - b;}
10 // führt eine Berechnung mit beiden Operanden über die übergebene Funktion aus;11 // die Funktion muss den Typ int (int, int) besitzen12 int rechne (int a, int b, int op (int, int))13 {14 // rufe übergebene Funktion mit beiden Operanden auf15 return op (a, b);16 }1718 int main ()19 {20 // führe ein paar Berechnungen durch21 cout << "2 + 5 = " << rechne(2, 5, add) << endl;22 cout << "3 - 4 = " << rechne(3, 4, sub) << endl;23 return 0;24 }
Die interessanten Stellen sind einerseits die Definition der Funktion rechne in Zeile 12 und die Aufrufe der Funktionen in den Zeilen 15, 21 und 22. Die Funktion rechne
56
Funktionstypen
Aufbau von Funktionstypen
Operationen auf Funktionen
Funktionstypen sind eher unauffällig
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
bekommt als dritten Parameter eine Funktion vom Typ int (int, int), die in Zeile 15 wie eine normale Funktion aufgerufen wird. In den Zeilen 21 und 22 werden die Funktionen add und sub „ganz normal“ an die Funktion rechne als Argumente übergeben. Das ist möglich, weil die Typen der übergebenen Funktionen und der Typ des Parameters op übereinstimmen.
3.4.3.2 ReferenzenReferenzen sind versteckte Verweise auf andere (oder alternative Namen von anderen) Entitäten. Der zugehörige Typ hat die Form:
Typ &Es gibt keinerlei Operationen (außer der Initialisierung), die auf einer Referenz ausgeführt werden können: Alle Operationen werden immer mit der Entität durchgeführt, auf welche die Referenz verweist. Referenzen sind somit keine „Objekte“ im eigentlichen Sinn. Deshalb sind es „versteckte“ Verweise, weil Sie die Indirektion im Quelltext nicht bemerken (außer am & in der Deklaration).
Weil es keine leeren Verweise geben darf, müssen Referenzen immer initialisiert werden. Das Weglassen der Initialisierung ist ein Fehler.
Beispiel:1 /*** Beispiel ref.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int main ()7 {8 // dies ist eine normale Ganzzahl-Variable9 int anzahl = 5;10 // dies ist eine Referenz auf diese Variable11 int &refAnzahl = anzahl;12 // gib die Werte beider Entitäten aus13 cout << anzahl << " " << refAnzahl << endl;14 // erhöhe anzahl15 ++anzahl;16 // gib die Werte beider Entitäten aus17 cout << anzahl << " " << refAnzahl << endl;18 // erhöhe refAnzahl19 ++refAnzahl;20 // gib die Werte beider Entitäten aus21 cout << anzahl << " " << refAnzahl << endl;22 return 0;23 }
Dieses Programm generiert die folgende Ausgabe:5 56 67 7
Wie Sie sehen, ist es völlig egal, ob Sie mit der Referenz oder der zugrunde liegenden Variable arbeiten.23 Jetzt fragen Sie sich sicherlich, wofür Referenzen dann über
23) Deshalb gibt es auch schlaue Übersetzer, die in einem solchen Fall die Referenz „wegoptimieren“ und alle Operationen direkt auf der Entität durchführen, auf welche die Referenz verweist.
57
Referenzen sind versteckte Verweise
Operationen auf Referenzen
Initialisierung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
haupt gut sind. Um es kurz zu machen: Sie haben ihre Daseinsberechtigung, allerdings ist es an dieser Stelle schwierig, ein adäquates Beispiel zu zeigen. Referenzen werden hauptsächlich bei der Parameter-Übergabe bei Funktionen (3.6.4) und bei Elementen von Klassen (4.4.5.1) benutzt, deswegen werden sie dort in ihrem „natürlichen Umfeld“ näher erläutert. Dann werden Sie auch den Sinn und Zweck von Referenzen kennen lernen.
Java-Programmierer aufgepasst: Im Gegensatz zu C++ ist in Java das Referenz-Konzept implizit, d. h. in Java haben Sie keine Kontrolle darüber, wann über Referenzen und wann direkt auf Objekte bzw. Werte zugegriffen wird. Während in Java auf Objekte von Klassen immer über Referenzen und auf Elemente fundamentaler Datentypen immer direkt zugegriffen wird, haben Sie in C++ völlige Freiheit darüber. Insbesondere können Sie in C++ Referenzen auf Objekte fundamentaler Datentypen besitzen, was in Java nur über Objekte der entsprechenden Wrapper-Klassen (java.lang.Integer statt int, java.lang.Character statt char etc.) möglich ist.
Zusätzlich gibt es in Java sehr wohl Operationen, die sich auf die Referenzen „an sich“ und nicht auf die referenzierten Objekte bezieht: die Identitäts-Vergleiche und die Zuweisung. Während der Java-Code
1 Integer i1 = new Integer (5);2 Integer i2 = new Integer (5);3 Integer i3 = i1;4 Integer i4 = i2;5 System.out.println (i3 == i4 ? "gleich" : "ungleich");
ungleich ausgibt, da die verglichenen Referenzen auf unterschiedliche Objekte verweisen, produziert der äquivalente (!?) C++-Code
1 int i1 = 5;2 int i2 = 5;3 int &i3 = i1;4 int &i4 = i2;5 cout << (i3 == i4 ? "gleich" : "ungleich") << endl;
die Ausgabe gleich, da in C++ die Inhalte der referenzierten Variablen verglichen werden und diese gleich sind. Ähnlich verhält es sich mit der Zuweisung: Während in Java die Zuweisung an eine objektwertige Variable diese an ein neues Objekt bindet, wird in C++ der Inhalt des Objekts kopiert.
Referenzen können in C++ nicht neu gebunden werden; das bedeutet, dass das Ziel einer Referenz nicht verändert werden kann. Wenn Sie dies benötigen, müssen Sie auf Zeiger (3.4.3.3) ausweichen.
3.4.3.3 ZeigerZeiger sind ebenfalls Verweise wie Referenzen, jedoch sind sie „echte“ Objekte in dem Sinne, dass sie ausgelesen und modifiziert werden können. Man könnte sie als veränderbare Referenzen bezeichnen. Sie haben die Form:
Typ *Zeiger unterstützen eine Vielzahl an Operationen. Neben der Zuweisung, um das Ziel eines Zeigers zu verändern, kann man Zeiger miteinander auf Gleichheit (==) und Ungleichheit (!=) vergleichen sowie mit dem !-Operator prüfen, ob ein Zeiger ungleich dem Null-Zeiger (4.5.5.2) ist. Im Gegensatz zu Referenzen, die mit dem referenzierten Objekt initialisiert werden, muss bei der Initialisierung oder Zuweisung
58
Java-Referenzen: Unterschiede und Gemeinsamkeiten
Zeiger sind „echte“ Verweise
Operationen auf Zeigern; Adressen von Objekten
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
von Zeigern die Adresse des Objekts übergeben werden. Die Adresse können Sie sich ruhig bildlich vorstellen: sie stellt den Ort dar, an dem die betreffende Entität zu finden ist. Die Adresse eines Objekts wird mit Hilfe des &-Operators bestimmt, der dem entsprechenden Objekt vorangestellt wird.
Der Operator & zum Bestimmen der Adresse eines Objekts hat nichts, aber auch wirklich gar nichts mit dem Typ-Modifizierer & zu tun, der zum Erzeugen von Referenz-Typen verwendet wird. Die Ähnlichkeit ist dennoch nicht grundlos: Die Verwendung des & bei Referenz-Typen könnte als Hinweis darauf verstanden werden, dass Referenzen etwas ähnliches wie Zeiger sind und intern Adressen abspeichern.
Wenn auf den Inhalt des referenzierten Objekts zugegriffen werden soll, muss der Zeiger mit Hilfe des Operators * dereferenziert werden, der ebenfalls dem entsprechenden Ausdruck vorangestellt wird. Der Operator * ist also die Umkehr-Operation zum Adress-Operator &. Daneben gibt es noch den Operator ->, der aber erst bei Objekten einer Klasse von Bedeutung wird (4.4.5.3).
Da das alles für Sie am Anfang wahrscheinlich ziemlich harter Tobak ist, schreiben wir das obige Referenz-Beispiel so um, dass statt Referenzen Zeiger eingesetzt werden:
1 /*** Beispiel ptr.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int main ()7 {8 // dies ist eine normale Ganzzahl-Variable9 int anzahl = 5;10 // dies ist ein Zeiger auf diese Variable (beachten Sie den &-Operator)11 int *ptrAnzahl = &anzahl;12 // gib die Werte beider Entitäten aus (beachten Sie den *-Operator)13 cout << anzahl << " " << *ptrAnzahl << endl;14 // erhöhe anzahl15 ++anzahl;16 // gib die Werte beider Entitäten aus17 cout << anzahl << " " << *ptrAnzahl << endl;18 // erhöhe das Ziel von ptrAnzahl (beachten Sie die Reihenfolge der Operatoren!)19 ++*ptrAnzahl;20 // gib die Werte beider Entitäten aus21 cout << anzahl << " " << *ptrAnzahl << endl;22 return 0;23 }
Die Ausgabe ist dieselbe wie in dem Beispiel mit Referenzen.
Zeiger werden nicht nur benutzt, um Verweise auf lokale (3.3.4) Objekte zu speichern, sondern vor allem im Zusammenhang mit dynamischer Speicherverwaltung. Hierzu lesen Sie bitte den Abschnitt 4.5.5.
Der korrekte Umgang mit Zeigern ist nicht einfach. Es wird sogar als so schwierig angesehen, dass viele Programmiersprachen entweder kein Zeiger-Konzept besitzen (ursprüngliche BASIC-Dialekte wie GW-BASIC) oder es vor dem Programmierer „verstecken“ (Java, Visual BASIC). Gleichwohl erfordern viele komplexere Datenstrukturen wie Listen oder Bäume Zeiger oder ähnliche Konzepte zur dynamischen Verwaltung von Objekten. Aus diesem Grund werden wir auf Zeiger näher in Abschnitt 4.5.5.4 ein
59
Dereferenzierung von Zeigern
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
gehen, in dem eine einfach verkettete Liste zur Verwaltung dynamisch allozierter Objekte entwickelt wird.
Merksatz 7: Vermeide den unkontrollierten Umgang mit Zeigern!
3.4.3.4 KlassenKlassen (oder Stukturen bzw. Records) sind benutzerdefinierte Datentypen. Da Klassen in C++ die Grundlage objektorientierter Programmierung darstellen, müssen wir Sie auf Kapitel 4 vertrösten.
3.4.3.5 FelderC++ enthält in den Sprachkern eingebaute Felder. Beispiel:
1 /*** Beispiel feld.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int main ()7 {8 // definiere die Anzahl der Elemente im Feld9 const int anzahl = 10;
10 // definiere ein Feld aus int-Werten mit „anzahl“ Elementen und initialisiere sie11 int zahlen [anzahl] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };12 // gib alle Elemente aus13 for (int element = 0; element < anzahl; ++element)14 {15 cout << "Element " << element << " = "16 << zahlen [element] << endl;17 }18 return 0;19 }
Wie Sie erkennen können, werden Felder wie normale Variablen deklariert, besitzen jedoch zusätzlich eine (konstante) Größenangabe in eckigen Klammern, welche die Anzahl der Elemente in dem Feld angibt (Zeile 11). Wenn Sie bereits bei der Definition das Feld mit Werten belegen wollen, geht dies mit einer speziellen Syntax, wobei Sie innerhalb geschweifter Klammern die Initialwerte der Feldelemente angeben können (auch Zeile 11). Ebenfalls über eckige Klammern kann auf die Elemente zugegriffen werden, wobei ein entsprechender Index innerhalb der Klammern steht, der übrigens nicht konstant sein muss (Zeile 16). Felder können wie andere Variablen auch mit beliebigen Typen und Modifizierern wie const verknüpft werden, wobei sich beides auf die Elemente des Feldes bezieht. Ein const int-Feld ist also ein Feld aus const int-Elementen.
Diese Felder sind jedoch in ihrer Funktionsweise ziemlich beschränkt:
• Die Länge muss bei der Definition festgelegt werden und kann sich während des Programmablaufs nicht ändern.
• Die Länge eines Feldes muss bereits zur Übersetzungszeit feststehen und kann nicht zur Laufzeit ausgerechnet werden.
60
Klassen oder Strukturen
eingebaute Felder
Beschränkungen eingebauter Felder
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
• Sie können keine Felder von einer Funktion zurückgeben lassen (nur Zeiger auf Felder).
• Beim Zugriff auf Elemente wird der Index nicht gegen die Länge des Feldes geprüft; wenn Sie sich beim Zugriff „verrechnet“ haben, kann dies zum Programmabsturz führen!
Die Beschränkungen liegen allesamt daran, dass Felder in C++ keine „echten“ Objekte sind. Deshalb stellen wir in Abschnitt 8.3 eine Klasse der C++-Standard-Bibliothek vor, welche die obigen Beschränkungen nicht hat und wesentlich sicherer und flexibler zu benutzen ist.
3.4.3.6 Andere zusammengesetzte DatentypenC++ kennt noch weitere benutzerdefinierte Datentypen, die aber in diesem Skript keine große Rolle spielen und deshalb hier nur kurz der Vollständigkeit halber erwähnt werden:
• Aufzählungen: In C++ ist es möglich, eine Gruppe von symbolischen Konstanten (= Aufzählung) zu definieren und Typ-sicher zu benutzen. Beispiel:
1 /*** Beispiel enum.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 #include <string>6 using namespace std;78 // Definiere einen Aufzählungstyp zur Unterscheidung des Geschlechts9 enum Geschlecht10 {11 maennlich, // wenn nicht initialisiert, bekommt die erste Konstante den Wert 012 weiblich, // jede folgende Konstante bekommt den Wert der vorherigen + 113 unbekannt = 99 // man kann die Konstanten auch direkt initialisieren14 };15 // ab hier sind die Konstanten maennlich, weiblich und unbekannt verfügbar1617 // Gibt „Hallo Herr <name>“ oder „Hallo Frau <name>“ aus, je nach Geschlecht;18 // wenn „unbekannt“ als Geschlecht übergeben wird, gibt die Funktion „Hallo <name>“ aus19 void begruesse (string name, Geschlecht geschlecht)20 {21 if (geschlecht == maennlich)22 cout << "Hallo Herr " << name << endl;23 else if (geschlecht == weiblich)24 cout << "Hallo Frau " << name << endl;25 else26 cout << "Hallo " << name << endl;27 }2829 int main ()30 {31 // erfrage Name32 cout << "Name: ";33 string name;34 cin >> name;35 // erfrage Geschlecht36 cout << "Geschlecht (m/w): ";37 char geschlecht;
61
Aufzählungen sind symbolische Konstantengruppen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
38 cin >> geschlecht;39 // die switch- und break-Anweisungen lernen Sie im Abschnitt 3.5.3.2 kennen40 switch (geschlecht)41 {42 case 'm' :43 case 'M' :44 begruesse (name, maennlich);45 break;46 case 'w' :47 case 'W' :48 begruesse (name, weiblich);49 break;50 default :51 begruesse (name, unbekannt);52 break;53 }54 return 0;55 }
• Variante Records/Unions: C++ kennt, ähnlich Pascal, Strukturen, die während der Laufzeit zu verschiedenen Zeiten verschieden getypte Werte enthalten können (also z. B. mal eine Zeichenkette und mal eine Zahl). Diese Datenstruktur macht in C++ nur in sehr speziellen Anwendungsgebieten Sinn und wird deshalb nicht weiter erläutert.
3.4.4 Konstanten(Benannte) Konstanten sind Objekte wie Variablen, jedoch kann ihr Inhalt nach der Initialisierung nicht verändert werden. Konstanten werden wie Variablen definiert, nur wird ihrem Typ zusätzlich das Schlüsselwort const als Typ-Modifizierer vorangestellt:
1 bool habeGeburtstag (); // wird weiter unten benötigt23 // Das ist eine Ganzzahl-Variable4 int alter = 18;5 if (habeGeburtstag ())6 alter++; // Variablen können verändert werden78 // Das ist eine Fließkommazahl-Konstante9 const double Pi = 3.141592654;
10 Pi = 4; // Fehler: Konstanten können nicht verändert werden
Konstanten sollten immer dann verwendet werden, wenn der Inhalt wirklich nicht verändert werden soll. Das kommt häufiger vor, als Sie vielleicht denken. Beispielsweise sollte im ersten C++-Beispiel in Abschnitt 2.3 die Zeile 25
25 string meinName = liesName ();
so heißen:25 const string meinName = liesName ();
Dies ist besser, da meinName im restlichen Code nicht verändert wird (und verändert werden soll).
62
Unions
Konstanten
Konstanten vs. Variablen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
C++-Konstanten entsprechen in etwa final-Attributen in Java. Allerdings ist zu beachten, dass final-Attribute innerhalb von Klassen anders initialisiert werden: In Java sind Zuweisungen im Konstruktor sowie Initialisierung in der Klassen-Definition erlaubt, in C++ ist eine Initialisierungsliste im Konstruktor nötig. Näheres hierzu steht im Abschnitt 4.5.1.
Das Verwenden von benannten Konstanten hat mehrere Vorteile:
(1) Lesbarkeit: Sie sehen im Quelltext sofort, welche Werte veränderlich sind und welche nicht. Dadurch bekommen Sie einen besseren Überblick über die Struktur des betrachteten Programmteils. Besonders wertvoll ist const bei Referenz-Parametern (3.6.4), weil dann bereits an der Funktions- oder Operations-Schnittstelle erkannt werden kann, ob die Funktion bzw. Operation die übergebenen Argumente verändert oder nicht; siehe hierzu auch Abschnitt 4.4.7.1.
(2) Wartbarkeit: Auch Werte, die anfangs als konstant erachtet wurden, können sich mit der Zeit ändern (übliche Beispiele sind die Mehrwertsteuer o. ä.) Wurde der Wert in der Entwicklungsphase als benannte Konstante definiert und später über den Namen verwendet, so lässt sich eine Änderung sehr leicht durchführen, indem einfach der Initialisierungsausdruck der Konstante geändert wird. Haben Sie jedoch nicht benannte Zahlen-Konstanten quer über den Quelltext verteilt, müssen Sie jede einzelne Verwendung der entsprechenden Konstante suchen und ändern. Das ist nicht nur mühselig, sondern auch fehlerträchtig, weil Sie vielleicht nicht alle Stellen finden.
(3) Fehlerprüfung: Der Übersetzer kann ihre Absichten besser „nachprüfen“. Nach dem Prinzip „vier Augen sehen mehr als zwei“ ist es möglich, dass Sie eine Variable nicht verändern möchten aber es dennoch (aus Versehen) tun. Haben Sie vorher das betreffende Objekt durch const zur Konstante gemacht, fängt der Übersetzer die inkorrekte Zuweisung ab und generiert eine Fehlermeldung. Ohne const kann es passieren, dass Sie diesen Fehler erst viel später merken (wahrscheinlich, wenn es bereits zu spät ist und das Programm bereits beim Kunden ist.)
Merksatz 8: Verwende symbolische Konstanten!
Eine Alternative zum const-Modifizierer sind Aufzählungen (3.4.3.6), die ebenfalls benannte Konstanten darstellen. Allerdings sind solcherart definierte Konstanten immer von einem Zahlen-Typ (int oder größer, je nach Wertebereich der Konstanten-Werte), so dass diese Möglichkeit für Konstanten anderer Typen (etwa Zeichen-, Zeichenketten-, oder Objekt-Konstanten) weg fällt.
3.4.5 Typ-Verträglichkeit und Konvertierungen
3.4.5.1 Typ-Verträglichkeit und implizite Typ-KonvertierungenAn vielen Stellen in C++ ist es erforderlich, dass ein Ausdruck den „passenden“ Typ hat. In einer Sprache wie C++, die über viele verschiedene fundamentale und zusammengesetzte Typen verfügt, wäre es ein großes Hindernis, wenn der „passende“ Typ
63
gute Gründe, Konstanten zu verwenden
Typ-Verträglichkeit darf nicht zu eng gefasst sein
Aufzählungen als Alternative
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
lediglich exakt derselbe Typ sein müsste. In einem solchen Fall wäre sogar das folgende C++-Programm fehlerhaft:
1 // --- hypothetisches C++ mit strikten Typverträglichkeits-Regeln ---2 int main ()3 {4 short retCode = 0; // wäre falsch: int-Konstante → short-Variable5 return retCode; // wäre falsch: short-Ausdruck → int-Rückgabetyp6 }
Um solche Probleme zu vermeiden, definiert C++ verschiedene Regeln zur Typ-Verträglichkeit und Typ-Umwandlung. In diesem Abschnitt wollen wir nur die Verträglichkeit zwischen fundamentalen (und daraus zusammengesetzten) Typen untersuchen; die Verträglichkeit zwischen verschiedenen Klassen-Typen wird in Abschnitt 4.6.3 behandelt.
Zuerst wollen wir erarbeiten, in welchem Kontext eine Typ-Verträglichkeit erforderlich ist:
(1) Initialisierung von Konstanten oder Variablen (einschließlich Belegung von Parametern mit Argumenten)
(2) Zuweisung von Ausdrücken zu Variablen
(3) arithmetische Operationen
Wir wollen die ersten beiden Fälle gemeinsam behandeln, da sie fast identisch sind. Wir bezeichnen den Ausdruck, mit dem initialisiert wird oder der zugewiesen wird, als Quelle und das Objekt, das initialisiert oder verändert wird, als Ziel. Dementsprechend gibt es einen Quell- und einen Ziel-Typ. Nun versucht der Übersetzer, die
64
Wann wird eine Konvertierung benötigt?
Konvertierung bei Initialisierung und Zuweisung
Zieltyp →
Quelltyp ↓
Ganzzahl Fließkommazahl Zeichen Wahrheitswert
Ganzzahl eventuell nicht werterhaltend, s. u.
Konvertierung, eventuell mit Genauigkeitsverlust
Konvertierung in das Zeichen an der entsprechenden Position im Zeichensatz
0 → false, alles andere → true
Fließkommazahl
Konvertierung durch Abschneiden der Nachkommastellen; Fehler wenn Ergebnis zu groß
Konvertierung durch Abschneiden der Nachkommastellen; Fehler wenn Ergebnis zu groß
wie Konvertierung erst in eine Ganzzahl und dann in ein Zeichen; Fehler wenn Ergebnis zu groß
0 → false, alles andere → true
Zeichen Konvertierung in die Position des Zeichens im Zeichensatz
wie links Konvertierung von char nach wchar_t eventuell nicht werterhaltend
wie Konvertierung erst in eine Ganzzahl und dann in einen Wahrheitswert
Wahrheitswert
false → 0true → 1
false → 0true → 1
wie Konvertierung erst in eine Ganzzahl und dann in ein Zeichen
keine Konvertierung notwendig
Tabelle 12: Konvertierungen zwischen fundamentalen Datentypen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Quelle in einen Ausdruck des Ziel-Typs zu konvertieren. Diese Konvertierung ist trivial, wenn Quell- und Ziel-Typ identisch sind, ansonsten hat er mehr oder weniger etwas zu tun. Tabelle 12 fasst die möglichen Konvertierungen zusammen. Dabei gilt: je „röter“ das entsprechende Feld, umso gefährlicher ist die Konvertierung. Grün bedeutet „es kann nichts schiefgehen“, gelb bedeutet „hängt von den Werten ab, kann zu Verlusten, aber nicht zu Fehlern führen“ und rot bedeutet „wenn Sie Pech haben, kann zur Laufzeit des Programms ein Programmabsturz auftreten“.
Im Gegensatz zu anderen Programmiersprachen wie Java sind Konvertierungen zwischen Ganzzahl-Typen erlaubt, bei denen die Quelle nicht im Wertebereich des Zieltyps liegt. In einem solchen Fall wird die Quelle einfach geeignet „verkleinert“ (gemäß Modulo-Arithmetik). Dies gilt aber nicht für Konvertierungen zwischen Fließkomma-Typen oder zwischen Fließkomma- und Ganzzahl-Typ: wenn Sie dabei einen für den Zieltyp zu großen Wert erhalten, kommt es zu einem Programmfehler.
Merksatz 9: Meide gefährliche Typ-Umwandlungen!
Allgemein gilt, dass zwischen den Datentypen eine (partielle) Ordnung besteht, die in Abbildung 16 dargestellt wird. Zum Verständnis: Wenn eine Verbindung (direkt oder indirekt) von einem Typ zum anderen existiert, bedeutet dies, dass Ausdrücke des Quelltyps uneingeschränkt ohne Verluste in den Zieltyp konvertiert werden können. Diese Konvertierungen werden als sicher bezeichnet, weil sie nie zu Wertverlust, Verlust der Genauigkeit oder Programmabbrüchen führen können.
Wenn zusätzliche Annahmen gemacht werden können, etwa ob der Datentyp int dem Datentyp short oder dem Datentyp long entspricht, sind eventuell weitere Konvertierungen möglich.
In VC++ gilt beispielsweise, dass die Datentypen int und long dieselbe interne Repräsentation und somit denselben Wertebereich besitzen. Ebenso verhält es sich mit den vorzeichenlosen Varianten unsigned und unsigned long, mit double und long double sowie mit wchar_t und unsigned short. Des Weiteren ist der Typ char vorzeichenbehaftet und entspricht somit signed char. Schließlich ist der Wertebereich des Typs char echt kleiner als der von short, und der letztere echt kleiner
65
Verlustbehaftete Ganzzahl-Konvertierungen
Anordnung der fundamentalen Datentypen
Anordnung der fundamentalen Datentypen in VC++
Abbildung 16: Verlustfreie Konvertierungen in C++
unsignedlong
unsignedint
unsignedshort
unsignedchar
longintshortsignedchar
float longdoubledouble
char wchar_tbool
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
als der von long. Daraus lassen sich die folgenden sicheren Konvertierungen für VC++ ableiten (Abbildung 17). Beachten Sie aber, dass Ihre Programme nicht von diesem Wissen abhängen sollten, wenn Sie vorhaben, ihre Programme eines Tages unter einem anderen Übersetzer oder auf einer anderen Hardware-Architektur ablaufen zu lassen!
Arithmetische Operationen sind im Kontext von Konvertierungen etwas besonderes, weil sie zwei Operanden in ein Resultat überführen. Wir haben es also mit zwei Quellen (und Quell-Typen) zu tun. Ohne tiefer in die Details zu gehen, halten wir an dieser Stelle fest, dass beide Operanden in den Typ konvertiert werden, der beide Quell-Typen umfasst, ohne dass es zu Wertverlusten kommt. Die genauen Regeln sind ziemlich komplex und langweilig; wenn Sie neugierig sind, schauen Sie in Ihrem Lieblings-C++-Buch nach...
Die Verträglichkeit von Zeigern gehorcht ebenfalls bestimmten Regeln. Es ist am einfachsten, wenn man sagt, dass die Typen zweier Zeiger, die einander zugewiesen werden sollen, identisch sein müssen. Danach kann man die Ausnahmen studieren:
• Der Ziel-Typ kann mehr „const“ sein. Beispiel:int i = 1;const int *pi = &i; // OK, int* --> const int* ist gültige Konvertierung
• Der Ziel-Typ kann allgemeiner sein. Dies ist erst im Kontext von Klassen und Spezialisierung verständlich (4.6.3); es ist nur der Vollständigkeit halber bereits an dieser Stelle erwähnt.
Es gibt noch weitere Einschränkungen, die aber so speziell sind, dass wir sie hier nicht weiter behandeln.
3.4.5.2 Explizite Typ-Konvertierungen (Casting)Es gibt in C++ die Möglichkeit, die Konvertierung eines Ausdrucks von einem Typ in einen anderen explizit anzugeben. Dies wird im Allgemeinen Casting bzw. Casten genannt; ein Cast ist die Bezeichnung für eine explizite Typ-Umwandlung. Die Syn
66
Konvertierung bei arithmetischen Operationen
Verträglichkeit von Zeigern
explizite Typ-Umwandlungen
Abbildung 17: Verlustfreie Konvertierungen in VC++
unsignedlong
unsignedint
unsignedshort
unsignedchar
longintshortsignedchar
float longdoubledouble
char wchar_tbool
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
tax für eine solche explizite Typ-Umwandlung ist einfach: Man schreibt den gewünschten Typ in runden Klammern vor den Ausdruck. Beispiel:
1 int i = 1;2 char c = (char) i; // int-Wert in Typ char explizit umgewandelt
In Zeile 2 wird der Wert der int-Variable i in den Datentyp char umgewandelt.
Die gezeigte Syntax ist die einfachste, leider auch die für den Übersetzer und den Menschen am schwersten „auffindbare“: Da die Sprache C++ vor Klammern nur so „strotzt“, sind explizite Umwandlungen in der oben gezeigten Klammern-Syntax nur schwer im Quelltext auszumachen. Deshalb wurden C++ vier weitere Cast-Operatoren spendiert:
• static_cast < Ziel-Typ > ( Ausdruck ): Dieser Operator dient expliziten Typ-Umwandlungen, wie sie im obigen Absatz beschrieben wurden.
• const_cast < Ziel-Typ > ( Ausdruck ): Dieser Operator dient einzig und allein dem Hinzufügen oder Weglassen des const-Qualifizierers. Weil das Weglassen von const fast immer gefährlich ist und das Hinzufügen vom Übersetzer nach Bedarf selbsttätig durchgeführt wird, wird dieser Operator nur selten benutzt.
• dynamic_cast < Ziel-Typ > ( Ausdruck ): Dieser Operator wird nur im Zusammenhang mit polymorphen Zeigern und Referenzen benutzt. Da mit diesem Operator viel Unfug angestellt werden kann und er in 99% aller Fälle nicht benötigt wird, wird er in diesem Skript nicht weiter beschrieben.
• Ein vierter Operator, reinterpret_cast, wird nur in so speziellen Situationen benötigt, dass in dem Skript nicht weiter auf ihn eingegangen wird.
Die obige Klammer-Syntax für den Cast-Operator vereinigt die Funktionalität der drei Operatoren static_cast, const_cast und reinterpret_cast, allerdings mit den genannten Nachteilen.
Generell ist zu sagen, dass explizite Typ-Umwandlungen „von Übel“ sind: Wenn der umzuwandelnde Ausdruck nicht den richtigen Typ hat, sollten Sie in der Regel prüfen, warum dies so ist, und die Ursache dementsprechend ergründen. Meistens stellt sich heraus, dass ein Cast entweder nicht notwendig oder gefährlich ist. Es gibt nur wenige Situationen, in denen der Programmierer mehr über den Typ eines Ausdrucks „weiß“ als der Übersetzer; nur in solchen Situationen ist es angebracht, eine explizite Typ-Umwandlung vorzunehmen. Allgemein gilt, dass die Notwendigkeit, einen Ausdruck explizit umzuwandeln, erheblich geringer ist als in vergleichbaren Sprachen wie C und Java; selbst in C++ ist sie im Laufe der Jahre durch das Hinzufügen weiterer, mächtiger Sprachmittel wie Kovarianz (4.6.4.1) und Schablonen (7.2) erheblich geringer geworden.
3.4.6 Typ-AliaseC++ bietet die Möglichkeit, für bestehende Typen (unter Umständen neue) Namen einzuführen. Dies kann nützlich sein,
(1) wenn der zu benennende Typ ein komplexer zusammengesetzter Typ ist oder
67
Typ-Aliase zur Vereinfachung und Abstraktion
Syntax macht es nicht leicht, Casts aufzuspüren
alternative Cast-Operatoren
explizite Typ-Umwandlungen sind meistens fraglich
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
(2) wenn einfach durch eine weitere Indirektion eine Abstraktion eingeführt werden soll.
Betrachten wir den ersten Punkt. In dem Beispiel für Funktionstypen in Abschnitt 3.4.3.1 steht in Zeile 12:
12 int rechne (int a, int b, int op (int, int))Es macht Sinn, dem etwas komplexen Funktionstyp einen eigenen Namen zu geben. Dies führen wir mit Hilfe einer typedef-Definition durch:
12 typedef int Operation (int, int);13 int rechne (int a, int b, Operation op)
Betrachten Sie die neue Zeile 12. Durch das Schlüsselwort typedef wird der Bezeichner Operation ein Typ-Name, und zwar für den (bis dato unbenannten) Funktionstyp int (int, int). Dieser Typ kann dann genauso wie int als Typ-Name in der Definition der Funktionsparameter verwendet werden. Wenn Sie sich das typedef in der Definition wegdenken, würde eine Deklaration der Funktion Operation übrig bleiben. Doch das typedef-Schlüsselwort besagt: „Definiere/ deklariere kein Objekt, definiere stattdessen einen Typ dieses (hypothetischen) Objekts.“
Ein weiteres Beispiel: Angenommen, Sie wollen in Ihrem Programm Berechnungen anstellen, und Sie sind sich noch nicht ganz sicher, welchen Datentyp Sie wählen sollen. Am einfachsten gehen Sie vor, indem Sie zuerst eine (vorläufige) Wahl treffen und via typedef einen Typ-Alias auf den gewählten Typ definieren. Diesen Alias verwenden Sie dann im gesamten Rest des Programms:
1 /*** Beispiel funktionstyp2.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // wir wählen zuerst int als Grundlage unserer Berechnungen7 typedef int Zahl;8 // alle Berechnungen finden nun mit dem Typ Zahl statt9
10 // addiert beide Operanden und gibt das Ergebnis zurück11 Zahl add (Zahl a, Zahl b) {return a + b;}12 // subtrahiert beide Operanden voneinander und gibt das Ergebnis zurück13 Zahl sub (Zahl a, Zahl b) {return a - b;}14 // führt eine Berechnung mit beiden Operanden über die übergebene Funktion durch;15 // die Funktion muss den Typ Operation besitzen16 typedef Zahl Operation (Zahl, Zahl);17 Zahl rechne (Zahl a, Zahl b, Operation op)18 {19 // rufe übergebene Funktion mit beiden Operanden auf20 return op (a, b);21 }2223 int main ()24 {25 // führe ein paar Berechnungen durch26 cout << "2 + 5 = " << rechne(2, 5, add) << endl;27 cout << "3 - 4 = " << rechne(3, 4, sub) << endl;28 return 0;
68
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
29 }
Wenn Sie nun den Datentyp beispielsweise in long ändern möchten, müssen Sie dies nur einmal tun, nämlich in Zeile 7. Da alle anderen Deklarationen auf diesem Typ aufbauen, sind keine weiteren Änderungen notwendig. Dies zeigt hoffentlich den Sinn einer solchen Typ-Abstraktion.
Merksatz 10: Verwende Abstraktionen!
Der typedef-Spezifizierer erstellt keine neuen Typen – er erzeugt nur neue Namen für bestehende Typen. Das bedeutet, dass Typ-Vergleiche vom Übersetzer immer auf den zugrunde liegenden Datentypen durchgeführt werden und die durch typedef eingeführten Abstraktionen ignoriert werden. Im folgenden Code-Abschnitt sind für den Übersetzer die Datentypen Alter und Gewicht völlig identisch, obwohl sie von der Bedeutung her zwei völlig unterschiedliche Typen sind und auch im Programm unterschiedliche Namen tragen.
1 typedef int Alter; // Typ für das Alter von Personen2 typedef int Gewicht; // Typ für das Gewicht von Personen
Die einzige Möglichkeit in C++, wirklich neue und von anderen Typen unterscheidbare Typen zu erschaffen, ist das Definieren von Klassen (oder Aufzählungen, s. 3.4.3.6). Klassen werden in Kapitel 4 eingeführt.
3.5 AnweisungenAnweisungen sind das Herz, der Motor eines jeden C++-Programms. Ohne Anweisungen „läuft“ im wahrsten Sinne des Wortes nichts. Vieles, was Sie bisher kennen gelernt haben – Funktionen, Typen – sind nur dazu da, um für Anweisungen ein angenehmes Milieu zu schaffen.
In C++ wird jede Anweisung durch ein Semikolon (;) abgeschlossen, mit Ausnahme der Block-Anweisung (3.5.6). Anweisungen müssen immer innerhalb einer Funktion oder Methode existieren – wenn der Übersetzer sie außerhalb vorfindet, wird er meckern. Aber diese Einschränkung ist keine, denn wie Sie wissen, fängt die Ausführung eines jeden C++-Programms mit dem Aufruf der Funktion main an, und diese kann weitere Funktionen aufrufen. Sie müssen also „Ihre“ Anweisungen nur in die Funktion main oder eine der von ihr aufgerufenen Funktionen bzw. Methoden schreiben.
Generell werden Anweisungen sequentiell abgearbeitet, d. h. nacheinander: Die Ausführung einer Anweisung wird erst begonnen, wenn die vorherige Anweisung komplett abgearbeitet worden ist. Es gibt aber auch Ausnahmen von der Regel, beispielsweise bei Funktionsaufrufen (3.6.2) oder beim Werfen von Ausnahmen (5.2).
Bevor Sie allerdings blindlings irgendwelche Anweisungen in irgendwelche Funktionen schreiben, sollten Sie sich mit ihnen vertraut machen. Es gibt sechs wichtige Klassen von Anweisungen:
(1) Ausdrücke (3.5.1)
(2) Deklarationen (3.5.2)
(3) Fallunterscheidung und Selektion (3.5.3)
69
Typ-Aliase sind „nur“ Aliase
Anweisungen sind wichtig
Vorkommen von Anweisungen
Abarbeitung von Anweisungen
Arten von Anweisungen
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
(4) Schleifen (3.5.4)
(5) Sprünge (3.5.5)
(6) Blöcke (3.5.6)
Diese werden in den nächsten Abschnitten vorgestellt. Zuvor sollen Sie jedoch die allereinfachste Anweisung in C++ kennen lernen: die Null- oder leere Anweisung:
1 ; // die „berühmte“ Null-Anweisung
Sie besteht aus einem einfachen Semikolon und tut nichts. Sie hat sozusagen „Null“ Effekt – deshalb ihr Name. Ja, auch sie hat ihre Daseinsberechtigung, allerdings nur bei bestimmten Anwendungen von Sprüngen, die dieses Skript nicht erklärt (und auch nicht erklären will). Sie sollten die Null-Anweisung jedoch kennen, weil sie für Sie eine Erleichterung bedeutet – falls Sie beim Tippen einmal versehentlich zwei Semikola hintereinander eingeben, macht dies nämlich (meistens) nichts...
3.5.1 AusdrückeEine Ausdrucks-Anweisung besteht aus lediglich einem Ausdruck (3.4), der zur Ausführungszeit ausgewertet wird. Wenn der Ausdruck keine Seiteneffekte hat, hat die Anweisung keinerlei Effekt.
Die häufigsten Ausdrucks-Anweisungen, da mit Seiteneffekten verbunden, sind Zuweisungen (3.4.1) und Prozeduraufrufe (3.6.2).
3.5.2 DeklarationenDeklarationen und Definitionen können überall dort stehen, wo Anweisungen erlaubt sind. Dies unterscheidet C++ von anderen Programmiersprachen wie Pascal oder C24, welche vom Programmierer die Definition aller lokalen Variablen am Anfang eines Blockes erfordern. In C++ können lokale Variablen somit überall innerhalb einer Funktion und insbesondere dort definiert werden, wo sie gebraucht werden. Dieses Lokalitätsprinzip verbessert die Verständlichkeit des Quelltextes und vermindert Fehler durch das Verwenden derselben Variable zu verschiedenen Zwecken.
Beispiel: Anstatt1 void meineFunktion ()2 {3 int zaehler;4 // ... einige Zeilen Code, die zaehler nicht benutzen ...5 zaehler = 0;6 // ... einige Zeilen Code, die zaehler benutzen ...7 }
zu schreiben, sollten Sie folgenden Programm-Code wählen1 void meineFunktion ()2 {3 // ... einige Zeilen Code, die zaehler nicht benutzen ...
24) Dies gilt nur für den verbreiteten, aber formal inzwischen überholten ISO/IEC 9899:1990-C-Standard (sowie für traditionelles oder Kernighan&Ritchie-C). Der aktuelle C-Standard (ISO/IEC 9899:1999), der sich allerdings noch nicht flächendeckend durchgesetzt hat, erlaubt, dass Deklarationen und Anweisungen innerhalb von Blöcken in beliebiger Reihenfolge auftreten können.
70
Null-Anweisung
Ausdrücke als Anweisungen
Deklarationen als Anweisungen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
4 int zaehler = 0;5 // ... einige Zeilen Code, die zaehler benutzen ...6 }
Dieser Programmier-Stil vermeidet zusätzlich nicht initialisierte Variablen (Merksatz 5)! Also gleich zwei Fliegen mit einer Klappe geschlagen...
3.5.3 Fallunterscheidung und Selektion
3.5.3.1 FallunterscheidungIn jeder imperativen25 Programmiersprache wird eine Anweisung zur Fallunterscheidung benötigt. In C++ hat sie folgenden Aufbau:
if ( Bedingung )ThenAnweisung
[elseElseAnweisung]
Die Bedeutung ist wie folgt: Zuerst wird die Bedingung zwischen den beiden runden (obligatorischen!) Klammern hinter dem if ausgewertet. Dieser Ausdruck muss dabei vom Typ bool sein oder sich in diesen Typ konvertieren lassen (3.4.5). Wenn die Auswertung true ergibt, wird ThenAnweisung ausgeführt. Wenn die Auswertung false ergibt und der optionale else-Teil vorhanden ist, wird ElseAnweisung ausgeführt. Wenn die Auswertung false ergibt und kein else-Teil vorhanden ist, passiert überhaupt nichts weiter.
Beispiel:1 /*** Beispiel if2.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 using namespace std;67 /*8 * Diese Funktion prüft die Zugangsberechtigung des Benutzers9 * und liefert true bei Erfolg und false bei Misserfolg zurück,10 * abhängig vom übergebenen Alter.11 */12 bool pruefeZugangsberechtigung (int alter)13 {14 if (alter < 18)15 return false;16 else17 return true;18 }1920 /*21 * Diese Funktion darf nur bei erfolgreicher Prüfung der22 * Zugangsberechtigung aufgerufen werden!23 */24 void geschuetzterProgrammteil ()
25) d. h. Befehls- oder Anweisungs-orientierten
71
die if-Anweisung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
25 {26 // ... tut irgendetwas ...27 }2829 int main ()30 {31 cout << "Bitte geben Sie Ihr Alter ein: ";32 int alter;33 cin >> alter;34 if (!pruefeZugangsberechtigung (alter))35 cout << "Du bist leider zu jung..." << endl;36 else37 {38 cout << "Sie sind zum Zugang berechtigt." << endl;39 geschuetzterProgrammteil ();40 }41 return 0;42 }
In diesem Beispiel haben wir zwei if-Anweisungen: Einmal in der Funktion pruefeZugangsberechtigung in den Zeilen 14-17 und einmal in der Funktion main in den Zeilen 34-40, beide Male inklusive else-Teil. Bei der zweiten if-Anweisung können Sie im else-Teil sehen, dass Sie Blöcke (3.5.6) verwenden müssen, wenn Sie mehr als eine Anweisung im if- oder else-Teil ausführen wollen.
Erfahrene (oder aufmerksame) Leser wissen, dass die vorgestellte Syntax mehrdeutig ist. Schauen Sie sich die folgende Anweisung an:
1 if (alter >= 18)2 if (gewicht < 70)3 return true;4 else5 return false;
Ist dies nun1 if (alter >= 18)2 {3 if (gewicht < 70)4 return true;5 else6 return false;7 }
mit ThenAnweisung = „if (gewicht < 70) return true; else return false“ und ohne ElseAnweisung, oder
1 if (alter >= 18)2 {3 if (gewicht < 70)4 return true;5 }6 else7 return false;
mit ThenAnweisung = „if (gewicht < 70) return true“ und ElseAnweisung = „return false“? Beides ist möglich; in C++ wird die erste Möglichkeit gewählt. Ein else-Teil gehört also zur allernächsten if-Anweisung.
Sie können das Risiko einer Missdeutung gänzlich vermeiden, wenn Sie nur Blöcke als ThenAnweisung und ElseAnweisung verwenden.
72
Mehrdeutigkeiten
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Sie können als Bedingung auch eine Deklaration verwenden, vorausgesetzt dass das deklarierte Objekt sich in einen Wahrheitswert konvertieren (3.4.5) lässt. Beispiel:
1 using namespace std;2 // gib die Anzahl aus wenn ungleich Null3 if (const int anzahl = anzahlElementeInListe ())4 {5 cout << "Anzahl: " << anzahl << endl;6 }7 // ab hier kann auf die Konstante anzahl nicht mehr zugegriffen werden
Der Gültigkeitsbereich für das in der Bedingung deklarierte Objekt ist die if-Anweisung; außerhalb dessen ist das Objekt nicht existent.
VC++-Benutzer aufgepasst: In älteren Versionen des Übersetzers (einschließlich der Version 6.0, mit der wir in diesem Skript arbeiten) ist diese Einschränkung des Gültigkeitsbereichs nicht implementiert! Dort ist jede Entität, die Sie in einer if-Bedingung deklarieren, bis zum Ende des enthaltenden Blockes gültig (reicht also über das Ende der if-Anweisung hinaus). Falls Sie mit solchen Übersetzern arbeiten (müssen), empfiehlt es sich, alle in Bedingungen deklarierten Variablen innerhalb einer Funktion eindeutig zu benennen, um eventuelle Überschneidungen von Gültigkeitsbereichen im Vorfeld zu vermeiden.
3.5.3.2 SelektionIn C++ gibt es auch eine Selektions-Anweisung mit folgendem Aufbau:
switch ( Bedingung ){case KonstanterAusdruck1 :
[Anweisung1Block1Anweisung2Block1...break;]
case KonstanterAusdruck2 :[Anweisung1Block2Anweisung2Block2...break;]
...[default :
[Anweisung1BlockDefaultAnweisung2BlockDefault...
73
die switch-Anweisung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
break;]]}
Bei der Ausführung wird zuerst Bedingung ausgewertet. Gibt es innerhalb der switch-Anweisung eine case-Marke mit demselben Wert, werden die Anweisungen hinter dieser Marke ausgeführt, und zwar bis eine break-Anweisung (3.5.4) gefunden oder die switch-Anweisung beendet wird. Wenn keine passende Marke existiert, werden die Anweisungen hinter der default-Marke ausgeführt; fehlt auch diese, passiert überhaupt nichts. Die Bedingung kann wie bei der if-Anweisung auch eine passende Definition sein.
Ein Beispiel für die Selektions-Anweisung können Sie im Abschnitt 3.4.3.6, „Aufzählungen“finden.
Sie müssen darauf achten, am Ende jedes „Blocks“ von Anweisungen hinter einer case-Marke eine break-Anweisung zu setzen. Andernfalls werden die Anweisungen der nächsten Marke mit ausgeführt! Das unterscheidet C++ von anderen Programmiersprachen wie Pascal. (Manchmal ist dieses Verhalten aber auch erwünscht, siehe hierzu ebenfalls das o. g. Beispiel.)
Die switch-Anweisung ist nichts weiter als eine etwas übersichtlichere Form der if-Anweisung, allerdings mit geringerem Funktionsumfang, da die Ausdrücke in den case-Marken konstant sein müssen. if-Anweisungen haben diese Einschränkung nicht, so dass Sie jede switch-Anweisung in eine äquivalente if-Anweisung mit mehreren Zweigen (d. h. if- und else-Teilen) überführen können, etwa so:
1 if (Bedingung == KonstanterAusdruck1)2 {3 Anweisung1Block14 Anweisung2Block15 ...6 }7 else if (Bedingung == KonstanterAusdruck2)8 {9 Anweisung1Block2
10 Anweisung2Block211 ...12 }13 else if ...14 else15 {16 Anweisung1BlockDefault17 Anweisung2BlockDefault18 ...19 }
3.5.4 SchleifenSchleifen dienen dazu, Programmteile mehrfach auszuführen. In C++ gibt es deren drei:
(1) die while-Schleife als Kopfschleife
(2) die do-Schleife als Fußschleife
(3) die for-Schleife als Zählschleife
74
Vergessen Sie die break-Anweisungen nicht!
Schleifen in C++
if und switch im Vergleich
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Jede der Schleifen hat einen Schleifen-Körper und einen Schleifen-Kopf (oder Schleifen-Fuß bei der do-Schleife). Der Schleifen-Körper enthält die Anweisung, die bei jedem Schleifendurchlauf ausgeführt wird. Der Schleifen-Kopf bzw. Schleifen-Fuß enthält die kontrollierende Bedingung (s. u.)
Zu jeder Schleife gehört eine kontrollierende Bedingung, die vor oder nach jedem Schleifendurchlauf ausgewertet wird. Ist die Bedingung wahr, wird der nächste Schleifendurchlauf gestartet; ist sie falsch, wird der Schleifendurchlauf beendet. Folglich muss sich der Ausdruck stets in einen Wahrheitswert konvertieren lassen. Bei der for- und while-Schleife kann die kontrollierende Bedingung auch die Definition einer Variable sein, deren Gültigkeitsbereich dann lokal zur Schleife ist und beim Verlassen der Schleife wieder verschwindet.
Innerhalb jeder dieser drei Schleifen können die break- und die continue-Anweisungen vorkommen. Die break-Anweisung bewirkt, dass die Ausführung der Schleife abgebrochen wird und die Anweisungen hinter dem Schleifen-Körper ausgeführt werden. Dies ähnelt der Verwendung von break innerhalb der switch-Anweisung, wo ja auch der switch-Körper verlassen wird. Die break-Anweisung ist also eine alternative (und weniger strukturierte) Möglichkeit, eine Schleife zu verlassen.
Die continue-Anweisung beendet vorzeitig den aktuellen Schleifendurchlauf und fängt unmittelbar den nächsten Schleifendurchlauf an. Allerdings wird bei allen drei Schleifen die kontrollierende Bedingung geprüft, bevor der nächste Schleifendurchlauf angefangen wird.
3.5.4.1 Die while-Schleife
Die while-Schleife ist eine Kopf-gesteuerte Schleife. Das bedeutet, dass die kontrollierende Bedingung vor jedem Schleifendurchlauf ausgewertet wird. Das kann dazu führen, dass die Schleife überhaupt nicht ausgeführt wird.
Beispiel: Betrachten Sie den folgenden Programm-Code:1 /*** Beispiel while.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 #include <string>6 using namespace std;78 /*9 * Diese Funktion konvertiert die übergebene Zahl in die oktale10 * Darstellung (also zur Basis 8). Es wird immer eine führende Null11 * vorangestellt.12 */13 string dezimalZuOktal (int zahl)14 {15 const int Basis = 8; // die verwendete Basis16 string ergebnis = ""; // akkumuliert die umgewandelten Ziffern17 while (zahl > 0)18 {19 // berechne nächste Ziffer20 const int rest = zahl % Basis;
75
kontrollierende Bedingung
die break-Anweisung
die continue-Anweisung
Aufbau von Schleifen
die while-Anweisung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
21 // wandle in passendes Zeichen um22 const char ziffer = '0' + rest;23 // packe die Ziffer vor das Ergebnis24 ergebnis = ziffer + ergebnis;25 // reduziere die umzuwandelnde Zahl26 zahl /= Basis;27 }28 // führende Null, damit wir im Falle zahl == 0 keine leere Zeichenkette zurückgeben29 return "0" + ergebnis;30 }3132 int main ()33 {34 cout << "Bitte geben Sie eine Dezimalzahl ein: ";35 int zahl;36 cin >> zahl;37 cout << "Die Zahl in oktaler Schreibweise lautet: ";38 cout << dezimalZuOktal (zahl) << endl;39 return 0;40 }
Dieses Programm enthält eine Funktion, die eine Dezimalzahl in die korrespondierende oktale Darstellung (zur Basis 8) umwandelt. Der Kern dieser Funktion ist eine while-Schleife, die solange ausgeführt wird, wie Ziffern zur Umwandlung zur Verfügung stehen (d. h. solange die Zahl ungleich Null ist). Die Bedingung ist formuliert als zahl > 0, damit die Funktion auch abbricht, wenn (gemeinerweise) eine negative Zahl übergeben wird.
Hier wird auch deutlich, warum in diesem Beispiel eine Kopf-gesteuerte Schleife notwendig ist. Wenn eine negative Zahl übergeben wird, darf der Schleifen-Körper nicht durchlaufen werden, weil dies zu einem negativer Rest führt, der in dieser Funktion keinen Sinn macht und seltsame Effekte zur Folge hat.
3.5.4.2 Die do-Schleife
Die do-Schleife ähnelt der while-Schleife, nur dass sie Fuß-gesteuert wird. Sie wird also mindestens einmal durchlaufen. Die kontrollierende Bedingung wird nach jedem Schleifendurchlauf geprüft.
Beispiel:1 /*** Beispiel do.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 using namespace std;67 int liesAlter ()8 {9 int alter = 0;
10 do11 {12 cout << "Alter: ";13 cin >> alter;14 }15 while (alter < 0);16 return alter;17 }
76
die do-Anweisung
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
1819 int main ()20 {21 const int alter = liesAlter ();22 cout << "Ihr Alter ist: " << alter << endl;23 return 0;24 }
Die Funktion liesAlter liest das Alter vom Anwender ein. Um ein Alter kleiner Null zu vermeiden, wird der Anwender so lange „malträtiert“, bis er ein korrektes Alter größer oder gleich Null eingegeben hat. Diese Prüfung wird in der kontrollierenden Bedingung der Schleife durchgeführt. Da die Prüfung aber nur dann Sinn macht, wenn vorher das Alter einmal eingelesen worden ist, und weil das Einlesen im Schleifen-Körper stattfindet, wurde die Fuß-gesteuerte do-Schleife ausgewählt. Denn nur diese stellt sicher, dass die Schleife wie verlangt mindestens einmal ausgeführt wird.
3.5.4.3 Die for-Schleife
Die for-Schleife wird benutzt, wenn Sie von vornherein wissen, wie viele Schleifendurchläufe anstehen. Sie hat die folgende Syntax:
for ( Initialisierung [Bedingung]; [Iterationsausdruck] )Anweisung
Sie entspricht der folgenden Konstruktion:{
Initialisierungwhile ( Bedingung ){
AnweisungIterationsausdruck;
}}
Initialisierung ist entweder eine Ausdrucks-Anweisung (3.5.1), eine Deklarations-Anweisung (4.4.5.2) oder die leere Anweisung (3.5). Sie wird häufig dazu verwendet, einen Schleifenzähler (auch Laufvariable genannt) mit dem Startwert zu initialisieren. Falls Sie die Laufvariable in Initialisierung definieren, ist sie nur innerhalb der for-Schleife verfügbar; sobald die Schleife verlassen wird, ist sie nicht mehr existent.
Da eine Anweisung immer mit einem Semikolon (;) endet, enthält eine for-Anweisung zwischen den beiden runden Klammern immer zwei Semikola, auch wenn alle optionalen Teile weggelassen werden.
77
die for-Anweisung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
In VC++ ist die Beschränkung des Gültigkeitsbereichs der Laufvariable einer for-Schleife nicht implementiert! Wenn Sie VC++ verwenden (müssen) und Laufvariablen in Schleifen-Kopf initialisieren, müssen Sie darauf achten, dass diese Variablen auch außerhalb der Schleife gültig bleiben und dort keine Objekte mit gleichem Namen definiert werden:
1 // Beispiel-Schleife ohne irgendeine Funktion2 for (int i = 0; i < 10; ++i) { }3 int i = 42; // Fehler in VC++: i noch im Gültigkeitsbereich
Bedingung wird vor jedem Schleifendurchlauf (auch vor dem ersten) zu einem Wahrheitswert ausgewertet (d. h. der Ausdruck muss vom Typ bool sein oder sich in diesen Typ konvertieren lassen, s. Abschnitt 3.4.5). Nur wenn die Auswertung true ergibt, wird der nächste Schleifendurchlauf durchgeführt, ansonsten wird die Schleife abgebrochen. Sie werden in dem Ausdruck zum Testen der Abbruchbedingung fast immer auf den Schleifenzähler zugreifen wollen, den Sie in Initialisierung gesetzt haben.
Iterationsausdruck wird nach jedem erfolgten Schleifendurchlauf ausgewertet. Sie werden an dieser Stelle immer einen Ausdruck einsetzen, der den Schleifenzähler erhöht, erniedrigt oder auf einen anderen neuen Wert setzt.
Schließlich stellt Anweisung jene Anweisung dar, die bei jedem Schleifendurchlauf ausgeführt werden soll.
Beispiel 1: Diese Schleife gibt die Zahlen zwischen Eins und Zehn (beide eingeschlossen) aus und wird folglich zehnmal durchlaufen.
1 /*** Beispiel for1.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 int main ()7 {8 for (int zaehler = 1; zaehler <= 10; ++zaehler)9 cout << zaehler << endl;
10 return 0;11 }
Beispiel 2: Diese Schleife zählt von 60 in Fünfer-Schritten herunter, bis die Laufvariable Null oder negativ wird. Sie wird somit zwölfmal durchlaufen.
1 /*** Beispiel for2.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 void tuEtwas ()7 {8 cout << "..." << endl;9 }
1011 int main ()12 {13 for (int minuten = 60; minuten > 0; minuten -= 5)14 {15 cout << "Noch " << minuten << " Minuten!" << endl;16 tuEtwas ();
78
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
17 }18 return 0;19 }
Hier sehen Sie auch, wie Sie mehrere Anweisungen in den Schleifen-Körper schreiben können, nämlich indem Sie sie in einem Anweisungsblock (3.5.6) unterbringen.
Wenn Sie die continue-Anweisung innerhalb einer for-Schleife verwenden, wertet diese erst den Iterationsausdruck aus, bevor sie die Abbruchbedingung überprüft.
Der kundige Leser wird einwenden, dass die for-Schleife nicht nur zum Zählen verwendet werden kann. Das ist richtig, Sie sollten es sich aber angewöhnen, für alles andere die while- oder do-Schleifen zu verwenden. Die hat zum einen historische Gründe (for ist nun mal die Zählschleife), zum anderen verwirren Sie nur die Leute, die Ihren Code lesen (Sie selbst eingeschlossen).
3.5.5 SprüngeC++ kennt auch einige Anweisungen, um den Programmablauf ohne zusätzliche Bedingungen zu verändern. Wir stellen hier nur die return-Anweisung vor; die break- und continue-Anweisungen wurden bereits bei den Schleifen und der Selektions-Anweisung vorgestellt, und die unstrukturierte goto-Anweisung ist der Schrecken aller Informatiker...26
Die return-Anweisung hat zwei Varianten:
(1) return Ausdruck;(2) return;Die erste Variante kommt nur in „echten“ Funktionen vor, also in Funktionen, die einen Wert zurückgeben. Dort ist sie auch zwingend vorgesehen und ermöglicht es, dem Aufrufer der Funktion ein Ergebnis zu liefern, das zum Rückgabetyp der Funktion Typ-verträglich ist. Fehlt in einer solchen Funktion die return-Anweisung, liegt ein Fehler vor.
Die zweite Variante ist nur in Prozeduren (also void-Funktionen) erlaubt, dort optional und springt beim Erreichen zum Aufrufer zurück. Wenn innerhalb einer Prozedur keine return-Anweisung steht, wird die Kontrolle nach dem Ausführen der letzten Anweisung an den Aufrufer zurückgegeben, d. h. so, als ob vor dem abschließenden } ein return; stünde.
In beiden Fällen wird Programm-Code, der zwar innerhalb der Funktion aber hinter der return-Anweisung steht, nicht ausgeführt, da die Kontrolle an den Aufrufer zurückgegeben wird. Sie sollten darauf achten, dass kein toter Code entsteht, d. h. Anweisungen, die nie erreicht und ausgeführt werden.
Anders als in Java meckert der Übersetzer toten Code (s. o.) nicht als Fehler an.27 Sie sind also ziemlich auf sich alleine gestellt. Um die Gefahr von totem Code zu vermindern, empfiehlt es sich, nur eine einzige return-Anweisung innerhalb einer Funktion zu benutzen und diese ans Ende der Funktion zu stellen. So stellen Sie sicher, dass alle möglichen Pfade durch die Funktion letztlich an der return-Anweisung „vorbeikommen“.
26) vgl. [Dijk68]27) Ein guter Übersetzer wird aber sicherlich eine deutliche Warnung ausgeben.
79
Sprung-Anweisungen in C++
die return-Anweisung
die return-Anweisung in Funktionen
die return-Anweisung in Prozeduren
toten Code vermeiden
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
3.5.6 BlöckeBlöcke sind Hüllen um Anweisungen. Sie erscheinen nach außen wie eine einzige Anweisung, können aber aus vielen Anweisungen bestehen. Sie sind für all die Fälle gedacht, in denen C++ nur eine einzige Anweisung erlaubt, Sie aber mehr als eine Anweisung verwenden wollen. Dies ist der Fall:
• bei der if-else-Fallunterscheidung im if- und else-Teil
• bei der for-Schleife
• bei der while-Schleife
• bei der do-Schleife
Beispiele für Blöcke haben Sie bereits in den letzten Abschnitten zuhauf kennen gelernt.
Wichtig ist, dass jeder Block einen eigenen Gültigkeitsbereich für Variablen, Konstanten etc. darstellt. Objekte, die Sie in einem Block deklarieren, sind außerhalb des Blockes weder verfügbar noch existent. Zusätzlich können Objekte in inneren Blöcken Objekte in äußeren Blöcken verdecken (3.3.5). Beispiel:
1 bool pruefeBerechtigung (int alter, int gewicht, int groesse)2 {3 if (alter >= 18)4 {5 // berechne den BMI (Body Mass Index)6 int bmi = gewicht * 10000 / (groesse*groesse);7 if (bmi > 25)8 {9 return false; // zu dick!
10 }11 else12 {13 return true; // alles OK14 }15 }16 else17 {18 // hier ist bmi nicht existent19 return false; // zu jung!20 }21 // hier ist bmi ebenfalls nicht existent22 }
Die lokale Variable bmi, die in Zeile 6 definiert wird, ist außerhalb des zugehörigen Blockes (Zeilen 4-15) nicht existent, wohl aber innerhalb innerer Blöcke (wie z. B. in Zeile 9 und 13).
3.6 FunktionenFunktionen bilden die Abstraktion in der Informatik für auszuführende Aufgaben. Sie kapseln Anweisungen und besitzen eine Schnittstelle, welche die benötigten Daten, die zurückgegebenen Ergebnisse sowie die Aufgabe der Funktion definiert. Bevor wir jedoch detaillierter auf Funktionen eingehen, ein kleiner Hinweis: Alles, was in diesem Abschnitt über Funktionen gesagt wird, ist in dem gleichen Maße für Me
80
Gruppieren von Anweisungen
jeder Block ist ein Gültigkeitsbereich
Charakter von Funktionen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
thoden von Klassen (4.4.5.1) von Bedeutung, außer dass die Syntax sich etwas unterscheidet.
Sie haben in dem Skript bisher sehr viele Beispiele für Funktionen kennen gelernt und sind mit der Syntax von Funktionen einigermaßen vertraut. Dieser Abschnitt geht deshalb weniger auf die Syntax als auf die Verwendung von Funktionen ein und erläutert, worauf beim Definieren und Verwenden von Funktionen zu achten ist.
Eines ist ganz wichtig, egal ob es um Funktionen, Methoden, Operationen oder worum auch immer geht: der Unterschied zwischen Definition auf der einen Seite und Anwendung oder Applikation auf der anderen Seite. Zuerst wird irgendwo im Programm eine Funktion definiert, d. h. es werden der Name, eventuelle Parameter und der Rückgabetyp festgelegt sowie die Semantik der Funktion (= Bedeutung) über die enthaltenen Anweisungen beschrieben. Nun kann an einer beliebigen Stelle im Programm diese Funktion aufgerufen werden. Das heißt, dass die Funktion während des Progammlaufs als Dienstleister angesehen wird, deren Dienst man nutzt.
Die Unterschiede zwischen Definition und Anwendung einer Funktion sind enorm:
(1) Sie definieren eine Funktion genau einmal, können Sie aber ganz oft aufrufen (oder sogar gar nicht!)
(2) Definition und Anwendung einer Funktion können sich in ganz unterschiedlichen Programmteilen befinden. (Für den Aufruf einer Funktion ist allerdings zumindest eine Deklaration notwendig, damit der Übersetzer weiß, dass er es mit einer Funktion zu tun hat.)
(3) Die Definition einer Funktion geschieht zur Übersetzungszeit, ihre eventuellen Aufrufe erst zur Laufzeit.
(4) Der letzte Punkt impliziert, dass Sie sehr wohl von vornherein sagen können, welche Funktionen in Ihrem Programm existieren, aber nicht, welche Funktionen bei einem Testlauf ausgeführt werden, denn dies könnte von Eingaben des Anwenders abhängen.
3.6.1 Das prozedurale ParadigmaFunktionen und Prozeduren sind die Umsetzung von Algorithmen. Das prozedurale Paradigma sieht in Funktionen und Prozeduren das zentrale Mittel, um Programme zu strukturieren. Nach dieser Sichtweise ist die Hauptarbeit des Programmierers,
(1) zu entscheiden, welche Funktionen und Prozeduren gebraucht werden,
(2) den besten Algorithmus für das vorliegende Problem zu implementieren.
Dieses Paradigma sieht also den Algorithmus, d. h. das Verhalten, als besonders wichtig an. Daten sind von untergeordneter Bedeutung und werden in den Prozess der Programm-Strukturierung nicht oder nur ungenügend herangezogen. Die getrennte Betrachtung von Verhalten und Daten führt oft dazu, dass die Daten-Abhängigkeiten zwischen den einzelnen Programm-Teilen nur schwer zu erkennen sind. Das wiederum führt letztlich zu unübersichtlichem Code, da bei der Programm-Wartung, der Erweiterung um neue Funktionalität oder bei der Fehler-Beseitigung schnell mal ein
81
Unterschied zwischen Definition und Anwendung
das prozedurale Paradigma
Daten sind untergeordnet
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
Parameter oder eine globale Variable hinzugefügt wird, ohne sich Gedanken darüber zu machen, wo diese Daten am besten hingehören.
Diesen Nachteil beseitigt das objektorientierte Paradigma, das in Kapitel 4 vorgestellt wird. Die objektorientierte Sichtweise betrachtet Daten und Verhalten als eine Einheit und versucht, Programme und Systeme auf der Grundlage dieser Einsicht zu strukturieren. Das Ergebnis sind Programme, in denen die Verteilung von Daten und Verhalten klar zu erkennen ist. Das ist besonders dann vorteilhaft, wenn das Programm geändert oder erweitert werden muss.
Trotz der offensichtlichen Nachteile der prozeduralen Sichtweise wollen wir Funktionen als Mittel zur Strukturierung von C++-Programmen vorstellen, nicht zuletzt deswegen, weil Funktionen und Methoden (die objektorientierte Variante von Funktionen) sich relativ ähnlich sind. In den nächsten Abschnitten gehen wir deshalb näher auf die unterschiedlichen Formen von Funktionen in C++ ein.
3.6.2 (Klassische) FunktionenIn der Mathematik berechnen Funktionen aus einem oder mehreren Werten einen neuen Wert, wobei die Funktionsdefinition die zugehörige Rechenregel umfasst. In der Informatik geht es nicht immer ums Rechnen, aber die Analogie ist passend. Charakteristisch für Funktionen (auch in der Informatik) ist die Rückgabe eines Ergebnisses. Ob das Ergebnis innerhalb der Funktion berechnet wird, von anderen Funktionen geliefert wird oder konstant ist, spielt dabei keine Rolle.
Funktionen können so aufgebaut sein, dass sie auf Daten operieren, die beim Aufruf an die Funktion übergeben werden. Diese Daten heißen Argumente, die die Argumente aufnehmenden Objekte Parameter. Eine Funktion in der Mathematik hat immer mindestens ein Argument; in der Informatik muss das nicht so sein, denn eine Funktion kann einen konstanten Wert zurückliefern, wobei in diesem Fall kein Argument benötigt wird.28 Das folgende Beispiel zeigt zwei Funktionen, eine ohne und eine mit Parametern:
1 // liefere den Umrechnungskurs von Euro zur (alten) D-Mark2 double gibKursEURzuDEM ()3 {4 return 1.95583;5 }67 // rechne einen Betrag von Euro in D-Mark um (ohne auf 5 Nachkommastellen zu runden)8 double wandleEURzuDEM (double betragInEUR)9 {
10 return betragInEUR * gibKursEURzuDEM ();11 }
Da Funktionen immer einen Wert zurückgeben müssen, enthalten Sie immer eine return-Anweisung zur Übermittlung des Wertes an den Aufrufer. Dieser Wert muss zum Rückgabetyp der Funktion passen (3.4.5).
28) De facto ist eine solche Funktion ohne Parameter von einer Konstante semantisch nicht zu unterscheiden. Lediglich die Syntax erfordert ein zusätzliches Paar Klammern bei der Applikation einer Funktion verglichen mit der Applikation einer gewöhnlichen Konstanten.
82
klassische Funktionen
Argumente und Parameter
die return-Anweisung
OO betrachtet Einheit von Daten und Verhalten
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Der Name, der Rückgabetyp und die Parameter bilden zusammen mit dem Funktionskommentar die Schnittstelle der Funktion, die Anweisungen in der Funktionsdefinition sind die Implementierung der Funktion. Die Schnittstelle gibt an, was die Funktion tut, welche Eingaben sie erwartet und welche Ausgaben sie verspricht. Die Implementierung drückt das Wie der Funktion durch C++-Code aus. Die Schnittstelle muss so beschaffen sein, dass sie zum Verständnis und Benutzung der Funktion ausreichend ist. Fehlt diese Eigenschaft, so kann die Funktion eigentlich nicht genutzt werden und wird überflüssig. Nicht immer kann nämlich die Implementierung herangezogen werden, um herauszufinden, was die Funktion wirklich tut:
• Der Quelltext ist nicht verfügbar: Eventuell besitzen Sie den Quelltext der Funktion nicht. Es gibt proprietäre Bibliotheken, die nur im übersetzten Format ausgeliefert werden und denen nur Header-Dateien mit den entsprechenden Schnittstellen beigelegt sind.
• Der Quelltext ist in einer unbekannten Programmiersprache verfasst: Manchmal gibt es eine C++-Schnittstelle für eine Funktion, die in einer anderen Programmiersprache implementiert ist. Wenn Sie diese Sprache jedoch nicht verstehen, können Sie auch den Sinn und Zweck der Funktion nicht verstehen.
• Der Quelltext ist unübersichtlich oder komplex: Wenn die Funktion einen komplexen Algorithmus implementiert oder aus viel Code besteht, kann es sein, dass Sie die Funktionsweise trotz des Quelltextes nicht verstehen. Man sieht in einem solchen Fall sozusagen den Wald vor lauter Bäumen nicht.
Um all diese Probleme aus dem Weg zu schaffen, sollten Sie ausführliche Funktionskommentare zu den Schnittstellen der Funktionen schreiben, welche die Aufgabe der Funktion genau erläutern. Die Kommentare sollten sich auf das Was beschränken; Angaben zur Implementierung sollten vermieden oder nur sehr sparsam gemacht werden, um die Implementierung austauschbar zu halten.
Merksatz 11: Verwende ausführliche Funktionskommentare!
Eine Funktion wird aufgerufen, indem ihr Name in einem Ausdruck erscheint, gefolgt von einer öffnenden und schließenden runden Klammer, zwischen denen sich eventuell benötigte Argumente (3.6.4) befinden. Beim Aufruf werden die Argumente in die zugehörigen Parameter (3.6.4) kopiert und die Ausführung der Anweisungen innerhalb der Funktion begonnen.
Zum Abschluss wollen wir den syntaktischen Aufbau von Funktionen betrachten:
Rückgabetyp Name ( [Parameterliste] ){
[Anweisungen]}
Dabei ist Rückgabetyp ungleich void, und in Anweisungen ist mindestens eine return-Anweisung mit einem zum Rückgabetyp passenden Ausdruck enthalten.
83
Aufruf von Funktionen
Schnittstelle unf Implementierung
Probleme bei schlechter Schnittstellen-Dokumentation
Funktionskommentare
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
Die betrachteten Funktionen liefern immer nur einen Wert zurück. Wollen Sie mehr als einen Wert zurückliefern, ist es besser, Prozeduren (3.6.3) mit Ausgabe-Parametern (3.6.4) zu verwenden.
3.6.3 Prozeduren oder Funktionen „ohne Wert“Prozeduren sind wie Funktionen, nur dass sie keinen Wert zurückliefern. Stattdessen führen sie Seiteneffekte durch, indem sie beispielsweise andere Prozeduren aufrufen oder Ausgabe-Parametern (3.6.4) einen Wert zuweisen. Sie werden genauso definiert wie „normale“ Funktionen, nur dass sie immer den Rückgabetyp void besitzen und nicht unbedingt eine return-Anweisung enthalten müssen. Das folgende Beispiel stellt eine Prozedur vor, welche die Funktionen aus dem vorherigen Abschnitt nutzt, um solange Euro-Beträge in D-Mark umzurechnen, bis der Anwender „aufgibt“:
1 using namespace std;2 /*3 * Diese Prozedur liest solange Euro-Beträge vom Benutzer ein und4 * wandelt sie in D-Mark um, bis eine Null eingegeben wird.5 */6 void rechneUm ()7 {8 while (true) // Achtung: Abbruch durch return innerhalb der Schleife!9 {
10 cout << "Euro-Betrag eingeben (0 = Ende): ";11 double betrag;12 cin >> betrag;13 if (betrag == 0.0)14 return; // verlasse Schleife15 else16 {17 double ergebnis = wandleEURzuDEM (betrag);18 cout << "Ergebnis: " << ergebnis << " DEM" << endl;19 }20 }21 }
3.6.4 Parameter und ArgumenteParameter und Argumente ermöglichen es, Funktionen in ihrem Verhalten unmittelbar zu beeinflussen. Auf der Seite der Funktionsdefinition werden Parameter wie normale Variablen-Definitionen zwischen die runden Klammern nach dem Funktionsnamen geschrieben, wobei sie voneinander durch Kommata abgetrennt werden:
Rückgabetyp Name ( [Typ1 Parameter1 [, Typ2 Parameter2 [, ...]]] )Zur Laufzeit muss bei jedem Aufruf dieser Funktion jeder Parameter mit einem passenden Wert „belegt“ werden. Der Übersetzer achtet penibel genau darauf, dass die Anzahl der Argumente beim Aufruf genau der Anzahl der Parameter in der Funktionsdefinition ist. Außerdem überprüft er, ob sich jedes Argument in den Typ des zugehörigen Parameters umwandeln lässt (3.4.5). „Zugehörig“ bedeutet dabei, dass das erste Argument dem ersten Parameter zugeordnet wird, das zweite Argument dem zweiten Parameter u. s. w.
84
mehrere Ergebnisse
Prozeduren vs. Funktionen
Parameter und Argumente
Zuordnung von Argumenten zu Parametern
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Die Reihenfolge der Auswertung der Argumente beim Aufruf einer Funktion ist nicht spezifiziert. So ist es im unten angegebenen Beispiel unklar, welche Ausgabe das Programm erzeugt:
1 /*** Beispiel aufruf.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 // gibt die verwendete Programmiersprache zurück8 string gibSprache ()9 {10 cout << "Sprache" << endl;11 return "C++";12 }13 // bewertet die verwendete Programmiersprache14 string gibBewertung ()15 {16 cout << "Bewertung" << endl;17 return "toll";18 }19 // gibt die Sprache und die Bewertung aus20 void bewerte (string sprache, string bewertung)21 {22 cout << sprache << " ist " << bewertung << endl;23 }24 int main ()25 {26 bewerte (gibSprache (), gibBewertung ());27 return 0;28 }
Das Programm könnteSpracheBewertungC++ ist toll
oderBewertungSpracheC++ ist toll
ausgeben, da nicht definiert ist, ob beim Aufruf der Funktion bewerte zuerst das linke oder zuerst das rechte Argument ausgewertet wird. Sie sollten allgemein vermeiden, solchen Code zu schreiben, bei dem es innerhalb eines Funktionsaufrufs auf eine bestimmte Reihenfolge von Seiteneffekten ankommt.
VC++ wertet beim Funktionsaufruf die Argumente von rechts nach links aus, weshalb die Ausgabe
BewertungSpracheC++ ist toll
lautet.
In Java ist die Reihenfolge der Auswertung spezifiziert (sie geschieht immer von links nach rechts, d. h. das erste Argument in der Liste wird auch als erstes ausgewertet), so dass Java-Programmierer besonders aufpassen sollten, da diese Regel in C++ nicht mehr gilt. Allerdings gilt es auch in Java als schlechter Programmierstil, Argumente mit Seiteneffekten zu verwenden.
85
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
Der letzte Teil dieses Abschnitts behandelt Referenzen als Funktionsparameter. Normalerweise spielt es für den Aufrufer keine Rolle, ob die Funktion ihre Parameter verändert oder nicht, da die Veränderungen für ihn keinerlei Auswirkungen haben. Diese Parameter werden auch als Eingabe-Parameter bezeichnet, weil über sie lediglich Daten in die Funktion gelangen und nicht wieder heraus. Beispiel:
1 /*** Beispiel param1.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // erhoht übergebene Variable um Eins und gibt den neuen Wert aus7 void erhoehe (int variable)8 {9 variable++;
10 cout << "Variable erhöht auf: " << variable << endl;11 }1213 int main ()14 {15 int zaehler = 1;16 cout << "Zähler ist: " << zaehler << endl;17 erhoehe (zaehler);18 cout << "Zähler ist: " << zaehler << endl;19 return 0;20 }
Wenn Sie dieses Programm laufen lassen, erzeugt es die Ausgabe:Zähler ist: 1Variable erhöht auf: 2Zähler ist: 1
Haben Sie die Ausgabe, insbesondere die letzte Zeile, erwartet? Wenn nicht, rufen Sie sich ins Gedächtnis, dass beim Aufruf der erhoehe-Funktion in Zeile 17 der Parameter variable mit dem Wert des Arguments (d. h. dem Inhalt der Variable zaehler) initialisiert wird. Der Wert des Zählers wird also in den Parameter kopiert. Somit können Änderungen am Parameter keine Auswirkungen auf die ursprünglich übergebene Variable haben, weil der Parameter eine Kopie des Werts besitzt. Diese Art der Übergabe wird Übergabe per Wert genannt, weil nur der Wert der Variable, des Objekts etc. übergeben wird, aber nicht die Variable selbst.
Falls Ihnen das immer noch nicht klar ist, stellen Sie sich einfach vor, dass die Zeile 17 folgendermaßen lautet:
17 erhoehe (1);
Abgesehen davon, dass dieser Aufruf der Funktion erhoehe gemäß dem Funktionskommentar nicht sinnvoll ist (im Kommentar steht ja, dass eine Variable erhöht wird), ist dieser Aufruf vollkommen legal: Der Wert 1 wird in den Parameter variable kopiert. Natürlich kann die Erhöhung in Zeile 9 nicht die Konstante 1 auf den Wert 2 ändern – schließlich ist es eine Konstante. (Das Programm erzeugt auch dieselbe Ausgabe wie vorher).
Statt der Zahlen-Konstante 1 könnten Sie auch zaehler mit Hilfe von const konstant machen:
86
Eingabeparameter
Veränderungen werden nicht übernommen
Konstanten sind erlaubt
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
15 const int zaehler = 1;Auch hierbei bleibt das Ergebnis dasselbe: Das Programm lässt sich übersetzen, die Ausgabe bleibt wie gehabt. Und hier wollen Sie auch bestimmt nicht, dass die Funktion erhoehe hinter Ihrem Rücken den Wert Ihrer – bis dato – Konstante zaehler einfach ändert.
Was wir also brauchen, ist
(1) eine Möglichkeit, innerhalb einer Funktionsdefinition zu sagen: „Hier muss eine Variable übergeben werden, eine Konstante reicht nicht aus.“ und
(2) eine Möglichkeit, Veränderungen an solchen Parametern nach außen sichtbar zu machen.
Beides wird dadurch gelöst, dass der betreffende Parameter als Referenz definiert wird. Sie erinnern sich: Referenzen sind Verweise. Wenn der Parameter also ein Verweis auf die ursprünglich übergebene Variable ist, dann ist unser Problem gelöst.
Das angepasste Programm sieht nun folgendermaßen aus:1 /*** Beispiel param2.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // erhoht übergebene Variable um Eins und gibt den neuen Wert aus7 void erhoehe (int &variable) // beachten Sie das &-Zeichen!8 {9 variable++;10 cout << "Variable erhöht auf: " << variable << endl;11 }1213 int main ()14 {15 int zaehler = 1;16 cout << "Zähler ist: " << zaehler << endl;17 erhoehe (zaehler);18 cout << "Zähler ist: " << zaehler << endl;19 return 0;20 }
Und voilà! Die produzierte Ausgabe beläuft sich auf:Zähler ist: 1Variable erhöht auf: 2Zähler ist: 2
Das ist genau dass, was wir erreichen wollten. Und Sie können es ruhig mal ausprobieren, erhoehe (1) zu schreiben oder zaehler via const zu einer Konstante zu machen. Glauben Sie mir, Sie werden keinen Erfolg haben. Der Übersetzer wird Ihnen korrekterweise ankreiden, dass Sie eine veränderbare Variable an die Funktion übergeben müssen.
87
Voraussetzungen für veränderbare Parameter
Referenzen sind die Lösung
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
Derartige Parameter, die sowohl zur Eingabe als auch zur Ausgabe von Daten benutzt werden, werden Ein-/Ausgabe-Parameter genannt, die Art der Übergabe wird Übergabe per Referenz genannt. Daneben gibt es auch reine Ausgabe-Parameter, deren Bedeutung klar sein sollte. Ausgabe-Parameter werden vor allem dann benutzt, wenn mehrere Werte zurückgeliefert werden müssen. Beispiel:
1 /*** Beispiel param3.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // berechnet den Quotienten und Divisionsrest beider Operanden7 void quotientUndRest (8 int dividend, int divisor, int "ient, int &rest9 )
10 {11 quotient = dividend / divisor;12 rest = dividend % divisor;13 }1415 int main ()16 {17 int quotient = 0;18 int rest = 0;19 quotientUndRest (7, 5, quotient, rest);20 cout21 << "7 / 5 = " << quotient << "\n"22 << "7 % 5 = " << rest << endl;23 return 0;24 }
Die generierte Ausgabe ist:7 / 5 = 17 % 5 = 2
3.6.5 RekursionDas Thema „Rekursion“ dreht sich um Funktionen, die – direkt oder indirekt – sich selbst aufrufen. Eine solche Funktion haben Sie bereits in dem Beispiel in Abschnitt 3.2.1 gesehen. Lassen Sie uns die enthaltenen Fehler in den Kommentaren korrigieren und anhand des Beispiels verstehen, wie rekursive (also sich selbst aufrufende) Funktionen funktionieren:
1 /*** Beispiel fakultaet2.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 /*7 * Diese Funktion berechnet die Fakultät.8 * Eingabe:9 * „argument“ – das Argument der Funktion
10 * Ausgabe:11 * das Ergebnis der Fakultät von „argument“12 * Bemerkung:13 * Die Fakultät fakultaet ist rekursiv definiert als:14 * fakultaet(n) = 1 [n = 0]15 * fakultaet(n) = n * fakultaet (n – 1) [n > 0]
88
Ein-/Ausgabe- und reine Ausgabe-Parameter
Funktionen, die sich selbst aufrufen
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
16 */17 int fakultaet (int argument)18 {19 if (argument == 0) // Prüfe, ob das Ende der Rekursion erreicht ist20 // Rekursionsende („Bottom case“) erreicht, liefere Eins zurück21 return 1;22 else23 // (n-1)! wird rekursiv ausgerechnet24 return argument * fakultaet (argument - 1);25 }2627 int main ()28 {29 cout30 << "0! = " << fakultaet (0) << "\n"31 << "1! = " << fakultaet (1) << "\n"32 << "3! = " << fakultaet (3) << "\n"33 << "6! = " << fakultaet (6) << endl;34 return 0;35 }
Die wichtige Funktion fakultaet in diesem Beispiel ist rekursiv. Warum? Weil sie sich in Zeile 24 selbst aufruft:
24 return argument * fakultaet (argument – 1);Jetzt fragen Sie sich, wieso das Programm dennoch funktionieren kann. Wenn die Funktion sich selbst aufruft, und bei diesem zweiten Aufruf sich wieder selbst aufruft, wobei sie bei dem jetzt dritten Aufruf sich wieder aufruft, und... dann sieht es ganz so aus, als würde diese „Aufruf-Orgie“ nicht aufhören und die Funktion niemals einen sinnvollen Wert an den (oder die!) Aufrufer zurückgeben.
Der Schlüssel zum Verständnis sind hier die Zeilen 19-21:19 if (argument == 0) // Prüfe, ob das Ende der Rekursion erreicht ist20 // Rekursionsende („Bottom case“) erreicht, liefere Eins zurück21 return 1;
Hier wird anhand des Parameters argument irgendwann entschieden, nicht wieder den nächsten rekursiven Aufruf durchzuführen, sondern einfach zurückzuspringen. Somit wird die Rekursion genau dann unterbrochen, wenn der Parameter argument irgendwann den Wert Null inne hat. Das ist bei unseren Aufrufen der Funktion immer der Fall. Warum? Sehen Sie sich den rekursiven Aufruf in Zeile 24 einmal genauer an:
24 return argument * fakultaet (argument – 1);Wie Sie sehen, wird der Wert des Parameters argument um Eins erniedrigt, bevor er an den nächsten Funktionsaufruf übergeben wird. Wenn wir mit einer Zahl n größer Null beginnen, wird nach genau n + 1 Aufrufen der Parameter den Wert Null haben, und die Rekursion bricht ab. (Bei n = 0 wird die Funktion gleich beim ersten Aufruf in die ThenAnweisung der if-Anweisung verzweigen und gar keinen rekursiven Aufruf durchführen.)
Bei Rekursion müssen Sie besonders auf die Abbruchbedingung aufpassen! Wenn Sie beispielsweise unserer fakultaet-Funktion eine negative Zahl übergeben, gibt es einen Programmabsturz zur Laufzeit, weil die Bedingung argument == 0 nie erfüllt wird. Die Grund für diesen Fehler ist die Tatsache, dass es sich um die Umsetzung einer
89
Sich selbst aufrufen – geht das?
Abbruchbedingung ist notwendig
Gehen Sie sicher, dass die Rekursion auch abbricht!
Fallen bei der Verwendung von Rekursion
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
bekannten mathematischen Funktion handelt, die für negative Zahlen nicht definiert ist. Was einen Mathematiker aber nicht stört, ist für uns an dieser Stelle fatal. Besser ist es, an dieser Stelle die Bedingung argument <= 0 zu verwenden, auch wenn es nicht der mathematischen Definition der Funktion entspricht.
Eine andere Möglichkeit ist es, einen vorzeichenlosen Datentyp (etwa unsigned int) für den Parameter zu verwenden; damit können negative Zahlen von vornherein verhindert werden.
Abbildung 18 versucht, die rekursiven Aufrufe bei der Auswertung des Ausdrucks fakultaet(3) zu veranschaulichen. Jeder Kasten ist ein Aufruf der Funktion. Die Kästen sind ineinander geschachtelt, weil die Funktion sich selbst aufruft.
Wozu Rekursion, wenn sie doch etwas gefährlich zu verwenden ist? Wir wollen an dieser Stelle nicht zu tief in die Diskussion „rekursiv vs. iterativ“ einsteigen, da diese Diskussion bereits seit über dreißig Jahren in der Informatik vehement geführt wird. Nur so viel dazu: Viele Algorithmen lassen sich mit Hilfe von Rekursion sehr leicht formulieren und programmieren, während die nicht-rekursive Formulierung komplex und unübersichtlich werden kann. (Die nicht-rekursiven Varianten werden iterativ
90
rekursiv vs. iterativ
Abbildung 18: Rekursion am Beispiel der Berechnung von 3! (3 Fakultät)
2. Aufruf: argument == 2→ Verzweigung in den else-Teil
1. Aufruf: argument == 3→ Verzweigung in den else-Teil
3. Aufruf: argument == 1→ Verzweigung in den else-Teil
4. Aufruf: argument == 0→ Verzweigung in den then-Teil→ Rückgabe von 1
→ Rückgabe von 1 × 0! = 1 × 1 = 1
→ Rückgabe von 2 × 1! = 2 × 1 = 2
→ Rückgabe von 3 × 2! = 3 × 2 = 6
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
genannt, weil sie immer Schleifen zur Nachbildung der Rekursion verwenden.) Rekursive Funktionen entsprechen auch viel stärker dem mathematischen Funktionsbegriff, da es in der Mathematik keine Schleifen-Konstrukte für die Definition von Funktionen gibt. Iterative Algorithmen sind jedoch für die meisten Menschen leichter zu verstehen, auch wenn sie (wie oben gesagt) eher dazu neigen, lang und unübersichtlich zu werden.
Fairerweise muss man sagen, dass Sie auch bei nicht-rekursiven Algorithmen aufpassen müssen: wenn Sie nämlich Schleifen mit allgemeinen Bedingungen verwenden. Leicht kann man sich dabei eine sogenannte Endlos-Schleife einhandeln – also eine Schleife, die nicht abgebrochen wird und „ewig“ läuft (d. h. meistens bis die Ressourcen des Rechners erschöpft sind). Sie können sich Schleifen nämlich ebenfalls rekursiv vorstellen: Nach dem Ende eines jeden Schleifendurchlaufs „ruft sich die Schleife selbst wieder auf“, solange die Abbruch-Bedingung nicht erfüllt ist.
Mein Ratschlag zur Frage „Rekursion oder Iteration“ ist eher pragmatischer Natur: Wenn Sie die offensichtliche Wahl zwischen einer iterativen und rekursiven Formulierung eines Algorithmus haben und die iterative nicht wesentlich komplexer oder schwieriger zu verstehen ist als die rekursive, dann wählen Sie diese. Ansonsten wählen Sie die rekursive Variante.
Merksatz 12: Überlege gut den Einsatz von rekursiven Funktionen!
Merksatz 13: Überlege gut den Einsatz von Schleifen!
3.7 Literaturempfehlungen[Strou00] ist ein umfangreiches Buch von Bjarne Stroustrup, dem Erfinder von C++. Neben einer ausführlichen Behandlung der Sprachkonzepte von C++ widmet sich das Buch auch der Standard-Bibliothek und Fragestellungen des Programm-Entwurfs. Das Buch deckt ziemlich alle Zielgruppen ab, vom C++-unerfahrenen Programmierer bis hin zum Experten.
[Josu01] behandelt ebenfalls die Programmiersprache C++ relativ ausführlich, stellt aber die objektorientierten Konzepte in den Vordergrund. Das Buch ist bereits in diesem Abschnitt (und nicht erst in Kapitel 4) aufgeführt, weil der nicht-objektorientierte Teil von C++ gut dargestellt wird und auch C++-unerfahrene Programmierer durch viele Beispiele auf ihre Kosten kommen.
[Meye98] und [Meye99] versuchen nicht, Ihnen die Programmiersprache C++ in Ihrer Ganzheit näher zu bringen, sondern wollen Ihnen helfen, häufig gemachte Fehler zu vermeiden und Sie für Probleme verschiedenster Art zu sensibilisieren. Kenntnisse in C++ werden somit vorausgesetzt. Zum Teil werden die angeschnittenen Themen ziemlich technisch. Ein Stück weiter gehen [Sutt00] und [Sutt02], die fortgeschrittene Kenntnisse erfordern und Denkaufgaben samt Lösungen zu verschiedenen Themen wie Ausnahme-Sicherheit, objektorientierter Entwurf oder Optimierung enthalten.
91
Grundlegende Konzepte Objektorientiertes C++ für Einsteiger
3.8 ÜbungenÜ4 (*2) Machen Sie sich mit den Wertebereichen der Datentypen in Ihrer speziel
len C++-Implementierung vertraut! Was ist die kleinste, was die größte darstellbare Ganzzahl, Fließkommazahl? Wie lang kann eine Zeichenkette maximal werden?
Ü5 (*1) Implementieren Sie die Funktion fakultaet aus Beispiel 3.6.5 iterativ, d. h. unter Zuhilfenahme von Schleifen!
Ü6 (*2) Implementieren Sie die fibonacci-Funktion sowohl rekursiv als auch iterativ! Die Funktion fibonacci(n) ist folgendermaßen definiert:
fibonacci(0) = 1
fibonacci(1) = 1
fibonacci(n) = fibonacci(n - 1) + fibonacci(n - 2) [für n > 1]
Ü7 (*1,5) Implementieren Sie den Euklidischen Algorithmus zur Berechnung des größten gemeinsamen Teilers zweier ganzer Zahlen größer Null! Die Funktion ggT(a, b) ist folgendermaßen definiert:
ggT(a, 0) = a
ggT(a, b) = ggT(b, a % b) [für b > 0]
Ü8 (*2) Implementieren Sie die ggT-Funktion iterativ!
Ü9 (*1) Welche Merksätze werden hier verletzt? Korrigieren Sie die Funktion dementsprechend!
1 using namespace std;2 string dezimalZuBinaer (int zahl)3 {4 string ergebnis = ""; // akkumuliert die umgewandelten Ziffern5 while (zahl > 0)6 {7 // berechne nächste Ziffer8 const int rest = zahl % 2; 9 // wandle in passendes Zeichen um
10 const char ziffer = '0' + rest;11 // packe die Ziffer vor das Ergebnis12 ergebnis = ziffer + ergebnis;13 // reduziere die umzuwandelnde Zahl14 zahl /= 2;15 }16 // führende Null, damit wir im Falle zahl == 0 keine leere Zeichenkette zurückgeben17 return "0" + ergebnis;18 }
Ü10 (*2) Schreiben Sie die (korrigierte) Funktion aus Übung 9 so um, dass die Basis als Parameter übergeben wird! Dabei dürfen Sie davon ausgehen, dass die Basis immer in dem Wertebereich zwischen 2 und 10 liegt! (Denken Sie daran, die Funktion geeignet umzubenennen!)
92
Objektorientiertes C++ für Einsteiger Grundlegende Konzepte
Ü11 (*1) Schreiben Sie eine Funktion max, die den Maximal-Wert zweier Operanden zurückgibt! Implementieren Sie zwei Varianten: Eine mit if-Anweisung und eine mit dem Fallunterscheidungs-Operator!
Ü12 (*1,5) Schreiben Sie eine Funktion isRightTriangle, die ermittelt, ob drei Zahlen als Kantenlängen eines Dreiecks interpretiert ein rechtwinkliges Dreieck (Stichwort „Satz des Pythagoras“) ergeben!
Ü13 (*1,5) Schreiben Sie eine Funktion power zum Ermitteln der Potenz einer natürlichen Zahl!29 Implementieren Sie diese sowohl iterativ als auch rekursiv!
Ü14 (*1) Schreiben Sie die Funktionen AND, OR und NOT, welche die Bedeutung der logischen Operatoren &&, || und ! abbilden, ohne diese jedoch zu nutzen! Implementieren Sie jeweils zwei Varianten: Eine mit if-Anweisung und eine mit dem Fallunterscheidungs-Operator!
Ü15 (*2) Schreiben Sie eine Funktion subString, die die Teilzeichenkette einer vorgegebenen Zeichenkette, angegeben über eine Start-Position (einschließlich) und eine End-Position (ausschließlich), liefert! Dabei sei der Index des ersten Zeichens Null.
Ü16 (*2,5) Schreiben Sie eine Funktion reverse, die eine string-Zeichenkette umdreht! Implementieren Sie diese sowohl iterativ als auch rekursiv!
Ü17 (*3) Schreiben Sie eine Funktion towers, die eine Anleitung für die Lösung des Türme-von-Hanoi-Problems für Türme beliebiger Höhe ausgibt!
Das Türme-von-Hanoi-Problem besteht aus einem Turm A aus n unterschiedlich großen Scheiben (die größte liegt zuunterst) und zwei „leeren“ Türmen B und C. Ziel ist es, alle Scheiben des Turms A zum Turm B zu bewegen. Dabei darf man jedoch niemals mehr als eine Scheibe gleichzeitig bewegen, und es darf niemals eine größere Scheibe auf eine kleinere gelegt werden. Auf einen „leeren“ Turm kann eine beliebig große Scheibe gelegt werden.
Ü18 (*5) Entwickeln und implementieren Sie für Übung 17 eine iterative Lösung!
29)x0=1
x y= x⋅x⋅...⋅xy−mal
93
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4 Die Welt der ObjekteIn diesem Kapitel lernen Sie die Grundlagen der objektorientierten Programmierung und erfahren, wie Sie die Konzepte in C++ umsetzen.
4.1 GrundlagenDieser Abschnitt lehrt Sie die Grundlagen der objektorientierten Programmierung. Sie erfahren, was ein Objekt (im Sinne des OO-Paradigmas) ist, was Klassen sind und wie Objekte und Klassen im Verhältnis zueinander stehen. Sie lernen, wie Klassen strukturiert sind und welche Elemente sie enthalten können. Schließlich erfahren Sie, wie Gemeinsamkeiten zwischen Objekten und Klassen in der objektorientierten Welt ausgedrückt werden.
In diesem Abschnitt werden die meisten allgemeinen Begriffe des objektorientierten Paradigmas erläutert. Einige etwas speziellere Begriffe werden jedoch in späteren Abschnitten erklärt, wenn sich dies anbietet.
Die erklärten Konzepte werden zusätzlich an einem einfachen Beispiel veranschaulicht. Dieses Beispiel dreht sich um Personen, die Video-Filme besitzen und sich diese gelegentlich mit einem geeigneten Abspielgerät anschauen. Die Abschnitte, in denen die Konzepte an dem Beispiel erklärt werden, sind mit dem nebenstehenden Symbol gekennzeichnet. Hinweis: Jegliche in dem Beispiel verwendeten Produkte sind willkürlich gewählt; die Marken sind mit großer Wahrscheinlichkeit urheberrechtlich geschützt.
4.1.1 Objekte, Nachrichten, Operationen und MethodenWenn Sie noch nie objektorientiert programmiert haben, wenn das Thema für Sie völlig neu ist, dann sind erst einige Worte zum objektorientierten Paradigma nötig. Sie können sich die objektorientierte Welt vorstellen als ein System unterschiedlicher, miteinander interagierender Objekte (Abbildung 19). Alles sind Objekte. Genau wie in der Realität ein Apfel, eine Schere oder ein Glas Objekte sind, die Sie anfas
sen und mit denen Sie etwas anstellen können, sind Objekte in der objektorientierten Welt etwas Reales. Sie können Objekte erzeugen, mit ihnen arbeiten und sie nach getaner Arbeit auch wieder zerstören.
95
Überblick
System von Objekten
Überblick über die Grundbegriffe
Abbildung 19: System interagierender Objekte
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
In unserem Beispiel sind Video-Filme Objekte, aber auch Video-Medien, die diese Filme enthalten; Video-Abspielgeräte, welche diese wiedergeben; und auch Personen, die sich diese Filme anschauen wollen. Einige Objekte samt möglicher Beziehungen sind in Abbildung 20 gezeigt.
In der objektorientierten Welt hat jedes Objekt gewisse Fähigkeiten. Diese Fähigkeiten äußern sich dadurch, dass ein Objekt ein anderes bitten kann, etwas zu erledigen. Das eine Objekt schlüpft also in die Rolle eines Klienten, das andere in die Rolle eines Dienstleisters. Dieses Ansprechen geschieht in Form von speziellen Nachrichten. Enthält eine Nachricht zusätzliche Informationen, die der Dienstleister für die Ausführung des gewünschten Dienstes benötigt, spricht man von parametrisierten Nachrichten.
In unserem Beispiel hat Hans die VHS-Kassette 1 in das VHS-Abspielgerät JVC HR-S-5960 eingelegt und die Wiedergabe gestartet. Die Nachricht „Medium einlegen“ ist mit dem einzulegenden Medium parametrisiert. Dieser Vorgang wird in Abbildung 21 dargestellt.
„Versteht“ das Objekt diese Nachricht, so erfüllt es die ihm anvertraute Aufgabe, indem eine Methode ausgeführt wird. Ein Objekt versteht eine Nachricht nur, wenn eine entsprechende Operation vorhanden ist. Die Methode stellt den Programm-Code dar, der für diese Operation hinterlegt ist; dieses Hinterlegen von Code zu einer Ope
96
Klienten, Dienstleister, Nachrichten und Methoden
Abbildung 20: Video-Beispiel: Objekte und Beziehungen
Hans: Person
Schindlers Liste: Video-Film
Für eine Handvoll Dollar: Video-Film
Gummibärenbande: Video-Film
VHS-Kassette 1: VHS-Kassette
DVD 1: DVD
Philips DVP 520: DVD-Abspielgerät JVC HR-S 5960: VHS-Abspielgerät
Abbildung 21: Video-Beispiel: Klienten, Dienstleister und Nachrichten
Hansin der Rolle
eines Klienten
JVC HR-S-5960in der Rolle
eines Dienstleister
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
Nachricht „VHS-Kassette 1 einziehen“
Antwort „VHS-Kassette 1 eingezogen“
Nachricht „Wiedergabe starten“
Antwort „Wiedergabe gestartet“
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
ration nennt man Implementierung der Operation. Ein Beispiel aus der realen Welt: Wenn ich (der Klient) Sie (den Dienstleister) auffordere, ein C++-Programm einzutippen, so müssen Sie meine Aufforderung (die Nachricht) erst einmal verstehen, d. h. den deutschen Satz in seine Bestandteile zerlegen und die assozierte Bedeutung erkennen (die Operation). Wenn Sie nach dem Verstehen der Aufforderung nachkommen, reagieren Sie auf meine Aufforderung mit einer passenden Handlung (einer Methode), die Sie irgendwann gelernt (implementiert) haben.
In unserem Beispiel sind die Operationen des JVC HR-S-5960 allesamt implementiert, d. h. mit entsprechender Funktionalität ausgestattet. Dieser Sachverhalt wird durch Abbildung 22 veranschaulicht.
Versteht das Objekt diese Nachricht nicht, so liegt ein Programmfehler vor. Jedes Objekt versteht einen bestimmten Satz an Nachrichten. Wenn zwei verschiedene Objekte aber dieselbe Nachricht verstehen, heißt das nicht unbedingt, dass sie daraufhin auch dasselbe tun, sprich dieselbe Methode ausführen. Das ist genauso wie in der realen Welt: Tragen Sie fünf Menschen auf, für einen bestimmten Betrag einzukaufen, und sie bekommen mit hoher Wahrscheinlichkeit fünf unterschiedlich gefüllte Einkaufskörbe.
In unserem Beispiel versteht ein Abspiel-Gerät die Nachricht „Medium auswerfen“, das es dazu anleitet, ein Video-Medium herauszugeben; weitere sinnvolle Nachrichten sind „Wiedergabe starten“, „Wiedergabe stoppen“ sowie „Medium X einziehen“.
Zusammengefasst ergibt sich also:
• Eine Operation ist die Spezifikation eines Dienstes.
• Eine Methode ist die Realisierung eines Dienstes.
• Eine Nachricht ist die Anforderung eines Dienstes.
Wichtig ist noch herauszustellen, dass Objekte nur durch Nachrichten miteinander kommunizieren und somit nur ihre gegenseitigen Operationen kennen. Die Methode, die zu einer Operation gehört und sie quasi „mit Leben füllt“, ist nur dem jeweiligen Objekt bekannt. Diese Kapselung der Realisierung ist extrem wichtig für das Erstellen flexibler objektorientierter Programme, weil hierdurch Abhängigkeiten von einer
97
Abbildung 22: Video-Beispiel: Operationen und Methoden
JVC HR-S-5960
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
... starte Motoren ...
... aktiviere Video-Kopf ...
... deaktiviere Video-Kopf ...
... starte Motoren (rückwärts) ...
Operationen, Methoden und Nachrichten im Vergleich
Implementierung ist versteckt
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
ganz bestimmten Realisierungsstrategie vermieden werden. Die Implementierung einer Operation (z. B. einer Sortier-Operation) kann folglich ausgetauscht werden, ohne dass sich für den Rest des Programms etwas ändert.30 Somit ist die Trennung von Operation und Methode ein wichtiges Standbein der Flexibilität und Wartbarkeit objektorientierter Software. Ein anderes Standbein ist Abstraktion auf Objektebene, die sie weiter unten kennen lernen werden.
4.1.2 Assoziationen und AggregationenDie objektorientierte Welt, so wie sie bisher geschildert worden ist, wäre aber ziemlich langweilig, wenn es nicht Beziehungen zwischen den Objekten gäbe. In der realen Welt treten Beziehungen zwischen Objekten zuhauf zutage: Das Auto hat vier Räder (= HAT-EIN-Beziehung), der Chef kennt das Restaurant (= KENNT-EIN-Beziehung), der Schrank enthält die Akte (= HAT-EIN-Beziehung). Alle Beziehungen zwischen Objekten werden in der objektorientierten Welt Assoziationen genannt. Für besonders ausgezeichnete Assoziationen existieren gesonderte Ausdrücke: so wird etwa eine Ganzes-Teile-Beziehung Aggregation genannt (ein Rad IST EIN TEIL eines Autos/ein Auto HAT EIN Rad). Es gibt noch andere Arten von Beziehungen (etwa Abhängigkeit), die Sie später kennen lernen werden.
In unserem Beispiel existiert eine Beziehung zwischen dem VHS-Abspielgerät JVC HR-S-5960 und der eingelegten VHS-Kassette 1 (Abbildung 20). Die eingelegte Kassette ist dem Abspielgerät bekannt. Sobald sie wieder entnommen wird, „vergisst“ das Abspielgerät die Kassette, die Beziehung zwischen den beiden Objekten verschwindet. Eine Aggregation wäre (die nicht gezeigte) Beziehung zwischen dem VHS-Abspielgerät JVC HR-S-5960 und seinem Video-Kopf, da letzterer ein Teil des Geräts ist.
Assoziationen spielen in der objektorientierten Programmierung eine große Rolle, weil sie die einzige Möglichkeit darstellen, Aufgaben an mehrere Objekte zu verteilen. Schließlich können Sie nur dann Arbeit delegieren, wenn Sie wissen, wer diese Arbeit übernehmen kann. Da die konsequente Anwendung der objektorientierten Denkweise in der Regel zu Systemen mit vielen kleinen Objekten führt, die eine ganz bestimmte spezielle Aufgabe übernehmen, wird die Funktionalität des Gesamt-Programms durch die richtige „Verschaltung“ der Objekte untereinander erreicht, so ähnlich wie die Neuronen im menschlichen Gehirn nur durch die korrekte Verschaltung untereinander unsere geistigen Fähigkeiten ermöglichen.
In unserem Beispiel kann das VHS-Abspielgerät JVC HR-S-5960 auf die eingelegte Kassette zugreifen und das Magnetband auslesen, um an die Video-Daten heranzukommen. Dies kann es jedoch nur, solange die Kassette eingelegt ist, oder mit der obigen Terminologie formuliert: solange die Beziehung zwischen Abspielgerät und Kassette existiert.
4.1.3 Gemeinsamkeiten, Schnittstellen und PolymorphieDoch sind nicht alle Objekte völlig verschieden. Ähnlich wie in der Realität, in der ein Geschäft mehrere Kaffeemaschinen derselben Sorte verkauft, die sich in Hinblick auf Funktionalität nicht voneinander unterscheiden (sollen), kann es in der objektorientierten Welt Objekte geben, die einander ähnlich sind. Diese Ähnlichkeit kann sich
30) bis auf nicht-funktionale Eigenschaften, etwa Performance
98
Beziehungen zwischen Objekten
Ähnlichkeiten zwischen Objekten
Arbeitsverteilung
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
auf zwei unterschiedliche Aspekte beziehen: das Verstehen von Nachrichten und das Umsetzen dieser.
Wenn zwei Objekte dieselben Nachrichten verstehen, besitzen sie (zumindest teilweise) dieselben Operationen. Dieser Satz an gemeinsamen Operationen bildet eine gemeinsame Schnittstelle oder einen gemeinsamen Typ. Diese Schnittstelle ist dann eine Möglichkeit für Klienten, beide Objekte gleichartig zu behandeln. Wenn ein Klient nur über diese Schnittstelle auf beide Objekte zugreift, verschwinden für ihn alle anderen, möglicherweise vorhandenen Unterschiede zwischen diesen Objekten. Die Schnittstelle ist also eine Abstraktion der Realität. Die Eigenschaft, dass ein Klient unterschiedliche Objekte unter einer gemeinsamen Schnittstelle ansprechen kann, bezeichnet man als Polymorphie (Vielgestaltigkeit).
In unserem Beispiel verstehen das VHS-Abspielgerät JVC HR-S-5960 und das DVD-Abspielgerät Philips DVP 520 denselben Satz an Nachrichten: „Medium X einziehen“, „Medium auswerfen“, „Wiedergabe starten“ und „Wiedergabe stoppen“. Diese Operationen bilden also eine gemeinsame Schnittstelle, die wir Video-Bedienung nennen wollen, weil sie den Aspekt der Bedienung dieser beiden Geräte zusammenfasst (Abbildung23). Eine Person (etwa Hans) muss bei der Bedienung eines VHS- oder eines DVD-Abspielgeräts keine Unterschiede machen; sie greift also über die Schnittstelle Video-Bedienung polymorph auf beide Geräte zu.
Im täglichen Leben abstrahieren Sie andauernd. Beispielsweise ist es Ihnen egal, ob Sie einen Kugelschreiber oder einen Bleistift erwischen, wenn Sie sich nur kurz etwas auf einem Zettel notieren wollen. Die für Sie in diesem Augenblick interessante Schnittstelle, die Schreibfähigkeit des Stifts, wird von beiden in Frage kommenden Utensilien „verstanden“. Da Sie nur an dieser Schnittstelle interessiert sind, treten alle anderen Unterschiede zwischen Kugelschreiber und Bleistift zurück.
Sie sehen an diesem Beispiel auch, dass die Sichtweise auf zwei Objekte zu verschiedenen Zeiten unterschiedlich sein kann. Ein andermal möchten Sie vielleicht ein wichtiges Dokument unterschreiben und haben wieder den Bleistift und den Kugel
99
Abstraktion und gleiche Schnittstellen/Typen; Polymorphie
Abbildung 23: Video-Beispiel: Schnittstellen
VHS-Abspielgerät
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
DVD-Abspielgerät
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
<<interface>>Video-Bedienung
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
Person
<<realize>><<realize>>
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
schreiber zur Auswahl. Nun sind Sie bei dem zu verwendenden Stift auch an der Fähigkeit „dokumentenecht“ interessiert. Diese Eigenschaft kann als Schnittstelle in unserem Sinne aufgefasst werden: Stellen Sie sich vor, Sie schicken dem Objekt (Kugelschreiber oder Bleistift) die Frage „Bist du dokumentenecht?“ als Nachricht. Nur der Kugelschreiber wird mit „ja“ antworten, somit unterstützt in diesem Fall nur der Kugelschreiber die gewünschte Schnittstelle.
Wenn man von der Schnittstelle eines Objekts spricht, ohne konkret eine bestimmte zu benennen, so wird die vollständige Schnittstelle des Objekts gemeint, also alle Nachrichten, die das Objekt versteht.
In unserem Beispiel ist die Schnittstelle Video-Bedienung des JVC HR-S-5960 auch gleichzeitig dessen vollständige Schnittstelle, weil keine weiteren Dienste angeboten werden.
Die zweite Art der Ähnlichkeit geht noch weiter. Zwei Objekte können nämlich eine Nachricht nicht nur beide verstehen, sondern sich daraufhin gleich verhalten. Bei dieser Art der Ähnlichkeit sind also nicht nur die beiden entsprechenden Schnittstellen gleich, sondern auch das zugehörige Verhalten, ihre Implementierung. Diese Form der Ähnlichkeit kann so weit gehen, dass sich zwei Objekte in allen Aspekten genau gleich verhalten.
Hätte Hans in unserem Beispiel zwei JVC HR-S-5960-Geräte, stellten beide dieselben Operationen zur Verfügung (Schnittstellen-Gleichheit) und verhielten sich exakt gleich (Gleichheit der Implementierung).
Eine Schnittstelle sagt nur etwas darüber aus, was ein Objekt an Dienstleistungen erbringen kann. Das Wie wird dabei nicht spezifiziert. Objekte sind also weitestgehend unabhängig von der konkreten Umsetzung eines Dienstes. Dies macht objektorientierte Systeme so flexibel: Sie können die Implementierung einer Operation (eine Methode) verändern, ohne dass Sie irgendwelche anderen Teile des Systems anpassen müssen, da alle anderen Objekte die unveränderte Schnittstelle benutzen. Natürlich funktioniert das nur, wenn Sie die Schnittstelle nicht verändern und auch die Bedeutung der Operation nicht „umbiegen“. Eine Operation „Wiedergabe starten“ sollte auch immer eine Wiedergabe starten und nicht beispielsweise Kaffee kochen.
Der aufmerksame Leser wird feststellen, dass Polymorphie nur eine konsequente Fortsetzung der Trennung von Schnittstelle und Implementierung ist, die wir bei Operationen und Methoden bereits kennen gelernt haben. Auch Operationen sind in diesem Sinne polymorph, weil der Klient nie wissen kann, wie die Methode hinter einer Operation aussieht. Der Begriff „Polymorphie“ bezieht sich in der objektorientierten Welt üblicherweise jedoch auf die Austauschbarkeit von Objekten, also auf die Gemeinsamkeiten der Schnittstellen von Objekten und nicht von einzelnen Methoden. Das Konzept ist allerdings dasselbe. Somit ist Polymorphie, wie sie hier erläutert ist, ein weiteres Standbein der Flexibilität und Wartbarkeit objektorientierter Software.
Operationen werden zur Verdeutlichung der Tatsache, dass sie über die Implementierung nicht aussagen, abstrakte Operationen genannt.
100
gleiche Implementierung
vollständige Schnittstelle
Schnittstellen spezifizieren das „Was“
konsequente Trennung von Schnittstelle und Implementierung
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
In unserem Beispiel spezifiziert die Schnittstelle Video-Bedienung nicht, wie die Dienste erbracht werden. Sie werden von den beiden Video-Abspielgeräten JVC HR-S-5960 und Philips DVP 520 ganz unterschiedlich implementiert. Beispielsweise aktiviert ersterer beim Start der Wiedergabe einen Video-Kopf, um Informationen von einem Magnetband einzulesen, während letzterer einen Laser in Anspruch nimmt, um Vertiefungen in der DVD zu erkennen. Von der Bedeutung her sind aber beide gleich: Sie starten die Wiedergabe des eingelegten Video-Mediums.
Im vorletzten Abschnitt haben Sie gelernt, dass Objekte voneinander nur die jeweiligen Schnittstellen kennen. Deshalb ist es in der Software-Entwicklung sehr wichtig, diese Schnittstellen gut zu dokumentieren (z. B. über entsprechende Kommentare). Wenn die Schnittstellen schlecht dokumentiert sind, wird keiner die Dienstleistungen eines Objekts in Anspruch nehmen, das über diese Schnittstelle angesprochen werden kann – da keiner voraussehen kann, was wirklich passiert! Deshalb ist eine gute Dokumentation hier das A und O: Denken Sie immer an Merksatz 2!
4.1.4 AttributeEin Attribut ist im Prinzip die Definition einer Variable, die an ein Objekt gebunden ist. Zur Laufzeit hat jedes Objekt zu jedem seiner Attribute einen passenden Wert. Die Gesamtheit aller Werte zu einem Objekt bildet den Zustand des Objekts. Die Werte, die den Zustand eines Objekts bilden, sind nach außen hin nicht sichtbar; Klienten „sehen“ den Zustand nur indirekt, indem sie Nachrichten an das Objekt schicken, das entsprechend antwortet. Man spricht in dem Zusammenhang auch von Kapselung und Information Hiding, weil das Objekt seinen internen Zustand vor seinen Klienten wie in einer Kapsel versteckt.
In unserem Beispiel hat ein Video-Abspielgerät die Attribute „Marke“ und „Kaufpreis“ (Abbildung 24). Da es unseren Abspielgeräten an geeigneten Operationen zum Auslesen dieser Informationen fehlt, sind diese Attribute für alle potentiellen Klienten unsichtbar (und somit nutzlos!)
Ein Wert zu einem Attribut ist ansonsten ein gewöhnliches Objekt mit Operationen, Methoden, eigenen Attributen etc. Man könnte die Beziehung zwischen Objekt und Wert bzw. zwischen Klasse (s. u.) und Attribut auch als besondere Beziehung auffassen. Darauf geht Abschnitt 4.4.4 genauer ein.
4.1.5 KlassenSie haben gelernt, dass eine Schnittstelle einem Satz von Operationen entspricht. Eine Klasse ist auch eine Schnittstelle, allerdings greift der Begriff Klasse noch etwas weiter. Eine Klasse kann zusätzlich zu Operationen
101
Klassen spezifizieren das „Wie“
Schnittstellen müssen dokumentiert werden
Attribute sind Daten von Objekten
Abbildung 24: Video-Beispiel: Attribute
JVC HR-S-5960
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
Attribut „Marke“ = „JVC“Attribut „Kaufpreis“ = „100 EUR“
Philips DVP 520
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
Attribut „Marke“ = „Philips“Attribut „Kaufpreis“ = „60 EUR“
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
• Methoden und
• Attribute
besitzen. Während eine Schnittstelle immer nur aussagt, was ein Objekt tun kann bzw. was es an Nachrichten versteht (Operationen), sagt eine Klasse nun etwas darüber aus, wie das Objekt etwas tut (Methoden).
Somit gehören Objekte immer einer (konkreten, s. u.) Klasse an, weil ein Objekt immer auf alle Nachrichten reagieren muss, die es versteht, sprich zu jeder angebotenen Operation eine Methode parat haben muss. Man spricht davon, dass jedes Objekt ein Exemplar bzw. eine Instanz seiner Klasse ist.
Dementsprechend wird der Begriff Klasse auch verständlich. Alle Objekte derselben Klasse verstehen dieselben Nachrichten (da sie dieselbe Schnittstelle besitzen) und implementieren dasselbe Verhalten (da sie dieselben Methoden enthalten). Somit wird mit einer Klasse eine bestimmte Gruppe oder Klasse von Objekten beschrieben. Jedes dieser Objekte ist allen anderen Objekten derselben Klasse in Form (Schnittstelle) und Verhalten (Implementierung) gleich.
In unserem Beispiel haben wir bereits folgende konkrete Klassen kennen gelernt: „Person“, „VHS-Abspielgerät“, „DVD-Abspielgerät“, „VHS-Kassette“ und „DVD“.
Jede Schnittstelle ist auch eine Klasse, weil eine Klasse keine Methoden oder Attribute enthalten muss. Klassen, die nicht für alle Operationen eine Methode anbieten, werden abstrakt genannt. Zu einer abstrakten Klassen kann es keine Objekte geben. Im Gegenzug wird eine Klasse, die für jede Operation eine entsprechende Methode definiert, eine konkrete Klasse genannt.
In unserem Beispiel ist die Schnittstelle „Video-Bedienung“ eine solche abstrakte Klasse.
Zwischen einer Schnittstelle und einer abstrakten Klasse völlig ohne Methoden und Attribute gibt es faktisch keinen Unterschied. Beispielsweise werden beide Konzepte in C++ durch dasselbe Sprachmittel beschrieben. Allerdings gibt es Programmiersprachen wie Java und Notationen wie UML, die zwischen Schnittstellen und abstrakten Klassen einen Unterschied machen. Inbesondere wird die Konkretisierung von Klassen Vererbung und die von Schnittstellen Spezialisierung genannt (4.1.6).
Attribute, die innerhalb einer Klasse definiert sind, haben eine „Ganz oder gar nicht“-Eigenschaft: Jedes Objekt hat zu jedem Attribut seiner Klasse einen Wert – und zwar vom Zeitpunkt seiner Erzeugung bis hin zu seiner Zerstörung. Bei der Erzeugung eines Objekts werden die Attribute mit passenden Werten belegt. Im Verlauf des Programms können sich diese Werte verändern, nämlich dann, wenn das Objekt seinen Zustand ändert. Dies geschieht in der Regel als Reaktion auf eine Anforderung eines Klienten.
In unserem Beispiel sind die Attribute der Abspielgeräte konstant, weil sich der Kaufpreis und die Marke eines Geräts nach der Herstellung nicht mehr ändert. Hätten wir jedoch ein Attribut „Wiedergabe aktiv“, änderten die konkreten Abspielgeräte bei jeder Inanspruchnahme der Operationen „Wiedergabe starten“ und „Wiedergabe beenden“ den Wert des Attributs und somit ihren Zustand.
102
abstrakte und konkrete Klassen
Werte sind Belegungen von Attributen
Objekten sind Exemplare von Klassen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Ähnlich verhält es sich mit Operationen und Methoden: Jedes Objekt hat zu jeder Operation in seiner vollständigen Schnittstelle eine passende Methode. Allerdings ist diese Beziehung zwischen Operation und Methode unveränderlich: Sie können zur Laufzeit nicht bei einer Operation die Methode durch eine andere austauschen, während sie den Wert zu einem Attribut durchaus ändern können. Die Operationen, die ein Objekt versteht, und die Methoden, die diese implementieren, bleiben also in der gesamten Lebenszeit des Objekts konstant; sie werden durch die Klasse des Objekts beim Erzeugen des Objekts eindeutig festgelegt.
Der letzte Absatz trifft nicht immer zu. So gibt es Programmiersprachen (z. B. Smalltalk), bei denen diese Zuordnung zwischen Objekt und unterstützten Schnittstellen bzw. verstandenen Nachrichten nicht so einfach durchzuführen ist, weil es keine Repräsentation für Schnittstellen in der Sprache gibt und weil ein Objekt auch auf Nachrichten reagieren kann, die es eigentlich nicht versteht.31
4.1.6 Erweiterung, Spezialisierung und VererbungGenauso wie Beziehungen zwischen Objekten existieren, gibt es auch Beziehungen zwischen Schnittstellen und zwischen Klassen. Diese werden im Folgenden kurz vorgestellt.
Schnittstellen können durch weitere Operationen „angereichert“ werden. Diesen Vorgang nennt man Erweiterung der Schnittstelle. Die ursprüngliche, zu erweiternde Schnittstelle (oder den ursprünglichen Typ) nennt man auch den Supertyp, Obertyp oder Basistyp, die resultierte erweiterte Schnittstelle nennt man den Subtyp, Untertyp oder abgeleiteten Typ. (Alle diese Begriffe existieren auch mit -klasse am Ende, also etwa Oberklasse oder Unterklasse.)
In unserem Beispiel haben wir keine derartige Erweiterung, aber Sie könnten sich vorstellen, dass wir eine Schnittstelle „Erweiterte Video-Bedienung“ definieren. Diese Schnittstelle könnte die Schnittstelle „Video-Bedienung“ um die Operationen „Vorspulen“, „Zurückspulen“ und „Pausieren“ erweitern (Abbildung 25).
Eine weitere Beziehung zwischen Schnittstellen und/oder Klassen ist die Spezialisierung. Dabei wird eine Schnittstelle oder Klasse durch Methoden (Verhalten) oder Daten (Attribute) angereichert. Bei der Zuordnung einer Methode zu einer Operation sprechen wir von der Implementierung oder auch Definition der Operation. Das Ergebnis einer Spezialisierung ist auf jeden Fall eine (abstrakte oder konkrete) Klasse. Diese Beziehung wird Spezialisierung genannt, weil die resultierende Klasse durch
31) Das hört sich vielleicht paradox an, ist aber wahr. Sie können in Smalltalk für ein Objekt eine Methode namens doesNotUnderstand definieren, die alle Nachrichten empfängt, die das Objekt nicht versteht, und dort geeignet reagieren.
103
Beziehungen zwischen Typen
Spezialisierung von Schnittstellen und Klassen
Methoden sind Belegungen von Operationen
Abbildung 25: Video-Beispiel: Erweiterung einer Schnittstelle
<<interface>>Video-Bedienung
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
<<interface>>Erweiterte Video-Bedienung
Dienst „Vorspulen“Dienst „Zurückspulen“Dienst „Pausieren“
Erweiterung von Schnittstellen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
das Mehr an Methoden und/oder Attributen konkreter und damit spezieller ist als die ursprüngliche.
In unserem Beispiel ist die Beziehung zwischen einem VHS-Abspielgerät und einem Video-Abspielgerät eine Spezialisierung, denn jedes VHS-Abspielgerät implementiert die Operationen eines Video-Abspielgeräts. Ferner muss jedes Video-Abspielgerät die Schnittstelle „Video-Bedienung“ realisieren; dies drücken wir ebenfalls über eine Spezialisierung aus (Abbildung 26).
Eine Variante der Spezialisierung ist die Redefinition. Dabei werden keine Operationen, Methoden oder Attribute hinzugefügt. Vielmehr wird eine im Basistyp existierende Methode durch eine andere Methode ersetzt oder redefiniert. Diese Redefinition sollte natürlich so erfolgen, dass Klienten des Basistyps auch weiterhin funktionieren, d. h. das Verhalten der Methode im abgeleiteten Typ sollte das Verhalten der Methode im Basistyp umfassen.
Schließlich gibt es noch den Begriff der Vererbung, sozusagen die eierlegende Wollmilchsau. Die Vererbung steht für alle drei oben genannten Beziehungen zwischen zwei Schnittstellen bzw. Klassen. Wenn eine Klasse also von einer anderen erbt, kann das bedeuten, dass sie:
(1) die Schnittstelle der Basis-Klasse erweitert (Erweiterung) und/oder
(2) Operationen definiert oder Attribute hinzufügt (Spezialisierung) und/oder
(3) Methoden redefiniert (Redefinition).
Insbesondere der letzte Punkt in der obigen Aufzählung ist typisch beim Einsatz von Vererbung. In C++ ist auch der Begriff ableiten gebräuchlich.
Alle diese Beziehungen sind allesamt sogenannte „IST-EIN-“, „FUNKTIONIERT-WIE-“ oder „KANN-BENUTZT-WERDEN-WIE-“Beziehungen. Das bedeutet, dass auf der einen Seite der Beziehung eine Schnittstelle/Klasse steht, die an Stelle der Schnittstelle/Klasse auf der anderen Seite der Beziehung verwendet werden kann.
104
Redefinition von Methoden
Vererbung – alles zusammen
Ersetzbarkeit und LSP
Abbildung 26: Video-Beispiel: Spezialisierung von Schnittstellen und Klassen
<<interface>>Video-Bedienung
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
<<realize>>
Video-Abspielgerät
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
VHS-Abspielgerät
Dienst „Medium einziehen“Dienst „Wiedergabe starten“Dienst „Wiedergabe stoppen“Dienst „Medium auswerfen“
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
In unserem Beispiel gilt: Ein Video-Abspielgerät KANN-BENUTZT-WERDEN-WIE eine Video-Bedienung (besseres Deutsch wäre in diesem Fall: KANN-BENUTZT-WERDEN-ÜBER). Ein VHS-Abspielgerät IST EIN Video-Abspielgerät.
IST-EIN-Beziehungen oder generell Beziehungen zwischen Schnittstellen bzw. Klassen sind starre Beziehungen – sie ändern sich nicht im Programm-Verlauf. Deswegen ist es sehr wichtig, dass die abgebildeten Beziehungen auch „stimmig“ sind. Beispielsweise IST ein Student NICHT eine Person, denn er wird nicht als Student geboren bzw. beerdigt. Vielmehr ist ein Student eine bestimmte Rolle einer Person, die sie im zeitlichen Verlauf bekommen und auch wieder ablegen kann. Solche „dynamischen“ Typ-Zuweisungen sind über Spezialisierung und Vererbung nicht direkt herstellbar. Sie spielen aber in flexiblen, objektorientierten Entwürfen eine große Rolle (!) und werden häufig in Entwurfsmustern (6) verwendet.
Diese Ersetzbarkeit, die bei Erweiterung, Spezialisierung und Vererbung vorliegt, wird in dem Liskov’schen Substitutionsprinzip (LSP)32 formalisiert, welches genau beschreibt, wann eine solche „IST-EIN-“Beziehung vorliegt, nämlich wenn man in einem objektorientierten Programm Objekte einer Klasse durch Objekte einer anderen Klasse ersetzen kann, ohne dass sich die Bedeutung des Programms ändert. Das Liskov’sche Substitutionsprinzip spielt auch bei der Redefinition von Methoden eine große Rolle; siehe hierzu Abschnitt 4.6.4.1.
Vererbungs-Beziehungen zwischen Klassen erstrecken sich manchmal über mehrere Ebenen. Beispiel: Ein PKW IST EIN Auto IST EIN Fahrzeug. Man spricht in solchen Fällen von Vererbungslinien. Wenn mehrere Vererbungslinien in einer gemeinsamen Wurzel zusammenlaufen und somit eine Art Baumstruktur entsteht, spricht man gar von ganzen Vererbungshierarchien.
4.2 Ein erstes objektorientiertes ProgrammFalls Ihnen nach dem letzten Abschnitt der Kopf raucht, nur nicht verzweifeln! Lassen Sie uns gemeinsam ein erstes objektorientiertes Programm schreiben. Starten Sie also Ihren Lieblings-Editor und tippen Sie Folgendes ein:
1 /*** Beispiel oohello.cpp ***/2 #include <istream>3 #include <ostream>4 #include <iostream>5 #include <string>6 using namespace std;78 // diese Klasse begrüßt eine Person mit „Hallo“9 class HalloBegruessung10 {11 public :12 // begrüßt „person“ mit „Hallo“13 void begruesse (string person);14 };1516 void HalloBegruessung::begruesse (string person)17 {18 cout << "Hallo " << person << endl;19 }2021 int main ()
32) vgl. [Mart96]
105
Vererbungshierarchien
„hello“ einmal objektorientiert
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
22 {23 cout << "Bitte geben Sie Ihren Namen ein: ";24 string name;25 cin >> name;2627 // erzeuge Objekt28 HalloBegruessung begruessung;29 // sende Nachricht „begruesse“ an das Objekt „begruessung“,30 // zusammen mit dem Argument „name“31 begruessung.begruesse (name);3233 return 0;34 }
Lassen Sie uns die neuen objektorientierten Elemente untersuchen:
• Zeilen 8-14: Hier definieren wir eine Klasse HalloBegruessung. Klassen sind Schablonen für Objekte und definieren, welche Daten diese Objekte enthalten und welche Nachrichten sie verstehen. In unserem Beispiel haben Objekte der Klasse HalloBegruessung keine Daten, verstehen aber die Nachricht begruesse. Dies wird durch die Definition der Operation begruesse in Zeile 13 erledigt. Da die Operation einen Parameter person vom Typ string besitzt, müssen entsprechende Nachrichten an das Objekte immer ein Argument vom Typ string enthalten.
• Zeilen 16-19: Hier wird die Methode zu der Operation begruesse definiert. Die Methode definiert, was beim Erhalten der Nachricht begruesse tatsächlich getan wird. In unserem Fall wird einfach eine passende Begrüßung ausgegeben.
• Zeile 28: Hier wird ein Objekt der Klasse Hallo erzeugt. Wie Sie sehen, ist das eine ganz normale Variablen-Definition, nur dass als Typ eben eine Klasse und nicht ein primitiver Datentyp (wie int) verwendet wird.
• Zeile 31: Hier wird an das Objekt begruessung mit Hilfe des Punkt-Operators (.) die Nachricht begruesse verschickt. Das Verschicken einer Nachricht wird – bis auf den Unterschied mit dem Punkt-Operator – ansonsten genauso notiert wie der Aufruf einer Funktion. Insbesondere werden Daten, die zu einer Nachricht gehören, wie Argumente an die Methode übergeben.
Sie haben also an diesem Beispiel bereits vier grundlegende C++-Sprachmittel kennengelernt:
• Definieren einer Klasse samt Operationen
• Definieren von Methoden
• Erzeugen von Objekten
• Versenden von Nachrichten
Im Laufe des Skripts werden Sie weitere Sprachelemente kennen lernen und wann man diese geeignet einsetzt.
106
Definieren einer Klasse und ihrer Operationen
Definieren einer Methode
Erzeugen von Objekten
Versenden von Nachrichten
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Vielleicht werden Sie einwenden, dass die Nutzung objektorientierter Techniken bei diesem speziellen Beispiel keine nennenswerten Vorteile bringen. Das ist richtig; objektorientiertes Entwickeln kann erst bei größeren Projekten seine Vorteile ausspielen. Zur Demonstration grundlegender objektorientierter Konzepte in C++ taugt es jedoch allemal. Und ein 500-Zeilen-Programm würde Sie ohnehin nur verschrecken...
4.3 UML (Unified Modeling Language)Zur Darstellung von Klassen, Objekten und Beziehungen hat sich die UML als Notation durchgesetzt. Die UML ist eine Sprache zur Darstellung von Konstrukten aus dem Umfeld objektorientierter Technologien. Wir wollen in diesem Abschnitt die wichtigsten Diagramme vorstellen.
107
UML als Notation für OO-Entwicklung
Abbildung 27: Das Video-Beispiel als UML-Klassendiagramm
Video-Medium
VHS-Kassette DVD
Video-Film
Video-Abspielgerät
VHS-Abspielgerät DVD-Abspielgerät
Person
<<interface>>Video-Bedienung<<realize>>
marke: string
Tonkopf Videokopf Motor
0..*0..*
start()stop()wirfAus():Video-Medium
start()stop()wirfAus(): DVDnimm(med: DVD)
start()stop()wirfAus(): VHS-Kassettenimm(med: VHS-Kassette)
titel: stringlängeInMinuten: int
beschreibbar: bool
längeInMinuten: int groesseInMB: int
◄ besitzt
inhalt
0..*
benutzt ►
111 1
Laser
1
0..1
0..1
enth
ält ►
enth
ält ►
Fernbedienung <<realize>>
1
ziel
name: string
◄ beinhaltet
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
4.3.1 KlassendiagrammeIn einem UML-Klassendiagramm werden Klassen miteinander in Beziehung gesetzt. Abbildung 27 zeigt ein Klassendiagramm, das die Klassen und Beziehungen aus unserem Video-Beispiel zusammenfassend darstellt. Es folgen die Erläuterungen zu dem Diagramm:
• Alle Klassen (inklusive Schnittstellen) werden in UML durch Kästen dargestellt. Ein solcher Kasten besteht aus drei Teilen: Im obersten wird der Name der Klasse notiert, es folgen die Attribute und schließlich die Operationen bzw. Methoden. Der Teil mit den Attributen bzw. Methoden kann entfallen, wenn die Klasse keine Attribute bzw. Methoden enthält.
• Abstrakte Klassen wie Video-Abspielgerät oder Video-Medium werden in der UML-Notation durch einen kursiv gesetzten Namen oder durch den Zusatz {abstract} gekennzeichnet.
• Schnittstellen werden wie Klassen dargestellt, aber zusätzlich mit dem Stereotyp <<interface>> markiert. Es dürfen keine Attribute oder Methoden (s. u.) vorhanden sein.
• Die Implementierung einer Schnittstelle wird in der UML-Notation durch einen gestrichelten Pfeil mit hohler Pfeilspitze und dem Stereotyp <<realize>> dargestellt, wobei die speziellere Klasse auf die allgemeinere Schnittstelle zeigt.
• IST-EIN-Beziehung zwischen Klassen und generell alle Vererbungsbeziehungen, die nicht Spezialisierung von Schnittstellen sind, werden in der UML durch einen durchgezogenen Pfeil mit hohler Pfeilspitze dargestellt, wobei die speziellere Klasse auf die allgemeinere zeigt.
• Abstrakte Operationen werden kursiv geschrieben. Operationen werden in den dritten Teil eines Klassen-Kastens platziert; wenn keine Attribute vorhanden sind, können sie auch im zweiten Teil stehen.
• Attribute werden in den zweiten Teil eines Klassen-Kastens eingefügt.
• Methoden in implementierenden Klassen werden wie Operationen notiert, allerdings diesmal nicht kursiv. Dadurch wird sichtbar, dass für diese Operationen in dieser Klasse Methoden existieren.
• Normale Beziehungen werden durch einen durchgezogenen Pfeil dargestellt, wobei der Pfeil vom Enthaltenden zum Enthaltenen, vom Kennenden zum Gekannten, vom Verwender zum Verwendeten u. s. w. zeigt. An der Pfeilspitze steht eine Multiplizität: 0..* bedeutet „beliebig viele“, 1 „genau ein“, 1..* „mindestens ein“, 3..4 „drei bis vier“ u. s. w. An den beiden Enden des Pfeils können Rollen-Bezeichnungen stehen; so nimmt der Video-Film beispielsweise in der Beziehung zwischen Medium und Film die Rolle des Inhalts ein. Schließlich kann der Assoziations-Pfeil genauer durch entsprechende Beschriftungen erläutert werden; im Beispiel wird die erwähnte Beziehung als „beinhaltet ►“ tituliert, so dass klar ist, dass ein Video-Medium Video-Filme beinhaltet (und nicht etwa besitzt, benutzt o. ä.)
108
Beziehungen zwischen Klassen
Klassen
(abstrakte) Operationen
Attribute
Methoden
Assoziationen
abstrakte Klassen
reine Schnittstellen
Spezialisierung von Schnittstellen
Spezialisierung von Klassen; Vererbung
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
• In der UML werden Aggregationen als Linien mit einer hohle Raute bei jener Klasse markiert, das in der Ganzes-Teile-Beziehung das Ganze repräsentiert. In unserem Beispiel besteht ein VHS-Abspielgerät aus Tonköpfen, Video-Köpfen und Motoren.33
4.3.2 AktivitätsdiagrammeWährend Klassendiagramme die Struktur eines objektorientierten Programms beschreiben und darstellen, wie Objekte der entsprechenden Klassen zur Laufzeit des Programms miteinander in Beziehung stehen, ist der Zweck von Aktivitätsdiagrammen die Darstellung von Algorithmen. Dementsprechend sind sie ähnlich aufgebaut wie Programmablaufpläne oder Petri-Netze; wenn Sie mit den Notationen vertraut sind, sollten Sie keinerlei Probleme haben, die UML-Notation zu verstehen.
Abbildung 28 stellt das Beispiel zur do-Schleife aus Abschnitt 3.5.4.2 dar.
An den Diagramm können Sie grundlegende Elemente gut erkennen. Aktionen oder Aktivitäten werden durch „Beinahe-Rechtecke“ dargestellt. Jede Aktivität steht für eine Aktion des Programms, oder für mehrere Aktionen, die zu einem logischen
Ganzen verbunden sind und entweder alle zusammen oder gar nicht ausgeführt werden. Jede Aktivität enthält in ihrem Kasten eine kurze Zusammenfassung ihrer Wirkung, etwa „Name einlesen“.
33) sowie aus vielen anderen Teilen, die aus Gründen der Übersichtlichkeit und der Unkenntnis des Autors bewusst weggelassen wurden
109
Aggregationen
Beziehungen zwischen Aktivitäten
Aktivitäten
Abbildung 28: Aktivitätsdiagramm zum do-Beispiel (3.5.4.2)
Name initialisieren
Anfrage ausgeben
Name einlesen
Name zurückgeben
[Name leer][Name nicht leer]
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Übergänge zwischen den einzelnen Aktivitäten werden Transitionen genannt und durch Pfeile dargestellt. Eine einfache Transition zwischen zwei Aktivitäten bedeutet, dass beim Programmablauf die Kontrolle von der ersten Aktivität zur zweiten übergeht, und zwar ohne Ausnahme.
Rauten kennzeichnen Verzweigungen. Eine Verzweigung wird immer dann verwendet, wenn es mehrere Möglichkeiten gibt, nach der Ausführung einer Aktivität weiterzumachen. An einer Verzweigungsstelle gibt es also mindestens zwei Pfeile zu unterschiedlichen Aktivitäten. An den Pfeilen stehen innerhalb von eckigen Klammern Bedingungen, welche die Umstände spezifizieren, unter denen eine Transition ausgewählt wird. Im Beispiel hängt es vom eingelesenen Namen ab (leer oder nicht leer), ob nach dem Einlesen die Aktivität „Name zurückgeben“ oder „Anfrage ausgeben“ ausgeführt wird.
Nicht im Diagramm dargestellt, aber ebenfalls möglich sind Zusammenführungen von Verzweigungen an einer Raute. Dabei münden mehrere Kontrollfluss-Stränge an einer solchen Zusammenführung, und ein einzelner Pfeil führt zu der Aktivität, die der Zusammenführung folgt. Bei einer solchen Zusammenführung stehen natürlich keine Bedingungen an den Pfeilen. So eine Zusammenführung ist beispielsweise notwendig, wenn einer if-Anweisung weitere Anweisungen folgen.
Schleifen werden einfach dargestellt, indem ein Pfeil zu einer Aktivität „weiter oben“ im Diagramm geführt wird. Schleifen werden immer mit einer passenden Verzweigung gekoppelt, die dafür sorgt, dass die Schleife auch wieder verlassen werden kann.
4.4 Konkrete Datentypen: Daten und Methoden kapselnIn diesem Abschnitt werden Sie mit der objektorientierten Programmierung in C++ am Beispiel konkreter Klassen vertraut gemacht. Sie lernen in diesem Abschnitt, wie Sie mit dem objektorientierten Paradigma im Hinterkopf an ein Problem herantreten und es lösen. Nach dem Lesen dieses Abschnitts werden Sie auch wissen, wie Sie konkrete Klassen, Methoden und Objekte in C++ definieren und verwenden können.
4.4.1 ProblemstellungEs ist soweit: Sie lösen Ihr erstes Programmierproblem auf die objektorientierte Art und Weise. Die Aufgabe lautet folgendermaßen:
Implementieren Sie einen Queue-Container, der nach dem FIFO-Prinzip arbeitet! Die Queue muss:• neue Objekte aufnehmen können,• bestehende Objekte entfernen können,• die Information zur Verfügung stehen, ob sie Elemente
enthält oder nicht.
110
Zusammenführungen
Transitionen
Verzweigungen
Schleifen
objektorientierte Vorgehensweise
die zu lösende Aufgabe
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.4.2 Analyse-Phase
4.4.2.1 FachlexikonIn der Analyse-Phase versuchen wir zuerst, das Problem zu verstehen. Zuerst müssen wir unbekannte Begriffe klären:
• Queue: Englischer Begriff für →Schlange• Schlange: ein →Behälter für Objekte, der nach einem ganz bestimmten Prinzip,
dem →FIFO-Prinzip, arbeitet.
• Container: englischer Begriff für →Behälter• Behälter: ein Objekt, das →Elemente enthält und Operationen zum Verwalten
dieser Elemente anbietet, etwa Hinzufügen, Entfernen oder Suchen.
• FIFO: FIFO steht für „First in, first out“ und bedeutet, dass das zuerst in einen →Behälter hineingesteckte →Element auch zuerst wieder herauskommt. Gegensatz: →LIFO. Siehe auch →Schlange
• LIFO: LIFO steht für „Last in, first out“ und bedeutet, dass das zuletzt in einen →Behälter hineingesteckte →Element zuerst wieder herauskommt. Gegensatz: →FIFO. Siehe auch →Stapel.
• Stapel: ein →Behälter, der nach dem →LIFO-Prinzip arbeitet.
• Element: ein Objekt, das sich in einem →Behälter befindet.
Wie Sie sehen, sind beim Analysieren einige neue Informationen hinzugekommen, die aus dem Umfeld der Aufgabenstellung stammen. Das ist typisch für die Analysephase: Anfangs kann man normalerweise gar nicht absehen, welche Informationen später noch von Bedeutung sind und welche nicht.
Diese erarbeiteten Begriffsdefinitionen werden in einem sogenannten Fachlexikon zusammengefasst. Dieses Fachlexikon enthält Ihr Wissen zur Aufgabenstellung und ist nutzlos, wenn die enthaltenen Informationen falsch sind. Ein gutes Fachlexikon enthält nicht nur korrekte Definitionen der Begriffe, sondern auch Informationen über die Beziehungen zwischen den Begriffen (Querverweise).
Denken Sie nicht, dass Ihr Fachlexikon bereits am Anfang der Software-Entwicklung fertig ist und keinerlei Veränderungen mehr bedarf! Oft stellt sich während der nächsten Phasen heraus, dass einige Begriffe noch fehlen, zu ungenau formuliert sind oder für die weitere Software-Entwicklung nicht von Bedeutung sind. Machen Sie sich also auf die kontinuierliche Pflege des Fachlexikons gefasst.
4.4.2.2 Fachklassen-DiagrammWährend der Beschäftigung mit der Aufgabenstellung fallen Begriffe und Konzepte, die Sie nicht nur in Ihrem Fachlexikon notieren, sondern die auch in vielfältiger Weise miteinander in Beziehung stehen. Diese Beziehungen lassen sich oft einfacher mit einem Fachklassen-Diagramm ausdrücken als durch bloße textuelle Beschreibung. Sie identifizieren also die zentralen Begriffe und stellen sie in Beziehung zueinander. Im Allgemeinen stellt ein Fachklassen-Diagramm nur eine andere Darstellung der In
111
Problem verstehen
Sammlung von Begriffen im Fachlexikon
Beziehungen im Diagramm darstellen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
formationen im Fachlexikon dar; jede Beziehung und jede Klasse im Diagramm sollte auch im Fachlexikon aufgeführt und erklärt sein.
In unserem Beispiel sind die zentralen Begriffe Queue, Container und Element (wir wählen im Folgenden die englischen Begriffe, da sie oft kürzer sind und die Diagramme überschaubarer machen). Die zentralen Beziehungen sind sicherlich die KENNT-EIN-Beziehung zwischen einem Container und seinen Elementen34 sowie die IST-EIN-Beziehung zwischen der Queue und dem Container. Übertragen auf ein Klassendiagramm resultiert das Diagramm in Abbildung 29.
Während dieses Diagramm die erarbeiteten Beziehungen einigermaßen korrekt wiedergibt35, haben wir bei der Modellierung eine Schnittstelle verwendet. In diesem Abschnitt wollen wir uns jedoch auf konkrete Klassen beschränken. Deshalb verwenden wir ab jetzt ein etwas vereinfachte Modell (Abbildung 30).
34) Hier fragen Sie sich vielleicht, warum nicht auch eine HAT-EIN-Beziehung zwischen Container und dessen Elementen möglich ist. Kurz gesagt macht HAT-EIN als Ganzes-Teile-Beziehung (4.1.2) nur Sinn, wenn ein Element höchstens zu einem einzigen Container gehört (eine HAT-EIN-Beziehung ist immer hierarchisch). Da wir nicht verhindern wollen, dass Elemente durchaus verschiedenen Containern angehören können, modellieren wir die Beziehung zwischen einem Container und seinen Elementen als „normale“ KENNT-EIN-Beziehung.
35) Das Fachklassen-Lexikon legt nahe, dass die Beziehung zu den Elementen von Container ausgehen sollte und nicht von Queue. Da hier aber Queue als konkreter Container die Art und Weise der Speicherung von Elementen bestimmen muss und Container als Schnittstelle überdies gar keine Elemente verwalten kann, wurde diese Beziehung der Queue-Klasse zugerechnet.
112
vereinfachtes Modell
Abbildung 30: Vereinfachtes Fachklassen-Diagramm zur Queue-Aufgabe
Queue
add(e:Element)remove():ElementisEmpty():bool
Element0..*
elements
contains ►
Abbildung 29: Fachklassen-Diagramm zur Queue-Aufgabe
Queue
add(e:Element)remove():ElementisEmpty():bool
Element0..*
<<interface>>Container
add(e:Element)remove():ElementisEmpty():bool
<<realize>>
elementscontains ►
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.4.3 Entwurfs-PhaseIn der Entwurfs-Phase wird das Modell aus der Analyse weiter verfeinert. Diese Verfeinerung geht im Idealfall so weit, dass die Umsetzung des Entwurfs hinterher nur noch „ein Klacks“ ist. In der Regel werden erst im Entwurf Aufgaben den einzelnen Klassen zugeteilt und konkretes Verhalten spezifiziert.
4.4.3.1 TypenBisher haben wir uns kaum Gedanken um die Klasse Element gemacht. Jetzt stehen wir aber vor einem Problem. Im Idealfall möchten wir nämlich erreichen, dass unsere Queue Elemente beliebigen Typs enthalten kann, dabei aber Typ-sicher ist. Das bedeutet, dass wir gerne eine Queue hätten, die nur int-Werte enthält (wenn wir Zahlen in einer Queue speichern wollen), eine andere Queue soll nur Zeichenketten vom Typ string speichern, wieder eine andere Queue soll einzelne Zeichen vom Typ char speichern u. s. w.
Mit den uns bekannten Sprachmitteln lässt sich das nicht erreichen, die dazu erforderlichen Sprachkonzepte werden erst in Abschnitt 7.2 eingeführt. Wir müssen also uns auf einen Element-Typ festlegen. Wir wählen zu Demonstrationszwecken den Datentyp int, „verstecken“ ihn aber durch einen Typ-Alias namens Element, so dass wir die Klasse später leicht generisch (d. h. Typ-unabhängig) machen können.
4.4.3.2 VerhaltenIn unserem kleinen Beispiel haben wir bereits in der Analyse-Phase die Operationen der Klasse Queue bestimmt. Allerdings haben wir uns noch nicht konkret darüber Gedanken gemacht, wie die contains-Beziehung abgebildet wird. Objektorientierte Programmiersprachen haben nämlich nur in seltensten Fällen Sprachmittel zur direkten Unterstützung von 0..*-Beziehungen. In der Regel können nur Zu-Eins-Beziehungen – also Beziehungen zu genau einem Objekt – problemlos realisiert werden. C++ bildet hier keine Ausnahme, so dass wir uns etwas geeignetes überlegen müssen.
Am Einfachsten ist es, wenn wir auf eine bestehende Lösung aufbauen könnten. Die C++-Standard-Bibliothek (8) bietet uns verschiedene Möglichkeiten, Objekte zu verwalten. Wir wählen die Klasse list zur internen Speicherung der verwendeten Objekte. Diese Klasse erlaubt es, auf einfache Weise Elemente „hinten“ hinzuzufügen und „vorne“ herauszunehmen. Man kann auch leicht überprüfen, ob ein list-Objekt Elemente enthält.
4.4.3.3 ZuständeWir müssen uns noch detaillierter mit dem Verhalten unserer Queue beschäftigen. Zuerst haben wir noch nicht definiert, in welchem Zustand sich die Queue direkt nach der Erzeugung befindet. Enthält sie dann irgendwelche Objekte? Es ist klar, dass eine frisch erzeugte Queue am besten leer ist. Das scheint trivial, in größeren Projekten ist es aber unerlässlich, auch „Kleinigkeiten“ gewissenhaft zu notieren.
113
Problem lösen
Typen ergänzen
Verhalten detaillieren
Zustände ausmachen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
4.4.3.4 Fehler-SituationenAußerdem haben wir noch nicht darüber nachgedacht, was passieren soll, wenn die Methode remove auf eine leere Queue angewandt wird. Eigentlich soll sie das erste Element aus der Queue entfernen und zurückgeben, nur dass eine leere Queue kein erstes Element besitzt. Es handelt sich also um eine Fehlersituation, die geeignet signalisiert und behandelt werden muss. C++ bietet die Möglichkeit, bei einem Fehler den normalen Programmfluss zu unterbrechen und ein Fehlerobjekt an den Aufrufer auf eine bestimmte Weise zurückzugeben, so dass
• dieser weiß, dass ein Fehler aufgetreten ist,
• dieser weiß, welcher Fehler aufgetreten ist und
• er diesen geeignet behandeln kann (und zwar möglichst unmittelbar nach dem Auftreten des Fehlers).
Diese Art der Fehlerbehandlung werden Sie detaillierter in Kapitel 5 kennen lernen. Wir halten an dieser Stelle fest, dass bei einer remove-Nachricht an eine leere Queue ein Objekt der Klasse RemoveOnEmptyQueue generiert wird, die wir natürlich in unseren Entwurf integrieren müssen.
4.4.3.5 Resultierendes KlassendiagrammDamit wäre im Entwurf alles getan. Die Aufgaben sind klar verteilt – die Queue delegiert die Nachrichten, entsprechend verändert, an die Klasse list. Demnach erhalten wir das Diagramm in Abbildung 31. Dabei modellieren wir list bewusst nicht als Klasse, sondern als Attribut. Näheres hierzu finden Sie im nachfolgenden Abschnitt.
Der gestrichelte Pfeil zwischen Queue und RemoveOnEmptyQueue zeigt übrigens eine Abhängigkeit an. Queues besitzen zwar keine Objekte der Klasse RemoveOnEmptyQueue, und sie unterhalten auch keine sonstigen Beziehungen zu solchen Objekten, aber sie können Objekte dieser Klasse erzeugen. Das macht sie ebenfalls zu Klienten der Klasse RemoveOnEmptyQueue, und diese Abhängigkeit ist hier mit modelliert worden. Der Stereotyp <<creates>> weist deutlich auf die Art der Abhängigkeit hin.
114
Fehler-Situationen entdecken
überarbeitetes Klassendiagramm
Abhängigkeiten darstellen
Abbildung 31: Klassendiagramm nach der Entwurfsphase
Queue
add(e:Element)remove():ElementisEmpty():bool
elements: list Element0..*
elements
contains ►
RemoveOnEmptyQueue<<creates>>
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.4.4 Exkurs: Attribute und kompositionale BeziehungenIm letzten Beispiel haben Sie gesehen, dass list nicht per Assoziation mit der Klasse Queue verbunden wurde, sondern als Attribut eingebunden ist. Überhaupt werden Sie sich fragen, wozu Attribute denn gut seien. Denn schließlich ist in einem objektorientierten System alles ein Objekt, und jedes Objekt gehört einer Klasse an, oder? Somit kann man folgern, dass keine Attribute notwendig seien und alles über Assoziationen abgebildet werden kann, oder?
Die Antwort auf diese Fragen ist Übersichtlichkeit. Letztlich enthalten Objekte irgendwie „fundamentale“ Daten wie Zahlen, Zeichen, Zeichenketten oder Wahrheitswerte. Es ist richtig, dass in einer rein objektorientierten Welt beispielsweise jede Zahl ein eigenes Objekt der entsprechenden Zahlen-Klasse ist. Es ist jedoch so, dass wenn für jede solche Beziehung zu einer derart fundamentalen Klasse eine Beziehung im Diagramm eingezeichnet werden müsste, die Diagramme sehr bald sehr voll würden. Wenn eine Klassen z. B. fünf Attribute von jeweils drei verschiedenen Typen hat, müssten zusätzlich zum Klassen-Kasten noch drei weitere Kästen und fünf weitere Pfeile gezeichnet werden. Die Notation von Attributen für Objekte von solchen „fundamentalen“ Typen verkleinert die Diagramme also erheblich.
Das Charakteristische an Attributen ist, dass sie außerhalb ihres Objekts nicht existieren können. Diese Art von Beziehung haben wir noch nicht kennen gelernt: Es handelt sich dabei um die Komposition, die eine strengere Form der Aggregation ist. Attribute können also (mit allen oben genannten Nachteilen) als kompositionale Beziehungen modelliert werden. Abbildung 32 zeigt, wie das obige Klassendiagramm nach der Änderung von Attributen zu Kompositionen aussieht.
4.4.5 Implementierungs-PhaseJetzt kommen wir zur Umsetzung des Entwurfs in C++ (endlich!) Zuerst müssen wir die Syntax von C++ für das Definieren von Klassen, Operationen und Methoden kennen lernen.
4.4.5.1 Klassen und MethodenIn C++ hat eine (einfache) Klasse ungefähr folgenden Aufbau:
class Name
115
Attribute oder Beziehungen?
Attribute sind übersichtlicher bei fundamentalen Typen
Kompositionen sind strenge Aggregationen
Klassen-Definitionen in C++
Abbildung 32: Komposition anstatt von Attributen
Queue
add(e:Element)remove():ElementisEmpty():bool
Element0..*
elements
contains ►list1
RemoveOnEmptyQueue
<<creates>>
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
{[Zugriffs-Modifizierer1 :]
Element-Deklaration1Element-Deklaration2...
[Zugriffs-Modifizierer2 :]
Element-Deklaration3Element-Deklaration4...
};Am Anfang steht der Klassen-Kopf, der (zuerst) nur aus dem Namen der Klasse besteht. Nach der folgenden öffnenden geschweiften Klammer folgt der Klassen-Körper, der aus Deklarationen von Elementen besteht, wobei unter Elementen hier Operationen, Attribute und Typen gemeint sind. Optional kann solchen Deklarationen ein Zugriffs-Modifizierer vorangestellt werden. Mehr dazu erfahren Sie in Abschnitt 4.4.6.
Wenn ein Zugriffs-Modifizierer gänzlich fehlt, wird private angenommen. Beispiel:
1 class Flugzeug2 {3 void hebAb ();4 };
Die Methode hebAb ist privat und kann von Objekten anderer Klassen nicht verwendet werden.
Operations- und Attribut-Definitionen unterscheiden sich nicht von gewöhnlichen Funktions- bzw. Variablen-Deklarationen. Einen Unterschied gibt es bei den Attributen aber dennoch: sie dürfen nicht innerhalb der Klassendefinition initialisiert werden. Zur Initialisierung muss ein sogenannter Konstruktor definiert werden, dazu aber später mehr (4.5).
Erzeugen Sie für dieses Beispiel unbedingt ein eigenes Projekt, da es aus mehreren Dateien bestehen wird. Nennen Sie das Projekt queue1, weil Sie in den Übungen noch eine zweite Version der Queue-Aufgabe entwickeln werden. Und Sie wollen natürlich auch alle Übungen lösen, oder?
4.4.5.2 Definition der Klassen und SchnittstellenZuerst definieren wir die Schnittstelle der Klasse Queue, damit andere Module darauf zugreifen können. Wir fangen also mit der Header-Datei an. Tippen Sie Folgendes in eine Datei mit dem Namen queue.h ein:
1 /*** Beispiel queue1/queue.h ***/2 #include <list>3 using namespace std;4
116
Klassen-Kopf und Klassen-Körper
Definition der Klasse
Elemente sind standardmäßig privat!
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Zuerst der übliche „Programm-Kopf“. Beachten Sie, dass eine neue Header-Datei eingebunden wird. <list> enthält die Definition der Klasse list, die wir weiter hinten benötigen.
5 typedef int Element;Hier definieren wir den Typ Element als Alias zu int.
6 class Queue7 {
Der Klassen-Kopf.8 public :
Damit drücken wir aus, dass die folgenden Elemente der Klasse öffentlich zugänglich sind und somit zur öffentlichen Schnittstelle der Klasse gehören (4.4.6).
9 // stellt "element" ans Ende der Schlange10 void add (Element element);1112 // entfernt das erste Element aus der Schlange und gibt es zurück; wirft ein Objekt der13 // Klasse RemoveOnEmptyQueue aus, falls die Methode für eine leere Queue aufgerufen
wird14 Element remove ();1516 // liefert true zurück, wenn die Queue keine Elemente enthält, false sonst17 bool isEmpty () const;
Hier definieren wir die Operationen, welche die Klasse anbietet. Wenn die Schreibweise so aussieht wie für „normale“ Funktionen, dann wird dadurch auch gleich ausgedrückt, dass an anderer Stelle eine passende Methode dafür existiert. Die Syntax für abstrakte Operationen sieht etwas anders aus, diese lernen Sie in Abschnitt 4.6.1 kennen.
Neu für Sie ist das const hinter der Deklaration einer Operation. Es bedeutet, dass das Objekt, für das die Methode aufgerufen ist, zum Zeitpunkt der Ausführung wie ein const-Objekt behandelt wird. Das bedeutet im Klartext, dass eine solche Methode das Objekt nicht verändern darf. Mehr dazu finden Sie in Abschnitt 4.4.7.1.
18 private :Die öffentliche Schnittstelle ist abgeschlossen, jetzt kommen interne Details der Klasse, die nicht nach außen sichtbar sein sollen.
19 list<Element> elements;
Unsere die Elemente verwaltende Liste. Die seltsame Syntax mit den spitzen Klammern hat ihren Ursprung darin, dass list eine generische Klasse oder Schablone ist, die mit vielen Typen funktioniert. Durch <Element> wird dem Übersetzer mitgeteilt, dass wir eine Liste aus Element-Elementen definieren und benutzen wollen. Mehr zu Schablonen erfahren Sie in Abschnitt 7.2.
20 };21
Das Ende der Klassendefinition.22 class RemoveOnEmptyQueue23 {
117
const-Operationen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
24 };
Unsere die Ausnahme repräsentierende Klasse. Dies ist eine leere Klasse – sie enthält weder Attribute noch Operationen noch Methoden. Dennoch ist sie an dieser Stelle völlig ausreichend; warum das so ist, werden Sie in Abschnitt 5 verstehen.
Damit ist unsere Header-Datei abgeschlossen, und wir können uns dem Programm-Code zuwenden.
Wenn Sie sich in der Programmiersprache Java auskennen, werden Ihnen jetzt sicher einige Unterschiede zwischen Klassen-Deklarationen in Java und in C++ aufgefallen sein:
• In Java ist für „öffentliche“ Klassen ein public vor der Klassendefinition notwendig. In C++ sind alle Klassen öffentlich; will man Klassen verstecken, muss man sie in einem anonymen Namensraum (8.2) definieren.
• Die Klassen-Definition in C++ endet mit einem Semikolon, in Java nicht.
• In C++ werden die Methoden (in der Regel) außerhalb der Klasse definiert, in Java gibt es nur die Möglichkeit der Definition innerhalb der Klasse.
• In C++ können sich Elemente einen Zugriffs-Modifizierer teilen, in Java muss der Zugriffs-Modifizierer vor jeder Definition stehen. Weiterhin endet der Zugriffs-Modifizierer in C++ mit einem Doppelpunkt, in Java nicht.
• Wie oben erwähnt, können in C++ die Attribute nicht innerhalb der Klassendefinition initialisiert werden, in Java aber schon.
• Eine Datei, die eine Klasse enthält, muss in C++ nicht genauso wie die Klasse heißen, in Java schon.
4.4.5.3 Implementierung der OperationenJetzt kommen wir zu der Implementierung der Operationen der Klasse, den Methoden. In einer neuen Datei tippen Sie bitte folgenden Programm-Code in eine Datei namens queue.cpp ein:
1 /*** Beispiel queue1/queue.cpp ***/2 #include "queue.h"3
Beachten Sie, dass wir die Header-Datei queue.h einbinden, um die Definition der Klasse verfügbar zu machen.
4 void Queue::add (Element e)5 {6 elements.push_back (e);7 }8
Die Implementierung der Operation add. Bis auf den ::-Operator sollten Sie nichts ungewöhnliches feststellen. Der ::-Operator erlaubt es, bei der Verwendung eines Namens explizit einen Gültigkeitsbereich zu spezifizieren. In diesem Fall wollen wir die Methode add der Klasse Queue definieren, sind aber nicht mehr im Gültigkeitsbereich der Klasse. Durch Queue:: wird der nachfolgende Name add vom Übersetzer innerhalb der Definition der Klasse Queue gesucht. Queue::add wird auch qualifizierter Bezeichner genannt.
118
Unterschiede zur Java-Klassendefinition
Definition der Methoden
::-Operator
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Zum Gültigkeitsbereich: Jede Klasse bildet einen eigenen, kleinen Gültigkeitsbereich. Ein Name, der innerhalb einer Klassendefinition durch eine Deklaration eingeführt wird, ist also nur innerhalb dieser Klasse sichtbar. Das bedeutet, dass Sie sich nicht darüber Gedanken machen müssen, dass in zwei verschiedenen Klassen eine Operation denselben Namen hat: Da beide in verschiedenen Gültigkeitsbereichen liegen, ist das überhaupt kein Problem. Die Folge dieser Regelung ist aber, dass gelegentlich der Gültigkeitsbereich explizit mit Hilfe des ::-Operators (s. o.) angegeben werden muss, damit der Übersetzer einen verwendeten Namen richtig zuordnen kann.
Sie lernen hier auch noch einen weiteren Operator kennen: den Elementzugriffs-Operator . (Punkt!) Dieser Operator wird Ihnen in der objektorientierten (C++-)Welt ständig begegnen. Er ist nämlich derjenige Operator, der es Ihnen erlaubt, eine Nachricht an ein Objekt zu schicken.
Schauen Sie sich einmal die Zeile 6 genauer an: elements.push_back (e) bedeutet, dass dem Objekt elements die Nachricht push_back mit e als Argument geschickt wird. Dies führt dazu, dass eine Methode namens push_back für dieses Objekt aufgerufen wird. Ein weiteres Beispiel finden Sie in Zeile 16 (s. u.), dort gleich zweimal. Zuerst wird dem elements-Objekt die Nachricht begin geschickt. Das Ergebnis wird das Argument der Nachricht erase, die ebenfalls an das elements-Objekt gesendet wird. Die ganze Logik der objektorientierten Programmierung liegt also im Verschicken der richtigen Nachrichten mit richtigen Argumenten an die richtigen Objekte zur richtigen Zeit.
Preisfrage: Was für ein elements-Objekt wird in Zeile 6 als Ziel für die push_back-Nachricht benutzt? Na, das aus der Klassendefinition in Zeile 19, werden Sie antworten. Ja, aber es kann doch viele Objekte zu einer Klasse geben, wobei jedes ein „eigenes“ elements-Objekt besitzt. Woher weiß denn die Methode, welches zu benutzen ist?
Ich kann Sie beruhigen: Alles hat seine Ordnung. Immer wenn Sie eine Nachricht an ein Objekt schicken, wird an die zugehörige Methode auch das Objekt übergeben, für das sie aufgerufen wurde. Dieses Objekt wird jedoch nirgends deklariert und ist sozusagen „versteckt“. Es gibt in C++ das Schlüsselwort this, das einen Zeiger (3.4.3.3) auf das Objekt zurückgibt, für das eine Methode aufgerufen ist. Dieser Zeiger wird vom Übersetzer automatisch benutzt, wenn auf Objekt-eigene Attribute zugegriffen wird.
Wenn Sie das obige Beispiel betrachten, sehen Sie, dass an das elements-Attribut die Nachricht push_back geschickt wird. Innerhalb der Implementierung dieser Methode verweist this auf das Objekt elements. Wenn nun die Klasse list ein Attribut namens tail enthält und die Methode push_back darauf zugreift, wird konkret auf elements.tail zugegriffen, weil die Nachricht an das elements-Objekt geschickt wurde.
119
eine Klasse – ein Gültigkeitsbereich
Zugriff auf Klassenelemente über den .-Operator
das eigene Objekt: this
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Generell wird immer this verwendet, wenn Sie beim Zugriff auf Attribute und beim Aufruf von Methoden kein Objekt angeben. Die obige Funktion könnte man also folgendermaßen umschreiben:
4 void Queue::add (Element e)5 {6 this->elements.push_back (e);7 }8
Dabei ist der Operator -> eine besser lesbare Kombination aus Dereferenzierung (*) und Element-Zugriff (.); eine weitere Alternative wäre folglich
4 void Queue::add (Element e)5 {6 (*this).elements.push_back (e);7 }8
wobei die Klammern auf Grund der Prioritätsregeln (3.4) notwendig sind.
Jetzt haben wir uns aber genug mit der Methode add aufgehalten. Gehen wir zur nächsten Methode über.
9 Element Queue::remove ()10 {11 // prüfe ob Queue leer ist12 if (isEmpty ())13 throw RemoveOnEmptyQueue ();1415 Element result = elements.front ();16 elements.erase (elements.begin ());17 return result;18 }19
Hier werden Sie das erste Mal mit C++-Ausnahmen konfrontiert. Zeile 12 prüft, ob die Queue leer ist, denn dann ist ein Entfernen des ersten Elements ein Fehler. In einem solchen Fall wird ein throw-Ausdruck ausgewertet. Stellen Sie sich throw als einen Operator vor, der ein Objekt als Operanden hat. Dieses Objekt ist hier von der Klasse RemoveOnEmptyQueue; über das Erzeugen von Objekten einer Klasse handelt Abschnitt 4.5. Ein solches an throw übergebene Objekt wird zum Aufrufer (oder dessen Aufrufer oder zum Aufrufer dessen Aufrufers u. s. w. ) weitergereicht, bis sich jemand findet, der die Ausnahme behandelt. Details zu diesem Vorgang und wie man solche Ausnahmen auch tatsächlich behandelt, finden Sie in Abschnitt 5. Für Sie ist hier nur wichtig zu wissen, dass der Rest der Funktion (Zeilen 15-17) nicht ausgeführt werden, nachdem der throw-Ausdruck ausgewertet worden ist.
Der Rest der Funktion greift auf die Schnittstelle der Klasse list zu. front gibt das erste Element der Liste zurück, begin liefert einen Iterator (eine Art Verweis) auf das erste Element in der Liste, und erase löscht ein Element aus der Liste, das über einen Iterator referenziert wird. Nichts Weltbewegendes. Aber nochmals zur Erinnerung: isEmpty() entspricht this->isEmpty() und elements entspricht this->elements. Es gibt zur Programm-Laufzeit kein Attribut und keine Methode ohne zugehöriges Objekt. Punkt. Aus. Basta.
120
der Operator ->
Auswerfen von C++-Ausnahmen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
20 bool Queue::isEmpty () const21 {22 return elements.empty ();23 }
Die Funktion ist genauso einfach wie add. Die Nachricht wird einfach an das elements-Objekt weitergereicht, dasselbe geschieht mit dem Ergebnis. (Informationen zu dem const-Schlüsselwort in der Deklaration finden Sie in Abschnitt 4.4.7.1).
So, alle Methoden der Klasse Queue sind definiert. Damit wären wir in dieser Datei am Ende angelangt. Jetzt geht es darum, ein paar Objekte der Klasse zu testen und zu schauen, ob die Klasse auch das tut, was von ihr verlangt ist. Vorher aber schauen wir uns an, was C++ an Schutz gegenüber „bösen“ Klienten bietet...
4.4.6 Exkurs: ZugriffsschutzSie haben erfahren, dass Objekte gekapselt sind und ihre Daten vor anderen Objekten verstecken. Dieses Verstecken ist eine gute Sache. Je weniger Klienten von den speziellen Eigenheiten eines Dienstleisters wissen – und Attribute eines Objekts gehören dazu – desto kleiner ist die Kopplung zwischen Klient und Dienstleister und desto einfacher kann ein Objekt seine Implementierung ändern, ohne dass dies Klienten irgendwie stört (sie merken es ja nicht, solange die Schnittstelle dieselbe bleibt). Außerdem kann es sein, dass das Objekt sensible Daten enthält – etwa ein Passwort zur Autorisierung bei einem anderen Objekt.
Es macht oft auch Sinn, Operationen zu verstecken, wenn diese Methoden Klienten nicht zur Verfügung gestellt werden sollen. Beispielsweise wird ein Objekt, das eine Schnittstelle zum Lösen eines Gleichungssystems anbietet, vielleicht nur eine Nachricht verstehen: löse. Ist der Algorithmus zum Lösen jedoch relativ komplex, macht es Sinn, ihn in mehrere Teile aufzuspalten. In der prozeduralen Programmierung wird man den Algorithmus in mehrere Funktionen oder Prozeduren aufspalten. In der objektorientierten Welt gibt es stattdessen Methoden, die man aber Klienten nicht zur Verfügung stellen möchte, weil sie den internen Zustand des Objekts so manipulieren, dass er zwischenzeitlich vielleicht inkonsistent ist. In einem solchen Fall muss man die entsprechenden Operationen vor potentiellen Klienten verstecken.
Die Lösung hierfür ist einfach, verschiedene Sichten auf die vorhandene Schnittstelle anzubieten. Für Objekte völlig fremder Klassen existiert die öffentliche Sicht, die in C++ durch den Zugriffs-Modifizierer public dargestellt wird. Nur Elemente, die innerhalb der Klassendefinition in einem public-Bereich deklariert werden, sind für diese Objekte sichtbar.
public sollte nie für Attribute verwendet werden, weil dies dem Prinzip der Kapselung (4.1.4) zuwiderläuft. Attribute sollten immer als „Geheimnis“ des Dienstleisters angesehen werden und in ständiger Gefahr, geändert und entfernt zu werden. Nur die operationale Schnittstelle eines Objekts ist stabil. Außerdem kann ein Klient durch Manipulation eines fremden Attributs die Konsistenz der Daten dieses Objekts zerstören, so dass der innere Zustand des Objekts „in die Dutten“ geht. Nur die Methoden des Objekts stellen sicher, dass der Objekt-Zustand konsistent bleibt.
121
Attribute sind immer privat
Operationen sind manchmal privat
verschiedene Sichten auf Schnittstellen
Nie public-Attribute verwenden!
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Merksatz 14: Verwende nie öffentliche Attribute!
Für Objekte innerhalb derselben Vererbungshierarchie existiert der Zugriffs-Modifizierer protected. Alle protected-Elemente sind von erbenden Klassen aus zugreifbar; für nicht erbende Klassen hingegen sind sie ebenfalls privat. protected sollte nur für Operationen und nicht für Attribute verwendet werden.
Soll ein Element nur für Objekte der eigenen Klasse zugreifbar sein, so muss es innerhalb der Klasse in einem private-Abschnitt definiert werden. Alle Attribute sollten generell privat gemacht werden.
C++ bietet nicht die Möglichkeit, den Zugriffsschutz auf Objektebene zu spezifizieren, bloß auf Klassenebene. Das bedeutet, dass ein Objekt einer bestimmten Klasse X Zugriff auf die privaten Elemente aller anderen Objekte derselben Klasse X hat, nicht nur auf seine eigenen. Die direkte Manipulation von Attributen anderer Objekte derselben Klasse ist nicht besonders schön und sollte man nach Möglichkeit vermeiden. Besser ist es, zum Zugriff entsprechende private Operationen anzubieten.
Wichtig ist, dass Sie sich an dieser Stelle vergegenwärtigen, dass versteckte Operationen und (versteckte) Methoden zwei verschiedene Paar Schuhe sind. Operationen können Sie – wie oben beschrieben – mit Hilfe der Zugriffsmodifizierer public und private potentiellen Klienten bekannt machen bzw. vor potentiellen Klienten verstecken, so dass diese entsprechende Nachrichten an entsprechende Objekte verschicken können oder eben nicht. (Dies stellt der Übersetzer sicher.) Methoden hingegen (also die Implementierungen von Operationen) sind immer versteckt in dem Sinne, als dass potentielle Klienten nicht wissen, wie eine Operation tatsächlich implementiert ist. Das ist aber unabhängig vom Zugriffsmodifizierer der Operation: Selbst die Implementierung einer öffentlichen (= public) Operation ist potentiellen Klienten unbekannt. Methoden sind nur innerhalb der enthaltenden Klasse bekannt (und dort natürlich auch nicht „versteckt“).
4.4.7 Exkurs: const-Operationen und const-Parameter
4.4.7.1 const-OperationenOperationen lassen sich nach der Art des Zugriffs auf „ihr“ Objekt in drei verschiedene Gruppen einteilen:
(1) konstruierende Operationen: Sie erstellen ein Objekt.
(2) modifizierende Operationen: Sie verändern ein bestehendes Objekt.
(3) beobachtende Operationen: Sie liefern Informationen zum Objekt, verändern es jedoch nicht.
Diese Einteilung ist sinnvoll, weil sie den Blick auf die Schnittstelle eines Objekts noch einmal erweitern. Wir haben im letzten Abschnitt erfahren, dass es durch die Zugriffs-Modifizierer public, protected und private möglich ist, den Zugriff auf Operationen einer Klasse zu regeln. Doch diese Modifizierer erlauben nur eine „Ganz-oder-gar-nicht“-Strategie. Entweder ist die Operation für einen Klienten verfügbar oder nicht.
122
drei Arten von Operationen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Oft ist dies aber nicht ausreichend. Denn manchmal möchte man verhindern, dass ein Objekt von einem Programmteil (einer Funktion, einer Methode) verändert wird. Beispielsweise wollen Sie bestimmt nicht, dass die Methode isEmpty der Klasse Queue die Queue verändert; sie wollen ja nur erfragen, ob sie Elemente enthält. Die Methode isEmpty ist nach der obigen Klassifikation eindeutig eine beobachtende Operation und soll das Objekt nicht verändern. Diese Garantie der Nicht-Veränderung kann man in C++ explizit bei der Deklaration (und Definition) einer Operation bzw. Methode durch das const-Schlüsselwort ausdrücken:
16 bool isEmpty () const;Ein Dienstleister (wie diese Methode) sagt durch das Schlüsselwort const aus: „Du kannst dir sicher sein, dass ich das Objekt nicht verändern werde, für das ich aufgerufen wurde“. Dabei sind das nicht nur leere Worte: Der Übersetzer prüft sehr genau, ob die Methode das Objekt dennoch zu verändern versucht, und meldet in einem solchen Fall einen Fehler.
Merksatz 15: Verwende const bei beobachtenden Operationen!
Eine direkte Konsequenz daraus ist, dass eine beobachtende Methode (= mit const) nicht auf ihrem eigenen Objekt modifizierende Methoden (= ohne const) ausführen kann. Es ist ja auch unsinnig zu behaupten: „ich werde das Objekt nicht verändern“, und dann jemand anderes zu beauftragen, diese Veränderung durchzuführen. Einmal beobachtend, immer beobachtend; einmal const, immer const. Das stellt ebenfalls der Übersetzer sicher.
Eine weitere Konsequenz ist, dass für echte const-Objekte (die also in der Definition das const-Schlüsselwort enthalten, s. Abschnitt 3.4.4), ebenfalls nur const-Operationen aufgerufen werden dürfen. Denn sonst wäre folgender, ziemlich gefährlicher Code erlaubt:
1 /*** Beispiel const1.cpp ***/23 // Diese Klasse kapselt einen Wert.4 class Wert5 {6 public :7 // Konstruktor initialisiert das Objekt und setzt den Startwert (s. 4.5.1)8 Wert (int startwert);910 // gibt den gespeicherten Wert zurück11 int gibWert () const;1213 // ändert den Wert14 void setzeNeuenWert (int neuerWert);1516 private :17 int wert;18 };1920 // Definition eines Konstruktors (s. 4.5.1)21 Wert::Wert (int startwert)22 :23 wert (startwert)
123
Änderung verhindern mit const
einmal const, immer const
Methoden und konstante Objekte
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
24 {25 }2627 int Wert::gibWert () const28 {29 return wert;30 }3132 void Wert::setzeNeuenWert (int neuerWert)33 {34 wert = neuerWert;35 }3637 int main ()38 {39 // ein konstanter Wert40 const Wert dieAntwort (42);41 // Konstante wird verändert?!? Kein gültiges C++!42 //dieAntwort.setzeNeuenWert (24);43 return 0;44 }
In C++ führt die – auskommentierte – Zeile 42 (glücklicherweise!) zu einem Fehler und verhindert die Veränderung eines konstanten Objekts.
4.4.7.2 const-Parameter
const ist aber auch im Zusammenhang mit Referenzen nützlich, insbesondere bei der Parameterübergabe. Sie kennen bisher zwei Arten der Parameterübergabe (3.6.4):
(1) Übergabe per Wert, wenn die Funktion lediglich den Wert benötigt und die enthaltende Entität nicht verändern will
(2) Übergabe per Referenz, wenn die Funktion die übergebene Entität verändern will
Jetzt lernen Sie eine dritte Variante kennen:
(3) Übergabe per const-Referenz: wenn die Funktion die übergebene Entität nicht verändern will, jedoch eine Kopie einsparen will
Erinnern Sie sich? Bei der Wertübergabe wird der zu übergebende Wert in den Parameter der Funktion kopiert. Damit wird schließlich sichergestellt, dass das ursprüngliche Objekt nicht verändert wird. Bei größeren Objekten kann diese Kopiererei ziemlich viel Aufwand (bezogen auf Zeit und Raum) bedeuten. Bei Referenzen entfällt hingegen die Kopie (aus verständlichen Gründen); stattdessen wird ein Verweis auf das ursprüngliche Objekt übergeben. Dieser Verweis wird natürlich ebenfalls kopiert, hat aber immer dieselbe (kleine) Größe, während das zugehörige Objekt durchaus sehr groß sein kann.
Die dritte Variante vereint nun die Vorteile beider Welten: Das Objekt wird nicht kopiert, dennoch ist die Funktion nicht in der Lage, das Objekt zu verändern, weil es innerhalb der Funktion als Konstante behandelt wird. Beispiel:
1 /*** Beispiel const2.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;
124
Übergabe per const-Referenz
Kopieren vermeiden
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
67 // gibt „message“ aus8 void print (const string &message) // const-Referenz!9 {10 cout << message << endl;11 }1213 int main ()14 {15 string message (16 "Dies ist eine sehr lange Zeichenkette, die sehr viele "17 "Zeichen enthält. Eine Zeichenkette dieser Länger bei "18 "einem Funktionsaufruf zu kopieren dauert nicht "19 "besonders lange, aber stellen Sie sich vor, diese "20 "Funktion wird sehr oft aufgerufen (z. B. 100000 Mal). "21 "Dann werden Sie sicherlich einen Unterschied in der "22 "Geschwindigkeit spüren."23 );24 for (int i = 0; i < 100000; ++i)25 print (message);26 return 0;27 }
Die Funktion print in diesem Beispiel kann den Parameter message nicht verändern, weil es als Referenz auf ein const-Objekt definiert wurde. Jeder Versuch einer Änderung führt zu einer Fehler bei der Übersetzung.
Wenn Sie die Zeile 8 abändern zu:8 void print (string message)
wird das Programm weiterhin übersetzbar sein und funktionieren, allerdings etwas langsamer, weil bei jedem Aufruf der Funktion print eine Kopie der gesamtem Zeichenkette gemacht wird.36 Führen Sie mal den folgenden Versuch durch:
(1) Löschen Sie die Zeile 10, in der die Ausgabe auf den Bildschirm geschieht, damit das Programm beim Testlauf nicht ewig braucht (die Ausgabe ist der langsamste Teil in unserem Programm).
(2) Übersetzen Sie das Programm einmal mit Wert- und einmal mit Referenz-Übergabe und lassen Sie beide mehrmals laufen
(3) Vergleichen Sie die Laufzeiten beider Programme.
Sie werden feststellen, dass das Programm mit der Referenz-Übergabe wesentlich schneller läuft. (Falls Sie keinen oder nur marginale Unterschiede bemerken, erhöhen Sie die Anzahl der Schleifendurchläufe in Zeile 24, etwa auf das Zehnfache.)
Auf meinem PC mit einem AMD Athlon 2600-Prozessor (~ 1800 MHz Taktfrequenz) wurden beide Programme mit VC++ 6.0, Service Pack 5 und den Standard-Projekt-Einstellungen übersetzt. Das Programm mit Referenz-Übergabe benötigte bei 100000 Schleifendurchläufen zwischen 15 und 31 Millisekunden, das mit Wert-Übergabe zwischen 78 und 91 Millisekunden. Somit war das Programm mit Referenz-Übergabe etwa fünfmal schneller!
36) Fairerweise muss man sagen, dass dies sehr von der Implementierung der Klasse string abhängt. Intelligente Implementierungen vermeiden durch Techniken wie Referenzzähler unnötiges Kopieren der enthaltenen Daten, somit ist das obige Beispiel in solchen Fällen wesentlich weniger „eindrucksvoll“.
125
Übergabe per Referenz ist effizienter
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Aus diesem Grund werden größere Objekte in C++-Programmen häufig als const-Referenz übergeben, wenn sie nicht verändert werden sollen. Bei fundamentalen Datentypen (int, char u. s. w.) lohnt sich die Übergabe per const-Referenz in der Regel jedoch nicht, weil die Datenmenge so klein ist, dass keine Einsparung mehr möglich ist.
Sie können konstante Werte an eine Funktion mit einer const-Referenz übergeben:
1 /*** Beispiel const3.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;5 void gibAus (const int &wert)6 {7 cout << wert << endl;8 }9 int main ()
10 {11 gibAus (1);12 return 0;13 }
Dies funktioniert, obwohl 1 eine Konstante und kein Objekt in C++ ist. C++ definiert in diesem Fall, dass eine temporäre „Variable“ vom Typ const int erzeugt wird. Die Funktion gibAus bekommt dann einen Verweis auf diese Variable übergeben. Die temporäre Variable wird dann nach der Benutzung (d. h. nach Aufruf der Funktion) automatisch zerstört. Zu temporären Objekten siehe auch Abschnitt 4.5.4.
4.4.8 Test-PhaseSie haben nun Ihre erste C++-Klasse programmiert. Spätestens jetzt müssen Sie sich geeignete Testfälle ausdenken, um zu überprüfen, ob die Klasse Ihren Anforderungen entspricht.
In der Software-Entwicklung hat sich die Einsicht durchgesetzt, dass sich in jedem nicht-trivialen Programm Fehler befinden. Um diese möglichst frühzeitig finden und entfernen zu können, wurde die Methode der Test-getriebenen Entwicklung aus der Taufe gehoben. Diese Methode verfolgt die Philosophie, dass Testfälle so früh wie möglich erstellt werden sollen, in der Regel noch bevor die erste Zeile Programm-Code geschrieben wird. Ist der Programm-Code dann entwickelt, wird sofort getestet, und Fehler werden sehr schnell erkannt und ausgebaut. Die (eher traurige) Einsicht hinter dieser Philosophie ist diejenige, dass Programmierer generell zu faul sind, um Ihre Software vernünftig zu testen: Sobald sie das Gefühl haben, dass das Programm einen guten Eindruck macht, wenden Sie sich der nächsten Aufgabe zu. Das hat jedoch meistens fatale Folgen.
Zum Entwickeln der benötigten Tests stehen stehen heutzutage in vielen Programmiersprachen leistungsfähige Frameworks zur Verfügung, etwa JUnit37 in Java oder CppUnit38 in C++.
Merksatz 16: Teste viel und ausführlich!
37) s. http://www.junit.org/ und http://junit.sourceforge.net/38) s. http://cppunit.sourceforge.net/
126
Testen ist wichtig!
Konstanten und const-Referenzen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Die Testfälle lassen sich aus der Entwurfs- und sogar aus der Analyse-Phase ableiten. In unserem Fall müssen wir die besonderen Eigenschaften des Queue-Containers testen. Praktisch heißt das, dass wir mehrmals eine Queue erzeugen, verschiedene Objekte hineintun und überprüfen, dass sie in der richtigen Reihenfolge wieder herauskommen.
Das Entwickeln guter Tests ist nicht so einfach, wie es aussieht. Ein guter Test ist einer, der Fehler findet. Diese zu entwickeln bedeutet, sich intensiv mit der Schnittstelle und Funktionsweise der zu testenden Einheit auseinander zu setzen, Grenzwerte und Invarianten zu erarbeiten, Zustandsübergänge bei zustandsbehafteten Klassen zu überprüfen u. s. w. Es gibt inzwischen sogar eigene Lehrgänge mit abschließendem Zertifikat, die nur das Testen objektorientierter Systeme zum Thema haben. Auf Grund der Komplexität der Materie gehen wir im weiteren Verlauf des Skripts nicht tiefer auf das Thema Testen ein.
Ein Testfall besteht immer aus einer Beschreibung dessen, was getestet werden soll, und dem erwarteten Ergebnis. Letzteres wird häufig Erwartungswert oder Soll-Wert genannt. Beim Testen vergleicht man dann das tatsächliche Ergebnis, den Ist-Wert, mit dem Soll-Wert; werden Abweichungen festgestellt, so liegt ein Fehler vor. Dies kann ein Fehler in der getesteten Einheit (Methode, Funktion etc.) sein, aber auch ein Fehler im Test selbst. Das gilt es dann herauszufinden, den Fehler zu korrigieren und dann alle Tests erneut durchzuführen, um sicherzugehen, dass der Fehler eliminiert wurde und keine weiteren Fehler durch die Korrektur eingebaut wurden. Tests, die bei Änderungen der Software durchlaufen werden, nennt man auch Regressionstests.
Wir wollen folgende Tests durchführen (jeder Test beginnt mit einem neuen, leeren Objekt!):
(1) isEmpty() == true(2) add(1);
isEmpty() == false(3) add(1);
remove() == 1; isEmpty() == false(4) add(1); add(2);
isEmpty() == false(5) add(1); add(2);
remove() == 1; isEmpty() == false;remove() == 2; isEmpty() == true
(6) add(1); add(2); add(3);remove() == 1; remove() == 2; remove() == 3
Dazu schreiben wir uns geeignete Funktionen. (Man könnte auch eine geeignete Klasse mit entsprechenden Methoden definieren, aber wir machen es uns an dieser Stelle einfach...) Diese sehen dann z. B. inklusive Header-Datei so aus:
1 /*** Beispiel queue1/tests.h ***/2 // führt eine Reihe von Queue-Tests durch und liefert true wenn alle erfolgreich waren;3 // andernfalls wird false zurückgegeben und auf der Standard-Ausgabe4 // eine entsprechende Fehlermeldung ausgegeben
127
Testfälle ableiten
gute Tests finden Fehler
Soll- und Ist-Wert
Queue-Tests
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
5 bool performAllQueueTests ();1 /*** Beispiel queue1/tests.cpp ***/2 #include "tests.h"3 #include "queue.h" // die Queue-Klasse, die getestet werden soll4 #include <ostream>5 #include <iostream>6 #include <string>7 using namespace std;89 // Namensbereich für Funktionen, die Modul-intern sind
10 namespace11 {12 // Ausnahmeklasse, wird verwendet, um bei einem fehlerhaften Test das Testen abzubrechen13 class TestFailed {};1415 // Diese Funktion wertet "expression" aus:16 // Wenn der Ausdruck true ergibt, tut die Funktion nichts.17 // Wenn er false ergibt, wird eine Meldung unter Zuhilfenahme von „where“ (welcher Test?)18 // ausgegeben und eine Ausnahme vom Typ TestFailed ausgeworfen19 void check (bool expression, const string &where)20 {21 if (!expression)22 {23 cout24 << "Test fehlgeschlagen: " << where << endl;25 throw TestFailed ();26 }27 }28 // die verschiedenen Testfälle29 void test1 ()30 {31 Queue queue;32 check (queue.isEmpty () == true, "test1");33 }34 void test2 ()35 {36 Queue queue;37 queue.add (1);38 check (queue.isEmpty () == false, "test2");39 }40 void test3 ()41 {42 Queue queue;43 queue.add (1);44 check (queue.remove () == 1, "test3");45 check (queue.isEmpty () == true, "test3");46 }47 void test4 ()48 {49 Queue queue;50 queue.add (1);51 queue.add (2);52 check (queue.isEmpty () == false, "test4");53 }54 void test5 ()55 {56 Queue queue;57 queue.add (1);58 queue.add (2);59 check (queue.remove () == 1, "test5");60 check (queue.isEmpty () == false, "test5");
128
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
61 check (queue.remove () == 2, "test5");62 check (queue.isEmpty () == true, "test5");63 }64 void test6 ()65 {66 Queue queue;67 queue.add (1);68 queue.add (2);69 queue.add (3);70 check (queue.remove () == 1, "test6");71 check (queue.remove () == 2, "test6");72 check (queue.remove () == 3, "test6");73 }74 } // Ende des Namensraumes für Modul-interne Funktionen7576 // siehe Deklaration im Header zur Beschreibung77 bool performAllQueueTests ()78 {79 // achte ab hier auf ausgeworfene Ausnahmen80 try81 {82 // führe alle Tests nacheinander durch83 test1 ();84 test2 ();85 test3 ();86 test4 ();87 test5 ();88 test6 ();89 // wenn wir hier angekommen sind, hat alles geklappt (keine Ausnahmen)90 return true;91 }92 catch (TestFailed) // fange Ausnahme-Objekte vom Typ TestFailed ab93 {94 // falls einer der Tests fehlgeschlagen hat, landen wir hier; gib false zurück95 return false;96 }97 }1 /*** Beispiel queue1/main.cpp ***/2 #include "tests.h"3 #include <ostream>4 #include <iostream>5 using namespace std;67 int main ()8 {9 if (performAllQueueTests ())10 {11 cout << "Queue: alle Tests bestanden." << endl;12 return 0; // alles OK13 }14 else15 {16 cout << "Queue: mindestens ein Test fehlgeschlagen!"17 << endl;18 return 1; // liefere einen Wert ungleich Null zurück, um Fehler anzuzeigen19 }20 }
Das ist eine Menge Programm-Quelltext, aber Testen erfordert eben auch Geduld und Gründlichkeit. Lassen Sie uns auf die Punkte eingehen, die für Sie neu sind:
129
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
(1) Zeile 10 (tests.cpp): Hier lernen Sie ein neues Schlüsselwort kennen: namespace (dt. Namensraum). Ein Namensraum ist ein neuer Gültigkeitsbereich und wird verwendet, um Deklarationen logisch zu gruppieren und von anderen Deklarationen abzugrenzen. Er ist häufig benannt. In unserem Beispiel ist er nicht benannt (oder anonym); dadurch sind alle Namen, die in diesem Namensraum deklariert werden, lokal zum Modul (d. h. zur Datei). Damit wird vermieden, dass es durch die Verletzung der Eine-Definition-Regel (3.3.1) zu Fehlern kommt, etwa wenn beispielsweise in einem anderen Modul ebenfalls eine Funktion test1 definiert würde.
Ein anderes Beispiel zur Verwendung von Namensräumen ist die C++-Standard-Bibliothek. Sie haben bereits mehrfach in diesem Skript std::cout oder using namespace std; erblickt. Nun, std ist ein Namensraum, in dem alle Deklarationen der C++-Standard-Bibliothek untergebracht sind. Dadurch werden Konflikte vermieden. Sie können beispielsweise eine selbstgeschriebene Funktion zum Zählen von Objekten count nennen, ohne sich darum zu kümmern, dass die C++-Standard-Bibliothek ebenfalls eine Funktion mit diesem Namen anbietet.
Mehr Informationen zu Namensräumen und zur C++-Standard-Bibliothek finden Sie in Kapitel 8.
Merksatz 17: Verwende Namensräume zur Modularisierung!
(2) Zeile 21-26 (tests.cpp): Innerhalb der check-Funktion wird der übergebene Ausdruck expression auf seinen Wahrheitsgehalt hin überprüft. Wenn der Ausdruck logisch falsch ist, wird an dieser Stelle eine Ausnahme ausgeworfen (5.2). Dadurch kehrt die Funktion nicht normal zum Aufrufer zurück.
(3) Zeile 31 (tests.cpp): Hier wird eine Queue erzeugt. Wie Sie sehen, ist die Syntax nicht anders als bei der Definition einer Variable. Auch bei Objekten gilt, dass sie zerstört werden, wenn ihre Definition den Gültigkeitsbereich verlässt. Mehr zum Erzeugen und Zerstören von Objekten lernen Sie in den Abschnitten 4.5.1 und 4.5.2.
(4) Zeile 80-91 (tests.cpp): Dieser Abschnitt ist neu für Sie. Sie haben bereits erfahren, dass C++ bei Ausnahmen ein „außergewöhnliches“ Verlassen von Funktionen ermöglicht – über den throw-Ausdruck. Dabei wird ein Objekt einer Klasse erzeugt und sozusagen „ausgeworfen“. Um die Ausnahme behandeln zu können, muss sie jedoch auch jemand „auffangen“. Dieser Block, der mit dem Schlüsselwort try eingeleitet ist, sorgt dafür, dass Ausnahmen innerhalb dieses Blocks überhaupt „beachtet“ werden. Das try (dt. versuchen) sagt so etwas aus wie „die Anweisungen in diesem Block könnten fehlschlagen; ich versuche mal, sie auszuführen“. Details finden Sie in den Abschnitten 5.2 und 5.3.
130
anonymer Namensraum
try-Block zum Beobachten von Ausnahmen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
(5) Zeile 92-96 (tests.cpp): Auf einen try-Block folgen immer ein oder mehrere catch-Blöcke. In den catch-Blöcken werden die Ausnahmen aufgefangen (engl. to catch) und behandelt. Wir behandeln hier nur Ausnahme-Objekte vom Typ TestFailed. Das ist auch ausreichend, weil wir keine anderen Ausnahmen auswerfen. Die Programmlogik ist dabei folgende: Wenn ein Test fehlschlägt, wird eine Ausnahme vom Typ TestFailed ausgeworfen, die dann in der Funktion performAllQueueTests aufgefangen wird. Dabei werden alle anderen Tests, die noch nicht ausgeführt worden sind, übersprungen. Es wird also nach dem ersten fehlschlagenden Test abgebrochen. Mehr zum Behandeln von Ausnahmen erfahren Sie in Abschnitt 5.3.
(6) Zeile 18 (main.cpp): Im Falle eines fehlgeschlagenen Tests wird nicht Null, wie bisher üblich, von der Funktion main zurückgegeben, sondern Eins. So bekommt der Aufrufer des Programms die Möglichkeit, auf das Scheitern eines Tests geeignet zu reagieren.
Nun übersetzen Sie alle Dateien des Projekts, und führen Sie die Tests aus. Sie müssten eine Ausgabe erhalten, die Abbildung 33 ähnelt.
4.5 Objekte erzeugen, zerstören, und leben lassenDieser Abschnitt vermittelt Ihnen Kenntnisse über die Details der Objekt-Erzeugung und -Zerstörung. Sie lernen den wichtigen Begriff der Lebensdauer kennen und erfahren, wie Objekte ihren eingeschränkten Gültigkeitsbereich „überdauern“ können.
4.5.1 Konstruktoren und InitialisierungAbschnitt 4.4.5.1 hat Sie darüber aufgeklärt, dass Sie innerhalb einer Klassen-Definition keine Attribute initialisieren können. Darüber sind Sie natürlich unglücklich, weil Sie wissen, dass das Verwenden nicht initialisierter Variablen (und Attribute sind Variablen sehr ähnlich) ein grobes Vergehen ist, das mit langer und mühevoller Fehlersuche bestraft wird. Deshalb werden Sie jetzt darüber aufgeklärt, was ein Konstruktor ist und wie er definiert und verwendet wird.
Ein Konstruktor wird während des Programmlaufs immer dann aufgerufen, wenn ein Objekt zu einer Klasse erzeugt wird. Er sorgt sich darum, dass alle Attribute zum Objekt erzeugt und initialisiert werden, und kann auch weitere vorbereitende Arbeiten übernehmen, die nach der Erzeugung, aber vor der Benutzung eines Objekts erforderlich sind.
Konstruktoren werden wie Methoden definiert, mit den folgenden Ausnahmen:
131
catch-Block zum Auffangen von Ausnahmen
Wie werden Attribute in C++ initialisiert?
Konstruktoren initialisieren Objekte
Abbildung 33: Ausgabe der Queue-Tests
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
(1) Konstruktoren heißen immer genauso wie die zugehörige Klasse.
(2) Ein Konstruktor hat keinen Rückgabetyp (nicht einmal void).
(3) Ein Konstruktor kann innerhalb einer Initialisierungsliste Attribute initialisieren.
Ein kleines Beispiel gefällig:1 /*** Beispiel ctor.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // Diese Klasse repräsentiert einen einfachen Zähler.7 class Zaehler8 {9 public :
10 // Konstruktor. Initialisiert den Zähler mit übergebenem Startwert11 Zaehler (int startwert);12 // gibt den aktuellen Wert des Zählers zurück13 int gibWert () const;14 // erhöht den Zähler um Eins15 void erhoehe ();16 // addiert „delta“ zum Zähler17 void addiere (int delta);18 private :19 int wert;20 };2122 Zaehler::Zaehler (int startwert)23 :24 wert (startwert)25 {26 }2728 int Zaehler::gibWert () const29 {30 return wert;31 }3233 void Zaehler::erhoehe ()34 {35 addiere (1);36 }3738 void Zaehler::addiere (int delta)39 {40 wert += delta;41 }4243 int main ()44 {45 Zaehler zaehler (1);46 cout << "Wert des Zählers: " << zaehler.gibWert () << endl;47 zaehler.addiere (1);48 cout << "Wert des Zählers: " << zaehler.gibWert () << endl;49 zaehler.addiere (-2);50 cout << "Wert des Zählers: " << zaehler.gibWert () << endl;51 return 0;52 }
Die interessanten Konstrukte sind in:
132
Unterschiede zwischen Konstruktoren und Methoden
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
• Zeile 11: Hier wird der Konstruktor innerhalb der Klasse deklariert. Beachten Sie, dass sein Name dem Klassennamen entspricht und er keinen Rückgabetyp besitzt.
• Zeile 22: Hier wird der Konstruktor definiert. Abgesehen vom fehlenden Rückgabetyp (s. o.) unterscheidet er sich von einer normalen Methode wie erhoehe durch die Initialisierungsliste. Sie steht zwischen der schließenden runden Klammer, welche die Parameterliste abschließt, und der öffnenden geschweiften Klammer, welche den Anweisungsblock beginnt. Die Liste hat folgenden Aufbau::
Attribut1 ( Ausdruck1 )[, Attribut2 ( Ausdruck2 )[, Attribut3 ( Ausdruck3 )[, ...]]]
Dabei wird jedes Attribut mit dem zugehörigen Ausdruck initialisiert.Die Initialisierungsliste ist optional, aber Sie wissen ja bereits: Sie sollten keine undefinierten Variablen und folglich keine undefinierten Attribute riskieren! Man kann es nicht oft genug wiederholen. Außerdem gibt es Momente, wo die Initialisierung der Elemente einer Klasse über diese Liste geschehen muss, nämlich wenn Konstanten oder Referenzen verwendet werden. Beispiel:
1 class Konstante2 {3 public :4 Konstante (int zuSetzenderWert);5 int gibWert () const;6 private :7 const int wert; // Konstante, da Schlüsselwort const8 };910 int Konstante::gibWert () const11 {12 return wert;13 }1415 Konstante::Konstante (int zuSetzenderWert)16 :17 wert (zuSetzenderWert) // initialisiere Konstante18 {19 wert = zuSetzenderWert; // das geht nicht!20 }
Die Zuweisung in Zeile 19 ist nicht erlaubt, weil Konstanten kein Wert zugewiesen werden darf. Initialisierung von Konstanten ist jedoch erlaubt und sogar erforderlich, deshalb ist Zeile 17 sowohl möglich als auch nötig.
Attribute werden nicht in der Reihenfolge der Initialisierungsausdrücke in der Initialisierungsliste eines Konstruktors ausgeführt. Vielmehr werden die Attribute in der Reihenfolge initialisiert, in der sie in der Klassen-Definition stehen. Im folgenden Beispiel wird also das Attribut m_x vor dem Attribut m_y initialisiert, obwohl der
133
Initialisierungsliste im Konstruktor
Konstanten müssen im Konstruktor initialisiert werden
Reihenfolge der Initialisierung von Attributen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Initialisierungsausdruck für m_x in der Initialisierungsliste im Konstruktor nach jenem für m_y notiert ist:
1 class Point2 {3 private :4 int m_x;5 int m_y;6 public :7 Point (int x, int y);8 };9 Point::Point (int x, int y)
10 :11 m_y (y),12 m_x (x)13 {14 }
Unterschiedliche Reihenfolgen von Attributen in der Klassen-Definition und in einer Konstruktor-Initialisierungsliste sollten unbedingt vermieden werden, da ansonsten böse Fehler auftauchen können (siehe nächsten Abschnitt):
Merksatz 18: Verwende eine einheitliche Reihenfolge bei Attributen!
Eine unterschiedliche Reihenfolge der Attribute in der Klassen-Definition und in der Initialisierungsliste kann verwirren und schlimmstenfalls Programmfehler verursachen. Letzteres kann passieren, wenn Initialisierungsausdrücke von zuvor initialisierten Attributen abhängen. Das obige Beispiel, leicht umformuliert, zeigt uns die Probleme, die dabei auftauchen können:
1 class Point2 {3 private :4 int m_x;5 int m_y;6 public :7 Point (int x, int y);8 };9 Point::Point (int x, int y)
10 :11 m_y (y),12 m_x (x + m_y)13 {14 }
Hier soll in Zeile 12 das Attribut m_x auf die Summe des Parameters x und des Attributs m_y gesetzt werden. Doch ist dabei nicht bedacht worden, dass das Attribut m_y zu diesem Zeitpunkt noch keinen wohldefinierten Wert beinhaltet, da dessen Initialisierung noch nicht erfolgt ist – ungeachtet der Tatsache, dass der Initialisierungsausdruck für m_y lexikalisch vor dem Initialisierungsausdruck für m_x steht. Der obige Quelltext führt also zu undefiniertem Verhalten, was im schlimmsten Fall einen Programmabsturz zur Folge haben kann.
Wenn Sie in Java programmieren, dann müssen Sie besonders auf die Initialisierung von Konstanten achten, da Java nicht zwischen Zuweisung und Initialisierung unterscheidet. Stattdessen ist es erlaubt und notwendig, während der Konstruktion eines Objekts final-Attributen einen Wert zuzuweisen.
• Zeile 25/26: Zwischen den beiden geschweiften Klammern können beliebige Anweisungen stehen, die zur Initialisierung des Objekts notwendig sind. In die
134
Aufpassen bei Abhängigkeiten zwischen Attributen!
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
sem Fall haben wir keine benötigt, aber es gibt durchaus Fälle, in denen die Initialisierung eines Objekts komplexer ist als lediglich die Belegung von Attributen mit Werten.
• Zeile 45: Hier wird ein Objekt der Klasse Zaehler erzeugt. Sie sehen, dass die funktionale Schreibweise bei der Initialisierung verwendet wurde (3.3.3). Dieser Initialwert wird bei der Konstruktion des Objekts an den Konstruktor übergeben. Der Begriff der funktionalen Initialisierung wird jetzt deutlich: Der Konstruktor wird wie eine Funktion aufgerufen und eventuelle Ausdrücke zwischen den Klammern als Argumente übergeben.
Jetzt verstehen Sie auch, warum bei Objekten die funktionale Schreibweise bei der Initialisierung mehr Sinn macht: Wenn ein Konstruktor mehr als einen Parameter erfordert, kommen Sie nicht um diese Schreibweise umhin, denn in der anderen Schreibweise (die mit dem Gleichheitszeichen) können Sie nur einen Ausdruck angeben, und der Übersetzer wird meckern, dass Argumente fehlen.
Das Programm gibt übrigens aus:Wert des Zählers: 1Wert des Zählers: 2Wert des Zählers: 0
Jetzt wo Sie die grundlegende Syntax für die Definition und Verwendung von Konstruktoren kennen gelernt haben, ist es an der Zeit, mehr über die Eigenarten von Konstruktoren zu erfahren. Zunächst ist sicherlich die Reihenfolge bei der Initialisierung interessant. Dass die Attribute in der Reihenfolge der Definition innerhalb der Klasse initialisiert werden, wissen Sie ja bereits. Wichtig ist in diesem Zusammenhang, dass diese Initialisierungen vor dem Ausführen des Anweisungsblocks des Konstruktors durchgeführt werden. Dadurch wird sichergestellt, dass Anweisungen im Konstruktor auf die Attribute der Klasse problemlos zugreifen können und nicht Gefahr laufen, nicht initialisierte Objekte zu benutzen. Zusammengefasst sieht die Reihenfolge bei der Initialisierung also so aus:
(1) Zuerst werden die Konstruktoren der Basisklassen (soweit vorhanden) ausgeführt (siehe in diesem Zusammenhang die Abschnitte 4.6.4 und 4.6.5).
(2) Danach werden alle Attribute in der Reihenfolge der Definitionen innerhalb der Klasse initialisiert. Handelt es sich dabei um objektwertige Attribute, werden entsprechende Konstruktoren aufgerufen.
(3) Schließlich wird der Anweisungsblock des Konstruktors ausgeführt.
Weiterhin ist es wichtig zu wissen, was mit Klassen geschieht, in denen Sie keinen Konstruktor definieren. Es gilt folgende Regel: Immer wenn die explizite Definition eines Konstruktors fehlt, wird eine implizite Definition eines Konstruktors ohne Parameter und ohne Anweisungen generiert, der sogenannte Default-Konstruktor. Alle Klassen, die Sie bis zu diesem Abschnitt kennen gelernt haben, etwa die Klasse Queue, hatten einen solchen Default-Konstruktor. Ein Default-Konstruktor sieht immer folgendermaßen aus:
Klassenname :: Klassenname ( )
135
jede Klasse hat einen Konstruktor, den Default-Konstruktor
Reihenfolge der Initialisierung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
{}
Offensichtlich führt dieser generierte Konstruktor keine explizite Initialisierung der Attribute einer Klasse durch, deshalb ist die Verwendung eines Default-Konstruktors immer dann nicht erlaubt, wenn der obige Konstruktor nicht erlaubt ist, beispielsweise wenn die Klasse konstante Attribute oder Referenzen enthält (siehe Klasse Konstante weiter oben).
Wenn Sie ein Attribut nicht in der Initialisierungsliste eines Konstruktors initialisieren, hat es nur dann einen undefinierten Inhalt, wenn es nicht vom Typ einer Klasse ist, d. h. bei primitiven Datentypen (etwa int oder float) sowie bei zusammengesetzten Datentypen (etwa char *). Ist das Attribut ein Objekt (also vom Typ einer Klasse), wird für das Objekt bei fehlender Initialisierung der Default-Konstruktor aufgerufen. Somit wird sichergestellt, dass Objekte immer korrekt initialisiert sind. Ist kein solcher Default-Konstruktor vorhanden oder nicht nutzbar (etwa weil er private ist), ist das C++-Programm fehlerhaft. Beispiel:
1 // die Klasse Konstante sei wie im obigen Beispiel definiert2 class LeereKlasse3 {4 // jede Klasse ohne explizite Konstruktoren hat einen impliziten5 // Default-Konstuktor, s. u.6 };78 class Container9 {
10 public :11 // expliziter Default-Konstruktor12 Container ();13 private :14 Konstante k;15 LeereKlasse l;16 };1718 Container::Container ()19 :20 k (1) // explizite Initialisierung notwendig21 // Initialisierung von "l" entfällt, da dies der Übersetzer implizit erledigt22 {23 }24
In diesem Beispiel enthält die Klasse Container zwei Attribute, eines vom Typ Konstante und eines vom Typ LeereKlasse. Die Klasse LeereKlasse hat einen Default-Konstruktor, der zur Initialisierung benutzt werden soll, somit kann im Container-Konstruktor die explizite Initialisierung des Attributs l entfallen. Die Klasse Konstante besitzt hingegen keinen Default-Konstruktor (weil ein benutzerdefinierter Konstruktor existiert), somit muss die Initialisierung des Attributs k explizit angegeben werden.
136
automatische Nutzung von Default-Konstruktoren
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.5.2 Destruktoren und RAIIEin Destruktor ist der Pendant zum Konstruktor. Der Destruktor wird aufgerufen, wenn ein Objekt zerstört wird. Dabei macht der Destruktor im Regelfall die im Konstruktor vorgenommenen Operationen rückgängig. (Der Name kommt übrigens von destruieren = zerstören.)
Merksatz 19: Betrachte Konstruktoren und Destruktor als Team!
Ein kleines Beispiel zum Verständnis: Stellen Sie sich vor, Sie haben eine Klasse File, welche eine Abstraktion einer Datei darstellt und Operationen zum Öffnen, Lesen, Schreiben und Schließen von Dateien enthält:
1 /*** Beispiel file1.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 // Repräsentiert eine Datei.8 class File9 {10 public :11 // öffnet die angegebene Datei; liefert true bei Erfolg und false bei Misserfolg zurück12 bool open (string name);1314 // schließt die zuvor geöffnete Datei15 void close ();1617 // liest „count“ Zeichen aus der zuvor geöffneten Datei18 string read (int count);1920 // schreibt die Zeichenkette „data“ in die zuvor geöffnete Datei21 void write (string data);2223 private :24 // von der Implementierung benötigte Attribute25 };
Ohne weiter die Implementierung kennen zu lernen, lassen Sie uns betrachten, wie diese Klasse zu benutzen ist. Der Klient muss zunächst ein File-Objekt erzeugen. Dann kann er diesem die Nachricht open, versehen mit einem Dateinamen, schicken und hoffen, dass diese Nachricht true zurückliefert, was auf ein erfolgreiches Öffnen hindeutet. Das Öffnen beansprucht gewöhnlich Betriebssystem-Ressourcen. Danach kann er die read- und write-Nachrichten nach Belieben aufrufen. Wenn der Klient fertig ist, ruft er close auf, um die Datei wieder zu schließen und die beim Öffnen angeforderten Betriebssystem-Ressourcen freizugeben. Beispiel:
26 /*** Beispiel file1.cpp (Fortsetzung) ***/27 int main ()28 {29 File myFile;30 if (myFile.open ("myfile.dat"))31 {32 string vier = myFile.read (4);33 cout << "die ersten 4 Zeichen: " << vier << endl;34 myFile.close ();
137
Destruktoren zerstören Objekte
Destruktoren am Beispiel
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
35 }36 else37 {38 cout << "Konnte Datei nicht öffnen!" << endl;39 }40 return 0;41 }
Haben Sie die Gefahren bemerkt, die sich beim Benutzen dieser Klasse einschleichen können? Mindestens zwei Dinge sollten zum Nachdenken anregen:
(1) Es ist möglich, die Operation read aufzurufen, ohne vorher eine Datei über open geöffnet zu haben. Da man aus einer nicht geöffneten Datei nicht lesen kann, wird dies sicherlich einen Fehler produzieren.
(2) Es ist möglich, den Aufruf der close-Operation zu vergessen und somit angeforderte System-Ressourcen nicht ordnungsgemäß freizugeben. Man spricht in einem solchen Fall von einem Ressourcen-Leck.
Beides ist ungünstig, kann aber mit den Sprachmitteln von C++ leicht behoben werden:
(1) Zum ersten Problem: Wir beheben diese Gefahr, indem wir festlegen, dass ein File-Objekt immer eine geöffnete Datei repräsentiert. Bisher konnte ein File-Objekt zwei Zustände haben: geöffnet und nicht geöffnet, was zum oben beschriebenen Problem führen kann. Wenn wir aber sichergehen wollen, dass ein File-Objekt immer für eine geöffnete Datei steht, müssen wir die open-Operation verpflichtend machen. Das geht nur, wenn wir bereits im Konstruktor die Datei öffnen, denn den Konstruktor muss der Klient benutzen – denn sonst hat er überhaupt kein Objekt, mit dem er etwas anfangen kann
Das bedeutet, dass wir die Schnittstelle der Klasse entsprechend abändern müssen. Zum ersten muss der Konstruktor bereits den Dateinamen bekommen, damit er die Datei öffnen kann. Zum zweiten darf die open-Operation nicht mehr öffentlich sein, denn es macht keinen Sinn, dem Klienten das Öffnen einer bereits geöffneten Datei zu erlauben. Zum dritten muss der Konstruktor die Möglichkeit haben, einen Fehler beim Öffnen der Datei dem Aufrufer mitzuteilen. Bisher hat die open-Operation einfach false zurückgeliefert. Da ein Konstruktor keinen Rückgabewert hat, bleibt ihm nichts anderes übrig, als im Fehlerfall eine geeignete Ausnahme auszuwerfen. (Mehr über Ausnahmen erfahren Sie in Kapitel 5.)
Das resultiert in den folgenden Klassen und Definitionen:1 /*** Beispiel file2.cpp (vorläufig) ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 // wird ausgeworfen, wenn das Öffnen der Datei fehlschlägt8 class FileOpenException9 {
10 };1112 class File
138
potentielle Gefahren
Ressourcen-Lecks
Konstruktoren akquirieren Ressourcen
Konstruktoren generieren bei Fehlern Ausnahmen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
13 {14 public :15 // Konstruktor: öffnet die angegebene Datei16 // wirft eine FileOpenException-Ausnahme aus, wenn das Öffnen fehlschlägt17 File (string name);1819 // schließt die zuvor geöffnete Datei20 void close ();2122 // liest „count“ Zeichen aus der zuvor geöffneten Datei23 string read (int count);2425 // schreibt die Zeichenkette „data“ in die zuvor geöffnete Datei26 void write (string data);2728 private :29 // von der Implementierung benötigte Attribute3031 // öffnet die angegebene Datei; liefert true bei Erfolg und false bei Misserfolg zurück32 bool open (string name);33 };3435 File::File (string name)36 {37 // öffne Datei; wenn das fehlschlägt, wirf eine Ausnahme aus38 if (!open (name))39 throw FileOpenException ();40 }
(2) Zum zweiten Problem: Wir wollen sicherstellen, dass alle Ressourcen ordnungsgemäß an das System zurückgegeben werden. Wir wollen uns nicht darauf verlassen, dass der Klient eine entsprechende Operation aufruft, denn er könnte dies vergessen. C++ garantiert uns jedoch, dass vor der Zerstörung eines Objekts sein Destruktor aufgerufen wird. Also verlegen wir das Schließen der Datei in den Destruktor. Analog zur obigen Änderung werden wir auch die close-Operation privat machen, um zu verhindern, dass ein Klient erst eine Datei schließt und danach versucht, daraus zu lesen:
1 /*** Beispiel file2.cpp (Endfassung) ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 // wird ausgeworfen, wenn das Öffnen der Datei fehlschlägt8 class FileOpenException9 {10 };1112 class File13 {14 public :15 // Konstruktor: öffnet die angegebene Datei16 // wirft eine FileOpenException-Ausnahme aus, wenn das Öffnen fehlschlägt17 File (string name);1819 // Destruktor, schließt die geöffnete Datei20 ~File ();21
139
Destruktoren geben Ressourcen frei
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
22 // liest „count“ Zeichen aus der zuvor geöffneten Datei23 string read (int count);2425 // schreibt die Zeichenkette „data“ in die zuvor geöffnete Datei26 void write (string data);2728 private :29 // von der Implementierung benötigte Attribute3031 // öffnet die angegebene Datei; liefert true bei Erfolg und false bei Misserfolg zurück32 bool open (string name);3334 // schließt die zuvor geöffnete Datei35 void close ();36 };3738 File::File (string name)39 {40 // öffne Datei; wenn das fehlschlägt, wirf eine Ausnahme aus41 if (!open (name))42 throw FileOpenException ();43 }4445 File::~File ()46 {47 // schließe die Datei und gib Ressourcen frei48 close ();49 }
Wie Sie sehen, werden Destruktoren wie Konstruktoren definiert, nur haben sie zusätzlich eine Tilde (~) vor Ihrem Namen und generell keine Parameter. (Es gäbe ja keine Möglichkeit, einem Destruktor jemals Argumente zu übergeben, weil er implizit von der Laufzeit-Umgebung beim Zerstören eines Objekts aufgerufen wird.)
Da diese Änderungen die öffentliche Schnittstelle verändert haben, müssen wir auch die main-Funktion entsprechend anpassen:
50 /*** Beispiel file2.cpp (Fortsetzung) ***/51 int main ()52 {53 try54 {55 File myFile ("myfile.dat");56 string vier = myFile.read (4);57 cout << "die ersten 4 Zeichen: " << vier << endl;58 // hier endet der Gültigkeitsbereich von myFile; dabei wird automatisch der59 // Destruktor aufgerufen und die geöffnete Datei geschlossen60 }61 catch (FileOpenException)62 {63 // wenn wir hier ankommen, hat das Öffnen der Datei nicht geklappt64 cout << "Konnte Datei nicht öffnen!" << endl;65 }66 return 0;67 }
140
Form des Destruktors
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Wie Sie sehen, ist die main-Funktion sogar um eine Zeile kürzer geworden (wenn Sie sich die Kommentare wegdenken), da Erstellung des File-Objekts und Öffnen der Datei jetzt in einer Operation zusammengefasst sind.
Konstruktor und Destruktor geben also ein gutes Team ab beim Verwalten von Ressourcen, die bei einem Objekt bei der Initialisierung angefordert und bei der Zerstörung freigegeben werden sollen. Das beschriebene Vorgehen hat in der C++-Literatur auch einen Namen: Resource Acquisition is Initialization (RAII), zu deutsch Ressourcenbelegung ist Initialisierung. Dieses wichtige und häufig genutzte C++-Idiom stellt auf einfache Weise die korrekte Freigabe von Ressourcen sicher.
In Java gibt es, Destruktoren ähnlich, finalize-Methoden, doch sind sie nicht annähernd so nützlich wie in C++, weil in Java nicht vorhergesagt werden kann, wann die finalize-Methode aufgerufen wird. Da der Zeitpunkt aber oft wichtig ist, z. B. wenn im Destruktor eine Sperre freigegeben wird, tendieren Java-Programmierer dazu, die Ressourcen in solchen Fällen explizit freizugeben, mit allen beschriebenen Nachteilen.
Ein weiterer, unglaublich praktischer Vorteil von Destruktoren ist die Tatsache, dass die Destruktoren auch dann ausgeführt werden, wenn das Objekt durch eine Ausnahme bedingt zerstört wird. Genauer gehen wir darauf in Abschnitt 5.9 ein.
In Java haben Sie nur die Möglichkeit, try-finally-Konstruktionen zu verwenden, wenn Sie aufräumenden Code bei einer Ausnahme ausführen wollen. try-Konstrukte tendieren jedoch generell dazu, lang und unübersichtlich zu werden, sowie sich zu wiederholen. Die Destruktoren von C++ bieten hingegen eine übersichtliche und kurze Alternative.
Destruktoren haben – wie Konstruktoren – auch noch eine andere Aufgabe: Sie zerstören nicht nur das Objekt, für das sie aufgerufen werden, sondern auch alle „abhängigen“ Objekte, also alle Attribute (und Basisklassen, s. Abschnitte 4.6.4 und 4.6.5). Dabei geschieht das Zerstören in der umgekehrten Reihenfolge der Konstruktion:
(1) Zuerst wird der Anweisungsblock im Destruktor ausgeführt.
(2) Dann wird für alle Attribute der Destruktor aufgerufen, und zwar in umgekehrter Initialisierungsreihenfolge; d. h. das Attribut, das innerhalb der Klassendefinition als letztes definiert wird, wird als erstes zerstört.
(3) Schließlich wird der Destruktor der Basisklasse(n) aufgerufen, ebenfalls in umgekehrter Initialisierungsreihenfolge.
Diese Reihenfolge macht Sinn: Es wird sichergestellt, dass nicht Teile der Klasse nach ihrer Zerstörung verwendet werden können. Das könnte bei einer anderen Reihenfolge passieren, etwa wenn die Attribute vor der Ausführung des Anweisungsblocks des Destruktors zerstört würden und der Anweisungsblock diese noch benutzte.
Ebenso wie es einen Default-Konstruktor für Klassen gibt, die keinen eigenen Konstruktor definieren, wird ein Default-Destruktor definiert, wenn eine Klasse keinen explizit angibt. Dieser Default-Destruktor sieht so aus:
Klassenname :: ~ Klassenname ( ){
141
Resource Acquisition is Initialization (RAII)
Ressourcen-Freigabe auch bei Ausnahmen
Default-Destruktor
Reihenfolge bei der Zerstörung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
}und hat somit keinerlei Seiteneffekte (außer der – in jedem Destruktor immer durchgeführten – Zerstörung aller Attribute und Basisklassen).
Merksatz 20: Verwende Destruktoren zur Ressourcen-Freigabe!
4.5.3 Kopien und die Sache mit der LebensdauerJetzt kommen wir zu einem interessanten Thema: der Lebensdauer von Objekten. Bisher war die Lebensdauer immer recht einfach zu ermitteln: Ein Objekt war so lange existent, wie das Programm innerhalb seines Gültigkeitsbereiches agierte. Verließ der Kontrollfluss den jeweiligen Block mit der Objekt-Definition, so wurde das Objekt zerstört.
Fassen wir kurz die bisherigen Regeln zur Lebensdauer und Gültigkeitsbereich zusammen (mit Objekten sind nachfolgend Variablen und Konstanten zu allen möglichen Typen gemeint, nicht nur zu Klassen):
• Objekte, die innerhalb einer Funktion oder einer Methode definiert werden, sind bis zum Ende ihres Blocks existent. Ihr Gültigkeitsbereich erstreckt sich von dem Ort der Definition bis zum Ende des enthaltenden Blocks.
• Objekte, die innerhalb von Klassen definiert werden (Attribute), sind an das enthaltende Objekt gebunden. Deren Lebensdauer ist genauso lang wie die des enthaltenden Objekts. Der Gültigkeitsbereich erstreckt sich über alle Konstruktoren, Destruktoren und Methoden der Klasse.
Bisher war es also so, dass ein Objekt nie länger „leben“ konnte als in dem Zeitraum von seiner Definition im Quelltext bis zum Ende des umfassenden Blockes. Doch was, wenn das Objekt länger „leben“ muss als der definierende Block? Was ist beispielsweise, wenn eine Methode oder Funktion ein Objekt zurückgeben möchte, beispielsweise ein Queue-Objekt?
1 // liefert die ersten vier Primzahlen in einer Queue zurück2 Queue dieErstenVierPrimzahlen ()3 {4 Queue queue;5 queue.add (2);6 queue.add (3);7 queue.add (5)8 queue.add (7)9 return queue;
10 }
Wie kann die Objekt-Variable queue zurückgeliefert werden, wenn sie doch am Ende des Funktions-Blocks zerstört wird?
Die Antwort ist: Das Objekt queue wird gar nicht zurückgegeben, sondern eine Kopie. Das ursprüngliche Objekt wird sozusagen ordnungsgemäß zerstört und „lebt“ in einer Kopie „weiter“. Ein Aufruf der obigen Funktion könnte beispielsweise so aussehen:
11 Queue primzahlen = dieErstenVierPrimzahlen ();
142
Lebensdauer von Objekten
bisher Kopplung von Lebensdauer und Gültigkeitsbereich
Kopien werden erstellt
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Oder so:11 Queue primzahlen (dieErstenVierPrimzahlen ());
Ähnliches geschieht, wenn Sie Objekte in einen Container der C++-Standard-Bibliothek speichern. Auch diese Container speichern Kopien.
Doch was genau ist eine Kopie? Wie wird sie erzeugt? Und was ist, wenn keine Kopie gewünscht oder möglich ist?
4.5.3.1 Der Kopier-KonstruktorEine Kopie eines Objekts ist erst einmal genauso ein „gewöhnliches“ Objekt wie das Original. Es gibt keinen Unterschied zwischen einem Original und einer Kopie:
(1) Der Typ ist derselbe.
(2) Der Inhalt ist der gleiche: Alle Attribute des Original-Objekts werden mitkopiert und sind somit Teil der Kopie.
Der letzte Punkt deutet schon an, wie eine Kopie angefertigt wird, nämlich rekursiv. Zuerst wird die „Hülle“ der Kopie erzeugt und dann in jedes Attribut in dieser Hülle das entsprechende Attribut aus dem Original kopiert. Ist ein Attribut selbst ein Objekt, müssen dessen Attribute auf die gleiche Art und Weise kopiert werden, wobei diese wieder objektwertig sein können u. s. w.
Sie wissen aber, dass ein Objekt in C++ immer durch einen Konstruktor initialisiert wird. Der Default-Konstruktor (4.5.1) scheidet jedoch aus, denn dieser initialisiert das Objekt nicht oder zumindest nicht so wie das Original. Da Sie jedoch in C++ auch Objekte kopieren können, deren Klassen überhaupt keine Konstruktoren definieren, muss es noch einen zweiten Konstruktor geben, der standardmäßig definiert ist und sich um das Erzeugen von Kopien kümmert. Diesen Konstruktor gibt es wirklich: Es ist der sogenannte Kopier-Konstruktor.
Der Kopier-Konstruktor hat die folgende Deklaration:
Klassenname ( const Klassenname & Parametername );Der Kopier-Konstruktor bekommt als Argument das Objekt übergeben, das bei der Kopier-Operation als Quell-Objekt fungiert. Dies ist u. a.
• der Initialisierungsausdruck beim direkten Initialisieren (3.3.3) einer Kopie,
• das zugehörige Argument bei der Initialisierung eines Parameters (3.6.4),
• der Rückgabewert einer return-Anweisung (3.5.5) bei der Rückkehr von einer Funktion,
• das Ausnahme-Objekt beim Betreten eines catch-Blocks (5.3).
Falls der Kopier-Konstruktor nicht explizit für eine Klasse definiert wird, erstellt der C++-Übersetzer eine Standard-Implementierung, die alle Attribute eines Objekts kopiert. Das Kopieren der Attribute wird entweder wieder über den entsprechenden Kopier-Konstruktor erledigt (falls es sich um objektwertige Attribute handelt) oder aber
143
Was ist eine Kopie?
rekursive Erstellung von Kopien
Kopier-Konstruktor
Form des Kopier-Konstruktors
Wann werden Objekte kopiert?
Standard-Implementierung des Konstruktors
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
als (bitweise) Zuweisung (in allen anderen Fällen, etwa bei Attributen vom Typ int).
Natürlich kann der Kopier-Konstruktor auch mit einer eigenen Definition belegt werden. Das ist oft unerlässlich, wenn die Kopie eines Objekts eben nicht eins-zu- eins erfolgen darf. Bei unserer File-Klasse (4.5.1) zum Beispiel ist es fraglich, ob eine Eins-zu-eins-Kopie vernünftig wäre. Überlegen Sie einmal: Der Destruktor der File-Klasse gibt die angeforderten System-Ressourcen, die zur geöffneten Datei gehören, durch den Aufruf von close frei. Wenn nun eine Kopie eines solchen File-Objekts erstellt wird, werden spätestens am Ende des Programms zwei Destruktoren und somit zwei close-Methoden aufgerufen, was zur doppelten Freigabe derselben System-Ressource führt. Die meisten Betriebssysteme mögen dies überhaupt nicht. Also kann eine Eins-zu-eins-Kopie hier nicht richtig sein. Es gibt mehrere Lösungen in diesem speziellen Fall, nach aufsteigender Schwierigkeit geordnet:
(1) Das Kopieren von File-Objekten wird generell verboten (s. u.)
(2) Beim Kopieren wird die Ressource über einen geeigneten System-Aufruf dupliziert, so dass aus der Sicht des Betriebssystems zwei Ressourcen für dieselbe Datei existieren.
(3) Es wird ein Zähl-Mechanismus eingeführt, so dass eine Ressource erst freigegeben wird, wenn kein File-Objekt mehr auf sie verweist (Stichwort „Referenzzähler“).
Unabhängig von der Wahl der Methode haben Sie hoffentlich gemerkt, dass der Programm-Entwickler sich über das Kopieren von Objekten Gedanken machen muss, da es in C++ fest eingebaut, aber nicht immer passend ist.
Verdeutlichen wir die gewonnenen Kenntnisse, indem wir das Beispiel aus dem Abschnitt über const-Operationen (4.4.7.1) leicht abändern:
1 /*** Beispiel copyctor.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // Diese Klasse kapselt einen Wert.7 class Wert8 {9 public :
10 // Konstruktor initialisiert das Objekt und setzt den Startwert (s. 4.5.1)11 Wert (int startwert);1213 // Kopier-Konstruktor: kopiert den Wert und vermerkt, dass es sich um eine Kopie handelt14 Wert (const Wert &quelle);1516 // gibt den gespeicherten Wert zurück17 int gibWert () const;1819 // liefert true zurück falls das Objekt keine Kopie ist20 bool istOriginal () const;2122 // ändert den Wert23 void setzeNeuenWert (int neuerWert);
144
eigene Definition eines Kopier-Konstruktors
Kopieren erlauben? Wenn ja, wie?
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
2425 private :26 int wert;27 bool original;28 };2930 Wert::Wert (int startwert)31 :32 wert (startwert),33 original (true)34 {35 }3637 Wert::Wert (const Wert &quelle)38 :39 wert (quelle.gibWert ()),40 original (false)41 {42 }4344 int Wert::gibWert () const45 {46 return wert;47 }4849 bool Wert::istOriginal () const50 {51 return original;52 }5354 void Wert::setzeNeuenWert (int neuerWert)55 {56 wert = neuerWert;57 }5859 int main ()60 {61 // ein „originales“ Objekt62 const Wert o1 (42);63 // eine Kopie davon64 const Wert o2 = o1;65 // eine Kopie von der Kopie66 const Wert o3 (o2);6768 // Ausgabe, ob die Objekte Originale oder Kopien sind69 cout << "o1 ist " <<70 (o1.istOriginal () ? "Original" : "Kopie") << endl;71 cout << "o2 ist " <<72 (o2.istOriginal () ? "Original" : "Kopie") << endl;73 cout << "o3 ist " <<74 (o3.istOriginal () ? "Original" : "Kopie") << endl;7576 return 0;77 }
Folgende Anmerkungen zum Programm:
• Zeile 14: Ein expliziter Kopier-Konstruktor ist hinzugekommen.
• Zeile 27: Das Attribut original gibt an, ob ein Objekt mit Hilfe des normalen Konstruktors oder mit Hilfe des Kopier-Konstruktors erzeugt wurde. Dadurch
145
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
kann man erkennen, ob ein Objekt eine Kopie ist (original == false) oder nicht.
• Zeile 33: Der normale Konstruktor setzt das Attribut original auf true, um anzuzeigen, dass das Objekt keine Kopie ist.
• Zeilen 37-42: Hier ist die Definition des Kopier-Konstruktors. Der Kopier-Konstruktor muss genauso wie der normale Konstruktor jedes Attribut initialisieren. Das Attribut wert initialisiert er mit dem entsprechenden Wert aus dem Original, das Attribut original hingegen setzt er immer auf false, um festzuhalten, dass dieses Objekt eine Kopie ist.
• Zeilen 61-66: Hier werden drei Objekte erzeugt, eines mit Hilfe des normalen Konstruktors und zwei als Kopie. Dabei wird noch einmal verdeutlicht, dass wenn der zugehörige Konstruktor genau einen Parameter hat (etwa der Kopier-Konstruktor), sowohl die funktionale Schreibweise der Initialisierung funktioniert (Zeile 66) als auch diejenige mit dem Gleichheitszeichen (Zeile 64). Bei Objekten und Konstruktoren ist jedoch die funktionale Schreibweise vorzuziehen.39
Das Programm erzeugt folgende Ausgabe:o1 ist Originalo2 ist Kopieo3 ist Kopie
4.5.3.2 Verhindern von KopienEs gibt Situationen, in denen für eine Klasse von Objekten das Kopieren keinen Sinn macht. Beispielsweise gibt es Klassen, von denen es zur Programm-Laufzeit genau ein Objekt gibt (Singleton, s. Abschnitt 6.4.2) – nicht mehr aber auch nicht weniger. Das Kopieren eines solchen Objekts muss natürlich verboten werden.
In C++ geht dies sehr einfach. Wissen Sie bereits, wie? Die notwendigen Sprachmittel haben Sie nämlich bereits kennen gelernt...
Wenn Sie nicht darauf kommen, hier die Lösung: Sie deklarieren den Kopier-Konstruktor private. Dadurch wird allen verboten, Objekte dieser Klasse zu kopieren. Einfach, nicht? Aber schließlich ist ein Kopier-Konstruktor in gewisser Weise eine Operation wie jede andere und fällt somit auch unter die Regeln der Zugriffs-Modifizierer.
Das bedeutet aber, dass Objekte der eigenen Klasse sich weiter kopieren dürfen, weil der Zugriffsmodifizierer private nur allen anderen verbietet, Objekte zu kopieren. Deshalb sollten Sie aufpassen, dass Sie selbst in Ihrer eigenen Klasse nicht aus Versehen Ihre Objekte kopieren.
39) Es können jedoch unter gewissen Umständen Mehrdeutigkeiten auftreten, wenn Sie ein Objekt mit nur einem Ausdruck und der funktionalen Schreibweise initialisieren. In solchen Fällen wird der Übersetzer nicht ein Objekt definieren, sondern eine Funktion (!) deklarieren. Da es ziemlich schwierig ist, die genauen Umstände mit einfachen Worten zu erklären, verwenden Sie einfach die Schreibweise mit dem Gleichheitszeichen, falls der Übersetzer bei einer Initialisierung meckert.
146
Kopien nicht immer sinnvoll
privater Kopier-Konstruktor
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Den so deklarierten Kopier-Konstruktor müssen Sie übrigens nicht definieren, da Sie ihn nicht benutzen werden. Das ist generell in C++ so: Objekte, Typen oder Funktionen, die deklariert aber nicht benutzt werden, brauchen keine Definition. Dies löst auch das o. g. Problem: Wenn Sie fälschlicherweise Objekte Ihrer eigenen Klasse kopieren, obwohl dies eigentlich verboten sein soll, wird spätestens der Binder meckern, weil er die Definition des Kopier-Konstruktors nicht finden wird.
Beispiel:1 // Objekte dieser Klasse lassen sich nicht kopieren!2 class DoNotCopy3 {4 public :5 // normaler Konstruktor6 DoNotCopy ();7 private :8 // Kopier-Konstruktor (braucht nicht definiert zu werden)9 DoNotCopy (const DoNotCopy &quelle);10 };
Was ist aber, wenn Objekte wie eingangs erwähnt „lange“ leben müssen und beispielsweise innerhalb einer Funktion erzeugt und zurückgegeben werden? Das folgende Beispiel funktioniert ja nicht, wenn das Objekt keinen Kopier-Konstruktor hat:
1 // gibt ein DoNotCopy-Objekt zurück2 DoNotCopy erzeugeDoNotCopyObjekt ()3 {4 DoNotCopy objekt;5 return objekt; // funktioniert nicht, Kopie kann nicht durchgeführt werden6 }
Die Lösung ist, das Objekt in einem „langlebigen“ Speicherbereich anzulegen und mit Verweisen darauf zu arbeiten. Genaueres erfahren Sie in Abschnitt 4.5.5. Zunächst müssen wir uns aber mit Zuweisungen befassen.
4.5.3.3 ZuweisungenAlles, was über Kopier-Konstruktoren gesagt wurde, gilt ähnlich auch für Zuweisungen, nur ist dabei der Kopier-Konstruktor nicht betroffen. Denn eine Zuweisung (3.4.1) ist keine Erstellung einer Kopie. Vielmehr wird der Inhalt eines Objekts in ein bestehendes Objekt kopiert. Da somit kein neues Objekt erstellt wird, kann der Kopier-Konstruktor keine Rolle spielen. Stattdessen geht es hier um den Zuweisungsoperator.
Sie können für Ihre Klasse die Semantik des Zuweisens über einen eigenen Zuweisungsoperator definieren. Genauso wie der Kopier-Konstruktor ist auch der Zuweisungsoperator für jede Klasse implizit definiert. Seine Implementierung ist einleuchtend: Er kopiert Attribut für Attribut den Inhalt des Quell-Objekts in das Ziel-Objekt. Dabei wird bei objektwertigen Attributen wieder deren Zuweisungsoperator benutzt, während bei allen anderen eine bitweise Kopie angefertigt wird.
Bei einer Zuweisung wird der Zuweisungsoperator des Ziel-Objekts aufgerufen, weil dies das Objekt ist, das verändert wird. Dabei bekommt die Methode das Quell-Objekt als Argument übergeben.
147
Kopier-Konstruktor muss nicht definiert werden
Lebensdauer von nicht kopierbaren Objekten
eine Zuweisung ist keine Initialisierung
eigener Zuweisungsoperator
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Der Zuweisungsoperator wird folgendermaßen deklariert:
Klassenname & operator = ( const Klassenname & Parametername );Das Schlüsselwort operator zusammen mit dem Gleichheitszeichen zeigt an, dass der Zuweisungsoperator gemeint ist. Der Rückgabetyp ist eine Referenz auf die zugehörige Klasse. Die Implementierung muss neben der entsprechenden Zuweisung der Attribute nämlich auch eine Referenz auf das eigene, veränderte Objekt zurückliefern.
Beispiel:4 // Diese Klasse kapselt einen Wert.5 class Wert6 {7 public :8 // ... die alten Definitionen9
10 // der Zuweisungsoperator11 Wert &operator= (const Wert &quelle);1213 // ... die alten Definitionen14 };1516 Wert &Wert::operator= (const Wert &quelle)17 {18 wert = quelle.gibWert ();19 return *this; // gib Verweis auf das aktuelle Objekt zurück20 }
Einige Anmerkungen zum Programm:
• Zeile 18: Der Zuweisungsoperator in unserem Beispiel kopiert nur das Attribut wert; das Attribut original lässt er unangetastet. Die Idee dabei ist, dass ein Objekt durch Zuweisung seinen Status „Original“ oder „Kopie“ nicht verliert, weil es seine Identität beibehält. Ein Original bleibt somit ein Original, auch wenn ihm eine Kopie zugewiesen wird; und eine Kopie bleibt eine Kopie, auch wenn ihr irgendwann ein Original-Objekt zugewiesen wird.
• Zeile 19: Wie gesagt muss ein Zuweisungsoperator das veränderte Objekt zurückliefern. this ist ja ein Zeiger auf das Objekt, für das die Methode aufgerufen wurde (4.4.5.2); da wir aber nicht den Zeiger, sondern das Objekt, auf das der Zeiger verweist, benötigen, müssen wir den this-Zeiger mit Hilfe des *-Operators dereferenzieren (3.4.3.3).
Zum Schluss noch eine wichtige Anmerkung: Fast immer, wenn Sie einen benutzerdefinierten (d. h. eigenen) Kopier-Konstruktor implementieren, müssen Sie auch einen entsprechenden benutzerdefinierten Zuweisungsoperator entwickeln. Denn beide sind für das Kopieren von Objekten zuständig, nur in unterschiedlichen Kontexten: Während beim Kopier-Konstruktor das Ziel-Objekt noch nicht existiert, ist es beim Zuweisungsoperator bereits da. Beide müssen jedoch Inhalte von Attributen kopieren (entweder durch Initialisierung oder durch Zuweisung). Deshalb bilden beide in der Regel ein enges Team mit ähnlicher Funktionalität.
Merksatz 21: Betrachte Kopierkonstruktor und Zuweisungsoperator als Team!
148
Form des Zuweisungsoperators
Ein Zuweisungsoperator gibt *this zurück
Kopier-Konstruktor und Zuweisungsoperator bilden ein Team
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.5.4 Temporäre ObjekteWir haben in Zusammenhang mit Objekten noch ein wichtiges „Feature“ von C++ unterschlagen: temporäre Objekte. Temporäre Objekte sind unbenannte Objekte, deren Lebensdauer nur kurz („temporär“) ist und die nach ihrer Benutzung wieder verschwinden. In der nicht-objektorientierten Programmierung ist dieses Vorgehen gang und gäbe. Schauen Sie sich das folgende Beispiel einmal an:
1 int result = 1 + 42*23;Hier werden die Ganzzahl-Konstanten 1, 42 und 23 verwendet, ohne dass benannte Variablen oder Konstanten definiert und mit ihnen initialisiert werden.
Genauso wie bei primitiven Datentypen und den in C++ fest eingebauten Konstanten funktioniert das mit Objekten auch, nur die Syntax ist eine andere. Die Syntax für das Erzeugen und Benutzen temporärer Objekte ist ähnlich der Definition benannter Objekte, nur dass eben der Name fehlt:
Klassenname ( [Argument1 [, Argument2 [, ...]]] )Sie können sich die Erzeugung eines temporären Objekts auch so vorstellen, dass Sie den Konstruktor der Klasse wie eine Funktion aufrufen, der Ihnen dann ein Objekt der gewünschten Klasse zurück liefert. Dies ist übrigens ein Grund dafür, warum ein Konstruktor in C++ genauso heißt wie die zugehörige Klasse.
Wir wollen das Ganze an einem Beispiel verdeutlichen:1 /*** Beispiel tempobj.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // kapselt eine Ganzzahl7 class Number8 {9 public :10 Number (int n);11 int Value () const;12 private :13 int m_n;14 };15 Number::Number (int n)16 :17 m_n (n)18 {19 }20 int Number::Value () const21 {22 return m_n;23 }2425 int main ()26 {27 // 1 + 42*2328 cout << Number(1).Value()29 + Number(42).Value() * Number(23).Value() << endl;30 return 0;31 }
149
Syntax für das Erzeugen temporärer Objekte
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Die Klasse Number „verpackt“ int-Werte. Es gibt einen entsprechenden Konstruktor, der einen int-Wert in einem Number-Objekt zwischenspeichert, und eine Value-Operation, die ihn wieder herausholt. Somit entsprechen Number-Objekte ganzen Zahlen.
Uns interessiert hier jedoch weniger die Semantik40 der Klasse Number als vielmehr die Erzeugung und Benutzung von temporären Objekten. Dies sehen Sie in den Zeilen 28 und 29: Hier werden drei temporäre Number-Objekte erzeugt und verwendet.
Wozu aber überhaupt temporäre Objekte? Zuerst einmal: Man kann natürlich ohne sie auskommen und alle Objekte, mit denen man es zu tun hat, explizit als Variablen bzw. benannte Konstanten definieren. Allerdings ist es häufig der Fall, dass man Objekte innerhalb eines Ausdrucks zur Berechnung eines Ergebnisses benötigt, die Objekte aber sonst nirgendwo braucht. Ein Beispiel haben Sie bereits in Abschnitt 3.4.2.1 kennen gelernt:
4 string abba = string("AB")+string("BA");
Hier werden die Teil-Zeichenketten nach der Ausführung der Verkettung nicht mehr benötigt. Generell kann also gesagt werden, dass Sie temporäre Objekte immer dann benutzen können, wenn Sie die Dienste dieser Objekte nur kurzfristig „als Mittel zum Zweck“ einsetzen und anderweitig nicht benötigen.
Natürlich wird für ein temporäres Objekt neben dem obligatorischen Konstruktor auch der Destruktor aufgerufen, sobald das Objekt zerstört werden soll. Wann dies allerdings der Fall ist, ist etwas kompliziert. Generell kann man sagen, dass ein temporäres Objekt am Ende des enthaltenden Ausdrucks zerstört wird. Im obigen Beispiel werden also die Number-Objekte kurz vor dem Ausführen der return-Anweisung zerstört, da der enthaltende Ausdruck die Ausdrucks-Anweisung (3.5.1) in den Zeilen 28 und 29 ist.
Sie können temporäre Objekte an const-Referenzen (3.4.3.2) binden (mit nicht-const-Referenzen funktioniert das nicht, siehe hierzu auch Abschnitt 4.4.7.2). In einem solchen Fall gibt es die Regelung, dass das temporäre Objekt nicht wie gehabt am Ende des enthaltenden Ausdrucks zerstört wird; vielmehr wird dessen Lebensdauer (4.5.3) auf die Lebensdauer der Referenz ausgeweitet, an die es gebunden wird. Dadurch wird verhindert, dass die Referenz-Variable auf ein Objekt verweist, das nicht mehr existiert. Beispiel:
1 int square (int i)2 {3 const Number &result = Number(i*i);4 return result.Value ();5 }
Das temporäre Objekt, das in Zeile 3 erzeugt wird, wird an die Referenz-Variable result gebunden; dadurch wird es erst zerstört, wenn die Lebensdauer von result erlischt. Dies geschieht in diesem Beispiel am Ende der Funktion, wenn der Gültigkeitsbereich (3.3.4) der Referenz-Variable verlassen wird. Dadurch wird sichergestellt, dass die Nachricht Value in Zeile 4 an ein existierendes Objekt geschickt wird.
Sie sollten allerdings vermeiden, temporäre Objekte an const-Referenzen zu binden, wenn sich dies auch einfacher erledigen lässt. Beispiel:
40) Bedeutung
150
Wozu temporäre Objekte?
temporäre Objekte und Referenzen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
1 const Number &n1 = Number(5);2 const Number &n2 = Number(n1.Value()*n1.Value());
Hier können Sie auch1 Number n1(5);2 Number n2(n1.Value()*n1.Value());
schreiben, was einfacher, übersichtlicher und meistens sogar performanter ist.
Der häufigste Grund, temporäre Objekte an const-Referenzen zu binden, ist bei der Parameterübergabe (siehe hierzu die Abschnitte 3.6.4 und 4.4.7.2).
4.5.5 Dynamischer Speicher
4.5.5.1 Speicher belegen und freigebenIn diesem Abschnitt lösen wir das Problem der kurzlebigen Objekte. Bisher war es so, dass unsere Objekte immer nur in dem Block existieren konnten, in dem sie definiert wurden (4.5.3). Wir lernen jetzt eine Möglichkeit kennen, Objekte ohne eine zugehörige Definition zu erzeugen. Somit fällt die Begrenzung der Lebensdauer automatisch weg.
Es gibt einen Operator in C++, der Objekte in einem gesonderten Speicherbereich, dem Freispeicher oder Heap erzeugt: den Operator new. Ein Ausdruck mit dem new-Operator hat folgenden Aufbau:
new Typname [ ( Initialisierungsargumente ) ]
Das Ergebnis eines solchen Ausdrucks ist ein Zeiger (3.4.3.3) auf das neu erzeugte Objekt. Der Typ des Ergebnisses ist folglich Typname *.
Das Objekt existiert solange, bis es wieder explizit über den delete-Operator freigegeben wird:
delete Ausdruck
Dabei muss Ausdruck ein Zeiger sein, und zwar ein Zeiger, der vorher vom new-Operator zurückgeliefert wurde.
Auf das erzeugte Objekt kann normal zugegriffen werden, sobald der Zeiger darauf über die Operatoren * oder -> (3.4.3.3) dereferenziert wurde.
Beispiel:1 /*** Beispiel newdelete.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // Diese Klasse repräsentiert einen einfachen Zähler.7 class Zaehler8 {9 public :10 // Konstruktor. Initialisiert den Zähler mit übergebenem Startwert11 Zaehler (int startwert);12 // gibt den aktuellen Wert des Zählers zurück13 int gibWert () const;14 // erhöht den Zähler um Eins
151
Freispeicher und Operator new
delete ist Gegenstück zu new
Zugriff auf das Freispeicher-Objekt
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
15 void erhoehe ();16 // addiert „delta“ zum Zähler17 void addiere (int delta);18 private :19 int wert;20 };2122 Zaehler::Zaehler (int startwert)23 :24 wert (startwert)25 {26 }2728 int Zaehler::gibWert () const29 {30 return wert;31 }3233 void Zaehler::erhoehe ()34 {35 addiere (1);36 }3738 void Zaehler::addiere (int delta)39 {40 wert += delta;41 }4243 int main ()44 {45 Zaehler *zaehler = new Zaehler (1);46 cout << "Wert des Zählers: " << zaehler->gibWert () << endl;47 zaehler->addiere (1);48 cout << "Wert des Zählers: " << zaehler->gibWert () << endl;49 (*zaehler).addiere (-2);50 cout << "Wert des Zählers: " << (*zaehler).gibWert () <<endl;51 delete zaehler;52 return 0;53 }
Dieses Beispiel entspricht dem Beispiel in Abschnitt 4.5.1, bloß dass das Zaehler-Objekt jetzt auf dem Freispeicher liegt. Die Ausdrücke und Anweisungen, welche die Verwendung von Objekten im Freispeicher betreffen, sind nachfolgend erläutert.
• Zeile 45: Hier wird ein Objekt mit Hilfe des Operators new auf dem Freispeicher erzeugt und der Zeiger darauf in der Variable zaehler hinterlegt.
Der Zeiger ist die einzige Möglichkeit, an das erzeugte Objekt heranzukommen. Deshalb ist es wichtig, ihn nicht zu „verlieren“, bevor das Objekt via delete zerstört und der Speicher wieder freigegeben wird.
• Zeile 46-48: In diesen Zeilen wird mit dem Operator -> auf die Operationen des Objekts zugegriffen.
• Zeile 49-50: Dito, nur mit den *- und .-Operatoren. Beide Arten des Zugriffs sind vollkommen äquivalent, nur ist die erste kürzer und benötigt keine Klammerung.
152
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
• Zeile 51: Hier wird das in Zeile 45 erzeugte Objekt wieder zerstört und der zugeordnete Speicherbereich wieder freigegeben.
Der Operator new alloziert (belegt) nicht nur den Speicher für das Objekt, er ruft danach auch den entsprechenden Konstruktor auf und übergibt ihm die Initialisierungsargumente. Analog dazu ruft der delete-Operator den Destruktor auf, bevor er den Speicherbereich wieder freigibt. Die Konstruktoren und Destruktoren werden also genauso aufgerufen wie bei lokal (3.3.4) definierten Objekten.
Es kann nicht genug gesagt werden: Achten Sie immer darauf, dass Sie alle Objekte, die Sie mit new erzeugen, auch wieder mit delete zerstören. Wenn Sie dies vergessen, kann dies zu allerlei Fehlern führen:
(1) Der erste und offensichtliche Fehler ist Speicher-Verlust. Der Speicher, der für das nicht freigegebene Objekt reserviert ist, bleibt bis zur Beendigung des Programms für andere Objekte unerreichbar. Wenn Sie regelmäßig Verweise auf Objekte „vergessen“ und den Speicher nicht freigeben, wird der Speicher irgendwann volllaufen und das Programm wegen Speichermangel abbrechen.
(2) Der zweite und subtilere Fehler ist Ressourcen-Verlust. Neben dem Speicher kann ein Objekt noch andere System-Ressourcen verwalten, etwa offene Dateien, offene Netzwerkverbindungen, Sperren etc. Wird ein Objekt nicht zerstört, wird sein Destruktor nicht aufgerufen, und die Ressourcen werden folglich nicht zurückgegeben bzw. ordentlich „entsorgt“. Da jedes System über Grenzen verfügt (etwa die maximale Anzahl an geöffneten Dateien pro Prozess), können diese Grenzen bald erreicht sein, wenn Sie häufig ihre Objekte einfach „vergessen“
Um eine variable Anzahl an Objekten zu erzeugen, zu verwalten und wieder freizugeben, verwenden Sie am besten eine der Container-Klassen aus der C++-Standard-Bibliothek, wie vector oder list. Diese verwenden intern die new- und delete-Operatoren, so dass sich der Programmierer darum nicht mehr kümmern muss.
C++ verfügt standardmäßig nicht über einen sogenannten Garbage Collector, d. h. eine Komponente der Laufzeit-Umgebung, die automatisch Objekte zerstört und deren Speicher freigibt, auf die es keine Verweise (sprich: Zeiger und Referenzen) mehr gibt. Sie sind selbst dafür verantwortlich, die von Ihnen angeforderten Ressourcen auch wieder freizugeben, und das beinhaltet neben System-Ressourcen wie offene Dateien auch den Speicher der verwendeten Objekte, wenn sie nicht mehr gebraucht werden.
4.5.5.2 Der „leere“ VerweisWenn Objekte auf dem Freispeicher erzeugt werden, werden Verweise auf sie in Zeiger-Variablen gespeichert. Nun kann es sein, dass eine Zeiger-Variable zwar initialisiert werden muss (etwa im Konstruktor einer Klasse, wenn es sich um ein Attribut handelt), aber zu diesem Zeitpunkt kein Objekt erstellt werden soll, sondern erst später.
Zu diesem Zweck gibt es in C++ den sogenannten Null-Zeiger. Er wird durch die Ganzzahl-Konstante 0 dargestellt und steht für einen Zeiger, der ins „Nichts“ verweist. Ein Null-Zeiger ist mit jedem Zeiger-Typ verträglich und kann jeder Zeiger-Variable zugewiesen werden.
Achtung: Der Zeiger verweist wirklich ins „Nichts“! Wenn Sie einen solchen Null-Zeiger dereferenzieren, d. h. versuchen, das nicht vorhandene Objekt „hinter“ dem Zeiger zu nutzen, wird Ihr Programm mit hoher Wahrscheinlichkeit abstürzen. Gehen Sie also doppelt und dreifach sicher, dass sie auf kein Objekt über einen Null-Zeiger zuzugreifen versuchen!
153
new/delete und Konstruktoren/Destruktoren
Freispeicher-Objekte müssen explizit freigegeben werden!
Container zur Verwaltung dynamisch erzeugter Objekte nutzen
kein Garbage Collector in C++
Verweis ins Nichts: der Null-Zeiger
Null-Zeiger sind gefährlich!
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Weil Null-Zeiger so gefährlich sind, gibt es viele, die den Einsatz von Null-Zeigern generell ablehnen. Stattdessen schlagen sie den Einsatz von speziellen Objekten vor, die genauso aussehen wie die „richtigen“, aber auf jede Operation mit einer Ausnahme reagieren. Sie repräsentieren also so etwas wie Null-Objekte, haben aber den Vorteil, dass das Programm beim Zugriff nicht abstürzt, sondern eine Ausnahme auswirft, die geeignet behandelt werden kann.
Merksatz 22: Vermeide Null-Zeiger, wo es nur geht!
4.5.5.3 Wenn kein Speicher mehr da istSelbst wenn Sie auf dem größten und teuersten Rechner-System arbeiten: Speicher ist immer endlich. Es ist möglich, dass ihr Programm an die Speicher-Grenzen des Systems stößt. Wenn Sie versuchen, ein Objekt mit dem Operator new zu erzeugen und kein Speicher mehr zur Verfügung steht, wird von der C++-Laufzeit-Umgebung ein Objekt der Klasse std::bad_alloc (definiert in der Header-Datei <new>) erzeugt und als Ausnahme ausgeworfen. Diese Ausnahme können Sie auffangen und geeignet behandeln. Neben einem ziemlich drastischen Programmabbruch ist eine sinnvolle Alternative, wenig benötigte Objekte (die z. B. als Zwischenspeicher fungieren) freizugeben.
Das Ausnahme-Objekt wird selbst natürlich nicht auf dem Freispeicher erstellt, weil das zu einer unendlichen Fehler-Kette führen würde. Jede C++-Implementierung reserviert am Programm-Anfang einen speziellen Speicherbereich, der in solchen Situationen der extremen Speicher-Belastung für Ausnahme-Objekte benutzt wird.
In VC++ funktioniert der Mechanismus mit dem Auswerfen einer Ausnahme bei Speichermangel nicht automatisch. Er muss erst geeignet aktiviert werden. Sie sollten in jedem VC++-Programm die folgende Datei in Ihr Projekt einbinden, der Code zur Verwendung von C++-Ausnahmen bei zu wenig Speicher ermöglicht:
1 /*** VC++-Erweiterung vcppnew.cpp ***/2 /*** stellt sicher, dass bei Speichermangel eine Ausnahme ausgeworfen wird ***/3 #include <new> // für std::bad_alloc4 #include <new.h> // für set_new_handler5 using namespace std;67 #pragma warning (disable:4073) // Warnung unterdrücken8 #pragma init_seg(lib) // frühe Initialisierung des globalen Objekts9
10 // kapselt Logik zum Setzen eines neuen new-Handlers11 class BadAllocActivator12 {13 public :14 // Konstruktor: setzt den neuen new-Handler und merkt sich den alten15 // in oldNewHandler16 BadAllocActivator ();17 // Destruktor: restauriert den alten new-Handler18 ~BadAllocActivator ();19 private :20 // Attribut speichert alten new-Handler21 _PNH oldNewHandler;22 // neuer new-Handler23 static int newNewHandler (size_t size);24 };25
154
wenn der Speicher knapp wird
VC++ braucht etwas „Nachhilfe“
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
26 BadAllocActivator::BadAllocActivator ()27 {28 // verwende VC++-eigene Routine zum Setzen des new-Handlers29 oldNewHandler = _set_new_handler (newNewHandler);30 }3132 BadAllocActivator::~BadAllocActivator ()33 {34 // verwende VC++-eigene Routine zum Setzen des new-Handlers35 _set_new_handler (oldNewHandler);36 }3738 // Parameter wird nicht benötigt, deswegen ist sein Name auskommentiert39 int BadAllocActivator::newNewHandler (size_t /*size*/)40 {41 // wirf Ausnahme aus42 throw bad_alloc ();43 }4445 // globales Objekt, das noch vor der Ausführung von main initialisiert wird und46 // auch erst nach der Beendigung von main zerstört wird47 BadAllocActivator badAllocActivator;
Dieses Modul installiert, noch bevor die ersten Anweisungen der Funktion main aufgerufen werden, eine neue VC++-interne Fehlerbehandlungs-Funktion für den new-Operator, die eine Ausnahme bei zu wenig Speicher auswirft. Das Skript kann leider nicht auf alle Sprachmittel eingehen, die hier verwendet werden; nehmen Sie es einfach hin, dass der obige Programm-Code funktioniert.
4.5.5.4 Dynamische DatenstrukturenDynamisch allozierte Objekte werden verwendet, wenn nicht von vornherein bekannt ist, wie viele Objekte benötigt werden. Häufig werden diese Objekte in dynamisch wachsenden und schrumpfenden Datenstrukturen abgelegt. Für diese Datenstrukturen ist es charakteristisch und notwendig, dass Zeiger verwendet werden, um auf die verwalteten Objekte zu verweisen.
Wir wollen eine kleine, einfach verkettete Liste als Grundlage eines Stapels zum Speichern von Zahlen entwickeln, um den Einsatz von dynamischen Datenstrukturen mit Hilfe von Zeigern vorzustellen. Allerdings sollten Sie immer im Hinterkopf haben, dass die hier entwickelte Datenstruktur ein Beispiel ist, um gewisse C++-Sprachkonzepte zu verdeutlichen. Sie sollten stets die in der C++-Standard-Bibliothek angebotenen Datenstrukturen (8.3) nutzen, wenn Sie dynamisch Objekte verwalten wollen; diese sind reich an Algorithmen, optimiert, gut getestet, flexibel einsetzbar und generisch (d. h. für alle möglichen Typen geeignet, nicht nur für Zahlen).
Zuerst klären wir, was ein Stapel ist. Ein Stapel ist ein Behälter, bei dem nur auf das Element zugegriffen werden kann, das zuletzt „darauf gelegt“ wurde. Wie bei einem richtigen Stapel ist dieses Element „ganz oben“. Die typischen Operationen für einen Stapel sind (Abbildung 34):
• push (drauflegen)
• pop (herunternehmen)
• top (oberstes Element inspizieren, ohne es wegzunehmen)
155
Wozu dynamische Objekte?
exemplarisch: verkettete Liste als dynamische Datenstruktur
Analyse und Entwurf
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
• isEmpty (prüfen, ob der Stapel leer ist)
Dann klären wir, was eine einfach verkettete Liste ist. Eine solche Liste besteht aus Elementen, die sich gegenseitig kennen, und zwar kennt jedes Element seinen Nachfolger in der Liste. Kennt man somit den Anfang der Liste, kann man alle Elemente in der Liste finden, wenn man sich an den Elementen über die Nachfolger-Beziehung „entlanghangelt“. Abbildung 35 verdeutlicht dies anhand einer konkreten Liste mit drei Elementen.
Jetzt, wo wir wissen, wie eine Liste und ein Stapel funktionieren, können wir die Operationen und die Beziehungen zwischen den Klassen in einem Klassendiagramm zusammenfassend beschreiben (Abbildung 36).
156
Abbildung 35: Einfach verkettete Liste mit drei Elementen
element1: Element element2: Element
element3: Elementliste: Liste
anfang
Abbildung 34: Stapel-Operationen
element1 element1
element2
element1
top
push(leerer Stapel)
push pop
top
top
Abbildung 36: Entwurf einer Stapel-Klasse, die auf einer verketten Liste aufbaut
Liste
add(value: int)remove()getFirst(): intisEmpty(): bool
0..1
head
Element
value: intElement (value: int, next: Element)getValue(): intgetNext(): Element
0..1next
Stapel
push(value: int)pop()top(): intisEmpty(): bool
1impl
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Wir müssen noch die genaue Bedeutung der Operationen add und remove klären. Um es uns später einfach zu machen, sind diese Operationen folgendermaßen aufgebaut: Jedes neu hinzugefügte Element wird an den Anfang der Liste gestellt, jedes entfernte Element wird vom Anfang der Liste genommen. Somit ist unsere so entworfene Liste genauso wie der Stapel eine LIFO-Datenstruktur und für die Nutzung durch den Stapel geradezu prädestiniert.
Jetzt können wir fast den Entwurf eins-zu-eins in C++-Quelltext übertragen. Wir rekapitulieren: Das Ziel des Entwurfs ist es, die technische Lösung darzustellen, und zwar möglichst so, dass alle Informationen zur Implementierung der beschriebenen Klassen und Methoden existieren. Unser Entwurf ist detailliert genug, um die Implementierung relativ einfach „hinzuschreiben“. Wir wollen dies stückweise tun.
1 /*** Beispiel stapel.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;6
Zuerst kommt der fast schon obligatorische Anfang.7 // stellt den Typ für die gespeicherten Werte dar8 typedef int ValueType;9
Hier wird der Typ definiert, der in den Elementen der Liste gespeichert wird. In unserem Entwurf waren wir nicht vorausschauend genug und haben uns auf den Typ int festgelegt. Das ist natürlich unschön, und deshalb haben wir eine passende Abstraktion (ValueType) gewählt.
10 // Kapselt ein Listen-Element.11 class Element12 {13 public :14 // Konstruktor. Setzt den Wert und den Zeiger auf das nächste Element.15 Element (ValueType theValue, Element *theNext);1617 // Liefert gespeicherten Wert zurück.18 ValueType getValue () const;1920 // Liefert Zeiger aufs nächste Element zurück.21 Element *getNext () const;22 private :23 ValueType value;24 Element *next;25 };26
Die Klasse Element wird definiert. Die Kommentare sollten detailliert genug sein, um die Bedeutung der Operationen zu verstehen. Zu beachten ist, dass Objekte der Klasse Element im Prinzip konstant sind, denn es gibt keinerlei Operationen, die zur Änderung von Attributen herangezogen werden könnten. Einmal durch den Konstruktor initialisiert, behält eine Element-Instanz den gespeicherten Wert und die Verbindung zum Nachfolger-Element bei. Auch sind die Operationen zum Zugriff auf die Attribute const (4.4.7.1), weil sie das Objekt nicht verändern.
157
Implementierung
Typ-Abstraktion für zu speichernde Werte
Element-Objekte sind unveränderlich
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
27 Element::Element (ValueType theValue, Element *theNext)28 :29 value (theValue),30 next (theNext)31 {32 }3334 ValueType Element::getValue () const35 {36 return value;37 }3839 Element *Element::getNext () const40 {41 return next;42 }43
Die Implementierung der entsprechenden Operationen und des Konstruktors ist nicht weiter besonders aufregend.
44 // Implementiert eine einfach verkettete Liste.45 class Liste46 {47 public :48 // Konstruktor: erstellt leere Liste.49 Liste ();5051 // Fügt Wert am den Anfang der Liste ein.52 void add (ValueType value);5354 // Entfernt Wert vom Anfang der Liste.55 void remove ();5657 // Liefert Wert am Anfang der Liste.58 ValueType getFirst () const;5960 // Liefert true wenn Liste leer ist, false sonst.61 bool isEmpty () const;62 private :63 Element *head;64 };65
Die Definition einer Liste entspricht direkt unserem Entwurf. Die Operationen getFirst und isEmpty sind beide const, da sie die Objekte unverändert lassen.
66 Liste::Liste ()67 :68 // kein Element am Anfang: Liste ist leer69 head (0)70 {71 }7273 void Liste::add (ValueType value)74 {75 // erstellt neues Element, verkettet es mit dem Element, das momentan76 // am Anfang steht, und stellt das neue Element selbst an den Anfang77 head = new Element (value, head);78 }7980 void Liste::remove ()
158
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
81 {82 // speichere alten Anfang, setze Anfang auf das Element,83 // das ihm folgt, und zerstöre das alte Anfangs-Element84 Element *oldHead = head;85 head = head->getNext ();86 delete oldHead;87 }8889 ValueType Liste::getFirst () const90 {91 // frage Anfangs-Element92 return head->getValue ();93 }9495 bool Liste::isEmpty () const96 {97 // Liste ist leer, wenn kein Anfang existiert98 return head == 0;99 }
100
Dies ist die Implementierung der Operationen der Klasse Liste. Beachten Sie den Gebrauch der Element-Objekte, die auf dem Freispeicher erzeugt werden. In der Methode add wird in Zeile 77 für jedes neue Element ein Element-Objekt auf dem Freispeicher erzeugt und zum Anfang der Liste gemacht. Dabei wird der vorherige Anfang der Liste der Nachfolger des neuen Anfangs; somit ist der vorherige Anfang nun das zweite Element der Liste. Beim Einfügen des allerersten Elements gibt es keinen Anfang; in diesem Fall existiert kein Verweis auf das nächste Element und das neue Element ist gleichzeitig das Ende der Liste.
Die Methode remove entfernt das erste Element der Liste, indem es sich zuerst dieses in einer lokalen Variable merkt und dann das Element hinter dem aktuellen Anfang zum ersten Element macht. Danach wird das ursprünglich am Beginn stehende und nun nicht mehr gebrauchte Element in Zeile 86 ordnungsgemäß zerstört und der belegte Speicher freigegeben.
101 // Implementiert einen Stapel.102 class Stapel103 {104 public :105 // packt Wert auf den Stapel106 void push (ValueType value);107108 // entfernt obersten Wert vom Stapel109 void pop ();110111 // liest obersten Wert vom Stapel (lässt ihn aber dort)112 ValueType top () const;113114 // liefert true wenn Stapel leer ist, false sonst.115 bool isEmpty () const;116 private :117 Liste impl;118 };119
159
add und new
remove und delete
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Die Definition der Klasse Stapel entspricht ebenfalls direkt unserem Entwurf. Beachten Sie die Zeile 117: Die Zu-eins-Assoziation impl wurde als einfaches Attribut umgesetzt. Dies ist typisch für C++ und auch andere Sprachen: Zu-eins-Assoziationen und Attribute sind sich sehr ähnlich (4.4.4). Assoziationen hingegen, die auf viele Elemente verweisen (etwa 0..* oder 1..*) können nur durch geeignete Datenstrukturen abgebildet werden. Solche Datenstrukturen sind ja auch gerade die von uns entwickelten Klassen Liste und Stapel.
120 void Stapel::push (ValueType value)121 {122 impl.add (value);123 }124125 void Stapel::pop ()126 {127 impl.remove ();128 }129130 ValueType Stapel::top () const131 {132 return impl.getFirst ();133 }134135 bool Stapel::isEmpty () const136 {137 return impl.isEmpty ();138 }139
Die Implementierung der Operationen der Klasse Stapel gestaltet sich sehr einfach: Wir delegieren alle Operationen an das Element impl. (impl steht übrigens für Implementierung und zeigt an, dass die Dienste der Klasse Stapel mit Hilfe der Klasse Liste implementiert sind.)
140 // Zeigt Informationen zum Status des Stapels an:141 // 1) leer oder nicht leer142 // 2) wenn nicht leer: oberstes Element143 // Eingabe-Parameter:144 // "stapel": Referenz auf einen Stapel145 // "nachricht" wird vor den anderen Informationen als zusätzliche Information ausgegeben146 void zeigeStapelInfo147 (const string &nachricht, const Stapel &stapel)148 {149 bool empty = stapel.isEmpty ();150 cout151 << nachricht << ": \n"152 << " Stapel ist: "153 << (empty ? "leer" : "nicht leer") << endl;154155 if (!empty)156 cout << " oben liegt: " << stapel.top () << endl;157 }158
Diese Funktion hilft uns, die Implementierung der Klasse Stapel zu testen. Sie gibt Informationen über den Zustand des Stapels (leer/nicht leer, oberstes Element falls vorhanden) über den cout-Ausgabe-Strom aus.
160
Assoziationen und Attribute
Delegation
Test
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
159 int main ()160 {161 Stapel stapel;162 zeigeStapelInfo ("nach Konstruktor", stapel);163 stapel.push (1);164 zeigeStapelInfo ("nach push(1)", stapel);165 stapel.push (2);166 zeigeStapelInfo ("nach push(2)", stapel);167 stapel.push (3);168 zeigeStapelInfo ("nach push(3)", stapel);169 stapel.pop ();170 zeigeStapelInfo ("nach pop", stapel);171 stapel.pop ();172 zeigeStapelInfo ("nach pop", stapel);173 stapel.pop ();174 zeigeStapelInfo ("nach pop", stapel);175176 return 0;177 }
Unser Hauptprogramm erstellt in Zeile 161 ein (lokales) Objekt der Klasse Stapel und führt ein paar Operationen auf diesem Objekt aus. Die durchgeführten Ausgaben sind (falls Sie alles richtig gemacht haben):
nach Konstruktor: Stapel ist: leernach push(1): Stapel ist: nicht leer oben liegt: 1nach push(2): Stapel ist: nicht leer oben liegt: 2nach push(3): Stapel ist: nicht leer oben liegt: 3nach pop: Stapel ist: nicht leer oben liegt: 2nach pop: Stapel ist: nicht leer oben liegt: 1nach pop: Stapel ist: leer
4.6 Abstrakte Datentypen: Erweiterbarkeit und Flexibilität erhöhenIn diesem Abschnitt lernen Sie, wie Sie Gemeinsamkeiten beim Verhalten von Objekten in C++ ausdrücken. Sie lernen, Schnittstellen und Implementierung voneinander zu trennen und erfahren, wie Sie daraus konkret Nutzen ziehen können.
4.6.1 (Abstrakte) Operationen und abstrakte KlassenEine Operation ist, wie Sie in Abschnitt 4.1 gelernt haben, eine Spezifikation eines Dienstes. Eine abstrakte Operation ist eben genau dies – sie sagt aus, was getan wird, aber nicht, wie es getan wird. Es existiert keine Methode in der betreffenden Klasse, die diesen Dienst auch implementiert.
161
Benutzung
Wiederholung: abstrakte Operationen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Ein kleines Beispiel zum Verständnis: Betrachten wir einmal ein System zur Manipulation von geometrischen Figuren. Dieses System lässt den Benutzer u. a. Linien41, Rechtecke und Kreise erzeugen und sie geeignet verändern. Sie können sich unser System also als einen Editor für (einfache) Graphiken vorstellen.
Zuerst einmal ist es klar, dass wir für jede Art eines solchen graphischen Objekts eine eigene Klasse einführen. Wir haben also die Klasse Line zum Kapseln von Linien, die Klasse Rectangle zum Kapseln von Rechtecken und die Klasse Circle zum Kapseln von – na, was denken Sie? – Kreisen.
Was macht ein solches graphisches Objekt aus? Nun, zuerst hat jedes Objekt augenscheinlich gewisse Eigenschaften oder Attribute:
• Linie (Line): wird durch Start- und Endpunkt bestimmt
• Rechteck (Rectangle): wird durch zwei Eckpunkte bestimmt, die einander diagonal gegenüberliegen
• Kreis (Circle): wird durch Mittelpunkt und Radius bestimmt
Allen ist erst einmal gemein, dass sie Punkte zur Beschreibung der Position benötigen. Ein Punkt ist also ein weiteres Konzept, das als Klasse ausgedrückt werden sollte:
• Punkt (Point): wird durch eine X- und eine Y-Koordinate bestimmtHieran können Sie erkennen, wie wichtig eine gründliche Analyse eines gestellten Problems ist. In der ursprünglichen Aufgabenstellung spielen Punkte keine Rolle. Nichtsdestoweniger müssen Sie sich mit ihnen auseinandersetzen, weil die zu unterstützenden Figuren allesamt mit Punkten „zu tun haben“. Eine eigene Klasse für Punkte einzuführen ist wichtig, da sie die Kapselung von Eigenschaften eines Punktes ermöglicht.
Der letzte Satz bedarf einer Erklärung. Stellen Sie sich vor, Sie haben keine Klasse für Punkte eingeführt. Dann haben Sie in der Linien-Klasse wahrscheinlich Attribute wie startX und startY bzw. endX und endY, um den Start- und End-Punkt geeignet abzuspeichern. Ähnlich wird es in der Rechteck- und der Kreis-Klasse aussehen.
Jetzt kommt der Kunde (!) und fordert die Erweiterung des Systems auf den dreidimensionalen Raum. Sie müssen nun in allen Klassen, die von Koordinaten abhängen, ein zusätzliches Attribut für die Z-Koordinate einführen (z. B. startZ und endZ in der Linien-Klasse). Sie müssen sich in allen diesen Klassen darum kümmern, dass diese neuen Attribute geeignet initialisiert werden, eventuell müssen Sie Parameter zu Operationen hinzufügen, die mit Punkten operieren u. s. w. Sie haben also eine Menge zu tun.
Hätten Sie in diesem Fall von vornherein die Abstraktion Punkt erkannt und durch eine Klasse ausgedrückt, wäre Ihnen dies alles erspart geblieben. Alles, was Sie in diesem Fall tun müssen, ist ein entsprechendes Attribut für die Z-Koordinate zu der Punkt-Klasse hinzuzufügen und in dieser Klasse die Schnittstelle (Konstruktor, Zugriffs- und verändernde Operationen) entsprechend anzupassen. Alle Punkt-Klienten sind dadurch automatisch für den dreidimensionalen Raum gewappnet.42
41) genauer: Strecken42) Natürlich ist es mit dem Hinzufügen einer dritten Koordinate nicht getan: Die Algorithmen müssen
ebenfalls angepasst werden. Aber dies kann lokal geschehen, da diese Algorithmen (etwa der Abstand zweier Punkte) sicherlich Teil der Schnittstelle der Klasse Point ist, während im anderen Fall (keine eigene Klasse Point) die Algorithmen quer über das gesamte Programm verteilt sein könnten (und werden).
162
Beispiel: System für graphische Objekte
Eigenschaften und Zustände sind verschieden
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Diese Attribute haben alle eine unterschiedliche Funktion und sind somit in den einzelnen Klassen gut aufgehoben. Was die Klassen (inklusive der Klasse Point, die wir hinzugefügt haben) aber verbindet, ist die Schnittstelle, also wie ich mit den Objekten umgehe. Beispielsweise macht in dem Editor die folgende Schnittstelle Sinn:
• Anzeigen eines Objekts
• Verstecken eines Objekts
• Verschieben eines Objekts
• Drehen eines Objekts
• Vergrößern/Verkleinern eines Objekts
• u. v. a. m.
Natürlich ist die Realisierung dieser Operationen unterschiedlich: Ein Rechteck wird anders gedreht als eine Linie. (Oder haben Sie schon mal einen Kreis gedreht?) Wie die Operationen implementiert sind, ist aber dem Editor und letztlich dem Anwender so ziemlich egal. Alles was er will ist dem Objekt zu sagen, dass es sich bewegen, drehen, anzeigen oder verstecken soll. Wie das genau passiert, ist nicht wichtig.
Diese Gemeinsamkeiten in der Benutzung oder Schnittstelle der Objekte fasst man in einer abstrakten Klasse oder Schnittstellen-Klasse zusammen. In C++ gibt es keinen Unterschied zwischen (reinen) Schnittstellen-Klassen (keine Attribute, keine Methoden, nur Operationen) und abstrakten Klassen (mindestens eine Operation ohne Methode, kann Attribute und Methoden enthalten); beiden ist gemein, dass sie mindestens eine abstrakte Operation (d. h. Operation ohne Methode) besitzen. Eine solche Operation wird in C++ dadurch gekennzeichnet, dass ihr
(1) der virtual-Modifizierer vorangestellt und
(2) sie mit dem „Wert“ Null „initialisiert“ wird.
Beispiel:1 /*** Beispiel graphobj1.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // Kapselt ein graphisches Objekt.7 class GraphicalObject8 {9 public :10 // zeigt das Objekt an11 virtual void show () = 0;1213 // versteckt das Objekt14 virtual void hide () = 0;1516 // verschiebt das Objekt um „deltaX“ Pixel nach rechts und um „deltaY“ Pixel nach unten;17 // negative Werte ändern die Richtung der Verschiebung18 virtual void move (int deltaX, int deltaY) = 0;1920 // weitere mögliche Operationen weggelassen21 };
163
Schnittstelle ist gleich
Verhalten ist unterschiedlich
Schnittstellen-Klassen und abstrakte Klassen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
22Es gibt einige Unterschiede zwischen abstrakten Klassen und Schnittstellen in Java und C++:
• In Java müssen abstrakte Klassen mit einem Schlüsselwort (abstract) markiert werden.
• In Java kann eine Klasse abstrakt sein, ohne dass eine einzige Operation darin abstrakt ist. In C++ lässt sich dies nur dadurch nachbilden, dass solch eine Klasse nur protected-Konstruktoren besitzt; dadurch können keine Instanzen dieser Klasse von Klienten erzeugt werden.
• In Java gibt es den Unterschied zwischen Schnittstellen (interface) und abstrakten Klassen (abstract). Dies ist in Java erforderlich, da Java keine Mehrfachvererbung (4.6.5) kennt und eine Java-Klasse nur höchstens von einer (abstrakten oder konkreten) Klasse erben, aber viele Schnittstellen implementieren kann. C++ erlaubt Mehrfachvererbung, so dass in C++ formal kein Unterschied zwischen Schnittstellen-Klassen und abstrakten Klassen existiert.
• In Java müssen alle Operationen einer Schnittstellen-Klasse öffentlich (public) sein; in C++ gibt es diese Beschränkung nicht.
Das resultierende UML-Diagramm sehen Sie in Abbildung 37.
4.6.2 Implementierung von SchnittstellenZu einer solche Schnittstellen-Klasse oder abstrakten Klasse wie im letzten Abschnitt kann man natürlich keine Objekte erzeugen. Es gibt kein konkretes „graphisches Objekt“, nur Punkte, Linien, Kreise, Rechtecke u. s. w. Somit können wir nicht
1 // Kein gültiges C++!2 int main ()3 {4 GraphicalObject object;5 object.show ();6 return 0;7 }
oder1 // Kein gültiges C++!2 int main ()3 {4 GraphicalObject *object = new GraphicalObject;5 object->show ();6 delete object;7 return 0;8 }
164
es gibt keine „abstrakten Objekte“
Abbildung 37: GraphicalObject-Schnittstelle
<<interface>>GraphicalObject
void show()void hide()void move (int deltaX, int deltaY)
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
schreiben. GraphicalObject ist lediglich eine Abstraktion, eine Verallgemeinerung der Konzepte Punkt, Linie, Rechteck und Kreis in Bezug auf die Operationen, die man mit ihnen durchführen kann.
Dennoch ist es nützlich, diese Abstraktion zu verwenden. Bloß müssen die Operationen dieser Schnittstelle implementiert, mit Leben gefüllt werden. Die Schnittstellen-Klasse GraphicalObject kann dies aus den o. g. Gründen nicht leisten, die konkreten Klassen Point (Punkt), Line (Linie), Rectangle (Rechteck) oder Circle (Kreis) schon. Betrachten wir zunächst die Klasse Point:
23 // kapselt einen Punkt in der Ebene24 class Point : public GraphicalObject25 {26 public :27 // konstruiert einen Punkt aus einer X- und einer Y-Koordinate28 Point (int x, int y);2930 // liefert die jeweiligen Koordinaten zurück31 int getX () const;32 int getY () const;3334 // Methoden zu den Operationen aus der Schnittstelle GraphicalObject35 virtual void show ();36 virtual void hide ();37 virtual void move (int deltaX, int deltaY);3839 private :40 int m_x;41 int m_y;42 };43 44 Point::Point (int x, int y) : m_x (x), m_y (y) {}45 int Point::getX () const {return m_x;}46 int Point::getY () const {return m_y;}4748 void Point::show ()49 {50 cout << "Point (" << m_x << ", " << m_y << ") shown." <<
endl;51 }5253 void Point::hide ()54 {55 cout << "Point (" << m_x << ", " << m_y << ") hidden." <<
endl;56 }5758 void Point::move (int deltaX, int deltaY)59 {60 m_x += deltaX;61 m_y += deltaY;62 }63
Neu ist die Konstruktion in Zeile 24. Über die Syntax
: public Klassenname
165
Schnittstelle wird implementiert
Syntax der Spezialisierung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
erfolgt eine Spezialisierung der Schnittstelle oder Klasse Klassenname. Dadurch übernimmt die konkretere Klasse (hier: Point) alle Operationen, Attribute und Methoden der allgemeineren (hier: GraphicalObject). Im Falle von reinen Schnittstellen-Klassen werden natürlich nur (abstrakte) Operationen übernommen.
Konstruktoren und Destruktoren werden nicht vererbt. Sie müssen jeden Konstruktor einer Klasse definieren, ansonsten wird nur der Default-Konstruktor mit leerer Parameterliste (4.5.1) sowie der Kopier-Konstruktor (4.5.3.1) generiert. Gleiches gilt für den Destruktor. Konstruktoren und Destruktoren der Oberklassen werden aber immer implizit oder explizit aufgerufen, s. u.; dies unterscheidet sie von normalen vererbten Methoden. Zur Vererbung von Methoden siehe Abschnitt 4.6.4.
Diese Operationen können dann in der spezielleren Klasse implementiert werden. Im obigen Fall implementiert Point alle abstrakten Operationen der Schnittstelle GraphicalObject; da sie danach überhaupt keine abstrakten Operationen mehr enthält, ist sie eine konkrete Klasse. Damit ist es erlaubt, Point-Objekte zu erzeugen. Die Implementierung der abstrakten Operationen unterscheidet sich nicht von der Definition von „normalen“ Methoden; insbesondere muss das Schlüsselwort virtual, das in der Deklaration noch vorhanden ist, hier weggelassen werden (Zeilen 48, 53, 58).
Implementiert eine speziellere Klasse nicht alle Operationen der allgemeineren, so bleibt sie eine abstrakte Klasse. Der Spezialfall, dass sowohl die allgemeinere wie auch die speziellere Klasse beide nur abstrakte Operationen definieren und somit eine Schnittstellen-Erweiterung vorliegt (4.1.6), ist natürlich auch möglich.
166
partielle Implementierung und Schnittstellen-Erweiterung
Abbildung 38: GraphicalObject-Hierarchie
<<interface>>GraphicalObject
void show ()void hide ()void move (int deltaX, int deltaY)
Point
Point (int x, int y)int getX () constint getY () constvoid show ()void hide ()void move (int deltaX, int deltaY)
int m_xint m_y
<<realize>>
Line
Line (Point startPoint, Point endPoint)Point getStartPoint () constPoint getEndPoint () constvoid show ()void hide ()void move (int deltaX, int deltaY)
<<realize>>
m_startPoint m_endPoint
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Ähnlich wird auch die Klasse Line definiert. Auf die Definition der Klassen Rectangle und Circle verzichten wir aus Platzgründen. Das zugehörige UML-Klassendiagramm sehen Sie in Abbildung 38.
64 // kapselt eine Linie65 class Line : public GraphicalObject66 {67 public :68 // konstruiert eine Linie aus einem Start- und einem Endpunkt69 Line (Point startPoint, Point endPoint);7071 // liefert Startpunkt72 Point getStartPoint () const;73 // liefert Endpunkt74 Point getEndPoint () const;7576 // Methoden zu den Operationen aus der Schnittstelle GraphicalObject77 virtual void show ();78 virtual void hide ();79 virtual void move (int deltaX, int deltaY);8081 private :82 Point m_startPoint; // der Startpunkt83 Point m_endPoint; // der Endpunkt84 };8586 Line::Line (Point startPoint, Point endPoint)87 :88 m_startPoint (startPoint),89 m_endPoint (endPoint)90 {91 }9293 Point Line::getStartPoint () const {return m_startPoint;}94 Point Line::getEndPoint () const {return m_endPoint;}9596 void Line::show ()97 {98 cout << "Showing line:" << endl;99 cout << " "; m_startPoint.show ();
100 cout << " "; m_endPoint.show ();101 cout << "Line shown." << endl;102 }103 void Line::hide ()104 {105 cout << "Hiding line:" << endl;106 cout << " "; m_startPoint.hide ();107 cout << " "; m_endPoint.hide ();108 cout << "Line hidden." << endl;109 }110111 void Line::move (int deltaX, int deltaY)112 {113 m_startPoint.move (deltaX, deltaY);114 m_endPoint.move (deltaX, deltaY);115 }116
Das Hauptprogramm erzeugt einige Objekte und schickt ihnen zum Testen ein paar Nachrichten:
167
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
117 int main ()118 {119 Point p1 (1, 2);120 Point p2 (3, 5);121 Line l (p1, p2);122 l.show ();123124 Point p3 (6, 8);125 p3.show ();126 p3.hide ();127 p3.move (1, 1);128 p3.show ();129130 return 0;131 }
Die Ausgabe des Programms lautet:Showing line: Point (1, 2) shown. Point (3, 5) shown.Line shown.Point (6, 8) shown.Point (6, 8) hidden.Point (7, 9) shown.
4.6.3 PolymorphieIm letzten Abschnitt haben wir mit der Schnittstellen-Klasse GraphicalObject eine Gleichartigkeit in der Benutzung von Punkten und Linien ausgedrückt. Bisher haben wir jedoch diese Gleichartigkeit nicht ausgenutzt: Wenn wir einem Punkt oder einer Linie eine Nachricht schickten, wussten wir (der Übersetzer eingeschlossen), dass es sich um einen Punkt oder eine Linie handelt.
Durch die Schnittstelle GraphicalObject können wir jedoch nun einen Algorithmus niederschreiben, der von der konkreten Klasse eines Objekts nichts wissen muss. Solange alles, was der Algorithmus benötigt, in der Schnittstelle GraphicalObject enthalten ist, muss er keine weiteren Annahmen über die Klasse eines Objektes treffen. Das ist gut, weil der Algorithmus ansonsten in einer Fallunterscheidung (beispielsweise in einer if-Anweisung) das Verhalten für jede konkrete Klasse wählen müsste:
1 if ( Objekt ist vom Typ Point ) {2 // tu etwas3 } else if ( Objekt ist vom Typ Line ) {4 // tu etwas anderes5 } else {6 // ??? was tun???7 }
Dies ist schlecht, da der Algorithmus jede konkrete Klasse kennen muss. Fügen wir in unser Programm beispielsweise die Klasse Ellipse hinzu, muss der obige Abschnitt um eine if-Abfrage erweitert werden. Schlimmer noch: Jeder Algorithmus, der auf solchen Objekten arbeitet, wird eine solche Fallunterscheidung haben (müssen), somit wird diese Änderung an vielen verschiedenen Stellen erfolgen müssen. Die Erweiterbarkeit eines solchen Programms ist somit stark eingeschränkt.
168
Gleichartigkeit bisher nicht ausgenutzt
Unabhängigkeit von der konkreten Klasse ist gut
Nicht-Verwenden von Abstraktionen führt zu nicht wartbarem Code
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Eine andere fragliche Sache ist oben mit drei Fragezeichen markiert (???). Was ist, wenn das Objekt von einer Klasse ist, die der Algorithmus nicht kennt? Dann kann kein sinnvolles Verhalten erfolgen, es liegt also ein Fehler vor. Dies ist natürlich nicht wünschenswert; idealerweise sollte es immer ein definiertes Verhalten geben, wenn ein bestimmtes Objekt von einem solchen Algorithmus bearbeitet wird.
Beide Probleme löst die Bildung von abstrakten Klassen bzw. Schnittstellen-Klassen. Durch die Spezialisierung kann es zu einer Operation mehrere Methoden in der Vererbungshierarchie geben, wobei die jeweilige Methode zur Laufzeit von der tatsächlichen Klasse des Objekts abhängt. Eine Erweiterung des Programms führt einfach zu dem Hinzufügen einer neuen Klasse, die ebenfalls die Schnittstellen-Klasse implementiert. Die Algorithmen müssen sich nicht ändern, das erste Problem ist gelöst.
Weiterhin stellt die Sprache C++ sicher, dass es niemals Objekte zu einer abstrakten Klasse bzw. einer Schnittstellen-Klasse gibt und dass ein Objekt zu jeder Operation in seiner vollständigen Schnittstelle über seine Klasse eine entsprechende Methode besitzt. Dies beseitigt somit das zweite Problem.
In unserem Beispiel wollen wir eine Funktion implementieren, die ein graphisches Objekt verschiebt, jedoch vorher das Objekt versteckt und hinterher wieder anzeigt. Diese Funktion wollen wir safeMove nennen. Da diese Funktion auf allen graphischen Objekten arbeiten soll, hat sie als Parameter eine Referenz auf ein Objekt vom Typ GraphicalObject. Damit wird genau die gewünschte Eigenschaft von safeMove ausgedrückt: die Möglichkeit, beliebige Objekte zu verarbeiten, sofern sie die Schnittstelle GraphicalObject implementieren.
117 // verschiebt ein Objekt um deltaX Pixel nach rechts und deltaY Pixel nach unten118 // negative Werte ändern die Richtung der Verschiebung119 // versteckt das Objekt vorher und zeigt es hinterher wieder an120 void safeMove (GraphicalObject &object, int deltaX, int deltaY)121 {122 object.hide ();123 object.move (deltaX, deltaY);124 object.show ();125 }126
Unsere main-Funktion ruft nun zusätzlich safeMove beispielhaft auf, um zu verdeutlichen, dass diese Funktion sowohl mit Punkten als auch mit Linien umgehen kann:
127 int main ()128 {129 Point p1 (1, 2);130 Point p2 (3, 5);131 Line l (p1, p2);132 l.show ();133134 Point p3 (6, 8);135 p3.show ();136137 safeMove (l, 2, 3); // Aufruf mit einer Linie138 safeMove (p3, 3, 4); // Aufruf mit einem Punkt139140 return 0;
169
Funktion operiert auf Objekten mit GraphicalObject-Schnittstelle
Nicht-Verwenden von Abstraktionen kann zu Fehlern führen
Generalisierung und Spezialisierung sind die Lösung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
141 }
Die Ausgabe ist:Showing line: Point (1, 2) shown. Point (3, 5) shown.Line shown.Point (6, 8) shown.Hiding line: Point (1, 2) hidden. Point (3, 5) hidden.Line hidden.Showing line: Point (3, 5) shown. Point (5, 8) shown.Line shown.Point (6, 8) hidden.Point (9, 12) shown.
Kommen wir zum Namen dieses Abschnitts. Polymorphie kommt aus dem Griechischen und bedeutet Vielgestaltigkeit. Was hat dies nun mit dem obigen Programm zu tun? Nun, wenn Sie sich den Parameter object in Zeile 120 anschauen:
120 void safeMove (GraphicalObject &object, int deltaX, int deltaY)werden Sie feststellen, dass Sie innerhalb der Funktion gar nicht wissen, von welcher Klasse object wirklich ist. Gewiss, Sie wissen, dass es die Schnittstelle GraphicalObject implementiert, aber das sagt nichts über dessen wirkliche Klasse aus. In unserem Beispiel implementieren sowohl Line als auch Point diese Schnittstelle, somit können sich hinter object sowohl Line- als auch Point-Objekte verbergen. Folglich ist object polymorph oder vielgestaltig: Zu verschiedenen Zeitpunkten kann dieser Parameter mal eine Referenz auf ein Point-Objekt und mal eine Referenz auf ein Line-Objekt sein. Und später, wenn das Programm um weitere graphische Objekte erweitert wird, funktioniert der Algorithmus auch mit Ellipsen, Kreisen, Polygonen u. s. w., ohne dass auch nur eine einzige Zeile Code geändert werden muss! Dieser enorme Vorteil begründet den folgenden Merksatz:
Merksatz 23: Verwende Polymorphie anstatt Fallunterscheidungen!
Damit Polymorphie auch funktioniert, müssen Referenzen oder Zeiger verwendet werden. Warum? Weil Sie ansonsten immer ein konkretes Objekt „haben“ und dessen Klasse von vornherein kennen. Über die Referenz bzw. den Zeiger erreichen Sie die nötige Indirektion: Sie betrachten sozusagen das Objekt aus einer gewissen Entfernung. Diese Entfernung ist gerade weit genug, um die Unterschiede zwischen den konkreten Klassen (Line oder Point) verwischen zu lassen; sie ist aber auch nah genug, um mit den Objekten etwas anfangen zu können (Operationen der Schnittstelle GraphicalObject).
Wenn Sie im obigen Fall keine Referenz verwenden:120 void safeMove121 (GraphicalObject object, int deltaX, int deltaY)
170
Polymorphie ist Vielgestaltigkeit
Polymorphie erfordert Verweise
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
wird der Übersetzer einen Fehler melden. Sie versuchen dann nämlich, ein Objekt der Klasse GraphicalObject zu erzeugen. Sie erinnern sich, dass ein Argument bei normaler Wert-Übergabe in den entsprechenden Parameter kopiert wird (4.5.3.1). Dabei ist die Kopie vom Typ des Ziel-Objekts. Das Ziel-Objekt ist hier aber ein Parameter vom Typ GraphicalObject. Da ein Objekt zu dieser Klasse nicht erstellt werden kann, weil sie abstrakt ist, liegt ein Fehler vor.
4.6.4 EinfachvererbungWir haben in Abschnitt 4.6.2 gesehen, dass die Syntax für das Spezialisieren von Schnittstellen
class Unterklasse : public Oberklasse
lautet. Dabei handelt es sich um Einfachvererbung, weil es eine Beziehung zu genau einer Oberklasse ist. Dieser Abschnitt beschränkt sich auf eben diesen Fall; der nächste Abschnitt behandelt den allgemeinen Fall, nämlich dass eine Unterklasse von mehr als einer Oberklasse erbt.
Je nachdem, was die Unterklasse in Bezug auf die Oberklasse „tut“, spricht man von verschiedenen Dingen. Die Möglichkeiten sind:
(1) Erweiterung: Die Unterklasse fügt neue Operationen hinzu.
(2) Spezialisierung ohne Redefinition: Wie (1); zusätzlich fügt die Unterklasse neue Attribute und Methoden hinzu.
(3) Spezialisierung mit Redefinition: Wie (2); zusätzlich redefiniert die Unterklasse Methoden der Oberklasse.
Die obige Syntax kann alle drei Vorgänge ausdrücken. Beispiel:1 // Oberklasse für die folgenden Beispiele2 class GraphicalObject3 {4 public :5 virtual void move (int deltaX, int deltaY) = 0;6 };78 // #1: Erweiterung9 class DisplayableGraphicalObject : public GraphicalObject10 {11 public :12 virtual void show () = 0;13 virtual void hide () = 0;14 };1516 // #2: Spezialisierung ohne Redefinition:17 class Point : public DisplayableGraphicalObject18 {19 public :20 Point (int x, int y);21 virtual void show ();22 virtual void hide ();23 virtual void move (int deltaX, int deltaY);24 private :25 int m_x;26 int m_y;27 };
171
Bedeutung der C++-Spezialisierung
Wiederholung: Spielarten der Spezialisierung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
2829 // #3: Spezialisierung mit Redefinition:30 class DebugPoint : public Point31 {32 public :33 DebugPoint (int x, int y);34 virtual void show ();35 virtual void hide ();36 virtual void move (int deltaX, int deltaY);37 };38
Das entsprechende UML-Klassendiagramm können Sie in Abbildung 39 finden. Wie Sie sehen, unterscheidet UML nicht zwischen einer Schnittstellen-Erweiterung (zwischen DisplayableGraphicalObject und GraphicalObject) und gewöhnlicher Vererbung (zwischen DebugPoint und Point).
Die ersten beiden Verwendungsweisen haben wir bereits ausführlich besprochen sowie deren Einsatz gezeigt. Im Rest dieses Abschnitts wollen wir uns mit der letzten Variante, der Spezialisierung mit Redefinition, befassen.
4.6.4.1 Redefinition einer MethodeWie Sie an dem letzten Beispiel gesehen haben, funktioniert die Redefinition einer Methode ähnlich der Implementierung einer Operation: Die Methode ist sowohl in der Oberklasse (hier: Point) als auch in der Unterklasse (hier: DebugPoint) als virtual definiert.43 Beide Klassen sind konkret, also besitzen beide zu allen Operationen eine entsprechende Methode. Natürlich wird die Methode der Klasse 43) Genauer gesagt ist es nur erforderlich, dass die Definition in der Oberklasse mit dem Schlüssel
wort virtual versehen wird; alle Redefinitionen sind damit automatisch virtual.
172
Redefinition einer Methode
Abbildung 39: Verschiedene Spielarten der Vererbung
<<interface>>GraphicalObject
void move (int deltaX, int deltaY)
<<interface>>DisplayableGraphicalObject
void show ()void hide ()
Point
Point (int x, int y)void show ()void hide ()void move (int deltaX, int deltaY)
int m_xint m_y
<<realize>>
DebugPoint
DebugPoint (int x, int y)void show ()void hide ()void move (int deltaX, int deltaY)
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Point verwendet, wenn ein Point-Objekt vorliegt, und die Methode der Klasse DebugPoint, wenn ein DebugPoint-Objekt vorliegt. Dies funktioniert nach den Prinzipien der Polymorphie (4.6.3) auch für den Zugriff über eine Referenz oder einen Zeiger auf die Oberklasse. Beispiel:
1 int main ()2 {3 Point point;4 DebugPoint debugPoint;56 point.show (); // führt Point::show aus7 debugPoint.show (); // führt DebugPoint::show aus89 Point &pointRef = debugPoint;10 pointRef.show (); // führt DebugPoint::show aus11 // weil pointRef auf ein DebugPoint-Objekt verweist1213 return 0;14 }
Wenn Sie keine Referenz oder keinen Zeiger verwenden, funktioniert die Polymorphie nicht, weil Sie ein neues Objekt der entsprechenden Oberklasse erzeugen. Falls die Oberklasse eine konkrete Klasse ist (wie Point im obigen Beispiel), kann der Übersetzer Ihren Fehler nicht erkennen, und er erzeugt ein entsprechendes, neues Objekt, indem er das Objekt der Unterklasse entsprechend abschneidet. Beispiel:
1 Point point2 = debugPoint;2 point2.show(); // führt Point::show aus! point2 ist ein Point-Objekt!
Hier ist point2 ein reguläres Objekt der Klasse Point, dessen Attribut-Werte durch die entsprechenden Werte des Objekts debugPoint belegt worden sind. Durch die Zuweisung wird hier also eine Kopie des Point-Anteils erzeugt; somit ist verständlich, dass die DebugPoint-Methode show nicht ausgeführt wird.
Dieses Erzeugen eines „abgeschnittenen“ Objekts wird in der Fachliteratur als Slicing44 bezeichnet. Es geschieht immer bei einer Zuweisung oder Initialisierung, wenn das Quell-Objekt zu einer konkreteren und das Ziel-Objekt zu einer generelleren Klasse gehört. In manchen Fällen ist ein solches Abschneiden nicht möglich, etwa wenn der Ziel-Typ der Zuweisung oder Initialisierung ein abstrakter Typ ist. (Dies war z. B. in der Funktion safeMove der Fall, wo der Parameter vom Typ „Referenz auf GraphicalObject“, einer (abstrakten) Schnittstellen-Klasse, war.)
Es stellen sich nun zwei Fragen, die wir im Folgenden untersuchen wollen:
(1) Wann ist eine Redefinition syntaktisch korrekt?
(2) Wann ist eine Redefinition semantisch korrekt?
4.6.4.1.1 Syntaktisch korrekte Redefinitionen
Zum ersten Punkt: Es ist erforderlich, dass die redefinierende Methode genauso aussieht wie die redefinierte. Das bedeutet konkret:
• Der Name muss identisch sein.
• Der Rückgabetyp muss identisch sein (hier gibt es eine mögliche Ausnahme, siehe hierzu den nächsten eingerückten Absatz).
44) engl. to slice = (ab)schneiden
173
Zuweisungen von konkreten Objekten und Slicing
Achten Sie auf Verweise!
Wann ist eine Redefinition syntaktisch korrekt?
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
• Die Parameter müssen in Anzahl, Reihenfolge und Typ übereinstimmen. Die Parameter-Namen können sich jedoch unterscheiden.
• Die Qualifizierung der Methode (const oder nicht const) muss identisch sein.
Ansonsten laufen Sie Gefahr, eine neue Methode zu definieren, ohne die bestehende zu redefinieren! Ein Beispiel soll dies verdeutlichen:
1 class A2 {3 public :4 virtual int square (int i);5 };6 class B : public A7 {8 public :9 virtual long square (long l);
10 };
Hier wird die Methode A::square nicht in der Klasse B redefiniert. Vielmehr wird eine zusätzliche Methode eingeführt, die mit derjenigen in A nur den Namen gemein hat und diese im Kontext von B sogar verdeckt. Insbesondere funktioniert – weil es sich hier nicht um eine Redefinition handelt – Polymorphie nicht korrekt: Wenn Sie ein B-Objekt über einen A-Verweis nutzen und die Operation square in Anspruch nehmen, wird immer die Methode A::square ausgeführt!
Leider wird das obige Beispiel nicht vom Übersetzer zurückgewiesen, weil er in anderen Situationen (in Verbindung mit Überladung (7.1) und sog. using-Deklarationen) durchaus Sinn machen kann. Ein wirklicher Fehler tritt nur dann auf, wenn die redefinierende Methode und die redefinierte Methode identisch sind bis auf den Rückgabetyp. Da C++ zwei Operationen nicht ausschließlich am Rückgabetyp unterscheiden kann (s. Abschnitt 7.1), liegt dann ein Fehler vor. Moderne Übersetzer geben aber in allen anderen (legalen, aber fraglichen) Fällen, in denen eine virtuelle Methode von einer anderen nicht redefiniert, sondern verdeckt wird, eine Warnung aus.
Merksatz 24: Achte bei der Redefinition einer Methode auf Typ-Gleichheit!
Die obige Bemerkung, dass die Rückgabetypen der redefinierenden und der redefinierten Methode identisch sein müssen, ist nicht hundertprozentig korrekt: Der Rückgabetyp der redefinierenden Methode darf auch spezieller sein als derjenige der redefinierten Methode. Man spricht in diesem Fall von Kovarianz bzw. von Methoden mit kovarianten Rückgabetypen. Beispiel:
1 /*** Beispiel kovarianz.cpp ***/2 // Schnittstelle für alle Objekte, die kopiert werden können3 class Cloneable4 {5 public :6 virtual Cloneable *Clone () const = 0;7 };8 // kapselt Zahlen9 class Number : public Cloneable
10 {11 public :12 Number (int i);
174
Probleme bei inkorrekter Redefinition
Kovarianz
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
13 int Value () const;14 virtual Number *Clone () const;15 private :16 int m_i;17 };18 Number::Number (int i) : m_i (i) {}19 int Number::Value () const {return m_i;}20 Number *Number::Clone () const21 {return new Number(*this);}2223 void genericClone(Cloneable &c)24 {25 Cloneable *copy26 = c.Clone (); // Typ des Ausdrucks ist „Cloneable*“27 delete copy;28 }2930 int main ()31 {32 Number n1 (33);33 genericClone (n1);34 Number *n235 = n1.Clone (); // Typ des Ausdrucks n1.Clone() ist „Number*“36 delete n2;37 return 0;38 }
Der Rückgabetyp der Operation Number::Clone() ist kovariant: In der Basisklasse Cloneable ist er Cloneable*, in der abgeleiteten Klasse Number ist er Number*. Das ist nach der oben genannten Regel jedoch in Ordnung, da Number* spezieller ist als Cloneable* (jeder Zeiger auf ein Number-Objekt kann einem Zeiger auf ein Cloneable-Objekt zugewiesen werden, s. Abschnitt 3.4.5). Die Kovarianz macht hier Sinn, weil der Ausdruck in Zeile 35 ansonsten vom Typ Cloneable* wäre und die Initialisierung des Number-Zeigers n2 mit einem Cloneable-Zeiger ohne Cast (3.4.5.2) zu einem Fehler führte. Deshalb verhindert hier der speziellere Rückgabetyp eine (überflüssige) explizite Typ-Umwandlung.
Leider unterstützt VC++ keine kovarianten Rückgabetypen! Hier müssen Sie also auf explizite Typ-Umwandlungen zurückgreifen.
Merksatz 25: Nutze kovariante Rückgabetypen, um Casts zu vermeiden!
4.6.4.1.2 Semantisch korrekte Redefinitionen
Zum zweiten Punkt: Eine Methode ist genau dann eine semantisch korrekte Redefinition einer anderen Methode, wenn das Liskov’sche Substitutionsprinzip (4.1.6) weiterhin volle Gültigkeit hat. Dies bedeutet im Kontext von Methoden:
(1) Eine redefinierende Methode darf nicht mehr verlangen als die redefinierte Methode. Das heißt, dass Vorbedingungen gelockert, aber nicht verschärft werden dürfen. Ein typisches Beispiel für unzulässig verschärfte Vorbedingungen ist die Einschränkung des Wertebereichs eines oder mehrerer Parameter.
(2) Eine redefinierende Methode darf nicht weniger versprechen als die redefinierte Methode. Das heißt, dass Nachbedingungen verschärft, aber nicht gelockert werden dürfen. Ein typisches Beispiel für unzulässig gelockerte Nachbedingungen ist die Erweiterung des Wertebereichs des zurückgegebenen Wertes.
175
Wann ist eine Redefinition semantisch korrekt?
Vorbedingungen dürfen gelockert werden
Nachbedingungen dürfen verschärft werden
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Wieso tragen diese Regeln dazu bei, dass das Substitutionsprinzip gilt? Wir wollen dies an zwei kleinen Beispielen veranschaulichen:
1 // Ausnahme-Klasse für ungültige Argumente (s. Abschnitt 5.2)2 class UngueltigesArgument {};34 // Fakultät-Dienst5 class Fakultaet6 {7 public :8 // Berechnet die Fakultät von i. i muss positiv sein, ansonsten wird eine9 // Ausnahme vom Typ UngueltigesArgument ausgeworfen (siehe Abschnitt 5.2).
10 virtual int berechne (int i);11 };1213 class MeineFakultaet : public Fakultaet14 {15 public :16 virtual int berechne (int i); // Redefinition17 };18
Die Methode Fakultaet::berechne berechnet die Fakultät des Parameters i. Sie hat die Vorbedingung, dass i positiv ist, und die Nachbedingung, dass die Fakultät des Parameters berechnet wird.
Nun schauen wir uns ein paar mögliche Reimplementierungen von berechne an:
(1) Die erste Redefinition erlaubt alle Zahlen (inklusive negativer Zahlen) als Argumente.
(2) Die zweite Redefinition erlaubt nur die Zahlen von 0 bis 10 als Argumente, ansonsten wird eine Ausnahme vom Typ UngueltigesArgument ausgeworfen.
(3) Die dritte Redefinition ist für alle positiven Zahlen genauso definiert wie die ursprüngliche Methode, bei negativen Zahlen hingegen gibt sie Null zurück, anstatt eine UngueltigesArgument-Ausnahme zu erzeugen.
(4) Die vierte Redefinition liefert immer Eins zurück.
Zur ersten Redefinition: Die erste Regel besagt, dass diese Redefinition korrekt ist, weil die Vorbedingungen nicht mehr verlangt als die ursprüngliche Methode. Und in der Tat, es gibt kein Programm, das mit der ursprünglichen Methoden-Definition funktioniert und mit der neuen nicht. Dies sieht man leicht, indem wir die möglichen Argumente überprüfen: Die ursprüngliche Methode war nur für positive Argumente definiert; die neue verhält sich aber in diesem Bereich genauso wie die alte. Das Verhalten hat sich nur bei negativen Argumenten geändert; da diese jedoch von der Vorbedingung der ursprünglichen Methode ausgeschlossen waren, führt die Redefinition zwar neue Funktionalität hinzu, ändert aber nichts an korrekten alten Programmen, die gegen die Spezifikation der alten Methode implementiert wurden. Diese Redefinition hat also die Vorbedingungen gelockert, was in Ordnung ist.
Die zweite Redefinition hingegen ist hochgradig problematisch. Durch die Einschränkung eines Eingabe-Parameters haben wir effektiv die Vorbedingung der Me
176
korrekte und inkorrekte Redefinitionen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
thode verschärft, weil nun wesentlich weniger Werte an die Methode übergeben werden dürfen. Die erste Regel macht uns aber darauf aufmerksam, dass wir Vorbedingungen nicht verschärfen dürfen. Dass diesmal das Substitutionsprinzip wirklich verletzt ist, wird deutlich, wenn wir die neue Methode nutzen, um die Fakultät von 6 zu berechnen: Während die ursprüngliche Methode keine Probleme damit hatte, wirft die Redefinition eine Ausnahme aus. Somit hat sich das Verhalten des Programms durch die Substitution geändert.
Die dritte Redefinition ist korrekt, obwohl das manchen vielleicht erstaunen mag. Ähnlich wie bei der ersten Redefinition gilt hier, dass existierende Vorbedingungen nicht verschärft werden, sondern das Verhalten sich nur für Werte ändert, die vorher tabu waren. Die Vorbedingungen werden gelockert, weil jetzt vorher undefinierte Funktionsergebnisse nun „definierter“ werden.
Die vierte Redefinition ist natürlich inkorrekt. Dies liegt daran, dass die ursprünglichen Nachbedingungen nicht mehr erfüllt werden: Während die originäre Methode zusagt, für positive Zahlen die Fakultät zu berechnen, kann das die Redefinition – außer in den Fällen i = 0 und i = 1 – nicht mehr garantieren. Die Nachbedingungen sind also gelockert worden, was von der zweiten Regel explizit verboten wird.
Wir schauen uns jetzt ein zweites Beispiel an, um zu zeigen, dass verschärfte Nachbedingungen das Substitutionsprinzip nicht verletzen:
1 // Stellt einen instabilen Sortieralgorithmus dar.2 class InstableSorter3 {4 public :5 // sortiert die Elemente in dem Container;6 // stellt nicht sicher, dass gleiche Elemente ihre relative Ordnung beibehalten7 virtual void sort (Container &c);8 };910 // Stellt einen stabilen Sortieralgorithmus dar.11 class StableSorter : public InstableSorter12 {13 public :14 // sortiert die Elemente in dem Container;15 // stellt sicher, dass gleiche Elemente ihre relative Ordnung beibehalten16 virtual void sort (Container &c);17 };
Die Idee der Klasse InstableSorter ist es, einen Sortieralgorithmus zur Verfügung zu stellen. Die Nachbedingung der Methode sort ist klar: Die Elemente des Containers sind nach der Anwendung der Methode sortiert. Die Vorbedingungen sind weniger klar, allerdings muss die Methode zwei Elemente vergleichen können, um eine Sortierung erfolgreich durchzuführen. Deshalb existiert die Vorbedingung, dass eine totale Ordnung auf den Elementen des Containers existiert.
Weiterhin ist der Algorithmus, der von dieser Klasse implementiert wird, instabil: Das bedeutet, dass die relative Reihenfolge gleicher Elemente zueinander bei einer Sortierung nicht beibehalten werden muss (aber kann). Ein Beispiel: Beinhaltet der Container Zeichenketten, und definieren wir, dass die Teil-Zeichenketten „ä“ und „ae“ bei der Sortierung gleich behandelt werden sollen, dann sind beispielsweise die
177
stabile und instabile Sortieralgorithmen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Wörter „Säge“ und „Saege“ gleich. Haben wir nun einen Container, in dem diese Wörter vorkommen (Abbildung 40), so darf ein instabiler Sortieralgorithmus diese Wörter untereinander vertauschen (Abbildung 41), ein stabiler hingegen nicht (Abbildung 42).
Die Klasse StableSorter implementiert nun einen anderen Sortieralgorithmus, der verspricht, dass der Sortiervorgang stabil ist. Ein stabiler Algorithmus garantiert alles, was ein instabiler Sortieralgorithmus auch garantiert, und darüber hinaus noch mehr: Er garantiert, dass gleiche Elemente nicht in ihrer relativen Reihenfolge vertauscht werden. Die Ersetzung eines – möglicherweise – instabilen Sortieralgorithmus durch einen stabilen Sortieralgorithmus ist eine korrekte Redefinition gemäß der zweiten Regel, denn alle Klienten des alten Sortieralgorithmus können mit dem neuen arbeiten, ohne dass sich für sie etwas ändert. Das kommt daher, dass Klienten bisher nicht von einer bestimmten Reihenfolge gleicher Elemente untereinander ausgehen konnten – schließlich war der Algorithmus, den sie benutzten, instabil. Somit gilt das Substitutionsprinzip, und die Welt ist in Ordnung.
Anders sieht es aus, wenn die Klassenhierarchie umgedreht wird: Die Basisklasse stellt in diesem Fall den stabilen und die abgeleitete Klasse den instabilen Algorithmus dar. Nun hat ein Klient bei der Nutzung der ursprünglichen Implementierung bisher davon ausgehen können, dass der Algorithmus die relative Reihenfolge gleicher Elemente beibehält. Wird nun dieser Algorithmus durch einen instabilen reimplementiert, ist dies nicht mehr gewährleistet, und der Klient hat es auf einmal mit einem – aus seiner Sicht – nicht richtig sortierten Container zu tun, was zu allerhand Fehlern führen kann. Hieran sehen Sie, dass das Lockern von Nachbedingungen falsch ist und zu inkorrektem Programmverhalten führen kann.
Leider kann Ihr Übersetzer semantische Verletzungen des Substitutionsprinzips nicht erkennen und Sie folglich nicht darauf aufmerksam machen. Deshalb müssen Sie solche Überlegungen bei jeder Redefinition einer Methode selbst anstellen und sicherstellen, dass das Substitutionsprinzip gewahrt bleibt.
178
Abbildung 40: Ursprüngliche Anordnung der Elemente
„Säge“„Saege“ „Hammer“„Schaufel“ „Hobel“
1 2 3 4 5
Abbildung 42: Anordnung nach stabiler Sortierung
„Säge“„Saege“„Hammer“ „Schaufel“„Hobel“
1 2 3 4 5
Abbildung 41: Mögliche Anordnung nach instabiler Sortierung
„Säge“ „Saege“„Hammer“ „Schaufel“„Hobel“
1 2 3 4 5
semantische Verletzungen werden vom Übersetzer nicht gefunden
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Eine letzte Bemerkung noch: Dieselben Überlegungen gelten auch für (abstrakte) Operationen. Hier existiert bloß kein Code, so dass man eine eventuelle Verletzung des Substitutionsprinzips nicht so einfach demonstrieren kann. Die Überlegungen zu Vor- und Nachbedingungen sind jedoch genauso zu führen wie bei implementierten Operationen.
4.6.4.2 Aufruf der OberklasseRedefinierende Methoden in der Unterklasse können die redefinierten Methoden der Oberklasse aufrufen. Dies ist häufig notwendig, um das erwartete Verhalten der Operation aus der Sicht der Klienten sicherzustellen (4.6.6). Dabei kann eine geeignete Vor- und Nacharbeitung sinnvoll sein. Beispiel:
1 void Point::show ()2 {3 cout << "(" << m_x << ", " << m_y << ")" << endl;4 }5 void DebugPoint::show ()6 {7 cout << "DebugPoint: in Methode show (Anfang)" << endl;8 Point::show ();9 cout << "DebugPoint: in Methode show (Ende)" << endl;10 }
Hier involviert die redefinierende Methode DebugPoint::show die redefinierte Methode in der Klasse Point auf, um die eigentliche Arbeit zu erledigen. Vorher und nachher tut sie jedoch noch andere Dinge; in unserem Fall gibt sie zusätzliche Informationen über die Ausführung der Methode aus.
Die Syntax beim Aufruf einer Methode der Oberklasse ist folglich:
Oberklasse :: Methode ( [ Argumente ] )Es ist nicht zwingend notwendig, in einem solchen Fall die Methode der Oberklasse aufzurufen. Allerdings muss man die Erwartungen seiner Klienten erfüllen (4.6.6), und da man die Funktionalität normalerweise nicht noch einmal implementieren möchte, wird man in vielen Fällen auf die Dienste der vorhandenen Methode in der Oberklasse zurückgreifen.
Sie können mit Hilfe dieser Syntax keine Konstruktoren und Destruktoren der Oberklasse aufrufen. Der Konstruktor der Oberklasse kann nur im Konstruktor der Unterklasse aufgerufen werden (4.6.4.4), der Destruktor der Oberklasse wird immer nur implizit vom Destruktor der Unterklasse aufgerufen (4.6.4.5).
4.6.4.3 Vererbung und PolymorphieSie haben sicherlich gemerkt, dass in diesem Abschnitt alle Methoden-Deklarationen mit dem Schlüsselwort virtual versehen wurden. Das hat seine Berechtigung: Nur so funktionieren Vererbung und Polymorphie reibungslos miteinander. Betrachten Sie dazu das folgende Beispiel:
1 /*** Beispiel virtual.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;
179
Arbeitsteilung zwischen Unterklasse und Oberklasse
Syntax bei Delegation an Oberklasse
die Bedeutung von virtual
Substitutionsprinzip gilt auch für Implementierung von Operationen
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
67 // einfache Ausgabe-Klasse8 class VirtualPrinter9 {
10 public :11 // gibt "message" aus12 virtual void print (const string &message);13 };1415 // einfache Ausgabe-Klasse16 class NonVirtualPrinter17 {18 public :19 // gibt "message" aus20 void print (const string &message);21 };2223 void VirtualPrinter::print (const string &message)24 {25 cout << message << endl;26 }27 void NonVirtualPrinter::print (const string &message)28 {29 cout << message << endl;30 }31
Zuerst haben wir zwei Printer-Klassen definiert, die beide eine Operation print implementieren. Der einzige Unterschied zwischen diesen beiden Klassen ist – abgesehen vom Namen – der virtual-Modifizierer bei der Operation: in VirtualPrinter ist er vorhanden, in NonVirtualPrinter nicht. Ansonsten ist die Implementierung der beiden Operationen identisch.
32 // erweitert VirtualPrinter um eine zusätzliche Ausgabe33 class DebugVirtualPrinter : public VirtualPrinter34 {35 public :36 // gibt "message" aus37 virtual void print (const string &message);38 };3940 // erweitert NonVirtualPrinter um eine zusätzliche Ausgabe41 class DebugNonVirtualPrinter : public NonVirtualPrinter42 {43 public :44 // gibt "message" aus45 void print (const string &message);46 };4748 void DebugVirtualPrinter::print (const string &message)49 {50 cout << "VirtualPrinter::print(" << message << ")" << endl;51 VirtualPrinter::print (message);52 }53 void DebugNonVirtualPrinter::print (const string &message)54 {55 cout << "NonVirtualPrinter::print(" << message << ")" <<
endl;56 NonVirtualPrinter::print (message);57 }58
180
eine Klasse mit und eine ohne virtual-Operationen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Hier haben wir zwei weitere Klassen definiert, die jeweils von den vorherigen beiden erben. Diese Debug-Klassen erweitern die print-Methoden um eine zusätzliche Ausgabe. Diese zusätzliche Ausgabe erlaubt uns zu verstehen, was für eine Rolle der virtual-Modifizierer bei Operationen in Zusammenhang mit Vererbung und Polymorphie spielt.
Schauen wir uns nun an, wie sich Objekte dieser Klassen bei Benutzung verhalten:59 int main ()60 {61 VirtualPrinter vp;62 NonVirtualPrinter nvp;63 DebugVirtualPrinter dvp;64 DebugNonVirtualPrinter dnvp;6566 // Nutzung der Nicht-Debug-Objekte67 // direkter Aufruf68 vp.print ("Hallo Virtual direkt");69 nvp.print ("Hallo NonVirtual direkt");70
Die direkte Benutzung der beiden Printer-Objekte liefert – wie erwartet – das erwartete Ergebnis:
Hallo Virtual direktHallo NonVirtual direkt
Jetzt benutzen wir die Debug-Objekte:71 // Nutzung der Debug-Objekte72 // direkter Aufruf73 dvp.print ("Hallo DebugVirtual direkt");74 dnvp.print ("Hallo DebugNonVirtual direkt");75
Die Benutzung der Debug-Objekte führt zu den erwarteten Meldungen, es erfolgt jedoch vorher eine zusätzliche Ausgabe:
VirtualPrinter::print(Hallo DebugVirtual direkt)Hallo DebugVirtual direktNonVirtualPrinter::print(Hallo DebugNonVirtual direkt)Hallo DebugNonVirtual direkt
Bis jetzt funktioniert alles wie gehabt, und es ist kein Unterschied zwischen der Variante mit und der ohne virtual-Schlüsselwort zu erkennen. Jetzt greifen wir auf die Objekte polymorph, d. h. über einen Verweis (hier: einen Zeiger) zu:
76 // Nutzung der Nicht-Debug-Objekte77 // Aufruf über Zeiger auf die Basisklasse78 VirtualPrinter *pvp = &vp;79 NonVirtualPrinter *pnvp = &nvp;80 pvp->print ("Hallo Virtual über Virtual-Zeiger");81 pnvp->print ("Hallo NonVirtual über NonVirtual-Zeiger");82
Hier wird auf die Printer-Objekte über entsprechende Zeiger zugegriffen. Da der Typ des Zeigers dem tatsächlichen Objekt entspricht, sind die Ergebnisse vorauszusehen: Es werden VirtualPrinter::print und NonVirtualPrinter::print aufgerufen, mit den folgenden Ausgaben als Ergebnis:
181
beide Klassen werden spezialisiert und die Methoden redefiniert
direkte Benutzung ergibt keinen Unterschied
indirekter Zugriff führt zu unterschiedlichen Ergebnissen!
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Hallo Virtual über Virtual-ZeigerHallo NonVirtual über NonVirtual-Zeiger
Jetzt wird es interessant: Wir nutzen Polymorphie und greifen auf ein Objekt einer spezielleren Klasse über ein Zeiger auf eine allgemeinere Klasse zu:
83 // Nutzung der Debug-Objekte84 // Aufruf über Zeiger auf die Basisklasse85 pvp = &dvp;86 pnvp = &dnvp;87 pvp->print ("Hallo DebugVirtual über Virtual-Zeiger");88 pnvp->print ("Hallo DebugNonVirtual über NonVirtual-Zeiger");8990 return 0;91 }
Die Ergebnisse sind:VirtualPrinter::print(Hallo DebugVirtual über Virtual-Zeiger)Hallo DebugVirtual über Virtual-ZeigerHallo DebugNonVirtual über NonVirtual-Zeiger
Und hier kommt der Unterschied zwischen Methoden mit und ohne virtual-Modifizierer zum Tragen. Beim Zugriff auf das Objekt dnvp der Klasse DebugNonVirtualPrinter über den Zeiger pnvp wird beim Senden der Nachricht print diese nicht durch die Methode DebugNonVirtualPrinter::print behandelt, sondern durch die Methode NonVirtualPrinter::print! Das Programm hat also „vergessen“, dass sich hinter dem Zeiger pnvp ein Objekt der Klasse DebugNonVirtualPrinter verbirgt; vielmehr wird das Objekt so behandelt, als wäre es von der Klasse NonVirtualPrinter. Das widerspricht jedoch dem Grundgedanken von Polymorphie, dass über eine gemeinsame (und abstraktere) Schnittstelle (hier: NonVirtualPrinter) Objekte speziellerer Klassen (hier: Objekt dnvp der Klasse DebugNonVirtualPrinter) verwendet werden können und sich diese dementsprechend verhalten.
Wir lernen daraus: Ist eine Klasse die Grundlage für speziellere Klassen, sollten alle Operationen virtual sein, damit Polymorphie funktioniert. Ist eine Klasse nicht dafür vorgesehen, dass sie spezialisiert wird, können Sie Operationen ohne das Schlüsselwort virtual deklarieren.
Es gibt nur sehr wenig Gründe dafür, eine Klasse nicht für weitere Spezialisierungen zu öffnen. In den meisten Fällen erweisen sich solche Gründe als Trugschlüsse, spätestens dann, wenn die Funktionalität dieser Klasse erweitert werden muss (z. B. auf Grund eines Kundenwunsches). Sie sollten also nach Möglichkeit virtual-Operationen verwenden und somit ihre Klassen für Erweiterungen vorbereiten. Im Kapitel über Entwurfsmuster (6) werden Sie einige Muster kennen lernen, in denen auch die Definition und Nutzung von Methoden, die nicht virtual sind, Sinn macht (etwa das Template-Method-Muster (6.3.1)).
Jetzt verstehen Sie sicherlich auch, warum bei Schnittstellen die (abstrakten) Operationen durch virtual und = 0 markiert werden. Durch virtual werden sie zur (mit Polymorphie verträglichen) Redefinition „freigegeben“, mit = 0 wird angezeigt, dass diese Klasse für diese Operationen keine Methode anbietet. Nur = 0 macht keinen Sinn, denn das würde bedeuten, dass die Methode zur Operation nicht existiert und auch durch speziellere Klassen nicht hinzugefügt werden kann.
182
virtual und Polymorphie gehen Hand in Hand
„Diese Klasse wird nie erweitert“ ist problematisch!
= 0 ohne virtual?
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.6.4.4 Konstruktoren in VererbungshierarchienIn Abschnitt 4.6.2 wurde erwähnt, dass Konstruktoren und Destruktoren nicht vererbt werden, sich jedoch gegenseitig explizit oder implizit aufrufen. Genauer formuliert bedeutet das, dass innerhalb einer Vererbungslinie der Konstruktor einer jeden Klasse aufgerufen werden muss, um „seinen“ Teil des Objekts entsprechend zu initialisieren.
Im obigen Beispiel muss also der Konstruktor der Klasse DebugPoint den Konstruktor der Klasse Point aufrufen. Wäre er nicht dazu gezwungen, führte dies zu nicht initialisierten Attributen in der Oberklasse Point. Das ist generell nicht wünschenswert. Konstruktoren von Oberklassen werden genauso wie Attribute in der Initialisierungsliste eines jeden Konstruktors aufgerufen. Wir erweitern die Syntax der Initialisierungsliste also um den Aufruf von Konstruktoren:
:Element1 ( Argument(e) )[, Element2 ( Argument(e) )[, Element3 ( Argument(e) )[, ...]]]
In dieser Notation können Elemente sowohl Klassen-Namen als auch Attribut-Namen sein. Im ersten Fall sind die Argumente die Konstruktor-Argumente für den Konstruktor der Oberklasse, wobei die Argument-Anzahl der Anzahl der Parameter im jeweiligen Konstruktor entspricht. Im zweiten Fall existieren ein oder mehrere Argument, die als Initialisierungsausdrücke für das jeweilige Attribut verwendet werden. (Mehrere Ausdrücke können natürlich nur dann verwendet werden, wenn das Attribut ein Objekt ist, dessen Klasse einen passenden Konstruktor besitzt.)
Wichtig ist festzustellen, dass bei der Initialisierung von Oberklassen nur die Konstruktoren direkter Oberklassen aufgerufen werden dürfen. Konstruktoren indirekter Oberklassen (sofern vorhanden) werden von Konstruktoren ihrer (direkten) Unterklasse aufgerufen. Jeder Konstruktor ist also nur für seine direkte Basisklasse (oder bei Mehrfachvererbung: seine direkten Basisklassen) verantwortlich.
Vielleicht verwirrt Sie, dass eine Klasse anscheinend mehr als eine Oberklasse haben kann. C++ unterstützt die sogenannte Mehrfachvererbung, bei der eine Klasse von mehr als einer Klasse erben kann. Genaueres erfahren Sie in Abschnitt 4.6.5.
Eine mögliche und sinnvolle Implementierung des DebugPoint-Konstruktors ist also beispielsweise:
1 DebugPoint::DebugPoint (int x, int y)2 :3 Point (x, y) // Aufruf des Konstruktors der Oberklasse4 {5 cout << "DebugPoint-Objekt erstellt" << endl;6 }
In Zeile 3 wird der Konstruktor der Oberklasse mit passenden Argumenten aufgerufen.
183
Initialisierungsliste im Konstruktor (erweitert)
Konstruktor der Unterklasse ruft den der Oberklasse auf
jeder Konstruktor ist nur für direkte Oberklassen verantwortlich
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Zur Reihenfolge der Initialisierung: Generell werden zuerst alle Oberklassen initialisiert und dann die Attribute der eigenen Klasse. Dies ist auch logisch, wenn man den Konstruktionsprozess eines Objekts als „von unten nach oben“ oder „vom Abstrakten zum Konkreten“ betrachtet. Zur Reihenfolge der Initialisierung der einzelnen Oberklassen finden Sie in Abschnitt 4.6.5 nähere Informationen.
4.6.4.5 Destruktoren in VererbungshierarchienWie Sie in Abschnitt 4.5.2 erfahren haben, hat jede Klasse einen Destruktor (der durchaus vom Übersetzer automatisch generiert sein kann). Wenn diese Klasse eine Oberklasse hat, ruft der Destruktor nach getaner Arbeit den Destruktor seiner Oberklasse auf. Es wird also immer der Destruktor der speziellsten Klasse als erstes ausgeführt. Dies entspricht der umgekehrten Reihenfolge der Konstruktor-Aufrufe: Dort wird zuerst der Konstruktor der allgemeinsten Klasse ausgeführt. Durch dieses Verhalten wird sichergestellt, dass notwendige Aufräumungsarbeiten auch im Kontext von Vererbung ordnungsgemäß durchgeführt werden.
Aufpassen müssen Sie dennoch: Stellen Sie sicher, dass Ihre Destruktoren immer polymorph verwendet werden können, d. h. machen Sie Ihre Destruktoren virtual. Warum dies wichtig ist, zeigt folgendes Beispiel:
1 /*** Beispiel dtor.cpp ***/2 #include <ostream>3 #include <iostream>4 using namespace std;56 // normale Klasse mit virtuellem Destruktor7 class Base8 {9 public :
10 virtual ~Base ();11 };12 Base::~Base ()13 {14 cout << "in ~Base" << endl;15 }1617 // abgeleitete Klasse, ebenfalls mit virtuellem Destruktor18 class Derived : public Base19 {20 public :21 virtual ~Derived ();22 };23 Derived::~Derived ()24 {25 cout << "in ~Derived" << endl;26 }2728 int main ()29 {30 // Derived-Objekt erzeugen31 Base *base = new Derived;32 // Derived-Objekt über einen Zeiger auf Base zerstören → Polymorphie nutzen!33 delete base;34 return 0;35 }
184
Reihenfolge der Initialisierung
Machen Sie Destruktoren virtual!
Reihenfolge der Zerstörung
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Dieses Programm macht nichts weiter als zwei Klassen zu definieren (Base und Derived), ein Derived-Objekt zu erzeugen und es wieder zu zerstören, allerdings über einen Zeiger auf die Base-Klasse. Das Programm gibt ordnungsgemäß
in ~Derivedin ~Base
aus. (Denken Sie daran, dass der Destruktor der speziellsten Klasse zuerst aufgerufen wird.) Wenn Sie sich die Diskussion in Abschnitt 4.6.4.3 noch einmal vergegenwärtigen, sehen Sie sofort das Problem, falls die Destruktoren nicht virtual sind: Da der Übersetzer in diesem Fall den tatsächlichen Typ des Objekts, das sich hinter dem base-Zeiger verbirgt, „vergisst“, wird nur der Destruktor der Klasse Base aufgerufen! Das bedeutet, dass in diesem Fall das Objekt nicht ordnungsgemäß zerstört wird. Die C++-Sprache sagt in einem solchen Fall, dass ein Fehler vorliegt und dass das Verhalten des Programms in solch einer Situation nicht vorhersehbar ist, sprich ein Programmabbruch oder Schlimmeres zur Folge haben kann. (Deshalb finden Sie das Beispiel mit nicht-virtuellen Destruktoren auch nicht im Skript, damit Ihre Festplatte durch das fehlerhafte Programm nicht aus Versehen formatiert wird...)
Solche Probleme treten natürlich nicht auf, wenn Sie auf die Objekte niemals polymorph, d. h. über einen Zeiger oder eine Referenz auf eine Basisklasse, zugreifen. Doch wird in solchen Fällen üblicherweise von vornherein keine Vererbungsbeziehung zwischen den Klassen existieren, da dies nur im Kontext von Polymorphie Sinn macht. Deshalb sollten Sie einer Schnittstellen-Klasse oder abstrakten Klasse immer einen virtuellen Destruktor spendieren (auch wenn er keine Anweisungen enthält).
Merksatz 26: Verwende virtuelle Destruktoren bei Basisklassen!
4.6.5 MehrfachvererbungMehrfachvererbung (oder Mehrfach-Spezialisierung) erlaubt Ihnen, von mehr als einer Klasse zu erben bzw. mehr als eine Klasse zu spezialisieren. Die Syntax hierfür ist eine Erweiterung derer für das Spezialisieren einer Klasse:
: public Klassenname1, public Klassenname2 [, ...]
Die Mehrfachspezialisierung drückt aus, dass das Liskov’sche Substitutionsprinzip (4.1.6) für alle angegebenen Klassen Gültigkeit hat. Objekte der Klasse SIND bzw. VERHALTEN SICH also WIE Objekte der Klasse Klassenname1 und Objekte der Klasse Klassenname2. Das bedeutet, dass Objekte der Klasse alle Nachrichten verstehen, die auch Objekte der Klasse Klassenname1 und Objekte der Klasse Klassenname2 verstehen.
Betrachten wir ein Beispiel zum besseren Verständnis. Wir wollen eine große objektorientierte Anwendung entwickeln, in denen viele Objekte vorkommen, die unterschiedliche Schnittstellen besitzen und sich unterschiedlich verhalten. Nichtsdestoweniger gibt es Gemeinsamkeiten. Insbesondere haben wir herausgefunden, dass wir viele Objekte haben, die wir an der Oberfläche anzeigen lassen wollen, etwa ein System-Objekt, das Informationen über das Laufzeit-System der Anwendung (Betriebssystem, verwendete Bibliotheken etc.) anzeigt. Weiterhin werden an den unterschied
185
nicht-virtuelle Destruktoren können zu Fehlern führen
Syntax für Mehrfachvererbung
Motivation für Mehrfachspezialisierung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
lichsten Stellen in der Anwendung Objekte kopiert, etwa Objekte, die Personen-Daten (Name, Anschrift, Beruf etc.) kapseln.
Zwei Abstraktionen haben wir also identifiziert; nun wollen wir diese in C++ umsetzen. Wir wollen dafür zwei abstrakte Klassen einführen: eine für darstellbare Objekte (Displayable) und eine für kopierbare („klonbare“) Objekte (Cloneable).
1 /*** Beispiel multi.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 // alle Objekte, die kopierbar sind, implementieren diese Schnittstelle8 class Cloneable9 {
10 public :11 virtual Cloneable *Clone () const = 0;12 };1314 // alle Objekte, die sich anzeigen können, implementieren diese Schnittstelle15 class Displayable16 {17 public :18 virtual void Display () const = 0;19 };20
(Wir haben hier die meisten Kommentare aus didaktischen Gründen weggelassen, weil wir uns auf das Wesentliche konzentrieren wollen. Machen Sie das ja nicht in Ihren eigenen Programmen! Das ist so ein typischer Punkt, an dem Lehrbücher von ihren eigenen Regeln abweichen (müssen)...)
So weit, so gut. Nun gibt es sicherlich Objekte, die darstellbar sind, aber nicht kopiert werden sollen, etwa das oben erwähnte System-Objekt, das sicherlich nur einmal im ganzen System vorhanden sein soll (siehe hierzu auch die Beschreibung des Singleton-Musters in Abschnitt 6.4.2). Andersherum gibt es auch Objekte, die sich kopieren lassen, aber nicht unbedingt eine sinnvolle Repräsentation auf der Oberfläche besitzen. Ein Beispiel hierfür sind Objekte, welche den Zugriff auf andere Objekte kapseln, sog. Proxys (6.2.3). Manchmal macht es Sinn, die Zugriffe auf Objekte nicht direkt zu erlauben, sondern über andere Objekte zu regeln. Dadurch kann z. B. erreicht werden, dass gewisse Programm-Teile nur einen lesenden Zugriff auf ein Objekt gestattet bekommen und andere auch einen schreibenden Zugriff. Unter Umständen müssen solche Proxys (also Zugangskanäle) kopiert werden, um einem anderen Programmteil Zugang zu einem Objekt zu gewähren; allerdings will man solche internen „Hilfs“-Objekte natürlich nicht auf der Programm-Oberfläche darstellen.
Diese ganze Diskussion dient nur dem Zweck, um Ihnen zu verdeutlichen, dass die Abstraktionen Displayable und Cloneable nicht in einer Spezialisierungsbeziehung stehen: Weder ist Displayable spezieller als Cloneable noch umgekehrt, weil es Objekte gibt, die jeweils nur eine Schnittstelle unterstützen können.
Nun hat unsere zu entwickelnde Anwendung aber auch Objekte, die sowohl auf der Oberfläche darzustellen als auch kopierbar sind. Ein typisches Beispiel wäre ein
186
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
fachliches Objekt, das auch Daten kapselt, etwa ein Personen-Objekt. Personen-Daten muss man selbstverständlich anzeigen können, und man wird sicherlich Personen-Objekte kopieren müssen, etwa bevor man dem Anwender eine Änderung am Objekt erlaubt (damit der Anwender immer noch die Möglichkeit hat, den ganzen Vorgang abzubrechen und die Daten so zu belassen, wie sie sind). Unsere Klasse Person versteht also beide Schnittstellen; folglich nutzen wir die Fähigkeit der Mehrfachspezialisierung, um dies in C++ abzubilden:
21 // eine Person kann kopiert und angezeigt werden22 class Person : public Cloneable, public Displayable23 {24 public :25 Person (const string &name);26 virtual Person *Clone () const;27 virtual void Display () const;28 private :29 string m_name;30 };3132 Person::Person (const string &name)33 : m_name (name)34 {35 }36 Person *Person::Clone () const37 {38 cout << "Kopiere Person " << m_name << endl;39 return new Person (*this);40 }41 void Person::Display () const42 {43 cout << "Person mit Namen " << m_name << endl;44 }45
Unsere Klasse Person implementiert nun beide Schnittstellen, sowohl Displayable als auch Cloneable. Nun kann jeder Code, der über eine der beiden Schnittstellen auf ein Objekt zugreift, mit Person-Objekten umgehen:
46 // zeigt ein Displayable-Objekt an47 void Display (const Displayable &d)48 {49 d.Display ();50 }51 // fertigt eine Kopie eines Cloneable-Objekts an52 Cloneable *Clone (const Cloneable &n)53 {54 return n.Clone ();55 }56 int main ()57 {58 // erzeuge ein Person-Objekt59 Person p ("Markus");60 // zeige die Person an61 Display (p);62 // klone die Person (natürlich nur das Objekt!)63 Cloneable *c = Clone (p);64 // lösche Kopie65 delete c;66 return 0;67 }
187
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Die Funktionen in den Zeilen 46-50 und 51-55 greifen jeweils auf die Displayable- bzw. Cloneable-Schnittstelle eines Objekts zu und zeigen es an bzw. fertigen eine Kopie des Objekts an. In den Zeilen 61 und 63 wird an beide Funktionen ein Person-Objekt übergeben, das in Zeile 59 erzeugt wird. Das ist in Ordnung, denn die Klasse Person implementiert sowohl die Schnittstelle Displayable als auch die Schnittstelle Cloneable.
Dies war ein Beispiel für das mehrfache Spezialisieren einer Schnittstelle ohne Redefinition. Wie bei der Einfachvererbung auch gibt es drei Dinge, die man mit der oben gezeigten Syntax ausdrücken kann:
(1) Erweiterung: Die Unterklasse fügt neue Operationen hinzu.
(2) Spezialisierung ohne Redefinition: Wie (1); zusätzlich fügt die Unterklasse neue Attribute und Methoden hinzu.
(3) Spezialisierung mit Redefinition: Wie (2); zusätzlich redefiniert die Unterklasse Methoden einer oder mehrerer Oberklassen.
Wir wollen uns im restlichen Abschnitt besonders mit den letzten beiden Punkten befassen, da im Rahmen der Mehrfachvererbung dort die häufigsten Fehler passieren können. Wir werden uns dabei hauptsächlich auf die Unterschiede zwischen Einfach- und Mehrfachvererbung beschränken, deshalb sollten Sie für das Verständnis dieses Abschnitts den Abschnitt über Einfachvererbung (4.6.4) gelesen haben.
4.6.5.1 Rauten & Co.Durch den Einsatz der Mehrfachvererbung ist es einer Klasse möglich, von mehr als einer Klasse zu erben. Dies haben Sie im letzten Abschnitt gesehen. Wenn diese anderen Klassen selbst aber von derselben Klasse erben (oder dieselbe Klasse spezialisieren), dann kann eine – für die Mehrfachvererbung durchaus typische – Situation auftreten: Eine Klasse existiert zweimal innerhalb derselben Vererbungshierarchie.
Wir wollen uns dies an einem kleinen Beispiel veranschaulichen. Wir entwerfen Schnittstellen für Kollektionen, also für Container von Objekten. Eine Kollektion – ohne dass wir etwas Näheres darüber wissen – soll nur das Hinzufügen von Elementen unterstützen. Außerdem soll man über einen Iterator (6.3.2) die Elemente einer Kollektion sukzessiv ermitteln können. Diese Schnittstelle ist allgemein genug, als dass viele verschiedene, konkrete Kollektionen diese implementieren können.
Nun erweitern wir diese Schnittstelle zweimal. Zum einen wollen wir geordnete Kollektionen über eine geeignete Schnittstelle abbilden (die eine Operation zum Sortieren enthält). Zum anderen möchten wir eine Schnittstelle für Felder45 einführen, da man auf die Elemente eines Feldes bekanntlich wahlfrei zugreifen kann (d. h. ein Klient muss nicht über alle vorherigen Elemente iterieren, um an das gewünschte Element heranzukommen, sondern kann direkt sagen: Ich möchte auf das n-te Element der Kollektion zugreifen). Da es sowohl ungeordnete Felder als auch geordnete
45) Damit sind jetzt nicht C++-Felder gemeint, sondern allgemein jede Datenstruktur mit wahlfreiem Zugriff.
188
Mehrfachvererbung als Oberbegriff
Rauten in Klassenhierarchien
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
„Nicht-Felder“ gibt, sind diese beiden Schnittstellen miteinander nicht weiter verwandt.
Schließlich möchten wir eine konkrete Klasse für geordnete Felder implementieren. Diese Klasse ist folglich sowohl geordnet als auch ein Feld, somit muss sie beide Schnittstellen implementieren. Das Ergebnis dürfen Sie in Abbildung 43 bewundern.
Wie Sie sehen, entsteht in dieser Situation eine sogenannte Raute in der Vererbungshierarchie. Diese Rauten können ziemlich viele Probleme aufwerfen, sowohl für den Entwickler als auch für den Implementierer eines C++-Übersetzers. Deshalb werden sie manchmal auch mit dem Akronym DDD bezeichnet, eine Abkürzung für „Dreaded Diamond of Death“ (übersetzt in etwa „Gefürchteter Diamant des Todes“). Wir können in diesem Skript nicht auf alle Problemstellungen eingehen, werden jedoch die wichtigsten aufzeigen.
4.6.5.2 Virtuelle BasisklassenWenn Sie das oben genannte Beispiel in C++ folgendermaßen umsetzen, werden Sie eine Überraschung erleben:
1 class Collection
189
Abbildung 43: Raute bei Mehrfachvererbung
<<interface>>OrderedCollection
Dienst „Kollektion sortieren“
<<interface>>Array
Dienst „Element zurückgeben“Dienst „Element überschreiben“
<<interface>>Collection
Dienst „Element hinzufügen“Dienst „Iterator zurückgeben“
OrderedArray
Dienst „Element hinzufügen“Dienst „Iterator zurückgeben“Dienst „Kollektion sortieren“Dienst „Element zurückgeben“Dienst „Element überschreiben“
<<realize>> <<realize>>
„Dreaded Diamond of Death“
Tanzt C++ aus der Reihe?
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
2 {3 // ...4 };5 class OrderedCollection : public Collection6 {7 // ...8 };9 class Array : public Collection
10 {11 // ...12 };13 class OrderedArray : public OrderedCollection, public Array14 {15 // ...16 };
Dieser Quelltext (obwohl er korrekt aussieht) führt nicht zum gewünschten Ergebnis. Er führt vielmehr zu einer Klassenhierarchie wie in Abbildung 44.
Was ist passiert? Die Schnittstelle Collection taucht zweimal in der Klassen-Hierarchie auf! Das ist aber ein Problem, denn jeder Verweis auf Collection innerhalb der Klassenhierarchie ist mehrdeutig, z. B.
190
Abbildung 44: Rauten, die keine sind
<<interface>>OrderedCollection
Dienst „Kollektion sortieren“
<<interface>>Array
Dienst „Element zurückgeben“Dienst „Element überschreiben“
<<interface>>Collection
Dienst „Element hinzufügen“Dienst „Iterator zurückgeben“
OrderedArray
Dienst „Element hinzufügen“Dienst „Iterator zurückgeben“Dienst „Kollektion sortieren“Dienst „Element zurückgeben“Dienst „Element überschreiben“
<<realize>> <<realize>>
<<interface>>Collection
Dienst „Element hinzufügen“Dienst „Iterator zurückgeben“
Klassen tauchen mehrfach in der Hierarchie auf
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
• wenn der Übersetzer einen OrderedArray-Verweis in einen Collection-Verweis konvertieren soll (etwa bei der Parameterübergabe an einen Algorithmus, der mit Collection-Objekten arbeitet), so weiß der Übersetzer nicht, welche Collection-Schnittstelle gemeint ist;
• wenn eine explizite Qualifizierung einer Operation in der Collection-Schnitstelle (beispielsweise Collection::add) durchgeführt wird.
Wünschenswert ist in fast46 allen Fällen, dass eine Basisklasse (wie Collection) nur einmal in der gesamten Klassenhierarchie vorkommt. Das ist in C++ möglich, indem beim Erben von dieser Klasse das Schlüsselwort virtual verwendet wird:
1 class Collection2 {3 // ...4 };5 // Achtung: Collection ist virtuelle Basisklasse!6 class OrderedCollection : virtual public Collection7 {8 // ...9 };10 // Achtung: Collection ist virtuelle Basisklasse!11 class Array : virtual public Collection12 {13 // ...14 };15 class OrderedArray : public OrderedCollection, public Array16 {17 // ...18 };
Sie sehen in Zeile 6 und 11, dass das Schlüsselwort virtual verwendet wurde. Dadurch wird erreicht, dass die Klasse Collection nun nur einmal in der Klassenhierarchie vorkommt. Allerdings gibt es einiges zu dieser Konstruktion zu sagen:
• Das Schlüsselwort virtual an dieser Stelle hat nichts, aber auch gar nichts mit der Verwendung zur Kennzeichnung polymorpher Operationen zu tun. Wie schon bereits in Abschnitt 2.3 erwähnt wurde, vermeidet C++ das Einführen neuer Schlüsselwörter, wo es nur geht, leider auch zum Teil auf Kosten der Verständlichkeit. Hier wird virtual lediglich verwendet, um dem Übersetzer mitzuteilen, dass die Basisklasse nur dann in die Klassenhierarchie eingefügt werden soll, wenn sie dort nicht bereits existiert.
• Es ist unwesentlich, ob das Schlüsselwort virtual vor oder nach dem Schlüsselwort public steht.
• Eine Klasse, von der mit Hilfe des Schlüsselworts virtual abgeleitet wird, nennt man in C++ virtuelle Basisklasse.
46) Ja, es gibt wirklich Situationen, in denen das doppelte Auftauchen derselben Klasse in der Hierarchie Sinn machen kann, allerdings ist der Entwurf eine solchen Klassenhierarchie nicht unbedingt gut und es gibt immer Mittel und Wege, dasselbe Ziel ohne solche Konstruktionen zu erreichen, was zu übersichtlicheren und verständlicheren Entwürfen führt.
191
mehrfache Existenz einer Klasse fast immer unerwünscht
Regeln in Bezug auf den virtual-Modifizierer bei Vererbung
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
• Das Schlüsselwort muss bei jeder Spezialisierung der Klasse Collection verwendet werden! Für jede Ableitung, bei der Sie das Schlüsselwort virtual nicht verwenden, wird eine zusätzliche Instanz der Klasse in die Vererbungshierarchie einfügt. Sie sollten sich also merken: Einmal virtuelle Basisklasse, immer virtuelle Basisklasse. Diese Erkenntnis ist in einem Merksatz am Ende des Abschnitts zusammengefasst.
• Virtuelle Basisklassen haben gewissermaßen einen Sonderstatus in C++, weil man mit ihnen nicht alles machen kann, was mit „normalen“ Basisklassen möglich ist. Es ist beispielsweise nicht möglich, einen Zeiger auf ein Objekt einer virtuellen Basisklasse in einen Zeiger auf ein Objekt einer abgeleiteten Klasse zu konvertieren.47 Außerdem haben virtuelle Basisklassen einen erhöhten Laufzeit-Aufwand zur Folge, so dass das intensive Benutzen virtueller Basisklassen sich unter Umständen negativ auf die Performanz des Programms auswirken kann. Deshalb sollten Sie virtuelle Basisklassen wirklich nur dann nutzen, wenn es sein muss (etwa im obigen Fall).
Merksatz 27: Vermeide den Einsatz virtueller Basisklassen!
Merksatz 28: Einmal virtuelle Basisklasse, immer virtuelle Basisklasse!
4.6.5.3 Redefinition einer Methode und DominanzDie Redefinition einer Methode funktioniert bei der Mehrfachvererbung im Prinzip genauso wie bei Einfachvererbung. Allerdings muss jetzt nicht nur gesichert sein, dass es für jede Operation mindestens eine, sondern auch höchstens eine Methode gibt. Abbildung 45 veranschaulicht dies:
47) Es ist nicht schlimm, wenn Sie dies nicht nachvollziehen können – virtuelle Basisklassen sind nun mal ein etwas obskures Gebiet in C++, in das man sich nur hineinwagen sollte, wenn man unbedingt muss.
192
die Frage nach der Dominanz
Abbildung 45: Beispiel für Dominanz von Methoden
List
void add (Element e)Element getFirst ()Element getNext (Element e)
Array
void add (Element e)Element get (int index)void set (int index, Element e)
<<interface>>Collection
void add (Element e)
ArrayList
<<realize>> <<realize>>
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Wir haben eine Schnittstelle Collection und zwei Klassen List und Array, die jeweils eine verkettete Liste und ein Feld implementieren. Nun dachte sich ein Entwickler, Mehrfachvererbung wäre doch eine tolle Sache, um eine Klasse zu bekommen, deren Objekte sich wie Felder und wie Listen verhalten, so dass man über beide Schnittstellen darauf zugreifen kann. Dieser Klasse, ArrayList genannt, hat der Entwickler keine weiteren Methoden spendiert, weil diese bereits alle schon „da“ sind, und zwar über die Basisklassen Array und List.
Nun entsteht aber offensichtlich ein Problem. Gehen wir davon aus, wir haben ein Objekt der Klasse ArrayList und schicken ihm die Nachricht add (geeignet parametrisiert mit einem Element-Objekt):
1 ArrayList arrayList;2 Element e;3 arrayList.add (e); // <-- Problem!
Die Preisfrage ist: Welche Methode wird in Zeile 3 ausgeführt? Es kann die Methode der Klasse Array sein, oder die der Klasse List. Keine davon ist besser oder schlechter geeignet als die jeweils andere. Keine der Methoden ist dominant, es liegt also eine Mehrdeutigkeit vor, die der Übersetzer dann auch mit einer entsprechenden Fehlermeldung quittiert.
In einem solchen Fall muss der Entwickler dem Übersetzer „das Denken abnehmen“ und in der Klasse ArrayList die Methode add explizit redefinieren. In deren Implementierung wird der Entwickler sich dann für den Aufruf der einen oder anderen geerbten Methode (oder vielleicht für den Aufruf beider Methoden?) entscheiden. Diese Entscheidung kann ihm der Übersetzer aber nicht abnehmen, da es keine eindeutige Lösung gibt.
4.6.5.4 Konstruktoren und Destruktoren bei MehrfachvererbungFür Konstruktoren und Destruktoren gilt das bereits in 4.6.4.4 und 4.6.4.5 Gesagte, allerdings gibt es im Falle von Mehrfachvererbung mehr als eine Basisklasse, so dass unter Umständen mehr als ein Konstruktor explizit aufgerufen werden muss. Beispiel:
1 // expliziter Default-Konstruktor der Klasse Array-List2 ArrayList::ArrayList ()3 :4 List (), // ruft explizit den Default-Konstruktor der Basisklasse List auf5 Array () // ruft explizit den Default-Konstruktor der Basisklasse Array auf6 {7 }
Das Beispiel verdeutlicht, dass die Syntax für den Aufruf von Konstruktoren dieselbe ist wie bei der Einfachvererbung. Es ist – ebenfalls wie bei der Einfachvererbung – zu beachten, dass nur Konstruktoren direkter Basisklassen aufgerufen werden dürfen. Allerdings gibt es bei virtuellen Basisklassen eine Ausnahme, die weiter unten erörtert wird.
193
Welche Methode soll benutzt werden?
explizite Redefinition manchmal notwendig
Aufruf mehrerer Konstruktoren im Konstruktor einer Unterklasse
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Bei der Mehrfachvererbung gibt es eine ähnliche Gefahr bei der Initialisierung von Basisklassen wie im Kontext der Einfachvererbung bei der Initialisierung von Attributen: Die Konstruktoren werden nicht in der Reihenfolge der Aufrufe im Konstruktor der Basisklasse aufgerufen, sondern in der Reihenfolge der public-Konstrukte im Klassenkopf. Beispiel:
1 // Achtung: erst Array, dann List im Klassenkopf2 class ArrayList : public Array, public List3 {4 public :5 ArrayList ();6 virtual void add (Element e); // nötig wegen Dominanz-Regel7 };89 ArrayList::ArrayList ()
10 :11 List (), // Achtung: erst Aufruf des List-Konstruktors,12 Array () // dann Aufruf des Array-Konstruktors!13 {14 }
In Zeile 11 und 12 werden die Konstruktoren der Basisklassen aufgerufen, allerdings in einer anderen Reihenfolge als die Klassen in Zeile 2 im Klassenkopf vermerkt sind. Die Reihenfolge in Zeile 2 ist jedoch die wesentliche, somit wird im ArrayList-Konstruktor in Wirklichkeit erst der Aufruf in Zeile 12 und dann der Aufruf in Zeile 11 durchgeführt.
Bei virtuellen Basisklassen ist äußerste Vorsicht geboten: In C++ werden virtuelle Basisklassen immer vor allen nicht-virtuellen Basisklassen initialisiert und nur von der speziellsten Klasse aufgerufen. Beispiel:
1 // Container-Abstraktion2 // Operationen aus Gründen der Übersichtlichkeit weggelassen3 class Collection4 {5 public :6 Collection (int size);7 private :8 int m_size;9 };
10 Collection::Collection (int size)11 :12 m_size (size)13 {14 }1516 // Feld-Abstraktion17 // Achtung: Collection ist virtuelle Basisklasse!18 class Array : virtual public Collection19 {20 public :21 Array (int size);22 };23 Array::Array (int size)24 :25 Collection (size)26 {27 }2829 // Listen-Abstraktion30 // Achtung: Collection ist virtuelle Basisklasse!31 class List : virtual public Collection
194
Reihenfolge der Initialisierung von Basisklassen
virtuelle Basisklassen und Konstruktoren
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
32 {33 public :34 List (int size);35 };36 List::List (int size)37 :38 Collection (size)39 {40 }4142 // kapselt Objekte, die sowohl Felder als auch Listen sind43 // Achtung: Collection ist (indirekt) virtuelle Basisklasse!44 class ArrayList 45 : virtual public Array, virtual public List46 {47 public :48 ArrayList (int size);49 };50 ArrayList::ArrayList (int size)51 :52 // Achtung: drei verschiedene Größen (siehe Text)!!!53 // Achtung: unpassende Reihenfolge (siehe Text)54 Array (size + 2),55 List (size + 3),56 Collection (size + 1)57 {58 }
Dieses etwas konstruierte Beispiel soll das Besondere bei der Initialisierung virtueller Basisklassen demonstrieren. In diesem Beispiel wird der Konstruktor der Klasse Collection an drei verschiedenen Stellen, nämlich in den Zeilen 25, 38 und 56, aufgerufen. Wenn ein Objekt der Klasse Array erzeugt wird, wird der Aufruf in Zeile 25 benutzt, bei List-Objekten derjenige in Zeile 38. Dies bereitet keine Probleme. Bei ArrayList-Objekten ist die ganze Geschichte jedoch nicht mehr so einfach. Folgende Beobachtungen drängt der obige Quelltext auf:
• Fehlte der Aufruf in Zeile 56, wüsste der Übersetzer nicht, wie er die Collection-Basisklasse für ein ArrayList-Objekt initialisieren sollte. Ganz ähnlich wie in Abschnitt 4.6.5.3 fehlt hier ein dominanter Konstruktor-Aufruf48. Deshalb ist die Initialisierung in Zeile 56 notwendig.
• Jetzt wird der Übersetzer mit drei Initialisierungen konfrontiert, von denen die in Zeile 56 dominant ist. Deshalb werden alle anderen Initialisierungen derselben virtuellen Basisklasse (also jene in den Zeilen 25 und 38) ignoriert. Insbesondere werden die Argumente, die in den Zeilen 54 und 55 an den Collection-Konstruktor durchgeschleift werden sollen, überhaupt nicht benutzt!
• Virtuelle Basisklassen werden immer zuerst initialisiert. Deshalb findet der Konstruktor-Aufruf in Zeile 56 vor denen in den Zeilen 54 und 55 statt. Man sollte folglich Aufrufe von Konstruktoren virtueller Basisklassen immer an den Anfang der Initialisierungsliste stellen.
Alles in allem ist die Benutzung konkreter (d. h. nicht abstrakter) virtueller Basisklassen (insbesondere jener mit Konstruktoren und Zuweisungsoperatoren) derart problematisch, dass generell davon abgeraten wird.49 Die vorherrschende Meinung unter C++-Entwicklern ist, dass virtuelle Basisklassen reine Schnittstellen-Klassen sein sollten
48) Bei virtuellen Basisklassen von Dominanz zu sprechen ist im C++-Jargon nicht unbedingt üblich, der Begriff drückt jedoch gut die Problematik bei virtuellen Basisklassen aus.
49) Es kann passieren, dass Zuweisungsoperatoren für virtuelle Basisklassen mehrfach ausgeführt werden!
195
konkrete virtuelle Basisklassen sind hochgradig problematisch
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
(also ohne Methoden und Konstruktoren). (Virtuelle) Destruktoren stellen jedoch kein Problem dar, da diese nicht zu Mehrdeutigkeiten führen und auch (in der Regel) vom Übersetzer und nicht vom Benutzer aufgerufen werden.
Merksatz 29: Vermeide konkrete virtuelle Basisklassen!
Die Regeln zu Destruktoren unterscheiden sich nicht von denen bei der Einfachvererbung. Insbesondere werden diese in umgekehrter Reihenfolge der Konstruktor-Aufrufe bei der Initialisierung ausgeführt. Im Falle von Mehrfachvererbung und virtuellen Basisklassen (4.6.5.2) bedeutet dies, dass Destruktoren virtueller Basisklassen immer zuletzt aufgerufen werden
4.6.6 Korrekte Anwendung von VererbungIn diesem Abschnitt befassen wir uns näher mit dem Vererbungs-Mechanismus, den wir in den letzten Abschnitten kennen gelernt haben. Wir werden sehen, dass die Begriffe Spezialisierung und Vererbung in der Informatik nicht immer mit den „natürlichen“ übereinstimmen.
4.6.6.1 Beispiel 1: Rechteck und QuadratBetrachten wir einmal folgendes Beispiel aus der Mathematik. Wir wissen alle, was ein Quadrat und was ein Rechteck ist. Betrachten wir einmal die Beziehung zwischen Quadrat und Rechteck. Wir können sagen, dass ein Quadrat ein spezielles Rechteck ist, nämlich eines, bei dem alle Seiten gleich lang sind. Die Klasse aller Quadrate ist also eine Teilmenge der Klasse aller Rechtecke, charakterisiert über diese spezielle Eigenschaft der Gleichheit aller Seiten. Diese Spezialisierung können wir – wie gewohnt – in UML ausdrücken (Abbildung 46, links).
Bis jetzt ist „die Welt in Ordnung“. Nun machen wir uns Gedanken über sinnvolle Operationen auf Objekten dieser beiden Klassen. Gewiss machen bei Rechtecken Operationen zur Abfrage der beiden Seitenlängen Sinn, ebenso die Abfrage der (einen) Seitenlänge bei einem Quadrat (Abbildung 46, Mitte).
Wir wollen jedoch unsere Objekte auch verändern können. Denken Sie zurück an unseren graphischen Editor – bei Rechtecken wollen wir auch in der Lage sein, die Seitenlängen zu verändern, um z. B. dem Befehl des Anwenders, ein Rechteck zu strecken, nachzukommen. Also fügen wir die entsprechenden Operationen zum Setzen der Höhe und Breite eines Rechtecks hinzu (Abbildung 46, rechts).
Und jetzt fangen unsere Probleme an! Gehen wir davon aus, dass wir zum Strecken eines Rechtecks die folgende Funktion definiert haben:
1 /*2 * Streckt das übergebene Rechteck in seiner Breite um den3 * angegebenen Faktor.4 */5 void stretchHorizontally (Rectangle &rect, double factor)6 {7 rect.setWidth (rect.getWidth () * factor);8 }
196
Wann ist der Einsatz von Vererbung angebracht?
Wann sind Quadrate wirklich Rechtecke?
Strecken von Rechtecken ist tödlich für die Spezialisierung!
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Diese Funktion kann beispielsweise folgendermaßen definiert werden (die Existenz eines geeigneten Rectangle-Konstruktors natürlich vorausgesetzt):
9 int main ()10 {11 // Rechteck erzeugen12 Rectangle rect (/*Breite*/ 10,/*Höhe*/ 5);13 // Rechteck strecken14 stretchHorizontally (rect, 2);15 // rect.getWidth() == 20, rect.getHeight() == 5
So weit, so gut. Was passiert aber im folgenden Code-Fragment?16 // Quadrat erzeugen17 Square square (/*Breite und Höhe*/ 10);18 // Quadrat strecken?!19 stretchHorizontally (square, 2);20 // square.getWidth() == 20, square.getHeight() == 10!21 return 0;22 }
Nach dem Strecken ist unser Quadrat gar kein Quadrat mehr! Oder vielleicht doch? Zuerst müssen wir klären, ob das obige Programm überhaupt korrekt ist. Nach unserem Verständnis von Spezialisierung können wir etwas konkreteres oder spezielleres immer dann benutzen, wenn etwas abstrakteres oder allgemeineres erforderlich ist. Ein Quadrat ist ein spezielles Rechteck (oder: ein Quadrat IST EIN Rechteck, um auf die IST-EIN-Beziehung zurückzukommen), also ist das Übergeben eines Quadrats an die Funktion stretchHorizontally erst einmal korrekt.
Was aber seltsam anmutet ist die Verwendung der Operation setWidth auf Quadraten. Bei Rechtecken ist es verständlich, dass ein Klient die Breite eines Rechtecks verändern kann. Bei einem Quadrat hingegen ist das problematisch: Wenn jemand die Breite eines Quadrats verändert, muss die Höhe ebenfalls mitgeändert werden, denn ansonsten erhalten wir ein Quadrat, das keines ist!
Im obigen Fall wurde die Breite unabhängig von der Höhe verändert, was zu dem unerwünschten Ergebnis square.getWidth() != square.getHeight()
197
Abbildung 46: Spezialisierung zwischen Quadrat und Rechteck
Square
Square
Square
Rectangle
int widthint height
Rectangle
getWidth(): intgetHeight(): int
int widthint height
Rectangle
getWidth(): intgetHeight(): intsetWidth(int width)setHeight(int height)
int widthint height
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
führte. Ändern wir die Semantik von setWidth auf Square-Objekten dementsprechend ab, dass eine setWidth-Nachricht zu einer äquivalenten setHeight-Nachricht auf demselben Objekt führt (und umgekehrt), sind wir aber kein Stück besser dran! Warum, das zeigt der nächste Code-Abschnitt:
1 void checkedSetWidth (Rectangle &rect, int newWidth)2 {3 int oldHeight = rect.getHeight ();4 rect.setWidth (newWidth);5 if (oldHeight != rect.getHeight ())6 error ();7 }
Hier ist eine Funktion checkedSetWidth definiert, die auf der Tatsache aufbaut, dass die Änderung der Breite eines Rechtecks keine Auswirkungen auf die Höhe haben darf – wenn doch, wird eine Fehlerbehandlungs-Funktion (error) aufgerufen. Nun ist diese Funktion vielleicht nicht die scharfsinnigste oder hilfreichste, aber sie ist völlig korrekt – sie darf diese Annahme machen, solange der Kommentar der setWidth-Operation nichts anderes sagt! Und das sollte auch nicht der Fall sein; schließlich würde es den armen Programmierer (und das sind Sie!) nur verwirren, wenn er wüsste, dass setWidth manchmal zu setHeight führt und umgekehrt (bei Quadraten) und manchmal nicht (bei Rechtecken). Wie die Namen der Operationen schon ausdrücken, sollte setWidth nur die Breite verändern und setHeight nur die Höhe. Punkt.
Die obige checkedSetWidth-Funktion wird also bei Quadraten nicht korrekt funktionieren (bzw. die error-Funktion aufrufen), wenn eine Änderung der Breite zu einer Änderung der Höhe führt; und ein Aufruf der setWidth-Operation wird bei Quadraten zu seltsamen Zuständen führen, wenn er nicht von einem entsprechenden setHeight-Aufruf gefolgt wird. Egal wie wir es drehen und wenden, es klappt einfach nicht: Irgend etwas wird nicht korrekt funktionieren.
Was schließen wir daraus? Ein Quadrat mag in der Mathematik durchaus ein spezielles Rechteck sein, in der Informatik hapert diese Klassifizierung jedoch, sobald Verhalten zu den Klassen hinzukommt (in unserem Beispiel setWidth und setHeight). Wir lernen: Es kommt darauf an, die richtige Frage zu stellen. Nicht: IST ein Quadrat EIN Rechteck? Sondern: VERHÄLT sich ein Quadrat WIE ein Rechteck? Die letzte Frage müssen wir mit einem klaren „Nein!“ beantworten, denn mit Rechtecken können wir wesentlich mehr nützliche Dinge tun, die es als Rechteck belassen, während ein Quadrat dabei nicht immer ein Quadrat bleibt (etwa Strecken und Stauchen). Es ist also Verhalten, das entscheidet, ob eine Klasse spezieller ist als eine andere und ob der Einsatz von Spezialisierung (oder Vererbung) in einem konkreten Fall Sinn macht.
4.6.6.2 Beispiel 2: Elemente und Listen derselbenEin weiteres Beispiel können wir unseren graphischen Objekten aus diesem Kapitel entnehmen. Wir haben die Objekte so modelliert, so dass gilt: ein Punkt IST EIN graphisches Objekt (ebenso bei Linien, Kreisen etc.) Wir haben die Spezialisierung zwischen Point und GraphicalObject dadurch begründet, dass sich jeder
198
Verhalten ist das Wesentliche
Listen und spezielle Listen
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
Punkt wie ein graphisches Objekt verhält (wenn man die Schnittstelle GraphicalObject betrachtet). Die Spezialisierung ist korrekt und funktioniert fabelhaft.
Jetzt stellen wir uns aber vor, dass wir graphische Objekte in einer Liste aufbewahren wollen. Nennen wir diese Klasse GraphicalObjectList; Objekte zu dieser Klasse repräsentieren Ansammlungen von GraphicalObject-Objekten. Solch eine Liste kann also beliebige graphische Objekte enthalten, z. B. einen Kreis, zwei Punkte und vier Linien. Oft ist es wünschenswert, dass man aber festlegen kann, dass sich in einer Liste z. B. nur Punkte oder nur Linien befinden. Zu diesem Zweck entwerfen wir entsprechende Klassen, die wir PointList (Liste aus Punkten), LineList (Liste aus Linien) u. s. w. nennen.
So weit, so gut. Nun kommt der entscheidende Schritt. Wir sagen: „Ein Point-Objekt IST EIN GraphicalObject-Objekt“. Gilt denn nun auch: „Ein PointList-Objekt IST EIN GraphicalObjectList-Objekt“ (Abbildung 47)?
Einige Anmerkungen zum Diagramm: Zwischen PointList und GraphicalObjectList herrscht eine Vererbungsbeziehung, da die Klasse GraphicalObjectList (im Gegensatz zu GraphicalObject) eine konkrete Klasse ist, zu der auch Objekte existieren können – nämlich jene Listen, die irgendwelche GraphicalObject-Objekte enthalten. Der Name der Beziehung zwischen PointList und Point wird mit einem Schrägstrich eingeleitet, was darauf hinweist, dass diese Beziehung abgeleitet ist: Es ist nämlich dieselbe Beziehung wie die zwischen GraphicalObjectList und GraphicalObject, bloß aus der Sicht von PointList. Das ist auch logisch: Wenn meine Oberklasse bereits angibt, dass eine Verbindung zu anderen Objekten existiert, dann existiert diese Verbindung bei Objekten zu einer Unterklasse erst recht. Schließlich werden Assoziationen genauso mitvererbt wie Attribute.
Zurück zum eigentlichen Problem: Ist die angegebene Vererbung korrekt in dem Sinne, dass jedem Klienten, der ein Objekt der Klasse GraphicalObjectList erwartet, ein Objekt der Klasse PointList „untergeschoben“ werden kann und das
199
Abbildung 47: Spezialisierung zwischen PointList und GraphicalObjectList
<<interface>>GraphicalObject
show ()hide ()move ()
Point
show ()hide ()move ()
<<re
aliz
e>>
GraphicalObjectList
void add (GraphicalObject object)GraphicalObject get (index)void remove (index)bool isEmpty ()
PointList
void add (Point object)Point get (index)
element
* ◄ contains
/ element
* ◄ contains
abgeleitete Beziehungen in der UML
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Programm weiterhin funktioniert? Denken Sie ruhig ein paar Minuten nach, bevor Sie weiterlesen...
Die Antwort ist: Nein, Sie bekommen mit dem obigen Modell Probleme beim Hinzufügen von Elementen. Nehmen wir einmal an, wir haben folgende Funktion:
1 void addToList (GraphicalObjectList &list, GraphicalObject &elem)2 {3 list.add (elem);4 }
Eine kleine und harmlose (?) Funktion. Jetzt können Sie, konform zu Ihrem Modell, Folgendes tun: Sie können dieser Funktion ein PointList-Objekt und ein Line-Objekt übergeben:
5 int main ()6 {7 PointList pointList;8 Line line (Point (1, 2), Point (3, 4));9 addToList (pointList, line); // !!!!!!!!!!
10 return 0;11 }
Überlegen Sie einmal genau, was in Zeile 9 passiert! Sie fügen eine Linie in eine Liste mit Punkten ein. Das ist aber etwas, was Sie eigentlich verbieten wollten. Nur eine GraphicalObjectList-Liste kann beliebige graphische Objekte verwalten; eine PointList-Liste soll (natürlich) nur Punkte aufnehmen können.
Die Moral von der Geschichte: Diese Vererbungsbeziehung ist inkorrekt. Ein PointList-Objekt verhält sich nicht wie ein GraphicalObjectList-Objekt, weil es strengere Vorbedingungen hat: Während die Argumente von GraphicalObjectList::add alle möglichen GraphicalObject-Objekte sein können, dürfen es bei PointList::add nur Point-Objekte sein. Das aber bedeutet, dass ich nicht jedem Klienten, der ein GraphicalObjectList-Objekt erwartet, ein PointList-Objekt übergeben kann, weil dann diese strengere Bedingung eventuell nicht eingehalten wird. Die Stärken der Polymorphie sind ausgehebelt, und Chaos kann sich ausbreiten (in unserem Beispiel eine PointList-Liste mit Linien darin).
Dass die Spezialisierung zwischen PointList und GraphicalObjectList nicht korrekt ist, können Sie noch an zwei weiteren Dingen erkennen. Zum einen ist die Methode add der Klasse PointList keine Redefinition der Methode add in der Klasse GraphicalObjectList, weil der Parametertyp ein anderer ist (siehe hierzu Abschnitt 4.6.4.1). Zum anderen hat die Implementierung der Methode PointList::get ein Problem: Sie wird – um das Objekt aus der Liste zu holen – vermutlich GraphicalObjectList::get in Anspruch nehmen. Diese Operation liefert jedoch einen Zeiger auf ein GraphicalObject-Objekt zurück. Da die Methode PointList::get jedoch einen Zeiger auf ein Point-Objekt zurückliefern muss, wird ihr nichts anderes übrig bleiben, als den Zeiger in den erwarteten Typ explizit umzuwandeln (3.4.5.2). Explizite Typ-Umwandlungen sind jedoch zu vermeiden; hier sind sie Ausdruck eines schlechten Entwurfs.
200
spezielle Listen sind problematisch
weitere Anzeichen inkorrekter Spezialisierung
Objektorientiertes C++ für Einsteiger Die Welt der Objekte
4.6.6.3 ZusammenfassungDie Quintessenz der beiden Beispiele sollten Sie unbedingt verinnerlichen. Sie besagt nämlich, dass die Strukturierung von Systemen über das Verhalten geregelt werden sollte und nicht über gemeinsame Daten (Attribute). Sie besagt auch, dass in den meisten Fällen Spezialisierung (und noch schlimmer Vererbung) fehlerhaft angewendet wird, um z. B.
• (Spezialisierung) IST-EIN-Beziehungen in der realen Welt abzubilden,
• (Vererbung) gemeinsame Attribute in einer Oberklasse zusammenzufassen,
• (Vererbung) gemeinsame Methoden in einer Oberklasse zusammenzufassen,
ohne das Verhalten der Objekte zu berücksichtigen. Sie besagt schließlich, dass Sinn oder Unsinn von Spezialisierung und Vererbung kontextabhängig ist. Die obigen Beispiele sind korrekt, solange Sie keine setWidth-/setHeight- oder Strecken-/Stauchen-Operationen in Rectangle bzw. keine add-Operation in GraphicalObjectList einbauen.
Alles in allem: Halten Sie die Augen offen, wenn Sie Spezialisierung und Vererbung einsetzen, und machen Sie sich stets klar, dass die Objekte nicht nur existieren, sondern auch leben, und als solche „Lebewesen“ auch ein Verhalten an den Tag legen. Und dieses Verhalten muss zu jedem Typ, jeder Schnittstelle passen, durch diese sie ein solches Objekt gerade betrachten. Um zum Schluss auf das erste Beispiel zurückzukommen: Ein Quadrat durch die Rechteck-„Brille“ zu betrachten kann manchmal ganz schön problematisch sein...
Merksatz 30: Korrektheit einer Spezialisierung ist vom Verhalten abhängig!
Merksatz 31: Bedenke den Einsatz von Vererbung!
4.7 Literaturempfehlungen[Oest01] legt den Schwerpunkt auf die Konstrukte der Beschreibungssprache UML. Das Buch enthält aber ebenso viele Informationen zum objektorientierten Paradigma und zur objektorientierten Software-Entwicklung. Insbesondere handelt es sich nicht um ein C++-Buch, da die behandelten objektorientierten Konzepte sich in vielen Programmiersprachen wiederfinden.
[GHJV95] ist das Standard-Werk zum objektorientierten Entwurf. Das Buch ist die erste Veröffentlichung, die sich ausschließlich mit der Klassifikation und ausführlichen Beschreibung von Entwurfsmustern (6) beschäftigt, enthält aber auch viele allgemeine Informationen über gute objektorientierte Entwürfe. Die Code-Beispiele in dem Buch sind zu großen Teilen in C++ verfasst.
4.8 ÜbungenÜ19 (*2) Schreiben Sie das Beispiel queue1 aus Abschnitt 4.4.5.2 so um, dass es
dem Entwurf in Abbildung 29 entspricht!
201
Einsatz von Spezialisierung und Vererbung will gut bedacht sein
Die Welt der Objekte Objektorientiertes C++ für Einsteiger
Ü20 (*2) Vervollständigen Sie das Beispiel graphobj aus Abschnitt 4.6.1 um die Definition und Implementierung der Klassen Circle und Rectangle!
Ü21 (*5) Bauen Sie das Beispiel aus der vorigen Übung zu einem einfachen graphischen Editor von geometrischen Objekten aus! Erweitern Sie die Klassen ggfs. um weitere Operationen und Algorithmen!
Ü22 (*2,5) Erweitern Sie das Beispiel oohello aus Abschnitt 4.2 derart, dass Sie zwei weitere Begrüßungs-Klassen FoermlicheBegruessung und EnglischeBegruessung definieren, die auf die Nachricht begruesse mit den Meldungen „Sehr geehrte(r) person“ bzw. „Hello person“ reagieren (wobei person ein Platzhalter für die übergebene Person darstellt)! Erzeugen Sie eine geeignete Schnittstellen-Klasse als Abstraktion der drei Begrüßungs-Klassen! Erlauben Sie dem Benutzer, über eine Funktion die gewünschte Begrüßung auszuwählen! Verwenden Sie dabei die Vorteile von Polymorphie, um die Abhängigkeit zur gewählten Begrüßung möglichst gering zu halten!
Ü23 (*2,5) Implementieren Sie das Video-Beispiel aus Abschnitt 4.1 so, wie es im Entwurf in Abbildung 27 zu sehen ist! Implementieren Sie dabei die entsprechenden Methoden, indem Sie geeignete Ausgaben über die durchgeführten Aktionen ausgeben (etwa „Ziehe Medium ein“)! Erweitern Sie die Klassen ggfs. um weitere Operationen und Methoden!
Ü24 (*2,5) Stellen Sie einstellige mathematische Funktionen (also Funktionen mit genau einem Parameter), etwa die Sinus-Funktion, als Klassen dar! Jede dieser Klassen soll eine Operation zum Berechnen der jeweiligen Funktion enthalten! Überlegen Sie, wie Sie mit Hilfe von zwei zusätzlichen Klassen die Funktionen beliebig komponieren können! (Die Komposition zweier Funktionen ist die mathematische Verkettung: Der mathematische Ausdruck sin(cos(tan(x))) ist die Anwendung der Verkettung der drei Funktionen sin, cos und tan auf das Argument x. Es wird also zuerst tan(x) berechnet, dann auf das Ergebnis die cos-Funktion angewandt und schließlich dieses Resultat als Argument der sin-Funktion verwendet.) Hinweis: Sie werden eine geeignete Abstraktion benötigen!
Ü25 (*3) Modellieren und implementieren Sie ein Programm zur Verwaltung von Stücklisten! Die Produkte bestehen dabei aus Produktteilen, die ihrerseits aus weiteren Teilen bestehen, bis irgendwann elementare Einzelteile vorliegen. Der Preis eines Produkts bzw. eines Produktteils berechne sich aus der Summe der Preise aller Teile, zuzüglich eines frei zu definierenden „Mehrwerts“. Implementieren Sie in Ihren Klassen eine Operation berechnePreis, die für alle Produkte den Preis berechnet! Hinweis: Sie werden für elementare Einzelteile und zusammengesetzte Produkte eine geeignete Abstraktion benötigen!
Ü26 (*3) Entwerfen Sie eine Klassenhierarchie, die arithmetische Ausdrücke abbildet! Es soll also Klassen wie Konstante, Addition oder Division geben, wobei die Klasse Addition auf die Unterausdrücke verweist, die sie addiert. Implementieren Sie in jeder Klasse eine Operation, die den Ausdruck ausrechnet! Hinweis: Sie werden eine geeignete Abstraktion Ausdruck benötigen!
202
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
5 Fehler erkennen und behandelnDieses Kapitel geht näher auf die Sprachelemente von C++ ein, die für die Behandlung von Fehlern existieren. Es wird geklärt, was Ausnahmen sind und wie sie sinnvoll verwendet werden können. Das Kapitel geht auch auf die Rolle der Ausnahme-Sicherheit ein und wie sie erreicht werden kann. Schließlich wird die Interaktion von Ausnahmen mit dem RAII-Idiom (4.5.2) betrachtet.
5.1 GrundlegendesFehlerbehandlung ist ein wichtiges und dennoch häufig vernachlässigtes Element jeder Software-Entwicklung. Bevor wir jedoch tiefer in das Thema einsteigen, müssen wir zuerst einige zentrale Begriffe klären. Dies ist wichtig, weil diese Begriffe häufig ziemlich lax verwendet werden, aber für die folgenden Abschnitte von zentraler Bedeutung sind. Die Definitionen sind [Zell03] entnommen.
• Fehler: Ein Fehler ist eine Abweichung vom Korrekten, Richtigen, Wahren.
• Defekt: Ein Defekt ist ein Fehler innerhalb eines Programms. Defekte werden gemeinhin als Bugs bezeichnet. Gelegentlich wird ein Defekt ein innerer Fehler genannt.
• Störung / Versagen: Eine Störung liegt vor, wenn ein Defekt zur Ausführung gelangt und damit den ordnungsgemäßen Ablauf des Programms verhindert. Störungen sind somit „von außen“ sichtbare Fehler, deshalb werden sie manchmal auch als äußere Fehler bezeichnet. Wichtig ist festzuhalten, dass nicht unbedingt jeder Defekt zu einer Störung führen muss – etwa wenn er sich nur bei bestimmten Daten manifestiert, die aber während eines Programmlaufs nicht auftreten.
• Irrtum: Ein Irrtum ist eine menschliche Handlung oder Entscheidung, die zu einem Fehler führt.
• Ausnahme: Eine Ausnahme stellt ein Ereignis dar, das zum Unterbrechung des normalen Programmablaufs führt.
Zusammengefasst führen also Irrtümer zu Fehlern, die sich in Programmen als Defekte manifestieren und bei ihrer Ausführung Störungen zur Folge haben.
Im Rahmen dieses Kapitels geht es uns insbesondere um Fehlersituationen, die durch die Interaktion des Programms mit seiner Außenwelt – seien es der Benutzer, die Laufzeit-Umgebung, das Betriebssystem – entstehen. Diese Situationen müssen vom Programm erkannt und geeignet behandelt werden. Ansonsten kann sich ein Defekt einschleichen, der zur Laufzeit zu einer Störung führt.
Ein Beispiel ist ein Programm, das zwei vom Benutzer eingegebene Zahlen durcheinander dividiert und das Ergebnis ausgibt. Vergisst der Programmierer den Divisor daraufhin zu prüfen, dass er ungleich Null ist, ist dies ein Irrtum, der zu einem Defekt führt (der Abwesenheit einer notwendigen Prüfung). Dieser Defekt äußert sich zur Laufzeit in einer Störung, sobald der Benutzer als Divisor Null wählt. Die Störung kann sich z. B. in einem geregelten Programmabbruch mit entsprechender Meldung oder einem unkontrollierten „Absturz“ äußern.
203
grundlegende Begriffe
Fokus liegt auf Fehlern, die „von außen“ kommen
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
In C++ gibt es ein einfaches Modell, um mit solchen Situationen im Programm umzugehen: Immer wenn ein Fehler festgestellt wird, erzeugt das Programm eine Ausnahme. Diese Ausnahme ist in der Regel ein Objekt, das Informationen zum Auftreten des Fehlers und zur Fehlerursache (sofern bekannt) beinhaltet. Ist ein solches Ausnahme-Objekt erzeugt, verlässt das Programm den normalen Programmablauf und reicht die Ausnahme an einen sogenannten Ausnahme-Behandler weiter. Dieser hat dann die Möglichkeit, das Ausnahme-Objekt auszuwerten und geeignete Maßnahmen durchzuführen (etwa die Ausgabe einer Meldung, dass ein Divisor nicht Null sein kann). Ist der Ausnahme-Behandler fertig, kann das Programm ab diesem Punkt normal ausgeführt werden. Das Weiterreichen einer Ausnahme wird im C++-Kontext (von den Bedeutungen der entsprechenden C++-Schlüsselwörter abgeleitet) häufig Auswerfen, das Behandeln Fangen einer Ausnahme genannt.
Ausnahme-Behandler haben immer einen bestimmten „Arbeitsbereich“, d. h. sie sind nur für bestimmte Ausnahmen in bestimmten Programmteilen verantwortlich. Außerhalb seines Bereichs erzeugte Ausnahmen interessieren ihn nicht. Sie werden sehen, dass in C++ die Bereiche eines oder mehrerer Ausnahme-Behandler ziemlich deutlich erkennbar sind. Dabei ist aber nicht ausgeschlossen, dass sich Bereiche verschiedener Behandler gegenseitig einschließen; bei einer entsprechenden Ausnahme wird dann der Behandler ausgewählt, welcher dem Programmteil, der die Ausnahme erzeugt hat, am dichtesten ist.
Wichtig ist zu verstehen, dass in C++ das Erkennen einer Ausnahmesituation und die Behandlung derselben an zwei verschiedenen Stellen des Programms geschehen. Das resultiert aus der Beobachtung, dass der erkennende Programmteil häufig gar nicht weiß, wie die Fehlersituation am besten zu behandeln ist. Deshalb „gibt dieser Programmteil auf“ und „hofft“ darauf, dass irgendwo ein Ausnahme-Behandler existiert, der sich der Ausnahme annimmt. Der Behandler hingegen weiß nicht, wo die Ausnahme genau ausgelöst wurde. Ist das Ausnahme-Objekt hingegen informativ genug (d. h. wurden beim Erzeugen der Ausnahme genügend Informationen im Objekt gespeichert), kann der Ausnahme-Behandler viel über den Kontext und die Ursache der Ausnahmesituation herausfinden und geeignet reagieren. Dabei müssen Informationen nicht nur als Daten im Objekt enthalten sein: Häufig reicht schon allein der konkrete Typ des Objekts aus, um eine Ausnahme geeignet zu klassifizieren.
Schließlich ist noch anzumerken, dass es in C++ nicht möglich ist, den Programmablauf an jener Stelle fortzusetzen, an welcher der Fehler erkannt wurde. Wenn eine Ausnahme ausgeworfen wird, gibt es keinen Weg zurück: Der Behandler kann vielleicht ein Problem beheben und den entsprechenden Programmteil, in dem die Fehlersituation erkannt wurde, erneut ausführen. Er kann aber nicht an derselben Stelle aufsetzen.
5.2 Erzeugen von AusnahmenSetzen wir das Beispiel des letzten Abschnitts in ein einfaches C++-Programm ohne Eingabe-Überprüfung um:
1 /*** Beispiel except1.cpp ***/2 #include <istream>3 #include <ostream>
204
das C++-Modell zur Ausnahmebehandlung
Begrenzung von Behandlern
Erkennen und Behandeln von Fehlern getrennt
kein Wiederaufsetzen nach einer Ausnahme in C++
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
4 #include <iostream>5 using namespace std;67 // teilt „dividend“ durch „divisor“ und gibt das Ergebnis zurück8 int teile (int dividend, int divisor)9 {10 return dividend / divisor;11 }1213 int main ()14 {15 int dividend = 0;16 int divisor = 0;17 cout << "Bitte Dividend eingeben: "; cin >> dividend;18 cout << "Bitte Divisor eingeben: "; cin >> divisor;19 cout20 << dividend << "/" << divisor << " = "21 << teile (dividend, divisor) << endl;22 return 0;23 }
Wir wollen das Programm nun um eine angemessene Fehlerbehandlung erweitern. Ziel ist es, dass das Programm nicht mehr unkontrolliert abbricht, wenn als Divisor Null eingegeben wird.
Als erstes lokalisieren wir die Mögliche Quelle einer Störung. Es handelt sich um die Division in Zeile 10. Die Funktion muss also vor dieser Division prüfen, ob der Divisor Null ist, und eine geeignete Ausnahme erzeugen.
Als nächstes müssen wir uns überlegen, welche Informationen wir einem potentiellen Ausnahme-Behandler mitgeben wollen. Wir wollen den Namen der Funktion mitgeben, in der die Fehlersituation entdeckt wird.
Jetzt haben wir bereits genügend viele Informationen, um ein Ausnahme-Objekt zu beschreiben, die Prüfung zu implementieren und das Auswerfen der Ausnahme zu erledigen:
1 /*** Beispiel except2.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 #include <exception> // enthält exception-Klasse6 using namespace std;78 // Klasse für „Division durch Null“-Ausnahmen9 class DivisionDurchNull : public exception10 {11 public :12 DivisionDurchNull (const string &function);13 virtual const char *what () const throw ();14 private :15 string m_message;16 };1718 DivisionDurchNull:: DivisionDurchNull (const string &function)19 :20 m_message (21 "Division durch Null in Funktion " + function22 + " aufgetreten!"23 )
205
Wo müssen Ausnahmen erzeugt werden?
Welche Informationen soll eine Ausnahme mit sich führen?
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
24 {25 }2627 const char *DivisionDurchNull::what () const throw ()28 {29 // konvertiere string-Objekt in eine C-Zeichenkette30 return m_message.c_str ();31 }3233 // teilt „dividend“ durch „divisor“ und gibt das Ergebnis zurück;34 // wirft eine Ausnahme vom Typ „DivisionDurchNull“ aus, wenn der Divisor Null ist35 int teile (int dividend, int divisor)36 {37 if (divisor == 0)38 throw DivisionDurchNull ("teile");39 else40 return dividend / divisor;41 }42
Schauen wir uns die wichtigen Neuerungen im Programm an:
• Zeile 5: Hier benutzen wir die Standard-Header-Datei <exception>, in der die Klasse std::exception definiert ist. Diese Klasse nutzen wir in der Klasse unseres Ausnahme-Objekts als Basisklasse (siehe unten).
Anders als in Java ist es nicht erforderlich, eigene Ausnahme-Klassen von exception (oder einer anderen Klasse) abzuleiten. Genau genommen muss es nicht einmal eine Klasse sein: ein throw-Ausdruck (s. u.) kann auch von einem primitiven Datentyp (etwa int) sein. Es bietet sich aber an, von exception (oder einer spezielleren Klasse) abzuleiten, da diese Klasse garantiert, dass jedes Ausnahme-Objekt eine Meldung besitzt, die beispielsweise angezeigt und gespeichert werden kann.
• Zeilen 8-16: Hier definieren wir eine Klasse für unsere Ausnahme-Objekte. Sie besitzt einen Konstruktor (definiert ab Zeile 18), der den Namen der die Ausnahme erzeugenden Funktion entgegennimmt, und eine polymorphe Operation what (definiert ab Zeile 27). Diese Operation ist eine Redefinition der entsprechenden Operation in der Klasse exception, von der unsere Ausnahme-Klasse erbt, und ist dafür gedacht, eine Meldung über die ausgeworfene Ausnahme zurückzugeben.
Der Rückgabetyp dieser Methode ist const char *, d. h. ein Zeiger auf ein (Feld von) Zeichen. Es handelt sich dabei um ein C-Relikt: In C gab es keinen Datentyp für Zeichenketten, also behalf man sich mit Feldern aus Zeichen, auf die dann mit Zeigern verwiesen wurde und deren Ende mit einem speziellen Ende-Zeichen ('\0') markiert wurde. Die Operation exception::what hat sehr alte Wurzeln in der C++-Standardisierung, weshalb hier noch diese alte Art verwendet wird, auf Zeichenketten zu verweisen. Es ist jedoch kein Problem, ein string-Objekt in einen solchen Zeiger auf ein Zeichen-Feld umzuwandeln (siehe unten).
Der throw-Modifizierer wird in Abschnitt 5.7 erläutert; hier reicht es zu wissen, dass damit garantiert wird, dass die Methode what unter keinen Umständen eine Ausnahme erzeugt. Tut sie es dennoch, führt dies zu einem „harten“ Programmabbruch.
206
exception-Klasse als Oberklasse
what-Operation zur Rückgabe der gespeicherten Meldung
Verwendung der exception-Klasse ist nicht erforderlich
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
• Zeilen 18-25: Hier wird der Konstruktor definiert, der den übergebenen Funktionsnamen verwendet, um ein string-Objekt mit einer geeigneten Meldung zu initialisieren.
• Zeilen 27-31: Hier wird die Methode what der Klasse DivisionDurchNull definiert. Beachten Sie den Aufruf der Operation c_str: Damit wird aus einem string-Objekt eine passende C-Zeichenkette erzeugt, die dann zurückgegeben wird.
• Zeilen 37-40: Die Division ist hier um eine Prüfung des Divisors erweitert worden: Ist der Divisor Null, wird ein sogenannter throw-Ausdruck ausgewertet. Ein throw-Ausdruck hat folgende Syntax:
throw Ausdruck
Die Bedeutung des throw-Ausdrucks ist die, dass Ausdruck ausgewertet wird und das Ergebnis als Ausnahme an den passenden Ausnahme-Behandler weitergereicht wird. In unserem Beispiel erzeugen wir ein temporäres Objekt (4.5.4) vom Typ DivisionDurchNull und übergeben an den Konstruktor unseren Funktionsnamen.
Beachten Sie, dass wie oben erwähnt das Auswerfen einer Ausnahme mit Hilfe von throw den normalen Programmablauf unterbricht und „übergangslos“ zum passenden Ausnahme-Behandler springt. Deshalb ist es auch kein Problem, dass der erste Zweig der if-Anweisung keine return-Anweisung enthält: Die Funktion kehrt hier nicht auf „normalem“ Wege zum Aufrufer zurück, somit ist auch keine return-Anweisung notwendig. Sie würde sowieso nicht ausgeführt, da alle Anweisungen, die auf einen ausgewerteten throw-Ausdruck folgen, ohnehin ignoriert werden. Wie der „passende“ Ausnahme-Behandler bestimmt wird, werden Sie weiter unten erfahren.
5.3 Behandeln von AusnahmenBis jetzt haben wir einen möglicher Programm-Defekt durch eine entsprechende Prüfung und das Werfen einer Ausnahme bei negativem Ausgang ersetzt. Noch haben wir uns aber keine Gedanken gemacht, wo und wie wir diese Ausnahme behandeln. Dazu ist erst einmal zu klären, wie Ausnahmen in C++ überhaupt behandelt werden und wie ein passender Ausnahme-Behandler gefunden wird. Dies wollen wir jetzt nachholen.
In C++ ist das Behandeln von Ausnahmen an einen Block von Anweisungen gebunden. Durch eine entsprechende Syntax kann dem C++-Übersetzer mitgeteilt werden, dass bestimmte Anweisungen „unter Vorbehalt“ ausgeführt werden: Wenn zur Laufzeit bei der Ausführung dieser Anweisungen eine Ausnahme erzeugt wird, kann dafür ein passender Behandler „hinterlegt“ werden. Wir wollen dies am obigen Beispiel demonstrieren:
43 int main ()44 {45 int dividend = 0;46 int divisor = 0;
207
c_str wandelt ein string-Objekt in eine C-Zeichenkette um
Ausnahmen werden durch throw-Ausdrücke erzeugt
Ausnahmen verlassen den normalen Programmablauf
Ausnahme-Behandler „überwachen“ Anweisungen
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
47 cout << "Bitte Dividend eingeben: "; cin >> dividend;48 cout << "Bitte Divisor eingeben: "; cin >> divisor;49 // versuchen (= to try) wir mal...50 try51 {52 cout53 << dividend << "/" << divisor << " = "54 << teile (dividend, divisor) << endl;55 return 0;56 }57 // was tun, wenn eine DivisionDurchNull-Ausnahme ausgelöst wird?58 catch (const DivisionDurchNull &e)59 {60 cout << "Fehler: " << e.what() << endl;61 return 1;62 }63 }
Betrachten wir den Quelltext etwas genauer:
• Zeilen 49-56: Hier wird mit dem Schlüsselwort try ein „überwachter“ Block von Anweisungen eingeleitet. Innerhalb dieses Blocks werden alle Anweisungen auf Ausnahmen geprüft. Weil diese Prüfung aber effektiv zur Laufzeit durchgeführt wird, bedeutet dies, dass auch alle Anweisungen, die indirekt innerhalb dieses Blocks stattfinden (etwa die Anweisungen einer Funktion, die innerhalb des Blocks aufgerufen wird), ebenfalls überwacht werden. In unserem Beispiel wird dies deutlich: Der throw-Ausdruck steht lexikalisch außerhalb des try-Blocks. Was aber zählt ist, dass zur Laufzeit die Funktion in Zeile 54 innerhalb des try-Blocks aufgerufen und ausgeführt wird, so dass eventuelle Ausnahmen behandelt werden können.
• Zeilen 57-62: In diesen Zeilen steht ein Ausnahme-Behandler. Ein solcher Behandler beginnt mit dem Schlüsselwort catch und enthält sowohl die Definition eines Ausnahme-Parameters (in runden Klammern) als auch einen Anweisungsblock (in geschweiften Klammern). Zuerst bezieht sich ein so definierter Ausnahme-Behandler nur auf den zugehörigen try-Block; Ausnahmen, die woanders aufgerufen werden, können nicht mit diesem Ausnahme-Behandler aufgefangen werden. Der try-Block ist also der Bereich des Ausnahme-Behandlers.
Die Definition des Ausnahme-Parameters regelt, ob ein Ausnahme-Behandler eine Ausnahme behandeln darf: Das ausgeworfene Objekt muss zum catch-Parameter passen. In unserem Beispiel heißt das, dass nur Ausnahmen behandelt werden können, die durch DivisionDurchNull-Objekte (und Objekte abgeleiteter Klassen) dargestellt sind. Wenn ein Ausnahme-Objekt eines anderen, nicht passenden Typs erzeugt wird, wird dieser Behandler übergangen.
Wenn nun eine passende Ausnahme im überwachten Bereich erzeugt wird, wird der normale Programmablauf unterbrochen und das Programm „springt“ direkt vom throw-Ausdruck in Zeile 38 in den zugehörigen Ausnahme-Behandler in Zeile 59. Ab hier wird das Programm ganz normal fortgeführt. Das heißt, dass nach dem Sprung alle Anweisungen wieder „der Reihe nach“ ausgeführt werden.
208
try schließt die zu überwachenden Anweisungen ein
catch definiert Ausnahme-Behandler
throw-Ausdruck muss zum catch-Parameter passen
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
In unserem Fall wird die Meldung des Ausnahme-Objekts ausgegeben und das Programm via return verlassen.
Beachten Sie, dass das Ausnahme-Objekt, das im throw-Ausdruck initialisiert wurde, am Ende des entsprechenden Ausnahme-Behandlers zerstört wird. In unserem Fall geschieht dies in Zeile 62, kurz bevor die Kontrolle wieder an die Laufzeit-Umgebung zurückgegeben wird.
Ein try-Block muss immer mindestens einen catch-Block besitzen; umgekehrt kann ein catch-Block nicht ohne einen try-Block existieren. Die Syntax für dieses Sprachkonstrukt ist wie folgt:
try{
[ Anweisungen ]}catch ( Parameter-Definition ){
[ Anweisungen ]}[ catch ( Parameter-Definition ){
[ Anweisungen ]}[ ... ] ]
Wie Sie sehen, können die Blöcke jeweils leer sein. Leere try-Blöcke sind niemals sinnvoll, weil keine Ausnahmen in ihnen entstehen können und die assoziierten catch-Blöcke folglich nie ausgeführt werden können. Leere catch-Blöcke können in einigen (sehr seltenen) Fällen durchaus sinnvoll sein. Allerdings schreiben Sie Ausnahme-Behandler, um Ausnahmen zu behandeln, d. h. auf Ausnahmen geeignet zu reagieren – und „nichts zu tun“ ist nicht unbedingt eine angemessene Reaktion...
Merksatz 32: Vermeide leere catch-Blöcke!
5.4 Ausnahmen während des ProgrammlaufsNachdem wir jetzt unser Programm syntaktisch um die entsprechenden Konstrukte zur Ausnahmebehandlung erweitert haben, wollen wir uns verdeutlichen, was zur Laufzeit da eigentlich passiert. Wir wollen uns dabei auf den Fall beschränken, dass eine Ausnahmesituation entsteht; die erforderlichen Eingaben des hypothetischen Benutzers seien deshalb 7 und 0.
Die Ausführung des Programms startet in der Funktion main:
209
Ausnahme-Objekte werden am Ende des Behandlers zerstört
Syntax von try/catch
Ausnahmen zur Laufzeit
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
43 int main ()44 {
Als erstes werden die lokalen Variablen definiert und initialisiert:45 int dividend = 0;46 int divisor = 0;
Dann wird der Benutzer angewiesen, die Eingabe-Daten einzugeben:47 cout << "Bitte Dividend eingeben: "; cin >> dividend;48 cout << "Bitte Divisor eingeben: "; cin >> divisor;
Nun enthalten die Variablen dividend und divisor die Werte 7 und 0.
Als nächstes wird ein überwachter Anweisungsblock eingeleitet. Wenn in den darin enthaltenen Anweisungen eine Ausnahme auftritt, kann sie in einem anhängigen catch-Block (sofern alles passt) behandelt werden:
49 // versuchen (= to try) wir mal...50 try51 {
Jetzt wird das Ergebnis berechnet und ausgegeben:52 cout53 << dividend << "/" << divisor << " = "54 << teile (dividend, divisor) << endl;
Doch halt! Diese Anweisung ist eine Ausdrucks-Anweisung (3.5.1) und enthält einen Funktionsaufruf als Teilausdruck.50 Das bedeutet, dass die Anweisungen in der Funktionsdefinition von teile eingeschoben werden:
35 int teile (int dividend, int divisor)36 {
Alle Anweisungen der Funktion sind jetzt logisch Teil des try-Blocks, auch wenn sie lexikalisch nicht im try Block „stehen“. Es zählt allein die Tatsache, dass die Ausführung der Funktion auf eine Anweisung im try-Block zurückgeführt werden kann.
Als erstes wird in der Funktion geprüft, ob divisor Null ist. Das ist bei uns tatsächlich der Fall, deshalb wird der ersten Zweig der if-Anweisung gewählt. Dort steht ein throw-Ausdruck, der ein temporäres Objekt (4.5.4) des Typs DivisionDurchNull initialisiert und danach auswirft. An dieser Stelle wird der gewöhnliche Programmablauf verlassen; insbesondere werden alle folgenden Anweisungen in der Funktion ignoriert:
37 if (divisor == 0)38 throw DivisionDurchNull ("teile");
Jetzt müssen Sie versuchen, sich in die Situation hinein zu versetzen: Sie sind jetzt auf der Suche nach einem passenden Ausnahme-Behandler. Bildlich gesprochen wandern Sie den ganzen Weg zurück, der Sie zum throw-Ausdruck gebracht hat, bis Sie auf einen try-Block stoßen; wenn dieser einen passenden catch-Block 50) Wie Sie in Abschnitt 7.1.3 sehen werden, ist auch die Benutzung des Operators << in diesem Kon
text letztlich ein Funktionsaufruf; dieser interessiert uns aber an dieser Stelle nicht, da er keine Ausnahmen wirft, die wir behandeln.
210
das Suchen nach dem passenden Behandler
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
beinhaltet, wird dieser dann ausgeführt. Falls kein passender catch-Block vorliegt, gehen Sie weiter zurück bis zum nächsten try-Block u. s. w.
Falls überhaupt kein try-Block gefunden wird oder kein try-Block einen passenden catch-Block besitzt, geschehen schreckliche Dinge: Das Programm wird auf der Stelle über die Funktion std::terminate abgebrochen!
In unserem Fall ist der erste und einzige try-Block, den wir auf unserem Rückweg finden, der in Zeile 49. Wir müssen nun als nächstes seine catch-Blöcke inspizieren, und zwar von oben nach unten (die Reihenfolge ist relevant, siehe Abschnitt 5.6!) Bei jedem der catch-Blöcke überprüfen wir, ob unser throw-Ausdruck zum catch-Parameter passt. Dabei heißt „passend“, dass
(1) die Parameter- und Argument-Typen entweder exakt dieselben sind, oder
(2) die Parameter- und Argument-Typen dieselben sind außer der Tatsache, dass der Parameter-Typ eine Referenz ist und der Argument-Typ nicht, oder
(3) der Parameter-Typ const ist und der Argument-Typ nicht, oder
(4) der Parameter-Typ eine Oberklasse des Argument-Typs ist, oder
(5) der Parameter-Typ ein Zeiger auf eine Oberklasse und der Argument-Typ ein Zeiger auf eine entsprechende Unterklasse ist, oder
(6) der Parameter-Typ eine Referenz auf eine Oberklasse und der Argument-Typ eine Referenz auf eine entsprechende Unterklasse ist.
Die Regeln können auch kombiniert werden: Somit kann ein throw-Argument des Typs Derived von einem catch-Parameter des Typs const Base & aufgefangen werden (Kombination der Regeln 2, 3 und 6), vorausgesetzt der Typ Derived ist spezieller als der Typ Base.
Achtung: Bei der Überprüfung auf Typ-Verträglichkeit zwischen throw-Argument und catch-Parameter werden nicht die üblichen Regeln zur Typ-Verträglichkeit angewandt, wie sie in Abschnitt 3.4.5 beschrieben sind! Dies hat technische Gründe, auf die wir hier nicht näher eingehen können. Insbesondere werden alle Standard-Konvertierungen (außer der Unterklasse-zu-Oberklasse-Konvertierung, die oben erwähnt wurde) nicht durchgeführt. Das kann insbesondere im Bereich primitiver Datentypen zu Überraschungen führen: Ein throw-Ausdruck, dessen Argument vom Typ int ist (etwa throw 1) wird nicht von einem catch-Ausnahme-Behandler aufgefangen, dessen Parameter vom Typ long ist!
Es gibt in unserem Beispiel jedoch nur einen catch-Block, so dass wir hier nur überprüfen müssen, ob das Argument zum Parameter passt. Unser Argument ist vom Typ DivisionDurchNull, der Parameter vom Typ const DivisionDurchNull &. Gemäß den obigen Regeln ist dies in Ordnung. Der Parameter e wird nun mit dem throw-Ausdruck initialisiert. Ab diesem Zeitpunkt gilt die Ausnahme als behandelt, und der normale Programmablauf ist wiederhergestellt. Es werden nun also die Anweisungen im catch-Block ausgeführt:
58 catch (const DivisionDurchNull &e)59 {60 cout << "Fehler: " << e.what() << endl;
211
std::terminate
Wann passt ein throw-Ausdruck zu einem catch-Parameter?
Typ-Verträglichkeit ist bei Ausnahmen anders!
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
61 return 1;62 }
Wenn Sie sich fragen, wie es sein kann, dass ein throw-Ausdruck vom Typ X an einen catch-Parameter von Typ X & (Regel 2) gebunden werden kann, wo doch temporäre Objekte normalerweise nur an const-Referenzen gebunden werden können (4.5.4), haben Sie ein gutes Gespür für „merkwürdige“ Dinge. Die Wahrheit ist, dass die Laufzeit-Umgebung bei der Auslösung einer Ausnahme das Ausnahme-Objekt in einen anderen Speicherbereich kopiert. Dies ist notwendig, weil ein temporäres Objekt am Ende des enthaltenden Ausdrucks zerstört wird; wenn das Ausnahme-Objekt nicht kopiert würde, überlebte ein Ausnahme-Objekt nicht einmal den throw-Ausdruck.51 Das kopierte Objekt gilt dann im Folgenden nicht mehr als temporäres Objekt im eigentlichen Sinn, deshalb ist das Fangen per Nicht-const-Referenz erlaubt. Die Lebensdauer eines Ausnahme-Objekts erstreckt sich vom Zeitpunkt seiner Erzeugung bis hin zum Verlassen des catch-Blocks, der die Ausnahme behandelt.
Das Kopieren und Zerstören von Ausnahme-Objekten durch die Laufzeit-Umgebung ist übrigens der Grund dafür, dass nur Ausnahme-Objekte benutzt werden können, deren Typ entweder ein primitiver Datentyp ist oder deren Klasse einen öffentlich zugänglichen Kopierkonstruktor und Destruktor besitzt.
5.5 Behandeln beliebiger AusnahmenIn C++ kann man einem Ausnahme-Behandler erlauben, beliebige Ausnahmen zu behandeln, ohne einen konkreten Typ anzugeben. Statt der üblichen Parameter-Definition wird die Ellipse (...) verwendet. Beispiel:
1 try2 {3 // beliebige Anweisungen4 }5 catch (...) // behandelt alle Ausnahmen, die im obigen try-Block auftreten6 {7 // behandelnde Anweisungen8 }
Das Sprachkonstrukt ist jedoch nicht annähernd so nützlich, wie es vielleicht im ersten Moment erscheint. Es liegen dem Behandler nämlich keinerlei Informationen über die aufgefangene Ausnahme vor, so dass die korrekte Behandlung schwierig werden kann. Deshalb wird das Konstrukt typischerweise nur in zwei Situationen verwendet:
(1) im Einsprungspunkt des Programms, der Funktion main, um zu verhindern, dass irgendwelche Ausnahmen das Programm gänzlich verlassen und damit das Programm über std::terminate außerplanmäßig abgebrochen wird
(2) um Aufräumarbeiten unabhängig von der Ausnahme durchzuführen und danach die Ausnahme weiterzureichen (siehe Abschnitt 5.6)
Das Problem in (2) kann jedoch viel einfacher mit dem RAII-Idiom gelöst werden (siehe hierzu die Abschnitte 4.5.2 und 5.9). Deshalb bleibt nur (1) als Einsatzgebiet des „universellen Ausnahme-Behandlers“, und das ist nicht besonders viel...
51) Diese Erläuterung berücksichtigt nicht, dass der C++-Standard durchaus auch eine direkte Initialisierung des passenden catch-Parameters mit dem throw-Argument erlaubt, so dass ein temporäres Objekt überflüssig wird. De facto ist mir jedoch keine einzige Implementierung bekannt, die eine derartige Optimierung anbietet.
212
Lebensdauer von Ausnahme Objekten
öffentlicher Kopierkonstruktor und Destruktor notwendig
der „universelle“ Behandler
Probleme mit catch(...)
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
Ein konkretes Beispiel für die Anwendung eines solchen universellen Ausnahme-Behandlers finden Sie im nächsten Abschnitt.
5.6 Erneutes Auswerfen von AusnahmenEs kann vorkommen, dass ein Ausnahme-Behandler feststellt, dass er nicht in der Lage ist, die Ausnahme komplett zu behandeln. Dies kann zum Beispiel der Fall sein, wenn der „Behandler“ die Ausnahme gar nicht behandeln, sonder nur in einer Protokoll-Datei vermerken will. In einem solchen Fall hat er die Möglichkeit, die Ausnahme an den nächsten Behandler weiterzureichen. Dazu benutzt er einfach den throw-Ausdruck ohne ein Argument. Beispiel:
1 try2 {3 try4 {5 throw 42; // werfe Ausnahme aus6 }7 catch (...) // behandelt alle Ausnahmen, die im obigen try-Block auftreten8 {9 cout << "Ausnahme aufgefangen!" << endl;10 throw;11 }12 }13 catch (int i)14 {15 cout << "int-Ausnahme aufgefangen: Wert=" << i << endl;16 }
In diesem kleinen Beispiel haben wir zwei geschachtelte try/catch-Blöcke. Im inneren try-Block wird eine Ausnahme vom Typ int ausgeworfen. Der innere catch-Block bekommt diese als erster zu Gesicht, gibt eine Meldung aus und reicht die Ausnahme an den nächsten Behandler weiter. Der äußere catch-Block bekommt die weitergereichte Ausnahme als nächster zu sehen, gibt ebenfalls eine (etwas informativere) Meldung aus, reicht die Ausnahme jedoch nicht weiter, so dass mit dem Ende des catch-Blockes der normale Programmablauf wiederhergestellt ist.
Sie sehen an diesem Beispiel auch, warum eine eigene throw-Syntax für das Weiterreichen der Ausnahme erforderlich ist: Nicht immer haben Sie nämlich Zugriff auf das originale Ausnahme-Objekt, etwa wenn Sie in einem Behandler mit Ellipse (5.5) sind. Aber es gibt auch einen anderen wichtigen Grund, auch bei gewöhnlichen Ausnahme-Behandlern diese neue Syntax zu verwenden: Slicing. Wenn der Parameter eines catch-Blockes vom Typ der Basisklasse eines Ausnahme-Objekts ist, wird beim Auffangen einer entsprechenden Ausnahme das ursprüngliche Objekt beim Initialisieren des Parameters abgeschnitten (siehe Abschnitt 4.6.4.1). Wenn Sie dieses Objekt dann mit Hilfe der normalen Syntax weiterreichen, „vergisst“ das Programm den ursprünglichen (spezielleren) Typ der Ausnahme, weil das Parameter-Objekt ja jetzt einen anderen Typ hat. Beispiel:
1 /*** Beispiel rethrow1.cpp ***/2 #include <istream>3 #include <ostream>
213
Weiterreichen von Ausnahmen
throw ohne Argumente ist nötig zum korrekten Weiterreichen
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
4 #include <iostream>5 #include <exception>6 using namespace std;78 class DivisionDurchNull : public exception9 {
10 public :11 virtual const char *what () const throw ();12 };13 const char *DivisionDurchNull::what () const throw ()14 {15 return "Division durch Null!";16 }1718 int teile (int dividend, int divisor)19 {20 if (divisor == 0)21 throw DivisionDurchNull ();22 return dividend / divisor;23 }2425 int berechne (int operand1, int operand2, int f (int, int))26 {27 try28 {29 return f (operand1, operand2);30 }31 catch (exception e) // Achtung: Slicing möglich!32 {33 cout34 << "Ausnahme bei Berechnung aufgetreten: "35 << e.what ()36 << endl;37 throw e; // Achtung: Abgeschnittenes Objekt wird propagiert!38 }39 }4041 int main ()42 {43 int dividend = 0;44 int divisor = 0;45 cout << "Bitte Dividend eingeben: "; cin >> dividend;46 cout << "Bitte Divisor eingeben: "; cin >> divisor;47 try48 {49 int ergebnis = berechne (dividend, divisor, teile);50 cout51 << dividend << "/" << divisor << " = "52 << ergebnis << endl;53 return 0;54 }55 catch (const DivisionDurchNull &e)56 {57 cout << "Division durch Null (Meldung: "58 << e.what () << ")" << endl;59 return 1;60 }61 catch (const exception &e)62 {63 cout << "Ausnahme aufgetreten: " << e.what () << endl;64 return 2;65 }
214
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
66 catch (...)67 {68 cout << "Unbekannte Ausnahme aufgetreten!" << endl;69 return 3;70 }71 }
Das Beispiel enthält gleich zwei Probleme:
(1) Zum einen wird in Zeile 31 die Ausnahme nicht per Referenz aufgefangen. Das erfordert eine zusätzliche Kopie. Schlimmer noch ist, dass auch Objekte abgeleiteter Typen mit diesem Ausnahme-Behandler aufgefangen werden können, etwa ein Objekt des Typs DivisionDurchNull. Da der Typ des Parameters jedoch kein Referenz-Typ ist, wird das ursprüngliche Objekt bei der Initialisierung des catch-Parameters abgeschnitten (4.6.4.1). Das äußert sich darin, dass beim Aufruf der Operation what in Zeile 35 nicht die Methode what der Klasse DivisionDurchNull ausgeführt und somit keine oder eine falsche Meldung ausgegeben wird (je nachdem, wie exception::what implementiert ist).
(2) Zum anderen wird in Zeile 37 das Ausnahme-Objekt nicht via throw; weitergereicht, sondern als Argument explizit angegeben. In diesem Fall heißt das aber, dass das abgeschnittene Objekt weitergereicht wird. Somit wird im weiteren Verlauf der Ausnahme-Behandler ausgeführt, der in Zeile 61 und nicht in Zeile 55 beginnt, weil das weitergereichte Objekt nicht (mehr) vom Typ DivisionDurchNull ist. Das ist aber garantiert unbeabsichtigt.
Diese beiden Probleme führen dazu, dass die Ausgabe nicht die erwartete ist.Unter VC++ gibt das Programm bei der Eingabe von 5 und 0 als Dividend bzw. Divisor
Bitte Dividend eingeben: 5Bitte Divisor eingeben: 0Ausnahme bei Berechnung aufgetreten: Unknown exceptionAusnahme aufgetreten: Unknown exception
aus. Die Meldung Unknown exception ist eine Standard-Meldung der exception-Klasse unter VC++. Ihre C++-Implementierung kann durchaus eine völlig andere Meldung zurückgeben.
Schließlich können Sie an diesem Beispiel erkennen, dass die Reihenfolge der catch-Blöcke wichtig ist. Wenn Sie die Ausnahme-Behandler in den Zeilen 55-60 und 61-65 vertauschen, wird immer der exception-Ausnahme-Behandler aufgerufen, und der speziellere kommt nicht mehr zum Zuge. Das liegt daran, dass Ausnahme-Behandler in der Reihenfolge ihres Auftretens im Quelltext auf Typ-Verträglichkeit des throw-Arguments mit dem catch-Parameter geprüft werden. Deshalb müssen Sie aufpassen, dass Sie den (oder die) speziellsten Ausnahme-Behandler immer zuerst erwähnen.
Merksatz 33: Nutze Referenzen bei Ausnahme-Parametern!
Merksatz 34: Ordne Ausnahme-Behandler vom speziellsten zum allgemeinsten!
Korrigieren Sie nun die beiden aufgezeigten Probleme in Zeile 31:
215
Ausnahmen und Slicing
der Typ des Ausnahme-Objekts muss erhalten bleiben
Die Reihenfolge von catch-Blöcken ist wichtig!
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
31 catch (const exception &e) // kein Slicing mehr möglich
und Zeile 37:37 throw; // originalesObjekt wird propagiert
und lassen Sie das Programm ablaufen. Bei einer Eingabe von 5 für den Divisor und 0 für den Dividenden sollte nun die folgende Ausgabe entstehen:
Bitte Dividend eingeben: 5Bitte Divisor eingeben: 0Ausnahme bei Berechnung aufgetreten: Division durch Null!Division durch Null (Meldung: Division durch Null!)
5.7 Ausnahme-SpezifikationenBisher haben wir noch nicht die Rolle des throw-Modifizierers (nicht des throw-Ausdrucks) besprochen. Wenn eine Operation, eine Methode oder eine Funktion am Ende des Kopfes
throw ( [Ausnahmetyp1 [, Ausnahmetyp2 [, ...]]] )enthält, bedeutet dies, dass diese Operation/Methode/Funktion (der Einfachheit halber wird im Rest dieses Abschnitts der Begriff „Funktion“ benutzt) nur Ausnahmen der aufgelisteten (und spezielleren) Typen auswerfen kann. Diese Liste wird Ausnahme-Spezifikation genannt. Ist diese Liste leer, garantiert die Funktion, dass sie überhaupt keine Ausnahmen auswirft. Wohlgemerkt bedeutet dies nicht, dass innerhalb der Funktion keine Ausnahmen erzeugt werden können; es bedeutet nur, dass keine dieser Ausnahmen die Funktion jemals verlassen werden. Das bedeutet in der Praxis, dass die Funktion entweder ganz simpel ist (so dass einleuchtend ist, dass keine Ausnahmen auftreten können) oder einen geeigneten try/catch-Block enthält, um dieses Versprechen halten zu können.
Diese Garantie steht nicht nur „auf dem Papier“ zu Dokumentationszwecken, sondern wird zur Laufzeit auch rigoros überprüft. Wirft eine Funktion eine Ausnahme aus, die nicht von einem der aufgelisteten Typen ist (und auch nicht von einem spezielleren Typ), so reagiert die Laufzeit-Umgebung, indem die Funktion std::unexpected aufgerufen wird. Diese ruft standardmäßig die Funktion std::terminate auf, die einen Programmabbruch bewirkt. Somit wird sichergestellt, dass das Nicht-Einhalten der Ausnahme-Spezifikation nicht zu fatalen Fehlern innerhalb der Anwendung führt, sondern der instabile Programmzustand schnellstmöglich verlassen wird.
Java-Programmierer aufgepasst: In C++ lautet das Schlüsselwort throw und nicht throws! Außerdem sind die Ausnahme-Typen in C++ innerhalb runder Klammern aufgelistet, die in Java fehlen.
Weil es sich aber um einen „harten“ Programmabbruch handelt und dieser in vielen Fällen dennoch unerwünscht ist – vielleicht möchte der Entwickler solche Ereignisse protokollieren, den externen Programm-Zustand einigermaßen wieder in Ordnung bringen (etwa temporäre Dateien löschen) u. s. w. – empfehlen viele Autoren, auf die Benutzung von Ausnahme-Spezifikationen zu verzichten. Ein weiterer Grund für diese Empfehlung ist, dass die Prüfung von Ausnahme-Spezifikationen wirklich nur
216
Syntax und Bedeutung von Ausnahme-Spezifikationen
Ausnahme-Spezifikationen werden zur Laufzeit überprüft
Ausnahme-Spezifikationen sollten mit Bedacht verwendet werden
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
zur Laufzeit und nicht zur Übersetzungszeit passiert. Ein Beispiel verdeutlicht, warum dies so ist:
1 // kapselt das Auftreten eines negativen Arguments2 class NegativeArgument {};34 // wirft eine Ausnahme vom Typ NegativeArgument aus, falls i < 05 void f (int i) throw (NegativeArgument);67 void g () throw ()8 {9 // f() könnte theoretisch eine Ausnahme werfen, tatsächlich passiert dies nie10 f (1);11 }
Der Autor der Funktion f möchte den Benutzer darauf hinweisen, dass bei negativen Werten eine Ausnahme vom Typ NegativeArgument ausgeworfen wird. C++ erlaubt jedoch nicht, diese semantischen Bedingungen syntaktisch zu erfassen. Somit hat der Entwickler nur die Möglichkeit, über eine Ausnahme-Spezifikation generell die Möglichkeit einer NegativeArgument-Ausnahme anzuzeigen und die semantischen Bedingungen, die zu dieser Ausnahme führen, im Kommentar zu hinterlegen. Die Funktion g hingegen nutzt f in einer Weise, die gemäß f’s Schnittstelle nicht zu einer Ausnahme führen sollte. Deshalb – und weil keine anderen Anweisungen zu Ausnahmen führen können – ist g’s Ausnahme-Spezifikation leer.
Prüfte bereits der C++-Übersetzer die Ausnahme-Spezifikationen, müsste er mangels tieferer Einsicht in die Kommentare und Implementierung von f deren Aufruf innerhalb von g verbieten. Dies sehen aber viele C++-Entwickler als „Beschneidung ihrer Mündigkeit“ an, weshalb C++-Ausnahme-Spezifikationen nur zur Laufzeit geprüft werden. Für die wirklich unerwarteten Fälle (d. h. wenn Ausnahmen auftreten, die gar nicht auftreten dürfen) gibt es dementsprechend auch eine scharfe Ächtung via std::unexpected seitens der C++-Laufzeit-Umgebung.52
Java sieht seine Entwickler offensichtlich als „weniger mündig“ an, da in Java eben solche Ausnahme-Spezifikationen bereits zur Übersetzungszeit geprüft werden. Dies führt dann entweder dazu, dass Ausnahmen behandelt werden müssen, die eigentlich gar nicht auftreten können, oder zu sogenannten Error-Ausnahmen, die diejenige Untermenge aller Ausnahmen darstellen, die nicht zur Übersetzungszeit überprüft werden.53 Natürlich kann weder dem einen noch dem anderen Ansatz ein absoluter Anspruch auf Angemessenheit und Korrektheit unterstellt werden. Entwickler jedoch, die in beiden Welten entwickeln (müssen), sollten sich der Unterschiede durchaus bewusst sein, wobei der Übergang von C++ zu Java typischerweise mit mehr Ärger über den peniblen Übersetzer, der Übergang von Java zu C++ mit mehr Fehlern zur Laufzeit einhergeht...
Merksatz 35: Benutze Ausnahme-Spezifikationen mit Bedacht!
52) Weitere Probleme mit dem Prüfen von Ausnahme-Spezifikationen zur Übersetzungszeit können Sie in [Ellis90], Abschnitt 15.5, nachlesen.
53) Auch RuntimeException-Ausnahmen (und entsprechende Unterklassen) werden nicht geprüft.
217
keine statische Prüfung von Ausnahme-Spezifikationen in C++
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
Bisher haben wir keine Ausnahme-Spezifikationen verwendet. Die Frage stellt sich, welcher Ausnahme-Spezifikation dies entspricht. Die Antwort ist nicht offensichtlich: Eine Funktion ohne Ausnahme-Spezifikation sagt nicht aus, dass sie keine Ausnahmen auswirft, sondern dass sie beliebige Ausnahmen auswerfen kann. Ein Grund dafür ist, dass es keine throw-Syntax für den Fall „kann alles Mögliche auswerfen“ gibt.
Auch das ist etwas, was sich von den Ausnahme-Spezifikationen in Java unterscheidet. In Java bedeutet bekanntlich das Fehlen der Ausnahme-Spezifikation die Garantie, dass keine Ausnahmen ausgeworfen werden. Und der Fall, dass eine Methode jede beliebige Ausnahme auswerfen kann, kann mit Hilfe von throws Exception ausgedrückt werden. Das geht in C++ so nicht, weil nicht gefordert ist, dass Ausnahme-Typen von einer bestimmten Klasse abgeleitet sind.
Die Kombination von Ausnahme-Spezifikationen und Spezialisierung erfordert zusätzlich etwas Nachdenken. Wir wollen, dass das Liskov’sche Substitutionsprinzip (4.1.6, 4.6.4.1) auch im Kontext von Ausnahme-Spezifikationen gilt. Der Schlüssel hierfür ist, Ausnahmen gewissermaßen als spezielle Nachbedingungen zu interpretieren. Wenn also eine Operation in einer Basisklasse garantiert, nur eine bestimmte Menge an Ausnahmen jemals auszuwerfen, dann darf eine implementierende Methode in einer abgeleiteten Klasse nicht mehr Ausnahmen erlauben. Beispiel:
1 #include <exception>2 using namespace std;34 // Ausnahme-Klasse für ungültige Argumente5 class UngueltigesArgument : public exception6 {7 public :8 virtual const char *what () const throw ();9 };
10 // Ausnahme-Klasse für Datei-Ein-/Ausgabe-Fehler11 class DateiFehler : public exception12 {13 public :14 virtual const char *what () const throw ();15 };1617 // Fakultät-Dienst18 class Fakultaet19 {20 public :21 // Berechnet die Fakultät von i. i muss positiv sein, ansonsten wird eine22 // Ausnahme vom Typ UngueltigesArgument ausgeworfen.23 virtual int berechne (int i)24 throw (UngueltigesArgument);25 };2627 class FakultaetMitDateiausgabe : public Fakultaet28 {29 public :30 // Berechnet die Fakultät von i und speichert den errechneten Wert in einer Datei ab.31 // Wenn das Schreiben in die Datei fehlschlägt, wird eine Ausnahme vom Typ DateiFehler32 // ausgeworfen.33 virtual int berechne (int i) // Redefinition34 throw (UngueltigesArgument, DateiFehler);35 };
218
fehlende Ausnahme-Spezifikation
Ausnahme-Spezifikationen und Spezialisierung
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
In diesem Beispiel ist der Quelltext der Methoden der Übersichtlichkeit halber weggelassen worden. Er spielt aber auch keine Rolle in unseren Betrachtungen.
Schauen wir uns einmal die Methode berechne in beiden Klassen an. In der Basisklasse Fakultaet enthält die Ausnahme-Spezifikation die Ausnahme-Klasse UngueltigesArgument, in der abgeleiteten Klasse FakultaetMitDateiausgabe zusätzlich den Typ DateiFehler. Nun überlegen Sie einmal, wie sich diese erweiterte Ausnahme-Spezifikation in der abgeleiteten Klasse auf Klienten auswirkt, die bisher nur mit Objekten der Basisklasse Fakultaet gearbeitet haben. Sicherlich gehen diese davon aus, dass nur Ausnahmen vom Typ UngueltigesArgument ausgeworfen werden. Vielleicht behandeln sie diese Ausnahmen mit einem try/catch-Konstrukt ähnlich dem folgenden:
36 // wirft keine Ausnahmen aus!37 void fakultaetKlient (Fakultaet &f, int i) throw ()38 {39 try40 {41 int ergebnis = f.berechne (i);42 cout << i << "! = " << ergebnis << endl;43 }44 catch (const UngueltigesArgument &e)45 {46 cout << "Ausnahme: " << e.what () << endl;47 }48 }
Diese Funktion hat bisher erfolgreich mit Fakultaet-Objekten gearbeitet. Aber was passiert, wenn ein FakultaetMitDateiausgabe-Objekt übergeben wird? Wenn die Dateiausgabe problemlos funktioniert, ist alles in Ordnung. Wenn sie aber fehlschlägt (aus welchen Gründen auch immer), hat fakultaetKlient ein riesiges Problem: Die Funktion behandelt die Ausnahme vom Typ DateiFehler nicht. Deshalb wird sie an den nächsten Ausnahme-Behandler weitergereicht. Dadurch aber, dass fakultaetKlient eine leere Ausnahme-Spezifikation besitzt und damit effektiv aussagt, keine Ausnahmen an seinen Aufrufer weiterzureichen, wird die C++-Laufzeit-Umgebung das Programm via die unexpected/terminate-Kette auf schnellstem Wege beenden.
Deshalb ist bei der Implementierung von Operationen bzw. der Redefinition von Methoden darauf zu achten, dass die Ausnahme-Spezifikation der spezielleren Operation/Methode nicht mehr mögliche Ausnahme-Typen umfasst als diejenige der allgemeineren Operation/Methode. Diese Regel verbietet die obige Redefinition. Sie verbietet auch, dass die what-Methode der Klasse UngueltigesArgument oder der Klasse DateiFehler mit einer nicht leeren Ausnahme-Spezifikation versehen wird. Da exception::what eine leere Ausnahme-Spezifikation besitzt und wir gemäß der Regel in spezielleren Klassen keine Ausnahme-Typen zur Ausnahme-Spezifikation hinzufügen dürfen, muss die Ausnahme-Spezifikation der Methode what in von exception abgeleiteten Klassen leer bleiben.
219
speziellere Methoden dürfen nicht mehr Ausnahmen werfen
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
Beachten Sie, dass der umgekehrte Fall von der Regel nicht verboten wird: Eine speziellere Operation/Methode kann durchaus weniger Ausnahme-Typen in ihrer Ausnahme-Spezifikation auflisten als die Operation/Methode der allgemeineren Klasse. Denken Sie ruhig darüber nach, und versuchen Sie Beispiele zu finden, um sich die Gültigkeit des Substitutionsprinzips in solchen Fällen klar zu machen.
5.8 Ausnahme-Sicherheit und warum sie so wichtig istIn diesem Abschnitt wollen wir untersuchen, was Ausnahmesicherheit bedeutet und wann ein Programm (oder ein Programm-Teil, etwa eine Methode oder eine Klasse) ausnahmesicher ist. Wir betrachten im Folgenden Funktionen, die gewonnenen Erkenntnisse können jedoch problemlos auf Methoden, Klassen und ganze Programme ausgeweitet werden. Wir können im Rahmen dieses Skripts dieses Thema nur kurz anschneiden; für eine detaillierte Diskussion ist [Sutt00] sehr zu empfehlen.
Generell gibt es drei Garantien, die eine Funktion ihren Klienten zusichern kann54, wobei jede Garantie die vorherigen Garantien einschließt:
(1) Grundlegende Garantie: Auch in der Gegenwart von Ausnahmen verursacht die Funktion keine Ressourcen-Lecks. Dies bedeutet, dass Ausnahmen nicht zu irreparablen Fehlern und letztlich zu einem Programmabsturz führen.
(2) Hohe Garantie: Tritt während der Abarbeitung der Funktion eine Ausnahme auf, so stellt die Funktion sicher, dass sich der Zustand des Programms nicht verändert. Das ist eine andere Formulierung des „Ganz-oder-gar-nicht“-Prinzips: Entweder führt die Funktion alle ihre Aufgaben erfolgreich durch oder gar keine. „Halbe“ Erfolge existieren nicht.55
(3) Absolute Garantie (oder „nothrow“-Garantie): Die Funktion wirft unter keinen Umständen eine Ausnahme aus.
Schauen wir uns dazu einige Beispiele an:1 void tuEtwasAnderes (int &i);2 void tuEtwas ()3 {4 int *i = new int (0);5 tuEtwasAnderes (*i);6 delete i;7 }
Die Funktion tuEtwas erfüllt aller Wahrscheinlichkeit nicht einmal die grundlegende Garantie. Wenn während der Abarbeitung der Funktion tuEtwasAnderes eine Ausnahme auftritt, wird der Speicherbereich der int-Variable, die in Zeile 4 erzeugt und initialisiert wird, nicht freigegeben. Somit entsteht ein Ressourcen-Leck.
1 void tuEtwasAnderes (int &i);2 void tuEtwas ()3 {4 int i = 0;5 tuEtwasAnderes (i);6 }
54) vgl. [Sutt00]55) Für Leser, die sich mit Transaktionen auskennen: Die hohe Garantie ist vergleichbar mit der For
derung nach Atomarität (Atomicity) für Transaktionen.
220
speziellere Methoden dürfen weniger Ausnahmen werfen
Ausnahme-Sicherheit und Ausnahme-Garantien
die drei Ausnahme-Garantien
grundlegende Garantie
hohe Garantie
absolute Garantie
Beispiele
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
Die Funktion benutzt jetzt keinen dynamischen Speicher mehr. Jetzt hängt die Garantie, die tuEtwas zusichern kann, von tuEtwasAnderes ab:
• Wenn tuEtwasAnderes die hohe Garantie zusichert, dann sichert tuEtwas diese auch zu. Das liegt daran, das tuEtwas nicht wirklich etwas am Programmzustand ändert, sondern die ganze Arbeit tuEtwasAnderes überlässt.
• Wenn tuEtwasAnderes die absolute Garantie zusichert, dann sichert tuEtwas diese auch zu. Das liegt daran, dass tuEtwas selbst keine Ausnahmen auswirft; wenn tuEtwasAnderes dies auch nicht tut, erfüllen beide Funktionen die absolute Garantie.
An diesem Beispiel ist zu erkennen, dass die zugesicherte Ausnahme-Garantie durchaus von den Garantien benutzter Programm-Teile abhängt.
Schließlich noch ein Beispiel für das Einhalten der grundlegenden (aber nicht hohen) Garantie:
1 void tuEtwasAnderes (int &i);2 void tuEtwas ()3 {4 for (int i = 0; i < 10; ++i)5 tuEtwasAnderes (i);6 }
Hier erfüllt tuEtwas nur die grundlegende Garantie, falls tuEtwasAnderes dies tut und zu beliebiger Zeit eine Ausnahme auswerfen kann. Stellen Sie sich vor, beim sechsten Aufruf (i==6) wirft tuEtwasAnderes eine Ausnahme aus. Dann werden die fünf vorherigen und erfolgreichen Aufrufe in ihrer Wirkung aber nicht rückgängig gemacht. Das Ganz-oder-gar-nicht-Prinzip gilt also nicht. Erfüllt jedoch tuEtwasAnderes mindestens die grundlegende Garantie, gilt dies auch für tuEtwas.
Wozu untersuchen wir überhaupt diese Garantien? Nun, wenn Sie Ausnahmen in Ihrem Programm einsetzen – und Einsetzen bedeutet auch, andere Programm-Komponenten zu benutzen, die Ausnahmen einsetzen –, dann müssen Sie sich mit den Garantien herumschlagen. Denn nur wenn alle Ihre Funktionen, Methoden, Klassen etc. die grundlegende Garantie erfüllen, ist Ihr Programm ausnahmesicher. Und Ausnahmesicherheit ist Ihr wichtigstes Ziel. Denn nur dann haben Sie Gewissheit, dass Ihr Programm auch unter unvorhergesehenen Umständen nicht wichtige Ressourcen verliert und den Programmzustand durcheinander bringt.
Die hohe Garantie ist natürlich besser als die grundlegende, allerdings häufig mit einem erhöhten Aufwand verbunden, sowohl in der Implementierung als auch in Analyse und Entwurf. Deshalb ist hier in der Regel eine Kosten-Nutzen-Analyse sehr hilfreich.
Merksatz 36: Stelle zumindest die grundlegende Ausnahme-Garantie sicher!
221
Ausnahme-Garantie ist abhängig von benutzen Programm-Teilen
Einhaltung der grundlegenden Ausnahme-Garantie notwendig
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
5.9 Ausnahmen und RAIIAbschnitt 4.5.2 hat bereits angedeutet, dass RAII und Ausnahmesicherheit eine natürliche Symbiose eingehen. Warum dies so ist, wollen wir in diesem Abschnitt untersuchen. Wir schauen uns dazu das Beispiel aus Abschnitt 4.5.2 noch einmal leicht abgewandelt an:
1 /*** Beispiel except_raii.cpp ***/2 #include <ostream>3 #include <iostream>4 #include <string>5 using namespace std;67 // wird ausgeworfen, wenn das Öffnen der Datei fehlschlägt8 class FileOpenException9 {
10 };1112 class File13 {14 public :15 // Konstruktor: öffnet die angegebene Datei16 // wirft eine FileOpenException-Ausnahme aus, wenn das Öffnen fehlschlägt17 File (string name);1819 // Destruktor, schließt die geöffnete Datei20 ~File ();2122 // liest „count“ Zeichen aus der zuvor geöffneten Datei23 string read (int count);2425 // schreibt die Zeichenkette „data“ in die zuvor geöffnete Datei26 void write (string data);2728 private :29 // von der Implementierung benötigte Attribute3031 // öffnet die angegebene Datei; liefert true bei Erfolg und false bei Misserfolg zurück32 bool open (string name);3334 // schließt die zuvor geöffnete Datei35 void close ();36 };3738 File::File (string name)39 {40 // öffne Datei; wenn das fehlschlägt, wirf eine Ausnahme aus41 if (!open (name))42 throw FileOpenException ();43 }4445 File::~File ()46 {47 // schließe die Datei und gib Ressourcen frei48 close ();49 }5051 int main ()52 {53 try
222
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
54 {55 File myFile ("myfile.dat");56 File myFile2 ("myfile2.dat");57 string acht = myFile.read (4) + myFile2.read (4);58 cout << "die ersten 4 Zeichen aus beiden Dateien: "59 << acht << endl;60 // hier endet der Gültigkeitsbereich von myFile und myFile2; dabei werden61 // automatisch die Destruktoren aufgerufen und die geöffneten Dateien geschlossen62 }63 catch (FileOpenException)64 {65 // wenn wir hier ankommen, hat das Öffnen der Datei nicht geklappt66 cout << "Konnte Datei nicht öffnen!" << endl;67 }68 return 0;69 }
Die Änderung findet sich in den Zeilen 55-57: Wo vorher nur ein File-Objekt initialisiert wurde, sind es jetzt deren zwei. Diese kleine Änderung hat aber eine große Auswirkung auf die Ausnahmesicherheit, wie wir gleich sehen werden.
Stellen wir uns einmal vor, dass die Datei myfile.dat in Zeile 55 erfolgreich geöffnet werden kann. Jetzt versucht unser Programm in Zeile 56, die Datei myfile2.dat zu öffnen. Wir wissen, dass eine Ausnahme vom Typ FileOpenException ausgeworfen wird, wenn das Öffnen fehlschlägt. Die Preisfrage ist nun: Was passiert nun mit der bereits geöffneten Datei myfile.dat?
Wenn die Ausnahme vom Konstruktor der Klasse File in Zeile 42 ausgeworfen wird, ist der einzige catch-Block, der als Ausnahme-Behandler in Frage kommt, derjenige in den Zeilen 63-67. Der Gültigkeitsbereich des File-Objekts myFile in Zeile 55 ist jedoch bereits in Zeile 62 zu Ende. Das bedeutet, dass das Objekt myFile zum Zeitpunkt der Ausnahme-Behandlung zerstört worden sein muss.
Das Zerstören eines File-Objekts ist jedoch untrennbar mit der Ausführung des File-Destruktors verbunden. Dieser schließt die Datei ordnungsgemäß. Können wir also davon ausgehen, dass die Datei myfile.dat zum Zeitpunkt der Ausnahme-Behandlung in den Zeilen 63-67 korrekt geschlossen worden ist?
Das können wir in der Tat. C++ garantiert, dass alle lokalen Objekte, die durch das Auswerfen und Behandeln einer Ausnahme ihre Gültigkeit verlieren, ordnungsgemäß zerstört werden, d. h. dass für jedes dieser Objekte der zugehörige Destruktor aufgerufen wird. Die betreffenden Objekte sind dabei alle jene, die seit Beginn des try-Blocks erzeugt und noch nicht zerstört worden sind. In unserem Fall betrifft dies nur das Objekt myFile, das in Zeile 55 erstellt und initialisiert wird. Objekte außerhalb des try-Blocks sind entweder noch gültig (dies wäre für lokale Objekte der Fall, die vor dem Beginn des try-Blocks definiert sind) oder noch nicht gültig (dies gilt für alle lokalen Objekte hinter dem letzten, zum try-Block gehörenden catch-Block).
Sie sehen also, dass das RAII-Idiom Ihnen hilft, die hohe Ausnahme-Garantie zu erfüllen. Denn wenn der Destruktor alles rückgängig macht, was der Konstruktor getan hat, dann werden bei einer Ausnahme alle Effekte zurückgenommen, die zwischen dem Beginn des try-Blocks und dem Auswerfen einer Ausnahme durchgeführt
223
Ausnahmen und lokale Objekte
Ausnahmen zerstören ungültig gewordene lokale Objekte
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
wurden. Somit erlaubt Ihnen RAII, sich auf den ausnahmesicheren Entwurf Ihrer Klassen mit passenden Konstruktoren und Destruktoren zu konzentrieren; den Rest erledigt der Übersetzer und die Laufzeit-Umgebung.
Nun fragen Sie sich sicherlich, warum die Betonung auf lokalen Objekten liegt. Nun, die Antwort ist einfach: Wenn Sie dynamische Objekte verwenden, werden diese nicht zerstört, was zu Ressourcen-Lecks führt! Betrachten Sie einmal das obige Beispiel derart abgewandelt, dass dynamische Objekte und nicht lokale Objekte verwendet werden:
53 try54 {55 File *myFile = new File ("myfile.dat");56 File *myFile2 = new File ("myfile2.dat");57 string acht = myFile->read (4) + myFile2->read (4);58 cout << "die ersten 4 Zeichen aus beiden Dateien: "59 << acht << endl;60 // hier endet der Gültigkeitsbereich von myFile und myFile261 }62 catch (FileOpenException)63 {64 // wenn wir hier ankommen, hat das Öffnen der Datei nicht geklappt65 cout << "Konnte Datei nicht öffnen!" << endl;66 }
Jetzt werden die Objekte in den Zeilen 55 und 56 über den Operator new im dynamisch erzeugt. Wo genau sind jetzt die Unterschiede?
Der wichtigste und fatalste ist der, dass die erzeugten Objekte nicht ordnungsgemäß zerstört werden. Woran liegt das? Wenn der Gültigkeitsbereich von myFile und myFile2 in Zeile 61 verlassen wird, werden diese Variablen zerstört. Dummerweise sind diese Variablen keine File-Objekte, sondern Zeiger auf File-Objekte! Wenn jedoch ein Zeiger zerstört wird, wird das Objekt „hinter“ dem Zeiger nicht zerstört. Dies muss auch so sein, schließlich können in einem Programm durchaus mehrere Zeiger auf dasselbe Objekt verweisen.56 Dies führt jedoch in diesem Fall zu Ressourcen-Lecks, weil Sie in Ihrem Programm nie mehr an die beiden File-Objekte herankommen können. Ihre Dateien werden somit eventuell nicht korrekt geschlossen.57 Und wenn schon ein normaler Programmablauf (d. h. einer ohne Ausnahmen) zu Ressourcen-Lecks führt, sollte klar sein, dass Ausnahmen die Situation nicht gerade verbessern.
Nun können Sie natürlich Ihren Code so umschreiben, dass explizit der Operator delete aufgerufen wird:
53 try54 {55 File *myFile = new File ("myfile.dat");56 File *myFile2 = new File ("myfile2.dat");57 string acht = myFile->read (4) + myFile2->read (4);58 cout << "die ersten 4 Zeichen aus beiden Dateien: "59 << acht << endl;
56) Hinzu kommt, dass es in C++ keine eingebaute „Garbage Collection“ gibt, die unbenutzte Objekte findet und freigibt.
57) Die meisten Betriebssysteme schließen nach Programmende alle noch offenen Dateien; darauf kann man sich aber in einem portablen C++-Programm nicht verlassen.
224
Benutzung von dynamischen Objekten ist problematisch
delete allein hilft nicht!
Objektorientiertes C++ für Einsteiger Fehler erkennen und behandeln
60 delete myFile2;61 delete myFile;62 // hier endet der Gültigkeitsbereich von myFile und myFile263 }64 catch (FileOpenException)65 {66 // wenn wir hier ankommen, hat das Öffnen der Datei nicht geklappt67 cout << "Konnte Datei nicht öffnen!" << endl;68 }
Jetzt funktioniert Ihr Programm im Ausnahme-losen Fall korrekt. Es ist aber nicht ausnahmesicher. Betrachten Sie wieder Zeile 56: Wenn die Initialisierung des dort erzeugten File-Objekts fehlschlägt, wird das Objekt, auf dass myFile verweist (Zeile 55) nie freigegeben, weil der entsprechende delete-Aufruf in Zeile 61 nicht ausgeführt wird. Somit wird nicht einmal die grundlegende Garantie erfüllt.
Natürlich wäre es am Einfachsten, Ihnen zu raten, sich auf lokale Objekte zu beschränken. Allerdings ist das nicht immer möglich. Deshalb muss es eine weitere Lösung für das Problem geben. Und die gibt es in der Tat: Wir müssen nur sicherstellen, dass das Objekt, auf das ein Zeiger zeigt, bei der Zerstörung des Zeigers auch zerstört wird. Für genau diesen Fall gibt es die Klasse auto_ptr in der C++-Standard-Bibliothek. Diese Klasse ist quasi ein intelligenter Zeiger-Ersatz und stellt in ihrem Destruktor sicher, dass das Objekt, auf das verwiesen wird, ebenfalls zerstört wird.
Das obige Beispiel schreibt sich nun folgendermaßen:53 try54 {55 auto_ptr<File> myFile (new File ("myfile.dat"));56 auto_ptr<File> myFile2 (new File ("myfile2.dat"));57 string acht = myFile->read (4) + myFile2->read (4);58 cout << "die ersten 4 Zeichen aus beiden Dateien: "59 << acht << endl;60 // hier endet der Gültigkeitsbereich von myFile und myFile2; die Destruktoren der61 // auto_ptr-Klasse kümmern sich um die Zerstörung der assoziierten File-Objekte62 }63 catch (FileOpenException)64 {65 // wenn wir hier ankommen, hat das Öffnen der Datei nicht geklappt66 cout << "Konnte Datei nicht öffnen!" << endl;67 }
Sie sehen in den Zeilen 55 und 56, dass die File-Verweise nicht mehr vom Typ File *, sondern vom Typ auto_ptr<File> sind. Die Klasse auto_ptr ist nämlich gar keine „richtige“ Klasse, sondern eine Schablone (7.2) und als solche für viele Typen anwendbar. Die genaue Syntax können wir hier nicht weiter erläutern; hier ist es nur wichtig, dass dadurch eine Art intelligenter Zeiger auf ein File-Objekt gemeint ist. Weiterhin müssen Sie noch die Header-Datei <memory> einbinden, um auto_ptr verwenden zu können.
Nun ist Ihr Programm ausnahmesicher, denn sowohl bei einem normalen Programmablauf als auch bei der Existenz von Ausnahmen werden die Destruktoren der File-Objekte ausgeführt und die Ressourcen ordnungsgemäß freigegeben. Zu bemerken ist hier noch abschließend, dass die Ausnahmesicherheit durch die Anwen
225
intelligente Zeiger helfen hier
Fehler erkennen und behandeln Objektorientiertes C++ für Einsteiger
dung des RAII-Idoms hergestellt worden ist, und zwar in der auto_ptr-Schablone. RAII ist also immer wieder zum Erreichen von Ausnahmesicherheit nützlich.
Was lernen wir daraus? Ausnahmesicherheit gehört wohlüberlegt; es reicht nicht aus, einfach ein paar try/catch-Blöcke in den Quelltext zu schreiben. Es ist essentiell, sich damit auseinanderzusetzen, welche Ressourcen wo angefordert und wo freigegeben werden und ob das alles auch ordnungsgemäß funktioniert, wenn Ausnahmen ins Spiel kommen. Generell dürfen Sie davon ausgehen, dass das Schreiben ausnahmesicherer Programm sicherlich etwas komplexer ist als das Schreiben von Programmen, die sich nicht um Ausnahmesicherheit scheren. Allerdings erspart Ihnen das RAII-Idiom viel Arbeit und Hirnschmalz, und die Zeit, die Sie in Ausnahmesicherheit investieren, wird der Qualität Ihrer Programme doppelt und dreifach zugute kommen.
Merksatz 37: Verwende RAII in ausnahmesicheren Programmen!
Eine wichtige Sache zum Schluss: Stellen Sie in Ihren Programmen sicher, dass ein Destruktor niemals eine Ausnahme auswirft! Das erschöpfend zu erklären sprengt den Rahmen dieses Skripts58, soviel aber dazu: Wenn eine Ausnahme ausgeworfen, aber noch nicht behandelt wurde, werden die Destruktoren aller ungültig werdender lokaler Objekte aufgerufen (s. o.) Wenn aber während der Ausführung eines solchen Destruktors erneut eine Ausnahme ausgeworfen wird, haben wir eine Ausnahmesituation innerhalb einer Ausnahmesituation. Die C++-Sprache fordert, dass in einem solchen Fall das Programm umgehend terminiert wird. Verbannen Sie also unbedingt Ausnahmen aus Destruktoren!
Merksatz 38: Stelle sicher, dass kein Destruktor jemals eine Ausnahme wirft!
58) Sie können Details in [Sutt00] und [Sutt02] nachlesen.
226
RAII und Ausnahme-Sicherheit gehen Hand in Hand
Destruktoren und Ausnahmen
Objektorientiertes C++ für Einsteiger Entwurfsmuster
6 EntwurfsmusterWird in einer späteren Version des Skripts ergänzt
6.1 EinführungWird in einer späteren Version des Skripts ergänzt
6.2 Strukturelle MusterWird in einer späteren Version des Skripts ergänzt
6.2.1 CompositeWird in einer späteren Version des Skripts ergänzt
6.2.2 DecoratorWird in einer späteren Version des Skripts ergänzt
6.2.3 ProxyWird in einer späteren Version des Skripts ergänzt
6.3 VerhaltensmusterWird in einer späteren Version des Skripts ergänzt
6.3.1 Template MethodWird in einer späteren Version des Skripts ergänzt
6.3.2 IteratorWird in einer späteren Version des Skripts ergänzt
6.3.3 ObserverWird in einer späteren Version des Skripts ergänzt
6.3.4 StrategyWird in einer späteren Version des Skripts ergänzt
6.3.5 StateWird in einer späteren Version des Skripts ergänzt
6.3.6 CommandWird in einer späteren Version des Skripts ergänzt
6.4 ErzeugungsmusterWird in einer späteren Version des Skripts ergänzt
227
Entwurfsmuster Objektorientiertes C++ für Einsteiger
6.4.1 Abstract FactoryWird in einer späteren Version des Skripts ergänzt
6.4.2 SingletonWird in einer späteren Version des Skripts ergänzt
228
Objektorientiertes C++ für Einsteiger Überladung und Schablonen
7 Überladung und SchablonenWird in einer späteren Version des Skripts ergänzt
7.1 ÜberladungWird in einer späteren Version des Skripts ergänzt
7.1.1 Überladung von FunktionenWird in einer späteren Version des Skripts ergänzt
7.1.2 Überladung von Operationen und MethodenWird in einer späteren Version des Skripts ergänzt
7.1.3 Überladung von OperatorenWird in einer späteren Version des Skripts ergänzt
7.2 Schablonen (Templates)Wird in einer späteren Version des Skripts ergänzt
229
Objektorientiertes C++ für Einsteiger Die Standard-Bibliothek
8 Die Standard-BibliothekWird in einer späteren Version des Skripts ergänzt
8.1 EinführungWird in einer späteren Version des Skripts ergänzt
8.2 NamensräumeWird in einer späteren Version des Skripts ergänzt
8.3 DatenstrukturenWird in einer späteren Version des Skripts ergänzt
8.4 AlgorithmenWird in einer späteren Version des Skripts ergänzt
8.5 Ein-/AusgabeWird in einer späteren Version des Skripts ergänzt
231
Objektorientiertes C++ für Einsteiger
MerksätzeMerksatz 1: Jedes C++-Programm ist eine Folge von Deklarationen!............................17Merksatz 2: Kommentiere so gut du kannst!...................................................................21Merksatz 3: Für jede verwendete Entität gibt es genau eine Definition!........................ 28Merksatz 4: Tue Schnittstellen und Implementierung in verschiedene Dateien!............ 31Merksatz 5: Verwende niemals nicht initialisierte Variablen!........................................ 33Merksatz 6: Mach dich möglichst nicht von lokalen Eigenheiten abhängig!..................46Merksatz 7: Vermeide den unkontrollierten Umgang mit Zeigern!................................ 60Merksatz 8: Verwende symbolische Konstanten!........................................................... 63Merksatz 9: Meide gefährliche Typ-Umwandlungen!.................................................... 65Merksatz 10: Verwende Abstraktionen!..........................................................................69Merksatz 11: Verwende ausführliche Funktionskommentare!........................................83Merksatz 12: Überlege gut den Einsatz von rekursiven Funktionen!............................. 91Merksatz 13: Überlege gut den Einsatz von Schleifen!.................................................. 91Merksatz 14: Verwende nie öffentliche Attribute!........................................................122Merksatz 15: Verwende const bei beobachtenden Operationen!.................................. 123Merksatz 16: Teste viel und ausführlich!...................................................................... 126Merksatz 17: Verwende Namensräume zur Modularisierung!......................................130Merksatz 18: Verwende eine einheitliche Reihenfolge bei Attributen!........................ 134Merksatz 19: Betrachte Konstruktoren und Destruktor als Team!................................137Merksatz 20: Verwende Destruktoren zur Ressourcen-Freigabe!.................................142Merksatz 21: Betrachte Kopierkonstruktor und Zuweisungsoperator als Team!..........148Merksatz 22: Vermeide Null-Zeiger, wo es nur geht!...................................................154Merksatz 23: Verwende Polymorphie anstatt Fallunterscheidungen!........................... 170Merksatz 24: Achte bei der Redefinition einer Methode auf Typ-Gleichheit!..............174Merksatz 25: Nutze kovariante Rückgabetypen, um Casts zu vermeiden!................... 175Merksatz 26: Verwende virtuelle Destruktoren bei Basisklassen!................................ 185Merksatz 27: Vermeide den Einsatz virtueller Basisklassen!........................................192Merksatz 28: Einmal virtuelle Basisklasse, immer virtuelle Basisklasse!.................... 192Merksatz 29: Vermeide konkrete virtuelle Basisklassen!............................................. 196Merksatz 30: Korrektheit einer Spezialisierung ist vom Verhalten abhängig!..............201Merksatz 31: Bedenke den Einsatz von Vererbung!..................................................... 201Merksatz 32: Vermeide leere catch-Blöcke!................................................................. 209Merksatz 33: Nutze Referenzen bei Ausnahme-Parametern!........................................215Merksatz 34: Ordne Ausnahme-Behandler vom speziellsten zum allgemeinsten!....... 215Merksatz 35: Benutze Ausnahme-Spezifikationen mit Bedacht!..................................217Merksatz 36: Stelle zumindest die grundlegende Ausnahme-Garantie sicher!............. 221Merksatz 37: Verwende RAII in ausnahmesicheren Programmen!.............................. 226Merksatz 38: Stelle sicher, dass kein Destruktor jemals eine Ausnahme wirft!........... 226
232
Objektorientiertes C++ für Einsteiger
BeispieleBeispiel "hello"................................................................................................................10Beispiel "kommentar"......................................................................................................20Beispiel "fakultaet1"........................................................................................................20Beispiel "zeichenketten1"................................................................................................26Beispiel "zeichenketten2"................................................................................................27Beispiel "antwort1"..........................................................................................................29Beispiel "antwort2"..........................................................................................................29Beispiel "antwort3"..........................................................................................................31Beispiel "sichtbarkeit"..................................................................................................... 35Beispiel "ausdruck"......................................................................................................... 40Beispiel "incdec"............................................................................................................. 43Beispiel "incdec"............................................................................................................. 43Beispiel "ziffern"............................................................................................................. 46Beispiel "if1"................................................................................................................... 51Beispiel "fliesskomma"................................................................................................... 53Beispiel "void".................................................................................................................55Beispiel "funktionstyp1"..................................................................................................56Beispiel "ref"................................................................................................................... 57Beispiel "ptr"................................................................................................................... 59Beispiel "feld"..................................................................................................................60Beispiel "enum"............................................................................................................... 61Beispiel "funktionstyp2"..................................................................................................68Beispiel "if2"................................................................................................................... 71Beispiel "while"............................................................................................................... 75Beispiel "do"....................................................................................................................76Beispiel "for1"................................................................................................................. 78Beispiel "for2"................................................................................................................. 78Beispiel "aufruf".............................................................................................................. 85Beispiel "param1"............................................................................................................86Beispiel "param2"............................................................................................................87Beispiel "param3"............................................................................................................88Beispiel "fakultaet2"........................................................................................................88Beispiel "oohello"..........................................................................................................105Beispiel "queue1".......................................................................................................... 116Beispiel "const1"........................................................................................................... 123Beispiel "const2"........................................................................................................... 124Beispiel "const3"........................................................................................................... 126Beispiel "ctor"................................................................................................................132Beispiel "file1"...............................................................................................................137Beispiel "file2"...............................................................................................................139Beispiel "copyctor"........................................................................................................144Beispiel "tempobj".........................................................................................................149Beispiel "newdelete"......................................................................................................151Beispiel "stapel".............................................................................................................157
233
Objektorientiertes C++ für Einsteiger
Beispiel "graphobj1"......................................................................................................163Beispiel "kovarianz"...................................................................................................... 174Beispiel "virtual"........................................................................................................... 179Beispiel "dtor"............................................................................................................... 184Beispiel "multi"............................................................................................................. 186Beispiel "except1"......................................................................................................... 204Beispiel "except2"......................................................................................................... 205Beispiel "rethrow1"....................................................................................................... 213Beispiel "except_raii".................................................................................................... 222
234
Objektorientiertes C++ für Einsteiger
Literaturverzeichnis[Dijk68] Dijkstra, Edsger W.: Go-to statement considered harmful, 1968, http://ww
w.cs.utexas.edu/users/EWD/ewd02xx/EWD215.PDF, Abrufzeitpunkt: 03.06.2005
[Ellis90] Ellis, Margaret A.; Stroustrup, Bjarne: The Annotated C++ Reference Manual, , Addison-Wesley, 1990
[GHJV95] Gamma, Erich; Helm, Richard; Johnson, Ralph; Vlissides, John: Design Patterns, Reissue, Addison-Wesley, 1995
[Josu01] Josuttis, Nicolai: Objektorientiertes Programmieren in C++, 2. Auflage, Addison-Wesley, 2001
[Mart96] Martin, Robert C.: The Liskov Substitution Principle, 1996, http://www.objectmentor.com/resources/articles/lsp.pdf, Abrufzeitpunkt: 18.08.2005
[Meye98] Meyers, Scott: Effektiv C++ programmieren, , Addison-Wesley, 1998[Meye99] Meyers, Scott: Mehr Effektiv C++ programmieren, , Addison-Wesley, 1999[Oest01] Oestereich, Bernd: Objektorientierte Softwareentwicklung, 5. Auflage, Ol
denbourg, 2001[Strou00] Stroustrup, Bjarne: Die C++-Programmiersprache, 4. Auflage, Addison-
Wesley, 2000[Sutt00] Sutter, Herb: Exceptional C++, , Addison-Wesley, 2000[Sutt02] Sutter, Herb: More Exceptional C++, , Addison-Wesley, 2002[Zell03] Zeller, Andreas: Causes and Effects in Computer Programs, 2003,
http://www.st.cs.uni-sb.de/papers/aadebug2003/aadebug.pdf, Abrufzeitpunkt: 16.01.2004
235