This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Liebe Leserin, lieber Leser, ich freue mich, Ihnen dieses Kompendium zu Visual C++ 2010 vorstellen zu dürfen. Eine solche umfassende Behandlung des Themas, wie es dieses Buch bietet, ist einfach notwendig, denn dank Visual C++ 2010 können Sie die Vorteile von C++ auch unter .NET nutzen. Einer davon springt besonders ins Auge: C++ ist nicht an .NET gebunden. Es können auch C++-Programme auf anderen Systemen geschrieben werden. Der erste Teil des Buches, ANSI C++, richtet sich vor allem an Programmierer ohne C++-Kenntnisse: Sie erhalten eine Einführung in die Sprache, angefangen bei den Variablen über Klassen und Vererbung bis hin zu den Templates. Aber auch wenn Sie schon Erfahrung mit C++ haben, möchten Sie vielleicht das ein oder andere zu den Grundlagen noch einmal nachlesen. Wenn Sie schon mit C++ programmiert haben, werden Sie sich vor allem für die Erweiterungen und Änderungen der Sprache interessieren, die unter .NET notwendig sind. Dieses Thema wird im zweiten Teil des Buches, C++/CLI, behandelt. Hier wird auf die Auswirkungen auf die Klassen und die Vererbung eingegangen und es werden u.a. die Dateiverwaltung, Delegaten und Ereignisse sowie Collections behandelt. Im dritten Teil wird ausführlich auf die Programmierung grafischer Oberflächen eingegangen. Hier finden Sie alles Wichtige zu Windows Forms, zur Entwicklung von Steuerelementen, Menüleisten und Kontextmenüs und zum Zeichnen mit GDI+. Außerdem wird gezeigt, wie Sie eine oder mehrere Seiten drucken, und auch die Datenbankanbindung kommt nicht zu kurz. Dieses Buch wurde mit großer Sorgfalt begutachtet, lektoriert und produziert. Sollten sich dennoch Fehler eingeschlichen haben oder Fragen auftreten, zögern Sie nicht, mit uns Kontakt aufzunehmen. Sagen Sie uns, was wir noch besser machen können. Ihre Anregungen und Fragen sind uns jederzeit willkommen.
Gerne stehen wir Ihnen mit Rat und Tat zur Seite: [email protected] bei Fragen und Anmerkungen zum Inhalt des Buches [email protected] für versandkostenfreie Bestellungen und Reklamationen [email protected] für Rezensions- und Schulungsexemplare
Bibliografische Information der Deutschen Nationalbibliothek Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar. ISBN
Die eigene Liste ........................................................................... 7.1.1 Konstruktoren und Destruktoren .................................... 7.1.2 Hilfsmethoden ............................................................... 7.1.3 Öffentliche Methoden .................................................... 7.1.4 Iteratoren ....................................................................... Die Klasse »Kontakt« ................................................................... 7.2.1 Die Klassendefinition ..................................................... 7.2.2 Die Methoden ............................................................... Die Klasse »Kontaktliste« ............................................................. 7.3.1 Die Klassendefinition ..................................................... 7.3.2 Die Methoden ............................................................... Die Hauptfunktion .......................................................................
375 377 379 384 391 394 394 396 398 398 399 404
TEIL II C++/CLI 8
Grundlagen von C++/CLI .......................................................... 409 8.1 8.2
8.3
C++/CLI ....................................................................................... .NET ............................................................................................ 8.2.1 Common Language Runtime (CLR) ................................. 8.2.2 Common Language Specification (CLS) ........................... 8.2.3 Common Type System (CTS) ........................................... 8.2.4 Klassenbibliothek ........................................................... 8.2.5 WPF ............................................................................... CLR-Konsolenanwendung ............................................................ 8.3.1 Die Projekt-Dateien .......................................................
Die Schnittstelle IKontakt ............................................................ Die Klasse »Kontakt« ................................................................... 12.2.1 Klassendefinition ............................................................ 12.2.2 Die externen Definitionen .............................................. Die Klasse »KontaktTxt« ............................................................... 12.3.1 Die Klassendefinition ..................................................... 12.3.2 Die externen Definitionen .............................................. Die Schnittstelle »ITelefonbuch« .................................................. Die Klasse »Telefonbuch« ............................................................. 12.5.1 Die Klassendefinition ..................................................... 12.5.2 Die externen Definitionen .............................................. Die Klasse »TelefonbuchTxt« ........................................................ 12.6.1 Die Klassendefinition ..................................................... 12.6.2 Die externen Definitionen .............................................. Das Hauptprogramm ................................................................... Datei-IO ...................................................................................... 12.8.1 Die angepasste IKontakt-Schnittstelle ............................ 12.8.2 Die angepasste Kontakt-Klasse ....................................... 12.8.3 Die angepasste ITelefonbuch-Schnittstelle ...................... 12.8.4 Die abstrakte Fabrik .......................................................
Die Schnittstelle »IKontaktFabrik« .................................. Die Klasse »KontaktFabrikTxt« ........................................ Die angepasste Klasse »Telefonbuch« ............................. Die angepasste Klasse »TelefonbuchTxt« ........................
584 584 584 586
TEIL III .NET-Klassenbibliothek 13 Einführung in Windows Forms ................................................ 591 13.1 13.2 13.3 13.4 13.5
13.6
13.7
13.8
Das Hauptprogramm ................................................................... Die Form-Datei ............................................................................ Das Eigenschaftenfenster des Designers ....................................... Component ................................................................................. Control – Basis aller Steuerlemente .............................................. 13.5.1 Öffentliche Eigenschaften ............................................... 13.5.2 Öffentliche Methoden .................................................... 13.5.3 Öffentliche Ereignisse ..................................................... ScrollableControl – scrollbare Container ....................................... 13.6.1 Öffentliche Eigenschaften ............................................... 13.6.2 Öffentliche Methoden .................................................... 13.6.3 Öffentliche Ereignisse ..................................................... Form – die Formularklasse ........................................................... 13.7.1 Öffentliche Eigenschaften ............................................... 13.7.2 Öffentliche Methoden .................................................... 13.7.3 Öffentliche Ereignisse ..................................................... Ereignisse im Designer ................................................................. 13.8.1 Handler hinzufügen ........................................................ 13.8.2 Handler entfernen ..........................................................
ToolStrip – die Symbolleiste ......................................................... 17.1.1 Eigenschaften ................................................................. MenuStrip – die Menüleiste ......................................................... StatusStrip – die Statusleiste ........................................................ ContextMenuStrip – das Kontextmenü ......................................... Die ToolStrip-Elemente ................................................................ 17.5.1 ToolStripItem – die Basis der ToolStrip-Elemente ........... 17.5.2 ToolStripButton – ein einfacher Button .......................... 17.5.3 ToolStripComboBox – eine Combobox für ToolStrips ...... 17.5.4 ToolStripDropDownButton – eine aufklappbare Schaltfläche ...............................................
751 752 753 754 755 755 755 757 758 758 19
Inhalt
17.5.5
17.6
ToolStripLabel, ToolStripStatusLabel – eine Leistenbeschriftung ........................................................ 17.5.6 ToolStripMenuItem – ein Menüpunkt ............................ 17.5.7 ToolStripProgressBar – der Fortschrittsbalken in der Leiste ................................................................... 17.5.8 ToolStripSeparator – der Spalter unter den Elementen ... 17.5.9 ToolStripSplitButton – die Kombischaltfläche ................. 17.5.10 ToolStripTextBox – die Textbox in der Leiste .................. ToolStripContainer – die Spielwiese für Leisten ............................
Anhang ........................................................................................... 881 A
Arbeiten mit der Entwicklungsumgebung ............................................... A.1 Der Debugger .............................................................................. A.1.1 Haltepunkte ................................................................... A.1.2 Schrittweise Abarbeitung ............................................... A.1.3 Komplexere Haltepunkte ................................................ A.2 Variablen überwachen ................................................................. A.3 Die Klassenansicht ....................................................................... A.3.1 Der Objektbrowser ........................................................ A.3.2 Die Aufrufhierarchie ....................................................... A.4 Der Klassendesigner .................................................................... A.4.1 Ein Klassendiagramm erstellen .......................................
883 883 885 887 888 890 891 894 894 895 896
Index ............................................................................................................ 899
22
Vorwort
VSchon wieder eine neue Version von Visual C++, aber Entwicklung ist das beste Anzeichen einer lebendigen Sprache. Zugegeben, ein Großteil der Entwicklung fand im Bereich des .NET-Frameworks statt, welches nun in der Version 4 vorliegt. Aber auch C++ hat einen Schritt nach vorn getan, der hauptsächlich in der Behebung von Fehlern der Vorgängerversion besteht. Im Vergleich zur Programmiersprache C#, die bei jeder neuen Version des Visual Studios mit zahlreichen Erweiterungen aufwartet, wird C++ scheinbar stiefmütterlich behandelt. Diese Betrachtung lässt allerdings die Entwicklungsgeschichte dieser beiden Sprachen außer Acht. Während C# speziell für die .NET-Programmierung entworfen wurde und deshalb naturgemäß auch mit dem .NET-Framework wächst und erweitert wird, ist C++ eine Sprache, deren Haupteinsatzgebiet außerhalb von .NET liegt und deren Standard auch nicht von Microsoft vorgeschrieben wird. Es ist daher sinnvoll, die unter C++/CLI notwendigen Ergänzungen für die .NET-Kompatibilität so gering wie möglich zu halten und stattdessen dem C++-Programmierer seine gewohnten Hilfsmittel bereitzustellen, wie z. B. die STL/CLR, das .NET-Pendant der C++-eigenen STL, welche in der nun vorliegenden Version besser denn je einsetzbar ist. Wozu C++?
Sie kennen wahrscheinlich das geflügelte Wort, dass man keiner Statistik trauen sollte, die man nicht selbst gefälscht hat. Trotzdem möchte ich hier einige Zahlen über die momentane Verbreitung (Stand November 2010) der Programmiersprachen nennen. Exemplarisch möchte ich hier den TIOBEIndex (zu finden unter www.tiobe.com) anbringen. Die prozentualen Werte anderer Rankings sind leicht abweichend, das globale Bild bleibt aber dasselbe. Demnach steht Java auf Platz 1 (18,5 %), Platz 2 belegt C (16,7 %), gefolgt von C++ (9,5 %) auf dem dritten Platz. C# findet sich mit 5,7 % auf Platz 5. Visual Basic .NET als dritte Programmiersprache des Visual Studios liegt mit 0,32 % weit abgeschlagen auf Rang 41. C und C++ gemeinsam betrachtet decken
23
Vorwort
mehr als ein Viertel der eingesetzten Programmiersprachen weltweit ab. Ein guter Grund zum Erlernen von C++. Oft entscheidet aber auch der Einsatzbereich. Während sich Java im Internet breitgemacht hat und auch in Handys und Bluray-Playern Verwendung findet, kommt C/C++ überwiegend dort zum Einsatz, wo es schnell gehen muss. Von Gerätetreibern über Signalverarbeitung in Echtzeit bis hin zu High-End 3D-Spielen. Auch sagt man, dass es einem C++-Programmierer leichter fällt, Java zu lernen als umgekehrt. Aber das mag auch nur ein Gerücht sein. Wozu C++/CLI?
Mit Erscheinen von Visual Studio 2010 und der darin enthaltenen Version von C++/CLI ist diese Frage klärungsbedürftiger denn je. Grundsätzlich ist C++/CLI ein C++-Dialekt, der notwendige Ergänzungen für die Kompatibilität mit dem .NET-Framework enthält. Manche behaupten, C++ und C++/CLI seien zwei völlig unterschiedliche Programmiersprachen, die nichts miteinander zu tun hätten. Technisch gesehen ist das korrekt, da das resultierende Kompilat in unterschiedliche Sprachen für unterschiedliche Laufzeitumgebungen übersetzt wird. Syntaktisch und semantisch sind diese Sprachen aber nahezu identisch, so dass es einem C++-Programmierer keine Mühe macht, C++/CLI zu erlernen. Aber die Frage bleibt: Wozu C++/CLI? Microsoft macht ganz klar, dass für die Programmierung von .NET-Applikationen die hauseigene Sprache C# bevorzugt werden sollte, welch’ Wunder. Die Tatsache, dass Intellisense, ein den Programmierer unterstützender Mechanismus, in der Version 2010 nur noch bei nativem C++ und nicht mehr bei C++/CLI vorhanden ist, scheint diese Empfehlung noch zu unterstützen. Hintergrund ist allerdings ein Redesign des Intellisense-Systems. Und weil für diese Version der Entwicklungsschwerpunkt auf anderen Dingen lag, gibt es Intellisense nur für natives C++. Ob es in einem Service Pack oder in einer zukünftigen Version auch wieder für C++/CLI bereitstehen wird, bleibt abzuwarten. Trotzdem kann in C++/CLI jedwede .NET-Applikation programmiert werden, auch wenn der Programmierer dabei nicht so tatkräftig von der Entwicklungsumgebung unterstützt wird, wie bei C# oder Visual Basic .NET. Dass eine .NET-Entwicklung auch für Microsoft nicht völlig abwegig ist, zeigen die Ergänzungen in C++/CLI, die speziell darauf abzielen, dass sich auch C++-Programmierer unter .NET wohlfühlen, wie z. B. die STL/CLR. Unschlagbar ist C++/CLI auf jeden Fall als sogenannte Interop-Sprache, als Mittler zwi-
24
Vorwort
schen nativem Programmcode und dem verwalteten Programmcode von .NET. Ein typisches Anwendungsbeispiel wäre das Kapseln einer in nativem C++ geschriebenen Klasse in eine .NET-Klasse, so dass sie z. B. in C# weiterverwendet werden kann. Um als Programmierer möglichst breit aufgestellt zu sein, bietet es sich daher an, zuerst C/C++ zu lernen, anschließend die .NET-Welt mit C++/CLI zu erkunden, und sich bei exzessiver Entwicklung von .NET-Applikationen noch C# anzueignen, eine C++ sehr ähnliche Sprache. Der Aufbau des Buchs
Das Buch ist im Wesentlichen in drei Teile gegliedert: Der erste Teil erklärt natives C++ ohne .NET-Unterstützung. Er sollte von Programmieranfängern auf jeden Fall durchgearbeitet werden und dient erfahreneren C++-Programmierern als Nachschlagemöglichkeit. Im zweiten Teil werden die Erweiterungen von C++/CLI besprochen. Erfahrende C++-Programmierer können hier bequem in die .NET-Programmierung einsteigen. Der dritte Teil schließlich beschäftigt sich mit der Klassenbibliothek von .NET, allen voran der Oberflächenprogrammierung. Die Klassenbibliothek steht allen .NET-Programmiersprachen zur Verfügung. Die hier vorgestellten Klassen könnten Sie daher auch unter C# oder Visual Basic .NET mit der entsprechenden Syntax verwenden. Die Beispiele in diesem Buch
Das Buch beinhaltet eine Reihe von Beispielen und praktischen Anwendungen, die Sie als Projekte von Visual C++ 2010 Express auf der DVD finden. Für jedes Kapitel, das Beispiele enthält, existiert eine Projektmappe, die wiederum die einzelnen Listings enthält. Oft werden die Beispiele in kleinen Schritten erweitert, so dass es keinen Sinn macht, für jeden Schritt ein eigenes Projekt anzulegen. Wenn also bei den Projekten vermeintliche Lücken vorhanden sind, dann beinhaltet das Folgeprojekt immer alle Ergänzungen und Änderungen, die bis dahin gemacht wurden. Manche Projekte, die in sich geschlossenen sind, wie beispielsweise das Telefonbuch, finden Sie unter ihrem Namen in der Projektmappe.
25
TEIL I ANSI C++
Dieses Kapitel führt Sie in die Grundstruktur eines C++-Programms ein. Es zeigt die Ein- und Ausgabe und stellt die in C++ verfügbaren Datentypen und Operatoren vor.
1
Grundlagen von ANSI C++
1.1
Die Win32-Konsolenanwendung
Die ersten Schritte in C++ werden wir als Win32-Konsolenanwendung vornehmen. Dazu wählen Sie in der Entwicklungsumgebung über das Menü den Punkt Datei 폷 Neu 폷 Projekt. Sie erhalten das in Abbildung 1.1 dargestellte Fenster.
Abbildung 1.1
Neues Projekt anlegen
Je nach Edition des Visual Studios kann der Umfang der angebotenen Projektvorlagen variieren. Die in diesem Buch relevanten Projektgruppen sind CLR für .NET-gestützte Projekte und die nun thematisierte Projektart Win32. In dieser Gruppe finden Sie die Win32-Konsolenanwendung, die Sie jetzt wählen. Unter Name geben Sie den Namen des Projekts an, in unserem Beispiel wurde dort HelloWorld gewählt. Der Punkt Ort bieten Ihnen die Möglichkeit, den Speicherort des Projekts zu bestimmten.
29
1
Grundlagen von ANSI C++
In Visual Studio werden Projekte in einer Projektmappe verwaltet. Soll für diese Projektmappe physikalisch ein eigener Ordner angelegt werden, dann müssen Sie einen Haken vor Verzeichnis für Lösung erstellen setzen und einen Projektmappennamen angegeben. Anschließend öffnen Sie über OK das Einstellungsfenster für die Win32-Konsolenanwendung, zu sehen in Abbildung 1.2.
Abbildung 1.2 Die Anwendungseinstellungen für das Win32-Projekt
Dort setzen Sie einen Haken vor Leeres Projekt. Über die Schaltfläche Fertig stellen wird das Projekt angelegt und in der Entwicklungsumgebung angezeigt.
1.1.1
Eine C++-Datei hinzufügen
Um das erste Programm zu schreiben, benötigen wir noch eine entsprechende Quellcodedatei. Dazu klicken Sie im Projektmappen-Explorer mit rechts auf den Projektnamen und wählen den Punkt Hinzufügen 폷 Neues Element aus. Es erscheint das in Abbildung 1.3 dargestellte Fenster. Zur Einschränkung der Auswahl sollten Sie in der linken Spalte auf Code klicken. Von den verbleibenden Dateitypen wählen Sie C++-Datei, geben dieser unter Name einen Namen (die für diesen Dateityp verwendete Endung .cpp wird automatisch angehängt) und klicken auf Hinzufügen. In C++ gibt es keinerlei syntaktischen Bezug zwischen dem Namen einer Datei und ihrem Inhalt. Zur besseren Übersicht sollten Sie aber möglichst
30
Die Win32-Konsolenanwendung
aussagekräftige Namen vergeben. Die C++-Datei mit der späteren Hauptfunktion können Sie beispielsweise immer main nennen.
Abbildung 1.3 Neues Element hinzufügen
Um an dieser Stelle den vollständigen Entwicklungszyklus eines Programms zu besprechen, schreiben Sie bitte das kleine Programm aus Listing 1.1 in die C++-Datei. Abbildung 1.4 zeigt das Ergebnis. int main() { } Listing 1.1 Das erste Programm
Abbildung 1.4 Das erste Programm in der Entwicklungsumgebung
31
1.1
1
Grundlagen von ANSI C++
Der Stern hinter dem Dateinamen zeigt Änderungen an, die noch nicht gespeichert wurden. Entweder können Sie über die üblichen Punkte Speichern oder Alle speichern die Änderungen manuell sichern, oder Sie starten die Kompilation des Programms, vor der automatisch alle Änderungen gespeichert werden.
1.1.2
Das Projekt kompilieren
C++-Projekte müssen kompiliert, also vom Compiler in Maschinensprache übersetzt werden.Unter dem Menüpunkt Erstellen finden Sie die dafür notwendigen Funkionen. Die erste Gruppe von Menüpunkten betrifft die gesamte Projektmappe, also alle in der Projektmappe enthaltenen Projekte. Die zweite Gruppe bezieht sich nur auf das aktuelle Startprojekt. Das Startprojekt ist im ProjektmappenExplorer daran zu erkennen, dass es fett geschrieben ist (um bei mehreren Projekten das Startprojekt zu wechseln, klicken Sie im Projektmappen-Explorer das gewünschte Projekt mit rechts an und wählen den Punkt Als Startprojekt festlegen). Für beide Gruppen existieren folgende Möglichkeiten: 왘
Erstellen: Alle noch nicht kompilierten Bestandteile werden kompiliert, und ein ausführbares Programm wird erstellt.
왘
Neu erstellen: Alles wird neu kompiliert, egal, ob es bereits kompiliert war oder nicht, und dann wird ein ausführbares Programm daraus erstellt.
왘
Bereinigen: Alle für das Projekt unwesentlichen Dateien werden gelöscht (etwa die kompilierten Dateien des Projekts, die nicht wichtig sind, da sie immer wieder aus den Quellcodedateien erzeugt werden können). Dieser Punkt sollte ausgeführt werden, bevor ein Projekt beispielsweise auf CD gebrannt wird, um unnötigen Speicherverbrauch zu vermeiden.
Für den normalen Kompilationsvorgang reicht der Punkt Projektmappe erstellen, der auch bequem über (F7) aufgerufen werden kann. Der Kompilationsvorgang umfasst je nach gewähltem Punkt die Kompilation aller oder nur der geänderten Dateien, die Erstellung eines Manifests und die abschließende Verknüpfung aller erstellten Dateien des Projekts zu einem ausführbaren Programm. Die letzte Zeile im Ausgabefenster gibt eine kurze Zusammenfassung der Projektzustände:
32
Die Win32-Konsolenanwendung
왘
erfolgreich: Anzahl der erfolgreich kompilierten Projekte
왘
Fehler bei: Anzahl der Projekte mit Fehlern (aus einem fehlerhaften Projekt wird keine ausführbare Datei erzeugt; sollte das Projekt vorher einmal fehlerfrei kompiliert worden sein, dann entspricht die ausführbare Datei diesem Stand)
왘
aktuell: Anzahl der Projekte, an denen nichts geändert wurde und die deswegen auch nicht kompiliert werden mussten
왘
übersprungen: Anzahl der Projekte, die von der Kompilation ausgenom-
men sind.
1.1.3
Das Programm starten
Unter dem Menüpunkt Debuggen finden Sie Möglichkeiten des Programmstarts über die Entwicklungsumgebung. Damit das Ausgabefenster nicht direkt nach Beendigung des Programms wieder geschlossen wird, sollten Sie das Programm über den Punkt Starten ohne Debuggen oder (Strg) + (F5) starten. Bedenken Sie: Nur das als Startprojekt festgelegte Projekt wird ausgeführt. Wie ein Programm im Debug-Modus gestartet wird, erfahren Sie in Abschnitt A.1, »Der Debugger«.
1.1.4
Die Fehlerkorrektur
Nicht selten stellt der Compiler während der Kompilation syntaktische Fehler fest. Zum Beispiel können Sie das Wort int einmal mit zwei n schreiben (innt) und dann das Programm nochmals kompilieren. Das Ausgabefenster (zu sehen in Abbildung 1.5) listet die gefundenen Fehler auf und merkt in der Statuszeile am Ende »Fehler bei 1« an.
Abbildung 1.5 Der Fehler im Ausgabefenster
33
1.1
1
Grundlagen von ANSI C++
Eine strukturiertere Auflistung der gefundenen Fehler bietet die Fehlerliste, die Sie entweder im Menü unter Ansicht 폷 Fehlerliste oder über die Tastenkombination (Strg) + (^) und (Strg) + (E) öffnen können (Abbildung 1.6).
Abbildung 1.6 Die Fehlerliste
In dieser Fehlerliste können Sie sich die aufgetretenen Fehler, Warnungen und Meldungen ansehen. Um die Darstellung einer bestimmten Kategorie zu aktivieren/deaktivieren, klicken Sie auf den Namen der Kategorie. In Abbildung 1.6 wurde nur die Darstellung der Fehler aktiviert. Um einen Fehler schnell im Projekt zu finden, klicken Sie doppelt darauf. Der Editor öffnet automatisch die fehlerhafte Datei und markiert den Fehler mit einem kleinen Pfeil links vor der betreffenden Zeile. Zusätzlich kann der Fehler noch durch eine rote Linie unter der betreffenden Zeile gekennzeichnet sein. Sollte die Fehlerliste mehrere Fehler beinhalten, dann sollten Sie immer zuerst den ersten Fehler beheben, da die weiteren Fehler häufig nur Folgefehler des ersten sind.
1.2
Die Hauptfunktion
So flexibel die Sprache C++ auch ist, etwas haben alle Programme gemeinsam: den durch die Hauptfunktion definierten Startpunkt des Programms. Die Hauptfunktion eines C++-Programms besitzt den Namen main und ist wie folgt aufgebaut: int main() { }
34
Die Hauptfunktion
Jedes C++-Programm benötigt genau eine main-Funktion; sie wird beim Programmstart aufgerufen, und mit ihr wird das Programm beendet. Groß- und Kleinschreibung In C++ muss penibel auf die Groß- und Kleinschreibung geachtet werden, weil diese ein Unterscheidungsmerkmal von Bezeichnern, also der im Programm gewählten Namen für Variablen, Objekte und Klassen, ist. Die Schreibweise von beispielsweise int und Int bezeichnet in C++ zwei unterschiedliche Dinge.
1.2.1
Der Funktionskopf
Die erste Zeile einer Funktion, in der die Parameter und der Rückgabetyp der Funktion definiert werden (int main()), wird als Funktionskopf bezeichnet. Wie eigene Funktionen definiert werden, behandelt Abschnitt 2.3. Hinter dem Funktionskopf steht der Anweisungsblock, gebildet von einem Paar geschweifter Klammern. Die Anweisungen dieses Anweisungsblocks werden beim Aufruf der Funktion ausgeführt, im Falle der main-Funktion also beim Programmstart. C++ ist bei den Möglichkeiten der Quellcodeformatierung sehr flexibel. Das bisherige Beispiel wäre auch wie in Listing 1.2 oder Listing 1.3 formatiert problemlos kompiliert worden: int main(){} Listing 1.2 Alles in einer Zeile
int main( ){ } Listing 1.3 Wilde Formatierung
Allerdings ist eine überschaubare Formatierung ratsam. Üblicherweise schreibt man den Funktionskopf in eine eigene Zeile (mit der öffnenden geschweiften Klammer aus Platzgründen dahinter) und rückt die Anweisungen innerhalb des Anweisungsblocks der Optik wegen etwas ein. Die schließende geschweifte Klammer steht vertikal bündig mit der Zeile der dazugehörenden öffnenden Klammer.
35
1.2
1
Grundlagen von ANSI C++
1.3
Die Ausgabe
Um weitere Grundlagen von C++ vorzustellen, wird das vorige Programm um einige Anweisungen erweitert: #include using namespace std; int main() { cout << "Hello World" << endl; } Listing 1.4 Eine Ausgabe in C++
Tipp Sollte das Ausgabefenster nach Beendigung des Programms sofort wieder geschlossen werden, dann bietet sich als letzte Anweisung des Programms system("pause");
an. Dadurch wird das Windows-eigene pause-Kommando aufgerufen, das auf einen Tastendruck zum Fortfahren wartet. Soll das Fenster nur geöffnet bleiben, wenn das Programm über die Entwicklungsumgebung gestartet wird, dann bietet sich folgende Vorgehensweise an: 1. Klicken Sie im Projektmappen-Explorer mit rechts auf den Projektnamen, und wählen Sie Eigenschaften. 2. Wählen Sie dort in der linken Spalte den Punkt Konfigurationseigenschaften 폷 Linker 폷 System aus. 3. Setzen Sie rechts die Option Subsystem auf Konsole. 4. Schließen Sie das Eigenschaftenfenster mit Ok.
Das hier zu betrachtende Beispiel besitzt im Anweisungsblock der main-Funktion nur eine einzige Anweisung: cout << "Hello World" << endl;
Ein Hinweis kurz vorweg: In C++ wird jede Ausdrucksanweisung mit einem Semikolon abgeschlossen. Eine Ausdrucksanweisung ist eine Anweisung, die einen Ausdruck bildet. Abgesehen von den Operatoren mit einem oder drei Operanden ist die typische Form eines Ausdrucks »Operand Operator Operand«, wobei jeder Operand selbst wieder ein Ausdruck sein kann. Demnach ist 3+4 ebenso ein Ausdruck wie a-b, x=5 oder cout << "x".
36
Die Ausgabe
Für konstante Zeichenfolgen gibt es auch eine syntaktische Regel: Konstante Zeichenfolgen stehen immer in doppelten Anführungszeichen.
1.3.1
cout
Der Befehl zur Ausgabe heißt cout. Das ist die Abkürzung für console out bzw. character out, beide Begriffe werden in gleichem Maße verwendet. Es handelt sich hierbei um ein Objekt mit der Fähigkeit, Zeichenketten und elementare Datentypen (darunter versteht man die in C++ enthaltenen Datentypen, die nicht aus anderen Elementen zusammengesetzt sind) auf dem Standard-Ausgabegerät (im Normalfall der Konsole) auszugeben. Diese Ausgabe geschieht mithilfe des Operators <<, hinter dem das auszugebende Element steht. Ausgegeben wird in diesem Beispiel zunächst der Text »Hello World«.
1.3.2
endl
Hinter der Zeichenkette steht nochmals der Operator << und dahinter endl. Diese Schreibweise ist möglich, weil der <<-Operator verkettet werden kann. Alternativ hätten wir auch zwei Anweisungen schreiben können: cout << "Hello World"; cout << endl; endl beendet die Zeile, indem es ein New-Line-Zeichen (NL) ausgibt und
anschließend einen Flush ausführt. Dazu muss man wissen, dass es sich bei der Ausgabe über cout um eine gepufferte Ausgabe handelt. Die Ausgabe wird zuerst in einen Puffer geschrieben und anschließend in einem Gang auf den Bildschirm gebracht. Es sind also Situationen denkbar, in denen sich ausgegebener Text im Puffer befindet, der noch nicht auf dem Bildschirm zu sehen ist. An dieser Stelle kann ein Flush, das erzwungene Ausgeben des Puffers auf die Konsole, sinnvoll sein. Der Ausgabepuffer wird aber bei Programmende und vor einer Eingabe automatisch geleert, von daher kommt ein isoliertes Flush nur selten zum Einsatz. Interessant an endl ist für uns die Ausgabe des New Line. Zu Testzwecken können Sie einmal das endl mitsamt dem vorhergehenden <<-Operator entfernen, und Sie werden sehen, der vom Compiler ausgegebene Text »Drücken Sie…« steht direkt hinter dem »Hello World« und nicht mehr in einer eigenen Zeile.
37
1.3
1
Grundlagen von ANSI C++
1.3.3
Escape-Sequenzen
Unter Escape-Sequenzen versteht man die Codierung besonderer Zeichen, die sonst in einer Zeichenkette nicht ausgegeben werden könnten. Nehmen wir als Beispiel das doppelte Anführungszeichen. Es wird in C++ verwendet, um Zeichenketten einzugrenzen. Schreiben wir ein Anführungszeichen in eine Zeichenkette, wird es nicht ausgegeben, sondern als Ende der Zeichenkette interpretiert. Mit einer Escape-Sequenz ist die Ausgabe eines Anführungszeichens kein Problem. Eine Escape-Sequenz beginnt mit dem Backslash \ und einem Zeichen, im Fall der doppelten Anführungszeichen dem ". Soll der Text »Hello World« auch bei der Ausgabe in Anführungszeichen stehen, schreiben wir das demnach so: cout << "\"Hello World\"" << endl;
Die verfügbaren Escape-Sequenzen sehen Sie in Tabelle 1.1 aufgeführt. Escape Sequenz
Zeichen
\a
BEL (bell): Gibt ein akustisches Warnsignal.
\b
BS (backspace): Der Cursor geht eine Position nach links.
\f
FF (formfeed): Löst einen Seitenvorschub aus.
\n
NL (new line), der Cursor springt zum Anfang der nächsten Zeile.
\r
CR (carriage return): Der Cursor springt zum Anfang der aktuellen Zeile.
\t
HT (horizontal tab): Der Cursor springt zur nächsten horizontalen Tabulatorposition.
\v
VT (vertical tab): Der Cursor springt zur nächsten vertikalen Tabulatorposition.
\"
Das Zeichen " wird ausgegeben.
\'
Das Zeichen ' wird ausgegeben.
\?
Das Zeichen ? wird ausgegeben.
\\
Das Zeichen \ wird ausgegeben.
Tabelle 1.1 Die Escape-Sequenzen
1.3.4
Manipulatoren
Bei der Ausgabe und später zum Teil auch bei der Eingabe existieren sogenannte Manipulatoren, die in den Ausgabestrom geschoben werden können und bestimme Veränderungen der Ausgabe bewirken.
38
Die Ausgabe
Manipulator
Beschreibung
boolalpha
Gibt Boolesche Werte als Wörter (true und false) aus.
dec
Gibt ganzzahlige Werte dezimal aus.
fixed
Stellt Fließkommawerte in der normalen Schreibweise dar.
hex
Gibt ganzzahlige Werte hexadezimal aus.
left
Ordnet Ausgaben linksbündig an. Eventuelle Füllzeichen werden rechts angehängt.
noboolalpha
Macht boolalpha rückgängig.
noshowbase
Macht showbase rückgängig.
noshowpoint
Macht showpoint rückgängig.
noshowpos
Macht showpos rückgängig.
noskipws
Macht skipws rückgängig.
nounitbuf
Macht unitbuf rückgängig.
oct
Gibt ganzzahlige Werte oktal aus.
right
Ordnet Ausgaben rechtsbündig an. Eventuelle Füllzeichen werden links angehängt.
scientific
Stellt Fließkommawerte in der Exponentialschreibweise dar.
showbase
Stellt bei Ausgaben im Oktal- oder Hexadezimalsystem den für C++ typischen Präfix voran (0 für oktal und 0x für hexadezimal).
showpoint
Zeigt alle Ziffern nach dem Komma, auch wenn diese 0 sind.
showpos
Setzt bei positiven Werten ein + davor.
skipws
Überliest bei der Eingabe Whitespaces.
unitbuf
Puffert die Ausgabe nicht, sondern gibt sie sofort aus.
Tabelle 1.2
Die Standard-Manipulatoren
Im folgenden Beispiel wird mit boolalpha die Ausgabe von false anstelle von 0 bewirkt: bool b=false; cout << boolalpha << b << endl;
Zusätzlich sind in der Header-Datei iomanip noch zwei interessante Manipulatoren definiert: 왘
setfill(charT c) setzt das Füllzeichen auf das Zeichen c.
왘
setprecision(int p) setzt die Ausgabe bei Fließkommawerten auf insge-
samt b Ziffern, den Dezimalpunkt nicht mitgezählt. setprecision definiert
39
1.3
1
Grundlagen von ANSI C++
nicht die Anzahl der Nachkommastellen, sondern wirklich die Anzahl der gesamten Ziffern, sowohl vor als auch nach dem Komma. 왘
setw(int b) definiert die minimale Breite einer Werteausgabe auf b Zei-
chen. Nicht benötigte Stellen werden mit einem Füllzeichen (standardmäßig das Leerzeichen, kann aber mit setfill geändert werden) gefüllt. Sollte die Ausgabe mehr Zeichen benötigen, als mit setw definiert, dann wird die Ausgabe trotzdem vollständig durchgeführt.
1.4
Die include-Direktive
Nachdem die Inhalte der Hauptfunktion nun ausreichend erklärt sind, fehlen noch die beiden Anweisungen vor der main-Funktion. Zum Ersten ist da die Include-Direktive: #include
Bei diesem Befehl handelt es sich um eine sogenannte Präprozessordirektive. Der Präprozessor durchläuft eine Quellcodedatei vor der Kompilation und sucht nach an ihn gerichteten Befehlen. Das Ergebnis seiner Textbearbeitung wird dann dem Compiler zur Kompilation vorgesetzt. Zu erkennen ist eine Präprozessordirektive an dem einleitenden #. Die Direktive in unserem Fall heißt include und ersetzt die Direktive temporär für die Kompilation mit der dahinter angegebenen Datei. Die spitzen Klammern besagen, dass die angegebene Datei an von der Entwicklungsumgebung vorgegebenen Orten gesucht wird. Auf diese Weise werden im Normalfall die HeaderDateien der Standardbibliothek eingebunden. Wie Sie eigene Header-Dateien einbinden können, lesen Sie in Abschnitt 2.4, »Module«. In unserem Programm wird die Header-Datei iostream eingebunden, welche die Definition der Input- und Outputstreams, unter anderem auch cout und endl, beinhaltet.
1.5
using
Die zweite noch zu besprechende Anweisung ist die using namespace-Anweisung. Ihre Syntax lautet: using namespace Namensbereich;
40
Kommentare
Elemente der C++-Standardbibliothek und später auch des .NET-Frameworks sind in sogenannte Namensbereiche gruppiert, die einen eigenen Bezugsrahmen bilden und damit Doppeldeutigkeiten von Bezeichnern vermeiden. Zwei Elemente mit demselben Namen dürfen in unterschiedlichen Namensbereichen stehen, weil sie jeweils in ihrem Namensbereich eindeutig sind und nach außen hin über den sie umgebenden Namensbereich angesprochen werden müssen. Die Elemente der C++-Standardbibliothek stehen allesamt im Namensbereich std. Die using namespace-Anweisung sagt dem Compiler, in welchen Namensbereichen er automatisch suchen soll, wenn ein Bezeichner im aktuellen Bezugsrahmen nicht gefunden wird. Entfernen Sie die using namespace-Anweisung aus dem Programm, hagelt es Fehlermeldungen, weil die im Namensbereich std befindlichen Elemente cout und endl nicht mehr gefunden werden.
1.5.1
Bezugsrahmenoperator
Um Elemente eines Namensbereichs anzusprechen, wird der Bezugsrahmenoperator :: verwendet: #include int main() { std::cout << "Hello World" << std::endl; } Listing 1.5 Die Verwendung des Bezugsrahmenoperators
Die Anweisung std::cout heißt demnach: im Namensbereich std das Element cout. Weil diese vollständige Qualifizierung (d. h., die Nennung des Bezugsrahmens bei der Namensnennung, std::cout; im Gegensatz zu einem unqualifizierten Namen, cout) bei regelmäßigem Gebrauch eines Elements lästig wird, gibt es die using namespace-Anweisung, die Abhilfe schafft. Wie Sie eigene Namensbereiche definieren können, behandelt Abschnitt 5.1.
1.6
Kommentare
Auch wenn die meisten Programmierer ihre Wichtigkeit ungerne zugeben und sich häufig mit Händen und Füßen dagegen wehren, sie zu schreiben, sollten Kommentare ein elementarer Bestandteil eines jeden Programms sein. Wenn sie auch keinerlei Aussagekraft für den Compiler besitzen, sind
41
1.6
1
Grundlagen von ANSI C++
sie eine wertvolle Informationsquelle für jeden, der den Versuch unternimmt, das Programm zu verstehen. Natürlich immer unter der Voraussetzung, dass der Verfasser der Kommentare ernsthafte Inhalte geschrieben hat.
1.6.1
Einzeilige Kommentare
Die einfachste Form von Kommentar ist der einzeilige Kommentar, der mit // beginnt und mit dem Ende der Zeile endet: cout << "Hello World"; // Textausgabe cout << endl; Listing 1.6 Ein einzeiliger Kommentar
Die zweite Zeile gehört nicht mehr zum Kommentar, weil er sich nur bis zum Ende der ersten Zeile erstreckt. Soll der Kommentar aus mehreren Zeilen bestehen, dann bleibt mit dieser Variante keine andere Möglichkeit, als jede Zeile mit // beginnen zu lassen.
1.6.2
Mehrzeilige Kommentare
Glücklicherweise sind in C++ auch mehrzeilige Kommentare möglich. Sie beginnen mit /* und enden mit */: /* Ein mehrzeiliger Kommentar */ Listing 1.7 Ein mehrzeiliger Kommentar
Der Kommentartext muss nicht unbedingt in derselben Zeile wie /* beginnen, theoretisch kann direkt vor und hinter dem mehrzeiligen Kommentar sogar noch Code stehen, auch wenn das nicht gerade die Lesbarkeit erhöht: cout << "Hello World"; /* Ein mehrzeiliger Kommentar */ cout << endl;
1.6.3
Kommentare verschachteln
Es ist in bestimmten Grenzen möglich, Kommentare zu verschachteln, so z. B. einzeilige Kommentare: //cout << endl;
42
// Neue Zeile
Variablen
Oder einzeilige Kommentare innerhalb eines mehrzeiligen Kommentars: /* cout << "Hello World"; // Textausgabe cout << endl; // Neue Zeile */
Nicht unterstützt wird jedoch die Verschachtelung von mehrzeiligen Kommentaren.
1.6.4
Kommentare in der Entwicklungsumgebung
Das Markieren von Kommentaren wird in Visual C++ über zwei Schaltflächen unterstützt, wie Abbildung 1.7 zeigt.
Kommentar entfernen Kommentar hinzufügen Abbildung 1.7 Die Schaltflächen für Kommentare
Die Entwicklungsumgebung verwendet ausschließlich einzeilige Kommentare.
1.7
Variablen
Unter einer Variablen versteht man in einer Hochsprache die Bereitstellung von Speicherplatz, um den Wert eines bestimmten Datentyps speichern zu können. Eingesetzt werden Variablen immer dann, wenn das Programm Daten manipulieren soll. Ob das Programm diese Daten beispielsweise über eine Tastatureingabe, das Einlesen einer Datei oder eine Datenbankabfrage ermittelt spielt dabei keine Rolle. Zum Zeitpunkt der Datenverarbeitung müssen die Daten im Arbeitsspeicher des Computers vorliegen, und zwar üblicherweise in Form von Variablen oder Objekten. Syntaktisch wird eine Variable in C++ wie folgt definiert:
43
1.7
1
Grundlagen von ANSI C++
Datentyp Variablenname [= Initialisierungswert] ;
Die Teile in den eckigen Klammern sind optional, die Angabe eines Initialisierungswerts ist bei der Definition also nicht zwingend. Welche Datentypen in C++ zur Verfügung stehen, behandelt Abschnitt 1.8. Im Folgenden werden die int-Variablen x und y definiert und x zusätzlich noch mit dem Wert 42 initialisiert: int x=42; int y;
Die elementaren Datentypen können problemlos mit cout ausgegeben werden: cout << x << endl; cout << y << endl;
Allerdings besitzen uninitialisierte Variablen (in diesem Fall y) keinen vorhersagbaren Wert. Das ist grundsätzlich kein Problem, aber meist ungewollt und damit ein Sicherheitsrisiko. Aus diesem Grund wird im Debug-Modus ein Fehler zur Laufzeit gemeldet. Abbildung 1.8 zeigt ihn.
Abbildung 1.8 Fehlermeldung der Laufzeitumgebung
In der Release-Konfiguration wird der zufällige Wert allerdings fehlerfrei ausgegeben. Abbildung 1.9 zeigt, wie die Projektkonfiguration zwischen Debug und Release gewechselt werden kann.
Projektkonfiguration wechseln Abbildung 1.9 Wechseln der Projektkonfiguration
44
Variablen
1.7.1
Bezeichner
Immer wenn in C++ etwas angelegt wird, sei es eine Variable, sei es ein Objekt, eine Funktion, eine Klasse etc., dann wird ein Bezeichner benötigt. In C++ wird ein Bezeichner nach dem folgenden Regeln gebildet: 왘
Das erste Zeichen eines Bezeichners muss ein umgebungsspezifischer Buchstabe oder ein Unterstrich sein.
왘
Jedes weitere Zeichen darf ein umgebungsspezifischer Buchstabe, ein Unterstrich oder eine Zahl sein.
왘
Reservierte Wörter der Sprache dürfen nicht als Bezeichner verwendet werden.
왘
Es wird zwischen Groß- und Kleinschreibung unterschieden.
Tabelle 1.3 bietet eine Übersicht über die reservierten Wörter in C++. alignof
and
and_eq
asm
auto
bitand
bitor
bool
break
case
catch
char
char16_t
char32_t
class
compl
const
const_cast
constexpr
continue
decltype
default
delete
do
double
dynamic_cast
else
enum
explicit
export
extern
false
float
for
friend
goto
if
inline
int
long
mutable
namespace
new
not
not_eq
nullptr
operator
or
or_eq
private
protected
public
register
reinterpret_cast
return
short
signed
sizeof
static
static_assert
static_cast
struct
switch
template
this
thread_local
throw
true
try
typedef
typeid
typename
union
unsigned
using
virtual
void
volatile
wchar_t
while
xor
xor_eq
Tabelle 1.3 Reservierte Wörter in C++
45
1.7
1
Grundlagen von ANSI C++
1.7.2
Eingabe
Analog zur Ausgabe mit cout existiert das Objekt cin, welches die Möglichkeit der Eingabe von Werten über die Konsole bietet. cin steht für »console in« oder »character in«. Die Eingabe über die Konsole erfolgt so: cin >> variablenbezeichner;
Listing 1.8 zeigt ein Beispiel für cin. Dort wird ein int-Wert eingelesen und anschließend wieder ausgegeben: #include using namespace std; int main() { cout << "Bitte geben Sie einen ganzzahligen Wert ein:"; int x; cin >> x; cout << "Der Wert lautet " << x << endl; } Listing 1.8
Die Eingabe mit »cin«
Genau wie der Ausgabe-Operator << kann auch der Eingabe-Operator >> verkettet werden: #include using namespace std; int main() { cout << "Bitte geben Sie zwei Ganzzahlen ein:"; int x,y; cin >> x >> y; cout << "Sie haben "<<x<<" und "<
Verketteter Eingabe-Operator
Die konkreten Eingaben trennen Sie entweder über die (¢)-Taste oder mit einem Leerzeichen.
46
Variablen
1.7.3
Literale
Unter Literalen versteht man konstante Werte, die direkt im Programmcode stehen. In der Anweisung int a = 42;
ist 42 ein Literal. Literale werden oft auch als Konstanten bezeichnet. Um aber eine begriffliche Unterscheidung zu konstanten Objekten oder Elementen zu ermöglichen, wird im C++-Standard der Begriff Literal verwendet. In einer typisierten Programmiersprache wie C++ besitzen auch Literale einen Typ. Dieser ist für ganze Zahlen int, für Fließkommazahlen double und für Zeichen char. Um Literale anderer Datentypen zu erzeugen, werden Suffixe verwendet. Diese werden hinter das Literal gesetzt. Dabei spielt die Groß- und Kleinschreibung (z. B. ll, Ll, lL oder LL) sowie die Reihenfolge (ull oder llu) keine Rolle. Ganzzahlige Literale
Tabelle 1.4 zeigt die Datentypen für ganzzahlige Literale an. Suffix
Datentyp
l oder L
Long int
ll oder LL
Long long int
u oder U
Unsigned
Tabelle 1.4 Die Suffixe für ganzzahlige Literale
int i=20; unsigned int ui = 20u; long l = 20l; unsigned long ul = 20ul; long long ll = 20ll; unsigned long long ull = 20ull; Listing 1.10 Beispiele für ganzzahlige Suffixe
Ganzzahlige Literale können oktal, dezimal oder hexadezimal angegeben werden. Tabelle 1.5 zeigt die Syntax und Listing 1.11 einige Beispiele dazu.
47
1.7
1
Grundlagen von ANSI C++
Erste Zeichen
Zahlensystem
0
oktal
0x oder 0X
hexadezimal
alles andere
dezimal
Tabelle 1.5 Zahlensysteme für ganze Zahlen
int i; i = 42; // dezimal 42 i = 052; // oktal 42 i= 0x2a; // hexadezimal 42 Listing 1.11 Literale in unterschiedlichen Zahlensystemen
Fließkommaliterale
Bei Fließkommavariablen stehen die in Tabelle 1.6 aufgeführten Suffixe zur Verfügung. Suffix
Datentyp
f oder F
float
l oder L
Long double
Tabelle 1.6
Suffixe für Fließkommavariablen
Hinweis In C++ wird als Dezimaltrennzeichen der Punkt verwendet, nicht wie in Deutschland üblich das Komma.
Darüber hinaus erlauben Fließkommaliterale auch die Exponentialschreibweise. Listing 1.12 zeigt zwei Beispiele: double d; d = 314e-2; // 3.14 d = 42e3; // 42000 Listing 1.12 Exponentialschreibweise bei Fließkommaliteralen
1.7.4
Konstanten
Konstanten sind im Vergleich zu den Literalen mit ihrer direkten Wertangabe (z. B. 3,14) Bezeichner mit einem konstanten Wert. Diese Konstanz wird
48
Datentypen
mit dem Schlüsselwort const eingeleitet. Die Definition einer Konstanten erfolgt über: const Datentyp Variablenname = Initialisierungswert;
Das folgende Beispiel berechnet anhand eines fest vorgegebenen Mehrwertsteuersatzes den Bruttowert aus dem Nettowert: #include using namespace std; const float MWST=1.19f; int main() { cout << "Bitte Netto-Wert eingeben:"; float wert; cin >> wert; cout << "Brutto: " << wert*MWST << endl; system("pause"); } Listing 1.13 Brutto-Berechnung
Mit einer Konstanten müssen Sie den tatsächlichen Wert nur ein einziges Mal im Programm angeben. Dadurch lässt sich dieser schnell und ohne Fehlerpotenzial verändern. Manche fordern, abgesehen vom Wert 0 alle anderen Werte nur über Konstanten im Programm zu verwenden. Das mag in bestimmten Anwendungsfällen durchaus sinnvoll sein, aber wie fast überall können allzu starre Dogmen auch ins Gegenteil umschlagen.
1.8
Datentypen
Üblicherweise dienen Programme der Manipulation oder Verarbeitung von Daten. Diese Daten, von wo auch immer sie stammen, müssen sich zum Zeitpunkt der Verarbeitung im Arbeitsspeicher des Computers befinden. C++ ist eine typisierte Programmiersprache, deswegen haben alle Daten eines C++-Programms auch einen Datentyp.
49
1.8
1
Grundlagen von ANSI C++
1.8.1
Elementare Datentypen
Die elementaren Datentypen von C++ sind in Tabelle 1.7 aufgeführt. Name
Bytes
Wertebereich
bool
1
true, false
char
1
–128 bis 127
wchar_t
2
0 bis 65.535
2
–32.768 bis 32.767
4
–2.147.483.648 bis 2.147.483.647
4
–2.147.483.648 bis 2.147.483.647
long long int long long
8
–9.223.372.036.854.775.808 bis 9.223.372.036.854.775.807
float
4
3.4E +/ bis 38 (sieben Stellen)
double
8
1.7E +/ bis 308 (15 Stellen)
long double
8
1.7E +/ bis 308 (15 Stellen)
short int
Abkürzung
short
int long int
long
Tabelle 1.7 Die elementaren Datentypen von C++
Der Datentyp bool kann nur die beiden Werte true oder false speichern, die Schlüsselwörter der Sprache sind. Obwohl in Tabelle 1.7 bei allen Datentypen die Größe in Bytes und deren Wertebereich angegeben ist, gilt diese nur für Visual C++. Der C++-Standard selbst definiert keine festen Größen. Vielmehr richten sich diese nach der Plattform des Compilers. Der Datentyp char ist dabei so groß, dass alle Zeichen der Compiler-Implementierung unterschieden werden können. Um alle auf der Plattform verwendeten Zeichen darstellen zu können, existiert wchar_t. Es könnte beispielsweise ein englischer Compiler auf einem japanischen Betriebssystem verwendet werden. Die Größen der ganzzahligen Datentypen char, short int, int, long int und long long int stehen lediglich in Relation zueinander: char 울 short 울 int 울 long 울 long long Dabei sollte der Datentyp int die auf dem System übliche Größe besitzen (auf einem 32Bit-System also 4 Bytes). Es hängt von der Implementierung des Compilers ab, ob der Datentyp char vorzeichenbehaftet (signed) oder nicht vorzeichenbehaftet (unsigned) ist. Die
50
Datentypen
Datentypen short int, int, long int und long long int sind implizit vorzeichenbehaftet, das Schlüsselwort signed kann aber explizit davor gesetzt werden (z. B. signed int). Um vorzeichenlose Datentypen zu erhalten, muss unsigned davor gesetzt werden, z. B. unsigned long long int. Die Wertebereiche der vorzeichenlosen Datentypen verdoppeln sich im positiven Bereich. Die Fließkommatypen float, double und long double existieren nur vorzeichenbehaftet. Auch hier ist die Größe im C++-Standard nur relativ definiert: float 울 double 울 long double Um den tatsächlichen Wertebereich eines elementaren Datentyps zu ermitteln, existieren zwei Möglichkeiten. Zum einen können Sie auf die in den Headerdateien climits und cfloat definierten Konstanten zurückgreifen, die in Tabelle 1.8 und Tabelle 1.9 aufgeführt sind. Das Beispiel in Listing 1.14 gibt den größtmöglichen double-Wert aus: #include #include using namespace std; int main() { cout << DBL_MAX << endl; } Listing 1.14 Ausgabe des größten double-Werts
Konstante
Bedeutung
CHAR_BIT
Anzahl der Bits eines char
CHAR_MAX
größter Wert eines char
CHAR_MIN
kleinster Wert eines char
INT_MAX
größter Wert eines int
INT_MIN
kleinster Wert eines int
LLONG_MAX
größter Wert eines long long int
LLONG_MIN
kleinster Wert eines long long int
LONG_MAX
größter Wert eines long int
LONG_MIN
kleinster Wert eines long int
Tabelle 1.8 Die Konstanten von »climits«
51
1.8
1
Grundlagen von ANSI C++
Konstante
Bedeutung
SCHAR_MAX
größter Wert eines signed char
SCHAR_MIN
kleinster Wert eines signed char
SHRT_MAX
größter Wert eines short int
SHRT_MIN
kleinster Wert eines short int
UCHAR_MAX
größter Wert eines unsigned char
UINT_MAX
größter Wert eines unsigned int
ULLONG_MAX
größter Wert eines unsigned long long int
ULONG_MAX
größter Wert eines unsigned long int
USHRT_MAX
größter Wert eines unsigned short int
Tabelle 1.8 Die Konstanten von »climits« (Forts.)
Konstante
Bedeutung
DBL_DIG
Anzahl der Dezimalstellen von double
DBL_EPSILON
kleinster double-Wert ungleich 0
DBL_MANT_DIG
Bitgröße der double-Mantisse
DBL_MAX
größter Wert eines double
DBL_MAX_10_EXP
größter Exponent zur Basis 10 eines double
DBL_MAX_EXP
größter Exponent zur Basis 2 eines double
DBL_MIN
kleinster positiver Wert eines double
DBL_MIN_10_EXP
kleinster Exponent zur Basis 10 eines double
DBL_MIN_EXP
kleinster Exponent zur Basis 2 eines double
FLT_DIG
Anzahl der Dezimalstellen von float
FLT_
kleinster float-Wert ungleich 0
FLT_MANT_DIG
Bitgröße der float-Mantisse
FLT_MAX
größter Wert eines float
FLT_MAX_10_EXP
größter Exponent zur Basis 10 eines float
FLT_MAX_EXP
größter Exponent zur Basis 2 eines float
FLT_MIN
kleinster positiver Wert eines float
FLT_MIN_10_EXP
kleinster Exponent zur Basis 10 eines float
FLT_MIN_EXP
kleinster Exponent zur Basis 2 eines float
LDBL_DIG
Anzahl der Dezimalstellen von long double
Tabelle 1.9 Die Konstanten von »cfloat«
52
Datentypen
Konstante
Bedeutung
LDBL_EPSILON
kleinster long double-Wert ungleich 0
LDBL_MANT_DIG
Bitgröße der long double-Mantisse
LDBL_MAX
größter Wert eines long double
LDBL_MAX_10_EXP
größter Exponent zur Basis 10 eines long double
LDBL_MAX_EXP
größter Exponent zur Basis 2 eines long double
LDBL_MIN
kleinster positiver Wert eines long double
LDBL_MIN_10_EXP
kleinster Exponent zur Basis 10 eines long double
LDBL_MIN_EXP
kleinster Exponent zur Basis 2 eines long double
Tabelle 1.9 Die Konstanten von »cfloat« (Forts.)
Die andere Möglichkeit ist der Einsatz des sizeof-Operators, der die Größe eines Objekts oder Datentyps in Bytes ermittelt. Das Beispiel in Listing 1.15 gibt die Größe der Variablen x und des Datentyps long long int in Bytes aus: #include using namespace std; int main() { int x; cout << sizeof(x) << endl; cout << sizeof(long long) << endl; } Listing 1.15 Einsatz des sizeof-Operators
1.8.2
Zusammengesetzte Datentypen
Zu den zusammengesetzten Datentypen gehören folgende Typen, die in späteren Kapiteln detailliert beschrieben werden: 왘
Zeiger
왘
Referenzen
왘
Aufzählungen
왘
Unions
왘
Strukturen
왘
Klassen
53
1.8
1
Grundlagen von ANSI C++
1.9
Operatoren
Operatoren dienen dazu, Ausdrücke zu formulieren. Das Ergebnis des Ausdrucks hängt dabei von den Operanden und dem verwendeten Operator ab. C++ kennt Operatoren mit einem, zwei und sogar drei Operanden. Im folgenden Beispiel werden zwei Variablen x und y definiert und x mit dem Wert 5 initialisiert. Anschließend wird der Ausdruck y=3*x gebildet. Wegen der unterschiedlichen Bindungsstärke (Abschnitt 1.9.9) der Multiplikation und der Zuweisung wird zunächst der Ausdruck 3*x berechnet, und danach dann die Zuweisung an y vorgenommen. int x=5; int y; y=3*x;
1.9.1
Zuweisungsoperatoren
Zuweisungsoperatoren sind die mit Abstand am meisten eingesetzten Operatoren. Denn sieht man einmal von den Inkrement- und Dekrementoperatoren ab, sind sie die einzigen, die eine Wertänderung einer Variablen oder eine Zustandsänderung eines Objekts bewirken. Die einfachste Form des Zuweisungsoperators sieht so aus: x=y;
Die Abarbeitung erfolgt von rechts nach links, der Wert von y wird x zugewiesen. Wegen dieser Abarbeitungsreihenfolge würde ein Ausdruck auf der rechten Seite vor der Zuweisung abgearbeitet. x=y+5 ist gleichbedeutend mit x=(y+5). Eine Sonderform des Zuweisungsoperators sind die sogenannten kombinierten Zuweisungsoperatoren, die eine Zuweisung mit einer arithmetischen Verknüpfung kombinieren, im folgenden Fall die Zuweisung mit der Addition: x+=y;
Diese kombinierten Zuweisungsoperatoren haben zwei wesentliche Vorteile. Zum einen verkürzen sie den Quellcode. Diese Einsparung wird umso deutlicher, je ausführlicher die Variablenbezeichnung vorgenommen wurde. Zum Beispiel ist MaximaleBetriebsdauer=MaximaleBetriebsdauer+1;
weitaus aufwändiger zu schreiben als
54
Operatoren
MaximaleBetriebsdauer+=1;
Des Weiteren sind kombinierte Zuweisungsoperatoren performanter, weil beispielsweise im obigen Fall der Wert 1 direkt auf die bestehende Variable addiert wird. Bei dem normalen Zuweisungsoperator muss zuerst eine Summe (MaximaleBetriebsdauer+1) als eigenständiges temporäres Objekt gebildet werden, bevor die Variable mit dem neuen Wert überschrieben werden kann. Tipp Kombinierte Zuweisungsoperatoren sind performanter und kürzer zu schreiben als eine von der Zuweisung getrennte Operation.
C++ bietet folgende kombinierte Zuweisungsoperatoren: Operator
Beschreibung
+=
a+=b weist a die Summe a+b zu.
-=
a-=b weist a die Differenz a-b zu.
*=
a*=b weist a das Produkt a*b zu.
/=
a/=b weist a den Quotienten a/b zu.
%=
a%=b weist a den Rest der Division von a/b zu. Ist nur auf ganzzahlige Datentypen anwendbar.
<<=
a<<=b weist a das Ergehnis von a<
>>=
a>>=b weist a das Ergebnis von a>>=b zu.
&=
a&=b weist a das Ergebnis von a&b zu.
|=
a|=b weist a das Ergebnis von a|=b zu.
~=
a~=b weist a das Ergebnis von a~b zu.
Tabelle 1.10
Die kombinierten Zuweisungsoperatoren
Zu beachten ist, dass durch den kombinierten Zuweisungsoperator die Priorität der verknüpften arithmetischen Operation überdeckt wird. Während x=x*3+5;
wegen der höheren Priorität von * zuerst den Wert von x mit 3 multipliziert und dann die 5 hinzuaddiert wird, bildet x*=3+5;
zuerst die Summe 3+5 und verwendet den resultierenden Wert 8 dann als Faktor mit x.
55
1.9
1
Grundlagen von ANSI C++
Im Zusammenhang mit Sprachelementen, die Änderungen an Variablen oder Objekten vornehmen (hier die Zuweisungsoperatoren), spricht man auch von L-Werten und R-Werten (engl. lvalue und rvalue). Ein L-Wert ist eine Variable oder ein nicht konstantes Objekt, welches sowohl auf der linken als auch auf der rechten Seite eines Zuweisungsoperators stehen kann. Ein RWert ist nicht veränderbar (Konstante, Literal oder konstantes Objekt) und kann nur auf der rechten Seite eines Zuweisungsoperators stehen.
1.9.2
Arithmetische Operatoren
Die Gruppe der arithmetischen Operatoren umfasst primär die typischen Grundrechenarten Addition, Subtraktion, Multiplikation, Division und Restwertbildung (Modulo). Operator
Beschreibung
*
a*b bildet das Produkt a*b.
/
a/b bildet den Quotienten a/b.
%
a%b bildet bei ganzzahligen Datentypen den Rest der Division von a/b.
+
Die binäre Variante (a+b) bildet die Summe a+b. Die unäre Schreibweise (+5) dient als Vorzeichen.
-
Die binäre Variante (a-b) bildet die Differenz a-b. Die unäre Schreibweise (-5) dient als Vorzeichen.
Tabelle 1.11
Die arithmetischen Operatoren
Achtung Die unären Operatoren + und – auf Variablen angewandt verhalten sich nicht wie bei den Konstanten. Der Ausdruck +x bildet nicht den positiven Wert von x, sondern lässt ihn wie bei einer Multiplikation mit +1 unverändert. Der Ausdruck -x ändert immer das Vorzeichen des Wertes von x, wie bei einer Multiplikation mit –1. Der in x gespeicherte Wert bleibt dabei natürlich unverändert, solange keine Zuweisung stattfindet.
Ein kleines Beispiel: #include using namespace std; int main() {
56
Operatoren
cout << "Bitte Zahl 1 eingeben:"; int x; cin >> x; cout << "Bitte Zahl 2 eingeben:"; int y; cin >> y; cout << "Die Summe ist: " << x+y << endl; cout << "Die negierte Summe ist: " << -(x+y) << endl; } Listing 1.16 Der Einsatz des +-Operators
1.9.3
Inkrement und Dekrement
Die Inkrementoperator ++ und der Dekrementoperator – kann als rein arithmetischer Operator und in der sogenannten Zeigerarithmetik eingesetzt werden. In der Funktion als arithmetische Operatoren erhöhen bzw. vermindern sie den Wert einer Variablen um 1. Dabei wird zwischen der Präfix- und der Postfix-Schreibweise unterschieden. In der Präfix-Schreibweise steht der Ausdruck für den neuen Wert der Variablen. Der Ausdruck ++x;
erhöht den Wert von x um 1, und der Ausdruck steht für den neuen, bereits erhöhten Wert von x. Deshalb ist bei int x=42; int y = ++x;
der Wert von x und y jeweils 43. Anders verhält es sich bei der PostfixSchreibweise. Der Ausdruck x++;
erhöht x zwar auch um 1, steht aber für den alten, noch nicht erhöhten Wert von x. Demnach ergibt int x=42; int y = x++;
für x den Wert 43 (x wird durch das Inkrement um 1 erhöht), für y aber den Wert 42 (Der Postinkrement-Ausdruck steht für den alten Wert von x). Das gleiche Verhalten zeigt der Dekrementoperator.
57
1.9
1
Grundlagen von ANSI C++
Hinweis Der Unterschied zwischen der Präfix- und Postfix-Schreibweise zeigt sich nur, wenn das Ergebnis des Ausdrucks in eine weitere Verarbeitung einfließt (Zuweisung oder Bestandteil eines komplexeren Ausdrucks). Bei einer isolierten Anwendung von Inkrement oder Dekrement gibt es zwischen Präfix- und Postfixschreibweise keinen Unterschied. ++x;
und x++;
erhöhen beide ohne Unterschied den Wert von x um 1.
Zeigerarithmetik
Im Zuge der Zeigerarithmetik erhöhen und vermindern die Inkrement- und Dekrementoperatoren den Wert des Zeigers nicht um 1, sondern addieren bzw. subtrahieren die Größe des Datentyps. Dadurch zeigt bei einem Inkrement der Zeiger auf das nächste Element, bei einem Dekrement auf das Element davor. Detailliertere Informationen finden Sie in Abschnitt 3.4.7, »Zeigerarithmetik«. Tipp Da bei der Postfix-Schreibweise der alte Wert der Variablen temporär gespeichert werden muss, um nach den Inkrement- oder Dekrementoperatoren noch zur Verfügung zu stehen, sind die Präfix-Schreibweisen im Normalfall performanter als die Postfix-Schreibweisen. Wenn Sie die Wahl haben, sollten Sie daher immer auf die Präfix-Schreibweise setzen.
1.9.4
Bitweise Operatoren
Die bitweisen Operatoren verknüpfen ihre Operanden auf Bitebene. Es heißt ja immer, der Computer versteht nur 0 und 1, und das ist auch wahr. Diese Einheit, die entweder nur 0 oder 1 sein kann, heißt Bit. Ein Bit kann demnach zwei verschiedene Werte annehmen, 0 und 1. Nehmen wir zwei Bits, dann sind bereits vier verschiedene Kombinationen möglich (00, 01, 10 und 11). Wenn wir das Bit links mit 2 multiplizieren, dann entstehen aus diesen vier Kombinationen die Werte von 0–3 (0*2+0=0, 0*2+1=1, 1*2+0=2 und 1*2+1=3). Kommt noch ein drittes Bit hinzu und wird dieses mit 4 multipliziert, dann haben wir bereits die Zahlen von 0–7. Das vierte Bit mit dem Faktor 8 versehen liefert uns die Möglichkeit, die Zahlen von 0–15 darzustellen.
58
Operatoren
Betrachten wir die Faktoren der Bits genauer, dann stellen wir fest, dass es sich um Zweierpotenzen handelt, 2=21, 4=22 und 8=23. Zur Verallgemeinerung multiplizieren wir das rechte Bit mit 1, das ist 20. Diese Kombination von vier Bit wird Nibble genannt. Abbildung 1.10 zeigt den Zusammenhang. 23
22
21
20
0
0
0
0
Abbildung 1.10 Bitweise Darstellung eines Nibbles
Ein Nibble immer mit vier binären Ziffern zu schreiben, z. B. 12 als 1100, ist aufwändig. Deswegen wird an dieser Stelle oft das Hexadezimalsystem verwendet, welches die Basis 16 besitzt und daher optimal zu einem Nibble passt, welches ja auch genau 16 Kombinationen abdeckt (die Werte von 0– 15). Dabei werden im Hexadezimalsystem die Werte von 0–9 auch mit den Ziffern 0–9 bezeichnet. Die Werte 10–15 werden mit den Buchstaben A–F oder a–f zum Ausdruck gebracht. Tabelle 1.12 zeigt die Zuordnungen in den verschiedenen Zahlensystemen. Dezimal
Hexadezimal
Binär
0
0
0000
1
1
0001
2
2
0010
3
3
0011
4
4
0100
5
5
0101
6
6
0110
7
7
0111
8
8
1000
9
9
1001
10
A
1010
11
B
1011
12
C
1100
13
D
1101
14
E
1110
15
F
1111
Tabelle 1.12
Der Zusammenhang zwischen Dezimal-, Hexadezimal- und Binärsystem
59
1.9
1
Grundlagen von ANSI C++
C++ bietet leider keine Möglichkeit, Werte binär einzugeben, aber mit dem Präfix 0x sind hexadezimale Werte erlaubt: int x = 0xa;
// Weist x den Wert 10 zu
Zwei solcher Nibbles, also 8 Bit, werden zu einem Byte zusammengefasst und das bietet die Möglichkeit, Zahlen von 0–255 darzustellen. Im Hexadezimalsystem sind dazu zwei Stellen nötig (eine pro Nibble). Abbildung 1.11 zeigt die Zusammensetzung eines Bytes. 27
26
25
24
23
22
21
20
0
0
0
0
0
0
0
0
0 16
0 1
160
Abbildung 1.11
Ein Byte
Diese Bytes können jetzt wiederum verknüpft werden zu noch größeren Datentypen mit noch größerem Wertebereich. Der Datentyp short z. B. besteht aus zwei Bytes und kann in seiner vorzeichenlosen Variante die Werte von 0–65.535 abdecken. Abbildung 1.12 zeigt die Zahl 2010 als short in der binären und hexadezimalen Schreibweise. 215 214 213 212 211 210
0
0
0
0
0
29
28
27
26
25
24
23
22
21
20
1
1
1
1
0
1
1
0
1
0
1
0
7
D
A
163
162
161
160
Abbildung 1.12
Die Zahl 2010
Der bitweisen Darstellung einer Zahl komm dann Bedeutung zu, wenn bitweise Operatoren auf sie angewendet werden. Nehmen wir als Beispiel das bitweise Und (&). Die Ausgabe cout << (2010&1990) << endl;
gibt 1986 auf dem Bildschirm aus. Die Klammerung der beiden Zahlen ist notwendig, weil << eine höhere Priorität als & hat. Ohne Klammern würde der Ausdruck wie (cout << 2010) & (1990 << endl) ausgewertet. Abbildung 1.13 zeigt, wie es zu dem Ergebnis kommt.
60
Operatoren
215 214 213 212 211 210 2010
0
0
0
0
0
1
29
28
27
26
25
24
23
22
21
20
1
1
1
1
0
1
1
0
1
0
1
1
0
0
0
1
1
0
1
1
0
0
0
0
1
0
& 1990
0
0
0
0
0
1
1
1 =
1986
0
0
0
0
0
1
1
1
Abbildung 1.13 Bitweise Und-Verknüpfung von 2010 und 1990
Die Bits der beiden Zahlen 2010 und 1990 mit dem gleichen Stellenwert werden mit Und verknüpft. Das Ergebnis-Bit ist also nur dann 1, wenn beide verknüpften Bits 1 sind. In allen anderen Fällen ist das Ergebnis-Bit 0. Die so erhaltene Binärzahl ergibt dezimal 1986. Tabelle 1.13 listet die bitweisen Operatoren von C++ auf und erklärt ihre Funktionsweise. Operator
Beschreibung
~
Bitweise Negation. Jedes Bit wird einzeln negiert: 왘 왘
~0 = 1 ~1 = 0
<<
Bitweise Linksverschiebung. a<
>>
Bitweise Rechtsverschiebung. a>>b schiebt die Bits von a um b Stellen nach rechts. Die rechts hinausgeschobenen Bits gehen verloren. Dafür rücken links b 0-Bits nach.
&
Bitweises Und. Das Ergebnis-Bit ist nur dann 1, wenn beide Operanden-Bits 1 sind: 왘 왘 왘 왘
^
& & & &
0 1 0 1
= = = =
0 0 0 0
Bitweises exklusives Oder. Das Ergebnis-Bit ist nur dann 1, wenn genau ein Operanden-Bit eins ist: 왘 왘 왘 왘
Tabelle 1.13
0 0 1 1
0 0 1 1
^ ^ ^ ^
0 1 0 1
= = = =
0 1 1 0
Die bitweisen Operatoren
61
1.9
1
Grundlagen von ANSI C++
Operator
Beschreibung
|
Bitweises inklusives Oder. Das Ergebnis-Bit ist nur dann 1, wenn mindesten ein Operanden-Bit 1 ist: 왘 왘 왘 왘
Tabelle 1.13
1.9.5
0 0 1 1
| | | |
0 1 0 1
= = = =
0 1 1 1
Die bitweisen Operatoren (Forts.)
Vergleichsoperatoren
Vergleichsoperatoren dienen, wie der Name schon sagt, dem Vergleich von Werten. Das Ergebnis dieses Vergleichs ist ein Boolescher Wert (true oder false). Tabelle 1.14 listet alle Vergleichsoperatoren auf. Operator
Beschreibung
<
a
<=
a<=b liefert true, wenn a kleiner oder gleich b ist, andernfalls liefert es false.
==
a==b liefert true, wenn a gleich b ist, andernfalls liefert es false.
!=
a!=b liefert true, wenn a ungleich b ist, andernfalls liefert es false.
>=
a>=b liefert true, wenn a größer oder gleich b ist, andernfalls liefert es false.
>
a>b liefert true, wenn a größer b ist, andernfalls liefert es false.
Tabelle 1.14
Die Vergleichsoperatoren
Sehen wir uns dazu folgendes Fragment an: int x=10; bool a = (x>=10); bool b = (x<5); cout << boolalpha << a << endl; cout << b << endl; Listing 1.17 Der Einsatz von Vergleichsoperatoren
Die Ergebnisse zweier Vergleiche werden zwei bool-Variablen zugewiesen und anschließend ausgegeben.
62
Operatoren
1.9.6
Logische Operatoren
Logische Operatoren dienen dazu, Boolesche Werte zu verknüpfen. Sie funktionieren analog zu den bitweisen Operatoren, nur dass sie eben keine Bits, sondern Boolesche Werte verknüpfen. Tabelle 1.15 listet die verfügbaren logischen Operatoren auf. Operator
Beschreibung
!
Logische Negation. Der Wahrheitsgehalt wird negiert: 왘 왘
Logisches Und. Die Verknüpfung ist wahr, wenn alle Aussagen wahr sind:
C++ bietet keinen Operator für das logische exklusive Oder, er lässt sich aber leicht mit den anderen Operatoren simulieren: (a && !b) || (!a && b). Besonderheiten von C++
In C++ gibt es zwar den Datentyp bool speziell für Boolesche Werte, er ist aber nicht wirklich ein so eigenständiger Datentyp, wie man vermuten könnte. Genau genommen, ist bool nichts anderes als ein weiterer ganzzahliger Datentyp, der nur deshalb existiert, um speziell für diese Art von Werten Funktionen überladen zu können. Deshalb sind true und false auch lediglich Konstanten für die Werte 1 und 0, die auch stellvertretend verwendet werden können: bool b=1; int i=false;
63
1.9
1
Grundlagen von ANSI C++
C++ geht sogar noch einen Schritt weiter und definiert, dass alle Werte außer der 0 wahr sind. Daher können wir so etwas problemlos schreiben: int i=5; if(i) { /* ... */ }
Da jeder Wert außer der Null wahr ist, ließe sich die Bedingung ausführlich auch i!=0 schreiben. In anderen Sprachen wie Java ist das auch Pflicht. In C++ nicht. Diese Vereinfachung birgt auch Tücken wie die folgende: int i=5; if(i=10) { /* ... */ }
Als erfahrener C++-Programmierer sieht man gleich, dass dort jemand versucht hat, einen Vergleich auf i gleich 10 zu schreiben, aber versehentlich nicht den Gleichheitsoperator ==, sondern den Zuweisungsoperator = verwendet hat. Der Compiler sieht das aber nicht. Er führt die Zuweisung durch, i ist danach 10, und prüft dann, ob 10 wahr ist. Und das ist es für C++. Tipp Dieser Fehler lässt sich vom Compiler überprüfen, wenn man 10==i schreibt. Sollte jetzt versehentlich der Zuweisungsoperator verwendet werden, dann würde einer Konstanten ein neuer Wert zugewiesen, und das geht selbst in C++ nicht.
Kurzschlusseigenschaft
Die logischen Operatoren && und || besitzen noch ein besonderes Verhalten, welches Kurzschlusseigenschaft genannt wird. Schauen wir uns folgendes konstruierte Beispiel an: int x=20, y=20, z=1; if(--x==0 && --y==0) z--; cout << "x = " << x << ", y = " << y << endl;
Wenn sowohl x als auch y gleich 0 sind, wird z um eins vermindert. Die interessante Frage lautet aber: Welche Werte werden in der letzten Zeile für x und y ausgegeben? Völlig unabhängig davon, ob das Dekrement nun Prä oder Post ist, bis zur letzten Zeile ist es auf jeden Fall ausgeführt. Daher sollte für x und y jeweils 19 ausgegeben werden. Aber betrachten wir den Code etwas genauer. Da --x den neuen Wert von x liefert, prüft der erste Vergleich den Wert 19 auf 0, was natürlich false lie-
64
Operatoren
fert. Und jetzt kommt die Kurzschlusseigenschaft ins Spiel: Bei einer UndVerknüpfung kommt nur dann true heraus, wenn beide verknüpften Bedingungen wahr sind. Wenn aber die erste Bedingung bereits als falsch identifiziert wurde – und das ist bei uns der Fall –, dann kann der Und-Operator nur noch false liefern, völlig unabhängig von dem Ergebnis der zweiten Bedingung. Es ist also eigentlich überhaupt nicht mehr notwendig, ja sogar Zeitverschwendung, die zweite Bedingung zu prüfen, weil das Gesamtergebnis längst feststeht. Und das sieht C++ auch so. Die zweite Bedingung wird nicht mehr ausgewertet und damit auch das Dekrement für y nicht ausgeführt. Das Ergebnis der Ausgabe ist daher 19 für x und 20 für y. Mal abgesehen davon, dass bei dem Einsatz der logischen Operatoren wegen der Kurschlusseigenschaft höllisch aufgepasst werden muss, wo könnte dieser Sachverhalt von Vorteil sein? Dazu wieder einmal ein kleines Beispiel. Stellen Sie sich vor, Sie wollten überprüfen, ob der Wert 10 durch die Variable x geteilt größer 2 ist. Was müssen Sie dann auf jeden Fall sicherstellen? Genau, dass x nicht 0 ist, denn durch 0 teilen macht keinen guten Eindruck: int x=20; if(x!=0) if(10.0/x > 2) cout << "groesser 2" << endl;
Es wurde hier 10.0 geschrieben, weil es eine double-Konstante ist und deshalb das Ergebnis der Division ebenfalls vom Typ double sein wird. Das obere Beispiel ist mit dem Wissen um die Kurzschlusseigenschaft der logischen Operatoren nun einfacher zu implementieren: int x=20; if(x!=0 && 10.0/x>2) cout << "groesser 2" << endl;
Bitte beachten Sie, dass ein Vertauschen der Operanden von && wegen der Kurzschlusseigenschaft eine völlig andere Bedeutung ergeben würde. Achtung In C++ sind die logischen Operatoren nicht kommutativ.
Dies läuft entgegen dem Verhalten, wie es vielleicht aus der Booleschen Algebra bekannt ist.
65
1.9
1
Grundlagen von ANSI C++
Die Kurzschlusseigenschaft in Kombination mit der Tatsache, dass jeder Wert außer 0 wahr ist, erlaubt auch solche Konstruktionen: int i=8; i>5 && cout << "i groesser 5" << endl;
Sollte i nicht größer 5 sein, dann ist der linke Ausdruck von && falsch, der rechte Ausdruck würde damit nicht ausgewertet und die Ausgabe nicht durchgeführt. Solche Formulierungen erhöhen jedoch nicht die Übersichtlichkeit und bringen keinerlei Vorteile.
1.9.7
Spezielle Operatoren
Zu der Gruppe der speziellen Operatoren zähle ich die, deren Verständnis eine eingehendere Beschäftigung mit der Thematik erfordert, in deren Zusammenhang sie eingesetzt werden. Daher werden sie an dieser Stelle nur aufgeführt und später eingehend besprochen. Operator
Beschreibung
? :
Operator zur Formulierung einer Bedingung als Ausdruck, siehe Abschnitt 2.1.5, »?:-Operator«
static_cast
statische Typumwandlung zur Kompilationszeit, siehe folgenden Abschnitt 1.9.8
Zeigeroperator, siehe Abschnitt 3.4.5, »Zeiger auf Strukturund Klassenobjekte«
Tabelle 1.16
1.9.8
Weitere Operatoren
Operanden unterschiedlichen Datentyps
Betrachten wir folgenden Codeschnipsel: int x=10; int y=4; double z = x / y; cout << z << endl; Listing 1.18
66
Division zweier ganzzahliger Variablen
Operatoren
Welcher Wert wird auf dem Bildschirm ausgegeben? Die Herleitung könnte folgendermaßen lauten: Die Variable z ist vom Typ double und kann damit Fließkommazahlen speichern. Die Division von 10 durch 4 ergibt 2,5, also wird genau das ausgegeben. Dieser Gedankengang berücksichtigt eine entscheidende Tatsache von C++ nicht. Denn der Datentyp eines Ausdrucks hängt salopp gesprochen vom Operanden mit dem größeren Wertebereich ab. Die Operanden des Ausdrucks (x und y) sind aber beide int, damit ist auch das Ergebnis int. Und weil bei einer Division von int-Werten die Nachkommastellen immer wegfallen, liefert der Ausdruck den Wert 2. Dabei spielt es keine Rolle, dass die 2 nachträglich einer double-Variablen zugewiesen wird. Denn die Ganzzahl 2, in eine Fließkommazahl umgewandelt, ist 2,0. Nun soll eine der beiden Variablen double sein: int x=10; double y=4; double z = x / y; cout << z << endl; Listing 1.19
Division von »int« und »double«
Der Divisionsoperator besitzt jetzt als Operanden einen int- und einen double-Wert (dabei spielt es keine Rolle, welcher der beiden Operanden vom Typ double ist). Der größere Wertebereich ist double und demnach auch der Typ des Ergebnisses. Also erhalten wir 2,5, welches in z gespeichert als 2,5 erhalten bleibt. Wäre z im oberen Beispiel vom Typ int, dann würde das Ergebnis des Ausdrucks auf 2 abgerundet und als Ganzzahl in z gespeichert. Bei dieser Umwandlung kann der Compiler eine Warnung melden, weil der Wertebereich eingeschränkt wird und dadurch Informationen verloren gehen können (in unserem Fall konkret die Nachkommastelle). Explizite Typumwandlung
Um ein vernünftiges Ergebnis zu erhalten, mussten wir im oberen Abschnitt den Typ einer der beiden Variablen abändern. Diese Vorgehensweise ist nicht immer möglich. Beispielsweise könnten die beiden Werte von Programmteilen geliefert werden, die nicht von uns programmiert wurden und deshalb nicht änderbar sind. Eine Variante wäre das Hinzufügen einer zusätzlichen Variablen mit dem gewünschten Typ, die dann in die Rechnung einfließt:
67
1.9
1
Grundlagen von ANSI C++
int x=10; int y=4; double tmp = y; double z = x / tmp; cout << z << endl;
Auf diese Weise erhalten wir das gewünschte Ergebnis, ohne die Datentypen der Variablen x und y zu verändern. Obwohl sich diese Vorgehensweise in manchen Lösungen anbietet, wäre es mühsam, immer zu diesem Schritt gezwungen zu sein. Deshalb gibt es die Möglichkeit der expliziten Typumwandlung. Im oberen Beispiel wurde nichts anderes gemacht, als mithilfe der Variablen z den Typ des in y gespeicherten Wertes implizit – also ohne konkret formuliert zu werden – umzuwandeln. War der Wert in y noch die Ganzzahl 4, so ist er in z die Fließkommazahl 4,0. Der Typ von y und der darin enthaltene Wert bleiben natürlich weiterhin int. Der Typ einer Variablen kann nachträglich nicht mehr geändert werden. Die explizite Typumwandlung versetzt uns in die Lage, den Typ eines Wertes umzuwandeln, ohne ihn dazu in einer anderen Variablen zwischenspeichern zu müssen. Der dazu notwendige Befehl heißt static_cast und hat folgende Syntax: static_cast(UmzuwandelderWert)
Der obere Ausdruck steht für den Wert UmzuwandelnderWert, der in den Typ Zieltyp umgewandelt wurde. Schauen wir uns das einmal praktisch angewendet an unserem vorigen Beispiel an: int x=10; int y=4; double z = x / static_cast<double>(y); cout << z << endl; Listing 1.20 Division mit expliziter Typumwandlung
Der rechte Operand des Divisonsoperators ist nun der mit static_cast in double umgewandelte Wert von y. Auch hier gilt es zu beachten, dass sich durch diese Umwandlung der Typ von y nicht verändert hat.
68
Operatoren
Es reicht aus, einen der beiden an der Division beteiligten Werte in double umzuwandeln, weil sich der Ergebnistyp immer nach dem Operandentyp mit dem größeren Wertebereich richtet. Dabei spielt es auch keine Rolle, welcher der beiden Operanden umgewandelt wird. Theoretisch könnten wir auch beide Operanden umwandeln, das fällt jedoch unter die Rubrik »unnötige Mühe«, weil der Compiler den verbliebenen int-Wert automatisch in double umwandeln muss, um eine vernünftige Division durchführen zu können.
1.9.9
Bindungsstärke
Einige Operatoren haben gegenüber anderen Vorrang – die Tabelle 1.17 bietet einen Überblick der Operatoren einschließlich ihrer Bindungsstärke. Operator
Bedeutung
::
Bezugsrahmenoperator
++
Postinkrement
--
Postdekrement
( )
Parameterliste
[ ]
Indexoperator
.
Elementoperator
->
Zeigeroperator
typeid
Typbestimmung
static_cast
statische Typumwandlung
dynamic_cast
dynamische Typumwandlung
reinterpret_cast
neu interpretierende Typumwandlung
const_cast
const »wegcasten«
+
unäres Plus
-
unäres Minus
++
Präinkrement
--
Prädekrement
!
logische Negation
~
bitweise Negation
&
Adressoperator
Tabelle 1.17
Operatoren und ihre Bindungsstärken
69
1.9
1
Grundlagen von ANSI C++
Operator
Bedeutung
*
Dereferenzierungsoperator
sizeof
Größenbestimmung
(typ)
Typumwandlung C-Stil
new
Speicherplatzreservierung
delete
Speicherplatzfreigabe
.*
Verweis auf Klassenelement über Objekt
->*
Verweis auf Klassenelement über Zeiger
*
Multiplikation
/
Division
%
Restwertbestimmung
+
Addition
-
Subtraktion
<<
bitweise Linksverschiebung
>>
bitweise Rechtsverschiebung
<
kleiner
<=
kleiner gleich
>=
größer gleich
>
größer
==
gleich
!=
ungleich
&
bitweises Und
^
bitweises exklusives Oder
|
bitweises inklusives Oder
&&
logisches Und
||
logisches inklusives Oder
? :
Ausdruck mit Bedingung
=
Zuweisung
+=
Additionszuweisung
-=
Subtraktionszuweisung
*=
Multiplikationszuweisung
Tabelle 1.17
70
Operatoren und ihre Bindungsstärken (Forts.)
Die cmath-Funktionen
Operator
Bedeutung
/=
Divisionszuweisung
%=
Restwertzuweisung
<<=
Linksverschiebungszuweisung
>>=
Rechtsverschiebungszuweisung
&=
bitweise Und-Zuweisung
|=
bitweise Oder-Zuweisung
~=
bitweise Negationszuweisung
Tabelle 1.17
Operatoren und ihre Bindungsstärken (Forts.)
Die in Tabelle 1.17 aufgeführten Operatoren sind ihrer Bindungsstärke entsprechend absteigend gruppiert. Demnach bindet * seine Operanden stärker als +. Der Ausdruck w+x*y+z wird deshalb von der Operatorreihenfolge her wie w+(x*z)+z abgearbeitet. Die Bindungsstärke wird auch als Priorität der Operatoren oder als Vorrangregel bezeichnet. Alle unären und Zuweisungsoperatoren werden von rechts nach links abgearbeitet, die übrigen von links nach rechts. Der Ausdruck x+y+z wird wie (x+y)+z und x+=y+=z wird wie x+=(y+=z) umgesetzt. Achtung, Überladung C++ erlaubt für eigene Datentypen das Überladen von Operatoren. Diese Überladungen können den Operatoren komplett andere Funktionalitäten mitgeben, als von den Standarddatentypen her bekannt. Deshalb sind die folgenden Beschreibungen nur bei den elementaren Datentypen garantiert. Eigene Datentypen sollten ihr Verhalten zwar optimalerweise dem der elementaren Datentypen anpassen, eine Garantie gibt es aber nicht. Prominentes Beispiel sind die bitweisen Verschiebeoperatoren << und >>, die bei cout und cin eine völlig andere Funktionalität besitzen.
1.10
Die cmath-Funktionen
Ergänzend zu den Operatoren gibt es noch eine Gruppe mathematischer Funktionen, die in der Header-Datei cmath zu finden sind. Es ist Ihnen vielleicht aufgefallen, dass der Modulo-Operator nur auf ganzzahlige Werte anwendbar ist. Das Pendant für die Fließkommawerte finden Sie in dieser Funktionsgruppe. Die Funktionen sind exemplarisch für den Datentyp double aufgelistet, existieren aber auch noch parallel für die ande-
71
1.10
1
Grundlagen von ANSI C++
ren Fließkommatypen. Diese Möglichkeit, mehrere Funktionen mit demselben Namen zu definieren, wird Überladen genannt (mehr zu diesem Thema finden Sie in Abschnitt 4.7, »Überladen von Methoden«). Funktion
Beschreibung
acos
Umkehrfunktion des Kosinus
asin
Umkehrfunktion des Sinus
atan, atan2
Umkehrfunktionen des Tangens
ceil
Aufrunden
cos
Kosinus
cosh
Kosinus hyperbolicus
exp
Exponentialfunktion
fabs
Absolutwert
floor
Abrunden
fmod
Modulo
frexp
Aufspaltung einerZahl a in a=f*2i
HUGE_VAL
Rückgabewert bei Bereichsüberschreitung
ldexp
Umkehrung zu frexp
log
natürlicher Logarithmus
log10
dekadischer Logarithmus
modf
Aufspaltung einer Zahl a in a=f+i
pow
Potenz
sin
Sinus
sinh
Sinus hyperbolicus
sqrt
Quadratwurzel
tan
Tangens
tanh
Tangens hyperbolicus
Tabelle 1.18
Die Funktionen von »cmath«
acos – Arkus Kosinus double acos(double a);
Der Arkus Kosinus ist die Umkehrfunktion des Kosinus für a im Bogenmaß im Bereich [0,pi].
72
Die cmath-Funktionen
asin – Arkus Sinus double asin(double a);
Der Arkus Sinus ist die Umkehrfunktion des Sinus für a im Bogenmaß im Bereich [-pi/2, pi/2]. atan – Arkus Tangens double atan(double a);
Der Arkus Tangens ist die Umkehrfunktion des Tangens für a im Bogenmaß im Bereich [-pi/2, pi/2]. atan2 – Arkus Tangens double atan2(double a, double b);
Der Arkus Tangens ist die Umkehrfunktion des Tangens für a/b im Bogenmaß im Bereich [-pi, pi]. ceil – Aufrunden double ceil(double a);
Liefert die nächsthöhere Ganzzahl von a. cos – Kosinus double cos(double a);
Liefert den Kosinus von a im Bogenmaß. cosh – Kosinus hyberbolicus double cosh(double a);
Liefert den Kosinus hyperbolicus von a im Bogenmaß. exp – Exponentialwert double exp(double a);
Liefert den Exponentialwert von a (ea). fabs – Absolutwert double fabs(double a);
Liefert den Betrag von a ( |a| ).
73
1.10
1
Grundlagen von ANSI C++
floor – Abrunden double floor(double a);
Liefert die nächstniedrige Ganzzahl von a. fmod – Modulo double fmod(double a, double b);
Liefert den Modulowert der Division a/b. frexp double frexp(double a, int *b);
Spaltet a in einen ganzzahligen Wert i und einen Fließkommawert f im Bereich [0.5, 1] auf, so dass gilt: a=f*2i. i wird in b gespeichert und f von der Funktion zurückgegeben. HUGE_VAL – der Overflow-Wert
Repräsentiert den Wert, den die cmath-Funktionen bei einer Wertüberschreitung (overflow) zurückliefern. ldexp double ldexp(double a, int b);
Liefert a*2b und bildet damit die Umkehrfunktion von frexp. log – natürlicher Logarithmus double log(double a);
Liefert den natürlichen Logarithmus (Logarithmus naturalis) von a zur Basis e. log10 – dekadischer Logarithmus double log10(double a);
Liefert den Logarithmus von a zur Basis 10 (dekadischer Logarithmus). modf double modf(double a, int *b);
Spaltet die Fließkommazahl a in einen ganzzahligen Wert i und einen die Nachkommastellen enthaltenden Fließkommawert f auf, so dass gilt: a=i+f. i wird in b gespeichert und f von der Funktion zurückgegeben.
74
Die cmath-Funktionen
pow – Potenz double pow(double a, double b);
Liefert ab. sin – Sinus double sin(double a);
Liefert den Sinus von a im Bogenmaß. sinh – Sinus hyperbolicus double sinh(double a);
Liefert den Sinus hyperbolicus von a im Bogenmaß. sqrt – Quadratwurzel double sqrt(double a);
Liefert die Quadratwurzel von a. tan – Tangens double tan(double a);
Liefert den Tangens von a im Bogenmaß. tanh – Tangens hyperbolicus double tanh(double a);
Liefert den Tangens hyperbolicus von a im Bogenmaß.
75
1.10
Dieses Kapitel behandelt die Kontrollstrukturen von C++, wie Verzweigungen, Schleifen etc.
2
Kontrollstrukturen
Das vorige Kapitel war zugegebenermaßen etwas trocken. Selbst bei den Grundlagen gibt es in C++ bereits viele Detailinformationen, für die sich Ihnen vielleicht nicht auf Anhieb ein Einsatzbereich erschließt. Dieses Kapitel beschäftigt sich mit den Möglichkeiten, den Programmfluss zu beeinflussen und eröffnet Ihnen damit erste Anwendungsgebiete für das im ersten Kapitel erworbene Wissen.
2.1
Verzweigungen
Bisher liefen unsere Programme schnurstracks vom Anfang zum Ende. Jeder Befehl wurde immer ausgeführt, keiner ausgelassen. Häufig ist es aber sinnvoll, die Ausführung eines Anweisungsbocks von einer Situation abhängig zu machen. Nehmen wir ein Beispiel aus der Praxis. Sie haben soeben ein Ticket für eine Achterbahnfahrt erworben und wollen nun einsteigen. Als Brillenträger wäre jetzt vielleicht ein guter Zeitpunkt, die Brille abzunehmen. Wollten wir diesen Sachverhalt mit unserem bisherigen Wissen programmieren, hätten wir ein Problem. Denn entweder müsste mit unseren bisherigen Mitteln jeder die Brille abnehmen, was eine Hürde für Personen ohne Brille darstellen könnte, oder niemand müsste die Brille abnehmen. Wir bräuchten die in Abbildung 2.1 dargestellte Möglichkeit, die Brille nur dann abnehmen zu lassen, wenn die Person auch tatsächlich eine Brille trägt. Die Abbildung zeigt, dass jemand ohne Brille gleich Platz nehmen kann, während Brillenträger zunächst die Brille abnehmen müssen. Und genau das ist eine Verzweigung. Anhand einer Bedingung (Brillenträger oder nicht) wird ein Programmteil abgearbeitet oder nicht.
77
2
Kontrollstrukturen
[Brillenträger] [sonst] Brille abnehmen
Hinsetzen
Abbildung 2.1
2.1.1
Eine Verzweigung im realen Leben
Bedingungen & bool
Wie am oberen Beispiel schön zu erkennen ist, hängt die Ausführung einer Aktion von einer Bedingung ab. In der Programmierung können solche Bedingungen entweder wahr oder falsch sein. Dazwischen gibt es nichts. Für diese beiden Zustände existieren in C++ die bereits vorgestellten Schlüsselwörter true und false, sowie der zur Speicherung dieser Werte verwendete Datentyp bool. bool a = true;
Ich möchte hier noch einmal darauf hinweisen, dass es in C++ zwischen bool und int keine strikte Typunterscheidung gibt, wie in Abschnitt 1.9.6, »Logische Operatoren«, bereits gezeigt wurde. Hinweis In C++ ist jeder Wert außer 0 wahr.
2.1.2
if
Jetzt fehlt nur noch der Befehl, mit dem auf den Wahrheitsgehalt einer Bedingung reagiert werden kann. Und dazu dient die if-Anweisung: if( Ausdruck ) Anweisung
78
Verzweigungen
Eine Anweisung kann eine einzelne, mit Semikolon abgeschlossene Anweisung oder ein mit { } definierter Block von Anweisungen sein. Abgearbeitet wird die Anweisung, wie in Abbildung 2.2 dargestellt.
[Ausdruck wahr] [Ausdruck falsch] Anweisung
Abbildung 2.2 Die Funktionsweise von »if«
Sollte der Ausdruck innerhalb der runden Klammen von if wahr sein, dann wird die hinter if stehende Anweisung (oder der Anweisungsblock) ausgeführt. Andernfalls wird sie einfach übersprungen. Wir haben es hier mit einer Wenn-Dann-Verzweigung zu tun. In den runden Klammern von if kann eine Boolesche Variable stehen: bool b=true; if(b) { // Anweisungen }
Auch die Angabe eines Vergleichs ist möglich, weil diese ebenfalls einen Booleschen Wert liefert (Vergleiche werden mit den Vergleichsoperatoren aus Abschnitt 1.9.5, »Vergleichsoperatoren«, formuliert): int i=23; if(i>50) { // Anweisungen }
Bis hierhin geht C++ konform mit anderen Sprachen wie C# oder Java. Weil in C++ aber alle Werte außer 0 wahr sind, ist auch so etwas erlaubt: double d=3.14; if(d) {
79
2.1
2
Kontrollstrukturen
// Anweisungen }
Im oberen Fall wird der Anweisungsblock hinter if abgearbeitet, weil 3,14 definitiv nicht 0 ist. In Listing 2.1 sehen Sie ein komplettes Beispiel für die Anwendung von if: #include using namespace std; int main() { cout << "Bitte Wert eingeben:"; int x; cin >> x; if(x<0) { cout << "Sie haben einen negativen Wert eingegeben!" << endl; } cout << "Der Wert lautet " << x << endl; } Listing 2.1
Ein Beispiel für »if«
Das Programm gibt den eingegebenen Wert aus, quittiert jedoch zusätzlich die Eingabe einer negativen Zahl. Die Verwendung von geschweiften Klammern hinter if ist in diesem Fall unnötig, weil der Anweisungsblock nur aus einer Anweisung besteht. Da sich die geschweiften Klammern aber nicht negativ auf die Laufzeit auswirken, stattdessen aber die Übersichtlichkeit erhöhen, können sie nicht schaden. Die Formatierung des Beispiels zeigt die übliche Vorgehensweise, die öffnende Klammer des Anweisungsblocks direkt hinter die vorhergehende Anweisung zu schreiben.
2.1.3
else
Häufig möchte man aber nicht nur eine Aktion ausführen, wenn eine Bedingung wahr ist, sondern auch noch Programmcode abarbeiten lassen, wenn die Bedingung wahr ist. Wir haben es dann mit einer Entweder-Oder-Verzweigung zu tun.
80
Verzweigungen
Bevor wir für die oben beschriebene Achterbahnfahrt Platz nehmen können, müssen wir zum Beispiel ein Ticket erwerben. Das könnte wie in Abbildung 2.3 skizziert vonstatten gehen.
[sonst] [Älter als 12]
5€ bezahlen
3€ bezahlen
Ticket einstecken
Abbildung 2.3 Ticketverkauf als Wenn-Dann-Verzweigung
Ist der Mitfahrer älter als zwölf Jahre, muss er 5 € bezahlen, ansonsten 3 €. Er muss auf jeden Fall einen der beiden Preise bezahlen, entweder den einen oder den anderen, aber niemals beide oder keinen. Ein numerisches Beispiel ist das Prüfen auf 0. Wenn eine Variable den Wert 0 hat, soll ein entsprechender Text erscheinen. Ist sie ungleich 0, dann soll dies ebenfalls über eine Ausgabe mitgeteilt werden. Auch hier haben wir wieder eine Entweder-Oder-Verzweigung. Entweder die Zahl ist 0 oder nicht. Eins von beiden muss zutreffen, niemals beides und niemals keines. Mit unserem bisherigen Kenntnisstand können wir das Problem bereits lösen, indem wir zwei if-Verzweigungen hintereinandersetzen; die erste prüft auf gleich 0, die zweite auf ungleich 0: int x=1; if(x==0) { cout << "Der Wert ist null." << endl; } if(x!=0) { cout << "Der Wert ist ungleich null." << endl; } Listing 2.2 Zweimal »if« für entweder-oder
81
2.1
2
Kontrollstrukturen
Diese Lösung wirkt aber irgendwie doppelt gemoppelt, denn letztlich ist die zweite Bedingung nichts anderes als die negierte erste Bedingung. Weil eine solche Konstruktion häufiger vorkommt, gibt es das Schlüsselwort else, welches bezogen auf einen vorangehenden if-Anweisungsblock einen zweiten Anweisungsblock definiert, der immer dann ausgeführt wird, wenn die Bedingung bei if falsch ist: if( Ausdruck ) Anweisung1 else Anweisung2
Anweisung1 und Anweisung2 dürfen mit { } definierte Anweisungsblöcke sein. Anweisung1 wird wie gehabt ausgeführt, wenn der Ausdruck in den runden Klammern von if wahr ist. Sollte der Ausdruck aber falsch sein, dann wird Anweisung2 hinter else ausgeführt. Das vorige Beispiel der Prüfung auf 0 kann dann wie folgt umgeschrieben werden: int x=1; if(x==0) { cout << "Der Wert ist null." << endl; } else { cout << "Der Wert ist ungleich null." << endl; } Listing 2.3
Entweder-oder mit »if« und »else«
Vereinfachungen
Für Anweisungsblöcke gibt es die angenehme Vereinfachung, dass die geschweiften Klammern weglassen werden können, wenn der Anweisungsblock aus nur einer Anweisung besteht. Damit lässt sich das Beispiel aus Listing 2.3 wie folgt verkürzen: int x=1;if(x==0)
cout << "Der Wert ist null." << endl;else cout << "Der Wert ist ungleich null." << endl;
Jedoch kann insbesondere bei verschachtelten Anweisungsblöcken durch diese Vereinfachung ein erhebliches Maß an Übersichtlichkeit verloren gehen. Deswegen sollten die Klammern im Zweifel lieber einmal zu viel als einmal zu wenig gesetzt werden.
82
Verzweigungen
2.1.4
Einsatz logischer Operatoren
Stellen Sie sich vor, aus einem an dieser Stelle irrelevanten Anlass ist eine Zahl gültig, wenn sie größer 0 und kleiner 100 ist. Mit unserem bisherigen Wissen müssten wir für diese Abfrage zwei if-Anweisungen verschachteln: int x=1; if(x>0) if(x<100) cout << "Der Wert ist gueltig." << endl; Listing 2.4
Und-Verknüpfung mit zwei verschachtelten if-Blöcken
Weil eine if-Anweisung samt Anweisungsblock (und ihrem vielleicht vorhandenen else-Block) als eine Anweisung gilt, können wir auch bei der äußeren if-Anweisung auf die geschweiften Klammern verzichten. Wenn aber auch ausgegeben werden soll, dass ein Wert außerhalb des gültigen Bereichs ungültig ist, wo müssten wir diese Ausgabe im oberen Beispiel unterbringen? Die Lösung mag auf den ersten Blick verwirren: int x=1; if(x>0) if(x<100) cout << "Der Wert ist gueltig." << endl; else cout << "Der Wert ist ungueltig." << endl; else cout << "Der Wert ist ungueltig." << endl; Listing 2.5
Zusätzliche Ausgabe bei ungültigem Wert
Die gewünschte Information wird an zwei Stellen ausgegeben. Bei genauerem Hinschauen ist das auch logisch, denn sowohl die erste, als auch die zweite if-Anweisung könnten feststellen, dass der Wert ungültig ist, daher muss auch jede if-Anweisung diese Information mit ihrem eigenen elseBlock mitteilen. In der Schreibweise ohne expliziten Anweisungsblock ist optisch eigentlich nur durch das Einrücken einigermaßen ersichtlich, welches else zu welchem if gehört. Im Zweifel sollten Sie daher die Schreibweise mit geschweiften Klammern verwenden: int x=1; if(x>0) {
83
2.1
2
Kontrollstrukturen
if(x<100) { cout << "Der Wert ist gueltig." << endl; } else { cout << "Der Wert ist ungueltig." << endl; } } else { cout << "Der Wert ist ungueltig." << endl; } Listing 2.6 Die ausführliche Fassung von Listing 2.5
Da die Verknüpfung von Bedingungen bei der Programmierung eher der Regelfall ist, werden die logischen Operatoren (siehe Abschnitt 1.9.6, »Logische Operatoren«) verwendet. Der Und-Operator liefert genau dann true, wenn beide durch ihn verknüpften Bedingungen wahr sind. Ist nur eine der Bedingungen wahr oder keine, dann liefert er false. Er ist damit der ideale Kandidat, um das vorige Beispiel zu vereinfachen: int x=1; if(x>0 && x<100) cout << "Der Wert ist gueltig." << endl; else cout << "Der Wert ist ungueltig." << endl; Listing 2.7
2.1.5
Die Und-Verknüpfung mit &&-Operator
?:-Operator
An manchen Stellen kann in C++ keine Anweisung stehen, wohl aber ein Ausdruck. Ein Beispiel ist die Elementinitialisierungsliste eines Konstruktors. Aus diesem Grund gibt es einen Operator, der die Funktionalität eines ifelse-Konstrukts besitzt. Betrachten wir das folgende Programmfragment, welches der Variablen erg den größeren der beiden Werte von a und b zuweist: int a=15, b=30, erg; if(a>b) erg=a; else erg=b;
84
Verzweigungen
Der Wert von erg hängt im oberen Beispiel von einer Bedingung ab; ist a>b wahr, dann wird erg der Wert von a zugewiesen, andernfalls der Wert von b. Dies sieht mit dem ?:-Operator so aus: int a=15, b=30, erg; erg = (a>b)?a:b; Listing 2.8
Der Einsatz des ?:-Operators
Die Bedeutung der einzelnen Teile ist in Abbildung 2.4 dargestellt.
Bedingung
Wert, den der Ausdruck annimmt, wenn die Bedingung false ist
erg = (a>b)?a:b ; Wert, den der Ausdruck annimmt, wenn die Bedingung true ist
Abbildung 2.4 Funktionsweise des ?:-Operators
Vor dem Fragezeichen steht die Bedingung, die den Wert des Ausdrucks bestimmt. Ist diese Bedingung wahr, dann nimmt der ?:-Operator den hinter dem Fragezeichen stehenden Wert an. Ist die Bedingung falsch, dann steht der ?:-Operator für den Wert hinter dem Doppelpunkt. Weil der ?:-Operator auch viel kürzer ist und weniger Schreibarbeit erfordert, wird er gerne anstelle eines if-Konstrukts verwendet, auch wenn if problemlos einsetzbar wäre.
2.1.6
switch & case
Stellen Sie sich folgende Situation vor: Es wird eine Zahl eingelesen, und wenn diese Zahl 3, 5 oder 7 ist, dann soll die Zahl als Wort (drei, fünf, sieben) ausgegeben werden. Bisher können wir dies nur mit drei if-Anweisungen bewerkstelligen: cout << "Zahl eingeben:"; int x; cin >> x;
Im konkreten Fall schließen sich die drei Bedingungen logisch aus (Der Wert kann zum Beispiel nicht gleichzeitig 3 und 5 sein). Theoretisch – und praktisch allemal – könnte aber in einem if-Anweisungsblock etwas geschehen, wodurch die nächste Bedingung wahr wird. Wir simulieren das einmal ganz plump, indem der Variablen der entsprechende Wert zugewiesen wird: if(x==3) { cout << "drei" << endl; x=7; } if(x==5) cout << "fuenf" << endl; if(x==7) cout << "sieben" << endl;
Sollte x zu Beginn nun 3 sein, dann wird »drei« und »sieben« ausgegeben. Obwohl dieses Verhalten in seltenen Fällen gewünscht sein mag, versucht man überwiegend, das Phänomen einer mehrfachen Ausgabe zu vermeiden. Doch wie? Am einfachsten geht das, indem Sie das folgende if-Konstrukt immer in den else-Teil des vorhergehenden if packen. Im Folgenden sehen Sie das Ergebnis ausführlich mit Klammern und korrekter Einrückung (das Setzen von x auf 7 wurde wieder entfernt): if(x==3) { cout << "drei" << endl; } else { if(x==5) { cout << "fuenf" << endl; }
Wir haben auf diese Weise ein in C++ nicht vorhandenes else-if-Konstrukt simuliert. Um dies zu verdeutlichen, wird der Code formatiert, als gäbe es ein else-if: if(x==3) { cout << "drei" << endl; } else if(x==5) { cout << "fuenf" << endl; } else if(x==7) { cout << "sieben" << endl; } Listing 2.11 Simulation eines »else if« (kompakt)
Worauf das Beispiel aber hinauslaufen soll: Für die Unterscheidung verschiedener Fälle anhand von Werten gibt es die Fallunterscheidung. Das obere Beispiel sieht mit der Fallunterscheidung so aus: switch(x) { case 3: cout << "drei" << endl; break; case 5: cout << "fuenf" << endl; break; case 7: cout << "sieben" << endl; break; } Listing 2.12 Die Funktionsweise von »switch«
Die Funktionsweise ist in Abbildung 2.5 noch einmal grafisch dargestellt.
87
2.1
2
Kontrollstrukturen
Die switch-Syntax lautet: switch( Ausdruck ) { case GanzzahligeKonstante: // Anweisungen case GanzzahligeKonstante: // Anweisungen ... }
Abbildung 2.5 Die Funktionsweise von »switch« mit »break«
In den runden Klammern hinter switch wird angegeben, für welche Variable die Fallunterscheidung getroffen werden soll. Erlaubt sind nur ganzzahlige Variablen sowie die Variablentypen bool und char, die technisch auch zu den ganzzahligen Typen gezählt werden. Hinter case steht eine ganze Zahl (es können auch die Schlüsselwörter true oder false sein oder ein Zeichen in einfachen Anführungszeichen), die den Wert definiert, bei dem der caseBlock abgearbeitet wird. Die Angabe von Bereichen ist nicht möglich. Das break zum Abschluss jeden case-Blocks ist wichtig, damit nach der Ausführung des Blocks der switch-Block verlassen wird. Es ist syntaktisch auch erlaubt, auf die break-Anweisungen zu verzichten: switch(x) { case 3: cout << "drei" << endl;
88
Verzweigungen
case 5: cout << "fuenf" << endl; case 7: cout << "sieben" << endl; }
Allerdings ist das Verhalten etwas gewöhnungsbedürftig. Abbildung 2.6 zeigt den Ablauf.
Der Programmfluss zeigt sehr schön, dass die case-Anweisungen eigentlich nur Sprungmarken innerhalb des switch-Blocks sind. Der Wert 5 hat zum Beispiel die Ausgabe von »fuenf« und »sieben« zur Folge. Ohne das break wird die entsprechende case-Marke angesprungen und das Programm von dort aus bis zum Ende des switch-Blocks durchlaufen. Es stellt sich vielleicht die Frage, warum dieses Verhalten existiert und nicht automatisch nach Ende eines case-Blocks der gesamte switch-Block verlassen wird. Die Antwort ist einfach: Auf diese Weise können Codeverdopplungen vermieden werden. Es kommt nämlich häufiger vor, dass für mehrere Fälle derselbe Programmcode abgearbeitet werden soll. Dies wäre mit switch nicht möglich, wenn ein case-Block automatisch beendet würde. Nehmen wir als Beispiel ein Programm, welches nach einem Monat als Zahl fragt (1=Januar, 2=Februar etc.) und daraufhin die Anzahl der Tage dieses Monats
89
2.1
2
Kontrollstrukturen
ausgibt. Schaltjahre sollen dabei unberücksichtigt bleiben. Mit switch könnte die Lösung so aussehen: #include using namespace std; int main() { cout << "Bitte Monat eingeben (1-12):"; int monat; cin >> monat; switch(monat) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: cout << "Der Monat hat 31 Tage" << endl; break; case 4: case 6: case 9: case 11: cout << "Der Monat hat 30 Tage" << endl; break; case 2: cout << "Der Monat hat 28 Tage" << endl; break; } } Listing 2.13
Bestimmung der Tage eines Monats
Aus Platzgründen wurden einige case-Anweisungen nebeneinandergeschrieben, was durchaus erlaubt und in diesem Fall auch sinnvoll ist. default
Für switch gibt es noch einen besonderen Fall, nämlich denjenigen, der alle nicht behandelten Fälle abdeckt. Immer wenn die bei switch angegebene Variable einen Wert besitzt, für den es kein case gibt, dann wird – falls vorhanden – die default-Marke angesprungen. Die default-Marke können wir bei der Bestimmung der Tage eines Monats in Listing 2.13 verwenden, um ungültige Monatsangaben abzufangen. Hier der aktualisierte switch-Block: switch(monat) { case 1: case 3: case 5: case 7: case 8: case 10: case 12:
90
Schleifen
cout << "Der Monat hat 31 Tage" << endl; break; case 4: case 6: case 9: case 11: cout << "Der Monat hat 30 Tage" << endl; break; case 2: cout << "Der Monat hat 28 Tage" << endl; break; default: cout << "Ungueltige Monatsangabe!" << endl; break; } Listing 2.14
switch-Block mit default-Marke
Die default-Marke muss nicht zwingend die letzte Marke im switch-Block sein. Hier die Syntax: switch( Ausdruck ) { case GanzzahligeKonstante: // Anweisungen case GanzzahligeKonstante: // Anweisungen ... default: // Anweisungen }
2.2
Schleifen
Nachdem unsere Programme nun aufgrund einer Bedingung verzweigen können, wollen wir nun klären, wie Programmabschnitte wiederholt werden können, ohne sie mehrfach ins Programm schreiben zu müssen. Das ist immer dann sinnvoll, wenn ähnliche Programmschritte wiederholt werden sollen. Stellen Sie sich vor, Sie müssten zehn Zahlen einlesen und diese aufaddieren. Dann macht es einen Unterschied, ob Sie die Eingabe für jede Zahl einzeln – also zehnmal – programmieren, so wie wir es mit dem jetzigen
91
2.2
2
Kontrollstrukturen
Stand machen müssten, oder ob die Eingabe einmal programmiert und dann zehn Mal wiederholt wird.
2.2.1
while
Die einfachste Form der Wiederholung ist die while-Schleife: while( Ausdruck ) Anweisung
Der Programmablauf unterscheidet sich aber wesentlich, wie Abbildung 2.7 zeigt.
[Ausdruck wahr] [Ausdruck falsch] Anweisung
Abbildung 2.7 Die while-Schleife
Genau wie bei der if-Anweisung wird die Anweisung bzw. der Anweisungsblock nur ausgeführt, wenn der in den runden Klammern angegebene Ausdruck true ist. Nach der Ausführung des Anweisungsblocks springt das Programm aber wieder zurück zum Schleifenkopf und prüft den Ausdruck erneut. Sollte er immer noch true sein, dann wird der Anweisungsblock nochmals ausgeführt usw. Erst wenn der Boolesche Wert false ist, wird die Schleife beendet, und das Programm fährt hinter der Schleife fort. Die simpelste Form der Schleife sieht so aus: while(true) { cout << "Ich bin eine Schleife" << endl; } Listing 2.15
92
Eine Endlosschleife
Schleifen
Diese Schleife gibt wiederholt einen Text aus. Als Ausdruck wurde true angegeben. Da true den Wert true hat und sich das auch nicht so schnell ändert, wird die Schleife niemals enden. Wir haben eine Endlosschleife konstruiert. In den meisten Fällen ist eine solche Schleife unerwünscht. Sie ist nur dann sinnvoll, wenn es innerhalb der Schleife einen anderen Mechanismus zum Schleifenabbruch gibt, wie zum Beispiel break oder return. An dieser Stelle wollen wir den Fokus aber auf eine Programmierung lenken, bei der die Schleife sauber beendet wird. Dies geschieht nur, wenn der Ausdruck bei while irgendwann einmal false wird. Das schließt den Einsatz von Konstanten schon mal aus. Wir brauchen also eine Variable, die eine Zeitlang true liefert und dann zu false wechselt. Am einfachsten erreichen wir dies durch die Formulierung einer Bedingung: int a=1; while(a<=10) { cout << a << endl; ++a; } Listing 2.16 Eine Schleife, die von 1–10 zählt
Die Variable a wird zu Beginn mit dem Wert 1 initialisiert. Die Bedingung der while-Schleife (a<=10) ist damit true und der Anweisungsblock der Schleife wird ausgeführt. Darin wird die Variable ausgegeben und um 1 erhöht. Am Ende des Anweisungsblocks springt das Programm wieder zum Schleifenkopf und prüft die Bedingung erneut. Nun ist a gleich 2 und die Bedingung immer noch true; der Anweisungsblock wird wieder ausgeführt. So läuft die Schleife, bis a den Wert 11 erreicht und die Bedingung damit false wird. Die Schleife bricht ab. Wenn wir uns den Anweisungsblock genauer ansehen, stellen wir fest, dass die Ausgabe a ausgibt, bevor es inkrementiert wird. Gewissermaßen wird der alte Wert von a ausgegeben. Nun sollte es klingeln. Alter Wert… da war doch was. Genau, Postinkrement. Wenn sowieso der alte Wert ausgegeben wird, dann können wir gleich bei der Ausgabe ein Postinkrement tätigen und haben so den Anweisungsblock auf eine Anweisung verkleinert. Und bei einer Anweisung im Anweisungsblock können wir die Klammern weglassen: int a=1; while(a<=10) cout << a++ << endl; Listing 2.17
Die Schleife in kompakter Form
93
2.2
2
Kontrollstrukturen
Um sich noch ein wenig mit der Schleifenmechanik zu beschäftigen, können Sie darüber nachdenken, wie die Schleife noch verändert werden müsste, wenn anstelle von a++ das Präinkrement ++a verwendet wird. Die Schleife soll dann natürlich weiterhin die Zahlen von 1 bis 10 ausgeben. Die Lösung sieht so aus (die veränderten Stellen sind hervorgehoben): int a=0; while(a<10) cout << ++a << endl; Listing 2.18
Mit Präinkrement von 1–10 zählen
Rein aus Gründen der Lesbarkeit sollten Sie die erste Variante bevorzugen.
2.2.2
do while
Der zweite Schleifentyp in C++ ist die do-while-Schleife: do { Anweisung } while( Ausdruck );
Bitte beachten Sie das abschließende Semikolon hinter while (weil kein Anweisungsblock folgt). Im Vergleich zur while-Schleife steht das while nun am Ende des Schleifenblocks, was sich auch in der Schleifenlogik niederschlägt, die in Abbildung 2.8 dargestellt ist.
Anweisung [Ausdruck wahr]
[Ausdruck falsch]
Abbildung 2.8 Die do-while-Schleife
94
Schleifen
Wie die Abbildung klar hervorhebt, wird der Anweisungsblock beim Eintritt in die Schleife auf jeden Fall einmal ausgeführt, völlig unabhängig davon, ob der Ausdruck true oder false ist. Wir erinnern uns: Bei der while-Schleife ist es theoretisch auch möglich, dass der Schleifenblock überhaupt nicht ausgeführt wird, nämlich wenn der Ausdruck zu Beginn bereits false ist. Die do-while-Schleife führt ihren Schleifenblock aber definitiv einmal aus. Wozu kann das nützlich sein? Immer dann, wenn die Bedingung der Schleife von Anweisungen im Schleifenblock abhängig ist. Nehmen wir als Beispiel ein Programm, welches den Anwender nach einer Zahl fragt. Liegt diese Zahl nicht im gültigen Bereich, muss der Anwender die Zahl erneut eingeben, bis er eine gültige Zahl eingegeben hat. Bei dieser Aufgabe hängt die Bedingung der Schleife (Laufe, solange eingegebene Zahl ungültig) von der Eingabe im Schleifenblock ab. Der Schleifenblock muss demnach erst einmal ausgeführt werden, bevor die Schleifenbedingung sinnvoll geprüft werden kann. Schauen wir uns das dazugehörige Programm an, welches von einem gültigen Bereich der Zahl zwischen einschließlich 1 und 100 ausgeht: int a; do { cout << "Zahl eingeben [1,100]:"; cin >> a; } while(a<1 || a>100); Listing 2.19
Prüfen auf eine gültige Zahl mit »do while«
Es ist bei diesem Beispiel ausgesprochen wichtig, dass die Variable a außerhalb des Schleifenblocks definiert wird. Warum das so ist, erklärt Abschnitt 2.3.1, »Lokale Variablen«. Die im Schleifenblock eingelesene Variable wird am Schleifenende in while überprüft. Wenn der Wert nicht im gültigen Bereich liegt, wird nochmals der Schleifenblock durchlaufen und der Wert eingelesen. Dieses Problem hätten wir auch mit einer while-Schleife lösen können: int a=0; while(a<1 || a>100) { cout << "Zahl eingeben [1,100]:"; cin >> a; } Listing 2.20
Prüfen auf gültige Zahl mit »while«
95
2.2
2
Kontrollstrukturen
Der Ansatz mit der while-Schleife hat aber einen Schönheitsfehler: Weil die Bedingung bei while schon vor der Abarbeitung des Anweisungsblocks geprüft wird, müssen wir dafür Sorge tragen, dass die Schleife mit Sicherheit auch betreten wird. Die obere Lösung erreicht dies, indem die Variable a mit einem ungültigen Wert vorinitialisiert wird. Diese Lösung ist aber weitaus fehleranfälliger, weil bei einem Wechsel des gültigen Bereichs (z. B. [-100,100]) der Initialisierungswert von a plötzlich gültig sein könnte. Er müsste deshalb ebenfalls angepasst werden. Bei einem kleinen Programm wie unserem Beispiel ist das noch überschaubar, aber in komplexeren Programmen kann diese zusätzliche Änderung leicht übersehen werden. Der Ansatz mit der do-while-Schleife ist in diesem Fall der bessere.
2.2.3
for
Der komplexeste Schleifentyp in C++ ist die for-Schleife. Nehmen wir zum besseren Verständnis die folgende, von 1 bis 10 zählende while-Schleife zu Hilfe: int a=1; while(a<=10) { cout << a << endl; a++; } Listing 2.21 Zählen von 1–10
Wirklich als nützliche Anweisung wiederholt wird eigentlich nur die Ausgabe. Alle anderen Befehle sind technische Maßnahmen, um überhaupt eine kontrollierte Wiederholung zustande zu bringen. Schauen wir uns diese Teile einmal im Detail an: int a=1;
Dieser Teil initialisiert die Zählvariable mit dem Wert 1. while(a<=10)
Hier wird die Laufbedingung der Schleife formuliert (laufe, solange der Wert der Zählvariablen kleiner gleich 10 ist). Der dritte und letzte Teil dient dazu, die Schleife in den nächsten Schritt zu überführen, indem die Zählvariable um 1 erhöht wird: a++;
96
Schleifen
Man nennt diesen Schritt bei einer Schleife auch Iteration. Die for-Schleife fasst diese drei Aspekte syntaktisch komprimiert zusammen: for(Initialisierung; Bedingung; Iteration) Anweisung
Die von 1 bis 10 zählende Schleife, mit for umgesetzt, sieht so aus: for(int a=1; a<=10; a++) cout << a << endl;
Abbildung 2.9 hebt die wichtigen Bestandteile hervor. Initialisierung
Bedingung
Iteration
for(int a=1; a<=10; a++) { cout << a << endl; } Abbildung 2.9 Die Bestandteile der for-Schleife
Die tatsächliche Reihenfolge der einzelnen Anweisungen ist der Syntax nur bedingt zu entnehmen. Sie ist in Abbildung 2.10 dargestellt.
Initialisierung
[Bedingung wahr]
Anweisungsblock
Iteration
[Bedingung falsch]
Abbildung 2.10
Der Ablauf einer for-Schleife
Am Anfang wird einmalig der Initialisierungsteil ausgeführt. Danach wird – wie bei einer while-Schleife – die Bedingung geprüft, noch vor der Ausführung des Anweisungsblocks. Ebenso wie bei der while-Schleife ist es demnach
97
2.2
2
Kontrollstrukturen
möglich, dass bei einer ursprünglich falschen Bedingung der Anweisungsblock nie zur Ausführung kommt. Sollte die Bedingung wahr sein, werden der Anweisungsblock und daran anschließend der Iterationsteil abgearbeitet. Obwohl also der Iterationsteil mit im Schleifenkopf steht, wird er erst nach dem Anweisungsblock ausgeführt. Nach der Iteration wird wieder die Bedingung geprüft und gegebenenfalls eine weitere Wiederholung eingeleitet.
2.2.4
Wann welche Schleife?
Die Frage »Wann welche Schleife?« wird häufig gestellt, eine eindeutige Antwort existiert jedoch nicht. Prinzipiell gibt es kein Problem, dass nicht mit allen drei Schleifenkonstrukten gelöst werden kann. Die Praxis zeigt lediglich, dass bei bestimmten Problemen der eine oder der andere Schleifentyp eine syntaktisch elegantere Formulierung erlaubt oder eine geringere Fehleranfälligkeit mit sich bringt. In diesem Zusammenhang sei auf das Beispiel mit dem Einlesen einer Zahl und anschließendem Prüfen auf Gültigkeit verwiesen (Listing 2.19 in Abschnitt 2.2.2, »do while«). Unsere Betrachtungen haben gezeigt, dass hier ganz klar die do-while-Schleife die Nase vorn hat. Folgende Tipps sollen als grobe, unverbindliche Richtlinien gelten: 왘
Verwenden Sie eine while-Schleife, wenn das Ende der Schleife im Vorfeld nicht bekannt ist, wie zum Beispiel beim Auslesen einer Datei, dem Herunterladen eines HTML-Dokuments aus dem Internet etc.
왘
Benutzen Sie eine do-while-Schleife, wenn die Schleifenbedingung von Ergebnissen abhängig ist, die erst im Schleifenblock ermittelt werden. Ein Beispiel ist das Abfragen einer Zahl in Abschnitt 2.2.2, »do while«.
왘
Eine for-Schleife sollten Sie einsetzen, wenn etwas durchlaufen werden soll, dessen Anfang und Ende bekannt ist, beispielsweise ein Feld, ein Datencontainer, ein Suchergebnis etc.
Es ist klar, dass es für diese Anregungen bestimmt genauso viele Gegenbeispiele wie Bestätigungen gibt, deswegen betrachten Sie sie bitte wirklich nur als Denkanstoß.
2.2.5
break
Der Befehl break wurde bereits bei der Fallunterscheidung in Abschnitt 2.1.6, »switch & case«, eingesetzt. Dort sorgte er für ein Verlassen des switchBlocks. Innerhalb einer Schleife leistet er ähnliche Dienste; er beendet die innerste Schleife.
98
Schleifen
Mit seiner Hilfe ist es möglich, eine von der Bedingung her Endlosschleife zu programmieren, die praktisch jedoch ein Ende hat: int a=1; while(true) { if(a>10) break; cout << a++ << endl; } Listing 2.22 Schleifenabbruch mit break
Alleine von der while-Konstruktion haben wir eine Endlosschleife, wäre in ihrem Block nicht die if-Anweisung, die ein break ausführt, sobald a größer 10 ist. Um zu zeigen, dass nur die innerste Schleife beendet wird, lassen wir den oberen Code mit einer weiteren Schleife zweimal ausführen. Der Abwechslung halber nehmen wir eine for-Schleife, eine while- oder dowhile-Schleife hätte natürlich denselben Zweck erfüllt: for(int b=1; b<=2; b++) { int a=1; while(true) { if(a>10) break; cout << a++ << endl; } } Listing 2.23
break bei verschachtelten Schleifen
Die äußere Schleife versieht zuverlässig ihren Dienst, während die innere Schleife wie gehabt von break beendet wird.
2.2.6
continue
Ein weiterer Befehl zur Kontrolle des Schleifenflusses ist continue. Er bricht nicht wie break die gesamte Schleife ab, sondern nur die Abarbeitung des Schleifenanweisungsblocks, um zum nächsten Iterationsschritt zu gelangen. Nehmen wir einmal folgendes sinnlose Beispiel: for(int w=1; w<=10; w++) { if(w==6) continue; cout << w << endl; }
99
2.2
2
Kontrollstrukturen
Wenn Sie dieses Codefragment kompilieren und starten, werden Sie sehen, dass die 6 nicht ausgegeben wird. Denn mit w gleich 6 wird der Befehl continue ausgeführt, der an das Ende des Anweisungsblocks springt – und damit die Ausgabe überspringt – und das Programm mit dem Iterationsschritt fortsetzt. Bedenken Sie, dass bei einer while- oder do while-Schleife die Erhöhung/Verminderung der Variablen zum Anweisungsblock gehört und daher mit continue ebenfalls übersprungen werden könnte.
2.3
Funktionen
Mit Schleifen können Codeabschnitte wiederholt werden. Was aber, wenn diese Codeabschnitte an verschiedenen Stellen im Programm liegen? Nehmen wir als Arbeitsgrundlage folgendes Codefragment: cout << "Hopphopphopp" << endl; cout << "------------" << endl; cout << "Hopphopphopp" << endl;
Die Ausgabe von »Hopphopphopp«, die hier stellvertretend für komplexere Codeabschnitte steht, erfolgt in identischer Art und Weise an zwei verschiedenen Stellen im Code. Obwohl zweimal dasselbe geschieht, steht der dazu notwendige Code zweimal im Programm. Nicht nur, dass das Programm dadurch länger wird, bei eventuellen Änderungen an den Codeabschnitten müssen auch alle Abschnitte geändert werden. Die Gefahr, dass nicht alle in gleichem Maße geändert werden oder ein Abschnitt übersehen wird, ist groß. Es wäre doch viel praktischer, wenn die Ausgabe von »Hopphopphopp« nur einmal im Programm vorkäme und wir nur noch darauf zu verweisen bräuchten. Die Lösung dieses Problems lautet Funktionen. Mithilfe einer Funktion haben wir die Möglichkeit, Programmcode in einer unabhängigen Einheit zu kapseln. Hier die Syntax der Funktionsdefinition: Rückgabetyp Funktionsname(Parameterliste) { // Anweisungen }
Unsere Funktion soll keinen Rückgabewert besitzen (wir wissen augenblicklich ja noch nicht einmal, was das ist), deswegen muss vor dem Funktionsna-
100
Funktionen
men als Rückgabetyp void stehen. Der Funktionsname sollte möglichst aussagekräftig sein, damit der Anwender schnell erfassen kann, welchem Zweck die Funktion dient. Passend wäre hier hoppausgabe. Hinter dem Funktionsnamen stehen Funktionsparameter. Da noch nicht besprochen wurde, was damit gemacht wird, verzichten wir bis auf Weiteres auf sie und lassen die runden Klammern leer. Im Anweisungsblock hinter dem Funktionskopf steht der zur Funktion gehörende Programmcode, in diesem Fall die Ausgabe von »Hopphopphopp«. Daraus folgt die nachstehende Funktion: void hoppausgabe() { cout << "Hopphopphopp" << endl; } Listing 2.24
Die Funktion hoppausgabe
Für unsere ersten Ausflüge in die Programmierung von Funktionen ist es ausgesprochen wichtig, dass die neue Funktion vor main steht. Später werden wir in dieser Hinsicht noch flexibler. Einige Punkte sind bei der Definition einer Funktion zu beachten: 왘
Auch wenn die Funktion keine Parameter besitzt, muss das leere runde Klammerpaar vorhanden sein.
왘
Die Vereinfachungsregel aus Abschnitt 2.1, »Verzweigungen«, ist bei den geschweiften Klammern des Funktionsanweisungsblocks nicht anwendbar; sie müssen geschrieben werden, auch wenn der Funktionsblock nur aus einer Anweisung besteht.
왘
Die Angabe von void bei nicht erwünschtem Rückgabewert ist zwingend.
Aufgerufen wird die Funktion durch Angabe des Namens, gefolgt von der Parameterliste (den runden Klammern), die bei unserer Funktion leer bleibt: blahausgabe();
Sehen wir uns nun das vollständige Programm an: #include using namespace std; void hoppausgabe() { cout << "Hopphopphopp" << endl; }
Jedes Mal, wenn die Funktion hoppausgabe aufgerufen wird, springt das Programm zur Funktion und merkt sich den Punkt, von dem aus es zur Funktion gesprungen ist. Nach Beendigung der Funktion springt das Programm dann an die Stelle zurück, an der die Funktion aufgerufen wurde, und fährt dort mit der Ausführung fort.
2.3.1
Lokale Variablen
Bevor wir uns mit Funktionsparametern befassen, müssen wir noch Klarheit darüber schaffen, was lokale Variablen sind. Nehmen wir dazu Listing 2.19 in Abschnitt 2.2.2, »do while«, und wandeln es leicht ab: do { cout << "Zahl eingeben [1,100]:"; int a; cin >> a; } while(a<1 || a>100); // Fehler Listing 2.26 Eine Schleife mit lokaler Variablen
Anders als im Original wird die Variable a jetzt innerhalb des Schleifenblocks definiert. Erstaunlicherweise meldet der Compiler in der letzten Zeile einen Fehler. Angeblich sei die Variable a überhaupt nicht deklariert. Und das Schlimme ist: Er hat Recht. Ohne dass wir es bisher wussten, arbeiten wir momentan ausschließlich mit lokalen Variablen. Hinweis Lokale Variablen werden innerhalb eines Anweisungsblocks definiert und sind nur in ihm (und in darin eingeschlossenen Blöcken) zugänglich.
Und genau deshalb ist a in der letzten Zeile unseres Programms nicht mehr existent, weil while außerhalb des Anweisungsblocks liegt.
102
Funktionen
In diesem Fall haben wir die Variable zu spät definiert, denn damit sie hinter dem Schleifenblock noch existiert, muss sie vor dem Schleifenblock definiert werden. Trotzdem hat die späte Definition von Variablen ihre Vorteile. Zur Demonstration wird der Fehler im vorigen Codeabschnitt behoben und eine Ergänzung vorgenommen: cout << "Wollen Sie eine Zahl eingeben? (1=ja, 0=nein):"; int x; cin >> x; if(x==1) { int a; do { cout << "Zahl eingeben [1,100]:"; cin >> a; } while(a<1 || a>100); cout << "Eingegebene Zahl: " << a << endl; } Listing 2.27
Sinnvoller Einsatz einer späten Definition
Nun wird der Anwender vorher gefragt, ob er überhaupt eine Zahl eingeben soll. Die Variable a wurde jetzt innerhalb des if-Blocks, aber außerhalb des Schleifenblocks definiert. Aber wir hätten ja auch gleich Nägel mit Köpfen machen und a noch vor dem if-Block definieren können. Hätten wir, aber die jetzige Variante ist performanter. Und zwar aus einem einfachen Grund: Wozu wird die Variable a benötigt, wenn der Anwender gar keine Zahl eingeben möchte? Für nichts. Warum sie dann also definieren? Durch die Definition im if-Block wird a nur dann angelegt, wenn der Anwender auch wirklich eine Zahl eingeben möchte. Wenn nicht, bleibt dem Rechner das Reservieren des für a nötigen Speichers erspart. Das Programm läuft schneller und benötigt weniger Speicher. Zugegeben, bei den heutigen Rechnern fällt diese Einsparung so gut wie überhaupt nicht ins Gewicht, aber in einer anderen Situation könnte solch ein Codeabschnitt in einer sich millionenfach wiederholenden Schleife stecken, und, glauben Sie mir, dann macht sich die Optimierung auf jedem Rechner bemerkbar. Lokale Variablen und »for«
Eine Besonderheit haben die lokalen Variablen im Zusammenhang mit for. Nehmen wir folgende Schleife:
103
2.3
2
Kontrollstrukturen
for(int x=0; x<5; ++x) cout << "-"; cout << endl; cout << "Anzahl an Strichen:" << x << endl;
Was wird ausgegeben? Oder wird überhaupt etwas ausgegeben? Alles läuft auf die Frage hinaus, in welchem Anweisungsblock das x definiert wurde, im Anweisungsblock der Schleife oder im die Schleife umschließenden Anweisungsblock? Auf diese Frage gibt es zwei Antworten. Nach der neuen Norm ist eine im for-Kopf definierte Variable eine lokale Variable des Schleifenblocks. Demnach ist sie nach dem Verlassen des Schleifenblocks nicht mehr existent und kann daher hinter der Schleife auch nicht ausgegeben werden. Nach der alten Norm gehört x zu dem die Schleife umschließenden Block und kann daher hinter der Schleife ausgegeben werden. Da die Schleife solange läuft, wie x kleiner 5 ist, bricht sie bei x gleich 5 ab. Auf dem Bildschirm erscheint 5. Visual C++ 2010 hält sich an die neue Norm. Das obere Beispiel wird daher nicht einwandfrei kompiliert.
2.3.2
Funktionen mit Parametern
Kommen wir zu den Funktionen zurück. Funktionsparameter sind immer dann nützlich, wenn die Funktion zur Erledigung ihrer Arbeit noch zusätzliche Informationen braucht. Wenden wir uns dazu noch einmal der Ausgabe von »Hopphopphopp« zu. Zwischen den beiden Ausgaben wurde ein aus Bindestrichen bestehender horizontaler Strich ausgegeben. Zwecks Wiederverwendung wäre es doch schön, eine Funktion strich zu besitzen, die einen Strich variabler Länge ausgibt. Diese variable Länge soll über einen Funktionsparameter mitgeteilt werden. Ein Funktionsparameter wird innerhalb der runden Klammern wie eine typische Variable definiert, mit Typ und Namen: void strich(int x) { for(int a=1; a<=x; ++a) cout << "-"; cout << endl; } Listing 2.28
104
Die Funktion »strich«
Funktionen
Wenn nun ein Strich aus 15 Bindestrichen gezeichnet werden soll, dann sieht der Aufruf so aus: strich(15);
Es ist auch möglich, anstelle der Konstanten eine Variable anzugeben: cout << "Wie lang soll der Strich sein:"; int s; cin >> s; strich(s);
Hinweis Parameter sind lokale Variablen der Funktion und nur in ihr gültig.
Ein Funktionsparameter ist technisch nichts anderes als eine lokale Variable der Funktion. Das bedeutet, er kann innerhalb der Funktion wie eine herkömmliche Variable verwendet werden. Mit diesem Wissen im Hinterkopf können wir die Funktion strich so optimieren, dass sie ohne die Variable a auskommt: void strich(int x) { for(; x>=1; --x) cout << "-"; cout << endl; } Listing 2.29
Die optimierte Funktion »strich«
Der Funktionsparameter x wird in der Schleife heruntergezählt, solange er noch größer gleich 1 ist. Da x in der Schleife nicht initialisiert werden muss – denn x wurde bereits automatisch mit dem an die Funktion übergebenen Wert initialisiert –, bleibt der Initialisierungsteil leer. Wir bleiben noch kurz bei den Parametern und ihrer Bedeutung als lokale Variablen und schauen uns folgenden Code an: void aendern(int x) { x=50; } int main() { int x=20; cout << x << endl;
105
2.3
2
Kontrollstrukturen
aendern(x); cout << x << endl; }
Welche Werte werden ausgegeben? Die Versuchung ist groß, bei der zweiten Ausgabe auf 50 zu tippen. Aber bleiben Sie standhaft. Wir haben gelernt, dass Funktionsparameter lokale Variablen sind. Das gilt demnach auch für das x in aendern. Das x in main ist ebenfalls eine lokale Variable. Und lokale Variablen sind nur innerhalb des sie umschließenden Anweisungsblocks gültig. Für Funktionsparameter ist das der Funktionsanweisungsblock, auch wenn das optisch nicht zu erkennen ist. Wenn also das x in aendern nur in aendern gültig ist und das x in main nur in main existiert, dann muss es sich bei diesen beiden Variablen um unterschiedliche Variablen handeln. Jede Funktion besitzt ihr eigenes x. Von daher hat eine Zuweisung von 50 in aendern nur Auswirkung auf das x in aendern, nicht aber auf das x in main.
2.3.3
Mehrere Parameter
Eine Funktion kann beliebig viele Parameter besitzen. Zur Demonstration folgt eine Funktion rechteck, die ein aus Doppelkreuzen gezeichnetes Rechteck ausgibt: void rechteck(int x, int y) { for(int a=1; a<=y; ++a) { for(int b=1; b<=x; ++b) cout << "#"; cout << endl; } } Listing 2.30
Die Funktion »rechteck«
Hinweis Für jeden Funktionsparameter muss der Datentyp explizit angegeben werden.
Die äußere Schleife kann übrigens nicht auf die geschweiften Klammern verzichten, weil sie zwei Anweisungen enthält: die innere Schleife und die Ausgabe von endl.
106
Funktionen
2.3.4
Rückgabewerte
Die Funktion hoppausgabe soll derart verbessert werden, dass die Anzahl der ausgegebenen Hopps über einen Funktionsparameter variabel gehalten wird: void hoppausgabe(int hopps) { if(hopps>=1) { cout << "Hopp"; for(; hopps>=2; --hopps) cout << "hopp"; cout << endl; } } Listing 2.31
»hoppausgabe« mit variabler Anzahl an Hopps
Die Funktion wird wegen der if-Anweisung erst dann richtig aktiv, wenn die Anzahl der auszugebenden Hopps größer gleich 1 ist. Die Ausgabe des ersten Hopps ist wegen des großen H ein Sonderfall, deshalb wird sie außerhalb der Schleife getätigt. Innerhalb der Schleife muss nun ein Hopp weniger ausgegeben werden, daher die Laufbedingung hopps>=2. Damit uns diese Funktion zur Demonstration des Rückgabewertes auch ein »sinnvolles« Ergebnis liefern kann, soll sie bestimmen, aus wie vielen Zeichen das von ihr erzeugte »Hopphopphopp« besteht. Um einen Wert zurückzugeben, wird der Befehl return verwendet. Der Befehl sorgt für eine sofortige Beendigung der Funktion und liefert den hinter ihm angegebenen Wert zurück. Damit es der Funktion aber überhaupt erst erlaubt ist, einen Wert zurückzugeben, muss das void im Funktionskopf durch den Datentyp des Rückgabewerts ersetzt werden: 01 02 03 04 05 06 07 08 09 10 11 12 13
Die Variable anz ist vom Typ int. Damit ihr Wert am Ende der Funktion zurückgegeben werden kann, muss der Rückgabetyp der Funktion ebenfalls int sein (und nicht mehr void). Bei einer Funktion mit Rückgabewert steht der Funktionsaufruf für den zurückgegebenen Wert. Um den Rückgabewert beispielsweise in einer Variablen zu speichern, muss der Funktionsaufruf einfach einer Variablen zugewiesen werden: int c=hoppausgabe(3); cout << "Ausgegebene Zeichen:" << c << endl;
Der Funktionsaufruf kann überall dort stehen, wo auch eine Konstante erlaubt ist. Im Gegensatz zu anderen Sprachen muss das return nicht zwangsläufig am Ende der Funktion stehen und darf auch mehrfach vorkommen: int maximum(int x, int y) { if(x>y) return(x); else return(y); } Listing 2.33
Die Funktion »maximum«
Achtung Bei einer Funktion, die einen Wert zurückgibt, muss jeder Programmpfad einen Wert zurückliefern.
Ein Beispiel: int fehlerhaft(int x) { if(x>=10) return(1); } Listing 2.34
Fehlerhafter Einsatz von »return«
Die obere Funktion liefert nur dann einen Wert zurück, wenn x größer gleich 10 ist. Obwohl der Compiler bei dieser Funktion »nur« eine Warnung ausgibt, sind die Auswirkungen auf den Rückgabewert bei x kleiner 10 fatal, weil dann ein unvorhersehbarer Wert zurückgegeben wird. Ein solches Funktionsverhalten muss unter allen Umständen vermieden werden.
108
Funktionen
Tipp Eine Funktion ohne Rückgabewert kann ebenfalls an jeder beliebigen Stelle mit return beendet werden. Hinter return darf dann nur kein Wert stehen.
2.3.5
Standardwerte für Parameter
C++ erlaubt es, für Funktionsparameter Standardwerte zu definieren. Im folgenden Beispiel bekommt die Funktion hoppausgabe einen Standardwert: int hoppausgabe(int hopps=3) { // Anweisungen der Funktion } Listing 2.35
Die Funktion »hoppausgabe« mit Standardwerten
Sollte die Funktion jetzt ohne Angabe eines Parameters aufgerufen werden, also so hoppausgabe();
dann wird der Funktionsparameter hopps automatisch mit dem Wert 3 initialisiert. Bei der Verwendung von Standardwerten müssen zwei Regeln beachtet werden. Wenn eine Funktion Standardwerte besitzt, dann 왘
muss der letzte Parameter auf jeden Fall einen Standardwert haben, und
왘
es dürfen zwischen Parametern mit Standardwert keine Parameter ohne Standardwert liegen.
Das heißt, die Vergabe der Standardparameter beginnt mit dem letzten Funktionsparameter und hangelt sich dann bis maximal zum ersten durch. Achtung Durch den Einsatz von Standardwerten dürfen keine Zweideutigkeiten mit Überladungen entstehen.
Betrachten wir dazu die beiden folgenden Funktionen, deren Anweisungsblöcke leer bleiben: void test(int x) {} void test(int x, int y=5) {}
Würde test mit nur einem Parameter aufgerufen (test(8);), dann wüsste der Compiler nicht, ob er die einparametrige Funktion oder die zweipara-
109
2.3
2
Kontrollstrukturen
metrige Funktion mit Standardwert nehmen soll. Deshalb würde das obere Beispiel nicht kompiliert. Hinweis Als Vorgriff auf den nächsten Abschnitt: Standardwerte werden nur bei der Funktionsdeklaration angegeben, nicht bei der Funktionsdefinition.
2.3.6
Funktionsdeklarationen
Um die Bedeutung von Funktionsdeklarationen nachvollziehen zu können, wollen wir die Funktionsdefinition von maximum hinter die main-Funktion setzen: int main() { cout << maximum(10,20) << endl; } int maximum(int x, int y) { if(x>y) return(x); else return(y); } Listing 2.36
Funktionsdefinition hinter Funktionsaufruf
Durch diese Umpositionierung wird das Programm nicht mehr korrekt kompiliert: Der Compiler kompiliert eine Datei ordentlich von oben nach unten. An der Stelle des Aufrufs von maximum ist dem Compiler die Funktion aber noch nicht bekannt. Allerdings reicht es dem Compiler, wenn die Funktion zum Zeitpunkt des Aufrufs nur deklariert ist. Die Deklaration einer Funktion gibt lediglich Auskunft darüber, von welchem Typ eventuelle Funktionsparameter oder Rückgabewerte sind. Für maximum sieht die Funktionsdeklaration so aus: int maximum(int, int);
Für den Fall, dass die Namen der Funktionsparameter aussagekräftig gewählt wurden, bietet sich ihre Angabe ebenfalls an: int maximum(int x, int y);
Im Falle von maximum ist die Aussagekraft der Parameternamen eher zweifelhaft, aber dafür sagt der Funktionsname eigentlich alles.
110
Module
Tipp Dem Compiler reicht für den Aufruf einer Funktion deren Deklaration.
Die Ähnlichkeit der Funktionsdeklaration mit dem Funktionskopf ist frappierend. Eigentlich ist die Funktionsdeklaration nichts anderes, als der mit Semikolon abgeschlossene Funktionskopf ohne Anweisungsblock. Eine Frage wird jetzt bestimmt aufkommen: Warum sollte die Funktionsdefinition hinter den Funktionsaufruf gesetzt werden, nur um dann vor dem Aufruf eine Funktionsdeklaration zu platzieren? Hätte dann nicht gleich die Funktionsdefinition vor dem Aufruf stehen können? Die Antwort gibt der nächste Abschnitt.
2.4
Module
Stellen Sie sich vor, Sie hätten ein Programm mit 500 Funktionen geschrieben, die, wie bisher, alle in einer Datei stehen. Wenn Sie nun eine klitzekleine Änderung an einer dieser Funktionen vornehmen, muss die gesamte Datei mit allen 500 Funktionen neu kompiliert werden. Wäre es nicht besser, wenn nur die eine geänderte Funktion neu kompiliert würde? Ein C++-Compiler kompiliert grundsätzlich immer dateiweise. Wenn etwas von einer Änderung nicht betroffen sein soll, dann muss es in einer anderen Datei stehen. Je feiner diese Aufteilung ist, desto weniger muss unnötig kompiliert werden.
2.4.1
Definition auslagern
Wir wollen daher exemplarisch die maximum-Funktion in einer eigenen Datei unterbringen. Dazu muss im aktuellen Projekt eine neue Datei mit dem Namen funktionen.cpp angelegt werden. Der Name der Datei ist für den Compiler unwesentlich, sollte aber aussagekräftig gewählt werden, um schnell ihren Inhalt einschätzen zu können (wie eine neue Datei im aktuellen Projekt angelegt wird, erklärt Abschnitt 1.1.1, »C++-Datei hinzufügen«). In diese Datei wird nun die Funktionsdefinition verschoben. Die Deklaration von maximum sollte an ihrem alten Platz bleiben. Eine Änderung an einer der beiden Dateien hat nun keine Neukompilation der anderen Datei mehr zur Folge. Die Deklaration muss auf jeden Fall erhalten bleiben, denn der Compiler kompiliert jede Datei für sich getrennt. Wenn er die Datei mit der main-Funk-
111
2.4
2
Kontrollstrukturen
tion kompiliert, dann weiß er nicht, ob er funktionen.cpp bereits kompiliert hat oder überhaupt kompilieren wird. Die Deklaration ist unser Versprechen an den Compiler, dass die Funktion in einer anderen Datei definiert ist. Ob dieses Versprechen eingehalten wird, stellt nach der Kompilation der Linker fest, der alle einzeln kompilierten Dateien zu einem ausführbaren Programm zusammenbindet.
2.4.2
Deklaration auslagern
Eine Deklaration immer dorthin zu schreiben, wo sie benötigt wird, ist nicht sehr effizient, denn bei einem komplexeren Projekt werden Funktionen in vielen Dateien benötigt, die dann alle ihre eigene Deklaration beinhalten müssten. Aus diesem Grund wird auch die Deklaration ausgelagert und überall dort eingebunden, wo sie gebraucht wird. Funktionsdeklarationen werden in Header-Dateien mit der Endung .h gespeichert. Um eine einfachere Zuordnung herzustellen, sollte die Header-Datei so benannt werden wie die dazugehörige cpp-Datei. Diese Namensgleichheit ist nicht vorgeschrieben, sondern nur eine stillschweigende Vereinbarung der C++-Programmierer, um die Dateistruktur eines Projekts besser erfassen zu können. Abbildung 2.11 fasst die aktuelle Projektaufteilung noch einmal zusammen. funktionen.cpp
funktionen.h
main.cpp
int maximum(int x, int y) { if(x>y) return(x); else return(y); }
int maximum(int, int);
#include
Abbildung 2.11
#include "funktionen.h" using namespace std; int main() { cout << maximum(10,20) << endl; }
Die Aufteilung des Projekts
Hinweis Eine syntaktische Besonderheit ist an der Einbindung der eigenen Header-Datei zu erkennen: Der Einsatz spitzer Klammern < > hinter include bewirkt eine Suche der Datei in den Standardverzeichnissen des Compilers. Die Verwendung von Anführungszeichen lässt den Compiler im aktuellen Projektverzeichnis suchen.
Im Gegensatz zu iostream, wo der Name in spitzen Klammern steht, findet sich die eigene Header-Datei in Anführungszeichen wieder. Spitze Klammern teilen dem Präprozessor mit, die einzubindende Datei in den Standardver-
112
Module
zeichnissen des Compilers zu suchen. Aus diesem Grund werden HeaderDateien der Standardbibliothek immer mit spitzen Klammern eingebunden. Die Anführungszeichen besagen, dass die Datei ausgehend vom aktuellen Projektverzeichnis gesucht werden soll. Es sind auch relative Ordnerangaben möglich: #include "meineincludes/datei.h";
Die obere Zeile veranlasst den Präprozessor, die Datei datei.h im Ordner meineincludes zu suchen, der sich im Projektverzeichnis befindet. #include "../datei.h";
Diese Anweisung sucht nach der Datei in dem dem Projektverzeichnis übergeordneten Verzeichnis.
2.4.3
Kompilationsvorgang
Die aktuelle Projektkonfiguration erlaubt es uns, einige grundlegende Mechanismen der Kompilation eines Projektes zu betrachten, die so auch bei den komplexesten Projekten anzutreffen ist. Als Grundlage dieser Betrachtung soll Abbildung 2.12 dienen. Projektverwaltung Präprozessor funktionen.h iostream
Präprozessor
inclu in
de clude
main.cpp
funktionen.cpp
Compiler
Compiler
main.obj
funktionen.obj
Linker
Projektname.exe
Abbildung 2.12
Der Kompilationsvorgang im Detail
113
2.4
2
Kontrollstrukturen
Der steuernde Kern ist die Projektverwaltung. Sie prüft, welche Dateien wegen Änderung neu kompiliert werden müssen. Dabei werden auch Abhängigkeiten berücksichtigt. In unserem Projekt muss beispielsweise die Datei main.cpp neu kompiliert werden, wenn funktionen.h geändert wurde, denn sie wird von main.cpp eingebunden. Bevor die Quellcode-Dateien (.cpp) dem Compiler zum Fraß vorgeworfen werden, durchläuft sie der Präprozessor und sucht nach Anweisungen für sich. Das Einbinden der Header-Dateien (.h) geschieht zum Beispiel durch den Präprozessor. Hinweis Der Compiler kompiliert nur Quellcode-Dateien.
Das vom Präprozessor erzeugte Zwischenergebnis wird nicht in einer Datei zwischengespeichert, sondern direkt zum Compiler durchgereicht. Die Abbildung zeigt sehr schön, dass der Compiler – und damit auch der Präprozessor – nur Quellcode-Dateien bearbeitet. Header-Dateien werden vom Compiler nur berücksichtigt, wenn sie zuvor mit #include in eine QuellcodeDatei eingebunden wurden. Wichtig ist auch zu wissen, dass der Compiler jede Quellcode-Datei isoliert kompiliert. Während der Kompilation eines Projekts weiß er nicht, welche Dateien er bereits kompiliert hat und welche er noch kompilieren wird. Er kennt nur die Datei, die er gerade kompiliert. Daher kann der Compiler auch nicht feststellen, ob für eine eingebundene Funktionsdeklaration eine passende Definition tatsächlich in einer anderen Datei steht. Der Compiler speichert die kompilierten Quellcodedateien in sogenannten Objektdateien, die bei Visual C++ die Endung .obj haben, bei anderen Compilern aber durchaus andere Endungen (wie .o) besitzen können. Funktionsaufrufe bestehen in diesen Dateien nur in Form von Verweisen, weil die tatsächlichen Aufrufziele in den meisten Fällen nicht ausgemacht werden können (da sie häufig in anderen Dateien stehen). Die vom Compiler erzeugten Objektdateien werden nun alle zusammen dem Linker übergeben. Er ist es, der dadurch den kompletten Überblick über alle Funktionsaufrufe und alle kompilierten Funktionen besitzt und die Aufrufe den Funktionen zuordnen kann. Er merkt auch, wenn eine Funktion deklariert, aber nicht definiert wurde. Die meisten der vom Linker gemeldeten
114
Module
Fehler beziehen sich auf Funktions- oder Objektverweise, zu denen keine Funktion oder kein Objekt passt. Ist das Zusammenfügen fehlerfrei vonstatten gegangen, dann speichert der Linker das Ergebnis als ausführbare Datei (.exe) mit dem Namen des Projekts.
2.4.4
Mehrfachdeklarationen vermeiden
Es ist durchaus möglich, dass innerhalb einer Header-Datei mittels #include eine andere Header-Datei eingebunden wird. In der Praxis kommt es daher häufiger vor, dass eine Quellcode-Datei wegen einer Verkettung von #include eine Header-Datei mehrfach einbindet und Funktionen oder Klassen dadurch mehrfach deklariert werden. Dies ist in C++ nicht erlaubt, weswegen Schutzmaßnahmen ergriffen werden müssen, die dies verhindern. Wenn Sie in Ihrem Projekt in der Zeile #include
mit der rechten Maustaste auf iostream klicken und dann über Dokument öffnen die Datei öffnen, dann sehen Sie als erste Präprozessordirektive #pragma once
Der Befehl #pragma dient dazu, Befehle anzusprechen, die an die Entwicklungsumgebung gebunden sind. Diese Befehle sind daher von Natur aus nicht unter jeder Entwicklungsumgebung verwendbar. Tipp Mit #pragma once
als erster Anweisung in einer Header-Datei wird eine mögliche Mehrfachdeklaration verhindert.
Die Direktive #pragma once ist ein Befehl speziell von Visual C++ und teilt dem Präprozessor mit, dass er die Header-Datei, die diese Direktive beinhaltet, nur einmal pro Quellcode-Datei einzubinden hat, auch wenn die Datei durch Verkettung eigentlich mehrfach eingebunden werden müsste. Soll das Programm auf ein anderes System portiert werden, auf dem #pragma once nicht existiert, dann muss ein anderer, standardkonformer Weg gegangen werden. Dieser Weg macht sich die Präprozessorfähigkeit der bedingten Kompilierung zunutze. Sehen wir uns dazu folgendes Beispiel an:
115
2.4
2
Kontrollstrukturen
#ifndef FUNKTIONEN_H #define FUNKTIONEN_H // Hier steht der Inhalt der Datei #endif Listing 2.37
Bedingte Kompilation über den Präprozessor
Der erste Befehl #ifndef besagt, dass der Programmcode bis zum #endif nur dann an den Compiler weitergeleitet wird, wenn die Konstante hinter #ifndef nicht definiert ist. Wurde sichergestellt, dass die verwendete Konstante nur einmal im Projekt vorkommt, dann ist dies beim ersten Einbinden der Datei der Fall. Die Einzigartigkeit der verwendeten Konstante ist im Normalfall gewährleistet, wenn als Bezeichner der Dateiname verwendet wird. Aus syntaktischen Gründen – der Name einer Präprozessorkonstante darf keine Punkte enthalten – muss der Punkt durch einen Unterstrich ersetzt werden. Direkt hinter #ifndef wird nun die besagte Konstante definiert. Sollte diese Datei nochmals eingebunden werden, dann ist beim zweiten Mal die Konstante definiert und der Bereich zwischen #ifndef und #endif wird nicht an den Compiler weitergeleitet. Ein mehrmaliges Deklarieren wird so vermieden. Dieses bedingte Kompilieren kann natürlich auch dazu eingesetzt werden, verschiedene Versionen des Programms zu erstellen. Beispielsweise könnten Ausgaben, die nur bei der Programmentwicklung getätigt werden sollen, in einen entsprechenden if-Block des Präprozessors gepackt werden: #ifdef TESTVERSION cout << "Programm läuft noch" << endl; #endif
Die Direktive #ifdef ist das Gegenstück zu #ifndef; der ihr folgende Code wird dann an den Compiler weitergeleitet, wenn die Konstante definiert ist.
116
In diesem Kapitel besprechen wir verschiedene Möglichkeiten, wie mehrere Werte gespeichert werden können.
3
Komplexere Datentypen
Die bisherigen elementaren Datentypen können immer nur jeweils einen Wert speichern. Wenn Sie mit dieser Technik ein Programm schreiben müssten, welches 100 Werte einliest, aufaddiert und daraus einen Durchschnittswert berechnet, dann wären Sie eine Weile beschäftigt. Aus diesem und vielen anderen Gründen gibt es in C++ komplexere Datentypen, die aus den elementaren Datentypen zusammengesetzt sind.
3.1
Arrays
Sie erinnern sich vielleicht noch an die Indexschreibweise aus der Mathematik. Da hatte man es mit x2 oder a6 zu tun und konnte auf diese Weise mit einem Buchstaben verschiedene Werte abdecken, die durch den Index eindeutig benannt waren. Es ging sogar so weit, dass der Index selbst wieder eine Variable sein konnte, wie zum Beispiel xn oder am-1. Dieser Schreibweise kann eine gewisse Eleganz nicht abgesprochen werden. Grund genug, so etwas auch in C++ haben zu wollen. Und genau das gibt es in Form der Arrays. Rekapitulieren wir kurz die Definition einer Variablen eines beliebigen Typs, im folgenden exemplarisch int: int x;
Unter den Namen x ist jetzt ein einziger Wert ansprechbar. Die Syntax eines Arrays lautet: Datentyp Arrayname[Elementanzahl];
Wenn wir mehrere Werte haben wollen, dann verwenden wir bei der Definition den Indexoperator, der in C++ durch eckige Klammern dargestellt wird, und schreiben die Anzahl der gewünschten Werte hinein:
117
3
Komplexere Datentypen
int f[10];
Wir haben damit ein Array erstellt, dessen einzelne Werte wiederum über den Indexoperator angesprochen werden. Dabei gibt es eine wichtige Regel zu beachten: Achtung Das erste Element eines Arrays hat immer den Index 0.
Das erzeugte Array hat damit den in Abbildung 3.1 dargestellten Aufbau: f 0
1
2
Abbildung 3.1
3
4
5
6
7
8
9
Der interne Aufbau des Arrays
Soll das sechste Element den Wert 15 zugewiesen bekommen, sähe die entsprechende Anweisung so aus: f[5] = 15;
Es ist vielleicht gewöhnungsbedürftig, das sechste Element mit Index 5 anzusprechen, aber eine direkte Konsequenz aus der Tatsache, dass das erste Element den Index 0 hat. Achten Sie darauf, keinen Index außerhalb des gültigen Bereichs zu verwenden, da dies während der Kompilation nicht erkannt wird und während der Laufzeit unangenehme Probleme mit sich bringen kann, die nicht immer direkt der tatsächlichen Fehlerquelle zuzuordnen sind.
3.1.1
Variabler Index
Wie in der Mathematik erlauben die Arrays, als Index eine ganzzahlige Variable zu verwenden: int f[10]; for(int i=0; i<10; ++i) f[i]=0; Listing 3.1
Feldelemente mit 0 initialisieren
Als komplettes Beispiel soll hier die zu Beginn des Kapitels angesprochene Durchschnittsberechnung demonstriert werden, allerdings nur für zehn Werte, um das Testen des Programms nicht allzu sehr in die Länge zu ziehen:
Um das Programm einfacher an eine andere Anzahl von Werten anpassen zu können, wurde die Wertanzahl innerhalb des Programms als Konstante definiert. Das Ändern der Anzahl betrifft nun nur noch zentral eine Stelle im Programm, wodurch die Fehleranfälligkeit dramatisch reduziert wird. Denn je mehr Stellen von einer Änderung betroffen sind, desto größer ist die Wahrscheinlichkeit, eine zu übersehen.
3.1.2
Mehrdimensionale Arrays
Mithilfe des Indexoperators ist es auf einfache Weise möglich, mehrdimensionale Felder zu definieren. Für jede Dimension wird bei der Definition ein eigener Index angegeben: int g[4][20];
Analog dazu müssen auch bei der Bestimmung eines Elements beide Indexoperatoren angegeben werden: g[0][0] = 4;
Diese Syntax ist beliebig auf weitere Dimensionen erweiterbar. Dabei ist zu berücksichtigen, dass bei der Definition der Speicher für alle Elemente – auch
119
3.1
3
Komplexere Datentypen
der unbelegten – reserviert wird und der Bedarf bei mehreren Dimensionen schnell explodieren kann.
3.1.3
Einschränkungen von Arrays
Im Vergleich zu Datentypen wie den Vektoren besitzen Arrays einige Einschränkungen: 왘
Die Größe von Arrays ist fest.
왘
Ein Array kann keinem anderen Array direkt zugewiesen werden.
왘
Es gibt keine direkte Möglichkeit, die Anzahl der Elemente in einem Array zu bestimmen.
왘
Arrays können nicht ohne besondere Vorkehrungen als Funktionsparameter verwendet werden.
3.2
C-Strings
Die Arrays setzen wir nun ein, um C-Strings zu erstellen. Anschließend schauen wir uns den Klassentyp string an.
3.2.1
char
Ein Zeichen kann in C++ relativ einfach gespeichert werden. Wir benötigen dazu den bereits in Abschnitt 1.8, »Datentypen«, kurz vorgestellten Datentyp char: char c;
Im Gegensatz zu Zeichenketten, die in doppelten Anführungszeichen stehen, werden einzelne Zeichen in einfache Anführungszeichen gesetzt: c = 'X';
Der Datentyp char wird von cin und cout unterstützt: cout << "Zeichen eingeben:"; char c; cin >> c; cout << "Zeichen:" << c << endl;
Intern ist char als ganzzahliger Wert organisiert, eine Zuweisung an einen anderen numerischen Datentyp ist daher möglich:
120
C-Strings
char c='A'; int i=c; double d=c; cout << i << endl;
Die Ausgabe am Ende des Codefragments liefert den für das Zeichen »A« stehenden Wert. Wegen dieser »Doppeldeutigkeit« von char sind auch Rechenoperatoren anwendbar: char x='A'+4; cout << x << endl;
Auf dem Bildschirm erscheint ein »E«.
3.2.2
cctype-Funktionen
Eine wichtige Bibliothek im Zusammenhang mit Zeichen ist cctype. Es lohnt ein Blick hinein, den Tabelle 3.1 gewährt. Funktion
Beschreibung
int isalnum(int c);
Liefert einen wahren Wert, wenn es sich bei dem Zeichen um einen Buchstaben oder eine Ziffer handelt.
int isalpha(int c);
Liefert einen wahren Wert, wenn es sich bei dem Zeichen um einen Buchstaben handelt.
int isdigit(int c);
Liefert einen wahren Wert, wenn es sich bei dem Zeichen um eine Ziffer handelt.
int islower(int c);
Liefert einen wahren Wert, wenn es sich bei dem Zeichen um einen Kleinbuchstaben handelt.
int isspace(int c);
Liefert einen wahren Wert, wenn es sich bei dem Zeichen um ein Leerzeichen, FF (Form Feed), NL (New Line), CR (Carriage Return), HT (Horizontal Tab) oder VT (Vertical Tab) handelt.
int isupper(int c);
Liefert einen wahren Wert, wenn es sich bei dem Zeichen um einen Großbuchstaben handelt.
int tolower(int c);
Liefert einen Kleinbuchstaben zurück, wenn das übergebene Zeichen ein Großbuchstabe ist. Andernfalls wird das übergebene Zeichen zurückgegeben.
int toupper(int c);
Liefert einen Großbuchstaben zurück, wenn das übergebene Zeichen ein Kleinbuchstabe ist. Andernfalls wird das übergebene Zeichen zurückgegeben.
Tabelle 3.1 Die Funktionen von »cctype«
121
3.2
3
Komplexere Datentypen
Dabei ist für die oberen Funktionen die Formulierung »einen wahren Wert« nicht als das Boolesche true zu verstehen, sondern als irgendeine Ganzzahl ungleich 0. Das nachstehende Beispiel zeigt exemplarisch den Einsatz von isalpha: cout << "Zeichen:"; char z; cin >> z; if(isalpha(z)) cout << "Ein Buchstabe" << endl; else cout << "Kein Buchstabe" << endl; Listing 3.3
Eingabe auf Buchstaben prüfen
Es ist wichtig, zu berücksichtigen, dass die cctype-Funktionen nur auf den ASCII-Zeichensatz anwendbar sind. Umlaute beispielsweise sind mit den Funktionen nicht zu verarbeiten. Eine Lokalisierung – wie man die Anpassung des Programms an eine spezielle Menschensprache nennt – ist in reinem C++ etwas aufwändiger und soll hier nicht besprochen werden, da .NET dies bereits beherrscht.
3.2.3
Aufbau von C-Strings
Eine Zeichenkette ist eigentlich nichts anderes als eine Aneinanderreihung von Zeichen. Diesen Ansatz verfolgen die C-Strings, die ihren Namen der Tatsache verdanken, bereits unter C eingesetzt worden zu sein. Zeichen können mit der gleichen Technik aneinandergereiht werden wie andere Datentypen, nämlich mit einem Array: char s[20];
Das Array können Sie nun über den Indexoperator mit Zeichen bestücken: s[0]='C'; s[1]='+'; s[2]='+';
Die Ausgabe von C-Strings wird von cout unterstützt: cout << s << endl;
Allerdings wird die Ausgabe etwas überraschen, denn außer den drei von uns abgelegten Zeichen werden wahrscheinlich noch andere, ungewollte Zeichen erscheinen. Wir hatten ja bereits in Abschnitt 3.1, »Arrays«, erfahren,
122
C-Strings
dass es keine direkte Möglichkeit zur Bestimmung der Elementanzahl im Array gibt. Es muss daher eine künstliche Kennung geschaffen werden, anhand derer die Anzahl der wesentlichen Elemente im C-String ermittelt werden kann. Die Entwickler von C++ haben deshalb für C-Strings eine Endekennung eingeführt, die den Wert 0 hat. Wenn also ein C-String ausgegeben wird, dann werden so lange Zeichen ausgegeben, bis ein Zeichen mit dem Wert 0 (nicht dem Zeichen »0«) erreicht wird. Wir brauchen demnach nur hinter unserem C-String eine Endekennung zu setzen s[3]=0;
und schon wird die Ausgabe wie gewünscht ausgeführt. Genau genommen, haben wir schon früher mit C-Strings gearbeitet, ohne es vielleicht zu wissen. Folgende Ausgabe zum Beispiel gibt einen C-String aus: cout << "Visual" << endl;
Denn tatsächlich ist die obere Zeichenkette »Visual« im Speicher abgelegt, wie in Abbildung 3.2 dargestellt. 'V'
'i'
's'
'u'
'a'
'l'
0
0
1
2
3
4
5
6
Abbildung 3.2
Interner Aufbau des C-Strings »Visual«
Achtung Ein C-String braucht wegen der notwendigen Endekennung immer ein Zeichen mehr an Speicherplatz als der zu speichernde String an Zeichen lang ist.
3.2.4
Texteingabe
Ein String kann auch mit cin eingelesen werden: cout << "Text eingeben:"; char s[50]; cin >> s; cout << "Eingegebener Text: " << s << endl; Listing 3.4
Einen String mit »cin« einlesen
123
3.2
3
Komplexere Datentypen
Dieser Ansatz hat leider ein kleines Manko. Wenn Sie einmal Text mit Leerzeichen eingeben, dann werden Sie sehen, dass nur der Text bis zum ersten Leerzeichen eingelesen wurde. Das liegt an der Eigenschaft von cin, auch ein Leerzeichen als Ende einer Eingabe zu betrachten. Abhilfe schafft die Methode getline von cin: cin.getline(s,50);
Die Methode bekommt als Parameter das zu füllende char-Feld sowie dessen Größe übergeben. Dabei wird die Notwendigkeit der Endekennung berücksichtigt. Aus diesem Grund werden über die obere Anweisung nur maximal 49 Zeichen eingelesen. Leider tritt jetzt ein anderer unangenehmer Effekt im Zusammenspiel mit der herkömmlichen Eingabe über cin auf. Sehen wir uns dazu folgendes Beispiel an: int i; cin >> i; cout << "Text eingeben:"; char s[50]; cin.getline(s,50); cout << "Eingegebener Text: " << s << endl; Listing 3.5
Unerwünschte Zeichen im Eingabepuffer
Führen Sie das obere Codefragment aus, und Sie werden sehen, dass die Eingabe des Textes einfach übersprungen wird. Der Grund ist einfach: Die Eingabe der Zahl wird vom Anwender mit der Eingabetaste beendet. Dieses Drücken der Eingabetaste wird ebenfalls als Zeichen repräsentiert und bleibt im Eingabepuffer. Die Texteingabe liest als Erstes die Eingabetaste, folgert, die Eingabe ist beendet und bricht ab. Um einen reibungslosen Ablauf zu erhalten, müssen wir die Eingabetaste aus dem Eingabepuffer entfernen. Und das geschieht, indem wir das Zeichen einfach ignorieren: int i; cin >> i; cin.ignore(); cout << "Text eingeben:"; char s[50]; cin.getline(s,50); cout << "Eingegebener Text: " << s << endl; Listing 3.6
Der Einsatz von »ignore«
Nun klappt es auch mit der Texteingabe.
124
C-Strings
3.2.5
cstring-Funktionen
Es ist lästig, einen C-String immer durch Beschreiben der einzelnen Zeichen zu erzeugen. Daher gibt es in der Header-Datei cstring eine Funktion strcpy, mit der Ein C-String in einen anderen kopiert werden kann: char s[40]; strcpy(s,"Andre Willms"); cout << s << endl;
Dabei kann der zweite C-String eine konstante Zeichenkette oder auch ein char-Feld sein. Wichtig ist nur, dass das aufnehmende Feld groß genug ist. Die Header-Datei cstring enthält noch andere nützliche Funktionen, die in Tabelle 3.2 aufgeführt werden. Funktion
Beschreibung
memchr
Zeichen in einem Speicherblock suchen
memcmp
Speicherblöcke vergleichen
memcpy
Speicherblöcke kopieren
memmove
sicheres Kopieren von Speicherblöcken
memset
Speicherblock initalisieren
NULL
der Nullzeiger
strcat
C-String an einen anderen hängen
strchr
Zeichen vom Stringanfang aus im C-String suchen
strcmp
zwei C-Strings vergleichen
strcoll
zwei C-Strings umgebungsabhängig vergleichen
strcpy
C-String kopieren
strcspn
Zeichen aus Menge suchen
strerror
Liefert die Textbeschreibung zu einer Fehlernummer.
strlen
Länge des C-Strings ermitteln
strncat
Teil eines C-Strings an einen anderen hängen
strncmp
Teile von zwei C-Strings vergleichen
strncpy
Teile eines C-Strings kopieren
strpbrk
Zeichen aus Zeichenmenge suchen
strrchr
Zeichen vom Stringende aus im C-String suchen
strspn
nicht vorkommende Zeichen suchen
Tabelle 3.2 Weitere Funktionen von »cstring«
125
3.2
3
Komplexere Datentypen
Funktion
Beschreibung
strstr
Prüft, ob ein C-String in anderen C-Strings vorkommt.
strxfrm
Umwandlung der ersten Zeichen eines Strings
Tabelle 3.2 Weitere Funktionen von »cstring« (Forts.)
Es folgt die detaillierte Beschreibung der Funktionen: memchr – im Speicherblock suchen void* memchr(const void* feldadr, int z, size_t groesse);
Sucht in einem Speicherblock der Größe groesse, beginnend bei feldadr nach dem ersten Vorkommen des Zeichens z. Bei erfolgreicher Suche wird die Adresse des Zeichens zurückgegeben, andernfalls 0. memcmp – Speicherblöcke vergleichen int memcmp(const void* feldadr1, const void* feldadr2, size_t groesse);
Vergleicht die beiden ab feldadr1 und feldadr2 liegenden Speicherblöcke der Größe groesse. Die Funktion liefert einen der folgenden Werte: 왘
<0, wenn das erste unterschiedliche Zeichen in Feld 1 kleiner ist als in Feld 2
왘
0 bei Gleichheit der beiden Felder
왘
>0, wenn das erste unterschiedliche Zeichen in Feld 1 größer ist als in Feld 2
Kopiert anzahl Elemente des Speicherblocks beginnend an Adresse feldadr2 in den Speicherblock beginnend an Adresse feldadr1. Zurückgegeben wird feldadr1. memmove – Speicherblock sicher kopieren void* memmove(void* feldadr1, const void* feldadr2, size_t anzahl);
Wie memcpy, nur dass bei einer teilweisen Überlappung der Felder ein Verlorengehen von Informationen ausgeschlossen ist.
Füllt anzahl Elemente des an Adresse feldadr beginnenden Speicherblocks mit dem Zeichen z. NULL – der Nullzeiger
Eine Konstante, die als Nullzeiger benutzt werden kann und den Wert 0 oder (void*)0 hat. strcat – C-String anhängen char* strcat(char* ziel, const char* quelle);
Kopiert den C-String quelle mitsamt der Endekennung hinter den C-String ziel, wobei die Endekennung von ziel mit dem ersten Zeichen von quelle überschrieben wird. Es wird ziel zurückgegeben. strchr – nach Zeichen suchen char* strchr(char* s, int z); const char* strchr(const char* s, int z);
Liefert die Adresse des ersten in s vorkommenden Zeichens z, wobei die Endekennung ebenfalls berücksichtigt wird. Bei erfolgloser Suche wird 0 zurückgegeben. strcmp – C-Strings vergleichen int strcmp(const char* s1, const char* s2);
Vergleicht die beiden C-Strings s1 und s2. Die Funktion liefert einen der folgenden Werte: 왘
<0, wenn das erste unterschiedliche Zeichen in s1 kleiner ist als in s2
왘
0 bei Gleichheit der beiden C-Strings
왘
>0, wenn das erste unterschiedliche Zeichen in s1 größer ist als in s2
Gleiche Funktionsweise wie strcmp, nur dass die Zeichen umgebungsabhängig verglichen werden. strcpy – C-String kopieren char* strcpy(char* ziel, const char* quelle);
127
3.2
3
Komplexere Datentypen
Kopiert den C-String quelle mitsamt der Endekennung in den C-String ziel. Es wird ziel zurückgegeben. strcspn – nach Zeichen aus Menge suchen size_t strcspn(const char* s, const char* suchstr);
Liefert den Index des ersten Zeichens von s, welches mit einem der Zeichen von suchstr übereinstimmt, wobei die Endekennung mit einbezogen wird. strerror – Fehlertext ermitteln char* strerror(int fehlernummer);
Liefert die Adresse eines Strings, der den durch fehlernummer definierten Fehler als Text beschreibt. strlen – Länge eines C-Strings ermitteln size_t strlen(const char* s);
Liefert die Länge des C-Strings s ohne Endekennung zurück. strncat – Teil eines C-Strings anhängen char* strncat(char* ziel, const char* quelle, size_t n);
Kopiert die ersten n Zeichen des C-Strings quelle mit Hinzufügen einer Endekennung hinter den C-String ziel, wobei die Endekennung von ziel mit dem ersten Zeichen von quelle überschrieben wird. Es wird ziel zurückgegeben. Sollte quelle kleiner als n Zeichen sein, werden strlen(quelle) Zeichen plus Endekennung kopiert. strncmp – Teilstring vergleichen int strncmp(const char* s1, const char* s2, size_t n);
Vergleicht die ersten n Zeichen der C-Strings s1 und s2 und liefert einen der folgenden Werte: 왘
<0, wenn das erste unterschiedliche Zeichen in s1 kleiner ist als in s2
왘
0 bei Gleichheit der zu vergleichenden Stringteile
왘
>0, wenn das erste unterschiedliche Zeichen in s1 größer ist als in s2
Kopiert die ersten n Zeichen des C-Strings quelle in den C-String ziel. Es wird ziel zurückgegeben. Sollte quelle weniger als n Zeichen haben, werden strlen(quelle) Zeichen kopiert und die restlichen n-strlen(quelle) Zeichen mit 0 aufgefüllt. strpbrk – nach Zeichen aus Zeichenmenge suchen char* strpbrk(char* s, const char* suchstr); const char* strpbrk(const char* s, const char* suchstr);
Liefert die Adresse des ersten Zeichens von s, welches mit einem der Zeichen von suchstr übereinstimmt, wobei die Endekennung mit einbezogen wird. Falls kein Zeichen übereinstimmt, wird 0 zurückgegeben. strrchr – Zeichen vom Stringende aus suchen char* strrchr(const char* s, int z);
Liefert die Adresse des letzten in s vorkommenden Zeichens z, wobei die Endekennung ebenfalls berücksichtigt wird. Bei erfolgloser Suche wird 0 zurückgegeben. strspn – nicht vorkommende Zeichen suchen size_t strspn(const char* s, const char* suchstr);
Liefert den Index des ersten Zeichens von s, welches mit keinem der Zeichen von suchstr übereinstimmt, wobei die Endekennung nur für s mit einbezogen wird. strstr – Teilstring suchen char* strstr(char* s, const char* suchstr); const char* strstr(const char* s, const char* suchstr);
Liefert die Adresse der ersten Zeichenfolge von s, welche mit suchstr ohne Endekennung übereinstimmt. Bei keiner Übereinstimmung wird 0 zurückgegeben. strxfrm – Umwandlung der ersten Zeichen eines Strings size_t strxfrm(char *ziel, const char *quelle, size_t n);
Wandelt die ersten n Zeichen des Strings quelle nach einer umgebungsspezifischen Regel um und speichert sie in ziel. Die Umwandlung erfolgt so, dass strcoll(a1,a2) gleich mit strcmp(b1,b2) ist, wobei b1 das Ergebnis der Umwandlung von a1 und b2 das Ergebnis der Umwandlung von a2 ist.
129
3.2
3
Komplexere Datentypen
3.3
Strukturen
Mit den Arrays gibt es die Möglichkeit, mehrere Werte oder Daten über einen Bezeichner in Kombination mit dem Indexoperator anzusprechen und zu verwalten. Arrays haben aber je nach Anwendungsbereich einen entscheidenden Nachteil: Die Elemente eines Arrays haben immer denselben Datentyp.
3.3.1
Definition einer Struktur
Hier schaffen Strukturen Abhilfe. Strukturen definieren einen neuen Datentyp, der aus Elementen anderer Datentypen zusammengesetzt ist. Eine Struktur steht üblicherweise in einer eigenen Header-Datei (Endung .h), welche den Namen der Struktur trägt. Die Namensgleichheit von Datei und Struktur ist keine Pflicht wie in Java, sondern lediglich eine Konvention, um einen klaren Bezug zwischen der Datei und ihrem Inhalt herzustellen. Hier die Syntax einer Struktur: struct Strukturname { // Definition der Strukturelemente } [Objektname];
Als Beispiel wollen wir eine Struktur Farbe entwerfen, die in der Lage ist, den Namen der Farbe sowie deren Rot-, Grün- und Blaukomponente zu speichern. Die reine Strukturdefinition sieht so aus: struct Farbe { }; Listing 3.7
Definition der Struktur »Farbe«
Wie in der Syntaxbeschreibung einer Strukturdefinition zu erkennen, ist die Angabe eines Objektnamens optional und fehlt hier. Aus Gründen der Abwärtskompatibilität erlaubt C++ die Definition eines Objekts direkt bei der Definition der Struktur: struct Farbe { } einefarbe; Listing 3.8
Objektdefinition bei Strukturdefinition
Diese Art der Objektdefinition ist in anderen Sprachen nicht möglich und sollte aus Gründen der Wiederverwendbarkeit nicht eingesetzt werden,
130
Strukturen
denn jeder, der die Headerdatei mit der Klassendefinition einbindet, würde dann automatisch auch die Objektdefinition von einefarbe einbinden. Tipp Objekte sollten nicht zusammen mit der Struktur definiert werden.
Die Headerdatei mit der Strukturdefinition kann jetzt in eine Quellcodedatei eingebunden werden und dort Objekte der Struktur definieren: #include "Farbe.h" int main() { Farbe farbe1; } Listing 3.9
3.3.2
Definition eines Strukturobjekts in »main«
Definition der Strukturelemente
Innerhalb des Anweisungsblocks der Struktur können die Elemente der Struktur wie herkömmliche Variablen definiert werden: #pragma once #include <string> struct Farbe { std::string name; int rot; int gruen; int blau; }; Listing 3.10
Der aktuelle Stand der Datei Farbe.h
Um das Beispiel dichter an gängiger Praxis zu halten, greifen wir etwas vor und verwenden den Datentyp string, der in Abschnitt 6.8, »Strings«, genauer beschrieben wird. An dieser Stelle ist nur wichtig zu wissen, dass Objekte dieses Datentyps in der Lage sind, eine Zeichenkette zu speichern und dass die Definition von string in der Headerdatei string steht, die eingebunden werden muss.
131
3.3
3
Komplexere Datentypen
Tipp In einer Headerdatei sollten Sie nie using namespace-Anweisungen verwenden, weil diese dann mit der Klassendefinition in Quellcodedateien eingebunden würden.
Weil wir auf eine typische using namespace std-Anweisung in der Headerdatei verzichten, müssen wir Elemente der Standardbibliothek (hier string) mit ihrem Namensbereich angeben, deshalb std::string. Hinweis Die Elemente einer Struktur besitzen die Struktur als Bezugsrahmen, sind also lokal in ihr definiert. Ein Namenskonflikt mit gleichnamigen Elementen anderer Strukturen ist daher ausgeschlossen.
3.3.3
Zugriff auf die Struktur-Elemente
Ein Objekt vom Typ Farbe besitzt jetzt automatisch die in der Struktur definierten Strukturelemente, die über den Elementoperator . ansprechbar sind: Farbe farbe1; farbe1.name="Rot"; farbe1.rot=255; farbe1.gruen=0; farbe1.blau=0;
3.3.4
Strukturobjekt als Einheit
Objekte einer Struktur gelten als Einheit und können daher problemlos zugewiesen werden. Nehmen wir die einfache Struktur IntContainer, die maximal zehn int-Werte speichern kann und über anzahl festhält, wie viele Werte bisher gespeichert sind: struct IntContainer { int werte[10]; int anzahl; }; Listing 3.11
Die Struktur »IntContainer«
Wird ein Objekt dieser Struktur einem anderen Objekt zugewiesen, dann werden alle Elemente der Struktur kopiert, selbst das Feld, das eigentlich durch reine Zuweisung nicht kopiert werden konnte:
Arrays können normalerweise nicht einfach als Funktionsparameter verwendet werden. Zeiger bieten eine Möglichkeit, dies trotzdem zu tun. Denn mit ihrer Hilfe werden Variablen oder Objekte nicht als Kopie, sondern als Verweis übergeben. Das Verständnis von Zeigern bildet eine wichtige Grundlage für die C++-Programmierung, sowohl für ANSI C++ als auch für C++/CLI.
3.4.1
Adressoperator
Wir verwenden Variablen schon seit einigen Kapiteln und haben im vorigen Abschnitt auch schon Strukturelemente erzeugt. All diese Elemente haben eines gemeinsam: Sie müssen irgendwo im Arbeitsspeicher des Computers gespeichert sein. Wo genau im Computerspeicher war uns bisher egal, weil wir ohne Schwierigkeiten über den Bezeichner auf das Objekt oder die Variable zugreifen können. Nun ist der Punkt erreicht, etwas mehr über das Wo zu erfahren. Der Arbeitsspeicher des Computers besteht als kleinster, nicht mehr teilbarer Einheit aus Bits. Von diesen Bits werden jeweils acht zu einem Byte zusammengefasst. Diese Bytes sind durchnummeriert, man spricht auch von ihrer Adresse. Wenn eine Variable definiert wird, sucht das Programm einen freien Speicherbereich für die Variable und legt ihren Inhalt dort ab: int x=22;
Die Adresse eines Bytes wird üblicherweise im Hexadezimalsystem angegeben. In der Abbildung wurden die vier Bytes ab Adresse 92E2 für x reserviert und der zugewiesene Wert darin gespeichert. Heutige Rechnersysteme haben einen so großen Speicher, dass die Adressen dort achtstellig sind; der Einfachheit halber führen wir die Betrachtung aber mit vier Stellen durch. Wie kommen wir aber an die Adresse heran, ab der die Variable gespeichert ist? Dazu dient der Adressoperator &. Er wird einfach vor die Variable oder das Objekt geschrieben: cout << &x << endl;
3.4.2
Definition eines Zeigers
Auf die Dauer wird es langweilig, die Adressen von Variablen einfach nur auszugeben. Um wirklich etwas Sinnvolles mit ihnen anstellen zu können, müssen wir sie speichern. Und genau dazu ist der Zeiger da. Hinweis Variablen speichern Werte, Zeiger speichern Adressen.
C++ ist eine typisierte Programmiersprache. Bezogen auf Zeiger bedeutet das, ein Zeiger kann nur die Adresse eines bestimmten Typs speichern. Soll die Adresse einer int-Variablen gespeichert werden, dann wird ein Zeiger vom Typ Zeiger auf int, auch int* geschrieben, benötigt. Deklariert und definiert wird ein Zeiger, indem vor den Bezeichner ein * gesetzt wird:
134
Zeiger
int *p;
Der Zeiger p ist nun bereit, eine Adresse aufzunehmen: p=&x;
Abbildung 3.4 zeigt den Inhalt des Zeigers grafisch umgesetzt. In der Abbildung liegt der Speicherbereich des Zeigers direkt hinter der Variablen. Das muss nicht zwangsläufig so sein. Der Inhalt eines Zeigers – die in ihm gespeicherte Adresse – kann wie der Inhalt einer Variablen mit cout ausgegeben werden: cout << p << endl;
Wirklich interessant werden Zeiger aber wegen der Fähigkeit, auf den Inhalt der Variablen zugreifen zu können, deren Adresse im Zeiger gespeichert ist. Dieser Vorgang wird Dereferenzierung genannt. Durchgeführt wird sie mit dem Dereferenzierungsoperator *. Der Dereferenzierungsoperator wird vor den Zeiger geschrieben: cout << *p << endl;
In p ist die Adresse von x gespeichert. Die Dereferenzierung von p liefert damit den Inhalt von x, also 22. Abbildung 3.5 zeigt die Zusammenhänge.
Ihre Praxistauglichkeit erhalten die Zeiger als Funktionsparameter. Es besteht nämlich die Möglichkeit, über sie die an die Funktion übergebene Variable zu verändern: void aendern(int* x) { *x=20; } int main() { int a=30; cout << "a = " << a << endl; aendern(&a); cout << "a = " << a << endl; } Listing 3.13
Mit einem Zeiger auf die übergebene Variable zugreifen
Die Funktion aendern besitzt als einzigen Funktionsparameter einen Zeiger vom Typ int*. Es spielt keine Rolle, ob der Stern neben dem Typ steht (int* x) oder neben der Variablen (int *x). Die Schreibweise mit dem Stern am Typ spiegelt den Sachverhalt – Zeiger auf int – aber eher wider und wird hier deshalb bevorzugt. Dass die Funktion einen Parameter vom Typ int* besitzt, hat einige Konsequenzen. Bei dem Funktionsaufruf darf nun nicht mehr eine bloße Variable angegeben werden, weil sonst ein Wert an die Funktion übergeben würde –
136
Zeiger
Zeiger speichern aber Adressen. Stattdessen wird die Adresse der Variablen a übergeben. In der Funktion können wir nicht einfach den Namen des Zeigers verwenden, denn damit würde der Inhalt – die in ihm gespeicherte Adresse – angesprochen. Wir greifen daher mithilfe des Dereferenzierungsoperators auf den Wert der Variablen zu, deren Adresse im Zeiger gespeichert ist, und ändern damit den Wert der Variablen a aus main auf 20. Besprechen wir noch ein Beispiel aus der Praxis des Programmierens. Listing 2.33 in Abschnitt 2.3.4, »Rückgabewerte«, zeigt eine Funktion maximum, die den größeren von zwei übergebenen Werten zurückliefert. Häufig ist aber nicht interessant, welches der größere Wert ist, sondern welche Variable den größeren Wert beinhaltet. Auf eine Variable verweisen wir mit einem Zeiger, weshalb die Funktion einen Zeiger zurückliefern muss. Um einen Zeiger auf die Variable mit dem größeren Wert zurückliefern zu können, muss die Funktion jedoch zunächst Verweise auf die beiden potenziellen Kandidaten besitzen. Daher müssen die Adressen der beiden zu vergleichenden Variablen an die Funktion übergeben werden. Um diese Adressen aufnehmen zu können, muss es sich bei den Parametern um Zeiger handeln. Die Funktion bekommt also zwei Adressen übergeben (die in Zeigern gespeichert werden) und liefert eine Adresse zurück: int* maximum(int* a, int* b) { if(*a>*b) return(a); else return(b); } Listing 3.14
Die Funktion »maximum« mit Zeigern
Bei dem Vergleich der Variablen muss dereferenziert werden, weil nicht die in den Zeigern gespeicherten Adressen verglichen werden sollen, sondern die Werte der Variablen, deren Adressen in den Zeigern gespeichert sind. Die return-Anweisungen wiederum besitzen den jeweiligen Zeiger als Argument, weil die Funktion die im Zeiger gespeicherte Adresse zurückliefern soll. Ein simples Anwendungsbeispiel der Funktion könnte so aussehen: int x=30, y=40; int *p=maximum(&x, &y); cout << "Groessere Variable: " << *p << endl;
137
3.4
3
Komplexere Datentypen
Die Funktion erwartet Adressen, deswegen der Einsatz des Adressoperators. Die Funktion liefert eine Adresse zurück, daher muss das Ergebnis in einem Zeiger gespeichert werden. Nach dem Aufruf beinhaltet p die Adresse der Variablen, die den größeren Wert enthält. In der letzten Anweisung wird dieser Wert über den Dereferenzierungsoperator ausgegeben.
3.4.5
Zeiger auf Struktur- und Klassenobjekte
Zeiger können nicht nur auf die elementaren Datentypen zeigen, sondern auch auf komplexe Typen wie Strukturen oder später auch Klassen. Analog zur bisherigen Zeigerdefinition wird der Zeiger definiert wie ein Objekt der Struktur oder Klasse, nur dass dem Bezeichner ein * vorgesetzt wird. Nehmen wir als Beispiel die Struktur Farbe aus Abschnitt 3.3. Erzeugen wir ein Objekt (beim Ausprobieren das Einbinden der Header-Datei mit der Strukturdefinition nicht vergessen!): Farbe f;
Dementsprechend sieht die Definition eines Zeigers, der auf f zeigen kann, so aus: Farbe *p = &f;
Ebenfalls aus dem Abschnitt über Strukturen wissen wir, dass wir die Elemente eines Strukturobjekts über den Elementzugriffsoperator . ansprechen können. Wollten wir beispielsweise wissen, welchen Wert die Rot-Komponente der Farbe hat, ginge dies so: cout << f.rot << endl;
Natürlich können wir das Element rot auch über den Zeiger aufrufen. Um an das Objekt zu kommen, dessen Adresse im Zeiger gespeichert ist, müssen wir dereferenzieren. Auf das so ermittelte Objekt kann dann der Elementzugriffsoperator angewendet werden. Dazu muss wegen der Bindungsstärke der Operatoren geklammert werden: cout << (*p).rot << endl;
Diese Schreibweise ist aufwändig. Über Zeiger wird jedoch häufiger auf Klassenelemente zugegriffen, deswegen existiert für diesen Fall ein besonderer Operator, der Zeigeroperator ->. Angewendet wird er so: cout << p->rot << endl;
138
Zeiger
3.4.6
Zeiger auf Arrays
Zeiger auf Arrays stellen keine Besonderheit dar, weil beispielsweise ein Zeiger vom Typ int* sowohl auf eine einzelne Variable als auch auf ein intArray zeigen kann. Hinweis Die Adressermittlung eines Arrays benötigt keinen Adressoperator. Der Arrayname steht für die Adresse des Arrays.
Ein Beispiel: int f[10]; int *p = f;
Hinweis Die Adresse eines Arrays entspricht der Adresse des ersten Arrayelements.
Demnach hätte der Zeiger auch so initialisiert werden können: int *p = &f[0];
Um über den Zeiger die Arrayelemente anzusprechen, wird auf ihn der Indexoperator angewendet: p[3] = 935; // 4. Element von f beschreiben
Wir können mit diesem Wissen eine Funktion schreiben, die uns die Anzahl von Zeichen eines C-Strings liefert: int eigenes_strlen(const char* s) { int i=0; while(s[i]!=0) i++; return(i); } Listing 3.15
Die Funktion »eigenes_strlen«
Der verwendete Zeiger ist vom Typ const char*, weil C-Strings in charArrays gespeichert werden. Durch das davorgesetzte const kann über den Zeiger das Feld nur gelesen, aber nicht verändert werden. Das ist notwendig, um auch konstante Zeichenketten bearbeiten zu können, wie das folgende Beispiel zeigt:
139
3.4
3
Komplexere Datentypen
cout << eigenes_strlen("andre") << endl;
Das Ergebnis ist 5. Etwas ist zu beachten: Arrays speichern ihre Größe nicht explizit. Deswegen kann die Größe eines Arrays nicht über einen Zeiger ermittelt werden. Dazu müssen andere Techniken angewendet werden, wie z. B. die Endekennung eines C-Strings oder ein zusätzlicher Funktionsparameter, dem die Größe des Arrays übergeben wird.
3.4.7
Zeigerarithmetik
Zeiger besitzen die Fähigkeit einer einfachen Arithmetik. Gehen wir von folgender Situation aus: int f[20]; int *p = f;
Wir wissen, dass p nun auf das erste Element des Arrays zeigt. Es ist jetzt möglich, über p durch Addition die Adresse des fünften Elements zu bestimmen. In der folgenden Anweisung wird diese Adresse dem Zeiger q zugewiesen: int *q = p+4;
Das Ergebnis der Addition muss nicht unbedingt gespeichert werden, um es verwenden zu können. Die Summe kann auch direkt dereferenziert werden: cout << *(p+4) << endl;
Die Klammerung ist wichtig, weil ansonsten auf das dereferenzierte p der Wert 4 addiert würde. Um einen Offset von vier Elementen zu erzeugen, muss zwangsläufig bekannt sein, wie viel Speicher ein Element belegt. Beispielsweise muss bei vier 2 Byte großen Elementen der Offset 8 Byte betragen, wohingegen er bei vier 4 Byte großen Elementen 16 Byte groß sein muss. Glücklicherweise geschieht diese Berücksichtigung der Elementgröße bei der Zeigerarithmetik vollautomatisch. Die Anwendung der Inkrement- und Dekrementoperatoren auf Zeiger funktioniert ebenso. Der Zeiger zeigt danach auf das nächste oder vorherige Element. Ob es ein solches Element gibt, muss vom Programmierer sichergestellt werden. Grundsätzlich kann ein Zeiger auch eine Adresse enthalten, die keiner passenden Variablen entspricht. Problematisch wird es erst, wenn
140
Referenzen
dann dereferenziert und schreibend darauf zugegriffen wird. Zu Demonstrationszwecken soll hier die Funktion eigenes_strlen aus Listing 3.15 im letzten Abschnitt mit Zeigerarithmetik implementiert werden: int eigenes_strlen(const char* s) { int i=0; while(*(s++)!=0) i++; return(i); } Listing 3.16
3.5
Die Funktion »eigenes_strlen« mit Zeigerarithmetik
Referenzen
Referenzen sind den Zeigern sehr ähnlich, besitzen aber durch Einschränkung der Fähigkeiten eine einfachere Syntax. Eine Referenz ist, wie der Name bereits zum Ausdruck bringt, ein Verweis auf etwas. Im Gegensatz zu einem Zeiger kann eine Referenz aber nur auf ein einziges Element verweisen. Der Verweis selbst kann nicht mehr geändert werden. Aus diesem Grund muss eine Referenz bei ihrer Definition initialisiert werden. Bei ihrer Definition wird eine Referenz durch ein & gekennzeichnet: int w=3; int &r = w;
Da die Referenz von nun an immer auf die Variable w verweist und dieser Verweis nicht abgeändert werden kann, braucht syntaktisch nicht mehr unterschieden zu werden zwischen Inhalt und Dereferenzierung. Die Referenz r ist nun ein Synonym für die Variable w: r=11; cout << w << endl;
Die Referenz hat gegenüber dem Zeiger zwei syntaktische Vereinfachungen: 왘
Bei der Initialisierung der Referenz muss von der Variablen, auf die verwiesen wird, nicht explizit die Adresse ermittelt werden.
왘
Die Dereferenzierung erfolgt implizit und kommt daher ohne speziellen Operator aus.
141
3.5
3
Komplexere Datentypen
Als Beispiel soll die Funktion maximum, die in Listing 3.14 in Abschnitt 3.4.4, »Zeiger als Funktionsparameter«, mit Zeigern realisiert wurde, nun mit Referenzen implementiert werden: int& maximum(int& a, int& b) { if(a>b) return(a); else return(b); } Listing 3.17
Die Funktion »maximum« mit Referenzen
Im Vergleich zu der Lösung mit Zeigern können der Funktion nun die Parameter so übergeben werden, als würden bloße Kopien erwartet: int x=30, y=40; int &r=maximum(x, y);
142
Dieses Kapitel führt Sie in das Klassenkonzept ein, einen der wesentlichen Pfeiler der objektorientierten Programmierung.
4
Klassen
Zunächst werden wir Klassen als eine Möglichkeit kennenlernen, Variablen unterschiedlicher Datentypen zu einer Einheit zusammenzufassen. Anschließend werden die von Klassen unterstützten Mechanismen der objektorientierten Programmierung betrachtet.
4.1
Definition einer Klasse
Definiert wird eine Klasse mit dem Schlüsselwort class, gefolgt von dem Namen der Klasse und einem Paar geschweifter Klammern, die mit einem Semikolon abgeschlossen werden! Dieses Semikolon ist analog zu den Strukturen ein Erbe von C und ein beliebter Fehler. Die Syntax einer Klassendefinition lautet: class Klassenname { // Elemente der Klasse } [Objektname];
Hinweis Wie die Syntax zeigt, gibt es auch hier die Möglichkeit einer Objektdefinition hinter der Klassendefinition. Darauf sollten Sie aber verzichten, weil es nicht mehr zeitgemäß ist.
Nehmen wir als Beispiel eine Klasse namens Becher, die später als Komponenten den Inhalt als Text, das Fassungsvermögen in Millilitern und die Füllhöhe in Prozent beinhalten soll: class Becher { }; Listing 4.1
Die nackte Klasse »Becher«
143
4
Klassen
Üblicherweise steht die Definition einer Klasse wie die einer Struktur in einer eigenen Header-Datei mit dem Namen der Klasse. Die Header-Datei für die Klasse Becher heißt daher Becher.h.
4.1.1
Erstellen einer Klasse mit Visual C++
Genau wie die Dateien bisher können Sie die Header-Datei Becher.h manuell erstellen. Visual C++ bietet jedoch einen Assistenten, mit dem eine Klasse mitsamt der benötigten Dateien erstellt werden kann. Klicken Sie dazu, wie in Abbildung 4.1 gezeigt, im Projektmappen-Explorer mit der rechten Maustaste auf den Projektnamen, wählen Sie dort Hinzufügen und im darauf erscheinenden Untermenü Klasse.
Abbildung 4.1
Erstellen einer neuen Klasse
Daraufhin öffnet sich das in Abbildung 4.2 gezeigte Fenster. Als Kategorie wählen Sie C++, und dort die einzige Vorlage C++-Klasse. Bestätigen Sie durch Anklicken von Hinzufügen. Nachdem Sie die Vorlage ausgewählt haben, erscheint ein auf den ersten Blick reichhaltiger Dialog, mit dem die Klasse dann tatsächlich erstellt wird (Abbildung 4.3).
144
Definition einer Klasse
Abbildung 4.2
Klassentyp wählen
Abbildung 4.3 Klasseneinstellungen
Unter Klassenname geben Sie den Namen der zukünftigen Klasse an. Die Namen für die Header- und die Quellcode-Datei werden dem Klassennamen und der üblichen Benennung entsprechend vorgegeben, können bei Bedarf aber nachträglich beliebig verändert werden. Die Punkte Basisklasse, Zugriff und Virtueller Destruktor kommen erst im weiteren Verlauf des Buchs bei der Vererbung zum Tragen und werden hier noch ignoriert. In bestimmten Situationen, die später noch besprochen werden, macht es Sinn, die Klasse nicht in Header- und Quellcode-Datei aufzuteilen, sondern stattdessen die gesamte Klasse in die Header-Datei zu schreiben. In diesem Fall haken Sie den Punkt Inline ab, und die Erstellung einer Quellcode-Datei wird unterbunden. Durch Anklicken von Fertig stellen wird die Klasse
145
4.1
4
Klassen
erzeugt. Im Projektmappen-Explorer erscheinen die Header- und die Quellcode-Datei. Sie können sie durch Doppelklick auf den Dateinamen im Editor öffnen. Das folgende Listing zeigt die erstellte Klasse in Becher.h: #pragma once class Becher { public: Becher(void); ~Becher(void); }; Listing 4.2
Der Inhalt von »Becher.h«
Der erste Befehl dient der Vermeidung einer potenziellen Mehrfachdeklaration, wie wir in Abschnitt 2.4.4, »Mehrfachdeklarationen vermeiden«, erfahren haben. Die Klasse selbst besitzt schon einige Elemente – einen Konstruktor und den Destruktor. Wir kennen beide noch nicht (sie werden erst in den Abschnitten 4.5 und 5.2.3 behandelt), daher löschen wir sie hier, so dass die geschweiften Klammern wie in Listing 4.1 leer bleiben. Im Folgenden ist der Inhalt der Datei Becher.cpp aufgelistet: #include "Becher.h" Becher::Becher(void) { } Becher::~Becher(void) { } Listing 4.3
Der Inhalt von »Becher.cpp«
Als erste Amtshandlung wird die Datei Becher.h eingebunden. Das ist wichtig, weil in Becher.h beschrieben ist, aus welchen Elementen die Klasse besteht. Diese Informationen müssen dem Compiler vorliegen, bevor die Klassenelemente in der Quellcode-Datei genauer ausgeführt werden. Alles andere sind die Definitionen des in Becher.h deklarierten Konstruktors und Destruktors. Da wir deren Deklarationen in Becher.h bereits entfernt haben, müssen die Definitionen das gleiche Schicksal erleiden und werden aus der
146
Attribute
Datei gelöscht. Das Projekt sollte sich nun immer noch fehlerfrei kompilieren lassen. Auch Objekte der Klasse lassen sich bereits problemlos erzeugen: #include "Becher.h" int main() { Becher b; } Listing 4.4
Erzeugen eines Becher-Objekts
Um ein Klassenelement definieren zu können, muss die dazugehörige Klassendefinition verfügbar sein. Im oberen Beispiel wird dies durch Einbinden von Becher.h gewährleistet. Weder wird iostream eingebunden noch der Namensbereich std verfügbar gemacht, weil kein Element der Standardbibliothek benötigt wird.
4.2
Attribute
Nun soll die ursprünglich geplante Struktur der Klasse Becher weiter umgesetzt werden. Gewünscht war der Inhalt des Bechers als Text, das Fassungsvermögen in Millilitern und die Füllhöhe des Bechers in Prozent. Solche Datenelemente der Klasse werden Attribute genannt. Die Attribute werden genau wie die Elemente einer Struktur in die geschweiften Klammern der Klasse hineingeschrieben. Das folgende Listing zeigt den aktuellen Inhalt von Becher.h: #pragma once #include <string> class Becher { std::string inhalt; int fassungsvermoegen; float fuellhoehe; }; Listing 4.5
Die Klasse »Becher« mit Attributen
Damit uns der Datentyp string zur Verfügung steht, muss die Header-Datei string eingebunden werden. In einer Header-Datei sollten Sie keine using namespace-Anweisungen verwenden, weil Header-Dateien in Quellcode-
147
4.2
4
Klassen
Dateien eingebunden werden und die einbindende Quellcode-Datei eine in der Header-Datei enthaltene using namespace-Anweisung mit einbinden würde, völlig egal, ob gewollt oder nicht. Deshalb muss bei der Definition von inhalt der Datentyp string mitsamt seinem Namensbereich angegeben werden.
4.3
Zugriffsrechte
Die Klasse Becher hat nun drei Datenelemente. Wir wissen bereits von den Strukturen (siehe Abschnitt 3.3, »Strukturen«), wie auf die Elemente einer Klasse zugegriffen werden kann: mit dem Elementzugriffsoperator. Mit dessen Hilfe könnte ein erzeugter Becher zur Hälfte mit Milch gefüllt werden: Becher b; b.inhalt="Milch"; b.fassungsvermoegen=300; b.fuellhoehe=50;
Der Compiler ist mit diesen Anweisungen jedoch nicht einverstanden. Er sagt unter anderem Becher::inhalt: Kein Zugriff auf private Member… Die gleiche Fehlermeldung wird auch für die anderen beiden Attribute gemeldet. Diese Fehlermeldungen liefern zwei wesentliche Informationen: 1. Unsere Attribute sind privat. 2. Von der main-Funktion aus haben wir keinen Zugriff darauf. Das wiederum wirft drei Fragen auf: 1. Warum sind die Attribute privat? 2. Was bedeutet privat? 3. Warum hat die main-Funktion keinen Zugriff darauf? Beginnen wir von vorne. Auf jedes Element einer Klasse besteht ein sogenanntes Zugriffsrecht. Dieses Zugriffsrecht bestimmt, von wo aus auf das Element zugegriffen werden kann. In C++ werden drei Zugriffsrechte unterschieden: 1. Privat (private): Auf private Elemente dürfen nur Elemente der eigenen Klasse und Freunde der Klasse zugreifen. 2. Geschützt (protected): Wie privat, nur dass auch Elemente abgeleiteter Klassen (siehe Abschnitt 4.11) darauf zugreifen dürfen.
148
Methoden
3. Öffentlich (public): Keine Beschränkung des Zugriffs. Von überall kann auf das Element zugegriffen werden. Wird kein Zugriffsrecht angegeben, dann besitzen die Klassenelemente privates Zugriffsrecht. Das erklärt auch, warum auf die Attribute nicht zugegriffen werden konnte, denn die main-Funktion gehört definitiv nicht zur Klasse. Die zur Spezifikation des Zugriffsrechts verwendeten Schlüsselwörter (private, protected und public) werden in C++ als Zugriffsspezifizierer (access specifier) bezeichnet. Obwohl es in der objektorientierten Programmierung ein Sakrileg ist, für Attribute öffentliches Zugriffsrecht zu vergeben, wollen wir genau dies in den folgenden Abschnitten zu Anschauungszwecken tun. Ein Zugriffsrecht wird spezifiziert, indem es irgendwo in der Klasse mit Doppelpunkt angegeben wird. Das so spezifizierte Zugriffsrecht gilt so lange, bis ein anderes Zugriffsrecht spezifiziert wird. Um die Attribute von Becher als öffentlich zu spezifizieren, schreiben wir vor das erste Attribut public:, und schon wird der halbvolle Becher Milch kompiliert: class Becher { public: std::string inhalt; int fassungsvermoegen; float fuellhoehe; }; Listing 4.6
Öffentliche Attribute
Zugriffsrechte können in einer Klasse beliebig oft spezifiziert werden. Unterschied zwischen Klasse und Struktur In C++ liegt der einzige Unterschied zwischen Klasse und Struktur darin, dass bei einer Klasse die Elemente per Voreinstellung privat sind. Bei Strukturen sind die Elemente öffentlich.
4.4
Methoden
Eine weitere Art von Klassenelement sind die Methoden. Sie bilden den dynamischen Teil einer Klasse und sind technisch nichts anderes als zur Klasse gehörende Funktionen. Deswegen werden sie in C++ auch gerne Elementfunktion genannt.
149
4.4
4
Klassen
Wir könnten unsere Klasse Becher mit der Fähigkeit ausstatten, auf dem Bildschirm ausgeben zu können, mit welchem Inhalt der Becher gefüllt ist. Die notwendigen Ergänzungen sind schnell vorgenommen: #pragma once #include <string> #include class Becher { public: std::string inhalt; int fassungsvermoegen; float fuellhoehe; void ausgabe() { std::cout << "Becher mit " << inhalt << std::endl; } }; Listing 4.7
Die Methode »ausgabe«
Die Methode besitzt weder Rückgabewert noch Parameter und ist syntaktisch identisch mit einer Funktion. Zur Methode wird sie, weil sie innerhalb der Klasse steht. Als Methode – und damit Element der Klasse – kann sie auf alle Elemente von Becher zugreifen, ohne deren Zugriffsrechte berücksichtigen zu müssen. Sie selbst besitzt hier öffentliches Zugriffsrecht und kann von überall aus aufgerufen werden. Und der Aufruf läuft wieder über den Elementzugriffsoperator: b.ausgabe();
Eine herkömmliche Methode muss immer über ein Klassenobjekt aufgerufen werden. Wird die Methode – wie in der oberen Anweisung – über das Objekt b aufgerufen (dieser Aufruf ist in main möglich, weil das Zugriffsrecht auf ausgabe public ist), dann hat sie für diesen Aufruf Zugriff auf die Attribute von b. Der Zugriff auf inhalt in ausgabe ist für diesen Fall das Attribut inhalt von b.
4.4.1
Externe Definition
Die Definition der Methode ausgabe steht innerhalb der Klassendefinition. Man nennt eine solche Methode inline. Der Compiler versucht, den Aufruf
150
Methoden
einer Inline-Methode durch ihren Programmcode zu ersetzen. Dadurch wird das Programm zwar länger, weil der Programmcode der Methode so oft im Programm vorkommt, wie sie aufgerufen wurde. Aber das Programm gewinnt auch an Geschwindigkeit, weil beim Aufruf nicht zum Methodencode hin- und am Methodenende wieder zurückgesprungen werden muss. Bei längeren Methoden ist der Nachteil durch Codevervielfältigungen aber weitaus höher als der Geschwindigkeitsvorteil, deswegen werden auch nur kleine Methoden vom Compiler als inline behandelt. Hinweis Inline-Methoden werden nicht aufgerufen, sondern deren Quellcode ersetzt jeden ihrer Aufrufe. Die Folge sind schnellere, aber längere Programme sowie eine erhöhte Kompilationszeit.
Inline-Methoden haben aber einen Geschwindigkeitsnachteil zur Kompilationszeit, denn ihr Programmcode steht in einer Header-Datei, die unter Umständen in vielen anderen Dateien eingebunden wird. Der Methodencode wird dadurch mehrfach kompiliert, obwohl einmal völlig ausreichen würde. Für die Kompilation wäre es daher von Vorteil, wenn der Methodencode in eine eigene Datei ausgelagert werden könnte, die nur einmal kompiliert wird. Es wird keine Überraschung sein, dass genau dies möglich ist. Das Prinzip ist identisch mit der Aufteilung einer Funktion in Deklaration und Definition (siehe Abschnitt 2.4, »Module«). Innerhalb der Klassendefinition in Becher.h steht jetzt nur noch die Deklaration der Methode: #pragma once #include <string> class Becher { public: std::string inhalt; int fassungsvermoegen; float fuellhoehe; void ausgabe(); }; Listing 4.8
Die Deklaration von »ausgabe«
151
4.4
4
Klassen
In Becher.h findet keine Ausgabe mehr statt, das Einbinden von iostream ist deshalb nicht mehr notwendig. Doch wo wandert die Methodendefinition hin? Wie bei Funktionen in die dazugehörige .cpp-Datei, die im Folgenden aufgeführt ist: #include "Becher.h" #include using namespace std; void Becher::ausgabe() { cout << "Becher mit " << inhalt << endl; } Listing 4.9
Die Definition von »ausgabe«
Eine Methode gehört bekanntermaßen zu einer Klasse. Aber zu welcher? Als Die Methodendefinition noch innerhalb der Klassendefinition stand, war die Zuordnung klar. Nun aber wird die Definition ausgelagert und der direkte Klassenbezug fehlt. Schlimmer noch: Mehrere Methoden aus unterschiedlichen Klassen könnten denselben Namen besitzen. Um dem Compiler mitzuteilen, zu welcher Klasse eine extern definierte Methode gehört, wird vor den Methodennamen der Klassenname, und dazwischen der Bezugsrahmenoperator gesetzt. Weil die Methode ausgabe zur Klasse Becher gehört, steht im Methodenkopf Becher::ausgabe. Zu lesen als »von der Klasse Becher das Element ausgabe«. Soll eine extern definierte Methode trotzdem als inline betrachtet werden, dann muss vor die Deklaration das Schlüsselwort inline geschrieben werden. Inline ist immer nur eine Empfehlung. Unabhängig davon, ob die Methode explizit über das Schlüsselwort inline oder implizit durch Definition innerhalb der Klasse als inline gekennzeichnet wurde, der Compiler entscheidet, ob die Methode tatsächlich inline wird oder nicht.
4.4.2
this
Manchmal ist es innerhalb einer Methode sinnvoll, zu wissen, über welches Objekt die Methode aufgerufen wurde. Dazu stellt jede Methode einen Zeiger namens this bereit, über den das aufrufende Objekt angesprochen werden kann. Die Methode ausgabe von Becher könnten wir damit auch, wie unten aufgeführt, implementieren:
Mit this->inhalt wird vom aufrufenden Objekt das Attribut inhalt angesprochen. Die bloße Angabe des Attributnamens spricht ebenfalls das Attribut des aufrufenden Objekts an, insofern ist this hier unnötig. Es wird aber klar, dass über this das aufrufende Objekt verfügbar ist und dieses bei Bedarf darüber an Funktionen oder Methoden anderer Klassen übergeben werden kann.
4.5
Konstruktoren
Die Erzeugung eines Bechers läuft bisher so ab: 1. Definition eines Klassenobjekts 2. Zuweisen von Werten an die Attribute Aber wer zwingt uns, den zweiten Schritt korrekt oder überhaupt auszuführen? Es ist kein Problem, einen Becher mit undefiniertem Inhalt oder negativem Fassungsvermögen zu erstellen. Auch wenn Letzteres vielleicht manches Physikerherz höher schlagen lässt, sind diese Möglichkeiten nicht unbedingt praxistauglich. Um sicherzustellen, dass spätere Programmteile nicht mit Bechern aus einem Paralleluniversum konfrontiert werden, muss gewährleistet sein, dass alle Attribute einen korrekten Inhalt besitzen. Und genau dazu dienen Konstruktoren. Ein Konstruktor ist eine besondere Form von Methode, die den Ersteller eines Klassenobjekts zwingt, bestimmte Informationen zur Initialisierung des Objekts anzugeben. Wir könnten einen Nutzer der Klasse Becher bei der Objektdefinition zwingen, Inhalt, Fassungsvermögen und Füllhöhe anzugeben. Der Konstruktor benötigt dazu drei Parameter, die an ihn übergeben werden müssen und deren Typen mit den zu initialisierenden Attributen übereinstimmen sollten. Konstruktoren besitzen den Namen ihrer Klasse und können keine Werte zurückliefern. Aus diesem Grund darf nicht einmal void angegeben werden. Zunächst schreiben wir den Konstruktor inline in die Klassendefinition, wie im Folgenden zu sehen ist. class Becher { public:
153
4.5
4
Klassen
std::string inhalt; int fassungsvermoegen; float fuellhoehe; Becher(std::string i, int fa, float fu) { inhalt=i; fassungsvermoegen=fa; fuellhoehe=fu; } void ausgabe(); }; Listing 4.10
Die Klasse »Becher« mit Inline-Konstruktor
Der Konstruktor macht nichts anderes, als die an ihn übergebenen Parameter zur Initialisierung der Attribute zu verwenden. Wenn Sie nun behaupten, dass immer noch missratene Objekte erstellt werden können, weil für das Fassungsvermögen schlicht ein negativer Wert an den Konstruktor übergeben werden könnte, dann haben Sie Recht. Aber: Es kann kein Objekt mehr erzeugt werden, ohne dass der Konstruktor abgearbeitet wird. Von daher bräuchte im Konstruktor nur entsprechender Code untergebracht zu werden, der alle ungültigen Konstellationen abfängt. Wie auf solche entdeckten Fehler reagiert werden kann, wird bei den Ausnahmen in Abschnitt 5.3, »Ausnahmen«, besprochen. Uns soll an dieser Stelle die Gewissheit reichen, Missbrauch verhindern zu können, wenn wir es wollten. Um nun ein Objekt zu erzeugen, müssen bei der Definition die vom Konstruktor geforderten drei Argumente übergeben werden. Das könnte so aussehen: Becher b("Milch", 300, 50);
Die ursprüngliche Schreibweise Becher b;
funktioniert nicht mehr, weil die Klasse dazu einen Konstruktor ohne Parameter besitzen müsste, den man Standardkonstruktor nennt.
154
Konstruktoren
4.5.1
Externe Definition
Nachdem die Grundlagen eines Konstruktors besprochen sind, soll auch an ihm die Aufteilung von Deklaration und Definition vollzogen werden. In der Klassendefinition steht nur noch die Konstruktordeklaration: Becher(std::string i, int fa, float fu);
Die Definition in der .cpp-Datei sieht so aus: Becher::Becher(string i, int fa, float fu) { inhalt=i; fassungsvermoegen=fa; fuellhoehe=fu; } Listing 4.11
Der ausgelagerte Konstruktor von »Becher«
Die Schreibweise Becher::Becher mag etwas merkwürdig aussehen, aber sie gehorcht der bekannten Regel Klassenname::Methodenname.
4.5.2
Private Attribute
Nachdem wir nun mit den Konstruktoren Klassenobjekte initialisieren können und mit den Methoden die Möglichkeit haben, jegliche Funktionalität zur Verfügung zu stellen, gibt es keinen sinnvollen Grund mehr, die Attribute weiterhin öffentlich zu lassen. Wir werden sie – wie es sich für die Datenkapselung gehört – mit privatem Zugriffsrecht ausstatten: class Becher { private: std::string inhalt; int fassungsvermoegen; float fuellhoehe; public: Becher(std::string i, int fa, float fu); void ausgabe(); }; Listing 4.12
Die Klasse »Becher« mit privaten Attributen
Die explizite Angabe von private: am Anfang der Klasse ist nicht zwingend notwendig, weil Klassenelemente ohne explizites Zugriffsrecht automatisch privat sind.
155
4.5
4
Klassen
4.5.3
Elementinitialisierungsliste
Technisch gesehen wurden bereits alle Attribute eines Objekts von ihren Standardkonstruktoren initialisiert, bevor der Anweisungsblock des entsprechenden Konstruktors der Klasse abgearbeitet wird. Hinweis Vor der Konstruktorausführung werden alle Attribute mit ihrem Standardkonstruktor initialisiert.
Für die Klasse Becher heißt das, die Attribute inhalt, fassungsvermoegen und fuellhoehe sind mit ihren Standardwerten initialisiert, bevor sie im BecherKonstruktor mit den an ihn übergebenen Argumenten beschrieben werden; Der String beinhaltet eine leere Zeichenkette, die numerischen Typen wurden mit 0 initialisiert. Das erscheint irgendwie unnötig, denn warum die Attribute zuerst mit Standardwerten versehen, wenn sie anschließend mit den wirklichen Initialisierungswerten beschrieben werden? Diese Initialisierung vor der Abarbeitung des Konstruktor-Anweisungsblocks lässt sich nicht vermeiden, wohl aber angeben, womit die Attribute initialisiert werden sollen. Und genau dazu dient die Elementinitialisierungsliste. Sie steht in der Konstruktordefinition zwischen Konstruktorkopf und Anweisungsblock und ist vom Kopf mit einem Doppelpunkt getrennt. Die einzelnen Initialisierungen werden mit Kommata getrennt: Becher::Becher(string i, int fa, float fu) : inhalt(i), fassungsvermoegen(fa), fuellhoehe(fu) { } Listing 4.13
Konstruktor mit Elementinitialisierungsliste
Die Initialisierung der einzelnen Attribute in der Elementinitialisierungsliste sieht aus wie die Definition von Klassenobjekten mithilfe eines Konstruktors. Und technisch gesehen ist es auch so. Die Zuweisungen innerhalb des Anweisungsblocks sind nun überflüssig. Im Falle des Becher-Konstruktors hat der Einsatz der Elementinitialisierungsliste lediglich eine Verbesserung der Performanz zur Folge, er ist aber nicht zwingend.
156
Konstruktoren
Achtung Es gibt zwei Fälle, die eine Elementinitialisierungsliste notwendig machen: 1. Das Attribut wurde als konstant deklariert. 2. Das Attribut wurde als Referenz deklariert.
Das klingt einleuchtend, denn sowohl eine Referenz als auch eine Konstante müssen bei ihrer Definition initialisiert werden und können nicht nachträglich einen anderen Wert zugewiesen bekommen.
4.5.4
Explizite Konstruktoren
Betrachten wir folgende simple Klasse Person, die bewusst nur ein Attribut zum Speichern des Alters und einen passenden Konstruktor besitzt: class Person { int alter; public: Person(int a) : alter(a) { } }; Listing 4.14
Die Klasse »Person«
Ein Objekt dieser Klasse ist leicht erzeugt: Person p(33);
Eine Überraschung ist jedoch folgende Zuweisung: p=20;
Wieso kann einem Objekt des Datentyps Person ein Wert des Typs int zugewiesen werden? Weil der Compiler einen Konstruktor der Klasse zur Typumwandlung verwendet. Hinweis Konstruktoren, die nur mit einem Parameter aufgerufen werden können – entweder weil sie nur einen Parameter besitzen oder weil die anderen Parameter Standardwerte besitzen –, werden vom Compiler verwendet, um den Datentyp des Konstruktorparameters in den Datentyp der Klasse des Konstruktors umzuwandeln.
157
4.5
4
Klassen
Der Compiler verwendet also den Konstruktor von Person, um aus dem Parametertyp int ein Person-Objekt zu erzeugen, und weist dieses dann dem Person-Objekt auf der linken Seite des Zuweisungsoperators zu. Das ist oft sehr praktisch, manchmal aber auch lästig, speziell dann, wenn nicht wirklich klar ist, was genau die Zuweisung eigentlich bewirkt. Wenn Sie nicht möchten, dass der Compiler einen solchen Konstruktor zur impliziten Typumwandlung heranzieht, dann müssen Sie ihn als explizit deklarieren: class Person { int alter; public: explicit Person(int a) : alter(a) { } }; Listing 4.15
Die Klasse »Person« mit explizitem Konstruktor
Tipp Es können beliebig viele Konstruktoren als explizit deklariert werden.
4.5.5
Standardkonstruktor
Eine wichtige Variante der Konstruktoren ist der Standardkonstruktor. Hinweis Als Standardkonstruktor wird der Konstruktor bezeichnet, der ohne Parameter aufgerufen wird (entweder weil er keine Parameter besitzt oder weil er für alle Parameter Standardwerte definiert).
Oft besteht bei der Definition eines Standardkonstruktors die Schwierigkeit darin, sinnvolle Standardwerte für die Attribute zu finden. Was wäre zum Beispiel das Standard-Alter von Objekten der Klasse Person aus dem vorigen Abschnitt? Der Standardkonstruktor, der das Alter auf 0 setzt, sähe so aus: Person() : alter(0) { } Listing 4.16
158
Der Standardkonstruktor von »Person«
Konstruktoren
Tipp Numerische Objekte sollten als Standardwert ihre Repräsentation von 0 wählen (bei einer Bruch-Klasse, deren Objekte aus Zähler und Nenner bestehen, beispielsweise Zähler gleich 0 und Nenner gleich 1).
Besitzt eine Klasse einen Standardkonstruktor, dann können von ihren Objekten Arrays erzeugt werden: Person personen[10];
Achtung Nur von Klassen mit Standardkonstruktor lassen sich Arrays erzeugen.
4.5.6
Kopierkonstruktor
Eine weitere Sonderform von Konstruktoren ist der Kopierkonstruktor, der ein Objekt derselben Klasse übergeben bekommt und von diesem eine Kopie anfertigt. Die Syntax des Kopierkonstruktors lautet: Klassenname(const Klassenname& objektname) { }
Kopierkonstruktoren verwenden immer eine Referenz auf ein konstantes Objekt. Für die Klasse Person sähe dieser Kopierkonstruktor so aus: Person(const Person& p) : alter(p.alter) { } Listing 4.17
Der Kopierkonstruktor von »Person«
Hinweis Wenn kein Kopierkonstruktor definiert wird, dann fügt der Compiler implizit einen Kopierkonstruktor hinzu. Dieser kopiert das Objekt aber nur attributweise (flache Kopie). Belegen die Objekte einer Klasse dynamisch Ressourcen, dann ist ein eigener Kopierkonstruktor erforderlich, der auch die Ressourcen kopiert (tiefe Kopie).
159
4.5
4
Klassen
4.6
Konstanzwahrende Methoden
Kommen wir zurück zu den Bechern. Wir können mit dem Schlüsselwort const auch konstante Klassenelemente erzeugen: const Becher c("Kaffee", 200, 100);
Dieser Becher gefüllt mit Kaffee kann niemals ausgetrunken werden und eignet sich höchstens als Anschauungsmaterial. Dazu müsste aber die Methode ausgabe aufgerufen werden: c.ausgabe(); // Fehler
Erstaunlicherweise meldet der Compiler einen Fehler: »this-Zeiger kann nicht von 'const Becher' in 'Becher &' konvertiert werden.« Der Compiler möchte damit auf seine ihm eigene Art sagen, dass die Methode ausgabe für variable Klassenobjekte gedacht ist und daher nicht für ein konstantes Klassenobjekt aufgerufen werden kann. Die Frage ist nur, warum nicht? In ausgabe werden keine Änderungen an den Attributen vorgenommen, insofern bleibt das Objekt unverändert. Der Compiler möchte dies aber explizit mitgeteilt bekommen. Hinweis Damit eine Methode über ein konstantes Klassenobjekt aufgerufen werden kann, muss die Methode als konstanzwahrend deklariert sein.
Eine Methode muss sowohl bei der Deklaration als auch bei der Definition als konstanzwahrend deklariert werden. Dazu wird hinter dem Methodenkopf das Schlüsselwort const geschrieben: void ausgabe() const; Listing 4.18
Deklaration einer konstanzwahrenden Methode
Und noch die Definition: void Becher::ausgabe() const { cout << "Becher mit " << inhalt << endl; } Listing 4.19
Definition einer konstanzwahrenden Methode
Nun kann ausgabe auch für eine Konstante aufgerufen werden.
160
Überladen von Methoden
Hinweis Eines ist noch zu beachten: Nur Methoden, die wirklich keine Änderungen an den Attributen vornehmen, können als konstanzwahrend deklariert werden. Der Compiler prüft das nach!
4.6.1
Veränderliche Attribute
Wenn Sie gefragt werden, was die Aussage »ein Objekt ist konstant« bedeutet, was antworten Sie? Wahrscheinlich etwas wie »das Objekt kann nicht verändert werden«. Damit liegen Sie nicht falsch, nur, was haben wir uns darunter vorzustellen, dass ein Objekt nicht verändert werden kann? Ist ein Objekt nur dann konstant, wenn sich wirklich nichts an ihm ändern lässt? Oder reicht es schon aus, wenn der von außen sichtbare Zustand sich nicht ändern lässt? Denn eine Änderung, die der Benutzer des Objekts nicht merkt, ist für ihn auch keine Änderung. Vielleicht kommt Ihnen das wie Erbsenzählerei vor, aber es ist von entscheidender Bedeutung. Wenn Sie an den Hüften ein Kilo abgenommen haben, dafür aber am Bauch ein Kilo zugelegt, ist Ihr Gewicht dann konstant? In der objektorientierten Programmierung gilt die Regel, dass ein Objekt konstant ist, solange der äußere Zustand sich nicht ändert, beziehungsweise sich nicht ändern lässt. Es gibt aber Situationen, in denen aus verwaltungstechnischen Gründen Attribute eines konstanten Objekts geändert werden müssen. Dazu können Sie vor das Attribut das Schlüsselwort mutable schreiben. Diese so gekennzeichneten Attribute sind auch in konstanten Objekten veränderbar. Damit ist eine Methode, die ausschließlich mutable-Attribute verändert, immer noch konstanzwahrend.
4.7
Überladen von Methoden
Wir wissen jetzt, wie Methoden implementiert werden, und könnten die Klasse Becher etwas erweitern. Für den Anwender ist es vielleicht wichtig, zu wissen, ob eine bestimmte Menge noch in den Becher hineinpassen würde oder nicht. Wir schreiben dazu eine Methode reichtKapazitaet, die die fragliche Menge in Millilitern übergeben bekommt und als Booleschen Wert zurückliefert, ob diese Menge noch in den Becher passt oder nicht:
Der Ausdruck 100-fuellhoehe liefert die noch freien Kapazitäten des Bechers in Prozent, die dann in einen absoluten Wert in Millilitern umgerechnet werden. Das F hinter 100.0 zeigt an, dass es sich um eine Konstante vom Typ float handelt (siehe Abschnitt 1.7.3, »Literale«). Die beiden Anweisungen des Anweisungsblocks hätten auch zu einer zusammengefasst werden können: return(fassungsvermoegen/100.0F*(100-fuellhoehe) >= ml);
Als nächsten Schritt wollen wir eine Methode schreiben, die ein BecherObjekt übergeben bekommt und ermittelt, ob der Inhalt dieses Bechers in den aufrufenden Becher hineinpasst. Aber wie sollen wir die Methode nennen? Der Name reichtKapazitaet ist bereits vergeben. Die Problematik des gleichen Namens tritt nur auf, wenn die gleichnamigen Elemente sich im selben Bezugsrahmen befinden. Sind die Elemente in unterschiedlichen Klassen, Anweisungsblöcken oder Namensbereichen definiert, ist ein gleicher Name kein Problem. Glücklicherweise erlaubt C++ unter bestimmten Bedingungen auch Methoden im selben Bezugsrahmen, denselben Namen zu besitzen. Existieren unter einem Namen mehrere Methoden im selben Bezugsrahmen, dann ist dieser Name überladen. Ein Methodenname darf überladen sein, wenn 왘
die Methoden sich in der Anzahl ihrer Parameter unterscheiden oder
왘
sich Methoden mit gleicher Parameteranzahl in den Typen mindestens einer ihrer Parameter unterscheiden.
Die neu zu programmierende Methode reichtKapazitaet bekommt als Parameter ein Objekt des Typs Becher übergeben In reinem C++ wäre es üblich, das Objekt als Referenz zu übergeben; zur Einstimmung auf C++ unter .NET wird hier ein Zeiger verwendet. Damit unterscheiden sich die Parametertypen, und das Überladen ist gültig: bool Becher::reichtKapazitaet(const Becher* b) const { if(inhalt!=b->inhalt) return(false); return(reichtKapazitaet(
Haben Sie eine Vorstellung, warum die Methode als Parameter einen Zeiger auf eine Konstante und nicht auf eine Variable erwartet? Für die Methode reicht es aus, mit einem konstanten Parameter zu arbeiten, weil die Attribute des übergebenen Objekts nur ausgelesen werden. Der Vorteil liegt aber darin, dass der Methode auch eine Variable übergeben werden könnte. Denn es stört die Variable herzlich wenig, wenn sie als Konstante behandelt wird. Anders herum wäre es schon schwieriger. Würde die Methode eine Variable erwarten, aber eine Konstante übergeben bekommen, dann könnte die Methode die Konstante ändern. Weil das nicht sein darf, meldet der Compiler in solch einem Fall einen Fehler. Schauen wir uns die Implementierung etwas genauer an. Die Objekte der Klasse Becher sollen keine Mischungen enthalten, deswegen wird in der Methode zuerst geprüft, ob die beiden Becher dasselbe »Produkt« beinhalten. Anschließend wird berechnet, wie viel Milliliter Inhalt der übergebene Becher besitzt und damit dann die erste reichtKapazitaet-Methode aufgerufen. Weil diese Methode einen int-Wert erwartet, das Ergebnis der Berechnung aber float ist, müssen wir dieses vorher mit einem static_cast (siehe Abschnitt »Explizite Typumwandlung« in Abschnitt 1.9.8) in int umgewandeln. Die dazugehörige Deklaration in der Klassendefinition dürfte kein Problem mehr darstellen.
4.8
Statische Klassenelemente
Statische Klassenelemente sind nicht an ein Objekt, sondern an die Klasse gebunden. Was das genau bedeutet und wo es nützlich sein kann, zeigen die nächsten Abschnitte.
4.8.1
Statische Methoden
Die bisherige Implementierung der Klasse benötigt des Öfteren eine Umrechnung von prozentualen in absolute Werte. Eine reizvolle Gelegenheit, dafür eine kleine Hilfsmethode berechneAbsolutwert zu programmieren. Weil nicht nur der Absolutwert des Becherinhalts, sondern auch der Absolutwert
163
4.8
4
Klassen
der noch verfügbaren Becherkapazität benötigt wird, soll die Methode nicht die Attribute des Objekts auslesen, sondern die beiden zur Berechnung notwendigen Werte als Parameter übergeben bekommen: float Becher::berechneAbsolutwert(float gw, float ps) const { return(gw*ps/100.0F); } Listing 4.22
Die Methode »berechneAbsolutwert«
Die Methode bekommt den Grundwert und den Prozentsatz übergeben und liefert den Prozentwert zurück. Um ihren Einsatzbereich möglichst groß zu halten, arbeitet sie nur mit float-Werten. Die beiden reichtKapazitaet-Methoden können die neue Methode gut verwenden. Die Methode berechneAbsolutwert wird nur von anderen Methoden derselben Klasse aufgerufen. Sie könnte daher problemlos privates Zugriffsrecht besitzen: bool Becher::reichtKapazitaet(int ml) const { float platz = berechneAbsolutwert( static_cast(fassungsvermoegen), 100-fuellhoehe); return(platz >= ml); } bool Becher::reichtKapazitaet(const Becher* b) const { if(inhalt!=b->inhalt) return(false); return(reichtKapazitaet( static_cast(berechneAbsolutwert( static_cast(b->fassungsvermoegen), b->fuellhoehe)))); } Listing 4.23
»reichtKapazitaet« mit Verwendung von »berechneAbsolutwert«
Sie überlegen noch, ob der Einsatz dieser Methode wirklich so sinnvoll war, mussten doch zwecks Typkompatibilität einige Casts hinzugefügt werden, kommen aber zu dem Schluss, dass eine Codeverdopplung vermieden wurde, was in komplexeren Projekten unbedingt erstrebenswert ist, da flattert ein Werbeprospekt Ihrer Bank ins Haus. Es verspricht Ihnen sagenhafte 1,85 % auf Ihr Erspartes. Sie wollen kurz überschlagen, wie viel das im Jahr
164
Statische Klassenelemente
für Ihre 3581,44 € bringt. Noch ganz in der Euphorie der gelungenen BecherKlasse schwelgend, fällt Ihnen ein, dass dort doch eine Methode programmiert wurde, die genau das berechnen kann. Aber wie rufen Sie die Methode am geschicktesten auf? Methoden müssen immer über ein Objekt aufgerufen werden, insofern müssen Sie zunächst einen beliebigen Becher konstruieren, der mit Ihrem eigentlichen Problem nichts zu tun hat, um dann die gewünschte Methode aufzurufen. Der Methodenaufruf funktioniert natürlich nur dann, wenn die Methode öffentliches Zugriffsrecht besitzt. Becher b("BeliebigerInhalt", 300, 0); cout << b.berechneAbsolutwert(1.85F, 3581.44F) << endl;
Auch wenn Sie bei der Betrachtung des Ergebnisses vielleicht froh wären, einen Becher mit entsprechendem Inhalt danebenstehen zu haben, ist es aus Sicht der Programmierung etwas befremdlich, dass zur Berechnung der Sparbuchzinsen ein Becher konstruiert werden muss. Zumal es auch überhaupt keinen Sinn macht, berecheAbsolutwert über ein Objekt aufzurufen, denn die Methode greift auf keines der Objektattribute zu. In ANSI C++ haben wir eine einfache Möglichkeit: Statt einer Methode berechneAbsolutwert schreiben wir eine entsprechende Funktion, die von Natur aus ohne Objekt aufgerufen werden kann. Nur wollen wir später unter .NET programmieren, wo es keine Funktionen mehr gibt. Die Lösung lautet, wie der Titel dieses Abschnitts: statische Methoden. Statische Methoden sind nicht an ein Objekt gebunden, sondern an die Klasse. Deswegen werden sie auch Klassenmethoden genannt. Um eine Methode als statisch zu deklarieren, wird vor die Methodendeklaration das Schlüsselwort static geschrieben: static float berechneAbsolutwert(float gw, float ps);
Das const zur Deklaration der Methode als konstanzwahrend ist verschwunden, weil die Methode nicht mehr an ein Objekt gebunden ist und es ihr demnach egal sein kann, ob ein Objekt konstant oder variabel ist. Nun kann die Methode ohne ein Objekt und nur über den Klassennamen aufgerufen werden: cout << Becher::berechneAbsolutwert(1.85F, 3581.44F) << endl;
165
4.8
4
Klassen
Hinweis Auch eine statische Methode kann außerhalb der eigenen Klasse nur aufgerufen werden, wenn sie öffentliches Zugriffsrecht besitzt.
Der Aufruf über ein Objekt ist weiterhin möglich. Wichtig ist nur, dass eine statische Methode nicht auf Attribute ihrer Klasse direkt zugreift, eben weil sie nicht mehr zwingend über ein Objekt aufgerufen wird. Tipp Statische Methoden dürfen nicht direkt auf Objektdaten der eigenen Klasse zugreifen. Ein Zugriff selbst auf private Elemente anderer Objekte derselben Klasse ist allerdings erlaubt.
4.8.2
Statische Attribute
Analog zu den statischen Methoden sind statische Attribute (auch Klassenattribute genannt) nicht mehr an ein Objekt gebunden. Während die bisherigen Attribute für jedes Objekt einen eigenen Wert annehmen können, ist der Wert eines statischen Attributs für jedes Objekt gleich. Wir könnten zum Beispiel sagen, dass ein Becher, dessen Fassungsvermögen ein gewisses Maß überschreitet, eher zur Kategorie »Eimer« zählt und daher nicht mehr von der Klasse Becher abgedeckt werden sollte. Diese Grenze gilt für alle Objekte der Klasse. Es bietet sich an, hierfür ein Attribut zu verwenden, welches für alle Objekte denselben Wert hat: ein statisches Attribut. Genau wie statische Methoden wird ein statisches Attribut durch Voranstellen des Schlüsselworts static deklariert: static int maxmenge;
Wie alle anderen Attribute auch wird es im private-Bereich der Klasse untergebracht. Zu klären ist noch, wo das statische Attribut mit einem Wert initialisiert wird. Zusammen mit den anderen Attributen im Konstruktor ist der falsche Ort, denn das statische Attribut wird von allen Objekten geteilt und muss nur einmal initialisiert werden. Die Klasse besitzt bereits einen Platz für Elemente, die nur einmal »bearbeitet« werden sollen: die cpp-Datei. Dort hinein setzen wir die Initialisierung des statischen Attributs: int Becher::maxmenge=1000;
166
typedef
Durch die erneute Angabe des Datentyps von maxmenge wird dem Compiler mitgeteilt, dass es sich um die Initialisierung handelt. Besäße das Attribut öffentliches Zugriffsrecht, dann könnte es außerhalb der Klasse mit Becher::maxmenge angesprochen werden. Im Falle des statischen Attributs maxmenge müssen wir die Frage klären, ob das Attribut überhaupt während der Laufzeit geändert werden können soll oder ob der Wert einmal zur Kompilationszeit festgelegt wird und dann für die gesamte Laufzeit gleich bleibt. Um letzteren Fall zu garantieren, können Sie das statische Attribut zusätzlich noch mit const als konstant deklarieren: static const int maxmenge;
Solche statischen, konstanten Attribute dürfen direkt bei der Definition initialisiert werden, wenn es sich um ganzzahlige Typen handelt: static const int maxmenge=1000;
4.9
typedef
Mithilfe des Befehls typedef besteht die Möglichkeit, ein Synonym für einen Datentyp zu erstellen. Die Motivation, ein solches Synonym zu erstellen, ist die gleiche wie die, derentwegen Konstanten verwendet werden. Wir erinnern uns: Der Vorteil einer Konstanten liegt an ihrer zentralen Wertzuweisung: const float mwst = 1.19F;
Wenn nun im Programm nur noch mwst verwendet wird, dann kann durch eine Änderung der oberen Zeile der Mehrwertsteuersatz für das gesamte Programm verändert werden. Ein typedef funktioniert ähnlich – nur eben für Datentypen: typedef long long int MeinInt;
Für einen long long int wird ein Synonym namens MeinInt definiert. Der neue Datentyp MeinInt kann nun zur Definition von Objekten des Typs verwendet werden: MeinInt v;
Auch hier liegt der Vorteil klar auf der Hand. Sollte Bedarf an einem anderen ganzzahligen Datentypen entstehen, dann braucht nur der typedef geändert zu werden, und schon verwendet das gesamte Programm den neuen Typen.
167
4.9
4
Klassen
Nehmen wir als weiteres Beispiel die Methode berechneAbsolutwert von Becher. Der Rückgabetyp der Methode ist float, es könnte aber durchaus sein, dass es vielleicht doch noch int werden soll. Um diesen Wechsel auch noch in einer späteren Phase der Programmentwicklung ohne größeren Aufwand zu ermöglichen, wird ein typedef verwendet. Der Übersichtlichkeit halber werden zugleich die private- und public-Bereiche der Klasse vertauscht: class Becher { public: typedef float AbsWertTyp; Becher(std::string i, int fa, float fu); void ausgabe() const; bool reichtKapazitaet(int ml) const; bool reichtKapazitaet(const Becher* b) const; static AbsWertTyp berechneAbsolutwert(float gw, float ps); private: std::string inhalt; int fassungsvermoegen; float fuellhoehe; static int maxmenge; }; Listing 4.24
Die Klassendefinition von »Becher« mit »typedef«
Der typedef steht im public-Teil der Klasse, damit er von außen zugänglich ist. Wir betrachten gleich noch ein Beispiel dazu, müssen aber zuerst einen Blick auf die Definition von berechneAbsolutwert werfen: Becher::AbsWertTyp Becher::berechneAbsolutwert(float gw, float ps) { return(static_cast(gw*ps/100.0F)); } Listing 4.25
Die Methode »berechneAbsolutwert« mit »typedef«
Es ist vielleicht überraschend, dass bei der Methodendefinition der Rückgabetyp nun mit expliziter Klassenangabe (Becher::AbsWertTyp) deklariert wird, obwohl bei der Methodendeklaration ein bloßes AbsWertTyp ausreicht. Die Erklärung ist eigentlich ganz einfach: Wenn der Compiler beginnt, den Funktionskopf zu lesen, beginnt er links mit dem Rückgabetyp. Zu diesem Zeitpunkt weiß er noch nicht, dass es der Rückgabetyp einer Becher-Methode
168
Verschachtelte Klassen
ist und wüsste auch nicht, wo er AbsWertTyp suchen sollte, denn der Name könnte von mehreren Klassen verwendet worden sein. Erst, nachdem der Compiler mit der Information Becher::berechneAbsolutwert weiß, dass die Methode zur Klasse Becher gehört, kann einfach AbsWertTyp geschrieben werden, wie im Anweisungsblock zu sehen ist. Auch bei den Funktionsparametern hätte bereits der Typname ohne Klassenangabe ausgereicht. Der static_cast in der return-Anweisung ist unverzichtbar, weil der Ausdruck einen float-Wert ergibt und umgewandelt werden muss für den Fall, dass AbsWertTyp nicht float ist. Aus diesem Grund ist auch bei den reichtKapazitaet-Methoden eine Anpassung oder Ergänzung der Casts notwendig, aber das sollten Sie einmal selbst versuchen.
4.10
Verschachtelte Klassen
Klassen können innerhalb anderer Klassen definiert werden. Im folgenden Beispiel wird die Klasse LokaleKlasse innerhalb von Hauptklasse definiert: class Hauptklasse { public: class LokaleKlasse { }; }; Listing 4.26
Eine verschachtelte Klassendefinition
Hinweis Die lokale Klasse ist ein Klassenelement der übergeordneten Klasse. Es gelten die Zugriffsrechte wie bei anderen Klassenelementen auch.
Sie erzeugen ein Objekt der lokalen Klasse von außerhalb so: Hauptklasse::LokaleKlasse o;
Der Zugriff ist möglich, weil LokaleKlasse im public-Teil von Hauptklasse definiert wurde. Aber wie sehen die Zugriffsrechte zwischen den beiden Klassen aus? Jede Klasse bekommt ein privates Attribut sowie eine Methode test, in der ein Objekt der jeweils anderen Klasse erzeugt und auf das private Attribut zugegriffen wird:
169
4.10
4
Klassen
class Hauptklasse { // Privates Attribut von Hauptklasse int hauptPrivat; public: class LokaleKlasse { // Privates Attribut von LokaleKlasse int lokalPrivat; public: void test() { Hauptklasse ho; ho.hauptPrivat=10; // OK } }; void test() { LokaleKlasse lo; lo.lokalPrivat=10; }
// Fehler
}; Listing 4.27
Zugriffsrechte verschachtelter Klassen
Die lokale Klasse gilt als Element von Hauptklasse und hat daher, wie alle Klassenelemente auch, Zugriff auf die privaten Elemente. Anders sieht es aus mit dem Zugriff auf die privaten Elemente der lokalen Klasse von der übergeordneten Klasse aus. Dieser verhält sich wie ein Zugriff von außen, deshalb sind nur die öffentlichen Elemente ansprechbar. Sollen alle Elemente der lokalen Klasse von der umgebenden Klasse aus ansprechbar sein, dann muss die lokale Klasse die umgebende Klasse als Freund deklarieren: class Hauptklasse { int hauptPrivat; public: class LokaleKlasse { friend class Hauptklasse; int lokalPrivat; public: void test() { Hauptklasse ho; ho.hauptPrivat=10; // OK
Nun hat auch die äußere Klasse Zugriff auf alle Elemente der lokalen Klasse. Tipp Eine lokale Klasse kann im privaten Bereich der äußeren Klasse stehen und wäre dann von außen nicht mehr ansprechbar. Dieser Umstand ist ausgesprochen praktisch, wenn Objekte einer Klasse nur innerhalb einer anderen Klasse erstellt werden sollen/ dürfen. Wie zum Beispiel eine Listen- oder Baumklasse und ihre Knoten.
4.11
Vererbung
Die Technik der Vererbung ist ein wesentliches Abstraktionsprinzip der objektorientierten Programmierung und dient hauptsächlich einem einzigen Zweck: der Erweiterung bestehender Funktionalitäten, ohne die bereits vorhandene Implementierung verändern zu müssen. Angenommen, die Klasse Becher soll um die Fähigkeit erweitert werden, einen Aufdruck zu besitzen (wie z. B. »Weltbester C++-Programmierer«). Sie könnten diese Eigenschaft problemlos in die Klasse Becher einbauen. Dann tritt der Nächste mit einem Wunsch an Sie heran: Die Klasse Becher soll verschiedene Stoffe in unterschiedlicher Menge aufnehmen können (z. B. Kaffee, Milch und Zucker) Auch das bauen Sie in die Klasse ein. Nun hat der zukünftige Mischer aber auch noch den vorhin implementierten Aufdruck dabei, obwohl er ihn vielleicht nicht benötigt. Sie können dieses Beispiel weiterspinnen, bis Sie eine riesige, monolithische Klasse erhalten, die zwar fast alles kann, von dem der Anwender aber immer nur maximal 10 % benötigt. Dieses Problem wird mit Vererbung umgangen. Ein anderes Problem ist der Wunsch, eine Klasse zu erweitern, deren Quellcode Ihnen nicht zur Verfügung steht. Auch das ist mit Vererbung lösbar.
171
4.11
4
Klassen
4.11.1
Das Wesen der Vererbung
Die in fast allen Fällen verwendete Vererbung ist die öffentliche Vererbung, die ein Verhältnis ist ein(e) zwischen Klassen zum Ausdruck bringt. C++ kennt noch zwei andere Arten der Vererbung.1 Da vom .NET-Framework aber nur die öffentliche Vererbung unterstützt wird, wollen wir uns hier auf sie beschränken. Als einfaches Studienbeispiel soll die Becher-Klasse, wie im vorigen Abschnitt angesprochen, um die Möglichkeit erweitert werden, einen Aufdruck zu ermöglichen. Wir können sagen: Ein Becher mit Aufdruck ist ein Becher, der zusätzlich noch einen Aufdruck besitzt. Der Kern dieser Aussage lautet: Ein Becher mit Aufdruck ist ein Becher. Das ist auch logisch, denn ein Becher mit Aufdruck kann all das, was auch ein normaler Becher kann, er hat eben nur zusätzlich einen Aufdruck. Und genau das macht die Vererbung. Die Klasse, an die vererbt wird (auch Subklasse oder abgeleitete Klasse genannt), erbt alle Eigenschaften der vererbenden Klasse (auch Basisklasse oder Superklasse genannt) und kann diese nach Bedarf erweitern. Bei der Vererbung stehen die öffentlichen Elemente der Basisklasse als öffentliche Elemente der Subklasse zur Verfügung. Die geschützten Elemente der Basisklasse sind als geschützte Elemente der Subklasse vorhanden. Klasse B Unzugänglicher Bereich Private Elemente Klasse A Klasse A Privater Bereich Private Elemente Klasse A
Klasse A vererbt an Klasse B
Geschützte Elemente Klasse A Öffentliche Elemente Klasse A
Geschützter Bereich Geschützte Elemente Klasse A Öffentlicher Bereich Öffentliche Elemente Klasse A
Abbildung 4.4 Das Prinzip der öffentlichen Vererbung 1 Weitere Informationen finden Sie in Willms 2005.
172
Konstruktoren und Vererbung
Nur: Die privaten Elemente der Basisklasse werden zwar auch an die Subklasse vererbt, sie sind aber von der Subklasse aus nicht ansprechbar. Abbildung 4.4 stellt den Zusammenhang grafisch dar. Es ist vielleicht etwas gewöhnungsbedürftig, dass im oberen Fall Klasse B Elemente von Klasse A geerbt hat, auf die sie nicht zugreifen kann. Aber andererseits ist dies eine logische Folge aus der Forderung, dass auf private Elemente einer Klasse nur die Klasse selbst zugreifen darf.
4.11.2
Die Syntax der Vererbung
Die Syntax der öffentlichen Vererbung sieht so aus: class Subklassenname : public Basisklassenname { // Erweiterungen der Subklasse };
Syntaktisch ist die Vererbung schnell umgesetzt. Die neue Klasse wird zunächst wie in Abschnitt 4.1, »Definition einer Klasse«, beschrieben mit leerem Anweisungsblock angelegt: class BecherMitAufdruck { }
Hinter dem Klassennamen wird, durch einen Doppelpunkt getrennt, der Name der Basisklasse (in unserem Fall Becher) angegeben: class BecherMitAufdruck : public Becher { } Listing 4.29
Die Syntax der Vererbung
Das Schlüsselwort public vor dem Basisklassennamen leitet die von uns gewünschte öffentliche Vererbung ein.
4.12
Konstruktoren und Vererbung
Allerdings werden Sie feststellen, dass der Compiler einen Fehler meldet. Und zwar beschwert er sich über einen fehlenden Standardkonstruktor in der Klasse Becher. Ein guter Zeitpunkt also, ein wenig die Konstruktion von
173
4.12
4
Klassen
Objekten abgeleiteter Klassen zu beleuchten. Wir können der Lösung bereits mit einigen einfachen Fragen erstaunlich nah kommen: Wie wird in einer Klasse ein Objekt erzeugt? Über den Konstruktor. Wie konstruiert ein Konstruktor ein Objekt? Indem er – entweder im Anweisungsblock oder in der Elementinitialisierungsliste – die Attribute des Objekts initialisiert – meist mithilfe übergebener Argumente. Hier tritt schon der erste Fauxpas zutage: Unser Konstruktor besitzt noch keine sinnvollen Parameter. Diese kleine formale Unschärfe einmal kurz beiseite geschoben, tritt ein neues Problem auf: Wie soll der Konstruktor von BecherMitAufdruck die Objektattribute initialisieren? Einfach durch Zuweisung oder über die Elementinitialisierungsliste eher nicht, denn wie Abbildung 4.4 schön darstellt, können die Elemente der Subklasse – und dazu gehört der Konstruktor von BecherMitAufdruck – nicht auf die privaten Elemente der Basisklasse zugreifen. Wir bräuchten also etwas, dass auf der einen Seite Zugriff auf die privaten Elemente der Basisklasse hat – es kommt also nur ein Element der Basisklasse in Frage – und auf der anderen Seite in der Lage ist, die Basisklassenattribute zu initialisieren. Einmal kurz über den Quellcode von Becher meditiert, findet sich der geeignete Kandidat: Der Konstruktor von Becher. Er ist öffentlich und daher von der Basisklasse aus ansprechbar, und er weiß, wie die Attribute zu initialisieren sind. Wir brauchen also nur vom Subklassenkonstruktor aus den Basisklassenkonstruktor aufzurufen. Dummerweise benötigt der Basisklassenkonstruktor zur Initialisierung der Attribute drei Argumente. Damit ihm diese Argumente übergeben werden können, muss der Subklassenkonstruktor diese vom Anwender einfordern. Die grobe Richtung steht also fest: Der Basisklassenkonstruktor benötigt drei Argumente, die der Subklassenkonstruktor über eigene Parameter beschaffen muss. Betrachten wir dazu kurz die Datei BecherMitAufdruck.cpp, die den aktualisierten Konstruktor enthält: #include "BecherMitAufdruck.h" using namespace std; BecherMitAufdruck::BecherMitAufdruck(string i, int fa, float fu) { } Listing 4.30 Die Datei »BecherMitAufdruck.cpp«
174
Konstruktoren und Vererbung
Die Konstruktordeklaration in der Klassendefinition muss ebenfalls mit den Parametern versehen werden. Kompilieren lässt sich das Programm aber immer noch nicht, denn wir haben den Basisklassenkonstruktor noch nicht aufgerufen. Hinweis Der Basisklassenkonstruktor wird in der Elementinitialisierungsliste des Subklassenkonstruktors aufgerufen.
Der endgültige Konstruktor sieht so aus: BecherMitAufdruck::BecherMitAufdruck(string i, int fa, float fu) : Becher(i, fa, fu) { } Listing 4.31 Aufruf des Basisklassenkonstruktors
Und wenn Sie sich fragen, wie der ursprüngliche Fehler entstanden ist und was er bedeutet: Es muss immer ein Basisklassenkonstruktor aufgerufen werden. Achtung In C++ muss der Subklassenkonstruktor einen Basisklassenkonstruktor aufrufen.
Welcher Konstruktor aufgerufen wird – es können durch Überladung mehrere existieren –, spielt keine Rolle. Diese Regel ist so wichtig, dass selbst dann ein Basisklassenkonstruktor aufgerufen wird, wenn der Programmierer keinen expliziten Aufruf angegeben hat. Hinweis Besitzt der Subklassenkonstruktor keinen expliziten Aufruf eines Basisklassenkonstruktors, dann wird der Standardkonstruktor der Basisklasse aufgerufen.
Als Standardkonstruktor wird der Konstruktor ohne Parameter bezeichnet. Zu Beginn wurde also automatisch dieser Standardkonstruktor von Becher aufgerufen. Da wir einen solchen aber nicht programmiert haben, kam die Fehlermeldung »kein geeigneter Standardkonstruktor verfügbar«.
175
4.12
4
Klassen
Nun endlich kann ein Objekt der neuen Klasse erstellt werden: BecherMitAufdruck b("Milch", 300, 90);
Die Klasse BecherMitAufdruck hat durch die Vererbung alle Fähigkeiten der Basisklasse Becher geerbt. Ohne weiter entwicklerisch aktiv werden zu müssen, kann ein BecherMitAufdruck-Objekt daher über die geerbte Methode ausgabe ausgegeben werden: b.ausgabe();
Alle anderen in Becher implementierten Methoden sind ebenfalls verfügbar.
4.13
Erweitern durch Vererbung
Der bisher betriebene Aufwand für die Klasse BecherMitAufdruck hat uns zu dem Punkt gebracht, dass sie exakt die gleichen Fähigkeiten besitzt wie ihre Basisklasse Becher. Das klingt nicht nach einem großartigen Gewinn, aber: Wir haben über die Vererbung die Funktionalitäten von Becher übernommen, ohne die Klasse Becher verändern zu müssen. Diese Technik funktioniert daher auch bei Klassen, auf deren Quellcode wir keinen Zugriff haben (in diese Rubrik fallen die später besprochenen Klassen der .NET-Bibliothek). Die Klasse BecherMitAufdruck kann jetzt allerdings nach Belieben erweitert werden und soll im weiteren Verlauf ihrem Namen alle Ehre machen. Der Becheraufdruck soll in der Klasse als Text gespeichert werden. Wir benötigen dazu in BecherMitAufdruck ein entsprechendes Attribut. Damit dieses Attribut initialisiert werden kann, muss der Konstruktor um einen Parameter erweitert werden: class BecherMitAufdruck : public Becher { std::string aufdruck; public: BecherMitAufdruck(std::string i, int fa, float fu, std::string auf); }; Listing 4.32 Die Klasse »BecherMitAufdruck« mit neuem Attribut
Das neue Attribut steht direkt am Klassenanfang und besitzt deshalb implizit privates Zugriffsrecht. Im oberen Beispiel wurde der zusätzliche Konstruktorparameter an die Parameterliste angehängt. Die Parameterreihenfolge ist allerdings beliebig und kann nach Bedarf verändert werden. Die Parameter-
176
Methoden überschreiben
listen von Deklaration und Definition müssen aber übereinstimmen. Die Definition des Konstruktors sieht so aus: BecherMitAufdruck::BecherMitAufdruck(string i, int fa, float fu, string auf) : Becher(i, fa, fu), aufdruck(auf) { } Listing 4.33 Der neue Konstruktor von »BecherMitAufdruck«
Weitere Funktionalität kann auf diese Weise nach Belieben hinzugefügt werden. Eine Zugriffsmethode für den Aufdruck wäre beispielsweise nicht schlecht: std::string getAufdruck() const { return(aufdruck); } Listing 4.34 Die Zugriffsmethode »getAufdruck«
Oben ist nur die Definition der Methode zu sehen. Handelt es sich bei dieser Definition um eine externe oder um eine Inline-Definition? Wäre es eine externe Definition, müsste angegeben werden, zu welcher Klasse die Methode gehört (BecherMitAufdruck::getAufdruck), insofern muss es sich um eine Methodendefinition innerhalb der Klassendefinition handeln. Falls Sie nicht mehr genau wissen, welche Auswirkungen das const hinter dem Methodenkopf hat, dann schlagen Sie schnell in Abschnitt 4.6, »Konstanzwahrende Methoden«, nach.
4.14
Methoden überschreiben
Wir wissen aus den vorigen Abschnitten, dass in der Klasse BecherMitAufdruck durch die Vererbungsbeziehung zu Becher deren Methode ausgabe
geerbt wurde und aufgerufen werden kann. Nun wäre es für die Klasse BecherMitAufdruck aber schön, wenn bei der Ausgabe auch der Becheraufdruck ausgegeben würde. Aber wie stellen wir das an? Die bestehende Methode ausgabe entsprechend abzuändern, ist aus einem einfachen Grund nicht möglich: Die Methode gehört zu Becher, und dort
177
4.14
4
Klassen
existiert das auszugebende Attribut aufdruck noch nicht. Die Konsequenz daraus führt zu einer neuen Methode für BecherMitAufdruck. Eine Methode mit demselben Namen ist auf den ersten Blick nicht möglich, denn die Parameterliste würde sich nicht von der geerbten Methode unterscheiden und ein Überladen unmöglich machen. Eine Methode mit anderem Namen zu implementieren ist auch keine saubere Lösung, denn es stände weiterhin die geerbte ausgabe-Methode zur Verfügung, die den Aufdruck nicht mit ausgibt. Die Lösung ist erschreckend simpel: Wenn wir in BecherMitAufdruck eine Methode ausgabe implementieren, dann ist das kein Überladen. Wir erinnern uns (siehe Abschnitt 4.7, »Überladen von Methoden«): Überladen ist ein Name nur dann, wenn er mehrfach im selben Bezugsrahmen definiert ist. Die geerbte Methode gehört zum Bezugsrahmen Becher, die neue Methode wird zum Bezugsrahmen BecherMitAufdruck gehören. Unterschiedliche Bezugsrahmen, daher kein Überladen, vielmehr: ein Überschreiben. Hinweis Wird in einer abgeleiteten Klasse eine Methode mit gleichem Namen und Parameterliste einer geerbten Methode definiert, dann wird die geerbte Methode mit der neuen Methode überschrieben.
Praktisch heißt das: Wir können in BecherMitAufdruck eine neue Methode ausgabe definieren und haben damit einfach die alte überschrieben: void BecherMitAufdruck::ausgabe() const { cout << "Becher mit " << inhalt << " und Aufdruck \"" << aufdruck << "\"" << endl; } Listing 4.35 Erster Versuch einer Methode »ausgabe«
Fertig? Mitnichten! Die Methode wird bei der Kompilation einen Fehler melden. Der Grund müsste Ihnen bereits bekannt sein, wird aber im folgenden Abschnitt noch genauer besprochen.
4.15
Geschützte Attribute
Das Problem bei der Methode aus Listing 4.35 liegt im Zugriff auf das Attribut inhalt. Es handelt sich hierbei um ein privates Attribut von Becher, weshalb eine Methode von BecherMitAufdruck keinen Zugriff darauf hat. Wir
178
Geschützte Attribute
haben nun zwei Möglichkeiten. Die erste besteht darin, das Attribut mit geschütztem Zugriffsrecht zu versehen. Wie in Abschnitt 4.3, »Zugriffsrechte«, bereits beschrieben wurde, erlaubt das geschützte Zugriffsrecht auch abgeleiteten Klassen den Zugriff. Eigentlich genau das, was wir brauchen: class Becher { public: typedef float AbsWertTyp; Becher(std::string i, int fa, float fu); void ausgabe() const; bool reichtKapazitaet(int ml) const; bool reichtKapazitaet(const Becher* b) const; static AbsWertTyp berechneAbsolutwert(float gw, float ps); protected: std::string inhalt; private: int fassungsvermoegen; float fuellhoehe; static int maxmenge; }; Listing 4.36 Die Klasse »Becher« mit geschütztem Attribut »inhalt«
Nun lässt sich das Programm problemlos kompilieren. Aber: Eine vollständige Datenkapselung ist nicht mehr gewährleistet, weil auf inhalt nun jede abgeleitete Klasse zugreifen kann. Dieses Zugriffsrecht bezieht sich zwar nur auf das geerbte Attribut, und eine Methode der Klasse BecherMitAufdruck ist nicht in der Lage, auf das inhalt-Attribut eines Becher-Objekts zuzugreifen. Trotzdem kann eine abgeleitete Klasse eventuell von der Basisklasse auferlegte Beschränkungen umgehen. Aus diesem Grund sieht ein sauberer Ansatz weiterhin private Attribute vor und ermöglicht den Zugriff über Methoden, die nach Bedarf dann mit geschütztem Zugriffsrecht versehen werden können. Dazu machen wir das Attribut inhalt wieder privat und erweitern die Klasse um eine öffentliche Methode getInhalt: class Becher { public: typedef float AbsWertTyp; Becher(std::string i, int fa, float fu); void ausgabe() const; bool reichtKapazitaet(int ml) const;
179
4.15
4
Klassen
bool reichtKapazitaet(const Becher* b) const; static AbsWertTyp berechneAbsolutwert(float gw, float ps); std::string getInhalt() const { return(inhalt); } private: std::string inhalt; int fassungsvermoegen; float fuellhoehe; static int maxmenge; }; Listing 4.37 Die Klasse »Becher« mit »getInhalt«
Die Methode ausgabe von BecherMitAufdruck muss nun nur noch entsprechend angepasst werden: void BecherMitAufdruck::ausgabe() const { cout << "Becher mit " << getInhalt() << " und Aufdruck \"" << aufdruck << "\"" << endl; } Listing 4.38 Die endgültige Fassung von »BecherMitAufdruck::ausgabe«
4.16
Polymorphie
Wir wissen mittlerweile, dass die öffentliche Vererbung eine Beziehung ist ein(e) darstellt. In den letzten Abschnitten haben wir deshalb über die Vererbung zum Ausdruck gebracht, dass ein Becher mit Aufdruck ein Becher ist. Im Umkehrschluss muss es deshalb möglich sein, einen Becher mit Aufdruck als gewöhnlichen Becher zu behandeln, denn er ist ja ein Becher. Dieser Sachverhalt wird in der objektorientierten Programmierung Polymorphie genannt. Polymorphie Überall dort, wo ein Objekt der Basisklasse erwartet wird, kann auch ein Objekt einer abgeleiteten Klasse verwendet werden.
Nehmen wir im einfachsten Fall einen Zeiger vom Typ Becher: Becher *bptr;
180
Polymorphie
Weil ein Becher mit Aufdruck ein Becher ist, kann die Adresse eines BecherMitAufdruck-Objekts einem Becher-Zeiger zugewiesen werden: BecherMitAufdruck b("Milch", 300, 90, "Meine Privattasse"); bptr = &b;
Über diesen Zeiger können dann die Becher-Methoden des Objekts aufgerufen werden: cout << bptr->getFuellmenge() << endl;
Achtung Polymorphie funktioniert nur mit Zeigern und Referenzen.
Über den Becher-Zeiger können allerdings keine speziellen Methoden der Klasse BecherMitAufdruck aufgerufen werden, denn der Zeiger »weiß« schließlich nicht, dass das Becher-Objekt, auf das er zeigt, in Wirklichkeit ein BecherMitAufdruck-Objekt ist. Praktischer Einsatz der Polymorphie könnte eine maximum-Funktion sein, die zwei Becher-Objekte übergeben bekommt und den Becher mit mehr Inhalt zurückliefert: Becher* maximum(Becher* b1, Becher* b2) { if(b1->getFuellmenge()>=b2->getFuellmenge()) return(b1); else return(b2); } Listing 4.39 Eine maximum-Funktion für »Becher«
Die Funktion ist so geschrieben, dass bei gleicher Füllmenge das erste Objekt zurückgegeben wird, genauso verhalten sich auch die Funktionen der C++Standardbibliothek. Wegen der Polymorphie können maximum beliebige Becher übergeben werden, solange deren Klassen von Becher abgeleitet sind. Diese Art der Programmierung bietet ein enormes Maß an Wiederverwendbarkeit, denn es kann Programmcode geschrieben werden, der mit Objekten arbeitet, an die der Programmierer bis dato nicht einmal gedacht hat.
181
4.16
4
Klassen
4.17
Virtuelle Methoden
Im Rahmen der Polymorphie können Effekte auftreten, die vielleicht nicht auf Anhieb klar sind. Nehmen wir folgendes Beispiel: BecherMitAufdruck b("Milch", 300, 90, "Privattasse"); Becher *bptr = &b; bptr->ausgabe();
Was wird ausgegeben? Oder, programmtechnisch gefragt, welche ausgabeMethode wird aufgerufen, die von Becher oder die von BecherMitAufdruck? Es gibt zwei Argumente: 1. Die ausgabe-Methode von BecherMitAufdruck wird aufgerufen, weil das Objekt ein BecherMitAufdruck-Objekt ist. 2. Die ausgabe-Methode von Becher wird aufgerufen, weil der Zeiger vom Typ Becher* ist. Im Normalfall werden Datentypen zur Kompilationszeit geprüft. Dies wird statische Typüberprüfung genannt. Und zur Kompilationszeit zeigt bptr auf ein Becher-Objekt, weil bptr vom Typ Becher* ist. Es spielt dabei keine Rolle, dass dem Zeiger während des Programmlaufs ein BecherMitAufdruck-Objekt zugewiesen wird. Es wird also die ausgabe-Methode von Becher aufgerufen. Obwohl programmtechnisch nachvollziehbar, ist das Verhalten nicht unbedingt erwünscht. Denn nur, weil der Zugriff auf das Objekt über einen Zeiger vom Typ Becher* stattfindet, sollte trotzdem die richtige ausgabe-Methode aufgerufen werden. Um die richtige ausgabe-Methode aufzurufen, muss das Programm den tatsächlichen Typ des Objekts, auf das bptr zeigt, zur Laufzeit prüfen. Dies wird dynamische Typüberprüfung genannt. Aktiviert wird die dynamische Typüberprüfung für eine Methode, indem vor der Deklaration in der Basisklasse das Schlüsselwort virtual geschrieben wird. Daher wird eine solche Methode in C++ auch virtuelle Methode genannt. Für die dynamische Typüberprüfung spielt es keine Rolle, wie weit der tatsächliche Typ in der Klassenhierarchie von der Basisklasse entfernt ist. Sollte beispielsweise von der Klasse BecherMitAufdruck eine Klasse BecherMitAufdruckUndBild abgeleitet werden, dann würde über den Becher-Zeiger, wenn er auf ein solches Objekt zeigt, die ausgabe-Methode von BecherMitAufdruckUndBild aufgerufen – falls die Methode in der Klasse existiert. Es muss sich aber um eine tatsächliche Überschreibung handeln; die Methode
182
UML
in der Subklasse muss genauso heißen wie die überschriebene Methode und in Parameterliste und Rückgabetyp komplett übereinstimmen. Hinweis Virtuelle Methoden gewährleisten, dass sich das Verhalten eines Objekts nicht ändert, wenn über einen Basisklassenzeiger darauf zugegriffen wird. Das sogenannte LSP (Liskovsche Substitutionsprinzip) wird damit eingehalten.
Dynamische Typüberprüfung funktioniert nur mit nicht-statischen Methoden. Ein Grund mehr, auf den direkten Attributzugriff zu verzichten.
4.18
UML
Um im weiteren Verlauf des Buchs eine möglichst klare und einfache Darstellungsform für Klassen und deren Beziehungen einsetzen zu können, die darüber hinaus auch noch weit verbreitet ist, wollen wir diesen Abschnitt dem Klassendiagramm der UML (Unified Modeling Language) widmen. Die UML definiert einen Satz an Diagrammen, mit denen dynamische und statische Eigenschaften von Methoden und Klassen dargestellt werden können. Die bisher verwendeten Diagramme zur Darstellung des Programmflusses in diesem Buch sind Aktivitätsdiagramme der UML. Becher -inhalt : string -fassungsvermoegen : int -fuellhoehe : float -maxmenge : int = 1000 +Becher(in i : string, in fa : int, in fu : float) +ausgabe() +reichtKapazitaet(in ml : int) : bool +reichtKapazitaet(in b : const Becher*) : bool +berechneAbsolutwert(in gw : float, in ps : float) : AbsWertTyp +getInhalt() : string +getFuellmenge() : int
BecherMitAufdruck -aufdruck : string +BecherMitAufdruck(in i : string, in fa : int, in fu : float, in auf : string) +getAufdruck() : string +ausgabe() Abbildung 4.5 Die Becher-Hierarchie im UML-Klassendiagramm
183
4.18
4
Klassen
Um den statischen Aufbau von Klassen und deren Beziehungen untereinander darzustellen, wird das UML-Klassendiagramm eingesetzt. In Abbildung 4.5 ist die aktuelle Becher-Hierarchie als UML-Klassendiagramm dargestellt. Im Klassendiagramm wird eine Klasse durch ein in drei Bereiche aufgeteiltes Rechteck repräsentiert. Der obere Bereich beinhaltet den Klassennamen, darunter stehen die Attribute und im untersten Abschnitt die Methoden. Vor den Attributen und Methoden wird mit einem Zeichen das Zugriffsrecht des Elements angezeigt. Dabei steht: 왘
- für privates Zugriffsrecht
왘
# für geschütztes Zugriffsrecht
왘
+ für öffentliches Zugriffsrecht
Hinter Attributen steht, durch einen Doppelpunkt getrennt, deren Typ und dahinter optional mit Gleichheitszeichen der Initialisierungswert. Der Initialisierungswert wird nur angegeben, wenn er allgemeingültig ist, was nur bei statischen Elementen der Fall ist. Statische Elemente sind unterstrichen. Besitzt eine Methode einen Rückgabetyp, dann steht er, durch Doppelpunkt getrennt, hinter der Parameterliste. Ein Parameter besteht aus seinem Namen und seinem dahinter mit Doppelpunkt folgenden Datentyp. Optional kann vor dem Parameternamen noch die Übergaberichtung spezifiziert werden. Hier gibt es drei Möglichkeiten: 왘
in: Der Parameter dient nur zur Wertübergabe an die Methode. Für ihn
muss beim Aufruf ein gültiges Argument angegeben werden. Entspricht der Wert- oder Adressübergabe in C++. 왘
inout: Für diesen Parameter muss beim Aufruf ein gültiges Argument
angegeben werden. Änderungen am Parameter innerhalb der Methode wirken sich jedoch auf das beim Aufruf übergebene Argument aus. Entspricht in C++ der Übergabe als Referenz. 왘
out: Das an die Methode übergebene Argument dient nur dazu, innerhalb
der Methode beschrieben zu werden und muss beim Aufruf keinen gültigen Wert besitzen. Es wird innerhalb der Methode beschrieben und vom Aufrufer ausgelesen. Diese Variante wird von C++ nicht direkt unterstützt, kann aber mit Referenzen simuliert werden. Die Vererbung wird durch den Generalisierungspfeil zum Ausdruck gebracht, der immer von der Subklasse zur Basisklasse weist. Im weiteren Verlauf werden jeweils bei Bedarf noch einige Darstellungselemente hinzukommen.
184
Schnittstellen
4.19
Schnittstellen
Wir können nun für die Klasse Becher eine Bibliothek an Funktionen programmieren und wissen, dass jede Subklasse von Becher, egal wie tief sie in der Hierarchie liegen mag, mit dieser Bibliothek arbeiten kann. Und mit den virtuellen Methoden ist gewährleistet, dass sich das Verhalten der Subklassenobjekte nicht ändert, wenn über einen Basisklassenzeiger auf sie zugegriffen wird. Hinweis Unter .NET gibt es keine Funktionen. Dort wird eine solche Funktionsbibliothek durch statische Methoden einer Klasse realisiert.
Da wir für die Erweiterung der Klassenhierarchie keine Änderungen an der Bibliothek vornehmen müssen, ist die auf Becher basierende Klassenhierarchie beliebig erweiterbar. Auch die Funktionsbibliothek kann um Funktionen erweitert werden, ohne dass die tatsächlichen Subklassen von Becher bekannt sein müssen. Wir sehen, die Vererbung erlaubt ein hohes Maß an Wiederverwendbarkeit, wie es ohne sie nicht möglich wäre. Der bisherige Ansatz hat nur noch einen Schönheitsfehler: Jede Klasse, die mit der bestehenden Funktionsbibliothek zusammenarbeiten will, muss von der Klasse Becher oder einer ihrer Subklassen abgeleitet werden. Was aber, wenn die Objekte der neuen Klasse gar keine Becher sind? Das Problem entstand dadurch, dass die Funktionsbibliothek für eine konkrete Klasse programmiert wurde. Geschickter wäre es, wenn die Bibliothek nicht von einer konkreten Klasse, sondern lediglich von einer bestimmten Fähigkeit oder Funktionalität abhängig wäre. Nehmen wir als einfaches Beispiel die Funktionalität der Ausgabe. Wir wollen Funktionen schreiben, die von den zu bearbeitenden Objekten lediglich die Fähigkeit erwarten, ausgegeben werden zu können. Dies soll über eine Methode ausgabe geschehen. Wir könnten denselben Fehler wie vorhin begehen und die Funktionen mit Zeigern des Typs Becher* versehen, denn Becher besitzt eine ausgabe-Methode. Wollten wir dann aber vielleicht eine geometrische Form ausgeben, dann müsste diese von Becher erben, womit es zu der merkwürdigen Aussage »Eine geometrische Form ist ein Becher« käme, nur um die Funktionsbibliothek nutzen zu können.
185
4.19
4
Klassen
Um solch unrealistische Beziehungen zu vermeiden, müssen wir die Basisklasse weiter abstrahieren. Wenn die Funktionsbibliothek nur eine ausgabeMethode benötigt, dann sollte die oberste Basisklasse auch nur diese ausgabe-Methode besitzen. Wir werden diese Klasse IAusgabe nennen (das vorangestellte »I« steht für Interface, auf Deutsch Schnittstelle) und als InlineKlasse – also ohne Quellcodedatei – erstellen. Die Header-Datei sieht so aus: #pragma once #include class IAusgabe { public: virtual void ausgabe() const { std::cout << "Basis-Ausgabe" << std::endl; } }; Listing 4.40 Die Basisklasse »IAusgabe«
Die Methode ausgabe müssen wir als virtuell deklarieren, damit eine Überschreibung in der Subklasse korrekt aufgerufen wird. Die Klasse steht nicht im Namensbereich Getraenke, weil sie auch als Basisklasse anderer Hierarchien dienen kann. Als primitives Beispiel einer auf dieser Basisklasse fußenden Bibliothek soll die folgende Funktion dienen: void simpleAusgabe(IAusgabe* obj) { obj->ausgabe(); } Listing 4.41 Die Funktion »simpleAusgabe«
Zugegeben, diese Funktion ist nicht würdig, in eine praxisrelevante Bibliothek aufgenommen zu werden, sie demonstriert aber auf einfache Weise, worauf es ankommt. Auch eine komplexe Funktion wird nicht anders auf das Objekt zugreifen. Noch kann die Klasse Becher aber nicht von der neuen Bibliothek bearbeitet werden, denn sie hat IAusgabe noch nicht als Basisklasse. Das ist schnell nachgeholt: #pragma once #include "IAusgabe.h" #include <string>
186
Schnittstellen
class Becher : public IAusgabe { // Klasseninhalt }; Listing 4.42 »Becher« als Subklasse von »IAusgabe«
Alles bestens. Aber noch immer hat der Ansatz drei Schönheitsfehler, die eine gemeinsame Ursache teilen. Betrachten wir IAusgabe etwas genauer. Sie besitzt eine Methode ausgabe, auf die die Klassenbibliothek zugreift und die von den Subklassen mit ihren spezifischen Methoden überschrieben wird. Die Methode in IAusgabe wird aber nie etwas Sinnvolles ausgeben können, weil sie nicht wissen kann, welche Klassen alle von ihr ableiten. Trotzdem mussten wir die Methode mit einem Anweisungsblock ausstatten, der einen sinnlosen Text ausgibt. Gut, die Ausgabe des Textes hat theatralische Gründe, denn der Anweisungsblock hätte auch leer bleiben können. Eine leere Methode ist aber auch kein sehr viel sauberer Ansatz. Aber was wirklich verrückt ist: Obwohl die Klasse nichts macht, kann von ihr ein Objekt erzeugt und an eine der Bibliotheksfunktionen übergeben werden: IAusgabe o; simpleAusgabe(&o);
Und noch schlimmer: Da eine von ihr abgeleitete Klasse die unsinnige ausgabe-Methode erbt, ist die Subklasse nicht gezwungen, eine eigene ausgabe-Methode zu implementieren: class Teddybaer : public IAusgabe { std::string fellfarbe; public: Teddybaer(std::string ff) : fellfarbe(ff) {} }; Listing 4.43 Die Klasse »Teddybaer«
Wenn jetzt jemand einen Teddybären mit der gewünschten Fellfarbe konstruiert, dann wird er bei der Ausgabe vermutlich mit einer Erwähnung eben jener Farbe rechnen, aber Pustekuchen: Teddybaer baer("schwarz"); baer.ausgabe(); // Aufruf von IAusgabe::ausgabe
187
4.19
4
Klassen
All das sind Auffälligkeiten im Verhalten der Klassenhierarchie, die Sie im Optimalfall vermeiden sollten.
4.19.1 Rein virtuelle Methoden Es gibt in der objektorientierten Programmierung glücklicherweise die Möglichkeit, die Existenz einer Methode einzufordern, ohne sie selbst implementieren zu müssen. Solche Methoden werden abstrakte Methoden genannt. C++ bezeichnet sie auch als rein virtuelle Methoden. Deklariert werden sie durch ein =0 hinter dem Methodenkopf. Schauen wir uns das einmal praktisch an der Klasse IAusgabe an: class IAusgabe { public: virtual void ausgabe() const =0; }; Listing 4.44 Die Klasse »IAusgabe« mit rein virtueller Methode
Es fällt Ihnen sicher auf, dass es sich bei der rein virtuellen Methode nur noch um eine Deklaration handelt. Eine Definition – also ausführbarer Programmcode – ist nicht mehr notwendig. Doch die abstrakte Methode hat weitreichende Konsequenzen, denn dadurch wird die Klasse selbst ebenfalls abstrakt. Hinweis Eine Klasse mit abstrakten Methoden ist eine abstrakte Klasse. Von abstrakten Klassen kann kein Objekt erzeugt werden.
Demnach kann IAusgabe nicht mehr instanziiert werden: IAusgabe o; // Fehler
Eine abstrakte Methode wird erst dann konkret, wenn sie in einer Subklasse mit einer konkreten Methode – also einer Methode mit Definition – überschrieben wurde. Dies ist bisher in der Klasse Teddybaer noch nicht geschehen. Die Klasse hat die abstrakte Methode von IAusgabe geerbt und ist damit ebenfalls eine abstrakte Klasse. Um einen Teddybären erzeugen zu können, muss ausgabe überschrieben werden: class Teddybaer : public IAusgabe { std::string fellfarbe; public: Teddybaer(std::string ff)
188
Schnittstellen
: fellfarbe(ff) {} void ausgabe() const { std::cout << "Baer mit Fellfarbe " << fellfarbe << std::endl; } }; Listing 4.45 Die Klasse »Teddybaer« mit eigener Methode »ausgabe«
Nun kann auch wieder ein Teddybaer-Objekt erzeugt werden. Auf diese Weise wird jeder Programmierer, der von IAusgabe ableitet, gezwungen, eine ausgabe-Methode zu programmieren. Leider kann er nicht gezwungen werden, eine sinnvolle Methode zu implementieren, aber dass er eine implementieren muss, ist viel wert. Denn die auf ausgabe zurückgreifende Bibliothek hat die Gewissheit, dass immer eine ausgabe-Methode vorhanden ist. Denn eine Klasse ohne ausgabe-Methode ist abstrakt; von ihr kann kein Objekt erzeugt werden. Eine abstrakte Klasse muss dabei nicht zwangsläufig von der direkt folgenden Subklasse implementiert werden. Im Gegenteil, eine Subklasse kann noch weitere abstrakte Methoden hinzufügen, wie IGeometrischeFigur zeigt: class IGeometrischeFigur : public IAusgabe { public: virtual double umfang() const =0; virtual double flaeche() const =0; }; Listing 4.46 Die Klasse »IGeometrischeFigur«
Die Klasse IGeometrischeFigur könnte die Basisklasse einer anderen Bibliothek sein, die mit Flächen und Umfängen arbeitet. Weil sie von IAusgabe abgeleitet ist, können ihre Subklassen zusätzlich noch die Ausgabebibliothek verwenden, die bisher nur aus der Methode simpleAusgabe besteht. Eine Subklasse von IGeometrischeFigur muss die abstrakten Methoden von IGeometrischeFigur und IAusgabe implementieren, damit Objekte von ihr erzeugt werden können. Als Beispiel sei hier die Klasse Rechteck vorgestellt: class Rechteck : public IGeometrischeFigur { double x,y,b,h; public: Rechteck(double x, double y, double b, double h)
189
4.19
4
Klassen
: x(x), y(y), b(b), h(h) {} void ausgabe() const { std::cout << "Rechteck mit Breite " << b << " und Hoehe " << h << std::endl; } double umfang() const { return(2*b+2*h); } double flaeche() const { return(b*h); } }; Listing 4.47 Die Klasse »Rechteck«
Etwas merkwürdig mag der Konstruktor erscheinen, weil die Parameter denselben Namen wie die Attribute haben. Obwohl es problemlos funktioniert, sollten Sie es in der Praxis vermeiden. Da Sie in Ihrer C++-Karriere aber aller Wahrscheinlichkeit nach auch Code anderer Personen in die Finger bekommen werden, kann es nicht schaden, in loser Folge einige Unarten zu demonstrieren, damit Sie gewappnet sind. Deswegen gleich eine Frage: Welches h wird in der Ausgabe des Konstruktors unten ausgegeben? Rechteck(double x, double y, double b, double h) : x(x), y(y), b(b), h(h) { std::cout << h << std::endl; }
In diesem Fall spielt es natürlich keine Rolle, welches h ausgegeben wird, weil beide den gleichen Wert haben. Es gibt aber Situationen, in denen die Frage entscheidend ist. Die Frage kann mit einer anderen Frage beantwortet werden: Welches h ist lokaler? Eindeutig der Parameter. Deshalb wird in der Ausgabe auch der Inhalt des Parameters ausgegeben. Aber wie kann das Attribut trotzdem angesprochen werden, obwohl es vom Parameter verdeckt ist? Wir verwenden einfach den Objektzeiger this (siehe Abschnitt 4.4.2, »this«): Rechteck(double x, double y, double b, double h) : x(x), y(y), b(b), h(h) { std::cout << h << std::endl; // Parameter std::cout << this->h << std::endl; // Attribut }
190
Schnittstellen
Schauen wir uns abschließend noch in Abbildung 4.6 die aktuelle Klassenhierarchie an: IAusgabe +ausgabe()
Rechteck -x : double -y : double -b : double -h : double +Rechteck(ein x : double, ein y : double, ein b : double, ein h : double) +ausgabe() +umfang() : double +flaeche() : double
Abbildung 4.6
Die aktuelle Klassenhierarchie
Die Klassen Becher und BecherMitAufdruck sind ohne ihre Attribute und Methoden dargestellt, weil sich an ihnen nichts geändert hat. Wo vorhanden, sind die Namensbereiche der Klassen mit angegeben. Im Diagramm ist zu erkennen, dass abstrakte Klassen und Methoden kursiv dargestellt werden.
4.19.2 Rein abstrakte Klassen Klassen, die als Elemente nur abstrakte Methoden besitzen, werden als rein abstrakte Klassen bezeichnet. Sie entsprechen in Standard-C++ den Schnittstellen, die nicht direkt von C++ unterstützt werden, unter .NET (siehe Abschnitt 9.14, »Schnittstellen«) aber eine wichtige Rolle spielen. Grundsätzlich gilt als Regel – von der es natürlich auch Ausnahmen gibt –, dass ganz oben in der Klassenhierarchie immer eine Schnittstelle bzw. rein abstrakte Klasse stehen sollte.
191
4.19
4
Klassen
4.20 Downcasts Betrachten wir folgenden Codeschnipsel: BecherMitAufdruck b("Tee", 200, 95, "Top-Becher"); Becher * bptr=&b;
Wir wissen, dass wegen der Polymorphie die Adresse des BecherMitAufdruckObjekts in einem Zeiger des Typs Becher* gespeichert werden kann. Über diesen Zeiger können alle Methoden von Becher aufgerufen werden. Sollte es sich um virtuelle Methoden handeln, dann wird die Methode des tatsächlichen Typs aufgerufen, wie in Abschnitt 4.17, »Virtuelle Methoden«, gezeigt wurde. Über den Becher-Zeiger können aber keine Methoden aufgerufen werden, die in der Subklasse neu angelegt wurden, wie in diesem Fall getAufdruck. Manchmal ist aber genau das notwendig. Dazu muss der ursprüngliche Typ des Objekts über eine Typumwandlung wiederhergestellt werden. Der dafür notwendige Cast nennt sich dynamic_cast: BecherMitAufdruck *p = dynamic_cast(bptr);
Die in bptr gespeicherte Adresse wir in den Typ BecherMitAufdruck* umgewandelt und dem Zeiger p zugewiesen. Wir wissen an dieser Stelle, dass bptr tatsächlich auf ein BecherMitAufdruck-Objekt zeigt. Häufig wird der dynamic_ cast aber in Situationen angewendet, bei denen keine hundertprozentige Klarheit darüber herrscht, von welchem Typ das Objekt wirklich ist. Sollte das Objekt nicht von dem Typ sein, in den umgewandelt wird, schlägt die Umwandlung fehl. Es ist in solchen Fällen dann nötig, den Erfolg der Umwandlung zu überprüfen. Sollte die Umwandlung misslingen, liefert der Cast einen Nullzeiger zurück: if(p) cout << p->getAufdruck() << endl;
Der Aufruf der BecherMitAufdruck-Methode wird nur dann ausgeführt, wenn die Umwandlung erfolgreich war. Tipp Ein dynamic_cast sollte nur dann ausgeführt werden, wenn logisch gewährleistet ist, dass der Downcast erfolgreich sein wird. Ist es notwendig, den Erfolg des Downcasts zu überprüfen, oder wird er verwendet, um Informationen über den tatsächlichen Typ zu erhalten, dann deutet das meist auf einen Designfehler hin.
192
Dieses Kapitel beschäftigt sich mit Themen von C++, die bei der Entwicklung komplexerer Software unverzichtbar oder zumindest sehr hilfreich sind.
5
Fortgeschrittene Sprachelemente
Viele Programme lassen sich schreiben, ohne je dieses Kapitel gelesen zu haben. Trotzdem werden Sie feststellen, dass diese Themen das Leben erheblich vereinfachen können. Speziell geht es um: 왘
Namensbereiche, mit denen Programmelemente logisch gruppiert werden können
왘
Ausnahmen, die eine fortschrittliche Variante der Fehlermitteilung innerhalb des Programms bieten
왘
dynamische Speicherverwaltung, mit deren Hilfe Speicher je nach Bedarf zur Laufzeit beschafft werden kann
왘
Templates, ein Konstrukt, mit dem Typen von Klassen und Funktionen variabel gehalten werden können
왘
die Funktionalität von Operatoren für eigene Datentypen durch Überladen zu erweitern
5.1
Namensbereiche
In Abschnitt 1.5, »using«, wurde bereits erklärt, was genau Namensbereiche sind. Hier soll nun besprochen werden, wie eigene Namensbereiche erstellt werden. In den letzten Abschnitten haben wir die Klasse Becher erstellt, die Getränke aufnehmen soll. Theoretisch wäre vorstellbar, dass ein anderer ebenfalls eine Klasse Becher programmiert hat, die zur Mischung von Lacken verwendet wird. Es könnte geschmackliche Einbußen mit sich bringen, wenn die beiden Klassen verwechselt oder gemischt eingesetzt würden. Glücklicherweise kann die geschilderte Problematik nicht eintreten, denn würden diese beiden
193
5
Fortgeschrittene Sprachelemente
gleichnamigen Klassen im selben Namensraum definiert, dann hätte dies einen Compilerfehler zur Folge. Stattdessen tritt ein anderes Problem auf: Was, wenn in einem Projekt sowohl der Getränkebecher als auch der Lackbecher benötigt werden? Momentan hätten wir nur die Möglichkeit, die beiden Klassen umzubenennen, z. B. in GetraenkeBecher und LackBecher. Dieser Ansatz setzt allerdings voraus, dass die Klassen noch geändert werden können, was bei fremdentwickelten Klassen nicht immer möglich ist. Die übliche Lösung für dieses Problem sind Namensbereiche. Und zwar erstellen wir unseren eigenen Namensbereich, in den wir unsere Klasse Becher hineinsetzen. Ein Namensbereich wird mit dem Schlüsselwort namespace und einem darauffolgenden Namen erstellt. In den dahinterstehenden geschweiften Klammern steht der Inhalt des Namensbereichs. Die Syntax von namespace: namespace Name { }
Im Gegensatz zu den Klassen steht hinter der schließenden geschweiften Klammer kein Semikolon. Exemplarisch wollen wir die Klasse Becher in den Namensbereich Getraenke verfrachten. Nehmen wir uns zunächst die Klassendefinition vor: #pragma once #include <string> namespace Getraenke { class Becher { // Hier der bekannte Klasseninhalt }; } Listing 5.1
Der Namensbereich »Getraenke« in »Becher.h«
Die Methodendefinitionen und Initialisierungen statischer Elemente in der cpp-Datei müssen wir ebenfalls in den Namensbereich Getraenke schieben:
194
Namensbereiche
#include "Becher.h" #include using namespace std; namespace Getraenke { int Becher::maxmenge=1000; // Hier stehen die Methodendefinitionen } Listing 5.2
Der Namensbereich »Getraenke« in »Becher.cpp«
Eine weitere Änderung erfährt die Verwendung der Klasse Becher. Da sie nicht mehr im globalen Namensraum steht, müssen wir den sie enthaltenden Namensbereich explizit angeben: Getraenke::Becher c("Kaffee", 200, 100); c.ausgabe(); cout << Getraenke::Becher::berechneAbsolutwert(1.85F, 3581.44F) << endl;
5.1.1
using namespace
Oder wir verwenden die bekannte Vereinfachung durch ein using namespace: #include #include "Becher.h" using namespace std; using namespace Getraenke; int main() { Becher c("Kaffee", 200, 100); c.ausgabe(); cout << Becher::berechneAbsolutwert(1.85F, 3581.44F) << endl; } Listing 5.3
Der Einsatz von »using namespace«
195
5.1
5
Fortgeschrittene Sprachelemente
5.1.2
Darstellung in der UML
In der UML steht der Namensbereich mit :: getrennt vor dem Klassennamen, wie Abbildung 5.1 zeigt. Getraenke::Becher -inhalt : string -fassungsvermoegen : int -fuellhoehe : float -maxmenge : int = 1000 +Becher(ein i : string, ein fa : int, ein fu : float) +ausgabe() +reichtKapazitaet(ein ml : int) : bool +reichtKapazitaet(ein b : const Becher*) : bool +berechneAbsolutwert(ein gw : float, ein ps : float) : AbsWertTyp +getInhalt() : string +getFuellmenge() : int
Abbildung 5.1
5.1.3
Der Namensbereich in der UML
Verschachtelte Namensbereiche
Namensbereiche dürfen auch verschachtelt werden, um eine logische Hierarchie zum Ausdruck zu bringen: namespace Getraenke { namespace Heissgetraenke { } namespace Kaltgetraenke { class Limo {}; } class Wasser {}; } Listing 5.4
Verschachtelte Namensbereiche
Hier werden im Namensbereich Getraenke die beiden Namensbereiche Heissgetraenke und Kaltgetraenke definiert. Um ein Objekt der Klasse Limo zu erzeugen, müssen wir den gesamten Namensbereichspfad angeben: Getraenke::Kaltgetraenke::Limo l;
Die using namespace-Anweisung, die den Namensbereich Kaltgetraenke zugänglich macht, sähe so aus:
196
Dynamische Speicherverwaltung
using namespace Getraenke::Kaltgetraenke;
Auch wenn es logisch unsinnig ist, dass der Konstruktor der Klasse Wasser ein Objekt der Klasse Limo erzeugt, werden wir dies hier einmal umsetzen, um den Zugriff zu demonstrieren: class Wasser { public: Wasser() { Kaltgetraenke::Limo l; } }; Listing 5.5
Die Klasse »Wasser«
Weil sich die Klasse Wasser im Namensbereich Getraenke befindet, muss sie lediglich den Namensbereich Kaltgetraenke ansprechen, um ein Objekt von Limo zu erzeugen.
5.1.4
Synonyme definieren
Stellen Sie sich vor, der Herr Müller aus Ihrer Abteilung hat eine Klasse X geschrieben und diese ordentlich in einen eigenen Namensbereich gepackt: namespace MeinErsterEigenerNamensbereich { class X {}; }
Je nachdem, wie oft Sie den Namen dieses Namensbereichs schreiben müssen, könnte das Gesprächsstoff für die nächste Weihnachtsfeier bieten. Glücklicherweise erlaubt es C++, für Namensbereiche Synonyme zu erstellen: namespace Mueller = MeinErsterEigenerNamensbereich;
Nun ist die Klasse X gleich viel humaner zu erreichen: Mueller::X x;
5.2
Dynamische Speicherverwaltung
Wenn wir bisher Speicher benötigten, als Objekt oder Array, wurde er immer zur Kompilationszeit – also statisch – angefordert:
197
5.2
5
Fortgeschrittene Sprachelemente
Becher b("Limo", 500, 70); int f[20];
Die oberen Anweisungen definieren ein Becher-Objekt und ein 20-elementiges int-Array. Deren Inhalt und Größe wird bereits bei der Programmierung festgelegt. Häufig kann die notwendige Größe eines Arrays aber erst zur Laufzeit bestimmt werden, weil vielleicht der Anwender zuerst nach der gewünschten Größe gefragt wird. Es muss also ein Mechanismus her, mit dem Objekte oder Arrays zur Laufzeit erstellt werden können. Und genau dazu dient die dynamische Speicherverwaltung.
5.2.1
Erzeugen von Objekten
Objekte werden dynamisch mit dem Befehl new erzeugt. Hinter new wird der zu erstellende Datentyp mit eventuell notwendigen Konstruktorparametern angegeben. new liefert dann die Adresse des erstellten Objekts zurück, die im Normalfall in einem Zeiger gespeichert wird. Über den Zeiger kann dann in der bekannten Syntax auf das Objekt zugegriffen werden: Becher *p = new Becher("Suppe", 200, 98); p->ausgabe();
In ANSI C++ geht die Verantwortung des über new reservierten Speicherbereichs an den Programmierer über. Das heißt, entweder das Programm gibt dynamisch reservierten Speicher wieder frei oder niemand! Speziell bei Programmen mit langer ununterbrochener Laufzeit (wie auf Servern) können die durch Nichtfreigabe des Speichers entstehenden Speicherlecks (memory leak) das System in die Knie zwingen oder das Programm zum Absturz bringen. Achtung Dynamisch reservierter Speicher muss vom Programm wieder freigegeben werden, sonst entstehen Speicherlecks.
Der Befehl zur Freigabe von Speicher heißt delete. Ihm übergeben Sie die Adresse des freizugebenden Speichers: delete(p);
Sie sollten nur Speicher freigeben, der auch vorher reserviert wurde. Nachdem der Speicher freigegeben wurde, darf über den Zeiger nicht mehr auf
198
Dynamische Speicherverwaltung
ihn zugegriffen werden. Speicher kann nur in den Portionsgrößen freigegeben werden, in denen er auch reserviert wurde.
5.2.2
Erzeugen von Arrays
Das dynamische Erzeugen von Arrays funktioniert ähnlich. Das folgende Beispiel fragt den Anwender nach der gewünschten Anzahl an Werten, reserviert das zur Speicherung nötige Array und liest die Werte von der Tastatur ein: cout << "Wie viele Werte:"; int x; cin >> x; int *f = new int[x]; for(int i=0; i<x; ++i) { cout << "Wert " << i+1 << ":"; cin >> f[i]; } delete[](f);
Bei der Freigabe des Speichers mit delete wurden ebenfalls – leere – eckige Klammern verwendet. Diese sind notwendig, damit für jedes Element im Array ein eventuell vorhandener Destruktor aufgerufen wird. Was genau ein Destruktor ist, behandelt der nächste Abschnitt.
5.2.3
Destruktoren
Um die Arrays und deren dynamische Reservierung ein wenig zu kapseln, könnten Sie auf die Idee kommen, eine Klasse zu schreiben, deren Konstruktor übergeben bekommt, wie groß das Feld sein soll. Eine sehr gute Idee! Die folgende Klasse IntFeld speichert int-Werte in einem Feld, dessen Größe dem Konstruktor übergeben wurde: class IntFeld { int *feld; int groesse; public: IntFeld(int g) : groesse(g) { feld = new int[groesse]; }
199
5.2
5
Fortgeschrittene Sprachelemente
void setWert(int pos, int wert) { feld[pos]=wert; } int getWert(int pos) const { return(feld[pos]); } }; Listing 5.6
Die Klasse »IntFeld«
Beschrieben und gelesen wird das interne Feld über die Methoden setWert und getWert. Eine elegantere Möglichkeit werden wir mit dem Überladen von Operatoren (Abschnitt 5.5, »Operatoren überladen«) und den Indexern (Abschnitt 9.4, »Indexer«) noch kennenlernen. In der Praxis wäre es unverzichtbar, innerhalb von getWert und setWert die Position auf Gültigkeit zu prüfen. Hier soll die Klasse aber so schlank wie möglich gehalten werden. Kommen wir auf den Punkt zu sprechen, auf den dieses Beispiel hinauslaufen soll. Der Speicher für das Feld wurde im Konstruktor dynamisch angefordert, und wir wissen, dass dieser Speicher irgendwo wieder freigegeben werden muss. Nur wo? Momentan könnten wir alleinig eine Methode implementieren, bei deren Aufruf der Speicher freigegeben wird. Dieser Ansatz hat zwei Nachteile: 왘
Der Anwender der Klasse darf nicht vergessen, die Methode aufzurufen.
왘
Nachdem der Speicher über die Methode freigegeben wurde, könnte weiterhin mit getWert und setWert darauf zugegriffen werden, mit fatalen Konsequenzen.
Wir bräuchten ein Gegenstück zum Konstruktor, etwas, dass automatisch beim Abbau des Objekts aufgerufen wird. Und genau dazu ist der Destruktor da. Da ein Destruktor nicht explizit aufgerufen wird, kann er nicht über unterschiedliche Parameterlisten überladen werden; jede Klasse besitzt nur einen Destruktor, der eine leere Parameterliste besitzt. Der Name des Destruktors lautet wie der des Konstruktors, nur mit vorangestellter Tilde (~). Der Destruktor für die Klasse IntFeld sieht damit wie folgt aus: ~IntFeld() { delete[](feld); } Listing 5.7
200
Der Destruktor von »IntFeld«
Dynamische Speicherverwaltung
Destruktoren werden in C++ für Aufräumarbeiten verwendet. Oft bewirken sie das Gegenteil des Konstruktors. Hinweis Jede Klasse besitzt einen Destruktor. Sollten Sie keinen eigenen schreiben, fügt der Compiler einen Standard-Destruktor hinzu, der nichts macht.
Virtuelle Destruktoren
Eine virtuelle Methode dient der dynamischen Typüberprüfung. Sollte sie über einen Basisklassenzeiger aufgerufen werden, wird der wirkliche Typ des Objekts bestimmt und dessen Methode aufgerufen. Dieses Verhalten ist oft auch bei einem Destruktor erwünscht. Nehmen wir nachstehendes Fragment: Becher *p = new BecherMitAufdruck("Suppe", 200, 98,"Toptasse"); delete(p);
Die letzte Anweisung löscht das Objekt, dessen Adresse in p gespeichert ist. Weil p vom Typ Becher* ist, wird der Destruktor der Klasse Becher aufgerufen. Es handelt sich hier aber um ein Objekt der Klasse BecherMitAufdruck. Sollte deren Destruktor Aufräumarbeiten zu erledigen haben, dann werden diese nicht ausgeführt. Es müsste in diesem Fall sichergestellt werden, dass der richtige Datentyp ermittelt und dann dessen Destruktor aufgerufen wird. Genau wie bei jeder anderen Methode auch, müssen wir dazu den Destruktor als virtuell deklarieren. Hinweis Als Basisklassen fungierende Klassen sollten immer virtuelle Destruktoren besitzen.
5.2.4
Wenn »new« fehlschlägt
Bei den Speichermengen, mit denen heutige Computer gesegnet sind, ist es schon außerordentlich schwierig, mit einer eigenen ernsthaften Anwendung an das Speicherlimit zu gelangen. Trotzdem können andere Programme den Speicher bereits stark geplündert haben, so dass die eigene Anwendung leer ausgeht.
201
5.2
5
Fortgeschrittene Sprachelemente
Wird mit new eine Speichergröße angefordert, die nicht mehr an einem Stück verfügbar ist, dann wirft new eine Ausnahme namens bad_alloc. Wie genau Ausnahmen behandelt werden, zeigt der nächste Abschnitt.
5.3
Ausnahmen
Kommen wir zur objektorientierten Fehlerbehandlung. Der aufgetretene Fehler wird dabei durch ein Objekt repräsentiert, welches über den Ausnahmenmechanismus an die fehlerbehandelnde Stelle transportiert wird.
5.3.1
Ausnahmen werfen
Die Fehlerbehandlung soll an der Klasse Becher demonstriert werden. In Kapitel 4, »Klassen«, haben wir erfahren, dass der Vorteil des Zugriffs auf Attribute über Methoden und deren Initialisierung mit Konstruktoren darin besteht, Code für eine mögliche Fehlerbehandlung unterbringen zu können. Das nehmen wir nun in Angriff. Hier zur Erinnerung noch einmal der Konstruktor von Becher: Becher::Becher(string i, int fa, float fu) : inhalt(i), fassungsvermoegen(fa), fuellhoehe(fu) { } Listing 5.8
Der Konstruktor von »Becher«
Im Wesentlichen sollen Becher mit negativem Fassungsvermögen oder Füllmenge verhindert werden. Es wäre vielleicht auch noch interessant, nur bestimmte Inhalte zuzulassen und undefinierbare Flüssigkeiten, wie »Blubberbrühe«, zu unterbinden. Das soll an dieser Stelle aber vernachlässigt werden. Das Prüfen auf einen negativen Wert ist ein Kinderspiel: Becher::Becher(string i, int fa, float fu) : inhalt(i), fassungsvermoegen(fa), fuellhoehe(fu) { if(fa<0) // Hier Fehler für negatives Fassungsvermögen if(fu<0) // Hier Fehler für negative Füllmenge }
202
Ausnahmen
Ob nun der Wert des Attributs oder des Parameters überprüft wird, ist hier nebensächlich, weil beide den gleichen Wert haben. Die Parameter besitzen nur einen kürzeren Namen. Es wurde oben kurz angerissen, dass bei Eintreten eines Fehlers ein Objekt den Fehler repräsentiert. Der Einfachheit halber entscheiden wir uns für einen Fehler in Textform und verwenden ein string-Objekt. Um mit dem Fehlerobjekt eine Ausnahme zu erzeugen, muss es – wie man sagt – geworfen werden. Der dafür notwendige Befehl heißt throw: Becher::Becher(string i, int fa, float fu) : inhalt(i), fassungsvermoegen(fa), fuellhoehe(fu) { if(fa<0) throw string("Negatives Fassungsvermoegen"); if(fu<0) throw string("Negative Fuellmenge"); } Listing 5.9
Der Becher-Konstruktor mit Fehlerbehandlung
Die Aufrufhierarchie soll zu Demonstrationszwecken vertieft werden. Dazu schreiben wir eine Funktion erzeugeKaffee, die immer 0,3l-Becher Kaffee erzeugt, nur die Menge ist variabel: Becher* erzeugeKaffee(float menge) { return(new Becher("Kaffee",300,menge)); } Listing 5.10
Die Funktion »erzeugeKaffee«
Innerhalb von main können wir jetzt bequem eine halbvolle Tasse Kaffee bestellen: Becher *k=erzeugeKaffee(50); delete(k);
Die Tasse wird zwar direkt im Anschluss ihrer Erzeugung wieder ausgegossen, aber das Prinzip dürfte klar sein. Mit dieser Vorarbeit werden wir nun eine Tasse Kaffee mit negativer Füllmenge erzeugen: Becher *k=erzeugeKaffee(-50);
Wenn das Programm nach der Kompilation ausgeführt wird, erscheint ein in Abbildung 5.2 dargestellter Dialog.
203
5.3
5
Fortgeschrittene Sprachelemente
Abbildung 5.2 Ergehnis einer unbehandelten Ausnahme
An dieses Fenster können Sie sich schon einmal gewöhnen. Es erscheint nämlich immer, wenn eine Ausnahme geworfen wurde, auf die Ihr Programm nicht reagiert hat. Da .NET geradezu um sich wirft mit Ausnahmen, wird dies häufiger geschehen. Im aktuellen Fall ist es aber nicht verwunderlich, dass eine Ausnahme geworfen wurde, denn genau das war die Aufgabe des in den Becher-Konstruktor eingefügten Codes. Um zusätzliche Informationen über den Fehler zu erhalten, sollten Sie das Programm über den Menüpunkt Debuggen 폷 Debuggen starten beziehungsweise über Drücken von (F5) starten. Mit dieser Option wird der Debugger auf den Plan gerufen. Es erscheint der in Abbildung 5.3 dargestellte Dialog, der weitere Details enthüllt.
Abbildung 5.3 Eine unbehandelte Ausnahme
Der erste Satz bringt das Problem auf den Punkt: Unbehandelte Ausnahme. Dahinter steht noch der Typ der Ausnahme. Auch wenn es schwierig zu erkennen ist, handelt es sich bei dem Typ um den von uns verwendeten String. Den Dialog schließen Sie mit Unterbrechen, und er gibt den Blick frei auf das Debuggerfenster, das in Abbildung 5.4 gezeigt ist.
204
Ausnahmen
Abbildung 5.4 Das Debugger-Fenster
Das Codefenster zeigt die Methode, in der die Ausnahme geworfen wurde. Das mit »Aufrufliste« betitelte Fenster unten rechts zeigt die Aufrufhierarchie des Programms. Für uns sind nur die Einträge mit C++ als Sprache interessant. Es beginnt in der Funktion main, von dort aus wird die Funktion erzeugeKaffee aufgerufen, die wegen der Erzeugung des Becher-Objekts wiederum den Becher-Konstruktor aufruft. Bei einer Ausnahme verlässt das Programm in umgekehrter Aufrufreihenfolge die Methoden/Funktionen, bis die Ausnahme irgendwo auf diesem Weg aufgefangen wird. Wird die Ausnahme wie im aktuellen Programm überhaupt nicht aufgefangen, hat dies die Beendigung des Programms »in an unusual way« zur Folge. Wir, die bestrebt sind, das eigene Programm nicht so entwürdigend enden zu lassen, können in jeder der Methoden/Funktionen, durch die die Ausnahme wandert, eine Fehlerbehandlung einbauen. Doch erst einmal sollten Sie den Debugger über das Menü mit Debuggen 폷 Debuggen beenden beenden. Weitere Informationen über die Verwendung des Debuggers finden Sie in Abschnitt A.1.
205
5.3
5
Fortgeschrittene Sprachelemente
5.3.2
Ausnahmen fangen
Obwohl es technisch möglich wäre, macht es im oberen Beispiel keinen Sinn, die Ausnahme im Konstruktor aufzufangen, denn dort wurde der Fehler erkannt und die Ausnahme geworfen. Müsste dort eine Fehlerbehandlung stattfinden, dann könnte sie mit in den Anweisungsblock gepackt werden, der die Ausnahme wirft. Erst einmal lassen wir die Ausnahme bis in die main-Funktion vordringen und fangen sie dort auf. Der erste Schritt zum Fangen einer Ausnahme liegt in der Kennzeichnung des Codes, für den im Fehlerfall eine Ausnahme gefangen werden soll. Der Code wird dazu in einen sogenannten try-block gesteckt: try { Becher *k=erzeugeKaffee(-50); delete(k); }
Der relevante Code in der main-Funktion besteht aus dem Aufruf der erzeugeKaffee-Funktion, der nun im try-Block steht. Der try-Block besagt jedoch nur, dass eine in ihm auftretende Ausnahme aufgefangen wird, nicht aber, welche. Für die zu fangenden Datentypen werden hinter dem tryBlock in Form von catch-Blöcken entsprechende Ausnahme-Handler angegeben: try { Becher *k=erzeugeKaffee(-50); delete(k); } catch(string s) { cout << "Fehler: " << s << endl; }
Die Ausnahme-Handler sind an den vorausgehenden try-Block gebunden. Tritt im try-Block eine Ausnahme auf, dann springt der Programmfluss entweder zu einem Ausnahme-Handler, der den passenden Datentyp als Parameter besitzt, oder bricht die aktuelle Funktion/Methode wegen unbehandelter Ausnahme ab. Die Ausnahme muss dann auf höherer Ebene gefangen werden, um einen kompletten Programmabbruch zu verhindern. Sollen Ausnahmeobjekte unterschiedlicher Typen gefangen werden, dann ist für jeden Typ ein eigener Ausnahme-Handler mit eigenständigem catch-Block anzulegen, die hintereinander aufgeführt werden (anders das Abfangen ver-
206
Ausnahmen
schiedener Subklassentypen über den Basisklassentyp, siehe Abschnitt 16.7, »TreeView – die Baumdarstellung«, im .NET-Teil des Buchs). Innerhalb eines Ausnahme-Handlers kann über den Parameter auf das geworfene Objekt zugegriffen werden. Im oberen Beispiel wird auf diese Weise der Text des stringObjekts ausgegeben. Ist ein Ausnahme-Handler abgearbeitet worden, springt der Programmfluss hinter den letzten Handler und fährt dort fort.
5.3.3
Unterschiedliche Ausnahmen auffangen
Die oben angesprochene Möglichkeit, für einen try-Block mehrere Handler zu definieren, wollen wir genauer besprechen. Dazu wird im Konstruktor von Becher zusätzlich überprüft, ob die prozentuale Füllmenge den Wert 100 nicht überschreitet. Andernfalls wird der fehlerhafte Wert als Fehlerobjekt geworfen: Becher::Becher(string i, int fa, float fu) : inhalt(i), fassungsvermoegen(fa), fuellhoehe(fu) { if(fa<0) throw string("Negatives Fassungsvermoegen"); if(fu<0) throw string("Negative Fuellmenge"); if(fu>100) throw fu; }
Das neu geworfene Objekt ist vom Typ float, eine Ergänzung des try-Blocks in main um einen Ausnahme-Handler für float ist daher ratsam: try { Becher *k=erzeugeKaffee(5000); delete(k); } catch(string s) { cout << "Fehler: " << s << endl; } catch(float f) { cout << "Zu viele Prozent: " << f << endl; }
Der Aufruf von erzeugeKaffee wurde bereits abgeändert, um die neu implementierte Ausnahme zu provozieren. Schauen wir uns noch einmal in Abbildung 5.5 den genauen Ablauf an.
207
5.3
5
Fortgeschrittene Sprachelemente
Ausnahme-Handler [Ausnahme]
[Objektyp = string]
string-Handler
[sonst]
try-Block
[Objektyp = int] k=erzeugeKaffee
int-Handler
[sonst]
delete(k)
Ausnahme weiterwerfen
Abbildung 5.5 Die Ausnahmebehandlung als Aktivitätsdiagramm
Der try-Block definiert den Bereich, in dem eine Ausnahme potenziell aufgefangen wird. Wurde eine Ausnahme geworfen – und es spielt keine Rolle, bei welcher Anweisung dies geschah –, dann wird die Abarbeitung des tryBlocks unterbrochen, und die vorhandenen Ausnahme-Handler in der durch ihre Anordnung im Quellcode vorgegebenen Reihenfolge werden nach einem passenden Datentypen durchsucht. Der erste Handler, dessen Datentyp das in der Ausnahme geworfene Objekt aufnehmen kann, wird abgearbeitet. Nach Beendigung des Handlers fährt der Programmfluss hinter dem try-Block fort. Ist kein passender Handler vorhanden, wird das Ausnahmeobjekt weitergeworfen, um auf einer höheren Ebene des Programms aufgefangen zu werden oder das Programm als unbehandelte Ausnahme zu beenden.
5.3.4
Ausnahmen weiterwerfen
Die Titel der Abschnitte in diesem Kapitel klingen fast wie ein neuartiges Ballspiel, aber es besteht die Möglichkeit, innerhalb eines Handlers durch bloße Angabe des Befehls throw die aktuell bearbeitete Ausnahme weiterzuwerfen, damit an anderer Stelle nochmals auf sie reagiert werden kann: try { Becher *k=erzeugeKaffee(-50); delete(k);
Dieses Vorgehen ist z. B. sinnvoll, wenn innerhalb einer Bibliothek auf eine Ausnahme reagiert werden muss, der Fehler aber auch dem Anwender der Bibliothek mitgeteilt werden soll.
5.3.5
Eigene Ausnahme-Klassen
Oft ist es sinnvoll, nicht nur mitzuteilen, dass ein Fehler aufgetreten ist, sondern auch noch die genaueren Umstände zu beschreiben. Im Falle unseres Bechers möchte die Fehler behandelnde Instanz vielleicht wissen, welche ungültige Füllmenge den Fehler verursacht hat. In solchen Fällen bietet es sich an, eine eigene Ausnahme-Klasse zu schreiben, deren Objekte dann die gewünschten Informationen aufnehmen und an die Fehlerbehandlung weitergeben können. Die Klasse Becher soll dazu eine lokale Klasse UngueltigeFuellhoehe bekommen. Diese Klasse speichert nur die ungültige Füllhöhe. Sie besitzt einen Konstruktor, dem die Füllhöhe übergeben wird, und eine Zugriffsmethode GetFuellhoehe, mit der die ungültige Füllhöhe aus dem Objekt herausgeholt werden kann: class Becher : public IAusgabe { public: class UngueltigeFuellhoehe { float fuellhoehe; public: UngueltigeFuellhoehe(float fh) : fuellhoehe(fh) { } float GetFuellhoehe() const { return(fuellhoehe); } }; // Hier der Rest der Becher-Klasse }; Listing 5.11
Die Definition von »UngueltigeFuehoehe« in »Becher.h«
209
5.3
5
Fortgeschrittene Sprachelemente
Den Konstruktor von Becher müssen wir jetzt noch anpassen, damit er im Falle einer ungültigen Füllhöhe ein Objekt der neuen Ausnahme-Klasse wirft: Becher::Becher(string i, int fa, float fu) : inhalt(i), fassungsvermoegen(fa), fuellhoehe(fu) { if(fa<0) throw string("Negatives Fassungsvermoegen"); if(fu<0 || fu>100) throw UngueltigeFuellhoehe(fu); } Listing 5.12
Der neue Konstruktor von »Becher«
Aufgefangen und ausgewertet werden, könnte die Ausnahme z. B. so: try { Becher* b=erzeugeKaffee(-20); } catch(Becher::UngueltigeFuellhoehe e) { cout << "Fehlerhafte Fuellhoehe:" << e.GetFuellhoehe() << endl; } Listing 5.13
Exemplarisches Auffangen von »UngueltigeFuellhoehe«
Da es sich bei UngueltigeFuellhoehe um eine lokale Klasse handelt, muss der Name der umgebenden Klasse außerhalb mit angegeben werden (Becher::UnguelgieFuellhoehe). Der Name e ist willkürlich und dient nur dazu, das aufgefangene Objekt namentlich ansprechen zu können, damit GetFuellhoehe aufgerufen werden kann.
5.4
Templates
Templates – auch Vorlagen oder Schablonen genannt – sind eine speziell für C++ entwickelte Form der Abstraktion. Ein Hauptteil der C++-Standardbibliothek, die STL, basiert auf dieser Technik. Obwohl Templates von .NET und seiner Sprache C# nicht in dieser Art unterstützt werden, können sie auch bei der .NET-Programmierung mit C++ eingesetzt werden.
210
Templates
5.4.1
Funktionstemplates
Als Paradebeispiel für ein potenzielles Funktionstemplate holen wir die maximum-Funktion aus Abschnitt 2.3, »Funktionen«, wieder hervor: int maximum(int x, int y) { if(x>=y) return(x); else return(y); } Listing 5.14
Die Funktion »maximum« für int-Werte
Sie bekommt zwei int-Werte übergeben und liefert den größeren der beiden Werte zurück. Stellen Sie sich vor, die aktuellen Umstände erfordern eine weitere maximum-Funktion, die den größeren zweier float-Werte ermitteln soll. Mit der vorigen maximum-Funktion im Rücken ist diese schnell geschrieben. Dank der Technik des Überladens aus Abschnitt 4.7, »Überladen von Methoden«, können wir ihr sogar den gleichen Namen geben: float maximum(float x, float y) { if(x>=y) return(x); else return(y); } Listing 5.15
Die Funktion »maximum« für float-Werte
Und jetzt wird noch jeweils eine maximum-Funktion benötigt für double, long, char… Spätestens jetzt sucht ein Programmierer nach Alternativen. Zumal sich die beiden Funktion so frappierend ähneln. Nur die Typen im Funktionskopf sind unterschiedlich. Und genau hier kommen die Templates ins Spiel. Hinweis Mit einem Template können innerhalb einer Funktion oder Klasse Datentypen variabel gehalten werden.
Besprechen wir die Bedeutung an einem konkreten Beispiel: template Typ maximum(Typ x, Typ y) {
Die Definition eines Templates beginnt mit dem Wort template. Dahinter stehen in spitzen Klammern jeweils mit dem Schlüsselwort typename die Namen der variablen Typen. Im oberen Fall besitzt das Template einen variablen Typ namens Typ (der Name des variablen Typs ist nach den Namensregeln für Bezeichner frei wählbar, siehe Abschnitt 1.7.1, »Bezeichner«). Hinter dem Templatekopf steht eine normale Funktion, die den variablen Typ des Templates verwenden kann, als wäre es ein gültiger Datentyp. Die Definition des Templates erzeugt noch keinen Code. Sollte jetzt allerdings eine Funktion maximum aufgerufen werden cout << maximum(3,8) << endl;
dann sucht der Compiler zuerst nach einer konkreten Funktion maximum, die zwei int-Werte erwartet. Die gibt es nicht mehr, also versucht er sein Glück mit den Templates, die maximum heißen. Sollte es ein Template geben, dessen feste und variable Typen zu dem Funktionsaufruf passen, dann wird aus dem Template eine konkrete Funktion für den konkreten Typ erstellt. Im oberen Fall kann aus dem maximum-Template eine zum Aufruf passende Funktion erstellt werden, wenn der variable Typ Typ durch int ersetzt wird. Für einen weiteren Aufruf mit anderen Datentypen geschieht das Gleiche: cout << maximum(3.14, 22.67) << endl;
Nun wird aus dem Template eine maximum-Funktion erstellt, die zwei doubleWerte erwartet und einen double-Wert zurückliefert. Und das ist eine wichtige Konsequenz bei der Verwendung von Templates. Achtung Lediglich der Quellcode wird durch Templates kürzer, nicht der endgültige Programmcode.
212
Templates
Im oberen Fall wurde maximum mit zwei verschiedenen Typen aufgerufen, weshalb der Compiler aus dem Template auch zwei unterschiedliche Funktionen erzeugt. Im kompilierten Programm sind ebenso zwei maximum-Funktionen vorhanden, als wären die beiden Funktionen konkret implementiert worden. Templates vereinfachen nur den zu schreibenden Programmcode. Und sie erhöhen die Wiederverwendbarkeit, weil sie auch mit Typen zusammenarbeiten können, die bei der Erstellung des Templates noch nicht bekannt waren. Das maximum-Template beispielsweise kann das größere von zwei Objekten eines beliebigen Typs ermitteln, auf den der >-Operator anwendbar ist. Funktionstemplates können auch für Methoden definiert werden. Aber nicht nur Funktionen oder Methoden können als Templates formuliert werden, sondern auch Klassen! Mehr darüber erfahren Sie im nächsten Abschnitt.
5.4.2
Klassentemplates
In Listing 5.6 in Abschnitt 5.2.3, »Destruktoren«, wurde eine Klasse IntFeld vorgestellt, die in der Lage ist, ein int-Array beliebiger Größe zu verwalten. Wollten wir jetzt aber lieber ein Feld mit double-Werten haben, dann müsste entweder die aktuelle Klasse angepasst oder eine neue Klasse programmiert werden. Sie sehen, worauf es hinausläuft: Könnte der zu verwaltende Typ variabel gehalten werden, dann wären zwei Fliegen mit einer Klappe geschlagen. Natürlich ist die Lösung unseres Problems ein Template. Um der Tatsache Rechnung zu tragen, dass mit dem Template beliebige Datentypen verwaltet werden können, heißt es nur noch Feld: template class Feld { Typ *feld; int groesse; public: Feld(int g) : groesse(g) { feld = new Typ[groesse]; } ~Feld() { delete[](feld); }
213
5.4
5
Fortgeschrittene Sprachelemente
void setWert(int pos, Typ wert) { feld[pos]=wert; } Typ getWert(int pos) const { return(feld[pos]); } }; Listing 5.17
Das Template »Feld«
Der Aufbau des Klassentemplates läuft analog zu den Funktionstemplates. Wie bei den Funktionstemplates wird im Templatekopf der Name des variablen Datentypen bestimmt, der in der darauffolgenden Klasse als konkreter Datentyp verwendet wird. Nur das Erzeugen eines Klassenobjekts sieht etwas anders aus. Bei der Objektdefinition kann der Compiler nicht ermitteln, welchen Typ der variable Datentyp annehmen soll, deswegen muss ihm dies in spitzen Klammern hinter dem Klassennamen angegeben werden: Feld f(20); f.setWert(0,30); cout << f.getWert(0) << endl;
5.5
Operatoren überladen
Dieser Abschnitt behandelt die Fähigkeit von C++, Operatoren für eigene Datentypen überladen zu können. Um eine greifbarere Vorstellung von den dahinterliegenden Mechanismen zu bekommen, implementieren wir zwei Klassen, für die wir einige Operatoren überladen werden. Als Erstes wäre da die Klasse Aufnahmemedium, deren Objekte ein beliebiges Aufnahmemedium repräsentieren, welches eine bestimmte Kapazität und die Zeit, die bereits bespielt wurde, speichert: class Aufnahmemedium { int kapazitaet; int bespieltezeit; public: Aufnahmemedium(int k=240, int z=0) : kapazitaet(k), bespieltezeit(z) { }
214
Operatoren überladen
int GetKapazitaet() const { return (kapazitaet); } int GetBespielteZeit() const { return (bespieltezeit); } }; Listing 5.18
Die Klasse »Aufnahmemedium«
Der Konstruktor definiert Standardwerte für seine Parameter. Wird keine bespielte Zeit angegeben, dann ist das Medium zu Beginn unbespielt. Wird auch keine Kapazität angegeben, dann wird zu Ehren der früheren VHS-Kassetten eine maximale Spielzeit von 240 Minuten definiert. Um auch einen etwas komplexeren Datentypen zu besitzen, der mit dynamischer Speicherverwaltung arbeitet, entwerfen wir die Klasse Mediensammlung, die mehrere Objekte von Aufnahmemedium verwalten kann: class Mediensammlung { Aufnahmemedium* feld; int groesse; int anzahl; public: /* ** Fehlerklassen für Ausnahmen */ class FalscherIndex {}; class KapazitaetUeberschritten {}; /* ** Konstruktor legt dynamisch ein Feld der Größe g an */ Mediensammlung(int g) : groesse(g), anzahl(0), feld(0) { feld=new Aufnahmemedium[groesse]; } /* ** Kopierkonstruktor, fertigt tiefe Kopie an */ Mediensammlung(const Mediensammlung& ms)
Schauen wir uns zunächst die Zuweisungsoperatoren an. Kopier-Zuweisungsoperator
Der Kopier-Zuweisungsoperator weist ein Objekt einem anderen Objekt derselben Klasse zu. Er wird als Methode der Klasse implementiert. Die Syntax des Kopier-Zuweisungsoperators: Klassenname& operator=(const Klassenname& objektname) { }
Der Kopier-Zuweisungsoperator erwartet eine Referenz auf ein konstantes Objekt, so können auch Konstanten einem Objekt zugewiesen werden. Die Operator-Methode liefert das Objekt selbst zurück, um Verkettungen der Art a=b=c umsetzen zu können. Das Objekt wird als Referenz zurückgegeben, damit die syntaktisch erlaubte (aber logisch meist unsinnige) Reihenfolge (a=b)=c umgesetzt werden kann. Hinweis Wird kein eigener Kopier-Zuweisungsoperator implementiert, fügt der Compiler implizit einen Kopier-Zuweisungsoperator hinzu, der aber nur eine flache Kopie anfertigt, das Objekt also nur attributweise kopiert.
Dieser implizite Kopier-Zuweisungsoperator reicht für die Klasse Aufnahmemedium voll aus, denn dort müssen nur Attribute kopiert werden. Anders sieht es bei Mediensammlung aus. Dort müssen bei einer Zuweisung das alte Array freigegeben, ein neues der entsprechenden Größe reserviert und alle Aufnahmemedium-Objekte kopiert werden. Die Methode könnte so aussehen: Mediensammlung& operator=(const Mediensammlung& ms) { // Selbstzuweisung verhindern if(feld!=ms.feld) { // Neuen Speicherblock reservieren Aufnahmemedium* tmp=new Aufnahmemedium(ms.groesse); // Elemente kopieren for(int i=0; i<ms.anzahl; ++i) tmp[i]=ms.feld[i];
217
5.5
5
Fortgeschrittene Sprachelemente
// Altes Feld löschen und Anzahl und Groesse übernehmen delete[](feld); feld=tmp; anzahl=ms.anzahl; groesse=ms.groesse; } // Verweis auf sich selbst zurückgeben return(*this); } Listing 5.20
Der Kopier-Zuweisungsoperator von »Mediensammlung«
Der Operator prüft, ob ein Objekt sich selbst zugewiesen wird (a=a), denn dann muss kein neues Feld angelegt, kopiert und das alte wieder gelöscht werden. Andererseits ist die Selbstzuweisung so unwahrscheinlich, dass für diesen seltenen Fall ein Mehraufwand in Kauf genommen werden kann, dafür dann aber in allen anderen Fällen die unnötige Prüfung nicht durchgeführt werden muss. Die Zuweisung a=b wird jetzt vom Compiler transformiert zu: a.operator=(b);
Mit einem Trick lässt sich die Methode noch weiter vereinfachen. Die Klasse Mediensammlung besitzt bereits einen selbst definierten Kopierkonstruktor, der eine tiefe Kopie anfertigt. Warum nicht diesen verwenden? Mediensammlung& operator=(Mediensammlung ms) { Aufnahmemedium* tmp=feld; feld=ms.feld; ms.feld=tmp; anzahl=ms.anzahl; groesse=ms.groesse; return(*this); } Listing 5.21
Der verbesserte Kopier-Zuweisungsoperator
Die Methode erwartet jetzt nicht mehr eine Referenz, sondern ein Objekt. Dieses muss beim Aufruf kopiert werden, und zwar implizit mit dem Kopierkonstruktor. Das Objekt ms ist damit eine Kopie des übergebenen Objekts. Wir brauchen jetzt nur noch die Arrays auszutauschen (damit wir das frisch kopierte Array übernehmen und bei der Freigabe des lokalen Objekts unser altes Array freigegeben wird) und die Werte von anzahl und groesse zu übernehmen.
218
Operatoren überladen
Kombinierte Zuweisungsoperatoren
Die kombinierten Zuweisungsoperatoren kombinieren die Zuweisung mit einer arithmetischen oder bitweisen Operation. Wenn diese Operatoren nicht gerade für einen numerischen Datentypen überladen werden, stellt sich oft die Frage, welches Verhalten mit dem entsprechenden Operator denn umgesetzt werden soll. Nehmen wir stellvertretend den +=-Operator. Womit rechnet man, wenn dieser Operator auf Objekte des Typs Aufnahmemedium angewendet wird? Oft ist es eine Definitionssache, die dann entsprechend dokumentiert werden muss. Ein prominentes Beispiel ist der <<-Operator in Kombination mit cout. Dort hat er keinerlei bitweise Eigenschaften mehr, sondern dient der Ausgabe von Objekten. Definieren wir also, dass der +=-Operator bei Aufnahmemedium einfach die bespielte Zeit hinzuaddiert. Sollte die Kapazität nicht ausreichen, wird eine Ausnahme des selbst definierten Typs KapazitaetUnzureichend geworfen: Aufnahmemedium& operator+=(const Aufnahmemedium& am) { if(this!=&am) { if((bespieltezeit+am.bespieltezeit)>kapazitaet) throw KapazitaetUnzureichend(); bespieltezeit+=am.bespieltezeit; } return(*this); } Listing 5.22
»operator+=« von »Aufnahmemedium«
In diesem Fall ist eine Prüfung auf Selbstzuweisung unumgänglich, weil ein Aufnahmemedium im Normalfall nicht sich selbst kopieren kann. Die Prüfung wird vorgenommen, indem die Adressen der beiden Objekte verglichen werden. Bei der Klasse Mediensammlung ist der +=-Operator etwas aufwändiger, weil dort mit dynamischem Speicher gearbeitet wird: Mediensammlung& operator+=(const Mediensammlung& ms) { if(this!=&ms) { // Neue Größe bestimmen und Array reservieren int ngroesse=groesse+ms.groesse; Aufnahmemedium* nfeld=new Aufnahmemedium[ngroesse];
219
5.5
5
Fortgeschrittene Sprachelemente
// Daten des aufrufenden Objekts kopieren for(int i=0; i
5.5.2
»operator+=« für »Mediensammlung«
Arithmetische Operatoren
Stellvertretend für die arithmetischen Operatoren werden wir hier den +-Operator für die Klasse Aufnahmemedium überladen. Da bei dem +-Operator ein neues Objekt erzeugt wird, wollen wir im neuen Objekt die Summe der bespielten Zeit und die Summe der Kapazitäten bilden. Die überladenen Operatoren haben bislang immer Auswirkungen auf das aufrufende Objekt gehabt. Jetzt wird ein Ergebnis gebildet, ohne dass ein an der Operation beteiligtes Objekt verändert wird. Deshalb haben wir zwei Möglichkeiten, diesen Operator zu definieren. Operator als Methode
Die erste Variante ist die bereits angewendete, die Definition als Methode: const Aufnahmemedium operator+(const Aufnahmemedium& am) const { Aufnahmemedium tmp(kapazitaet+am.kapazitaet, bespieltezeit+am.bespieltezeit); return(tmp); } Listing 5.24
»operator+« als Methode
Die Methode erzeugt ein neues Objekt und liefert dieses zurück. Das zurückgegebene Objekt ist konstant, damit Schreibweisen wie (a+b)=c nicht kompiliert werden.
220
Operatoren überladen
Operator als Funktion
Bisher haben wir alle Methoden inline in die Klasse geschrieben. Alle binären Operatoren, bei denen nicht zwangsläufig ein Objekt der eigenen Klasse auf der linken Seite steht, können wahlweise auch als Funktion anstelle einer Methode implementiert werden. In diesem Fall kann der Operator nicht mehr in der Klasse stehen, sondern liegt als eigenständige Funktion außerhalb der Klasse vor. Deswegen müssen wir die Funktion in Deklaration (abgelegt in der .h-Datei) und Definition (abgelegt in der .cpp-Datei) aufteilen. Die Syntax einer operator-Funktion: const Klassenname operator+(const Klassenname& objektname1, const Klassenname& objektname1) { }
Nun nicht mehr Element der Klasse, hat die Funktion auch keinen Zugriff mehr auf die privaten Elemente der Klasse. Das Problem ist ziemlich einfach dadurch gelöst, dass die Funktion als Freund der Klasse deklariert wird: class Aufnahmemedium { friend const Aufnahmemedium operator+(const Aufnahmemedium& am1, const Aufnahmemedium& am2); // ... }; Listing 5.25
Friend-Deklaration der Operator-Funktion
Die operator+-Funktion selbst ist damit ein Klacks: const Aufnahmemedium operator+(const Aufnahmemedium& am1, const Aufnahmemedium& am2) { Aufnahmemedium tmp(am1.kapazitaet+am2.kapazitaet, am1.bespieltezeit+am2.bespieltezeit); return(tmp); } Listing 5.26
Die operator+-Funktion von »Aufnahmemedium«
In diesem konkreten Fall wäre eine friend-Deklaration aber nicht einmal nötig gewesen, weil alle notwendigen Informationen über die öffentliche Schnittstelle von Aufnahmemedium ermittelt werden können:
Diese Variante sollten Sie, wann immer möglich, wählen, da der Operator hier keinerlei Informationen über die Interna der Klasse benötigt. Und genau das sollte für nicht zur Klasse gehörende Elemente auch immer gelten. Es gibt aber Fälle, in denen die Operatoren auf interne Objektinformationen zugreifen müssen, so dass eine öffentliche Zugriffsmethode nicht in Frage kommt. Dann bleibt keine andere Wahl als eine friend-Deklaration. Methode oder Funktion?
Eine noch zu klärende Frage ist, ob es besser ist, einen Operator als Methode oder als Funktion zu implementieren. Der Ausdruck a+b wird bei einer Methode zu a.operator+(b);
umgewandelt. Bei einer Funktion kommt operator+(a,b);
heraus. Eines fällt gleich ins Auge. Hinweis Wird ein Operator als Methode implementiert, dann muss der linke Operand vom Typ der Klasse sein, in welcher der Operator definiert wurde.
Diese Einschränkung hat bei der Klasse Aufnahmemedium keine Auswirkungen, da dort immer nur Objekte untereinander verknüpft werden. Werden eigene numerische Datentypen implementiert, dann kann es aber reizvoll sein, diese auch mit den elementaren numerischen Typen verknüpfen zu können. Des Weiteren kann der Compiler bei Funktionsparametern implizite Typumwandlungen vornehmen (siehe Abschnitt 4.5.4, »Explizite Konstruktoren«). Bei einer Methode ist der Typ des linken Operanden hingegen fest. Funktionen sollten daher den Methoden vorgezogen werden, wenn dadurch die Datenkapselung nicht leidet.
222
Operatoren überladen
5.5.3
Vergleichsoperatoren
Auch die Vergleichsoperatoren können überladen werden. Sie liefern als Ergebnis einen Booleschen Wert zurück, der bestimmt, ob der Vergleich stimmt oder nicht. Exemplarisch soll der Operator == für die Klasse Aufnahmemedium überladen werden: bool operator==(const Aufnahmemedium& am1, const Aufnahmemedium& am2) { return(am1.GetKapazitaet()==am2.GetKapazitaet() && am1.GetBespielteZeit()==am2.GetBespielteZeit()); } Listing 5.28
»operator==« für »Aufnahmemedium«
Der Operator == wurde als Funktion implementiert. Er kommt mit der öffentlichen Schnittstelle von Aufnahmemedium aus und benötigt daher keine friend-Deklaration.
5.5.4
Indexoperator
Den Indexoperator zu überladen macht immer dann Sinn, wenn Objekte einer Klasse als Container anderer Objekte dienen. Ein Beispiel ist die hier verwendete Klasse Mediensammlung. Bisher müssen wir die gespeicherten Medien über GetMedium und Mediumhinzufuegen ansprechen. Viel schöner wäre es, wenn wir Folgendes schreiben könnten: Mediensammlung ms(10); cout << ms[2].GetBespielteZeit() << endl;
Und genau das ermöglicht uns der Indexoperator: Aufnahmemedium& operator[](int idx) { return(feld[idx]); } Listing 5.29
Der Indexoperator für »Mediensammlung«
Das Aufnahmemedium muss als Referenz zurückgegeben werden, damit auch Zuweisungen möglich sind: Aufnahmemedium am(120,30); ms[0]=am;
223
5.5
5
Fortgeschrittene Sprachelemente
Man könnte auch auf die Idee kommen, eine konstante Mediensammlung anzulegen: const Mediensammlung ms(10); cout << ms[2].GetBespielteZeit() << endl; // FEHLER
Erstaunlicherweise lässt sich der Indexoperator nicht mehr verwenden, obwohl wir nur lesend auf das Objekt zugreifen. Das liegt daran, dass die OperatorMethode nicht konstanzwahrend ist (Abschnitt 4.6, »Konstanzwahrende Methoden«). Aber selbst wenn sie es wäre, könnte über die Referenz als Rückgabeparameter das Objekt geändert werden. Deshalb benötigen wir einen zweiten Indexoperator, der konstanzwahrend ist und nur eine Kopie zurückliefert: Aufnahmemedium operator[](int idx) const { return(feld[idx]); } Listing 5.30
Ein konstanzwahrender Indexoperator
Hinweis Die Eigenschaft const (konstanzwahrend) reicht als Unterscheidungskriterium beim Überladen aus.
5.5.5
Dereferenzierungsoperator
Stellen Sie sich vor, wir wollten eine Klasse programmieren, deren Objekte Zeiger auf Zeichen eines Strings sein sollen. Dazu speichern die Objekte den String, der die Zeichen enthält, und den Index des Zeichens, auf das gerade gezeigt wird. Der Index ist zu Beginn auf 0 gesetzt: class StringZeiger { std::string& str; int pos; public: StringZeiger(std::string& s) : str(s), pos(0) { } }; Listing 5.31
Die Klasse »StringZeiger« mit Konstruktor
Wir können nun einen String erzeugen mitsamt einem StringZeiger darauf:
224
Operatoren überladen
string s="Andre"; StringZeiger sz(s);
Nur, wie sprechen wir jetzt über sz das Zeichen an? Bei einem echten Zeiger bräuchten wir nur zu dereferenzieren. Um die Fähigkeit, dereferenziert werden zu können, auch unseren Objekten zu verleihen, überladen wir den Dereferenzierungsoperator: char& operator*() { return(str[pos]); } Listing 5.32
Der Dereferenzierungsoperator von »Stringzeiger«
Der Dereferenzierungsoperator liefert eine Referenz auf das Zeichen des Strings zurück, damit auch Zuweisungen an ihn funktionieren. Nun kann unser eigenes Objekt auch dereferenziert werden: cout << *sz << endl;
5.5.6
Inkrement- und Dekrementoperator
Momentan ist der StringZeiger in seiner Funktionalität allerdings noch etwas eingeschränkt, denn er zeigt immer nur auf das erste Zeichen. Bei einem Zeiger würde jetzt der Inkrementoperator dafür sorgen, dass der Zeiger auf das nächste Zeichen zeigt. Um diese Funktionalität zu erreichen, müssen wir den Inkrementoperator (und dabei gleich auch den Dekrementoperator) überladen: StringZeiger& operator++() { ++pos; if(pos>=str.size()) pos=0; return(*this); } StringZeiger& operator--() { --pos; if(pos<0) pos=str.size()-1; return(*this); } Listing 5.33
Der Präinkrement- und -dekrementoperator
225
5.5
5
Fortgeschrittene Sprachelemente
Die Prä-Operatoren liefern das Objekt selbst zurück, denn der um 1 erhöhte oder verminderte StringZeiger ist immer noch ein StringZeiger. Nun können solche Sachen mit unseren eigenen Objekten geschrieben werden: for(int i=0; i<10; ++i) cout << *(++sz) << endl;
Um die Post-Operatoren zu implementieren, müssen wir einen Trick anwenden. Denn sowohl der Präinkrement- also der Postinkrementoperator heißen ++. Der Trick besteht darin, dass die operator++-Methode für den Post-Operator einen Funktionsparameter bekommt, der aber keinerlei Bedeutung hat: StringZeiger operator++(int) { StringZeiger tmp=*this; ++pos; if(pos>=str.size()) pos=0; return(tmp); } StringZeiger operator--(int) { StringZeiger tmp=*this; --pos; if(pos<0) pos=str.size()-1; return(tmp); } Listing 5.34
Der Postinkrement- und -dekrementoperator
Die Post-Operatoren müssen eine Kopie des eigenen Objekts anfertigen, weil ja der Zustand vor dem Inkrement/Dekrement zurückgegeben werden muss. Deshalb liefern die Post-Operatoren auch keine Referenz zurück. Tipp Eine Funktion/Methode sollte niemals einen Zeiger oder eine Referenz auf eine ihrer lokalen Variablen/Objekte zurückliefern, weil diese nach Beendigung der Funktion/ Methode nicht mehr existieren.
226
Die Philosophie der STL
Dieses Kapitel behandelt den wohl mächtigsten Teil der C++-Standardbibliothek. Die drei wesentlichen Komponenten, Container, Iteratoren und Algorithmen, werden in ihrer Struktur und Anwendung besprochen.
6
Die STL
Die Standard Template Library, kurz STL, ist ein Teil der C++-Standardbibliothek und basiert maßgeblich auf den in Abschnitt 5.4, »Templates«, besprochenen Templates. Sie befasst sich im Wesentlichen mit der Bereitstellung von Datenstrukturen, die als Container für andere Objekte fungieren, und den Algorithmen, die auf diesen Containern operieren. Diese Container sind als Templates implementiert, um eine hohe Flexibilität und Typsicherheit zu erzielen.
6.1
Die Philosophie der STL
Das besondere Konzept der STL besteht darin, dass die Algorithmen nicht direkt auf die Container zugreifen, sondern die Schnittstelle zwischen Algorithmus und Container durch sogenannte Iteratoren entkoppelt ist. Abbildung 6.1 zeigt diesen Zusammenhang.
Algorithmus
Iterator
Container
Abbildung 6.1
Das Zusammenspiel von Algorithmen, Iteratoren und Containern
Der Iterator wird im Allgemeinen vom entsprechenden Container zur Verfügung gestellt und besitzt die notwendigen Informationen, um sich innerhalb der Datenstruktur bewegen zu können. Er versetzt die Algorithmen durch seine einheitliche Schnittstelle in die Lage, auf die Daten des Containers zugreifen zu können, ohne etwas über die interne Struktur des Containers wissen zu müssen.
227
6.1
6
Die STL
Durch diese Entkopplung kann ein Algorithmus mit jedem beliebigen Container zusammenarbeiten, solange der entsprechende Iterator existiert. Auf diese Weise kann die STL durch eigene Container ergänzt werden, ohne dass die bereits vorhandenen Algorithmen neu programmiert werden müssen. Auf der anderen Seite wird ein selbst geschriebener Algorithmus, der über Iteratoren auf die Daten zugreift, mit jedem STL-konformen Container zusammenarbeiten können. Dieses Konzept ist dem Prinzip der Collections unter .NET überlegen. Glücklicherweise lässt sich die STL unter .NET auch nutzen, wodurch C++-Programmierer zum einen mit ihren gewohnten Strukturen arbeiten und ihre eigenen Ergänzungen unter .NET weiterverwenden können und zum anderen einen Vorteil gegenüber den Nutzern der anderen .NET-Sprachen haben, für die es keine STL gibt.
6.1.1
Container
Die STL unterstützt mehrere Container, die alle in Tabelle 6.1 aufgelistet sind.1 Zusätzlich ist noch mit angegeben, auf welcher Datenstruktur die einzelnen Container basieren. Container
Strukturtyp
vector
Array
deque
Array
list
doppelt verkettete Liste
set
Baum
multiset
Baum
map
Baum
multimap
Baum
string
Feld
Tabelle 6.1 Die von der STL zur Verfügung gestellten Container
Dabei zählen die Strings nicht hundertprozentig zu den STL-Containern, weil sie bestimmte Zugriffsmöglichkeiten für Iteratoren nicht unterstützen. Da 1 Im nächsten C++-Standard werden noch weitere Container wie array, forward_list, unordered_map oder unordered_set enthalten sein, die hier jedoch nicht aufgeführt werden.
228
Die Philosophie der STL
Strings jedoch Verwendung finden, werden wir uns auch mit ihnen beschäftigen. Einer der einfachsten Container ist der in der Header-Datei vector definierte Vektor. Er wird intern als Array implementiert und kann damit auch alle für Felder typischen Operationen umsetzen. Die typische Definition eines Vektors sieht so aus: vector vektor(20);
Die Variable vektor entspricht nun einem 20-elementigen int-Feld. Dieses Beispiel konfrontiert uns auch gleich mit einer Besonderheit von Containern. Hinweis Werden bei der Erzeugung eines Containers auch Elemente angelegt, dann wird für diese der Standardkonstruktor (siehe Abschnitt 4.5.5, »Standardkonstruktor«) aufgerufen.
Im oberen Beispiel wurde daher für alle 20 int-Elemente der Standardkonstruktor aufgerufen.
6.1.2
Iteratoren
Wie zu Beginn des Kapitels bereits erwähnt, bilden die Iteratoren die Schnittstelle zwischen den Containern und den Algorithmen. Doch was genau ist ein Iterator? Im Grunde genommen sind Iteratoren nichts weiter als abstrakte Zeiger. Hinweis Als abstrakten Zeiger bezeichnet man ein Objekt, welches sich wie ein Zeiger verhält, jedoch nicht notwendigerweise ein Zeiger sein muss. In der STL sind es üblicherweise Klassenobjekte.
Nehmen wir als Beispiel ein Array: int feld[20];
Wenn wir dieses Array nun mithilfe eines Zeigers ausgeben möchten, dann könnte dies wie folgt aussehen: int* zeiger=feld; for(int x=0;x<20;x++) { cout << *zeiger << endl;
229
6.1
6
Die STL
++zeiger; }
Es wurde bewusst die Präfix-Notation der operator++-Methode eingesetzt, da diese, wie in Abschnitt 1.9.3, »Inkrement und Dekrement«, erklärt, Laufzeitvorteile mit sich bringt. Wenn wir die Idee des Zugreifens auf ein Array mithilfe eines Zeigers weiterentwickeln, erhalten wir eine Klasse, welche die Zeigerfunktionalität kapselt: template class FeldIterator { private: Typ* feld; public: explicit FeldIterator(Typ* f) { feld=f; } Typ& operator*() { return(*feld); } FeldIterator& operator++() { feld++; return(*this); } FeldIterator& operator--() { feld++; return(*this); } bool operator!=(const FeldIterator& i) { return(feld!=i.feld); } }; Listing 6.1 Die Template-Klasse »FeldIterator«
Dieses Template definiert nur die absolut notwendigen Methoden, um unsere Klasse für das aktuelle Beispiel als Zeiger fungieren zu lassen. Der Operator != wurde überladen, um unseren Iterator später für Algorithmen tauglich zu machen. Ein richtiger Iterator weist noch weitere Eigenschaften auf, mit denen wir uns in Abschnitt 6.10 beschäftigen werden.
230
Die Philosophie der STL
Wollen wir mit diesem abstrakten Zeiger das Feld ausgeben, dann sieht das so aus: FeldIterator iterator(feld); for(int y=0;y<20;y++) { cout << *iterator << endl; ++iterator; }
Vom reinen Zugriff her unterscheidet sich dieser abstrakte Zeiger nicht von einem gewöhnlichen Zeiger. Ein gewaltiger Unterschied macht sich aber dann bemerkbar, wenn wir nicht mehr ein Feld, sondern eine Liste ausgeben wollen. Bei einem normalen Zeiger können wir dann nicht mehr den Inkrementoperator verwenden, sondern müssen das nachfolgende Listenelement über den Verkettungsverweis innerhalb des aktuellen Elements ermitteln. Der abstrakte Zeiger hingegen kapselt die Ermittlung des nachfolgenden Elements in seiner Klasse, weswegen sich bei der Verwendung nichts ändern wird. Lediglich die interne Implementation der Methoden operator++ und operator-- ändert sich. Davon bekommt der Anwender jedoch nichts mit. Das bedeutet, mithilfe von abstrakten Zeigern kann ein einheitlicher Zugriff auf alle nur denkbaren Datenstrukturen implementiert werden. Und genau darin liegt die Stärke der Iteratoren, die ja abstrakte Zeiger sind. Je nach Container werden an den Iterator verschiedene Anforderungen gestellt, bzw. nicht jeder Container kann alle möglichen Zugriffstypen unterstützen. Ein auf einem Array basierender Container kann beispielsweise ohne Bedenken einen direkten Zugriff (etwa durch ein Überladen des Indexoperators) zur Verfügung stellen, während ein solcher Zugriff bei einer verketteten Liste – obwohl programmtechnisch durchaus möglich – nicht ohne erhebliche Einbußen in der Laufzeit einhergeht. Deswegen werden die Iteratoren in verschiedene Kategorien eingeteilt, die jeweils eine bestimmte Menge von Operationen zur Verfügung stellen. Tabelle 6.2 liefert einen kurzen Überblick.
++
output
input
forward
bidirectional
random-access
X
X
X
X
X
X
X
X
X
X
X
X
lesen schreiben
Tabelle 6.2
X
Die Iteratorkategorien im Überblick
231
6.1
6
Die STL
output
input
forward
bidirectional
random-access
->
X
X
X
X
==, !=
X
X
X
X
X
X
-+, -, +=, -=
X
<, >, <=, >=
X
[ ]
X
Tabelle 6.2
Die Iteratorkategorien im Überblick (Forts.)
Dieser Tabelle ist beispielsweise zu entnehmen, dass der Random-Access-Iterator der bei Weitem mächtigste Iterator ist. Wegen der äußerst großzügigen Zugriffsoperatoren (wie dem Indexoperator) wird dieser Iterator jedoch nur von den wenigsten Containern unterstützt. Wenn im Folgenden Iteratoren als Parameter auftreten, dann werden an diese Iteratoren im Allgemeinen Mindestanforderungen gestellt. Um diese Mindestanforderungen zu definieren, wird die Iteratorkategorie angegeben, die diese Anforderungen noch erfüllt. Wenn eine Funktion also einen bidirektionalen Iterator benötigt, dann kann dort kein Output-Iterator, jedoch ohne Probleme ein Random-Access-Iterator verwendet werden. Da wir noch viel mit Iteratoren zu tun haben werden, einigen wir uns auf Abkürzungen, die in Tabelle 6.3 aufgeführt sind. Iteratorkategorie
Abkürzung
output
Output
input
Input
forward
Forward
bidirectional
Bi
random-access
Random
Tabelle 6.3 Abkürzungen für die Iteratorkategorien
Sollte es sich um einen konstanten Iterator handeln, also einen Iterator, der nur auf einem Container lesen, aber keine Veränderungen vornehmen kann, dann wird der Kennzeichnung ein C vorangestellt. CRandom wäre damit ein konstanter Random-Access-Iterator. Zusätzlich kann jeder Iterator in Form eines Reverse-Iterators auftreten. Wir werden diese besondere Form noch in
232
Die Philosophie der STL
Abschnitt 6.11, besprechen. Hier sei nur soviel gesagt, dass mit ReverseIteratoren ein Container rückwärts durchlaufen werden kann.
6.1.3
Algorithmen
Die Algorithmen sind nun diejenigen Funktionen, die über Iteratoren auf die Container zugreifen. Um das an einem Beispiel zu demonstrieren, wollen wir den simplen Algorithmus copy verwenden. Er wird folgendermaßen aufgerufen: copy(Iterator beginn, Iterator ende, Iterator ziel)
Dabei sind beginn, ende und ziel durch Iteratoren definierte Positionen, sogenannte Iteratorpositionen. Zuerst legen wir zwei Felder an: int quellfeld[20]; int zielfeld[20];
Nun definieren wir drei Iteratoren unseres eigenen Typs, die den drei für copy benötigten Positionen entsprechen: FeldIterator beginn(quellfeld); FeldIterator ende(&quellfeld[20]); FeldIterator ziel(zielfeld);
Achtung Die das Ende definierende Iteratorposition zeigt immer auf die Position hinter dem Ende.
Abbildung 6.2 verdeutlicht dies. begin
end
Abbildung 6.2 Die Start- und Endposition mit Iteratoren
Der Aufruf der copy-Funktion ist jetzt einfach. Um die Algorithmen der STL nutzen zu können, müssen wir die Datei algorithm mittels #include einbinden: copy(beginn,ende,ziel);
Da Iteratoren nur lesend und überschreibend auf Container einwirken, können mit Algorithmen keine neuen Elemente zu einem Container hinzugefügt
233
6.1
6
Die STL
werden. Lediglich das Überschreiben von Elementen ist möglich. Auf diese Thematik wird später noch intensiver eingegangen.
6.1.4
Allokatoren
Die Container der STL verwenden zur Verwaltung ihres Speichers sogenannte Allokatoren. Hinweis Ein Allokator ist eine Klasse, die Methoden zur Reservierung und Freigabe von Speicher zur Verfügung stellt.
Auf diese Weise kann das einem Container zugrunde liegende Speichermodell durch bloßes Auswechseln des Allokators geändert werden.
6.2
Grundlagen
Bevor wir mit den Containern starten, schauen wir uns zuerst ein paar Punkte an, die zum besseren Verständnis notwendig sind.
6.2.1
Zeitkomplexität
Um Algorithmen – oder allgemein Operationen auf Daten – qualitativ bewerten zu können, wird ein objektives Maß benötigt. Es bietet sich an, als Maß die vom Algorithmus benötigte Zeit zu verwenden. Dabei verdienen die Laufzeiten, die der Algorithmus schlimmstenfalls (worst case), bestenfalls (best case) und durchschnittlich (average case) benötigt, besondere Aufmerksamkeit. Um eine Laufzeit angeben zu können, die nur den Algorithmus selbst bewertet, dürfen keine implementationsabhängigen Faktoren mit einfließen. Nehmen wir als Beispiel die sequenzielle Suche. Sie findet ein vorhandenes Element, indem sie am Anfang der Datenstruktur beginnt und dann hintereinander jedes Element mit dem gesuchten Element vergleicht. Ist ein Vergleich positiv, dann wurde das Element gefunden. Erreicht die Suche erfolglos das Ende der Datenstruktur, dann ist das gesuchte Element nicht in der Datenstruktur enthalten. Da bei einer Anzahl von n Elementen maximal n Vergleiche durchgeführt werden müssen, besitzt die sequenzielle Suche eine obere Grenze der Lauf-
234
Grundlagen
zeit, die k * n entspricht. Dabei ist k eine umgebungs- und implementierungsabhängige Konstante, die für die eigentliche Betrachtung des Algorithmus keine Rolle spielt. Auf einem mit 3 GHz getakteten Rechner ist k beispielsweise kleiner als bei einem mit 2 GHz getakteten Rechner mit gleichem Prozessor. Und ein Multimedia-Freund, der den Verlauf der sequenziellen Suche grafisch auf dem Bildschirm anzeigen lässt, wird ein größeres k haben als jemand, der den reinen Algorithmus direkt in Maschinensprache implementiert. Deswegen kann die Konstante k bei der Betrachtung der Laufzeit vernachlässigt werden. Sie ist nicht algorithmusspezifisch. Die sequenzielle Suche liegt damit in O(n)-Zeit. Die O-Notation beschreibt das worst-case-Verhalten. Einteilung in Laufzeitklassen
Man kann die Laufzeiten von Algorithmen in Klassen aufteilen. Eine solche Aufteilung wurde in Tabelle 6.4 vorgenommen. Laufzeit
Bezeichnung
Beispiel
O(1)
konstant
Die Operationen push und pop bei Stacks benötigen im günstigsten Fall konstante Zeit. Sie sind damit unabhängig von der Elementanzahl.
O(log n)
logarithmisch
Die binäre Suche oder das Suchen in höhenbalancierten Bäumen fällt in diese Kategorie. Bei linearer Erhöhung der Elementanzahl steigt die benötigte Suchzeit immer langsamer.
O(n)
linear
Die bereits besprochene sequenzielle Suche besitzt ein lineares Laufzeitverhalten. Die benötigte Zeit steigt proportional zur Elementanzahl.
O(n * log n)
n * log n
Ein optimales, auf Schlüsselvergleichen basierendes Sortierverfahren kann bestenfalls O(n * log n)-Zeit besitzen.
O(n2)
quadratisch
Einfache Sortierverfahren wie Bubblesort besitzen ein quadratisches Laufzeitverhalten.
O(2n)
exponentiell
Beispielsweise benötigen alle Probleme, die in die Kategorie der NP-Vollständigkeit fallen, zu ihrer Lösung exponentielle Laufzeit.
Tabelle 6.4 Die verschiedenen Laufzeitklassen
Abbildung 6.3 zeigt die stilisierten Graphen der einzelnen Laufzeitklassen. Die Grafik dient nur der Darstellung, in welchem Maße die Laufzeit bei
235
6.2
6
Die STL
zunehmendem n für jede Laufzeitklasse steigt. Die Graphen sind nicht maßstabsgetreu. exponentiell
quadratisch
N log N linear
logarithmisch
konstant
Abbildung 6.3 Die benötigte Zeit der Laufzeitkassen in Abhängigkeit von der Elementanzahl
Eine Verdopplung der Elementanzahl wirkt sich bei den Laufzeitklassen, wie in Tabelle 6.5 aufgeführt, auf die Laufzeit aus. Laufzeitklasse
Verhalten
konstant
Die benötigte Zeit bleibt konstant.
logarithmisch
Die benötigte Zeit nimmt um einen konstanten Betrag zu.
linear
Die benötigte Zeit verdoppelt sich.
n * log n
Die benötigte Zeit verdopppelt sich und erhöht sich zusätzlich noch um einen konstanten Wert (Verknüpfung von linearer und logarithmischer Laufzeit).
quadratisch
Die benötigte Zeit vervierfacht sich.
exponentiell
Die benötigte Zeit quadriert sich.
Tabelle 6.5 Das Verhalten der Laufzeit bei einer Verdopplung der Elementanzahl
Best Case und Average Case
Häufig ist es interessant, nicht nur den worst case zu betrachten. Der günstigste Fall wird mit der Omega-Notation ausgedrückt. Für die sequenzielle Suche tritt die günstigste Situation dann ein, wenn das zu suchende Element als Erstes in der Datenstruktur steht. In diesem Fall benötigt die Suche eine Laufzeit von 1. Um den günstigsten Fall auszudrücken, verwenden wir die Omega-Notation: Ω(1). Für den Fall, dass das Laufzeitverhalten für den besten und den schlimmsten Fall identisch ist, benutzt man die Theta-Notation. Beispielsweise benötigen
236
Grundlagen
die Stack-Operationen push und pop im günstigsten und schlimmsten Fall jeweils konstante Laufzeit, weswegen ein Laufzeitverhalten von Θ(1) gilt. Für die Praxis ist auch der durchschnittliche Fall interessant. Der häufig eingesetzte Sortier-Algorithmus Quicksort arbeitet beispielsweise schlimmstenfalls in O(n2)-Zeit, was auf den ersten Blick eher abschreckend wirkt. Weil er aber im durchschnittlichen Fall nur ungefähr eine Zeit von n *⋅log n benötigt, wird er gerne eingesetzt.
6.2.2
Funktionsobjekte
Des Öfteren kommen in der STL Methoden oder Algorithmen vor, denen eine Funktion übergeben werden muss. Diese Funktion wird dann zur Ermittlung eines Entscheidungskriteriums herangezogen. Ein Beispiel ist der remove_if-Algorithmus, der aufgrund eines bestimmten Kriteriums Elemente aus einem Bereich herauslöscht: remove_if(anfang, ende, funktion)
Angenommen, wir wollten alle Elemente eines Bereichs, die einen geraden Wert haben, herauslöschen, dann könnten wir folgende Funktion schreiben: bool iseven(int a) { return((a%2)==0); }
Unter der Voraussetzung, dass a und b zwei Iteratorpositionen in einem intElemente enthaltenden Container sind, würde folgende Anweisung alle geraden Werte löschen: remove_if(a,b,iseven); remove_if ruft für jedes Element n die Funktion iseven(n) auf und entschei-
det anhand des Rückgabewertes (wahr oder falsch), ob das Element gelöscht wird oder nicht. Was aber, wenn iseven beispielsweise mitzählen soll, wie häufig es aufgerufen wurde, oder wenn festgehalten werden soll, wie viele Elemente einen geraden Wert hatten? Die Lösung sind Funktionsobjekte. Funktionsobjekt Unter dem Begriff Funktionsobjekt versteht man ein Objekt einer Klasse, die einen Funktionsaufrufoperator besitzt und damit wie eine Funktion aufgerufen werden kann.
237
6.2
6
Die STL
Passen wir iseven schnell an: class iseven { public: bool operator()(int a) { return((a%2)==0); } }; Listing 6.2 Die Klasse »iseven«
Der Aufruf von remove_if ändert sich damit in: remove_if(a,b,iseven() );
Der Aufruf iseven() steht natürlich nicht für den Aufruf der operator()Methode, sondern für den Aufruf des impliziten Standardkonstruktors, der ein Objekt von iseven erzeugt. Dieses Objekt wird dann an remove_if übergeben. Damit alle verwendeten Funktionsobjekte eine gemeinsame Basis haben, stellt die STL zwei Basisklassen zur Verfügung, für einparametrige Funktionsobjekte und für zweiparametrige Funktionsobjekte. Diese Basisklassen können wie folgt aussehen: template struct unary_function { typedef TypA argument_type; typedef Ergebnis result_type; }; template struct binary_function { typedef TypA first_argument_type; typedef TypB second_argument_type; typedef Ergebnis result_type; }; Listing 6.3
»unary_function« und »binary_function«
Durch das Ableiten von diesen Klassen besitzen alle Funktionsobjekttypen über ihre Basisklasse einheitliche Namen und Typen. Die STL-Definitionen von unary_function und binary_function finden Sie in der Header-Datei functional. Um unser Funktionsobjekt nun STL-konform zu implementieren, müssen wir von der Klasse unary_function ableiten:
238
Grundlagen
template class iseven : public unary_function { public: bool operator()(int a) { return((a%2)==0); } }; Listing 6.4
»iseven« mit »unary_function« als Basisklasse
Der entsprechende Aufruf von remove_if sieht dann so aus: remove_if(a,b,iseven());
Prädikate
Im Zusammenhang mit Algorithmen und Containern, dort speziell mit den Container-Methoden, werden des Öfteren Prädikate verwendet. Nachdem wir nun wissen, was Funktionsobjekte sind, ist der Schritt zu einem Prädikat nur noch ein kleiner: Prädikat Unter dem Begriff Prädikat versteht man ein Funktionsobjekt, welches einen Wert vom Typ bool zurückliefert.
Mit unserem Funktionsobjekt iseven haben wir bereits ein Prädikat implementiert, ohne es zu wissen. Wenn Prädikate eingesetzt werden, dann meistens in Form von elementaren logischen Operationen. Damit nicht jeder Benutzer von Prädikaten das Rad wieder neu erfinden muss, stellt die STL einige grundlegende Prädikate in der Header-Datei functional zur Verfügung, die in Tabelle 6.6 aufgelistet sind. Name
Funktionalität
equal_to
a == b
Greater
a > b
greater_equal
a >= b
Less
a < b
less_equal
a <= b
logical_and
a && b
logical_not
!a
Tabelle 6.6
Vordefinierte Prädikate
239
6.2
6
Die STL
Name
Funktionalität
logical_or
a || b
not_equal_to
a != b
Tabelle 6.6
Vordefinierte Prädikate (Forts.)
Das Prädikat logical_not ist das einzige Prädikat, welches von unary_ function erbt, und somit nur einen Parameter besitzt. Alle anderen Prädikate sind von binary_function abgeleitet und damit zweiparametrig. Als Beispiel schauen wir uns einmal an, wie das Prädikat equal_to aussehen könnte: template struct equal_to : public binary_function { bool operator()(const Typ& a, const Typ& b) const { return (a==b); } }; Listing 6.5
Das Prädikat »equal_to«
Immer, wenn in diesem Buch ein Prädikat gefordert wird, werden folgende Abkürzungen als Datentyp angegeben: Abkürzung
Bedeutung
Pred
Prädikat mit einem Parameter
BinPred
Prädikat mit zwei Parametern
Tabelle 6.7 Die verwendeten Abkürzungen für Prädikate
Arithmetische Objekte
Abgesehen von den Prädikaten wurden noch einige arithmetische Operationen als Funktionsobjekte implementiert: Name
Wenn Funktionen ein Funktionsobjekt fordern, dann wird dies durch Verwendung der folgenden Abkürzungen kenntlich gemacht: Abkürzung
Bedeutung
Op
Funktionsobjekt mit einem Parameter
BinOp
Funktionsobjekt mit zwei Parametern
Tabelle 6.9 Die verwendeten Abkürzungen für arithmetische Funktionsobjekte
Dabei ist zu berücksichtigen, dass Prädikate auch Funktionsobjekte sind. BinOp kann daher ein zweiparametriges arithmetisches Funktionsobjekt, aber auch ein zweiparametriges Prädikat sein. Binder
Häufig wird ein zweiparametriges Prädikat mit einem festen Wert benötigt. Wenn z. B. alle Elemente gelöscht werden sollen, die den Wert 10 haben, dann käme das Prädikat equal_to nur bedingt in Frage. Aber nur deswegen ein eigenes Prädikat mit nur einem Parameter zu schreiben, wäre zu aufwändig. Weil solche Fälle häufiger auftreten, wurde die STL um sogenannte Binder erweitert. Binder Ein Binder benutzt ein zweiparametriges Funktionsobjekt und belegt einen der beiden Parameter mit einem festen Wert. Das so entstandene einparametrige Funktionsobjekt kann dann wie gewohnt eingesetzt werden.
Von der STL werden dazu zwei Binder zur Verfügung gestellt. Der eine belegt den ersten Parameter mit einem festen Wert und heißt bind1st. Der zweite belegt den zweiten Parameter mit einem festen Wert und wird bind2nd genannt. Schauen wir uns einmal ein konkretes Beispiel an. Im Folgenden wird mittels remove_if ein bestimmter Bereich abgearbeitet und jedes Element, welches den Wert 10 hat, gelöscht: remove_if(a,b,bind1st(equal_to(),10)); bind1st hat als ersten Parameter das zu bindende Funktionsobjekt. Der
zweite Parameter spezifiziert den Wert, mit dem der gebundene Parameter belegt werden soll. Analog dazu existiert bind2nd mit festem erstem Wert.
241
6.2
6
Die STL
6.2.3
Paare
Bei Containern und Algorithmen werden ab und zu Paare benötigt. Zum Beispiel besteht bei dem Container map jedes Element sowohl aus einem Schlüssel als auch aus den Nutzdaten. Dabei besitzen Schlüssel und Nutzdaten für gewöhnlich unterschiedliche Datentypen. Um diese beiden Objekte trotzdem als ein Objekt betrachten zu können, wurden die Paare ins Leben gerufen. Dabei könnte die Definition eines Paares wie folgt aussehen (die Definition von pair und ihren Funktionen finden Sie in der Header-Datei utility): template struct pair { TypA first; TypB second; pair() : first(), second() {} pair(const TypA& a, const TypB& b) : first(a), second(b) {} }; Listing 6.6 Die Klasse »pair«
Die expliziten Aufrufe der Standardkonstruktoren von first und second im Standardkonstruktor von pair sind nicht notwendig, erhöhen hier aber die Übersichtlichkeit. Um ein Paar zu erzeugen, verwenden wir für Templates übliche Definitionen: pair a,b(2,"Andre");
Um das bloße Erzeugen eines Paares etwas angenehmer zu gestalten, existiert die Template-Funktion make_pair: template < typename TypA, typename TypB> inline pair make_pair(const TypA& a, const TypB& b) { return(pair(a,b)); } Listing 6.7
Die Funktion »make_pair«
Der Funktion werden einfach die beiden Objekte übergeben, und sie liefert das entsprechende Paar zurück. Paare müssen natürlich auch verglichen werden können. Grundsätzlich sind zwei Paare dann gleich, wenn jeweils die beiden ersten Objekte und die bei-
242
Grundlagen
den zweiten Objekte gleich sind. Die entsprechende Operator-Funktion, die bei Paaren global definiert wird, sieht wie folgt aus: template < typename TypA, typename TypB> inline bool operator==(const TypA& a, const TypB& b) { return((a.first==b.first) && (a.second==b.second)); } Listing 6.8
Die operator==-Funktion von »pair«
Zusätzlich benötigen wir noch einen Vergleich auf kleinerer Ebene. Ein Paar a ist genau dann kleiner als ein Paar b, wenn entweder das erste Objekt von a kleiner ist als das erste Objekt von b oder die beiden ersten Objekte gleich und das zweite Objekt von a kleiner als das zweite Objekt von b ist: template < typename TypA, typename TypB> inline bool operator<(const TypA& a, const TypB& b) { return((a.first
Die operator<-Funktion von »pair«
Innerhalb von operator< wurde bewusst auf einen Einsatz von operator== verzichtet. Schauen wir uns den Ausdruck !(b
6.2.4