Klaus Schmaranz
Softwareentwicklung in C++ 18. November 2002
Springer-Verlag Berlin Heidelberg NewYork London Paris Tokyo Hong Kong Barcelona Budapest
Vorwort des Autors
C++ ist aus vielen guten Gr¨ unden derzeit eine der am weitesten verbreiteten objektorientierten Programmiersprachen. Einer der wichtigsten der angesprochenen vielen guten Gr¨ unde f¨ ur diese Verbreitung ist sicherlich die Flexibilit¨at der Sprache, die es erlaubt praktisch von tiefster Maschinenebene bis zu beliebig hohen Abstraktionslevels alle Probleme auf eine saubere und konsistente Art anpacken zu k¨ onnen. Durch seine mittlerweile schon recht lange Geschichte (bezogen auf die schnelllebige Welt der Informatik) hat der Sprachstandard von C++ im Lauf ¨ der Zeit bereits einige kleine und gr¨ oßere Anderungen hinter sich gebracht. Alle diese Anpassungen waren in meinen Augen immer sehr durchdachte Verbesserungen, die dazu gef¨ uhrt haben, dass man gewisse Dinge noch eleganter l¨osen konnte als zuvor. Vor allem hatten die angesprochenen Ver¨anderungen zum allergr¨ oßten Teil den Charakter von Erweiterungen, es wurden also keine k¨ unstlichen Inkompatibilit¨ aten erzeugt, die ein moving Target in der Entwicklung dargestellt h¨ atten. Ich bin selbst in die C++ Entwicklung bereits zu Zeiten der sehr fr¨ uhen Versionen eingestiegen und war begeistert von den M¨oglichkeiten, elegante L¨ osungen f¨ ur Probleme zu konzipieren. Jetzt, viele Jahre sp¨ ater, bin ich von C++ in seiner heutigen Form mehr begeistert denn je. Eines soll allerdings auch nicht verschwiegen werden: Um C++ wirklich zu beherrschen, braucht man viel Erfahrung! Vor allem muss man den Sinn hinter den einzelnen Konstrukten der Sprache wirklich verstanden haben, um sie richtig einsetzen zu k¨ onnen. Die Gefahr, gewisse Features aufgrund von Unverst¨ andnis zu missbrauchen und dadurch unsauberen Code zu erzeugen, ist nicht gering! Leider muss ich sagen, dass gar nicht so wenige Entwickler, die C++ einsetzen, die Sprache nicht in vollem Umfang beherrschen. Jedoch, und das ist genau das Problematische daran, glauben die meisten dieser Entwickler, dass sie gut mit C++ umgehen k¨ onnten. Aus diesem Grund habe ich versucht, die Erfahrungen aus meinen eigenen Fehlern sowie die Erfahrungen mit Fehlern und Unverst¨ andnis Anderer in dieses Buch einzubringen. Das Buch ist nach bestem Wissen und Gewissen so gestaltet, dass C++ und die wichtigen Aspekte, die erst eine saubere Objektorientierung ausmachen, so aufbauend und gut strukturiert wie m¨oglich vermittelt werden.
VI
Vorwort des Autors
Es ist das vorliegende Werk auch zus¨atzlich noch ein Resultat aus seiner Verwendung im Rahmen eines im Studium sehr fr¨ uh angesiedelten Programmierpraktikums an der Technischen Universit¨at Graz. Aus den Reihen der Studierenden, die quasi als “Versuchskaninchen” fungierten kamen sehr viele und sehr fruchtbare Anregungen, was den Aufbau und gewisse Erkl¨arungen betrifft. Das Buch wurde aber nicht nur von Leuten gelesen, die C++ erst lernen mussten, sondern auch von einigen Leuten, die bereits echte C++ Profis sind. Von beiden Lesergruppen gab es durchgehend sehr positive Reaktionen, sowohl was die fachliche Seite als auch was die Strukturierung und die leserische Seite betrifft (der Begriff “literarische” Seite w¨are wohl doch etwas zu hoch gegriffen :-)). Auch die Profis waren angetan davon, dass sie in einigen Punkten durch die Lekt¨ ure dieses Buchs ein tieferes Verst¨andnis f¨ ur gewisse Dinge vermittelt bekamen und entsprechend etwas dazugelernt haben. Obwohl es in diesem Buch naturgegebenermaßen sehr stark um technische Aspekte geht, habe ich versucht, es so angenehm lesbar wie m¨oglich zu machen. Ich bin einfach der Ansicht, dass Fachb¨ ucher nicht nur trocken irgendwelche Fakten vermitteln sollen, sondern dass man auch Spaß am Lesen haben muss. Auf diese Art hat man gleich viel mehr Motivation, das Gelernte auch wirklich in die Tat umzusetzen. Einen Aspekt zum Umgang mit diesem Buch, der im Prinzip f¨ ur alle Fachb¨ ucher G¨ ultigkeit besitzt, m¨ ochte ich hier noch kurz ansprechen: Es steckt eine riesige Menge an Information auf den einzelnen Seiten. Je nach Wissensstand der Leser ist es im Normalfall praktisch nicht m¨oglich, diese gesamte Information auch wirklich bei einmaligem Durchlesen zu verinnerlichen. Deshalb ist es sehr geistreich, nach einiger Zeit das Buch erneut zur Hand zu nehmen und es noch einmal durchzugehen. Einer meiner Studierenden hat dies mit folgender Aussage auf den Punkt gebracht: Ich habe das Buch gelesen und mich mit allen darin beschriebenen Dingen gespielt, bis ich geglaubt habe, sie zu verstehen. Danach habe ich das Buch als Nachschlagewerk verwendet. Nach einiger Zeit dachte ich mir, ich lese es einfach zum Spaß noch einmal von vorne bis hinten durch. Und bei diesem zweiten Mal sind mir erst viele Dinge aufgefallen, die bisher spurlos an mir vor¨ uber gegangen sind. Genau diese Dinge aber waren die wirklich Wichtigen f¨ ur die Praxis. In diesem Sinne w¨ unsche ich allen Lesern viel Spaß bei der Lekt¨ ure und einen guten Einstieg in die Welt von C++. Klaus Schmaranz
Graz im August 2002
Vorwort des Autors
VII
Danksagung Ein Buch zu schreiben ist ganz grunds¨atzlich einmal sehr viel Arbeit. Vor allem ist es Arbeit, die man nicht durchgehend allein im stillen K¨ammerlein erledigen kann, denn einerseits wird man betriebsblind und andererseits w¨ urde man zwischendurch mehr als nur einmal die Nerven nach einer durchtippten Nacht wegwerfen. Aus diesem Grund gilt mein Dank in der Folge nicht nur den Personen, die in Form von Diskussionen und Kritik den Inhalt direkt und nachhaltig beeinflusst haben und den Personen, die das Buch lektoriert haben. Mein Dank gilt ebenso allen Personen, die f¨ ur mich immer ansprechbar waren, wenn mein Nervenkost¨ um wieder einmal nicht im allerbesten Zustand war. Einige der nachstehend angef¨ uhrten Leute haben in diesem ganzen Prozess auch mehr als nur eine Rolle u ¨bernommen. Ich kann und m¨ ochte keine Reihung der Wichtigkeit der einzelnen Beteiligten vornehmen, denn dies ist ein Ding der Unm¨oglichkeit. Jede einzelne Person war ein wichtiger Teil in einem komplizierten Puzzle, das nach seiner Zusammensetzung das fertige Buch ergeben hat. Wie es bei Puzzles u ¨blich ist, hinterl¨ asst jedes fehlende St¨ uck ein Loch und jedes einzelne Loch st¨ort das Gesamtbild enorm. Es wird Zeit, zum Wesentlichen zu kommen: Mein Dank gilt meinem Institutsleiter Hermann Maurer, der mehr als einfach nur “der Chef” ist. Auch als guter Freund steht er immer mit aufmunternden Worten, gutem Rat und Unterst¨ utzung zur Seite. Wie bereits bei meinem ersten Buch Softwareentwicklung in C war auch hier wieder Hermann Engesser mein direkter Ansprechpartner beim Verlag und in gewohnter Weise unb¨ urokratisch und sehr kooperativ. Meine Arbeitskollegen, Informatik-Gurus und außerdem guten Freunde Karl Bl¨ umlinger, Christof Dallermassl und Dieter Freismuth waren freiwillige Leser des Manuskripts und durchk¨ammten dieses auf fachliche Ungereimtheiten und sonstige Fehler. Christof Rabel, seines Zeichens enorm kompetenter C++ Spezialist, war durch seine mannigfaltigen Kommentare und seine Diskussionsbereitschaft eine sehr große Hilfe, wenn ich wieder einmal mit Betriebsblindheit geschlagen war. Ebenso wichtig waren viele Diskussionen mit meinem guten Freund und Informatik-Guru Dirk Schwartmann, der mir viele Denkanst¨oße zur Aufbereitung des Inhalts aus seinem reichen Erfahrungsschatz gab. Da schon sehr viel von guten Freunden die Rede war, geht es gleich mit solchen weiter: Mein herzlichster Dank geb¨ uhrt Monika Tragner und Anita Lang, die aufgrund ihrer germanistischen Kompetenz ehrenamtlich und kurz entschlossen das Lektorat f¨ ur dieses Buch u bernommen haben. Neben den wichtigen Personen in meinem Leben, die ¨ direkt an der Arbeit beteiligt waren, gibt es auch solche, die “nur” indirekt ihren Teil zur Entstehung des Buchs beigetragen haben, indem sie einfach f¨ ur mich da waren. Dies bedeutet aber nicht, dass ihr Beitrag deshalb geringer gewesen w¨ are. Hierbei w¨ aren vor allem Manuela Burger und meine Eltern zu nennen, die mir in vielerlei Hinsicht immer eine ganz große Hilfe waren und sind.
VIII
Vorwort des Autors
Last, but not least, geht auch mein besonderer Dank an die Studierenden der TU-Graz, die im Sommersemester 2002 das Programmierpraktikum besucht haben, sowie an Harald Krottmaier und an die Tutoren und Tutorinnen, die bei der Durchf¨ uhrung desselben geholfen haben.
Aus besonders traurigem Anlass m¨ ochte ich diese Danksagung zum Zeitpunkt der Drucklegung an dieser Stelle noch erg¨anzen und widme dieses Buch meinem Vater, dessen Tod mich heute v¨ollig unerwartet, aber daf¨ ur umso tiefer getroffen hat. Klaus Schmaranz
Graz am 17. November 2002
Inhaltsverzeichnis
1.
Ziel 1.1 1.2 1.3 1.4
und Inhalt dieses Buchs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Zum Inhalt . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Feedback . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Die beiliegende CD-ROM . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1 3 6 8 9
Teil I: Low-Level Konzepte von C++ . . . . . . . . . . . . . . . . . . . . . . . . . 11 2.
Datentypen und Variablen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.1 Primitive Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Deklaration, Definition und Initialisierung . . . . . . . . . . . . . . . . . 2.3 Das erste C++ Programm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4 Zusammengesetzte Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.1 Arrays . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.2 Structures . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.4.3 Unions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5 Scope und Lifetime . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6 Symbolische Konstanten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.7 Eigene Typdefinitionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13 13 20 25 31 31 37 40 46 47 49
3.
Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ¨ 3.1 Uberblick und Reihenfolge der Auswertung . . . . . . . . . . . . . . . . 3.2 Arithmetische Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3 Logische- und Vergleichsoperatoren . . . . . . . . . . . . . . . . . . . . . . . 3.4 Bitoperatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.5 Zuweisungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6 Datentypabfragen und explizite Typecasts . . . . . . . . . . . . . . . . . 3.6.1 Type Identification und Run-Time-Type-Information . 3.6.2 Unchecked Cast . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6.3 Compiletime-checked Cast . . . . . . . . . . . . . . . . . . . . . . . . 3.6.4 Runtime-checked Cast . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6.5 Remove-const Cast . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.6.6 C-Style Casts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
53 53 57 59 60 61 61 62 67 67 69 70 70
X
Inhaltsverzeichnis
4.
Kontrollstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.1 Selection Statements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2 Schleifen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.3 Das unselige goto Statement . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.
Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
6.
Pointer und References . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.1 References . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2 Pointer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.1 Pointer und Adressen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.2 Dynamische Memory Verwaltung . . . . . . . . . . . . . . . . . . 6.2.3 Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.4 Funktionspointer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2.5 Besondere Aspekte von Pointern . . . . . . . . . . . . . . . . . . Call-by-reference auf Pointer . . . . . . . . . . . . . . . . . . . . . . Mehrfachpointer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Pointer und Typecasts . . . . . . . . . . . . . . . . . . . . . . . . . . .
103 103 114 115 119 125 125 126 126 128 131
7.
Der 7.1 7.2 7.3
135 136 137 138
Preprocessor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Include Files . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ¨ Bedingte Ubersetzung ................................... Macros . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
73 74 79 82
Teil II: Objektorientierte Konzepte von C++ . . . . . . . . . . . . . . . . . 141 8.
Objektorientierung Allgemein . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.1 Module und Abl¨ aufe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.1.1 Der Weg zum Arbeitsplatz – ein kleines Beispiel . . . . . 8.2 Klassen und Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.3 Richtige Verwendung der OO Mechanismen . . . . . . . . . . . . . . . .
143 146 146 155 161
9.
Klassen in C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.1 Besonderheiten von Structures in C++ . . . . . . . . . . . . . . . . . . . . 9.2 Einfache Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.2.1 Konstruktor und Destruktor genauer beleuchtet . . . . . 9.2.2 Der Copy Konstruktor . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.2.3 Initialisierung vs. Zuweisung . . . . . . . . . . . . . . . . . . . . . . 9.2.4 Deklarieren von Konstruktoren als explicit . . . . . . . . 9.2.5 Object- und Class-Members . . . . . . . . . . . . . . . . . . . . . . . 9.3 Abgeleitete Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3.1 Mehrfachvererbung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9.3.2 Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . 9.4 Weitere wichtige technische Aspekte . . . . . . . . . . . . . . . . . . . . . . 9.4.1 Static und Dynamic Binding . . . . . . . . . . . . . . . . . . . . . .
167 168 171 178 184 188 189 192 196 205 215 219 219
Inhaltsverzeichnis
9.4.2 9.4.3 9.4.4 9.4.5 9.4.6 9.4.7 9.4.8
XI
Abstrakte Methoden und Klassen . . . . . . . . . . . . . . . . . . Virtuelle Ableitung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Downcasts von Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . Friends von Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Overloading von const und non-const Methoden . . . Besonderheiten bei der Initialisierung . . . . . . . . . . . . . . Tempor¨ are Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
232 235 241 247 250 251 253
10. Memory – ein kleines Beispiel . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.1 Das ADD . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.1.1 Identifikation der Grobmodule . . . . . . . . . . . . . . . . . . . . 10.1.2 Weitere Zerlegung der einzelnen Grobmodule . . . . . . . Input Handling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Spielsteuerung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Output Handling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Memory Spielfeld . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Memory Karte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Commandline Handling . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2 Das DDD . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.1 Klassendiagramm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.2 Klassendeklarationen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.3 Vector . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.4 ObjectDeletor . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.5 Konkrete Deletors . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.6 ArgumentHandler . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.7 MemoryCommandlineArgumentHandler . . . . . . . . . . . . . 10.2.8 CommandlineHandling . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.9 SimpleOutputHandling . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.10 Displayable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.11 OutputContext . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.12 TextOutputContext . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.13 GameCard . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.14 MemoryGameCard . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.15 MemoryGameboard . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.16 IntDisplayable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.17 TextDisplayable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.18 Event . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.19 WordEvent . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.20 SimpleInputHandling . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.21 SimpleEventHandling . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.22 MemoryGameControl . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.2.23 MemoryCardSymbolGenerator . . . . . . . . . . . . . . . . . . . . . 10.2.24 MemoryCardpair . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10.3 Ausz¨ uge aus der Implementation . . . . . . . . . . . . . . . . . . . . . . . . .
257 257 258 259 259 259 260 260 260 260 260 261 261 263 266 267 271 272 273 275 278 279 279 281 282 284 289 290 292 293 294 296 296 300 302 304
XII
Inhaltsverzeichnis
11. Exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 311 12. Operator Overloading . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12.1 Grundprinzipien des Operator Overloadings . . . . . . . . . . . . . . . . 12.2 Typumwandlungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12.3 Speicherverwaltung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12.3.1 Einfaches new und delete . . . . . . . . . . . . . . . . . . . . . . . . 12.3.2 Array new und delete . . . . . . . . . . . . . . . . . . . . . . . . . . . 12.3.3 Placement Operator new . . . . . . . . . . . . . . . . . . . . . . . . . 12.3.4 delete mit zwei Parametern . . . . . . . . . . . . . . . . . . . . . . 12.3.5 Globale new und delete Operatoren . . . . . . . . . . . . . . . 12.3.6 Weitere Aspekte der eigenen Speicherverwaltung . . . . Vererbung von new und delete . . . . . . . . . . . . . . . . . . . Verhalten bei “Ausgehen” des Speichers . . . . . . . . . . . . 12.4 Abschließendes zu overloadable Operators . . . . . . . . . . . . . . . . .
335 335 354 363 363 371 374 381 385 391 391 397 400
13. Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13.1 Function Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13.2 Overloading Aspekte von Function Templates . . . . . . . . . . . . . . 13.3 Class Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13.4 Ableiten von Class Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13.5 Explizite Spezialisierungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13.6 Verschiedenes zu Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13.7 Source Code Organisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
405 407 414 419 428 431 444 447
14. Namespaces . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 453 15. Verschiedenes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15.1 mutable Member Variablen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15.2 Unions im OO Kontext . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15.3 Funktionspointer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15.4 Besondere Keywords, Diagraphs und Trigraphs . . . . . . . . . . . . . 15.5 volatile Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15.6 RTTI und dynamic cast im OO Kontext . . . . . . . . . . . . . . . . . . 15.7 Weiterf¨ uhrendes zu Exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . .
463 463 465 470 475 477 477 482
Teil III: Ausgesuchte Teile aus der C++ Standard Library . . . 491 16. Die 16.1 16.2
C++ Standard Library . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ¨ Ubersicht .............................................. Container . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.1 Vektoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.2 Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.3 Double-Ended Queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.4 Standard Queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
493 493 497 497 499 501 502
Inhaltsverzeichnis
XIII
16.2.5 Priority Queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.6 Stacks . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.7 Maps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.8 Sets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16.2.9 Zusammenfassung der Container-Operationen . . . . . . . Iterators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Allocators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Numerik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Algorithmen und Funktionsobjekte . . . . . . . . . . . . . . . . . . . . . . .
503 505 506 507 510 513 517 518 521 531 534
A. Coding-Standard . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A.1 Generelle Regeln . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A.2 Coding-Rules . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A.3 Design Guidelines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
537 537 538 541
B. Vollst¨ andige Implementation des Memory Spiels . . . . . . . . . . B.1 Implementationen der einzelnen Klassen . . . . . . . . . . . . . . . . . . . B.1.1 Das Hauptprogramm . . . . . . . . . . . . . . . . . . . . . . . . . . . . . B.1.2 Implementation von Vector . . . . . . . . . . . . . . . . . . . . . . B.1.3 Implementation von CommandlineHandling . . . . . . . . . B.1.4 Implementation von SimpleOutputHandling . . . . . . . . B.1.5 Implementation von GameCard . . . . . . . . . . . . . . . . . . . . B.1.6 Implementation von MemoryGameCard . . . . . . . . . . . . . . B.1.7 Implementation von MemoryGameboard . . . . . . . . . . . . . B.1.8 Implementation von SimpleInputHandling . . . . . . . . . B.1.9 Implementation von MemoryGameControl . . . . . . . . . . . B.1.10 Implementation von MemoryCardSymbolGenerator . . . B.1.11 Implementation von MemoryCardpair . . . . . . . . . . . . . . B.1.12 Variablen f¨ ur die konkreten Deletors . . . . . . . . . . . . . . . B.1.13 Das MemoryMakefile . . . . . . . . . . . . . . . . . . . . . . . . . . . .
543 543 543 545 546 547 547 547 548 552 552 554 556 557 557
16.3 16.4 16.5 16.6 16.7 16.8
Literaturverzeichnis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 561 Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 563
1. Ziel und Inhalt dieses Buchs
Dieses Buch richtet sich an alle, die gerne die Programmiersprache C++ von Grund auf erlernen wollen oder ihre Kenntnisse vertiefen wollen. Es wird hierbei ein gewisses Grundverst¨ andnis f¨ ur die Softwareentwicklung im Allgemeinen vorausgesetzt. Kenntnisse der Programmiersprache C sind nat¨ urlich auch nicht von Nachteil. C++ hat sich, wie der Name schon sagt, aus C entwickelt und ist dessen objektorientiertes Pendant. Im Prinzip ist mit sehr wenigen Ausnahmen alles, was f¨ ur C gilt, auch f¨ ur C++ g¨ ultig, allerdings ist ++ der Sprachumfang von C durch die Objektorientierung bedeutend gr¨oßer. Lesern, die noch keine Erfahrung mit der Programmiersprache C haben, oder vielleicht noch u ¨berhaupt keine Erfahrung mit der Softwareentwicklung im Allgemeinen, sei als Grundlage, vor dem Durcharbeiten dieses Buchs, das Buch Softwareentwicklung in C ans Herz gelegt. Um nicht missverstanden zu werden: Ich m¨ochte mit diesen einleitenden S¨atzen keineswegs den Verkauf des oben genannten Buchs ankurbeln (eine elektronische Version desselben liegt ohnehin gratis der mitgelieferten CDROM bei). Es ist auch im vorliegenden Buch alles an Information enthalten, was man zum Erlernen von C++ braucht, auch wenn man noch kein C kann. Nur ist das grundlegende C-Wissen im zuvor genannten Buch ungleich detaillierter enthalten und daher f¨ ur Einsteiger bei weitem leichter genießbar. Eine derartig detaillierte Beschreibung der grundlegenden Dinge im hier vorliegenden Buch u urde dessen Rahmen bei weitem sprengen. ¨ber C++ w¨ Die Intention hinter der Empfehlung, zuerst C zu lernen, hat gute Gr¨ unde, die ich noch kurz n¨ aher erl¨ autern m¨ ochte: Um C++ wirklich gut zu beherrschen, muss man unbedingt tieferes Verst¨andnis in zwei verschiedenen Wissensgebieten erlangen: 1. Man muss das Konzept der objektorientierten (kurz: OO) Programmierung verinnerlicht haben, um ein sauber durchdachtes und schl¨ ussiges Softwaredesign erstellen zu k¨ onnen. 2. Man muss ein gewisses Verst¨ andnis f¨ ur die interne Arbeitsweise eines Computers entwickelt haben, um das sauber durchdachte Design auch vern¨ unftig umsetzen zu k¨ onnen. Die Erfahrung zeigt, dass man viel Zeit und Nerven spart, wenn man nicht beide Dinge zugleich lernt. In vielen B¨ uchern und in noch mehr Kursen wird
2
1. Ziel und Inhalt dieses Buchs
versucht, Softwareentwicklung gleich anhand einer objektorientierten Sprache zu lehren. Das Argument f¨ ur diese Vorgehensweise klingt auf den ersten Blick sehr schl¨ ussig: OO-Sprachen wurden entwickelt, weil den Menschen die OO-Denkweise besser liegt als die imperative. Also stellen OO-Sprachen, im Vergleich zu imperativen Sprachen, eine deutliche Verbesserung dar und sind deswegen auch leichter zu erlernen. Leider wurden bei diesem Argument ein paar Kleinigkeiten u ¨bersehen: Erstens haben Probleml¨ osungen, egal ob mit OO-Sprachen oder mit imperativen Sprachen, immer einen imperativen Anteil. Man muss ja einem Computer “genau sagen, was er tun soll”, zumindest im Kleinen. Das geht ohne Verst¨andnis f¨ ur die interne Arbeitsweise nicht wirklich. Zweitens existiert sehr wohl ein Unterschied zwischen der saloppen Denkweise von Menschen und den absolut exakten Formulierungen, die einer Programmiersprache zugrunde liegen. Drittens wurden OO-Sprachen u ¨ber lange Zeit hinweg immer weiterentwickelt, um noch eleganter und besser einsetzbar zu sein. Dementsprechend fanden viele Konstrukte in OO-Sprachen Eingang, die erst dann zu verstehen sind, wenn man die notwendige Erfahrung hat, um deren Sinn nachvollziehen zu k¨ onnen. W¨ahlt man also f¨ ur die ersten Schritte in der Softwareentwicklung gleich eine OO-Sprache, so ist man gezwungen, einige Interna zu begreifen, obwohl das notwendige Handwerkszeug und die notwendige Erfahrung dazu fehlen. Meine Beobachtungen von C++ Neulingen zeigen folgendes Bild: • Die meisten Entwickler, die zuvor entweder C oder eine andere imperative Sprache beherrschen, finden sehr schnell Gefallen an C++, da sie endlich ein Problem elegant l¨ osen k¨ onnen. Es ist mit der n¨otigen Erfahrung bei richtiger Anwendung des OO-Ansatzes (und nur dann!) m¨oglich, Probleme elegant in kleine, in sich abgeschlossene Teilprobleme zu zerlegen. Damit kann man sich immer um das Wesentliche des entsprechenden Teilproblems k¨ ummern, ohne durchgehend das große Ganze akribisch im Auge behalten zu m¨ ussen. Nat¨ urlich geht dies nur, nachdem das große Ganze einmal vern¨ unftig auf h¨ oherer Abstraktionsebene entworfen wurde. • Fast alle Entwickler, die C++ (oder auch Java) als Einstiegssprache lernen, ohne zuvor mit einer imperativen Sprache gearbeitet zu haben, finden C++ unglaublich kompliziert und sehen den Wald vor lauter B¨aumen nicht mehr. Das liegt daran, dass sie vor vielen Problemen, die durch das OO-Konzept gel¨ost werden, u ¨berhaupt noch nie gestanden sind! Wie soll man auch eine Probleml¨ osung verstehen und den damit verbundenen Overhead gerne in Kauf nehmen, wenn man das Problem nicht kennt bzw. nicht verstanden hat? Lesern, die trotz dieser Argumente keine Lust haben, zuerst C zu lernen, sondern sich gleich C++ aneignen wollen, m¨ochte ich zumindest die beiliegende elektronische Version von Softwareentwicklung in C als Nachschlagewerk f¨ ur Zwischendurch empfehlen. Es schadet in jedem Fall nicht, wenn man
1.1 Zum Inhalt
3
z.B. bei Abhandlungen u ¨ber Pointer einmal einen kurzen Blick in das entsprechende Kapitel des anderen Buchs wirft oder dieses vielleicht sogar im Schnellverfahren einmal von vorne bis hinten querliest. Auf einen Punkt sei noch ganz besonders hingewiesen: C++ als OOSprache unterst¨ utzt sehr elegant alle Eigenschaften, die immer mit OOSoftwareentwicklung in Verbindung gebracht werden. Die wichtigsten Schlagworte hierbei sind sicherlich die Modularit¨ at und die Wiederverwendbarkeit von Code. Ganz bewusst habe ich das Wort unterst¨ utzt besonders hervorgehoben, denn es herrscht der allgemeine Irrglaube, dass gewisse Eigenschaften, wie z.B. Wiederverwendbarkeit, allein durch das Verwenden einer OOSprache automatisch existieren bzw. erzwungen werden. Dies ist definitiv falsch. Kritisch betrachtet ist sogar das Gegenteil der Fall! Bei Verwendung von OO-Sprachen kann man Fehler im Design viel l¨anger hinter abenteuerlichen Objektkonstruktionen verstecken, als es bei Verwendung von imperativen Sprachen jemals m¨ oglich w¨ are. Wenn man dann allerdings den Punkt erreicht, an dem “nichts mehr geht”, dann ist die Katastrophe bei weitem gr¨oßer, als sie sonst jemals werden k¨onnte. Bei imperativen Sprachen muss man viel fr¨ uher das Handtuch werfen, weil man bei weitem schneller den Punkt erreicht, an dem man nichts mehr an einem Teil eines Programms ¨andern kann, ohne gleich an einer ganz anderen Ecke im Programm eine Katastrophe hervorzurufen. Bei OO-Sprachen kann man sich bewusst noch eine Zeit lang an diesen Problemen vorbei schwindeln, indem man Objektkapselungsmechanismen missbraucht. Das Ziel bei jeder Softwareentwicklung ist immer, dass das Endprodukt sauber und robust ist. Daher wird in diesem Buch besonderer Wert darauf gelegt, OO-Softwareentwicklung richtig zu betreiben, denn nur dann gelangt man zum gew¨ unschten Ziel. Dazu ist sehr viel Erfahrung vonn¨oten, vor allem, wenn es um saubere, elegante und allgemein einsetzbare Probleml¨osungsans¨atze geht. Wo auch immer auf dem Weg zum erkl¨arten Ziel typische Stolpersteine liegen, an denen sich C++ Neulinge erfahrungsgem¨aß stoßen k¨onnen, wird besonders auf sie hingewiesen.
1.1 Zum Inhalt Das Buch f¨ uhrt in kleinen Schritten von den Prinzipien der OO-Softwareentwicklung u ¨ber die Grundlagen der Sprache (z.B. primitive Datentypen, wie sie auch in C existieren) und u ¨ber die ersten objektorientierten Gehversuche zu zum Teil sehr speziellen Konstrukten von C++. Es wird immer besonderer Wert darauf gelegt, das Warum zu verstehen, anstatt nur ein Kochbuch mit einfachen Rezepten anzubieten. Nur zu oft sieht man C++ Code, der aus purem Unverst¨ andnis der Hintergr¨ unde unglaublich kompliziert ist, obwohl die Sprache an und f¨ ur sich eine elegante L¨osungen erm¨oglichen w¨ urde.
4
1. Ziel und Inhalt dieses Buchs
Zu allen Sprachkonstrukten werden entsprechende Beispiele angef¨ uhrt und ausf¨ uhrlich erkl¨ art. Diese Beispiele sind durchgehend mit kleinen Zeilennummern am linken Rand versehen, um ein Referenzieren im beschreibenden Text einfacher zu machen. Nat¨ urlich sind diese Zeilennummern nicht Teil des C++ Codes und d¨ urfen dementsprechend nicht in Programmen vorkommen! Alle Beispiele, die im Buch angef¨ uhrt sind, wurden so nahe wie m¨oglich an sinnvolle Konstrukte angelehnt, die auch in der t¨aglichen Praxis vorkommen. Auch die Herangehensweise an Probleml¨osungen, die in diesem Buch vermittelt wird, hat sich u urlich bedeutet dies nicht, ¨ber lange Zeit bew¨ahrt. Nat¨ dass die angef¨ uhrten Beispiele die einzig m¨ogliche L¨osung zu einem Problem darstellen. Ich m¨ ochte unbedingt dazu anregen, u ¨ber alternative L¨osungen nachzudenken und diese mit den vorgeschlagenen zu vergleichen. Dies ist die beste Methode, den Umgang mit verschiedensten Aspekten der Softwareentwicklung zu u ¨ben und die Kenntnisse u ¨ber eine Programmiersprache zu vertiefen. Um das Buch so gut wie m¨ oglich zu strukturieren, wurde es in drei Hauptteile untergliedert: Teil I – Low-Level Konzepte von C++: Im ersten Teil des Buchs werden alle Konzepte von C++ besprochen, die noch nichts mit Objektorientierung zu tun haben. Im Prinzip sind dies einerseits Konzepte, die C++ von Cu ¨bernommen hat, andererseits einige Features, die entweder im Vergleich zu C sinnvoll erweitert wurden bzw. v¨ollig neu sind. Wo auch immer Unterschiede zwischen C und C++ zu finden sind, sind diese explizit angemerkt. Teil II – Objektorientierte Konzepte von C++: Im zweiten Teil des Buchs werden die Features von C++ besprochen, die direkt mit OO-Softwareentwicklung zu tun haben und die es in C vom Konzept her u ¨berhaupt nicht gibt. Teil III – Ausgesuchte Teile aus der C++ Standard Library: Zu C++ gibt es eine sehr große Standard-Klassenbibliothek, die vieles enth¨alt, was man im Programmieralltag gut gebrauchen kann. Der Vorteil der StandardLibrary ist, dass man nicht f¨ ur die allt¨aglichen Bed¨ urfnisse das Rad neu erfinden muss und auch nicht auf Code von Drittherstellern angewiesen ist. Die Standard-Library hat, wie der Name schon sagt, den Vorteil, dass ihr Inhalt erstens standardisiert ist und dass sie zweitens standardm¨aßig bei jedem (ok, fast jedem :-)) C++ Compiler mitgeliefert wird. Um nun diese Standard-Library umfassend mit erkl¨arenden Codebeispielen zu behandeln, m¨ usste dieses Buch mindestens den doppelten Umfang haben. Aus diesem Grund wird im dritten Teil des Buchs vor allem der ¨ prinzipielle Umfang dieser Library vorgestellt, um einen Uberblick zu vermitteln. Umfassendere Beschreibungen gibt es nur zu den Teilen, die man im “¨ ublichen” Alltag sehr h¨ aufig braucht. Durch diese Art der Beschreibung werden die Konzepte klar genug beleuchtet, dass man die Ein-
1.1 Zum Inhalt
5
stiegspunkte hat, die man braucht, um mit der Online-Dokumentation problemlos zu Rande zu kommen. Um besondere Hinweise im Buch auf einen Blick erkennen zu k¨onnen, sind diese extra hervorgehoben und besitzen eine besondere Kennzeichnung am Rand: • Das Zeichen
signalisiert einen Hinweis auf einen Stolperstein.
• Das Zeichen signalisiert einen Hinweis auf einen wichtigen Unterschied zwischen C und C++. • Das Zeichen signalisiert, dass es als Zusatzinformation zu einem bestimmten Thema eine ausf¨ uhrliche Abhandlung im Buch Softwareentwicklung in C gibt. Eine elektronische Version dieses Buchs ist auch auf der beiliegenden CD-ROM vorhanden. Diese speziellen Hinweise auf C-Konstrukte wurden f¨ ur Leser eingef¨ uhrt, die im Umgang mit der Programmiersprache C noch ein wenig unsicher sind oder u ¨berhaupt noch keine C-Erfahrung haben. Ein paar Worte m¨ ochte ich noch u ¨ber das Thema Entwicklungsumgebung verlieren: C++ Entwicklungsumgebungen gibt es wie Sand am Meer. Je nachdem, um welche Entwicklungsumgebung es sich handelt und f¨ ur welche Zielplattform diese gedacht ist, ist sowohl der Funktionsumfang als auch der Umfang zus¨ atzlicher plattformabh¨ angiger Libraries sehr verschieden. Nun ist es nicht im Sinne des Erfinders, C++ als allgemein einsetzbare Programmiersprache an einer bestimmten Umgebung festzumachen. Aus diesem Grund wurde folgende Wahl getroffen: Die empfohlene Entwicklungsumgebung, auf der auch prinzipiell das gesamte Buch beruht, ist eine Umgebung unter Linux mit dem GNU C++ Compiler und GNU make. Diese Entscheidung ist darin begr¨ undet, dass dies eine frei verf¨ ugbare Open-Source Umgebung ist. Sie ist auch eine der stabilsten Entwicklungsumgebungen, die derzeit zur Verf¨ ugung stehen. Es soll sich nun allerdings niemand abschrecken lassen, der lieber unter MS-Windows mit einer anderen Entwicklungsumgebung arbeiten will. Das Wissen, das in diesem Buch vermittelt wird, ist nat¨ urlich auch daf¨ ur g¨ ultig. Es steht also jedem frei, eine beliebige Umgebung zu verwenden, ohne dass dadurch Einschr¨ ankungen zu erwarten sind. Jedoch m¨ochte ich jedem Softwareentwickler gerne ans Herz legen, es zumindest interessehalber einmal mit Linux zu probieren. Nach einer kurzen Eingew¨ohnungsphase lernt man doch gewisse Vorteile sch¨ atzen. Eine kurze Warnung m¨ ochte ich jedoch an dieser Stelle noch aussprechen: Einige Compiler entsprechen definitiv in einigen “Feinheiten” nicht dem C++ Standard und einige Compiler haben auch nicht alle Features implementiert, die im Standard enthalten sind. Dies betrifft auch die sehr weit verbreiteten Compiler gewisser Firmen, die den PC Markt beherrschen. Um zu erfahren, wo diese Compiler vom Standard abweichen, wirft man am besten einen Blick
6
1. Ziel und Inhalt dieses Buchs
auf die entsprechenden Web-Pages der Compilerhersteller. Dort existiert u ¨blicherweise eine Liste, die Auskunft u ¨ber die Abweichungen vom Standard gibt. Sollten Leser also auf Beispiele im Buch stoßen, die mit ihrem Compiler nicht u ochte ich empfehlen, einen Blick auf die Web¨bersetzbar sind, so m¨ Page des Herstellers zu werfen. Sollten dann immer noch Ungereimtheiten existieren, so ist das Feedback-Forum zum Buch (siehe Abschnitt 1.3) der beste Ort, eine Frage anzubringen. Ich werde dann so schnell wie m¨oglich versuchen, Klarheit zu schaffen. Leser, die sich gar nicht sicher sind, ob sie nun Linux ausprobieren sollen oder nicht, k¨ onnen auch einen Zwischenweg w¨ahlen: Alle n¨ utzlichen Tools (GNU C++ Compiler, Emacs als Editor und viele kleine Helferlein, wie z.B. make oder tar) sind auch als MS-Windows Portierungen frei im Internet verf¨ ugbar. Kurze Beschreibungen, die den Einstieg in die Arbeit mit diesen Tools erleichtern, sind auch im Buch Softwareentwicklung in C, das bereits zu Beginn dieses Kapitels genannt wurde, enthalten.
1.2 Motivation Softwareentwickler stehen in der Industrie im Regelfall unter starkem Druck. Es wird von ihnen nur zu oft verlangt, dass sie unrealistische Termine einhalten, dass sie jederzeit auf Wunsch mitten in der Entwicklung neue Features in die Software einbauen oder auch, dass sie einfach w¨ahrend der Entwicklung Konzepte ¨ andern. Das Allerschlimmste, das allerdings passieren kann, ist, dass ein Kunde oder Vorgesetzter fr¨ uhzeitig “schnell einmal etwas sehen will”. Leider bedeutet hier der Begriff etwas sehen, dass man einen Teil eines lauff¨ ahigen Programms demonstrieren muss. Kann man dies nicht, bzw. versucht man, ein erstelltes Design zu zeigen, dann sieht man sich sehr oft Zweifeln ausgesetzt, “ob man denn u ¨berhaupt etwas weiterbringt”. Nun funktioniert aber saubere Softwareentwicklung nicht so, dass man sich einfach zum Computer setzt und schnell ein Programm schreibt. Abgesehen davon, dass das sowieso die schlechtest m¨ogliche Herangehensweise ist, ist die Komplexit¨ at heutiger Softwarepakete so hoch, dass dieser Ansatz niemals funktionieren kann. Leider herrscht immer noch die Meinung vor, dass die tats¨ achliche Arbeit bei der Softwareentwicklung die Implementation der Software ist. Dies ist aber vollkommen falsch! Der gr¨oßte Teil der Arbeit im Rahmen einer sauberen (!!) Entwicklung findet in der Designphase statt! Das Codieren selbst nimmt im Rahmen eines Entwicklungszyklus nur einen sehr geringen Teil der Zeit in Anspruch. Einen weiteren großen Teil im Zyklus nehmen die laufenden Tests ein, die die Software von den ersten Designschritten bis zum Endprodukt begleiten m¨ ussen. Sieht man sich den Projektfortschritt bei einer sauberen Entwicklung an, so stellt man Folgendes fest: Lange Zeit scheint die Arbeit nur schleppend voranzugehen. Es gibt viele Diskussionsphasen im Team und das Ergebnis dieser Diskussionsphasen sind Entw¨ urfe auf Papier. Im Regelfall wird bis
1.2 Motivation
7
u alfte der Gesamtentwicklungszeit hinaus keine Zeile Code produ¨ber die H¨ ziert. Danach allerdings scheint sich die Arbeit von außen gesehen enorm zu beschleunigen. In sehr kurzer Zeit wird sehr viel implementiert und die Implementation ist u ¨blicherweise auch relativ fehlerfrei. Wenn man im Gegensatz dazu den Projektfortschritt bei einer Hackl¨ osung ansieht, bei der die Arbeit gleich von Beginn an am Computer stattfindet, ohne zuerst ein vollst¨ andiges (!!) und schl¨ ussiges (!!) Design zu erstellen, dann beobachtet man als Außenstehender im Prinzip das Gegenteil: Zu Beginn passiert in kurzer Zeit sehr viel und es gibt sehr schnell einen Prototypen zu sehen, der einen Teil der geforderten Funktionalit¨at mehr oder weniger gut implementiert. Gewisse Fehler werden nat¨ urlich toleriert, denn man steckt ja noch mitten in der Entwicklung. Mit jedem zus¨atzlichen Feature, das die Software dem Endprodukt n¨ aher bringt, geht die Arbeit schleppender voran. Nach einer gewissen Zeit bedeuten auch die kleinsten Erweiterungen des halbfertigen Pakets bereits immensen Implementationsaufwand. Außerdem steigt die Fehlerh¨ aufigkeit auf ein unertr¨aglich hohes Maß. Das Beheben von ¨ Fehlern wird immer mehr zum Spießrutenlauf, denn mit jeder Anderung in einem Teil der Software passieren ungeahnte Dinge in anderen Teilen der Software. Mit Gl¨ uck ist das geforderte Produkt vom Funktionsumfang her klein genug, dass man dessen Fertigstellung noch irgendwie mehr schlecht als recht u ¨ber die Runden bringt. Sehr oft allerdings muss man den Kunden erkl¨aren, dass gewisse Features “aus technischen Gr¨ unden gar nicht realisierbar sind” und hoffen, dass diese das akzeptieren. ¨ Noch viel dramatischer wird das Bild, wenn Erweiterungen oder Anderungen eines existenten Softwarepakets gefordert sind. Bei der sauberen Entwicklung ist die Erweiterbarkeit ein Teil des Konzepts und im Regelfall sind neue Features relativ einfach realisierbar. Sollte durch neue Features das Ursprungskonzept ver¨ andert werden m¨ ussen, so ist auch das im Normalfall m¨oglich, wenn auch mit einigem Aufwand verbunden. Das Endprodukt bleibt trotzdem sauber. Ganz im Gegensatz dazu steht die Erweiterbarkeit der Hackl¨osung. Schon ¨ die kleinsten Anderungen dauern enorm lang und destabilisieren die gesamte Software. Sehr oft beobachtet man den Fall, dass in sogenannten BugfixReleases zwar einige bekannte schwere Fehler behoben wurden, daf¨ ur aber mindestens genau so viele neue Fehler eingebaut wurden, da die Auswirkungen von Programm¨ anderungen nicht mehr u ¨berschaubar sind. Vollkommen katastrophal wird es, wenn man die Menge an Source-Code eines sauberen Projekts mit der einer Hackl¨osung vergleicht. Im Normalfall ist die Hackl¨ osung bei gleicher Funktionalit¨at gleich x-Mal so groß wie die saubere L¨ osung. Nicht nur, dass die Codemenge ungleich gr¨oßer ist, auch die Komplexit¨ at des Codes ist durch Seiteneffekte enorm. Es gibt einen ganz bestimmten Grund, warum ich mich u ¨ber saubere und Hackl¨ osungen so ausf¨ uhrlich auslasse: Zur Zeit, zu der OO-Sprachen, insbesondere C++, ihren Eingang in die Industrie fanden, war die Euphorie
8
1. Ziel und Inhalt dieses Buchs
u ¨ber die diesen Sprachen angedichteten Eigenschaften riesig. Pl¨otzlich wurde u ¨berall von Wiederverwendung gesprochen und davon, dass sich dadurch die Entwicklungszyklen drastisch verk¨ urzen. Entsprechend wurden sofort die Entwicklungszeiten k¨ urzer angesetzt als zuvor, allerdings bei einer h¨oheren Anzahl von Features. Man arbeitet ja objektorientiert, und damit geht das alles viel besser und schneller. Leider wurde bei dieser Euphorie etwas u ¨bersehen: OO-Entwicklung will erst richtig gelernt sein und es braucht einige Erfahrung, um ein tragf¨ahiges Konzept zu erstellen! Aber genau die Zeit, die Entwickler brauchen, um die Denkweise vollst¨ andig zu verinnerlichen und die Konzepte hinter dieser Denkweise in vern¨ unftige Softwarekonstrukte umzusetzen, wurde und wird ihnen nicht ausreichend gegeben. Es ist f¨ ur erfahrene C-Entwickler ohne große Probleme m¨ oglich, C++ als Sprache in einigen Tagen, bis zu wenigen Wochen, zu lernen. Es ist allerdings vollkommen unm¨oglich, die Denkmuster innerhalb dieser kurzen Zeit umzustellen. Imperativ zu programmieren ist eben einmal ein ganz anderer Ansatz als objektorientiert zu entwickeln. Vor allem ver¨ andert sich das Design der Software drastisch! Die Syntax von C++ zu beherrschen bedeutet noch lange nicht, objektorientiert C++ zu programmieren. Es ist leicht, C++ zu vergewaltigen, also Klassen und andere Konstrukte zu verwenden und trotzdem im Prinzip rein imperativ zu programmieren. Genau bei einer solchen Arbeitsweise kauft man den gesamten Overhead von C++ gegen¨ uber C ein, ohne die damit verbundenen zus¨atzlichen M¨oglichkeiten zum Vorteil zu n¨ utzen. Polemisch festgestellt: Wenn man mit einem Kleinwagen gut umgehen kann, dann bedeutet dies noch lange nicht, dass man sich einfach in ein Rennauto setzen kann und damit schneller ans Ziel kommt. Die Gefahr, durch das Rennauto u ¨berfordert zu werden, seine Grenzen nicht zu kennen und deshalb gleich einen Unfall zu haben, ist sehr groß. Deshalb kann ich abschließend nur sagen, dass alle Leser dieses Buchs sich so viel Zeit wie m¨ oglich zum Spielen und Probieren nehmen sollten, um C++ und die dahinter liegenden Konzepte zu verstehen, bevor sie sich an C++ im Rahmen einer kommerziellen Entwicklung versuchen.
1.3 Feedback Software wird niemals fertig. Kaum wird eine Version freigegeben, kommen auch schon die n¨ achsten W¨ unsche. Genau dasselbe passiert bei B¨ uchern, die Wissen vermitteln sollen. Es gibt kein Buch, das man nicht noch verbessern k¨onnte und das gilt nat¨ urlich auch (hoffentlich nicht ganz besonders) f¨ ur dieses Buch. Aus diesem Grund gibt es ein Feedback-Forum, u ¨ber das W¨ unsche, Anregungen, Beschwerden, Lob und Tadel an den Autor u ¨bermittelt werden k¨ onnen. Dieses Feedback Forum ist online erreichbar unter der Web-Page zum Buch: http://courses.iicm.edu/SWEntwicklungInCplusplus
1.4 Die beiliegende CD-ROM
9
Ich w¨ urde mich freuen, wenn die eine oder andere Leserin bzw. der eine oder andere Leser dazu beitr¨ agt, dieses Buch zu verbessern, indem sie/er entsprechende Vorschl¨ age macht. Das Feedback-Forum ist aber nicht nur dazu gedacht, Lesern die M¨oglichkeit zu geben, ihre Meinung zum Buch mitzuteilen. Ich sehe das Forum vielmehr als Leserforum, in dem auch eigene Beispiele, Alternativen zu vorgeschlagenen L¨ osungen, etc. ver¨ offentlicht werden k¨onnen und diverse Aspekte der Softwareentwicklung diskutiert werden k¨onnen. Es w¨are sch¨on, wenn das Forum eine Eigendynamik in diese Richtung entwickeln w¨ urde, denn das kommt sicher vielen Leuten zugute.
1.4 Die beiliegende CD-ROM Dem Buch liegt eine CD-ROM bei. Weil der Inhalt einer CD-ROM im Gegensatz zu einem Buch sehr kurzlebig ist, m¨ochte ich an dieser Stelle nur so viel erw¨ahnen: Es sind auf der CD-ROM alle im Buch abgedruckten Programme vorhanden. Was sonst noch auf der CD-ROM zu finden ist und wie man damit arbeitet, kann man erfahren, indem man die Datei index.html mit einem der g¨ angigen Internet-Browser ansieht. Eventuelle Zus¨atze, die sich im Lauf der Zeit nach Auslieferung der CD-ROM als n¨ utzliche Add-ons herausstellen, sind immer aktuell u ¨ber die zuvor erw¨ahnte Web-Page zum Buch abrufbar.
Teil I
Low-Level Konzepte von C++
2. Datentypen und Variablen
Die Verwendung von Variablen ist eines der Grundkonzepte von OO-Sprachen, gleich wie bei imperativen und im Gegensatz zu funktionalen Programmiersprachen. Prinzipiell ist eine Variable ein Datenobjekt, das u ¨ber einen symbolischen Namen (=Identifier ) angesprochen werden kann und dessen Inhalt vom Programm manipuliert werden kann. Zus¨atzlich zum Identifier besitzen Variablen in C++ auch noch einen Datentyp, der u ¨ber ihre Natur Auskunft gibt. Damit wird dem Compiler mitgeteilt, welchen Speicherbedarf eine Variable hat und welche Operationen auf ihr ausf¨ uhrbar sind, bzw. wie gewisse Operatoren in diesem Kontext interpretiert werden m¨ ussen.
2.1 Primitive Datentypen Als sogenannte primitive Datentypen werden die Typen bezeichnet, mit denen ein Computer im Prinzip “von sich aus” umgehen kann. Dies sind verschiedene Arten von Ganzzahlen (=integrale Typen), verschiedene Arten von Gleitkommazahlen (=floating-point Typen) und darstellbare Zeichen (=Characters). In C++ stehen alle primitiven Datentypen zur Verf¨ ugung, die bereits in C existieren. Wie man in der folgenden Tabelle sehen kann, gibt es in C++ auch einen boolschen Datentyp, den man in C vermisst. Ebenso ist wchar_t ein eingebauter Datentyp und nicht, wie in C, ein simples typedef. Typ char wchar_t bool int float double
Bedeutung Ein Character, nimmt ein (¨ ublicherweise) 8 Bit Zeichen auf. Ein wide Character, nimmt ein mindestens (!) 16 Bit Zeichen auf. Ein boolscher Wert, kann einen der Werte true oder false annehmen Ein ganzzahliger Wert in der f¨ ur die jeweilige Maschine “nat¨ urlichen” Gr¨ oße. Eine Gleitkommazahl mit einfacher Genauigkeit. Eine Gleitkommazahl mit doppelter Genauigkeit.
Zum Thema 8 Bit Zeichen in einem char m¨ochte ich noch eine kurze Erg¨anzung liefern: Per Definition ist ein char mindestens 8 Bit lang. Aller-
14
2. Datentypen und Variablen
dings ist auf allen gebr¨ auchlichen Plattformen ein char wirklich genau 8 Bit lang und deshalb werde ich diese Spitzfindigkeit in der Folge nicht mehr weiter beachten. Wie in C gibt es auch noch die folgenden Qualifiers, mit denen man die Eigenschaften bestimmter Grunddatentypen steuern kann: Qualifier signed unsigned short long
Bedeutung vorzeichenbehaftet (normalerweise nicht explizit angegeben) nicht vorzeichenbehaftet “kurz” “lang”
anwendbar auf char, int char, int int int, double
Der Qualifier signed wurde hier nur aus Gr¨ unden der Vollst¨andigkeit angegeben. Im Normalfall wird dieser nicht explizit in Programmen verwendet, da als Default immer angenommen wird, dass es sich um eine vorzeichenbehaftete Zahl handelt. Wenn man die erste Tabelle mit den Grunddatentypen n¨aher betrachtet, dann f¨ allt auf, dass C++ leider ein ganz großes Manko von C geerbt hat: Den Datentypen liegt die “nat¨ urliche” Gr¨oße auf verschiedenen Zielplattformen zugrunde! Man kann nun viel u ¨ber Vor- und Nachteile dieser Spezifikation diskutieren. Tatsache ist, dass oft sogar von erfahrenen Entwicklern immer wieder falsche Annahmen u ¨ber die Gr¨oße von Datentypen getroffen werden. Dies f¨ uhrt dann in manchen F¨allen zu v¨ollig unerkl¨arbaren Fehlern mit manchmal katastrophalen Auswirkungen. Die m¨ oglichen Kombinationen der Grunddatentypen mit entsprechenden Qualifiers ergibt folgendes Gesamtbild: char: Eine Variable vom Typ char kann genau ein Zeichen aus dem g¨ ultigen (¨ ublicherweise) 8 Bit-Zeichensatz der jeweiligen Zielplattform halten. Vorsicht ist geboten, denn es wird keine bindende Aussage getroffen, welcher Zeichensatz auf der Zielplattform g¨ ultig ist. Hier gibt es massive Unterschiede, denn außer Plattformabh¨angigkeiten gibt es auch Abh¨angigkeiten von der nat¨ urliche Sprache (z.B. Deutsch, Englisch), auf die ein Zeichensatz ausgelegt ist. Im Prinzip ist das Einzige, was man einigermaßen sicher sagen kann, dass im Normalfall ein Zeichensatz die Buchstaben a–z, A–Z, die Zahlen 0–9 und die wichtigsten Satzzeichen, wie Punkt, Komma, etc. enth¨ alt. Man kann jedoch nicht davon ausgehen, dass diese Zeichen in verschiedenen Zeichens¨atzen denselben Character-Code besitzen. Man kann nicht einmal davon ausgehen, dass alle m¨oglichen 256 Zeichen tats¨ achlich in einem Zeichensatz belegt sind, dass also ein echter 8 Bit Zeichensatz vorliegt. Oft findet man einen 7 Bit Zeichensatz auf einer Maschine vor, bei dem Characters mit einem Code gr¨oßer als 127 einfach “leer” sind.
2.1 Primitive Datentypen
15
Wenn man sich kurz u ¨berlegt, dass in einem char einfach nur ein Code in Form einer Zahl gespeichert wird, der dann erst bei der Darstellung des entsprechenden Zeichens in einer Zeichensatztabelle “nachgeschlagen” wird, dann erkennt man leicht, was es mit einem char eigentlich im Grunde auf sich hat: F¨ ur den Computer ist er einfach eine (zumeist) 8 Bit lange Ganzzahl. Daher kann man auch mit einem char rechnen wie mit allen anderen integralen Datentypen. Hier ist allerdings allergr¨ oßte Vorsicht geboten: Es gibt keine bindende Definition, ob ein Compiler einen char nun prinzipiell als signed oder unsigned behandelt! Will man also einen char als kleine vorzeichenbehaftete Ganzzahl verwenden, so ist der einzig sichere Weg, den voll ausgeschriebenen Datentyp signed char zu verwenden. In diesem Fall ist dann das Fassungsverm¨ ogen mit einem Wertebereich von -128–127 nicht gerade berauschend, aber je nach Anwendung ist dies ausreichend und wird aus Gr¨ unden der Speicher-Ersparnis verwendet. signed char: Wenn schon bei char nicht gesagt ist, ob dieser auf einer Plattform nun vorzeichenbehaftet ist oder nicht, dann muss man einen vorzeichenbehafteten char eben erzwingen, wenn man unbedingt einen solchen ben¨otigt. Genau dies ist dann der hier vorgestellte signed char mit einem Wertebereich von -128–127. unsigned char: Wo man einen signed char erzwingen kann, gibt es nat¨ urlich auch ein (sicher-)nicht-vorzeichenbehaftetes Pendant dazu: den unsigned char mit dem resultierenden Wertebereich von 0–255. Im Zusammenhang mit der Darstellung von Zeichen aus dem Zeichensatz ist es egal, ob man mit char, signed char oder unsigned char arbeitet. Zum “Nachschlagen” in der Zeichensatztabelle wird er sowieso als nichtvorzeichenbehaftet betrachtet (oder hat schon jemand negative ASCIICodes gesehen? :-)). wchar_t: F¨ ur einen wchar_t gilt dasselbe, was schon u ¨ber char gesagt wurde. Der einzige Unterschied ist, dass es sich hier um einen “Wide” Character handelt, der f¨ ur Unicode und ¨ ahnliche Zeichens¨atze eingesetzt wird und als solcher mindestens 16 Bit lang ist. Sich den Kopf u ¨ber signed und unsigned Varianten desselben zu zerbrechen hat auch nicht u ¨bertrieben viel Sinn, denn zum Rechnen stehen in diesem Gr¨oßenbereich bereits die “echten” Ganzzahlen zur Verf¨ ugung (z.B. short). bool: Der Datentyp bool ist ein primitiver Typ, der in C++ erst vor wenigen Jahren eingef¨ uhrt und zum Standard erkl¨art wurde. In C und in fr¨ uhen C++ Implementationen gab es diesen Datentyp noch nicht, allerdings gab es auch damals schon starke Bestrebungen, einen solchen einzuf¨ uhren. Aus diesem Grund findet man in vielen a¨lteren Standard
16
2. Datentypen und Variablen
Libraries noch verschiedenste Typdefinitionen f¨ ur einen boolschen Wert, die von boolean u urlich ¨ber Boolean und BOOL bis zu bool reichen. Nat¨ gab es auch die entsprechenden Definitionen der Wahrheitswerte dazu, die ebenfalls nicht standardisiert waren. Beliebige Schreibweisen wie z.B. TRUE/FALSE f¨ ur die Wahrheitswerte existierten. Um diesem Wildwuchs Einhalt zu gebieten, wurde der Datentyp bool mit den dazugeh¨ origen Wahrheitswerten true und false in den C++ Standard aufgenommen. Wo auch immer man also explizit einen boolschen Wert braucht, wird unbedingt die Verwendung dieses Typs empfohlen. Rekapituliert man kurz die Definition von C, die auch C++ vollinhaltlich zugrunde liegt, wann etwas als wahr bzw. falsch interpretiert wird, dann kommt man schnell auf des Pudels Kern, wie ein bool intern funktioniert: • Ein Ganzzahlenwert von 0 wird als false interpretiert. • Jeder Ganzzahlenwert ungleich 0 wird als true interpretiert. Der Datentyp bool ist in Wirklichkeit hinter den Kulissen ein Ganzzahlendatentyp. Es ist f¨ ur ihn keine bestimmte Gr¨oße vorgeschrieben. ¨ Ublicherweise wird ein bool intern durch einen char oder auch einen short (siehe unten) repr¨ asentiert. Per Definition ist false die Ganzzahl 0. Weil nun f¨ ur true das ganze Spektrum aller m¨ oglichen Zahlen ungleich 0 zur Verf¨ ugung steht, einigte man sich darauf, true durch die Ganzzahl 1 zu repr¨asentieren. Wo auch immer Variablen vom Typ bool in Rechenoperationen verwendet werden (ja, das funktioniert, bool ist ja eine Ganzzahl!), werden sie implizit zu int (siehe unten) umgewandelt. Eine Zuweisung von einer Ganzzahl auf einen bool f¨ uhrt dazu, dass ein Wert von 0 zu false wird (was sonst, false ist ja auch 0 :-)) und jeder Wert ungleich 0 wird konvertiert zu true (also einfach zu 1). Verwirrend? Keine Sorge, wir werden in der Folge noch ein Beispiel betrachten, in dem diese Zusammenh¨ange genau demonstriert werden. Das Einzige, was es mit bool, true und false auf sich hat, ist, dass hier ein eigener Datentyp eingef¨ uhrt wurde und die Wahrheitswerte im Endeffekt genau definiert auf 0 und 1 abgebildet werden, anstatt auf 0 und “irgendwas ungleich 0”, um dem existierenden Wildwuchs ein Ende zu setzen. int: Der Datentyp int (oder ganz genau signed int, diese Schreibweise ist aber nicht u asentiert den vorzeichenbehafteten Standard¨blich) repr¨ Ganzzahlentyp auf der jeweiligen Zielplattform. Es gibt keine Regel, wie groß denn nun ein int tats¨ achlich ist. Heute u ¨blich ist eine Gr¨oße von 32 Bit, verlassen darf man sich darauf allerdings niemals! Bis vor nicht allzu langer Zeit waren 16 Bit f¨ ur einen int auf PC-Systemen durchaus u ¨blich. Was passiert, wenn man sich darauf verl¨asst, dass 32 Bit zur Verf¨ ugung stehen (Wertebereich ca. -2 Mrd.–2 Mrd.) und pl¨otzlich
2.1 Primitive Datentypen
17
hat man aber nur noch 16 Bits zum Speichern einer Zahl (Wertebereich -32768–32767), das kann man sich sicherlich ausmalen. unsigned int bzw. unsigned: Der Datentyp unsigned (=¨ ubliche Kurzform f¨ ur unsigned int) unterliegt genau denselben Gesetzen wie sein vorzeichenbehaftetes Pendant: Sein Fassungsverm¨ ogen ist maschinenabh¨angig. Allerdings ist ein unsigned auf einer Zielplattform garantiert immer gleich lang wie ein int, nur die Interpretation ist verschieden: Ein unsigned wird immer als positive Ganzzahl betrachtet, also ohne Vorzeichenbit. Damit kann er auf Kosten der wegfallenden negativen Zahlen “doppelt so große” positive Zahlen speichern (0 gilt auch als positive Zahl!) wie sein vorzeichenbehafteter Bruder. short int bzw. short: Der Datentyp short (=¨ ubliche Kurzform f¨ ur short int) bezeichnet einen “kurzen” int. Tolle und unglaublich genaue Aussage, oder :-)? Leider kann wirklich nichts allzu Genaues dar¨ uber gesagt werden. Das Einzige, was per Definition garantiert ist, ist Folgendes: Ein short ist eine vorzeichenbehaftete Ganzzahl, deren Gr¨ oße kleiner oder gleich der Gr¨ oße eines int ist. Zum Gl¨ uck kann man die Aussage zumindest ein kleines Bisschen pr¨azisieren: Es wird auch garantiert, dass ein short mindestens 16 Bit lang ist. unsigned short int bzw. unsigned short: Genauso toll, wie die Aussage u ¨ber short ausgefallen ist, f¨allt sie auch f¨ ur unsigned short (=¨ ubliche Kurzform f¨ ur unsigned short int) aus: Ein unsigned short bezeichnet einen “kurzen” unsigned. Garantieren kann man wieder nur: Ein unsigned short ist eine positive Ganzzahl, deren Gr¨ oße kleiner oder gleich der Gr¨ oße eines unsigned ist. Auch hier gilt nat¨ urlich wieder, dass die L¨ ange mindestens 16 Bit betr¨ agt. long int bzw. long: Weil wir gerade bei den epochalen Erkenntnissen waren, machen wir damit gleich weiter: Ein long (=¨ ubliche Kurzform f¨ ur long int) bezeichnet einen “langen” int, f¨ ur den Folgendes garantiert ist: Ein long ist eine vorzeichenbehaftete Ganzzahl, deren Fassungsverm¨ ogen gr¨ oßer oder gleich dem eines int ist. Zum Gl¨ uck kann man auch diese Aussage zumindest ein kleines Bisschen pr¨azisieren: Es wird weiters garantiert, dass ein long mindestens 32 Bit lang ist. unsigned long int bzw. unsigned long: Dass nun f¨ ur einen unsigned long (=¨ ubliche Kurzform f¨ ur unsigned long int) Folgendes gilt, l¨aßt sich leicht erraten: Ein unsigned long ist eine positive Ganzzahl, deren Fassungsverm¨ ogen gr¨ oßer oder gleich dem eines unsigned ist. Die Garantie f¨ ur die Mindestl¨ ange von 32 Bit gilt nat¨ urlich hier ebenfalls. long long int bzw. long long:
18
2. Datentypen und Variablen
Je nach Plattform gibt es auch einen “besonders langen” long Wert. Seine Existenz ist allerdings vom Compiler abh¨angig. Hier hilft leider nur ausprobieren. Wenn er definiert ist, so kann man Folgendes garantiert sagen: Ein long long ist eine vorzeichenbehaftete Ganzzahl, deren Fassungsverm¨ ogen gr¨ oßer oder gleich dem eines long ist. unsigned long long int bzw. unsigned long long: Bez¨ uglich der Existenz eines unsigned long long gilt dasselbe, wie f¨ ur den long long Datentyp zuvor. Wenn er definiert ist, so ist Folgendes garantiert: Ein unsigned long long ist eine positive Ganzzahl, deren Fassungsverm¨ ogen gr¨ oßer oder gleich dem eines unsigned long ist. float: Wie die Ganzzahl-Datentypen, so sind auch die Gleitkomma-Datentypen von C++ maschinenabh¨ angig: float repr¨asentiert eine vorzeichenbehaftete Gleitkommazahl mit einfacher Genauigkeit (=single Precision), was auch immer das heißt. Ich m¨ ochte es einfach so formulieren: Arbeitet man in einem Programm mit Gleitkommazahlen, deren Gr¨oße sich in einem Bereich von in etwa ±1015 –±1035 bewegt und bei denen Rundungsfehler keine besondere Rolle spielen, dann ist die Verwendung von float normalerweise ausreichend. Will man ganz genau u ¨ber das Fassungsverm¨ ogen, die Genauigkeit und andere Eigenschaften eines float und auch anderer Datentypen Bescheid wissen, so kann man dies u ¨ber die sogenannten numeric_limits tun. Da es sich hierbei um eine objektorientierte Implementation handelt, wird die Besprechung auf Abschnitt 16.7 verschoben. Nicht-vorzeichenbehaftete Gleitkommazahlen (also unsigned float) gibt es nicht. Aufgrund der Natur dieser Zahlen w¨aren diese auch nicht besonders sinnvoll. double: Ein double repr¨ asentiert eine vorzeichenbehaftete Gleitkommazahl mit doppelter Genauigkeit (=double Precision), was auch immer man darunter nun versteht. Als kleinen Anhaltspunkt m¨ochte ich eigentlich nur erw¨ ahnen, dass double u ¨blicherweise vom Wertebereich (ca. ±10300 ) als auch von der Anf¨ alligkeit gegen¨ uber Rundungsfehlern her f¨ ur g¨angige Gleitkomma-Anwendungen brauchbar ist. Auch hier sind, wie bei float, alle Eigenschaften u ¨ber die numeric_limits herausfindbar. long double: Wo auch immer double nicht ausreichend ist, kann man auf long double zur¨ uckgreifen, der eine Gleitkommazahl mit erweiterter Genauigkeit (=extended Precision) bezeichnet. Hier wird es allerdings gleich noch etwas schwammiger als bei float und double zuvor, denn Genaues u ¨ber seine Natur und sein Fassungsverm¨ogen erf¨ahrt man wirklich nur noch durch die entsprechenden Abfragen von numeric_limits.
2.1 Primitive Datentypen
19
Leser, die ihr Grundwissen u ¨ber primitive Datentypen (auch Gleitkommazahlen), Besonderheiten derselben und die interne Repr¨asentation derselben kurz durch eine viel ausf¨ uhrlichere Erkl¨arung auffrischen m¨ochten, finden eine genaue Behandlung dieses Themas in Kapitel 4 und auch in Anhang A des Buchs Softwareentwicklung in C. Dort wird auch anhand von Beispielen die besondere Gefahr beim Mischen von Datentypen sowie von signed und unsigned-Varianten desselben Typs aufgezeigt. Alle diese Betrachtungen sind nicht nur f¨ ur C sondern auch f¨ ur C++ zu 100% g¨ ultig! Leider findet man immer wieder dieselben Fehler beim Umgang mit Variablen verschiedener Datentypen, egal, ob sie nun von Neulingen begangen werden oder ob es sich um erfahrenere Entwickler handelt. Die Ursache vieler Fehler ist sicherlich darin zu finden, dass Datentypen einfach einmal nach dem Motto “es gibt sie und sie funktionieren” betrachtet werden. Die genauen Interna werden als “das weiß sowieso der Computer” abgetan. Ich m¨ochte wirklich allen Lesern ans Herz legen, sich zumindest einmal mit den Interna genau auseinanderzusetzen, um das notwendige Verst¨andnis f¨ ur potentielle Fehlerquellen zu entwickeln. Aus diesem Grund m¨ochte ich dieses Kapitel auch mit ein paar Hinweisen auf m¨ ogliche Fallen schließen: Vorsicht Falle: Es d¨ urfen niemals Annahmen u ¨ber die Gr¨oße und das damit verbundene Fassungsverm¨ ogen von Datentypen getroffen werden. Insbesondere ist das Einzige, was man in Bezug auf die Gr¨oße von Ganzzahlendatentypen garantieren kann, Folgendes: sizeof(short) <= sizeof(int) <= sizeof(long) Anmerkung f¨ ur Leser, die C noch nicht beherrschen: sizeof liefert den ben¨otigten Speicherplatz f¨ ur einen Datentyp in Bytes. Es wird zu einem sp¨ateren Zeitpunkt noch genauer darauf eingegangen. Vorsicht Falle: Das Mischen von verschiedenen Datentypen (short, int, long, ...) in Operationen ist so definiert, dass der Compiler immer intern alles in den “m¨ achtigsten” Datentyp umwandelt. Wenn das Ergebnis einer Operation allerdings in einer Variable eines “weniger m¨achtigen” Typs gespeichert werden soll, dann kann dies zu abstrusesten Ergebnissen durch Overflow- und Underflow-Situationen f¨ uhren. Vorsicht Falle: Das Mischen von signed und unsigned-Varianten ein und desselben Datentyps in Operationen ist gef¨ahrlich, da diese Varianten trotz ihres gleichen Speicherbedarfs einen unterschiedlichen Wertebereich haben. Overflows und Underflows sind leider nur allzu schnell passiert und schon gibt es die lustigsten Effekte.
20
2. Datentypen und Variablen
Vorsicht Falle: Annahmen u ¨ber den zugrunde liegenden Zeichensatz auf einer Plattform sind nicht zul¨ assig. Aus diesem Grund sollen niemals Characters im Programm hardcodiert durch ihren Character-Code definiert werden. Weiters ist keinesfalls garantiert, dass es im Zeichensatz des Zielsystems bestimmte Sonderzeichen (z.B. deutsche Umlaute) u ¨berhaupt gibt. Das Thema der Zeichens¨ atze ist leider ohnehin ein sehr Leidiges, da gerade hier praktisch seit der Erfindung der Computer viele parallele “Standards” existieren.
2.2 Deklaration, Definition und Initialisierung Nachdem jetzt zumindest bekannt ist, welche primitiven Datentypen f¨ ur Variablen zur Verf¨ ugung stehen, wird es Zeit, diese auch wirklich zu verwenden. Dazu m¨ ochte ich gleich zu Beginn eine Definition der Begriffe Deklaration, Definition und Initialisierung anf¨ uhren, denn diese Begriffe werden fast durchgehend in der Literatur v¨ ollig durchmischt und nur allzu oft g¨anzlich falsch verwendet. Deklaration: Etwas zu deklarieren bedeutet, einen Hinweis zu geben, dass es “irgendwo” so etwas gibt. Im Falle einer Variable bedeutet damit eine Deklaration einfach nur einen Hinweis an den Compiler: Es gibt eine Variable unter einem bestimmten Namen mit einem bestimmten Typ. Sollte genau dieser Variablenname irgendwo verwendet werden, dann weißt du, wie sie zu behandeln ist. Im Falle einer Variable bedeutet deklarieren definitiv nicht, sie anzulegen und f¨ ur sie Speicher zu reservieren, obwohl sich diese Fehlverwendung des Begriffs durch die Literatur durchzieht. Speicher reservieren, etc. ist eine Definition! Definition: Etwas zu definieren bedeutet, es tats¨achlich mit allen Konsequenzen anzulegen. Im Falle einer Variable wird z.B. intern der Code eingesetzt, der entsprechenden Speicherplatz reserviert, etc. Einer der Hauptgr¨ unde, warum die Begriffe Deklaration und Definition so oft vermischt werden, ist, weil in den meisten Programmiersprachen, so auch in C++, die Deklaration nicht explizit stattfinden muss (aber kann!), sondern die Deklaration auch implizit bei der Definition passieren kann. Initialisierung: Etwas zu initialisieren bedeutet, ihm beim Anlegen einen Startwert zuzuweisen. Dies kann in den meisten Programmiersprachen, auch in C++, im Rahmen einer Definition geschehen, aber niemals bei einer expliziten Deklaration. Im Prinzip sonnenklar, denn wo nichts angelegt wird, dort kann auch nichts zugewiesen werden!
2.2 Deklaration, Definition und Initialisierung
21
In C++ wurde auch der Begriff der extern-Deklaration f¨ ur die Deklaration von Variablen von C u ¨bernommen. Zum Thema der expliziten Deklaration von Variablen in C findet sich in Kapitel 17 im Buch Softwareentwicklung in C eine ausf¨ uhrlichere Diskussion, die auch f¨ ur C++ volle G¨ ultigkeit hat. Explizite Deklaration von Variablen ist nur notwendig, wenn eine Variable in einem anderen File verwendet werden soll als in dem, in dem sie definiert wurde. Der Grund ist einfach: Beim u ¨bersetzen “sieht” der Compiler immer nur ein File nach dem anderen. Erst der Linker, als letztes Glied bei der Programm¨ ubersetzung, sieht alle Files und verkn¨ upft sie zu einem Gesamtprogramm. Nun muss in einem solchen Fall also dem Compiler mitgeteilt werden, dass es eine Variable unter einem bestimmten Namen und mit einem bestimmten Typ “irgendwo anders” gibt. Selbstredend muss eine Deklaration f¨ ur eine Variable denselben Typ angeben, wie die Definition, ansonsten beschwert sich der Compiler. Wie sich weiters leicht erraten l¨asst, darf es in einem Programm, egal aus wie vielen Files es besteht, immer nur genau eine Definition und beliebig viele Deklarationen geben. Der soeben beschriebene Fall, der eine explizite Deklaration notwendig macht, ist allerdings nicht der Regelfall. In sauber strukturiertem Code passiert es sehr selten bis u ¨berhaupt nicht, dass globale Variablen existieren, die aus mehreren Files heraus sichtbar sein m¨ ussen. Der Normalfall in C++ ist der, dass Variablen in einem einzigen Schritt deklariert und gleichzeitig definiert werden. Dies geschieht, gleich wie in C, in der folgenden Form: <[qualifier] type>
; Es wird also zuerst der Typ (mit optionalem Qualifier), gefolgt vom gew¨ unschten (hoffentlich sprechenden!) Variablennamen angegeben. Beispiele f¨ ur g¨ ultige Variablendefinitionen w¨aren: int count; unsigned index; long x_coordinate; Es gibt auch die M¨ oglichkeit, mehrere Variablen desselben Typs gleich gemeinsam in einem Statement zu definieren, indem man die einzelnen Variablennamen durch einen Beistrich trennt. Ein Beispiel hierf¨ ur w¨are: bool connected, sending_data, receiving_data; Allerdings m¨ ochte ich von dieser Art der Definition aus Gr¨ unden der Lesbarkeit des endg¨ ultigen Programms dringend abraten! Es tut nicht weh, wenn ein Programm um wenige Zeilen l¨ anger wird, aber es hilft, auf einen Blick alles zu finden. Wie sp¨ ater noch n¨ aher beleuchtet werden wird, gibt es einen sehr wichtigen Unterschied zwischen C und C++, was den erlaubten Ort von Variablendefinitionen angeht. In C durften Variablen ausschließlich zu Beginn eines Blocks definiert werden. Im Gegensatz dazu d¨ urfen Variablen in C++ prinzipiell beliebig im laufenden Code definiert werden, was den resultierenden Code deutlich lesbarer machen kann. Neben der erh¨ohten Lesbarkeit gibt es,
22
2. Datentypen und Variablen
je nach Situation, auch eine Performancesteigerung zu erw¨ahnen. Denn wo keine Variable angelegt (und initialisiert) wird, dort wird auch kein Speicher und keine Rechenleistung daf¨ ur verbraucht. Ist man tats¨ achlich gezwungen, Variablen aus bereits genannten Gr¨ unden explizit zu deklarieren, so geschieht dies durch Voranstellen des Keywords extern. Das Statement extern bool master_running; stellt eine solche explizite Deklaration dar. Durch diese wird dem Compiler nur mitgeteilt, dass es “irgendwo” die Variable namens master_running gibt, und dass sie den Typ bool hat. Eine explizite Initialisierung von Variablen kann zusammen mit deren Definition vorgenommen werden, indem man ihnen gleich direkt einen Wert zuweist (ich setze hier mutigerweise das = Zeichen als Zuweisungsoperator als bekannt voraus :-)). Beispiele f¨ ur g¨ ultige Definitionen von Variablen, die gleich eine Initialisierung beinhalten, w¨aren: int count = 0; unsigned long x_coord = 0, y_coord = 0; F¨ ur den Fall, dass keine explizite Initialisierung wie eben beschrieben vorgenommen wird, verh¨ alt sich C++ anders (und besser!) als C. Es wird n¨amlich unter bestimmten Umst¨ anden eine implizite Initialisierung durchgef¨ uhrt, was in C nicht der Fall ist. Diese bestimmten Umst¨ ande, die zu einer impliziten Initialisierung f¨ uhren, sind Folgende: • Globale Variablen werden auf ihre entsprechenden Nullwerte initialisiert. • Als static definierte lokale Variablen werden auf ihre entsprechenden Nullwerte initialisiert. Leser, denen nicht aus C gel¨ aufig ist, was static prinzipiell bewirkt, m¨ochte ich auf Abschnitt 17.3 von Softwareentwicklung in C hinweisen. • Namespace Variablen werden auf ihre entsprechenden Nullwerte initialisiert (was Namespace Variablen genau sind, wird in Kapitel 14 noch erkl¨art). Entsprechende Nullwerte im Sinne der Initialisierung bedeutet, dass Zahlenvariablen auf 0 (bzw. 0.0 bei Gleitkommazahlen), Pointer auf 0 und boolVariablen auf false initialisiert werden. Vorsicht Falle: Lokale Variablen (=auto-Variablen) werden nicht implizit initialisiert. Einer der h¨ aufigsten und am schwierigsten zu lokalisierenden Fehler ist es, Variablen nicht zu initialisieren, ihnen auch danach keinen Wert zuzuweisen, dann aber ihren Inhalt auszulesen! Dieser Inhalt ist dann “irgendein” Wert. Das Problem, das einen solchen Fehler so schwer lokalisierbar macht, ist der Umstand, dass ziemlich oft tats¨achlich zuf¨allig (durch Interna des Systems) ein Nullwert in der Variable steht. Damit kann es passieren,
2.2 Deklaration, Definition und Initialisierung
23
dass Programme lange Zeit scheinbar fehlerlos arbeiten, bis pl¨otzlich nach ¨ einer kleinen Anderung an irgendeiner Stelle im Programm die unerkl¨arlichsten Dinge passieren, obwohl der ge¨ anderte Teil und der verr¨ uckt spielende Teil im Prinzip gar nichts miteinander zu tun haben. Wenn so etwas unter dem u ¨blichen Zeitdruck bei der Softwareentwicklung passiert, freuen sich die Hersteller von Kaffeeautomaten und die Pizzalieferanten, denn die sp¨atn¨achtliche Debugging-Session ist damit garantiert (mit etwas Gl¨ uck ist es nur eine Nacht :-)). Da das Thema der internen Abwicklung der Initialisierung mit dem jetzigen Wissensstand noch nicht ersch¨opfend abgehandelt werden kann, vor allem da es im Kontext mit den OO-Features von C++ noch einiges dazu zu sagen gibt, m¨ ochte ich es im Augenblick bei diesen grunds¨atzlichen Aussagen belassen. Wo auch immer es wichtige weitere Fakten dazu gibt, werden diese erg¨anzend angef¨ uhrt. Eine Kleinigkeit, die zwar auch bei der Initialisierung gebraucht wird, aber im Prinzip ein eigenes Thema ist, sind die sogenannten Literals. Als Literals werden explizite konstante Zahlen- (z.B. 17, 12.5, etc.) und andere Werte (z.B. ’a’, true) bezeichnet. Ich m¨ochte das Thema dieser besonderen Konstanten bzw. Literals hier nur kurz und beispielhaft behandeln, da es im Prinzip v¨ollig intuitiv ist. Leser, die sich nach dieser kurzen Beschreibung vielleicht doch nicht so sicher f¨ uhlen, m¨ochte ich auf Kapitel 4 aus Softwareentwicklung in C verweisen. Leser, die mit dem oktalen oder dem hexadezimalen Zahlensystem noch nicht vertraut sind, finden zus¨ atzliche Information dazu in Abschnitt A.2.2 im Buch Softwareentwicklung in C. Ganzzahlen-Literals: Diese sind im Kontext mit allen verschiedenen Variationen von Ganzzahl-Datentypen verwendbar, also f¨ ur int, short, unsigned, etc., nat¨ urlich auch f¨ ur char bzw. unsigned char und, last, but not least auch bei bool. Bei Verwendung im Kontext mit bool gibt es noch ein paar Kleinigkeiten zu wissen, ein Beispiel dazu findet sich weiter unten. Die u ur die meisten Belange die beste ist, ¨blichste Schreibweise, die f¨ ist die v¨ ollig intuitive Dezimalschreibweise: Man schreibt einfach die gew¨ unschte Zahl, also z.B. 12, 348. Neben dieser Schreibweise gibt es aber noch zwei besondere Schreibweisen, n¨amlich die hexadezimale (=zur Basis 16) und die oktale (=zur Basis 8). Oktalzahlen werden durch eine vorangestellte 0 gekennzeichnet, Hexadezimalzahlen (kurz: hex-Zahlen) durch das Pr¨ afix 0x. Die folgenden Literals sind also im Prinzip gleichwertig: 18, 0x12 und 022, denn die letzteren beiden sind nur andere Darstellungen von 18 im hexadezimalen bzw. oktalen Zahlensystem. Will man ein Ganzzahlen-Literal explizit als unsigned kennzeichnen, so gibt man ihm das Suffix U bzw. u. Will man explizit ein long-Literal, so
24
2. Datentypen und Variablen
ist das Suffix L bzw. l angebracht. Also bezeichnet z.B. 18U oder auch 18u explizit einen unsigned Wert, wogegen 18 vom Compiler als int betrachtet wird. Durch 18L wird dann entsprechend eine Interpretation als long erzwungen. Nat¨ urlich funktioniert die Kombination 18UL zum Erzwingen einer Interpretation als unsigned long genauso. Wenn man, wie es in der Praxis der Regelfall ist, explizit keine Interpretation als bestimmten Datentyp erzwingt, so “sch¨atzt” der Compiler, was die sinnvollste Interpretation w¨are und handelt entsprechend. Boolean-Literals: Wie zu erwarten sind die Boolean-Literals einfach als true bzw. false hinschreibbar, ohne dass irgendwelche besonderen Konventionen, wie z.B. Anf¨ uhrungszeichen vonn¨oten w¨aren. Character-Literals: Ein Character-Literal wird einfach als entsprechender Character, eingeschlossen in einfache Anf¨ uhrungszeichen dargestellt (z.B. ’x’ steht f¨ ur den Character x). Das Umschließen mit einfachen Anf¨ uhrungszeichen ist notwendig, denn woher sollte sonst der Compiler zwischen einem Buchstaben und z.B. einer Variable mit einem einbuchstabigen Namen unterscheiden k¨ onnen (abgesehen davon, dass einbuchstabige Namen sowieso nicht erw¨ unscht sind :-))? Nun gibt es auch Characters, die nicht so einfach als druckbare (!) Einzelzeichen zur Verf¨ ugung stehen, wie z.B. ein Zeilenumbruch oder ein Tabulator. F¨ ur diesen Fall gibt es die sogenannten Escape-Sequenzen, die aus einem \ (=Backslash), gefolgt von zumindest einem Zeichen bestehen. Ein typischer Vertreter davon ist z.B. ’\n’, was f¨ ur einen Zeilenumbruch steht, oder ’\\’, was das Backslash-Zeichen selbst darstellt. Steht man vor dem Problem, dass man ein Zeichen als char-Literal schreiben will, das weder direkt darstellbar ist, noch u ¨ber eine vordefinierte Escape-Sequenz zur Verf¨ ugung steht, so kann man auch direkt dessen numerischen Character-Code in einer Escape-Sequenz verwenden. Diese Sequenz hat dann die Form ’\ddd’, wobei ddd f¨ ur eine dreistellige Oktalzahl steht. Wide-Character-Literals: Das einzige Problem bei diesen Literals ist, dem Compiler klar zu machen, dass er es nicht mit einem normalen char zu tun hat, sondern mit einem wchar_t. Dies geschieht durch Voranstellen von L vor das Literal. Z.B. w¨ urde also L’x’ den Unicode Buchstaben x bezeichnen. Im Vorgriff m¨ ochte ich hier auch noch gleich erw¨ahnen, dass diese Regel auch f¨ ur Strings gilt: Man stellt ihnen ein L voran. Wenn also ein normaler String z.B. als "otto" geschrieben wird, so wird sein Unicode-Pendant als L"otto" im Code verewigt. Gleitkomma-Literals: Auch diese Literals sind v¨ollig intuitiv, z.B. stellen 17.3 oder 2.0 g¨ ultige Gleitkommazahlen dar. Die “wissenschaftliche” Schreibweise, z.B. 0.173e2 (steht f¨ ur 0.173 ∗ 102 ) oder 14.93e-15 (steht −15 f¨ ur 14.93 ∗ 10 ) ist zul¨ assig. Hierbei ist darauf zu achten, dass keine Leerzeichen im Literal vorkommen. Z.B. w¨are 14.93 e -15 ung¨ ultig!
2.3 Das erste C++ Programm
25
Prinzipiell werden Gleitkomma-Literals vom Compiler als double interpretiert, jedoch kann man wie bei Ganzzahl-Literals auch eine andere Interpretation (als float) erzwingen, sollte dies gew¨ unscht sein. Man muss dazu nur das Suffix F bzw. f verwenden, also z.B. 2.5f. Vorsicht Falle: Vor allem im deutschsprachigen Raum gibt es manchmal das eine oder andere Problem bei Neulingen: Das Dezimaltrennzeichen bei Gleitkommazahlen ist in C++ immer ein Punkt! Ein Komma wird vom Compiler nicht akzeptiert.
2.3 Das erste C++ Programm Genug der grauen Theorie, es wird nun wirklich Zeit f¨ ur ein kleines Beispiel, das die bisher diskutierten Dinge zusammenfasst. Dazu schreiben wir ein C++ Programm mit dem Namen var_demo.cpp. Ein kleiner Exkurs: Historisch gesehen haben sich f¨ ur C++ Programme die Extensions cpp, cc und C (im Gegensatz zu c f¨ ur C-Programmen) eingeb¨ urgert. Welche Extension man nun verwendet, ist im Prinzip Geschmackssache. Ich war selbst aus mehreren Gr¨ unden immer ein Anh¨anger von C, aber mittlerweile setzt sich cpp auf allen Plattformen als Standard durch. Leider gibt es im Augenblick auch weit verbreitete Compiler gewisser Hersteller, die sich absolut weigern, Programme mit einem C als Extension als C++ Programme anzuerkennen. Das allerdings ist definitiv in meinen Augen nicht akzeptabel. Wie dem auch sei, im Rahmen dieses Buchs wird durchgehend cpp als Extension verwendet. Lesern, die bisher noch nie ein C-Programm gesehen haben, m¨ochte ich die Lekt¨ ure von Kapitel 3 aus Softwareentwicklung in C sehr ans Herz legen, bevor wir zum ersten C++ Programm kommen. Das File var_demo.cpp hat folgenden Inhalt (man bemerke: Das erste Programm heißt nicht hello_world.cpp :-)): 1
// var demo . cpp − demo program to show the behaviour o f C++ V a r i a b l e s
2 3
#include < i o s t r e a m>
4 5 6
using s t d : : cout ; using s t d : : e n d l ;
7 8
void aFunction ( ) ; // f u n c t i o n d e c l a r a t i o n
9 10 11
int a g l o b a l v a r = 1 7 ; // e x p l i c i t l y i n i t i a l i z e d int a n o t h e r g l o b a l v a r ; // i m p l i c i t l y i n i t i a l i z e d to 0
12 13
int main ( int a r g c , char ∗ argv [ ] )
26
14
2. Datentypen und Variablen
{ aFunction ( ) ;
15 16
cout << ” a g l o b a l v a r : ” << a g l o b a l v a r << e n d l ; cout << ” a n o t h e r g l o b a l v a r : ” << a n o t h e r g l o b a l v a r
17 18
<< e n d l ;
19
bool a b o o l e a n v a r = true ; cout << ” b o o l s e t to t r u e : ” << a b o o l e a n v a r << e n d l ; a boolean var = false ; cout << ” b o o l s e t to f a l s e : ” << a b o o l e a n v a r << e nd l ; a boolean var = 17; cout << ” b o o l from i n t : ” << a b o o l e a n v a r << e nd l ;
20 21 22 23 24 25 26
int a n i n t e g e r v a r = a b o o l e a n v a r ; // i m p l i c i t c o n v e r s i o n cout << ” b o o l to i n t : ” << a n i n t e g e r v a r << e nd l ;
27 28 29
char t e s t c h a r = 2 5 5 ; int t e s t i n t = t e s t c h a r ; cout << ” s i g n e d (−1) or unsigned ( 2 5 5 ) char ? ” << t e s t i n t << e n d l ;
30 31 32 33
aFunction ( ) ;
34 35
return ( 0 ) ;
36 37
}
38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void aFunction ( ) { s t a t i c int c a l l c o u n t e r ; // i m p l i c i t l y i n i t i a l i z e d to 0
43
cout << ” c a l l c o u n t e r : ” << ++c a l l c o u n t e r << e nd l ;
44 45
int u n i n i t i a l i z e d a u t o v a r ; // t h i s one i s a problem ! cout << ” u n i n i t i a l i z e d var : ” << u n i n i t i a l i z e d a u t o v a r << e n d l ;
46 47 48
}
Dieses Programm compilieren wir nun, wobei der Aufruf des Compilers je nach verwendeter Entwicklungsumgebung verschieden ist. Mit dem GNU C++ Compiler (egal ob unter einem beliebigen Unix oder MS-Windows xx) lautet der Aufruf: g++ -Wall -o var_demo var_demo.cpp Wobei g++ der Compiler ist, -Wall ist der Compiler-Switch, der bewirkt, dass strengst m¨ oglich alle Warnings ausgegeben werden, -o bestimmt, wie das Executable heißen soll (bei uns var_demo) und var_demo.cpp ist schließlich das C++ File, das compiliert werden soll. ¨ Zwei kleine Anmerkungen noch, um Uberraschungen zu vermeiden: 1. Je nach installierter Version des GNU C++ Compilers kann der Aufruf auch gcc statt g++ lauten. 2. Ebenso wieder abh¨ angig von der Version des Compilers kann es unter MS-Windows xx notwendig sein, das Output File statt als -o var_demo als -o var_demo.exe anzugeben. Das compilierte Programm wird mit dem Aufruf var_demo gestartet und sollte in etwa folgenden Output liefern: call counter : 1 u n i n i t i a l i z e d var : 1 0 7 3 7 9 6 1 7 6
2.3 Das erste C++ Programm
27
a global var : 17 another global var : 0 b o o l s e t to t r u e : 1 b o o l s e t to f a l s e : 0 b o o l from i n t : 1 b o o l to i n t : 1 s i g n e d (−1) or unsigned ( 2 5 5 ) char ? −1 call counter : 2 u n i n i t i a l i z e d var : 1 3 4 5 1 3 9 8 0
Der hier inkludierte Output stammt von einem 32 Bit Linux System. Die Zeilen, die die Werte der uninitialisierten Variablen anzeigen, k¨onnen nat¨ urlich bei jedem neuen Programmlauf und je nach System verschiedensten Output liefern. Die Zeile, in der ausgegeben wird, ob wir es auf unserem System implizit mit signed oder unsigned Characters zu tun haben, ist nat¨ urlich von System zu System verschieden. Auf den meisten Systemen wird allerdings -1 ausgegeben werden. B¨osartigerweise habe ich ein wenig vorgegriffen und im Programm bereits ein C++ Konstrukt verwendet, das erst im OO-Teil des Buchs genau besprochen werden wird: Den Standard-Output-Stream (namens cout) zur Bildschirmausgabe. Aber keine Panik, dieser Stream erleichtert unser Leben als Entwickler deutlich und es ist auch ganz einfach, damit umzugehen. F¨ uhren wir also das Programm einmal h¨appchenweise einer Analyse zu: • Bevor wir zu den technischen Details kommen, muss ich noch kurz etwas anmerken: Leser, denen z.B. die Namen der globalen Variablen komisch vorkommen, weil sie mit einem Underline enden, m¨ochte ich kurz auf den Coding Standard in Anhang A verweisen, der den Beispielen in diesem Buch zugrunde liegt. • In Zeile 1 sehen wir die typische Schreibweise von Kommentaren in C++: Ein Kommentar beginnt mit // irgendwo in der Zeile und endet automatisch mit dem Ende der Zeile. In C++ kann auch die C-Schreibweise der Block-Kommentare verwendet werden, die mit /* begonnen und mit */ beendet wird. Dies wird allerdings nicht empfohlen, da Schachtelungen dieser Bl¨ocke nicht erlaubt sind und dies zu Problemen f¨ uhren kann. Eine genaue Diskussion zu diesem Thema findet sich in Softwareentwicklung in C in Kapitel 4. • Das #include-Statement in Zeile 3 bewirkt, dass Entwickler, die StandardC gewohnt sind, l¨ achelnd den Kopf sch¨ utteln und sich sicher sind, dass mir hier ein Fehler unterlaufen ist, denn hier sollte doch wohl #include stehen. Leser, die noch keine Erfahrung mit C haben, beginnen erst jetzt l¨achelnd den Kopf zu sch¨ utteln und sind sich sicher, dass ich im Fieber liege, weil ich gerade wirres Zeug schreibe. Beides ist mitnichten der Fall! Des R¨ atsels L¨ osung sieht folgendermaßen aus: – Ich habe bereits erw¨ ahnt, dass es explizite Deklarationen von Variablen gibt. Ebenso gibt es auch explizite Deklarationen von allen m¨oglichen
28
2. Datentypen und Variablen
anderen Dingen, wie z.B. Funktionen (in Zeile 8 unseres Programms zu sehen) oder Klassen und anderen Datentypen, die wir noch kennen lernen werden. In sogenannten Libraries, also Programmbibliotheken, werden hilfreiche Tools zur Verf¨ ugung gestellt, die Entwicklern das Leben erleichtern. Diese Libraries werden vom Linker zu unserem ausf¨ uhrbaren Programm (=Executable) dazugeh¨angt, damit der darin enthaltene Code auch vom Programm aus aufrufbar ist. Die Deklarationen, was nun genau f¨ ur uns im Programm aus den Libraries verwendbar ist, stehen in sogenannten Header-Files. Header-Files haben u ¨blicherweise die FileExtension .h. – Die Header-Files der Libraries, die wir im Programm verwenden wollen, m¨ ussen wir explizit einbinden, damit die Deklarationen wirksam werden. Diese Aufgabe u ¨bernimmt der sogenannte Preprocessor, auf den wir sp¨ ater noch genauer eingehen werden. Dieser arbeitet das Pro¨ gramm noch vor dem C++ Compiler durch und nimmt textuelle Ubersetzungen vor. Das Einbinden der Headers erfolgt mittels der #include Preprocessor-Anweisung. – Bei #include wurde traditionsgem¨aß immer der Name der Headers inklusive der Extension .h angegeben, dies hat sich allerdings in den neueren Versionen der C++ Compiler ge¨andert. Einerseits aus Gr¨ unden der Kompatibilit¨ at zu ¨ alteren Versionen und andererseits um gewisse Op¨ timierungen z.B. durch Vor-Ubersetzung von Headers zu erm¨oglichen, wird es dem Preprocessor bei Standard-Headers selbst u ¨berlassen, die entsprechende Extension implizit zu verwenden oder auch einfach mit Headers ohne Extension zu arbeiten. Damit wird bei solchen Headers keine File-Extension mehr angegeben. Genaueres zur Arbeitsweise des Preprocessors wird in Kapitel 7 besprochen. • Die beiden Anweisungen in den Zeilen 5–6 des Programms m¨ochte ich hier kurz u ¨berspringen und erst weiter unten bei der Erkl¨arung von cout darauf zur¨ uckkommen. • In Zeile 8 steht die Deklaration einer Funktion namens aFunction, deren Definition ab Zeile 40 zu finden ist. F¨ ur Leser, die noch keine Erfahrung mit C haben und noch nicht so genau wissen, was eine Funktion ist, m¨ochte ich ganz kurz vorgreifen: Eine Funktion ist eine Zusammenfassung mehrerer Anweisungen zu einem aufrufbaren Ganzen. Sie nimmt beliebig viele Parameter entgegen und liefert einen return-Wert als Ergebnis. Eine genaue Erkl¨ arung, was es mit Funktionen in C prinzipiell auf sich hat, findet sich in Kapitel 8 des Buchs Softwareentwicklung in C. Ausgenommen von einigen sehr angenehmen Erweiterungen in C++, auf die sp¨ater noch n¨aher eingegangen wird, besitzt alles, was f¨ ur Funktionen in C gilt, auch in C++ seine volle G¨ ultigkeit.
2.3 Das erste C++ Programm
29
• Zeile 10 zeigt die Definition einer globalen Variable mit expliziter Initialisierung, wie sie auch in C u ¨blich ist. • In Zeile 11 finden wir dann eine implizite Initialisierung, wie sie nur in C++, nicht aber in C funktioniert. Durch das Fehlen einer expliziten Initialisie¨ rung setzt der Compiler beim Ubersetzen den richtigen Code ein, der die Variable automatisch auf 0 initialisiert. • Wie in C, so ist auch in C++ Programmen die spezielle Funktion main der Startpunkt eines jeden Programms. Und auch hier nimmt main als ersten Parameter die Anzahl der u ¨bergebenen Command-Line Arguments und als zweiten Parameter ein Array von Strings, das ebendiese h¨alt. Auch der return-Wert von main ist wie gewohnt ein int. Eine genaue Beschreibung, was es mit den Command-Line Arguments auf sich hat, findet sich in Abschnitt 20.1 in Softwareentwicklung in C. • Den Aufruf von aFunction in Zeile 15 m¨ochte ich an dieser Stelle u ¨berspringen, viel interessanter ist das Konstrukt, das in Zeile 17 zu finden ist: – cout ist der Name des Output-Streams, der den Standard-Output von Programmen repr¨ asentiert. Dieses cout tr¨agt auch Schuld daran, dass im Programm der Header iostream inkludiert wurde, denn dort findet sich seine Deklaration. – Was rechts neben cout so aussieht wie der left-Shift Bit-Operator, hat in Verbindung mit Output-Streams in C++ eine besondere Bedeutung, n¨amlich: Schreibe das Folgende auf diesen Stream. Diese Definition des <<-Operators ist das Ergebnis des sogenannten Operator-Overloadings, das im OO-Teil dieses Buchs noch genauer beschrieben werden wird. – Im Gegensatz zu printf in C muss man bei Streams nicht mehr mit Platzhaltern arbeiten, sondern man kann ganz einfach den Wert u ¨ber den <<-Operator auf den Stream schreiben, ohne R¨ ucksicht nehmen zu m¨ ussen, welchen Datentyp man nun schreibt. Die korrekte Behandlung wird dann intern sichergestellt. – Wie man leicht erkennen kann, kann man nicht nur immer ein St¨ uck nach dem anderen schreiben, man kann auch quasi eine Kette von zu schreibenden Werten und <<-Operatoren verwenden, um mehrere Werte aneinander gereiht zu schreiben. – Eine weitere Besonderheit von C++ gegen¨ uber C stellt endl dar: Dies bedeutet End of Line und ist garantiert plattformunabh¨angig definiert. Deshalb wird sehr dringend empfohlen immer endl anstatt des noch in Cu ¨blichen Newline-Characters \n zu verwenden. – Um cout und endl verwenden zu k¨onnen ist neben dem Inkludieren von iostream noch etwas zu beachten, was die Zeilen 5–6 erkl¨art: Sowohl cout als auch endl geh¨ oren zu einem sogenannten Namespace mit dem Namen std. Durch die Anweisung using std::cout;
30
2. Datentypen und Variablen
teilen wir dem Compiler mit, dass wir cout aus dem Namespace std verwenden wollen. W¨ urden wir diese Anweisung nicht im Programm stehen haben, dann w¨ urde sich der Compiler bei jeder Verwendung von cout beschweren, dass er diesen Stream nicht kennt. Ganz gleich verh¨ alt es sich mit endl. Eine genaue Erkl¨arung, was es mit Namespaces auf sich hat, findet sich in Kapitel 14. Einstweilen ist es genug, zu wissen, dass man mittels using die Verwendung gewisser Teile aus einem Namespace bekannt gibt. Fassen wir also noch einmal die Einzelteile in Zeile 17 zusammen, dann liest sie sich so: Schreibe hintereinander den String "a_global_var_: " und den Inhalt der int-Variablen a_global_var_, gefolgt von einem Zeilenumbruch auf den Standard-Output des Programms. • In Zeile 20 findet sich ein Beispiel daf¨ ur, dass Variablen in C++ beliebig im laufenden Code definiert werden k¨onnen, was in C nicht m¨oglich ist. • In den Zeilen 24–28 wird eine besondere Eigenschaft von Variablen vom Typ bool demonstriert: Sie sind kompatibel zu int-Variablen in der Hinsicht, dass man beliebige Zahlen zuweisen kann. Allerdings werden die Zahlen bei der Zuweisung immer genau auf die vordefinierten Wahrheitswerte true und false, also auf 0 und 1 reduziert. • In den Zeilen 30–32 wird kurz demonstriert, wie man untersuchen kann, ob char auf einer Zielplattform nun per Default als signed oder als unsigned interpretiert wird: Liefert Zeile 32 als Output die Zahl 255, so wird char als unsigned interpretiert. Liefert sie allerdings als Output -1, so liegt per Default eine signed-Interpretation zugrunde. Wem jetzt nicht ganz klar ist, wie man pl¨otzlich von 255 auf -1 kommt: 255 ist die l¨ angste Zahl, die man mit 8 Bit ohne Vorzeichen darstellen kann. Dabei sind intern alle Bits gesetzt (=auf 1). Wird diese Zahl nun als signed interpretiert, dann wird das h¨ ochstwertige Bit davon zum Vorzeichenbit. Weil dieses gesetzt ist, wird die ganze Zahl als negative Zahl angesehen. Die 8 Bit-Darstellung von -1 entspricht genau dem, was in der Zahl enthalten ist: Es sind alle Bits gesetzt. Lesern, die noch ein wenig unsicher mit der internen Darstellung von bin¨aren Ganzzahlen sind, m¨ ochte ich an dieser Stelle die Lekt¨ ure von Anhang A.2 aus Softwareentwicklung in C empfehlen. • Zeile 42 in aFunction demonstriert noch, dass auch als static definierte Variablen implizit initialisiert werden. • Last, but not least demonstriert Zeile 46, dass uninitialisierte Variablen nun wirklich keine sehr gute Idee sind. Dass eine solche Variable “irgendeinen” nicht vorherbestimmbaren Wert hat, sieht man eindeutig am Output des Programms.
2.4 Zusammengesetzte Datentypen
31
2.4 Zusammengesetzte Datentypen Die reale Welt besteht nicht einfach nur aus einzelnen, allein stehenden Zahlen oder Buchstaben. Es liegt in der Natur der Sache, dass verschiedenste Zusammengeh¨ origkeiten von kleineren Einheiten ein großes Ganzes ergeben. Wenn man einfach nur geschriebenen Text betrachtet, dann sieht man schon, dass mehrere Buchstaben aneinander gereiht ein Wort ergeben, mehrere Worte ergeben einen Satz, mehrere S¨ atze einen Absatz, etc. Parallel dazu liegt einem gedruckten Text noch eine Seitenstruktur zugrunde, usw. Um den Entwicklern beim Modellieren wie auch immer gearteter Zusammenh¨ange entgegenzukommen, gibt es in C++ neben den OO-Konstrukten zur Modellierung von Zusammenh¨ angen auch die von C u ¨bernommenen zusammengesetzten Datentypen. Dieses Kapitel befasst sich genau mit diesen, denn auch f¨ ur OO-Entwicklung ist das Wissen dar¨ uber unabdingbar. 2.4.1 Arrays Der erste zusammengesetzte Datentyp, der n¨aher unter die Lupe genommen werden soll, ist das Array, genauer gesagt, das statische Array (Anm.: Der Unterschied zum dynamischen Array, das sp¨ater besprochen wird, liegt nur in der Art und Weise, wie Speicher daf¨ ur reserviert wird). Ein Array ist einfach ein indizierbares Feld von Daten desselben Typs, egal um welchen Typ es sich dabei handelt. Bei der Definition von Array Variablen in C++ wird angegeben, welchen Typ die einzelnen Elemente haben und wie viele Elemente maximal darin gespeichert werden k¨onnen. Syntaktisch und semantisch entspricht dies der Definition einer “normalen” Variable, gefolgt von der Anzahl der Elemente in eckigen Klammern. Z.B. w¨ urde die Definition eines Arrays, das 50 Characters halten kann, folgendermaßen aussehen: char my_char_array[50]; Zuvor war die Rede davon, dass ein Array indizierbar ist, dass man also u ¨ber einen Index auf die einzelnen Elemente dieses Arrays zugreifen kann. Dies wird einfach dadurch bewerkstelligt, dass man beim Zugriff den Index des gew¨ unschten Elements in eckigen Klammern der entsprechenden Variable nachstellt. Will man z.B. aus dem oben definierten Array das Element mit dem Index 20 ansprechen, dann liest sich das so: my_char_array[20] Man kann nat¨ urlich sowohl das angesprochene Element auslesen als ihm auch einen Wert zuweisen. Jetzt ist nur noch genau zu kl¨aren, was es mit dem Index auf sich hat: Wie in den meisten Programmiersprachen u ¨blich, ist der Index des ersten Elements 0! Das bedeutet also logischerweise, dass f¨ ur ein Array mit size Elementen die Indizes von 0...(size − 1) im erlaubten Bereich liegen. ¨ Vorsicht Falle: In C++ Programmen wird zur Laufzeit keine Uberpr¨ ufung vorgenommen, ob der indizierte Zugriff auf ein Array auch innerhalb der
32
2. Datentypen und Variablen
erlaubten Grenzen liegt! Zumeist wird auf einzelne Elemente eines Arrays auch nicht u ¨ber konstante Werte zugegriffen, sondern es wird im Programm ein Index ausgerechnet, u ¨ber den dann zugegriffen wird. Dabei passiert es z.B. durch typische off-by-one Fehler (=durch Unachtsamkeit alles um 1 verschoben) nur allzu leicht, dass man einmal danebengreift. Das Programm quittiert im Gl¨ ucksfall solche Fehler sofort mit einer entsprechenden Segmentation Violation oder mit einem Blue-Screen. Hat man Pech, dann st¨ urzt das Programm nicht ab, weil der Speicher, auf den man irrt¨ umlich zugreift, dem Programm selbst geh¨ ort. Damit u ¨berschreibt man unabsichtlich irgendetwas, was gerade zuf¨ allig dort zu liegen kam (z.B. ein Element eines anderen Arrays). Als Folge verf¨ arben ungeahnte seltsame Ergebnisse bei diversen Testl¨aufen das Gesicht des Entwicklers zuerst in ein zartes Rosa, das im Lauf der Zeit in ein kr¨ aftiges Rot umschl¨agt. Dann setzt die Phase des kalten Schweißes ein, wobei gleichzeitig das Gesicht zu einem leicht gr¨ unlichen Weiß verblasst. F¨ ur die Initialisierung der einzelnen Felder von Array-Variablen gilt dasselbe, was bereits f¨ ur Variablen primitiver Datentypen gesagt wurde: globale, static-auto und Namespace Variablen werden auf die jeweiligen 0-Werte initialisiert. Legt man also z.B. eine globale Variable an, die ein statisches int-Array repr¨ asentiert, so werden bei Fehlen von expliziter Initialisierung die einzelnen Elemente auf 0 gesetzt. Nat¨ urlich kann man Arrays, gleich wie in C, explizit initialisieren. Dazu gibt man die Werte zu dessen Initialisierung, getrennt durch Beistriche, in einem Block an (d.h. von geschwungenen Klammern eingefasst). Dies l¨asst sich am besten gleich an einem Beispiel zeigen, das das Initialisierungsverhalten von Arrays demonstriert (array_initializing_demo.cpp): 1 2
// a r r a y i n i t i a l i z i n g d e m o . cpp − s m a l l program to demonstrate , how // a r r a y i n i t i a l i z i n g works i n C++
3 4
#include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10
// i m p l i c i t l y i n i t i a l i z e d int f i r s t g l o b a l a r r a y [ 5 ] ;
11 12 13
// e x p l i c i t l y i n i t i a l i z e d int s e c o n d g l o b a l a r r a y [ ] = { 1 , 2 , 3 , 4 , 5 } ;
14 15 16 17
// p a r t s o f the a r r a y e x p l i c i t l y i n i t i a l i z e d // r e s t o f i t i m p l i c i t l y i n i t i a l i z e d int t h i r d g l o b a l a r r a y [ 5 ] = { 1 , 2 , 3 } ;
18 19 20 21 22
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { unsigned count ;
23 24 25
cout << ” i m p l i c i t l y i n i t i a l i z e d a r r a y : ” ; for ( count = 0 ; count < 5 ; count++)
2.4 Zusammengesetzte Datentypen
33
cout << f i r s t g l o b a l a r r a y [ count ] << ” ” ; cout << e n d l ;
26 27 28
count = 5 ; cout << ” e x p l i c i t l y i n i t i a l i z e d a r r a y : ” ; for ( count = 0 ; count < 5 ; count++) cout << s e c o n d g l o b a l a r r a y [ count ] << ” ” ; cout << e n d l ;
29 30 31 32 33 34
count = 5 ; cout << ” p a r t l y e x p l i c i t l y i n i t i a l i z e d a r r a y : ” ; for ( count = 0 ; count < 5 ; count++) cout << t h i r d g l o b a l a r r a y [ count ] << ” ” ; cout << e nd l ; return ( 0 ) ;
35 36 37 38 39 40 41
}
Compiliert man dieses Programm wie gewohnt und startet es danach, dann bekommt man folgenden Output: i m p l i c i t l y i n i t i a l i z e d array : 0 0 0 0 0 e x p l i c i t l y i n i t i a l i z e d array : 1 2 3 4 5 partly e x p l i c i t l y i n i t i a l i z e d array : 1 2 3 0 0
Lesern, die mangels Erfahrung mit C noch nicht mit der Funktionsweise von for-Schleifen vertraut sind, m¨ ochte ich die Lekt¨ ure von Kapitel 7 von Softwareentwicklung in C empfehlen. Als kurze Erkl¨ arung f¨ ur die Neulinge, die im Augenblick keine Lust haben, kurzfristig das Buch zu wechseln: Im Prinzip geht es bei den for-Schleifen in diesem Programm einfach darum, dass die einzelnen Arrays Element f¨ ur Element vom Index 0 weg ausgegeben werden. Diese Schleifen werden auch noch in Abschnitt 4.2 genauer behandelt. Das erste Array, das in Zeile 10 definiert wird, u ¨berlassen wir vollst¨andig der impliziten Initialisierung von C++. Im Output, der von den Zeilen 24–27 erzeugt wird, zeigt sich auch, dass alle Elemente des Arrays auf 0 gesetzt wurden. Das zweite Array, das in Zeile 13 definiert wird, wird vollst¨andig explizit initialisiert, wobei das Element mit Index 0 den Wert 1, das Element mit Index 1 den Wert 2, etc. zugewiesen bekommt. Der Output, der von den Zeilen 29–33 erzeugt wird, sieht entsprechend aus. Dass in Zeile 13 bei der Definition des Arrays zwischen den eckigen Klammern keine Gr¨oße angegeben wurde, ist kein Fehler im Programm! Durch die Initialisierung mit 5 Elementen weiß der Compiler automatisch u unschte Gr¨oße des Arrays ¨ber die gew¨ Bescheid und legt deshalb gleich ein Array mit einem Fassungsverm¨ogen f¨ ur die in der Initialisierung angegebenen 5 Elemente an. Dass man implizite und explizite Initialisierung auch mischen kann, zeigt sich in Zeile 17, wo ein Array mit Fassungsverm¨ogen f¨ ur 5 Elemente angelegt, aber nur 3 davon initialisiert werden. Der Output, der von den Zeilen 35–39 erzeugt wird, demonstriert was hier passiert: Die explizite Initialisierung wird von “vorne” weg (also beginnend mit dem Element mit Index 0) durchgef¨ uhrt.
34
2. Datentypen und Variablen
Sobald alle Werte aus der expliziten Initialisierung “aufgebraucht” sind, wird der verbleibende Rest des Arrays auf 0 gesetzt. In den Zeilen 13 und 17 sieht man, dass man bei Verwendung expliziter Initialisierung die Wahl hat, entweder den Compiler die Gr¨oße eines Arrays ermitteln zu lassen oder die Gr¨ oße selbst vorzugeben. Wenn man allerdings die Gr¨oße selbst vorgibt, so muss diese unbedingt gr¨oßer oder gleich der Anzahl der Elemente in der Initialisierungsliste sein. Ansonsten reagiert der Compiler gar nicht sehr freundlich. W¨ urde man z.B. in Zeile 17 als Gr¨oße den Wert 2 anstatt 5 angeben, dann w¨ urde der Compiler zu Recht behaupten, dass man sich gef¨ alligst entscheiden soll, was man denn nun u ¨berhaupt will. Einerseits soll ein Array mit Gr¨oße 2 angelegt werden, andererseits will man gleich danach aber 3 Elemente hinein schreiben. Allerdings l¨asst sich durch richtig Stellen der gew¨ unschten Anzahl der Elemente der Compiler ganz einfach wieder beruhigen :-). Arrays gibt es, gleich wie in C, nicht nur eindimensional, sondern im Prinzip mit beliebig vielen Dimensionen (solange der Speicher nicht ausgeht...). Auch das ist am besten schnell an einem Beispiel demonstriert (multi_dimensional_array_demo.cpp): 1 2
// m u l t i d i m e n s i o n a l a r r a y d e m o . cpp − a s h o r t demo program f o r // multidimensional arrays
3 4
#include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10
// i m p l i c i t l y i n i t i a l i z e d matrix int f i r s t d e m o m a t r i x [ 3 ] [ 3 ] ;
11 12 13 14 15
// e x p l i c i t l y i n i t i a l i z e d matrix int second demo matrix [ ] [ 3 ] = { { 1 , 2 , 3 } , { 4, 5, 6 }, { 7, 8, 9 } };
16 17 18 19 20
// p a r t s o f the matrix e x p l i c i t l y i n i t i a l i z e d // r e s t o f i t i m p l i c i t l y i n i t i a l i z e d int t h i r d d e m o m a t r i x [ 3 ] [ 3 ] = { { 1 , 2 , 3 } , { 4, 5 } };
21 22 23 24 25 26
// a l s o t h i s i s p o s s i b l e int f o u r t h d e m o m a t r i x [ 3 ] [ 3 ] = { { 1 , 2 , 3 } , { 4, 5 }, { 6, 7, 8 } };
27 28 29
// t h i s r e s u l t s i n a warning , but i s p o s s i b l e int f i f t h d e m o m a t r i x [ 3 ] [ 3 ] = { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 } ;
30 31 32 33 34
int main ( int a r g c , char ∗ argv [ ] ) { int o u t e r c o u n t = 0 ; int i n n e r c o u n t = 0 ;
35 36 37 38
cout << ” i m p l i c i t l y i n i t i a l i z e d matrix : ” << e nd l ; for ( o u t e r c o u n t = 0 ; o u t e r c o u n t < 3 ; o u t e r c o u n t++) {
2.4 Zusammengesetzte Datentypen
35
for ( i n n e r c o u n t = 0 ; i n n e r c o u n t < 3 ; i n n e r c o u n t++) cout << f i r s t d e m o m a t r i x [ o u t e r c o u n t ] [ i n n e r c o u n t ] << ” ” ; cout << e nd l ;
39 40 41
}
42 43
cout << ” e x p l i c i t l y i n i t i a l i z e d matrix : ” << e n d l ; for ( o u t e r c o u n t = 0 ; o u t e r c o u n t < 3 ; o u t e r c o u n t++) { for ( i n n e r c o u n t = 0 ; i n n e r c o u n t < 3 ; i n n e r c o u n t++) cout << second demo matrix [ o u t e r c o u n t ] [ i n n e r c o u n t ] << ” ” ; cout << e nd l ; }
44 45 46 47 48 49 50 51
cout << ” p a r t l y e x p l i c i t l y i n i t i a l i z e d matrix : ” << e n d l ; for ( o u t e r c o u n t = 0 ; o u t e r c o u n t < 3 ; o u t e r c o u n t++) { for ( i n n e r c o u n t = 0 ; i n n e r c o u n t < 3 ; i n n e r c o u n t++) cout << t h i r d d e m o m a t r i x [ o u t e r c o u n t ] [ i n n e r c o u n t ] << ” ” ; cout << e nd l ; }
52 53 54 55 56 57 58 59
cout << ” o t h e r e x p l i c i t i n i t i a l i z a t i o n p o s s i b i l i t y : ” << e nd l ; for ( o u t e r c o u n t = 0 ; o u t e r c o u n t < 3 ; o u t e r c o u n t++) { for ( i n n e r c o u n t = 0 ; i n n e r c o u n t < 3 ; i n n e r c o u n t++) cout << f o u r t h d e m o m a t r i x [ o u t e r c o u n t ] [ i n n e r c o u n t ] << ” ” ; cout << e nd l ; }
60 61 62 63 64 65 66 67
cout << ” e x p l i c i t i n i t i a l i z a t i o n with warning : ” << e n d l ; for ( o u t e r c o u n t = 0 ; o u t e r c o u n t < 3 ; o u t e r c o u n t++) { for ( i n n e r c o u n t = 0 ; i n n e r c o u n t < 3 ; i n n e r c o u n t++) cout << f i f t h d e m o m a t r i x [ o u t e r c o u n t ] [ i n n e r c o u n t ] << ” ” ; cout << e nd l ; }
68 69 70 71 72 73 74 75
}
Dieses Progr¨ ammchen begl¨ uckt uns nach dem Compilieren und Starten mit folgendem Output: i m p l i c i t l y i n i t i a l i z e d matrix : 0 0 0 0 0 0 0 0 0 e x p l i c i t l y i n i t i a l i z e d matrix : 1 2 3 4 5 6 7 8 9 p a r t l y e x p l i c i t l y i n i t i a l i z e d matrix : 1 2 3 4 5 0 0 0 0 other e x p l i c i t i n i t i a l i z a t i o n p o s s i b i l i t y : 1 2 3 4 5 0 6 7 8 e x p l i c i t i n i t i a l i z a t i o n with warning : 1 2 3 4 5 6 7 8 0
In Zeile 10 des Programms wird ein 2-dimensionales Array (also eine Matrix) angelegt, wie man das intuitiverweise machen w¨ urde: Es werden einfach beide
36
2. Datentypen und Variablen
Dimensionen mit ihren jeweiligen Gr¨ oßen angegeben. Dass hier wieder eine implizite Initialisierung stattfindet, versteht sich von selbst. Diese Matrix wird in den Zeilen 36–42 Element f¨ ur Element ausgegeben. Die Zeilen 13–15 zeigen, wie man mehrdimensionale Arrays explizit initialisiert, n¨ amlich Zeile f¨ ur Zeile! Jede Zeile f¨ ur sich wird in einen Block verpackt (indem man sie in geschwungene Klammern einschließt). Die einzelnen Bl¨ ocke, je einer f¨ ur eine Zeile, werden in gewohnter Manier durch Beistriche getrennt und in einem umschließenden Block zusammengefasst. So weit klingt das alles ganz logisch und man vermutet, dass man auf diese Weise auch einfach wieder das Erraten des Fassungsverm¨ogens f¨ ur die einzelnen Dimensionen dem Compiler u ¨berlassen kann. In Zeile 13 erkennt man allerdings dabei eine Besonderheit, die auf den ersten Blick eigentlich nicht ganz verst¨ andlich ist: Hier ist die erste Dimension dem Compiler u ¨berlassen, die zweite Dimension aber ist explizit angegeben! Das ist nicht passiert, weil ich gerade beim Schreiben des Programms in einem Zustand schwerer geistiger Umnachtung war, dies ist tats¨achlich verpflichtend! Man darf ausschließlich die erste Dimension leer lassen, alle weiteren Dimensionen m¨ ussen explizit angegeben werden. Der Grund f¨ ur diese, zugegeben etwas komische, Einschr¨ankung ist ein historischer, wie in Zeile 29 demonstriert wird: Es ist nicht verpflichtend, die einzelnen Zeilen in Bl¨ ocken zusammenzufassen. Im Prinzip ist es auch m¨oglich, einfach alles in einem Block aneinander zu reihen. Je nach Compiler und eingestelltem Warning-Level wird dies zwar heutzutage als gef¨ahrlich beanstandet, dies bedeutet aber nicht, dass es nicht m¨oglich w¨are. Der Output dieses Arrays, der von den Zeilen 68–74 erzeugt wird, zeigt auch, wie die einzelnen Initialisierungswerte vom Compiler verwendet werden: Es werden die Elemente einfach der Reihe nach auf die einzelnen Zeilen der Matrix aufgeteilt. Wenn eine Zeile voll ist, wird mit der n¨achsten weitergemacht. Jetzt kann man sich auch ausmalen, woher die Einschr¨ankung kommt, dass nur die erste Dimension leer gelassen werden darf: Woher soll denn der Compiler erraten k¨ onnen, wie die Aufteilung der Elemente auf die einzelnen Dimensionen des Arrays erfolgen soll? Sind z.B. 12 Elemente nun 2 Zeilen zu je 6 Spalten oder doch 4 Zeilen zu je 3 Spalten oder...? Obwohl durch die in C++ erlaubte (eigentlich sogar stark empfohlene) vollst¨andige blockweise Aufteilung alle Dimensionen f¨ ur den Compiler ersichtlich sind, gibt es eben leider noch die von C geerbte Altlast des nichtSpezifizierens der einzelnen Sub-Bl¨ ocke. Deshalb wurde konsequenterweise definiert, dass die historische Regel weiterhin G¨ ultigkeit hat, dass nur eine Dimension dem Compiler u ¨berlassen werden darf. Bleiben wir aber bei der sauberen Schreibweise zur Initialisierung: Die Definition der Matrix in den Zeilen 19–20 zeigt, dass man sich auch bei beliebig vielen Dimensionen auf die implizite Initialisierung des verbleibenden Rests durch den Compiler verlassen kann. Auch der Fall, dass “mitten drin”
2.4 Zusammengesetzte Datentypen
37
einzelne Zeilen nicht vollst¨ andig explizit initialisiert werden, ist m¨oglich, wie die Zeilen 24–26 zeigen. Vorsicht Falle: Ich m¨ ochte dringend raten, die “Spaghetti-Schreibweise”, die in Zeile 29 des soeben behandelten Programms demonstriert ist, nicht in Programmen zu verwenden. Man kann sich leicht vorstellen, dass diese Schreibweise sehr schnell un¨ ubersichtlich und damit fehleranf¨allig wird. Es gen¨ ugt schon, ein Element zwischendurch zu vergessen und alle nachfolgenden Elemente sind um eben dieses fehlende Element nach vorne verschoben. Wie unterhaltsam die Suche nach solchen Fehlern wird, u ¨berlasse ich der Phantasie der Leser.
2.4.2 Structures Arrays, die zuvor behandelt wurden, sind Aggregate vieler Elemente desselben Typs. Oft braucht man aber Aggregate mehrerer Elemente verschiedenen Typs, die man aufgrund ihrer logischen Zusammengeh¨origkeit miteinander koppeln m¨ ochte. Typische Beispiele f¨ ur solche Bed¨ urfnisse sind z.B. bei Adressen zu finden: Hier werden Name, Ortsbezeichnung, Straße, Hausnummer, etc. zu einem gemeinsamen Ganzen, eben der Adresse, verbunden. Bevor ich allerdings zur Beschreibung der sogenannten Structures komme, die diesen gew¨ unschten Aggregat-Typen darstellen, m¨ochte ich noch etwas vorausschicken: Im Prinzip sind Structures in C++ nicht mehr das, was sie in C einmal waren. In C++ sind sie eigentlich Classes, nur dass alle ihre Members public sind (Genaueres dazu findet sich in Kapitel 9). Alles, was man in C mit Structures machen konnte, kann man auch in C++ und noch Einiges mehr. Um also Structures im C++ Sinn ernsthaft beschreiben zu k¨onnen, brauchen wir den entsprechenden OO-Kontext. Deshalb ist die nachfolgende Abhandlung u ¨ber Structures entsprechend kurz gehalten. Wir werden auf das Thema sp¨ ater noch einmal in Abschnitt 9.1 zur¨ uckkommen. Eine ausf¨ uhrliche Beschreibung von Structures, wie sie in C existieren, kann man in Kapitel 11 des Buchs Softwareentwicklung in C nachlesen. Dort wird auch auf einige wichtige Aspekte der Kapselung eingegangen, die im Endeffekt zum Konzept von Classes in C++ gef¨ uhrt haben. Am einfachsten sind Structures gleich am Beispiel erkl¨art. Um nicht, wie praktisch u ¨berall in der Literatur u ¨blich, eine Adresse zur Erkl¨arung heranzuziehen, bin ich fast schon unglaublich kreativ und verwende ein Pixel, das x und y-Koordinaten, sowie eine Farbe als Status besitzt :-) (struct_demo.cpp): 1
// struct demo . cpp − a t i n y demo program f o r the use o f s t r u c t u r e s
2 3 4
#include < i o s t r e a m>
38
5 6
2. Datentypen und Variablen
using s t d : : cout ; using s t d : : e nd l ;
7 8 9 10 11 12 13 14 15
struct P i x e l { unsigned x c o o r d ; unsigned y c o o r d ; // the s i n g l e c o l o r c h a n n e l s red , green and b l u e // a r e a c c e s s e d through the i n d i c e s 0 , 1 and 2 unsigned short r g b v a l u e [ 3 ] ; };
16 17
struct P i x e l g l o b a l p i x e l ; // i m p l i c i t l y i n i t i a l i z e d
18 19 20 21
int main ( int a r g c , char ∗ argv [ ] ) { struct P i x e l o n e p i x e l ;
22
one one one one one
23 24 25 26 27
p i x e l . x coord = 10; p i x e l . y coord = 30; p i x e l . r g b v a l u e [ 0 ] = 0 xFF ; // maximum red p i x e l . r g b v a l u e [ 1 ] = 0 x00 ; // no green p i x e l . r g b v a l u e [ 2 ] = 0 x00 ; // no b l u e
28
cout << ” i m p l i c i t l y i n i t i a l i z e d p i x e l : ” << e n d l << ” ( ” << g l o b a l p i x e l . x c o o r d << ” , ” << g l o b a l p i x e l . y c o o r d << ” ) , with R/G/B : [ ” << g l o b a l p i x e l . r g b v a l u e [0] << ” , ” << g l o b a l p i x e l . r g b v a l u e [1] << ” , ” << g l o b a l p i x e l . r g b v a l u e [2] << ” ] ” << e nd l ;
29 30 31 32 33 34 35
cout << ” auto−var o n e p i x e l : ” << e n d l << ” ( ” << o n e p i x e l . x c o o r d << ” , ” << o n e p i x e l . y c o o r d << ” ) , with R/G/B : [ ” << o n e p i x e l . r g b v a l u e [0] << ” , ” << o n e p i x e l . r g b v a l u e [1] << ” , ” << o n e p i x e l . r g b v a l u e [2] << ” ] ” << e n d l ;
36 37 38 39 40 41 42
return ( 0 ) ;
43 44
}
Folgendes bekommt man zu sehen, wenn dieses kreative Meisterst¨ uck compiliert und ausgef¨ uhrt wird: implicitly i ni t i a l i z e d pixel : ( 0 , 0 ) , with R/G/B : [ 0 , 0 , 0 ] auto−var o n e p i x e l : ( 1 0 , 3 0 ) , with R/G/B : [ 2 5 5 , 0 , 0 ]
Eine Structure deklariert (!!) man wie in den Zeilen 8–15 gezeigt: • Das Keyword struct beginnt die Deklaration. • Dem Keyword struct folgt der Name, unter dem diese Structure bekannt sein soll. • Diesem Header folgt ein Block, der die Deklaration der einzelnen Elemente dieser Structure (sog. Members) enth¨alt. • Nach der schließenden Klammer dieses Blocks muss ein Strichpunkt stehen.
2.4 Zusammengesetzte Datentypen
39
Vorsicht Falle: Nur allzu leicht wird der Strichpunkt nach der schließenden Klammer des Deklarationsblocks der Members einer Structure vergessen. Dies f¨ uhrt dann zu ¨ außerst kryptischen Fehlermeldungen des Compilers, da dieser versucht, das nachfolgende Statement noch mit der laufenden Deklaration in Verbindung zu bringen. Neulinge suchen bei solchen Fehlermeldungen zumeist verzweifelt am falschen Ort nach dem Fehler, da der Ort der Fehlermeldung des Compilers im Regelfall eine der Zeilen ist, die der structDeklaration nachfolgen, obwohl der tats¨achliche Fehler, also der vergessene Strichpunkt, bereits weiter oben liegt. Deshalb ist es bei unerkl¨ arlichen Fehlermeldungen in vermeintlich richtigen Programmzeilen immer sehr ratsam, auch ein wenig im Code nach oben zu sehen, ob sich nicht vielleicht irgendwo eine offiziell nicht (durch Strichpunkt) beendete Deklaration einer Structure findet. Bevor wir uns wieder dem Programm zuwenden, m¨ochte ich noch schnell erkl¨aren, warum ich bei Structures auf dem Begriff der Deklaration herumreite. Normalerweise wird immer von der Definition einer struct gesprochen (sogar von mir selbst im Buch Softwareentwicklung in C ). Ich bin aus folgendem Grund selbst dazu u ¨bergegangen, sehr pingelig zu sein und auch hier konsequent den Begriff der Deklaration einer struct zu verwenden: Man kann sich einfach u ¨berlegen, was ein Compiler aus einer struct-Deklaration macht. Er nimmt sie quasi als benutzerdefinierten Datentyp und entnimmt der Deklaration, welche Members in ihr enthalten sind und unter welchen Namen sie ansprechbar sind. Es wird kein Speicher angelegt oder irgendetwas Anderes unternommen, was sich an der Stelle der Deklaration im u urde. Und dieser ¨bersetzten Programm wiederfinden w¨ Hinweis “es gibt eine struct, die folgendermaßen aussieht...” ist genau eine Deklaration. Die Definition einer Variable dieses Typs erfolgt dann erst sp¨ater, z.B. in unserem Programm in Zeile 17. Nach diesem kurzen Exkurs wieder zur¨ uck zu unserem Programm: Eine struct ist, wie schon erw¨ ahnt, ein Aggregat aus verschiedenen Members, die beliebigen Typs sein k¨ onnen. Members k¨onnen auch z.B. selbst wieder irgendwelche Structures sein. Wie man nun eine Variable definiert, der eine struct zugrunde liegt, sieht man in den Zeilen 17 und 21. Nach der Definition einer struct-Variable kann man auf die einzelnen Members zugreifen, die f¨ ur diese struct deklariert wurden. Dies geschieht, indem man dem Variablennamen einen Punkt nachstellt, gefolgt vom Namen des gew¨ unschten Members. Die einzelnen Members der Variable one_pixel werden z.B. angesprochen, wie in den Zeilen 23–27 gezeigt. Auch bei struct-Variablen gelten wieder dieselben Regeln f¨ ur die implizite Initialisierung, wie sie bereits besprochen wurden. Bei der impliziten Initialisierung werden alle Members einzeln auf ihre entsprechenden Nullwerte gesetzt. Die Definition von global_pixel_ und das Ausgeben ihres Inhalts in den Zeilen 29–34 demonstrieren dies.
40
2. Datentypen und Variablen
Ganz wichtige Aussagen u ¨ber die Members einer struct bin ich noch schuldig geblieben, die in K¨ urze in Verbindung mit Unions und sp¨ater ganz besonders in Verbindung mit Pointern an Bedeutung gewinnen: • Die einzelnen Members einer struct sind garantiert genau in der Reihenfolge im Speicher angeordnet, in der sie in der Deklaration der struct angef¨ uhrt werden. • Es wird allerdings nicht garantiert, dass die einzelnen Members unmittelbar hintereinander im Speicher zu liegen kommen! Je nach Compiler und Zielplattform kann es passieren, dass zwischen den einzelnen Members “L¨ocher” im Speicher frei bleiben. • Es wird garantiert, dass die Startadresse des ersten Members im Speicher mit der Startadresse der gesamten struct u ¨bereinstimmt. 2.4.3 Unions Es gibt F¨ alle, in denen man damit umgehen k¨onnen muss, dass Variablen einen kontextabh¨ angigen Typ besitzen. Als Motivation f¨ ur die Existenz eines solchen Konstrukts m¨ ochte ich hier das Event-Handling bei graphischen Benutzeroberfl¨ achen anf¨ uhren: • Die Benutzeroberfl¨ achen der verschiedenen g¨angigen Betriebssysteme (MSWindows, Unix, MacOS, etc.) folgen alle demselben Prinzip: Egal, was passiert, ob nun eine Taste am Keyboard gedr¨ uckt wird, die Maus bewegt wird oder sonstige Ereignisse auftreten, die Programme bekommen eine Mitteilung u ¨ber das Auftreten eines solchen Ereignisses als sogenannten Event bzw. als Message mitgeteilt. • Alle Events werden ungeachtet des Typs in dieselbe Queue f¨ ur das jeweilige Programm gestellt, das von ihnen betroffen ist. Das Programm hat die Aufgabe, diese Events St¨ uck f¨ ur St¨ uck aus der Queue zu lesen und darauf zu reagieren. Nun kann man sich leicht vorstellen, dass z.B. ein Keyboard-Event ganz andere Informationen enth¨ alt als ein Mouse-Event. Bei einem Keyboard-Event ist es interessant, welche Taste gedr¨ uckt wurde, welche Modifiers (=Shift, Ctrl, etc.) gerade gedr¨ uckt waren, als die Taste gedr¨ uckt wurde und vielleicht auch noch, wann diese Taste gedr¨ uckt wurde. Bei einem Mouse-Event hingegen ist es interessant, wo der Mauszeiger gerade steht, ob eine oder mehrere Maustasten gedr¨ uckt wurden, etc. Es gibt nun, je nach System, eine Unmenge verschiedener Event-Typen mit der daraus folgenden Unmenge an verschiedener Information, die f¨ ur die einzelnen Typen relevant ist. Eine besonders brutale Methode, wie man nun alle m¨oglichen Informationsteile aller m¨oglichen verschiedenen Events unter einen Hut bekommt, ist die Definition einer struct, die f¨ ur alle verschiedenen F¨ alle alle verschiedenen Members enth¨alt. Je nach Typ des Events ist dann nur ein geringer Bruchteil davon interessant und der Rest enth¨alt
2.4 Zusammengesetzte Datentypen
41
undefinierte Werte. Woher sollte auch bei einem Keyboard Event eine Koordinate kommen? Der Computer weiß ja doch nicht, wo am Schreibtisch nun das Keyboard steht und ob das linke obere oder das linke untere Eck des Schreibtischs die Nullposition markiert :-). Was also ben¨ otigt wird, ist die M¨oglichkeit, sogenannte Varianten zu implementieren. In unserem Fall der Events w¨ urde eine solche Variante bei einem Keyboard-Event nur die Daten besitzen, die f¨ ur das Keyboard-Handling interessant sind, bei einem Mouse-Event nur die, die f¨ ur das Mouse-Handling interessant sind, etc. Wie auch C stellt uns C++ ein solches Konstrukt zur Verf¨ ugung: die union. Auch hier muss ich, wie schon bei den Structures, vorausschicken, dass Unions in C++ durch die Objektorientierung der Sprache umfassender definiert sind, als in C. Auf die Besonderheiten, die sich daraus ergeben, komme ich Abschnitt 15.2 zu sprechen. Bleiben wir hier aber im Augenblick beim Wesentlichen. Per Definition l¨asst sich eine union folgendermaßen charakterisieren: Eine union entspricht einer struct mit dem essentiellen Unterschied, dass sich die Members denselben Speicherplatz teilen! Dadurch wird Folgendes impliziert: • Der Speicherbedarf einer union ist immer genau so hoch, wie der Speicherbedarf f¨ ur den gr¨ oßten darin enthaltenen Member. Dies kommt genau daher, dass ja Members in einer union nur alternativ G¨ ultigkeit besitzen (anstatt gleichzeitig, wie bei einer struct). • Gerade erw¨ ahnt, aber weil es so wichtig ist, noch einmal: Es besitzt zu einem gewissen Zeitpunkt immer nur ein einziger Member G¨ ultigkeit! Die restlichen Members enthalten keine sinnvollen Werte. • Die Deklaration einer union erfolgt ¨ aquivalent zur Deklaration einer struct, nur wird statt des Keywords struct das Keyword union verwendet. • Der Zugriff auf Members einer union erfolgt analog zum Zugriff auf Members einer struct. Es wird ebenfalls einem Variablennamen ein Punkt gefolgt vom Namen des gew¨ unschten Members nachgestellt. • Es gibt keine von der Sprache direkt unterst¨ utzte M¨oglichkeit, zur Laufzeit herauszufinden, welcher der Members einer union gerade G¨ ultigkeit besitzt! Vorsicht Falle: Wie schon erw¨ ahnt, stellt C++ keine direkte M¨oglichkeit zur Verf¨ ugung, mittels der man den zu einem bestimmten Zeitpunkt g¨ ultigen Member einer union herausfinden kann. Genau dieser Umstand macht eine union zu einem potentiellen Kandidaten f¨ ur Softwarefehler. In Programmen finden sich leider immer wieder die abstrusesten Konstrukte, die entweder davon ausgehen, dass “jetzt sowieso nur dieser Member g¨ ultig sein kann” oder in denen sonstige wilde Annahmen und Sch¨atzungen stattfinden. Vor solchen Vorgehensweisen m¨ ochte ich unbedingt abraten, denn diese f¨ uhren unweigerlich fr¨ uher oder sp¨ ater durch Fehlzugriffe zu den unglaub-
42
2. Datentypen und Variablen
lichsten Effekten. In der Folge werden Methodiken vorgestellt, wie man auch ein so gef¨ ahrliches Konstrukt wie eine union sauber und sicher verwenden kann. Die erste Methode, wie man erreichen kann, dass zur Laufzeit bekannt ist, welcher Member einer union gerade G¨ ultigkeit hat, wird im folgenden Demoprogr¨ ammchen gezeigt (first_union_demo.cpp): 1
// f i r s t u n i o n d e m o . cpp − demo program f o r s a f e r use o f unions
2 3
#include < i o s t r e a m>
4 5 6
#define KEY EVENT 1 #define MOUSE EVENT 2
7 8 9 10 11 12 13
struct KeyEvent { unsigned e v e n t t y p e ; unsigned char key ; unsigned char m o d i f i e r s ; };
14 15 16 17 18 19 20 21
struct MouseEvent { unsigned e v e n t t y p e ; unsigned x c o o r d ; unsigned y c o o r d ; unsigned char b u t t o n s p r e s s e d ; };
22 23 24 25 26 27 28
union Event { unsigned e v e n t t y p e ; struct KeyEvent k e y e v e n t ; struct MouseEvent mouse event ; };
29 30 31 32
int main ( int a r g c , char ∗ argv [ ] ) { union Event event ;
33
// used f o r a key event event . e v e n t t y p e = KEY EVENT; event . k e y e v e n t . key = ’ x ’ ; event . k e y e v e n t . m o d i f i e r s = 0 ;
34 35 36 37 38
// the same v a r i a b l e , but used f o r a mouse event event . e v e n t t y p e = MOUSE EVENT; event . mouse event . x c o o r d = 1 0 ; event . mouse event . y c o o r d = 4 0 ; event . mouse event . b u t t o n s p r e s s e d = 0 ;
39 40 41 42 43 44
return ( 0 ) ;
45 46
}
Absichtlich ist hier kein Output in den laufenden Text eingebunden, denn das tolle Programm liefert ganz einfach keinen :-). Leser, die im Umgang mit C noch nicht ge¨ ubt sind, finden in den Zeilen 5– 6 ein bisher unbekanntes Konstrukt vor. Keine Panik – der Preprocessor, f¨ ur den diese Zeilen gedacht sind, wird in Kapitel 7 noch n¨aher erkl¨art. Einst-
2.4 Zusammengesetzte Datentypen
43
weilen m¨ ochte ich es dabei belassen, dass in den Zeilen 5 und 6 einfach nur symbolische Konstanten definiert sind, die den Code lesbarer machen. Es sind dies die Konstanten KEY_EVENT und MOUSE_EVENT, die dann in den Zeilen 35 und 40 f¨ ur die Angabe des Event-Typs verwendet werden. Das Kernst¨ uck unseres Programms findet sich in den Zeilen 23–28: die Deklaration der union Event. Diese ist so definiert, dass sich drei Members denselben Speicherplatz teilen, n¨ amlich ein einfacher unsigned-Member und zwei weitere Members, die alternativ entweder eine struct KeyEvent oder eine struct MouseEvent speichern k¨ onnen. Warum nun garantiert werden kann, dass der Member event_type aus der union Event immer G¨ ultigkeit hat und nicht den Alternativen zum Opfer f¨allt, sieht man am besten an Abbildung 2.1, die die Anordnung der Elemente im Speicher skizziert. alternate union members
unsigned
memory addresses
event_type
struct KeyEvent
struct MouseEvent
event_type
event_type
key
event_type
modifiers
event_type
union Event
buttons_pressed
Abbildung 2.1: Anordnung der Union-Members im Speicher
In dieser Skizze ist der Adressraum im Speicher des Computers von oben nach unten aufsteigend aufgetragen. Von links nach rechts sind die einzelnen u ¨berlappenden Members der union Event mit ihrer jeweiligen Ausdehnung im Adressraum eingezeichnet. Hierbei symbolisieren die jeweiligen mit vollem Strich gezeichneten Bl¨ ocke den tats¨ achlichen Speicherbedarf einzelner Teile der Alternativen. Strichliert eingefasst sind die Gesamtelemente. Die Skizze ist also wie folgt zu lesen: • Ganz links ist der erste Member der union Event, also der unsigned, der den Typ repr¨ asentiert, aufgezeichnet. • Das zweite Element von links ist eine Skizze der struct KeyEvent mit ihren einzelnen Members, n¨ amlich event_type, key und modifiers. An diesem Element sieht man beispielhaft, was bei der Beschreibung von Structures damit gemeint war, dass nicht garantiert ist, dass die Elemente im Speicher unmittelbar aufeinander folgen: Zwischen key und modifiers ist ein solches m¨ ogliches “Loch” dargestellt. • Das dritte Element ganz rechts ist eine Skizze der struct MouseEvent mit ihren einzelnen Members.
44
2. Datentypen und Variablen
• Die union Event braucht genau so viel Speicher, wie der gr¨oßte ihrer Members, in diesem Fall die struct MouseEvent. Bei der Besprechung von Structures war neben dem eventuellen Vorhandensein von L¨ ochern im Speicher auch die Rede davon, dass die Startadresse einer struct garantiert gleich der Startadresse ihres ersten Members ist. Die wichtige Aussage, die es bei Unions zu Adressen im Speicher zu treffen gibt, ist Folgende: Die Startadresse der alternativen Members, die in einer union deklariert sind, ist garantiert f¨ ur alle dieselbe und diese ist auch garantiert die Startadresse der gesamten union. Genau aus diesen Aussagen zu den Startadressen von Members in einer struct und in einer union ergibt sich, dass die event_type members alle garantiert genau u ¨bereinander (=an ein und derselben Stelle) zu liegen kommen. Es ist also egal, welcher Member der union gerade g¨ ultig ist, das Ergebnis der Abfrage nach dem event_type ist garantiert immer dasselbe. Das Einzige, worauf jetzt noch unbedingt Acht gegeben werden muss ist, dass man niemals vergisst, den zu einer gewissen Zeit g¨ ultigen Typ bei einer Zuweisung auch wirklich korrekt entsprechend ihrer Verwendung zu setzen. Dass man vor jeder Auslese-Operation aus einer union dann auch den Typ abfragen und entsprechend handeln muss, versteht sich selbstredend. Die soeben vorgestellte Vorgehensweise hat den Vorteil, dass man bei Verwendung von Pointern und mittels eines expliziten Typecasts die Union auf den derzeit aktiven Member casten kann (siehe Abschnitt 3.6). Dadurch w¨ urde man sich das hier sehr st¨ orende doppelte Ansprechen von Members ersparen, wie es z.B. in Zeile 36 vorkommt. Es gibt auch eine andere M¨oglichkeit, wie man eine union einsetzen kann, n¨amlich als anonymous union. Betrachten wir dies gleich an einem Beispiel (anonymous_union_demo.cpp): 1
// anonymous union demo . cpp − demo program f o r anonymous unions
2 3
#include < i o s t r e a m>
4 5 6 7
#define INT TYPE 1 #define FLOAT TYPE 2 #define DOUBLE TYPE 3
8 9 10 11 12 13 14 15 16 17 18
struct NumberAlternative { short type ; union { int i n t v a l u e ; float f l o a t v a l u e ; double d o u b l e v a l u e ; }; };
19 20 21 22
int main ( int a r g c , char ∗ argv [ ] ) { struct NumberAlternative n u m a l t e r n a t i v e ;
23 24
// used as a f l o a t j u s t a c c e s s f l o a t v a l u e
2.4 Zusammengesetzte Datentypen
45
// o f the anonymous union n u m a l t e r n a t i v e . type = FLOAT TYPE; num alternative . f l o a t v a l u e = 17.0;
25 26 27 28
// used as an i n t j u s t a c c e s s i n t v a l u e // o f the anonymous union n u m a l t e r n a t i v e . type = INT TYPE ; num alternative . int value = 10;
29 30 31 32 33
return ( 0 ) ;
34 35
}
In diesem Beispiel findet sich eine anonymous union direkt als Member einer struct in den Zeilen 12–17 wieder. Da kein expliziter Member in der struct existiert, der vom Typ union w¨ are, sondern einfach nur 3 Members zu einer union zusammengefasst werden, sind diese 3 Members der struct einfach alternativ, ohne weitere Indirektion ansprechbar. In den Zeilen 27 und 32 sieht man, dass alternativ die Members float_value oder int_value verwendet werden, je nachdem, welcher Datentyp nun gehalten wird. Vorsicht Falle: Trotz aller Vorteile, die eine union in bestimmten F¨allen bietet, m¨ ochte ich doch besonders darauf hinweisen, dass sie in einem Programm einen besonderen Gefahrenpunkt darstellt! Im OO-Teil dieses Buchs werden wir M¨ oglichkeiten kennen lernen, wie man durch saubere Datenkapselung diese Gefahren so weit wie m¨ oglich eliminiert. Es gibt n¨amlich nur sehr wenige Probleme, die noch schlimmer sind, als sich mit irrt¨ umlich fehlinterpretierten Daten herumzuschlagen! Manche Leser m¨ ogen sich nun wundern, warum ich das Thema der impliziten Initialisierung bei Unions bisher unter den Tisch gekehrt habe. Der Grund ist einfach: Eine union wird zwar gleich wie z.B. eine struct auf einen Nullwert initialisiert, allerdings kann man sich leicht vorstellen, dass es hierbei eine kleine Diskrepanz gibt. Die Members einer union teilen sich denselben Speicherplatz, welchen Member also setzt man auf 0? Im Falle, dass alle Members primitive Datentypen bzw. Structures bestehend nur aus primitiven Datentypen sind, ist die Antwort einfach, wenn man um die internen Repr¨ asentationen der primitiven Datentypen Bescheid weiß: Es wird einfach der gesamte Speicher, der von der union beansprucht wird, Byte f¨ ur Byte auf 0 gesetzt und damit ergeben sich automatisch f¨ ur alle Members die entsprechenden Nullwerte. Gar nicht so einfach zu beantworten wird die Frage sp¨ater im OO-Teil werden, wenn wir es mit Classes zu tun bekommen. Deshalb m¨ochte ich bereits hier im Vorgriff eine Warnung aussprechen: Vorsicht Falle: Dadurch, dass sich die Members einer union denselben Speicherplatz teilen, besteht das Problem, dass beim Anlegen einer union bekannt sein m¨ usste, welcher Member gerade G¨ ultigkeit besitzt, um sie entsprechend implizit initialisieren zu k¨ onnen. Aber genau das kann man gar
46
2. Datentypen und Variablen
nicht wissen! Aus diesem Grund darf man sich niemals auf die implizite Initialisierung von Unions verlassen. Der Vollst¨ andigkeit halber m¨ ochte ich hier zum Thema union noch den Querverweis auf Kapitel 19 aus Softwareentwicklung in C anf¨ uhren, in dem dieses Thema ausf¨ uhrlich behandelt wird.
2.5 Scope und Lifetime Zwei wichtige Kenngr¨ oßen begegnen uns im Life-Cycle von Variablen: Scope und Lifetime. Dabei bezeichnet der Begriff Scope (auch: Sichtbarkeit), wo im Programm eine Variable unter dem Namen ansprechbar ist, unter dem sie definiert wurde. Der Begriff Lifetime (auch: Lebenszeit) bezeichnet, wie lange der Speicher, der beim Definieren einer Variable angelegt wurde, erhalten bleibt. Diese zwei Gr¨ oßen sind keineswegs a¨quivalent, wie von manchen Entwicklern f¨ alschlicherweise angenommen wird! Abgesehen von einigen Besonderheiten, die uns im Lauf der Zeit in diesem Buch noch begegnen werden, gelten f¨ ur C++ dieselben Regeln f¨ ur Scope und Lifetime, wie sie auch in C G¨ ultigkeit besitzen: • Eine Variable ist immer innerhalb des Blocks sichtbar, in dem sie definiert wurde und zwar genau ab dem Punkt, an dem sie definiert wurde (man kann ja an jeder beliebigen Stelle im laufenden Code eine Variable definieren). • Die Lifetime einer Variable erstreckt sich von deren Definition bis zum Verlassen des umschließenden Blocks. Allerdings kann man als Entwickler explizit durch static-Definitionen darauf Einfluss nehmen. • Bei Schachtelung von Bl¨ ocken k¨ onnen Definitionen auftreten, bei denen Variablen ¨ außerer Bl¨ ocke durch Variablen innerer Bl¨ocke “versteckt” werden. Dies passiert, wenn in einem inneren Block eine Variable mit demselben Namen definiert wird, wie sie schon in einem ¨außeren Block definiert wurde. Durch eine solche Definition kommt eine ¨außere Variable out of Scope. • Globale Variablen werden einem fiktiven “alles umschließenden Block” zugerechnet und dementsprechend beginnt ihre Lifetime zu dem Zeitpunkt, zu dem ihr Initialisierungscode ausgef¨ uhrt wird (noch vor Aufruf von main!) und dauert den gesamten restlichen Programmlauf an. Ihr Scope ist ohne explizite Deklaration aus rein compilertechnischen Gr¨ unden auf ein einziges File beschr¨ ankt, allerdings kann man den Scope durch entsprechende extern Deklaration auf andere Files erweitern.
2.6 Symbolische Konstanten
47
Ich m¨ochte Lesern, die unge¨ ubt im Umgang mit Scope und Lifetime von Variablen in C sind, w¨ armstens die Lekt¨ ure der Abschnitte 8.2, 17.1 und 17.3 aus Softwareentwicklung in C ans Herz legen! Zu Scope und Lifetime gibt es noch zus¨atzliche Aspekte, die sich aus der Objektorientierung und aus dem Konzept von Namespaces ergeben. Diese besonderen Aspekte werden an den entsprechenden Stellen erg¨anzend angef¨ uhrt.
2.6 Symbolische Konstanten Der Begriff der symbolischen Konstanten bedeutet eine Zuordnung eines symbolischen Namens zu einem konstanten Wert, wobei im Programm der symbolische (sprechende!) Name verwendet wird, anstelle des dahinter versteckten konstanten Wertes. Entwickelt man z.B. Software, in der die maximale Anzahl gleichzeitiger Benutzer eine Rolle spielt, so ist es nat¨ urlich w¨ unschenswert, im Code einfach einen symbolischen Namen wie MAX_USERS zu verwenden, anstatt z.B. u ¨berall 120 hinzuschreiben. F¨ ur den Gebrauch von symbolischen Konstanten gibt es vor allem zwei Hauptmotivationen: • Konstanten mit sprechenden Namen machen den Code bei weitem leichter lesbar als kryptische hardcodierte Werte. • Konstanten, die an einer Stelle definiert und dann vielfach im Code verwendet werden, erh¨ ohen die Wartbarkeit und Erweiterbarkeit der Software erheblich. Nimmt man das Beispiel mit der maximalen Anzahl gleichzeitiger Benutzer von zuvor zur Hand, dann sieht man sofort, dass es einfacher ist, an einer Stelle die Definition von MAX_USERS von z.B. 120 auf 200 zu ¨andern, anstatt im gesamten Code nach jedem Auftreten der Zahl 120 zu suchen und diese auszubessern. Lesern, die nun meinen, dass so etwas ja sowieso bei modernen Editoren nach der Search-and-Replace Methode funktioniert, m¨ ochte ich nur entgegenhalten, dass ja keinesfalls garantiert ist, dass die Zahl 120 nicht auch anderw¨artig f¨ ur etwas v¨ollig Verschiedenes gebraucht wird. Diese Vorkommen will man nat¨ urlich nicht ¨andern und damit bleibt nur noch m¨ uhsamste Handarbeit. Ab einer gewissen Menge ¨ Code ist jede Anderung de facto unm¨oglich. Im Unterschied zu C, wo ausschließlich Preprocessor-Macros f¨ ur symbolische Konstanten verwendet werden, ist in C++ eine M¨oglichkeit der Definition von symbolischen Konstanten im Sprachumfang enthalten. Diese werden im Prinzip definiert wie Variablen, nur dass vor die Bezeichnung des Datentyps noch das Keyword const gestellt wird. Der Vorteil der Art der Definition von Konstanten in C++ gegen¨ uber der in C u blichen Preprocessor-Variante liegt auf der Hand: Konstanten in C++ ¨
48
2. Datentypen und Variablen
besitzen einen genau definierten Datentyp! Mit Preprocessor-Macros erreicht man nur eine rein textuelle Ersetzung im Code, die keine Typensicherheit bietet. Wie man am folgenden Beispiel sehen kann, ist die Definition von Konstanten v¨ ollig intuitiv (const_demo.cpp): 1 2
// const demo . cpp − a t i n y demo program f o r the use o f // t y p e s a f e c o n s t a n t d e f i n i t i o n s i n C++
3 4
#include < i o s t r e a m>
5 6 7 8
const unsigned short RED PART = 0; const unsigned short GREEN PART = 1 ; const unsigned short BLUE PART = 2 ;
9 10 11
const unsigned short MAX COLOR INTENSITY = 0xFF ; const unsigned short MIN COLOR INTENSITY = 0 x00 ;
12 13 14 15 16 17 18
int main ( int a r g c , char ∗ argv [ ] ) { // c o n s t a n t s do not need to be g l o b a l , the same // r u l e s apply f o r the scope o f c o n s t a n t s t h a t // a r e v a l i d f o r v a r i a b l e s i n C++ const unsigned short NUM COLOR CHANNELS = 3 ;
19
unsigned short r g b v a l u e [NUM COLOR CHANNELS] ;
20 21
r g b v a l u e [RED PART] = MAX COLOR INTENSITY; r g b v a l u e [GREEN PART] = MIN COLOR INTENSITY ; r g b v a l u e [BLUE PART] = MIN COLOR INTENSITY ;
22 23 24 25
return ( 0 ) ;
26 27
}
Ich denke, es er¨ ubrigt sich, dieses Programm genau zu erkl¨aren. Allerdings schaden ein paar kleine Anmerkungen vielleicht nicht: • Wie der Name schon sagt, sind Konstanten unver¨anderbar. Also wird logischerweise jeder Versuch, einer Konstante im Programm etwas zuzuweisen, mit einem Compilerfehler enden. • Konstanten m¨ ussen explizit auf einen Wert initialisiert werden. Vergisst man die Initialisierung und schreibt z.B. const unsigned short RED_PART; so wird dies vom Compiler bem¨ angelt. Dieses Verhalten ist auch logisch, denn wozu sollte man eine Konstante brauchen, wenn sie dann vom Compiler implizit auf 0 gesetzt werden soll? • Im Gegensatz zu Preprocessor-Macros folgen Konstantendefinitionen in C++ den Scope-Regeln, die auch f¨ ur Variablen gelten. • Weil eine Konstante nicht ¨ anderbar ist, muss auch nicht unbedingt Speicherplatz f¨ ur sie reserviert werden. Der Compiler kann nach vorheriger ¨ Uberpr¨ ufung der Typenkompatibilit¨at ihren Wert einfach einsetzen. Dies hat zwei Konsequenzen:
2.7 Eigene Typdefinitionen
49
1. Konstanten haben im Prinzip keine Lifetime, denn im laufenden Programm gibt es f¨ ur sie nicht unbedingt einen reservierten Speicher. Einzig der Scope folgt garantiert denselben Regeln wie f¨ ur Variablen. 2. Die Zugriffszeiten von Konstanten nach C++ Konvention sind, wenn der Compiler sauber arbeitet, ¨ aquivalent zu denen, die bei Verwendung von Preprocessor-Macros entstehen. Manche Entwickler verwenden C++ Konstanten nicht, da sie glauben, dass sie dadurch Einbußen bei der Laufzeit des Programms in Kauf nehmen m¨ ussen, doch das ist falsch. Nur der Vollst¨ andigkeit halber m¨ ochte ich an dieser Stelle erw¨ahnen, dass wir neben der Definition von symbolischen Konstanten noch andere Situationen kennen lernen werden, in denen das Keyword const eine wichtige Rolle spielt. Entsprechende Erg¨ anzungen folgen zu gegebener Zeit.
2.7 Eigene Typdefinitionen Es gibt verschiedene F¨ alle, bei denen Entwickler sich w¨ unschen, f¨ ur einen bestimmten Datentyp einen symbolischen Namen zur Verf¨ ugung zu haben. Z.B. wissen wir, dass das Fassungsverm¨ogen eines int maschinenabh¨angig ist. Es gibt aber F¨ alle, in denen man unbedingt z.B. einen 32 Bit langen Ganzzahltyp braucht und in denen es daher nicht ausreichend ist, zu wissen, dass ein int auch eventuell nur 16 Bit lang sein kann, weil man mit 16 Bit nicht arbeiten kann. Speziell bei plattform¨ ubergreifender Entwicklung ist es definitiv ein Ding der Unm¨ oglichkeit, beim Portieren eines Programms z.B. alle int-Variablen h¨ andisch auf long zu ¨andern, bloß weil ein int auf der Zielplattform einfach zu kurz ist. Aus diesem Grund gibt es in C das typedef-Statement, das auch in C++ zur Verf¨ ugung steht. Mit Hilfe dieses Statements kann man ein benutzerdefiniertes Synonym f¨ ur einen bestimmten Datentyp einf¨ uhren, wie man an folgendem Beispiel sieht (typedef_demo.cpp): 1
// typedef demo . cpp − demo program f o r the use o f t y p e d e f
2 3
#include < i o s t r e a m>
4 5 6
using s t d : : cout ; using s t d : : e n d l ;
7 8 9 10 11 12 13 14 15
typedef typedef typedef typedef typedef typedef typedef typedef
signed char i n t 8 ; unsigned char u i n t 8 ; short i n t 1 6 ; unsigned short u i n t 1 6 ; int i n t 3 2 ; unsigned int u i n t 3 2 ; long long i n t 6 4 ; unsigned long long u i n t 6 4 ;
16 17 18 19
int main ( int a r g c , char ∗ argv [ ] ) { int8 example 8 bit integer = 0;
50
2. Datentypen und Variablen
uint8 example unsigned 8 bit integer = 0;
20 21
int64 example 64 bit integer = 0; uint64 example unsigned 64 bit integer = 0;
22 23 24
cout << ” s i z e o f i n t 8 i s ” << s i z e o f ( i n t 8 ) ∗ ” B i t s ” << e nd l ; cout << ” s i z e o f i n t 1 6 i s ” << s i z e o f ( i n t 1 6 ) ” B i t s ” << e nd l ; cout << ” s i z e o f i n t 3 2 i s ” << s i z e o f ( i n t 3 2 ) ” B i t s ” << e nd l ; cout << ” s i z e o f i n t 6 4 i s ” << s i z e o f ( i n t 6 4 ) ” B i t s ” << e nd l ; return ( 0 ) ;
25 26 27 28 29 30 31 32 33 34
8 << ∗ 8 << ∗ 8 << ∗ 8 <<
}
Wenn dieses Programm z.B. unter einem 32 Bit Linux compiliert wird, dann liefert es den folgenden Output (ja, ich weiß, beim Compilieren mit Option -Wall liefert der Compiler auch Warnings u ¨ber unused Variables :-)): size size size size
of of of of
int8 i s 8 Bits int16 i s 16 Bits int32 i s 32 Bits int64 i s 64 Bits
Das bedeutet jetzt also, dass wir ein sch¨ones Werkzeug in der Hand haben, um uns endlich auf eine definierte Gr¨oße von Datentypen verlassen zu k¨onnen, sofern wir die typedef-Statements auf den einzelnen Plattformen entsprechend anpassen. Wollte man z.B. auf einer DEC-Alpha dieselben Datentypen zur Verf¨ ugung haben, so w¨ urden f¨ ur die beiden 64 Bit Varianten von Ganzzahlen auch die folgenden typedef-Statements gen¨ ugen: typedef long int64; typedef unsigned long uint64; Diese beiden typedef-Statements w¨ urden allerdings z.B. unter einem 32 Bit Linux nur f¨ ur 32 Bit lange Ganzzahlen gut sein. Um uns bei allen weiteren Beispielen in diesem Buch nicht mehr mit Betrachtungen zu den Plattformabh¨ angigkeiten von Ganzzahldatentypen herumschlagen zu m¨ ussen, m¨ ochte ab nun folgende Konvention einf¨ uhren: Wir schreiben ein Header-File namens user_types.h, das die zuvor angef¨ uhrten typedef-Statements enth¨ alt. Dieser Header wird in allen folgenden Programmen inkludiert und es werden ausschließlich die darin definierten Datentypen verwendet. Der angesprochene Header user_types.h sieht unter 32 Bit Linux aus wie folgt: 1
// u s e r t y p e s . h − important t y p e d e f s
2 3 4 5 6 7 8 9 10
typedef typedef typedef typedef typedef typedef typedef typedef
signed char i n t 8 ; unsigned char u i n t 8 ; short i n t 1 6 ; unsigned short u i n t 1 6 ; int i n t 3 2 ; unsigned int u i n t 3 2 ; long long i n t 6 4 ; unsigned long long u i n t 6 4 ;
2.7 Eigene Typdefinitionen
51
Um u ufen zu k¨ onnen, ob auf einer bestimmten Zielplattform die durch ¨berpr¨ typedef spezifizierten Synonyme auch korrekt sind, kann man folgendes Testprogramm heranziehen: 1 2
// u s e r t y p e s t e s t . cpp − t e s t program to determine whether // u s e r t y p e s . h has to be adopted f o r a new development p l a t f o r m .
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
int main ( int a r g c , char ∗ argv [ ] ) { cout << ” s i z e o f i n t 8 i s ” << s i z e o f ( i n t 8 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f u i n t 8 i s ” << s i z e o f ( u i n t 8 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f i n t 1 6 i s ” << s i z e o f ( i n t 1 6 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f u i n t 1 6 i s ” << s i z e o f ( u i n t 1 6 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f i n t 3 2 i s ” << s i z e o f ( i n t 3 2 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f u i n t 3 2 i s ” << s i z e o f ( u i n t 3 2 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f i n t 6 4 i s ” << s i z e o f ( i n t 6 4 ) ∗ 8 << ” B i t s ” << e nd l ; cout << ” s i z e o f u i n t 6 4 i s ” << s i z e o f ( u i n t 6 4 ) ∗ 8 << ” B i t s ” << e nd l ; return ( 0 ) ; }
Dieses Programm muss auf jeder Zielplattform den folgenden Output liefern, um die Korrektheit der definierten Synonyme zu gew¨ahrleisten: size size size size size size size size
of of of of of of of of
int8 i s 8 Bits uint8 i s 8 Bits int16 i s 16 Bits uint16 i s 1 6 Bits int32 i s 32 Bits uint32 i s 3 2 Bits int64 i s 64 Bits uint64 i s 6 4 Bits
Sollte der Output auf einer Plattform davon abweichen, so sind f¨ ur diese ¨ Plattform die entsprechenden Anderungen in den typedef-Statements vorzunehmen! (Anm.: Bei der Diskussion des Preprocessors in Kapitel 7 werden wir noch M¨oglichkeiten kennen lernen, mittels Macros f¨ ur alle Zielplattformen einen einzigen Header zu erstellen, in dem die entsprechenden Abh¨angigkeiten automatisch ber¨ ucksichtigt werden.)
3. Operatoren
Nachdem nun einmal die wichtigsten Grunddatentypen besprochen wurden, die in C++ zur Verf¨ ugung stehen (Pointer, References und OO-Konstrukte kommen noch), wollen wir mit diesen nat¨ urlich auch vern¨ unftig arbeiten k¨onnen. Dazu werden in der Folge alle Operatoren besprochen, die wir im t¨aglichen Umgang mit C++ brauchen. Der Vollst¨ andigkeit halber m¨ ochte ich an dieser Stelle zwei Dinge erw¨ahnen: 1. In diesem Kapitel sind alle Operatoren angef¨ uhrt, die es in C++ gibt. Zu manchen davon wurden allerdings die Grundlagen bisher noch nicht besprochen, die notwendig sind, um sie genau zu verstehen. Daher wird eine vollst¨ andige Abhandlung zu diesen speziellen Operatoren auf die jeweiligen Kapitel sp¨ ater im Buch verschoben. Um sp¨ ateres Nachschlagen der Operatoren nicht allzu sehr zu erschweren, wollte ich in diesem Kapitel zumindest einen gemeinsamen Einstiegspunkt bieten. 2. In C++ gibt es den Mechanismus des Operator-Overloadings. Dies bedeutet, dass man als Entwickler Zusammenh¨ange zwischen Operatoren und Datentypen sowie die Funktionsweise von Operatoren in Verbindung mit bestimmten Typen beeinflussen bzw. selbst definieren kann. In der Folge wird nur auf die Standard-Bedeutung von Operatoren eingegangen, wie sie im Core von C++ implementiert ist. Welche Operatoren nun welche Funktionsweise in Verbindung mit allen m¨oglichen Libraries haben, muss immer der jeweiligen Dokumentation zu den Libraries entnommen werden. Lesern, die noch sehr wenig Erfahrung mit der Softwareentwicklung im Allgemeinen haben, empfehle ich an dieser Stelle als wichtigen Einstieg die Lekt¨ ure von Kapitel 5 von Softwareentwicklung in C. Dort findet sich eine sehr genaue Beschreibung der wichtigsten Operatoren mit sehr vielen Beispielen.
¨ 3.1 Uberblick und Reihenfolge der Auswertung Die Reihenfolge, in der Operatoren in l¨angeren Ausdr¨ ucken ausgewertet werden, ist im Sprachstandard von C++ festgelegt und in Stein gemeißelt. Daran
54
3. Operatoren
kann auch durch Operator-Overloading, wie wir es noch kennen lernen werden, nichts ge¨ andert werden. Die Regeln, die hierbei gelten, sind einfach: • Alle Operatoren besitzen einen bestimmten Rang (=Precedence). Eine Auflistung der Operatoren nach ihren R¨angen ergibt die sogenannte Rangreihenfolge (=Order of Precedence) der Operatoren untereinander. Ein typisches Beispiel f¨ ur die Rangreihenfolge kennt man ja aus der Schulmathematik, wo z.B. die Regel Punkt- vor Strichrechnung gelehrt wird. Diese besagt, dass Multiplikation und Division (=Punktrechnung) ranggleich sind, ebenso wie Addition und Subtraktion (=Strichrechnung), dass jedoch die Punktrechnung insgesamt im Rang h¨oher ist als die Strichrechnung. In wie auch immer gearteten Ausdr¨ ucken werden die Operatoren immer ihrer Rangreihenfolge gem¨ aß, also vom h¨ochsten bis zum tiefsten Rang absteigend, ausgewertet. • Findet sich in einem Ausdruck eine Kette von Operatoren mit demselben Rang (also gleichwertige Operatoren), so werden diese der Reihe nach von links nach rechts ausgewertet. • Wenn die Reihenfolge der Auswertung beeinflusst werden soll, so gibt es die M¨ oglichkeit zur Klammerung von Ausdr¨ ucken. Dazu k¨onnen Paare von runden Klammern in beliebiger Schachtelungstiefe verwendet werden. Bei Klammerungen wird, wie zu erwarten, garantiert, dass die Ausdr¨ ucke von innen nach außen in der Schachtelungshierarchie abgearbeitet werden. Aus Gr¨ unden der Lesbarkeit von Ausdr¨ ucken m¨ochte ich noch eine Kleinigkeit anregen: Klammerungen d¨ urfen nat¨ urlich nicht ausschließlich dazu verwendet werden, die vorgegebene Auswertungsreihenfolge zu ver¨andern, sondern sie sind immer erlaubt. F¨ ur die Reihenfolge der Auswertung im Prinzip unn¨otige Klammerungen schlagen sich definitiv nicht im Laufzeitverhalten des Programms nieder. Allerdings erh¨ohen sie die Lesbarkeit von l¨angeren Ausdr¨ ucken erheblich, wenn man sie zum Signalisieren von logischen Zusammengeh¨ origkeiten verwendet. Salopp formuliert w¨are mein Rat also Folgender: Ein paar Klammern zu viel schaden nicht, ein paar zu wenig machen das Leben schwer. ¨ In der Folge ist aus Gr¨ unden des Uberblicks einmal eine Tabelle mit der Gesamtaufstellung aller Operatoren zusammen mit ihrem zugeh¨origen Rang zu finden. Hierbei bedeutet eine niedrige Zahl f¨ ur den Rang eine h¨ohere Priorit¨at in der Reihenfolge. Diese Konvention, dass eine niedrige Rangzahl eine hohe Priorit¨ at bedeutet, ist in sehr vielen Priorit¨atsschemata u ¨blich, deshalb will ich mit dieser Systematik hier nicht brechen. F¨ ur die Bedeutungen der einzelnen Operatoren wurden bewusst die englischen Bezeichnungen verwendet, da diese im t¨aglichen Leben des Softwareentwicklers gebr¨ auchlicher sind als die oftmals sehr holprigen deutschen ¨ Ubersetzungen. Allen Lesern, die nun erschrecken, weil die Tabelle vielleicht doch auf den ersten Blick nicht wirklich unglaublich informativ erscheint, kann ich nur
¨ 3.1 Uberblick und Reihenfolge der Auswertung
55
sagen: Bitte keine Panik! Es ist durchaus kein Problem, die folgende Tabelle einmal einfach zu u ¨berspringen und sie nur bei Bedarf zum Nachschlagen zu verwenden. Eine Legende zu den verwendeten Konventionen findet sich gleich unterhalb der Tabelle. Rang 1
Bedeutung scope resolution
1
global
2 2 2 2 2 2 2 2 2 2 2 2 2 3
member access member access index function call value construction post increment post decrement type identification runtime type info runtime-checked cast compiletime-checked cast unchecked cast remove-const cast size of
3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 5 5 5
pre increment pre decrement bit complement logical NOT unary minus unary plus address of dereference create (allocate) create (alloc and init) create (place) create (place and init) delete (deallocate) delete array cast member access member access multiply divide modulo
Operator im Kontext classname::member namespace-name::member ::name ::qualified-name object.member pointer ->member pointer [expr ] expr (expr-list) type(expr-list) lvalue++ lvalue-typeid(type) typeid(expr ) dynamic_cast(expr ) static_cast(expr ) reinterpret_cast(expr ) const_cast(expr ) sizeof expr sizeof(type) ++lvalue --lvalue ~expr !expr -expr +expr &lvalue *expr new type new type(expr-list) new(expr-list)type new(expr-list)type(expr-list) delete pointer delete[] pointer (type)expr object.*pointer-to-member pointer ->*pointer-to-member expr * expr expr / expr expr % expr
56
3. Operatoren
Rang 6 6 7 7 8 8 8 8 9 9 10 11 12 13 14 15 16 16 16 16 16 16 16 16 16 16 16 17 18
Bedeutung add subtract bit shift left bit shift right less than less than or equal greater than greater than or equal equal not equal bit AND bit XOR bit OR logical AND logical OR conditional expression assign multiply and assign divide and assign modulo and assign add and assign subtract and assign bit shift left and assign bit shift right and assign bit AND and assign bit OR and assign bit XOR and assign throw exception sequencing
Operator im Kontext expr + expr expr - expr expr << expr expr >> expr expr < expr expr <= expr expr > expr expr >= expr expr == expr expr != expr expr & expr expr ^ expr expr | expr expr && expr expr || expr expr ? expr : expr lvalue = expr lvalue *= expr lvalue /= expr lvalue %= expr lvalue += expr lvalue -= expr lvalue <<= expr lvalue >>= expr lvalue &= expr lvalue |= expr lvalue ^= expr throw expr expr , expr
Legende: In der Spalte Operator im Kontext wird die Verwendung des jeweiligen Operators skizziert. Dabei stehen kursiv gesetzte Worte f¨ ur ein Argument, das der Operator nimmt und in teletype gesetzte Worte bzw. Zeichen definieren den Operator selbst. Die Bedeutung der einzelnen Argumente ist wie folgt: name: Beliebiger Name (z.B. Variablenname, Funktionsname) im aktuellen Kontext (wird im OO-Teil besprochen). qualified-name: Beliebiger “vollst¨ andiger” Name ohne R¨ ucksicht auf den aktuellen Kontext (wird im OO-Teil besprochen). classname: Name einer Klasse (wird im OO-Teil besprochen). namespace-name: Name eines Namespaces (wird im OO-Teil besprochen).
3.2 Arithmetische Operatoren
57
type: Eine beliebige Typenbezeichnung, egal ob es sich um einen primitiven Datentyp, einen zusammengesetzten Datentyp oder einen benutzerdefinierten Datentyp handelt. object: Eine beliebige Instanz einer Klasse (siehe OO-Teil), Structure oder Union. member : Name eines Members, egal ob es sich nun um den Member einer Structure, einer Union oder eines Namespaces handelt. pointer : Ein beliebiger Pointer. pointer-to-member : Ein Pointer, der auf einen Member zeigt. expr : Ein beliebiger Ausdruck nach den Syntax- und Semantikregeln von C++. Dies kann z.B. eine Berechnung, wie var1 + var2 sein oder auch ein Funktionsaufruf, wie myFunc(), etc. expr-list: Eine Liste von expr, durch Kommas getrennt. lvalue: Bezeichnet ganz allgemein “irgendeinen Speicher im Memory”, also z.B. eine Variable. Der Begriff lvalue bedeutet voll ausgeschrieben leftvalue, weil er z.B. auf der linken Seite einer Zuweisung vorkommen kann. Die wirklich wichtige Eigenschaft eines lvalues ist die, dass in ihm (eventuell nach Auswertung einer expr, die zu einem lvalue evaluiert) etwas gespeichert werden kann. Das bedeutet, dass z.B. ein Ausdruck var1 = var2 + var3 * var4; gem¨aß den Priorit¨ aten in der Tabelle in folgender Reihenfolge ausgewertet wird: • Zuerst wird var3 mit var4 multipliziert, denn * hat mit 5 den in der Priorit¨ at am h¨ ochsten stehenden Rang. • Das Ergebnis aus dieser Multiplikation wird zu var2 addiert, denn die Addition hat mit 6 den in der Priorit¨at am zweith¨ochsten stehenden Rang. • Danach wird das Endergebnis auf var1 zugewiesen, denn die Zuweisung kommt mit Rang 16 erst zu allerletzt dran.
3.2 Arithmetische Operatoren In C++ finden sich als bin¨ are arithmetische Operatoren die vier Grundrechnungsarten (+, -, *, /) und der “modulo”-Operator (%), der den Divisionsrest berechnet. Der Begriff der bin¨ aren arithmetischen Operatoren kommt daher, dass diese Operatoren immer zwei Operanden, einen linken und einen rechten, entgegennehmen. Ich glaube, es ist unn¨otig, hier eine große Abhandlung zu schreiben, wie man z.B. die Addition zweier Zahlen darstellt, denn dies ist absolut intuitiv. Sehr wohl m¨ ochte ich allerdings eine paar Worte u ¨ber das Verhalten in Bezug auf die Datentypen, die bei einer Operation verwendet werden, verlieren:
58
3. Operatoren
Bis auf den “modulo”-Operator, der nur f¨ ur Ganzzahlen definiert ist, sind alle bin¨ aren arithmetischen Operatoren sowohl auf Ganzzahlen als auch auf Gleitkommazahlen anwendbar. Mischt man verschiedene Datentypen in einer Operation, indem man z.B. einen double-Wert zu einem int addiert, so wird intern eine so genannte implizite Typumwandlung vorgenommen. Diese Umwandlung erfolgt dergestalt, dass immer der m¨achtigste Datentyp f¨ ur die Operation herangezogen wird. Auch das Ergebnis der Operation entspricht dann diesem m¨ achtigsten Datentyp. Im Beispiel der Addition eines double zu einem int wird also zuerst der int in sein double-¨aquivalent umgewandelt, dann wird die Addition durchgef¨ uhrt und das Ergebnis ist vom Typ double. Mischt man z.B. einen short und einen int in einer Operation, so ist der m¨achtigere Datentyp nat¨ urlich int und das Spielchen l¨auft wie erwartet mit int ab. Im Prinzip ist das alles ganz logisch, trotzdem wird immer wieder eine ganz wichtige Tatsache u ¨bersehen: Man muss diesen Umstand auch beim Zuweisen des Ergebnisses an eine Variable beachten! Es passiert leider nur allzu oft, dass der Datentyp der Variable, die das Ergebnis h¨alt, weniger m¨ achtig ist, als das Ergebnis selbst. Damit muss das Ergebnis dann wieder in einen weniger m¨ achtigen Typ gewandelt werden, um in der Variable speicherbar zu sein. Bei dieser Operation besteht dann die Gefahr, dass das Fassungsverm¨ ogen des Ergebnistyps zu klein ist, wodurch es zu einer Verf¨alschung des Ergebnisses kommt. Begleitend zum Thema der impliziten Typumwandlungen empfehle ich die Lekt¨ ure von Kapitel 6 aus Softwareentwicklung in C. Neben den bin¨ aren arithmetischen Operatoren gibt es auch noch die sogenannten un¨ aren arithmetischen Operatoren, also solche, die nicht zwei Operanden miteinander verkn¨ upfen, sondern im Gegensatz dazu nur auf einen Operanden wirken. Ein solcher un¨ arer arithmetischer Operator ist nat¨ urlich das negative Vorzeichen, das man einem Operanden voranstellt. Im Gegensatz zu C gibt es in C++ auch das positive Vorzeichen als definierten un¨aren arithmetischen Operator. Man mag nun sagen, dass dies vielleicht nicht notwendig ist, denn ein positives Vorzeichen tut ja eigentlich gar nichts, allerdings ist es aus Gr¨ unden der Vollst¨andigkeit und in Hinblick auf Operator Overloading durchaus sinnvoll, dieses zuzulassen. Neben dem negativen und dem positiven Vorzeichen gibt es allerdings noch viel interessantere un¨ are Operatoren, die sehr typisch f¨ ur C++ (und auch C) sind und die f¨ ur Neulinge immer wieder einen kleinen Stolperstein darstellen. Die Rede ist hier von den pre- und post-increment, sowie von den pre- und post-decrement Operatoren, die nur auf ganzzahlige Datentypen angewandt werden k¨ onnen. Der increment-Operator wird als ++ dargestellt, der decrement-Operator als --. Ob es sich um eine pre- oder post-Version des jeweiligen Operators handelt, wird dadurch bestimmt, ob er dem Operanden vorangestellt (pre-
3.3 Logische- und Vergleichsoperatoren
59
increment bzw. decrement) oder hinten nachgestellt wird (post-increment bzw. decrement). Eine Anwendung dieser Operatoren auf eine Variable be¨ wirkt eine nachhaltige Anderung ihres Inhalts in Form einer Zuweisung. Dies verr¨at uns auch ein Blick in die Operator-Tabelle: Diese Operatoren verlangen einen lvalue als Operanden! Aber abgesehen von der Inhalts¨anderung hat es noch etwas Besonderes mit diesen Operatoren auf sich, wie man am verschiedenen Rang der pre- und post-Versionen in der Tabelle sieht: • Pre-increment bzw. pre-decrement bewirken, dass zuerst das Ergebnis der increment- bzw. decrement-Operation berechnet (und gespeichert) wird und mit dem neuen Wert weitergearbeitet wird. • Post-increment bzw. post-decrement bewirken, dass mit dem alten Wert weitergearbeitet wird und erst danach die increment- bzw. decrementOperation durchgef¨ uhrt wird. Klingt kompliziert, ist es aber gar nicht, wenn man sich diese Zusammenh¨ange am Beispiel ansieht. Der Ausdruck var1 = var2++; wird so ausgewertet, dass zuerst eine Zuweisung des Inhalts von var2 auf var1 stattfindet und danach var2 inkrementiert wird. Nehmen wir z.B. an, dass vor der Auswertung dieses Ausdrucks var2 den Wert 5 enth¨alt. Dann enth¨alt nach Auswertung des Ausdrucks var1 den Wert 5 und var2 den Wert 6. Betrachtet man den Ausdruck var1 = ++var2; so wird zuerst die increment-Operation durchgef¨ uhrt und dann das Ergebnis zugewiesen. H¨ atte var2 wieder vor der Auswertung den Wert 5, so w¨ urden nach Auswertung des Ausdrucks sowohl var1 als auch var2 den Wert 6 enthalten. Eine sehr genaue Abhandlung zum Thema der pre- und post-increment bzw. -decrement Operatoren findet sich im Buch Softwareentwicklung in C in Abschnitt 5.2.2.
3.3 Logische- und Vergleichsoperatoren Wie man der Tabelle entnehmen kann, stehen zum Vergleich von Werten in C++ folgende Operatoren zur Verf¨ ugung: == pr¨ uft auf Gleichheit, != auf Ungleichheit, > pr¨ uft, ob der linke Wert gr¨oßer als der rechte ist, < pr¨ uft, ob der linke Wert kleiner als der rechte ist, >= und <= funktionieren analog dazu, nur pr¨ ufen sie auf gr¨ oßer oder gleich bzw. auf kleiner oder gleich. Das Ergebnis einer Operation mit diesen Operatoren ist ein Wert vom Typ bool, also ein Wahrheitswert, der entweder true oder false sein kann. Bei der Definition, dass die Operatoren entweder true oder false liefern, erkennt man einen kleinen Unterschied zu C: In C gab es zwar eine genaue
60
3. Operatoren
Definition, dass 0 als false gilt, jedoch war nicht genau definiert, was nun als true gilt. Irgendetwas ungleich 0 eben. Aus diesem Grund gibt es in C keine exakte Definition, welchen Wert sie f¨ ur true liefern. In C++ jedoch gilt garantiert, dass die Operationen ein “echtes” C++ true, also 1 liefern. In den meisten F¨ allen mag das unwichtig sein, allerdings gibt es Umst¨ande, z.B. beim Abspeichern von boolean-Werten in bestimmten Einzelbits einer Ganzzahl, wo diese genauere Definition von C++ sehr n¨ utzlich sein kann. Vorsicht Falle: Der Vergleichsoperator == kann nur allzu leicht einmal durch eine Unachtsamkeit in einem Programm zum Zuweisungsoperator = mutieren. Da die Zuweisung so definiert ist, dass sie als Ergebnis den zugewiesenen Wert liefert, betrachtet das der Compiler keineswegs als Fehler. Soll er auch gar nicht, denn oft wird dies tats¨achlich absichtlich gemacht. Moderne Compiler produzieren allerdings mittlerweile zum Gl¨ uck Warnings u ¨ber possible unintended assignments, die man mit entsprechenden Klammerungen verhindern kann. Als logische Verkn¨ upfungsoperatoren findet man in C++ auch wieder die alten Bekannten, die es schon in C gab: && repr¨asentiert ein logisches AND, || ein logisches OR und ! ein logisches NOT. Bis auf den Tipp, dass gerade bei Verkn¨ upfungen mehrerer logischer Abfragen durch diese Operatoren eine entsprechende Klammerung den Code sehr viel lesbarer macht als eine einfache Aneinanderreihung, m¨ochte ich zu diesen Operatoren keine weiteren Worte verlieren. Sie sind ganz einfach intuitiv anwendbar. Lesern, die in C unge¨ ubt sind, wird zum Thema der Vergleichsoperatoren und der logischen Verkn¨ upfungen Abschnitt 5.3 aus Softwareentwicklung in C noch Zusatzinformation geboten.
3.4 Bitoperatoren Die Bit-Verkn¨ upfungsoperatoren & (Bit-AND), | (Bit-OR), ^ (Bit-XOR) und ~ (Bit-NOT oder auch Komplement bzw. 1-er Komplement) sowie die entsprechenden Bit-Shiftoperatoren << (Bit-left-shift bzw SL) und >> (Bitright-shift bzw SR) stehen in C++ zur Manipulation von Einzelbits und Bitmasken in ganzzahligen Werten zur Verf¨ ugung. Leider hat sich vor allem in den letzten Jahren die Meinung stark eingeb¨ urgert, dass man dieses “maschinennahe Zeug” ja sowieso nicht braucht und dass das nur f¨ ur Assemblerprogrammierer interessant ist. Dies ist mitnichten der Fall! Im Prinzip begegnen Softwareentwicklern viel o¨fter sogenannte Flags, als auf den ersten Blick zu vermuten w¨are (z.B. bei FileOperationen). Weiters hilft das Wissen um Bitrepr¨asentationen und Bitope-
3.6 Datentypabfragen und explizite Typecasts
61
ratoren in gewissen Bereichen unglaublich viel, wenn es um effiziente Implementationen geht. Ich habe jetzt nicht vor, eine lange Abhandlung u ¨ber Bitoperatoren hier in diesem Buch zu verewigen. Allerdings muss ich allen Lesern, die sich im Umgang mit Bitmasken und ¨ ahnlichen Konstrukten nicht ganz sicher f¨ uhlen, unbedingt den dringenden Rat geben, sich einmal Abschnitt 5.4 im Buch Softwareentwicklung in C genau zu Gem¨ ute zu f¨ uhren! Dort wird auf die wichtigen Zusammenh¨ ange sehr detailliert und beispielunterst¨ utzt eingegangen.
3.5 Zuweisungen Der Zuweisungs-Operator =, sowie die einzelnen Zuweisungs-Kurzformen (*=, /=, %=, +=, -=, <<=, >>=, &=, |= und ^=) sind in C++ ¨aquivalent zu C. Der Ausdruck var1 += var2; ist die Kurzform zu var1 = var1 + var2; Man sieht also, dass durch die Verwendung der Zuweisungs-Kurzformen Ausdr¨ ucke k¨ urzer und kompakter formuliert werden k¨onnen. Darum und weil die Verwendung der Kurzformen sowieso in C und C++ absolut typisch ist, m¨ochte ich unbedingt anregen, diese Operatoren nicht zu ignorieren. Von vielen Neulingen werden diese Operatoren als etwas Unn¨otiges erachtet und daher werden sie nicht verwendet. Im Sinne eines guten Programmierstils m¨ochte ich von dieser Vorgehensweise abraten. Außerdem m¨ochte ich auch noch darauf hinweisen, dass es sehr wohl ziemliche Unterschiede in der Performance zwischen Kurzzuweisung und Operation mit nachfolgender Zuweisung geben kann. Der Beweis zu dieser Aussage wird sp¨ater noch in Kapitel 12 angetreten.
3.6 Datentypabfragen und explizite Typecasts Es wurde bereits im Rahmen der Diskussion der arithmetischen Operatoren (siehe Abschnitt 3.2) besprochen, dass der Compiler gewisse implizite Umwandlungen von Datentypen vornimmt, wenn dies zur Durchf¨ uhrung einer Operation notwendig ist. Sehr oft will man dies nicht dem Compiler u ¨berlassen, sondern selbst eine Umwandlung erzwingen. Dies geschieht durch sogenannte explizite Typecasts (oder auch explizite Casts). Die Gr¨ unde f¨ ur explizite Casts sind mannigfaltig, der H¨aufigste davon ist, dass es unter gewissen Umst¨ anden dem Compiler beim besten Willen nicht m¨oglich ist, zu erraten, welcher Typ denn nun der geeignete ist, bzw. auch, wie denn nun
62
3. Operatoren
die Umwandlung in diesen Typ erfolgen soll. Vor allem im OO-Teil werden wir noch viele Beispiele zu dieser Thematik kennen lernen. Im Unterschied zu C, das nur einen einzigen Cast-Operator kennt, gibt es in C++ mehrere verschiedene Operatoren daf¨ ur. Außerdem existiert in C++ auch ein Mechanismus, zur Laufzeit Information u ¨ber den Typ von Variablen zu erhalten, die sogenannte Run-Time-Type-Information (kurz: RTTI ). Ganz bewusst m¨ ochte ich bei der Besprechung von Typecasts nicht mit den sogenannten C-Style Casts beginnen, die allen Lesern mit C-Vorbildung im Prinzip bekannt sind. Das Problem hierbei ist n¨amlich, dass diese CStyle Casts in C++ eigentlich eine Kombination aus mehreren M¨oglichkeiten sind, wie man Typen umwandeln kann. Diese M¨oglichkeiten beinhalten auch solche, die auf RTTI zur¨ uckgreifen, um Casts korrekt ausf¨ uhren zu k¨onnen. Daher m¨ ochte ich in diesem Kapitel zun¨achst Type-Identification und RTTI, danach die verschiedenen zur Verf¨ ugung stehenden Cast-Operatoren und erst am Ende den klassischen C-Style Cast besprechen. Obwohl das Thema der Casts in der Folge sehr genau besprochen wird, m¨ochte ich trotzdem, quasi “zum Aufw¨armen”, den Lesern Kapitel 13 aus Softwareentwicklung in C empfehlen. Vorsicht Falle: Entwickler, die einige Erfahrung mit C und mit ¨alteren Versionen von C++ haben, neigen dazu, bei weitem ¨ofter als notwendig explizite Casts einzusetzen. Egal, um welche Variante der in der Folge vorgestellten Casts es sich handelt, ungef¨ ahrlich sind sie im Prinzip alle nicht. Neuere C++ Versionen k¨ onnen in sehr vielen Situationen implizite Casts vornehmen, die von alten C++ Versionen und insbesondere auch von C noch nicht durchgef¨ uhrt wurden. Um nun keine Zeitbomben in ein Programm einzubauen, sollte man unbedingt folgenden Tipp beherzigen: Casts sollen nur dort verwendet werden, wo sie unbedingt notwendig sind. Im Fall, dass man sich nicht sicher ist, ob man einen Cast braucht oder nicht, schreibt man am besten nicht in vorauseilendem Gehorsam einen expliziten Cast. Das schlimmste was dadurch passieren kann ist, dass sich der Compiler beschwert. Danach kann man den Cast immer noch erg¨ anzen.
3.6.1 Type Identification und Run-Time-Type-Information In diesem Abschnitt wird ein Feature von C++ besprochen, dass es in C u oglichkeit, den Typ eines Objekts (z.B. einer ¨berhaupt nicht gibt: Die M¨ Variable) herauszufinden. Leider muss ich hier einen kleinen Spagat in Bezug auf die Struktur dieses Buchs machen, denn bei den Themen Type Identification und RTTI bekommen wir es pl¨ otzlich mit Klassen zu tun, weil dies in der Natur der hierf¨ ur
3.6 Datentypabfragen und explizite Typecasts
63
definierten Operatoren liegt. W¨ urde ich dieses Thema hier aussparen und erst im OO-Teil des Buchs anschneiden, dann w¨ urde wichtige Information fehlen, die es erst erm¨ oglicht, Casts richtig zu begreifen. Die Behandlung der Casts in den OO-Teil zu verlegen funktioniert auch nicht, denn ohne dieses Wissen w¨ urden wichtige Voraussetzungen fehlen, die jetzt schon ben¨otigt werden. Also m¨ ochte ich einen Kompromiss eingehen: In der Folge werden hier die Prinzipien der ben¨ otigten Mechanismen umrissen, ohne ganz genau auf deren Details einzugehen. Eine erg¨anzende Abhandlung von RTTI wird in Abschnitt 15.6 nachgeliefert. Zur¨ uck zum Thema Type Identification: C++ bietet die M¨oglichkeit, zur Laufzeit eines Programms zu Typinformationen z.B. von Variablen zu kommen. Dazu existiert der typeid-Operator, den wir uns am besten gleich einmal am Beispiel ansehen (typeid_demo.cpp): 1
// typeid demo . cpp − demo f o r C++ type i d e n t i f i c a t i o n f e a t u r e s
2 3 4 5
#include < i o s t r e a m> #include < t y p e i n f o> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14
struct T e s t S t r u c t { int just a member ; char one more member ; };
15 16 17 18 19 20
union TestUnion { int j u s t a v a r i a n t ; char o n e m o r e v a r i a n t ; };
21 22 23
typedef struct T e s t S t r u c t TypedefedTestStruct ; typedef union TestUnion TypedefedTestUnion ;
24 25 26 27
int main ( int a r g c , char ∗ argv [ ] ) { int32 test var = 0;
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
cout << ” i n t e r n a l typename o f char : ” << typeid ( char ) . name() << e nd l ; cout << ” i n t e r n a l typename o f s i g n e d char : ” << typeid ( signed char ) . name() << e nd l ; cout << ” i n t e r n a l typename o f unsigned char : ” << typeid ( unsigned char ) . name() << e nd l ; cout << ” i n t e r n a l typename o f s h o r t : ” << typeid ( short ) . name() << e nd l ; cout << ” i n t e r n a l typename o f s i g n e d s h o r t : ” << typeid ( signed short ) . name() << e n d l ; cout << ” i n t e r n a l typename o f unsigned s h o r t : ” << typeid ( unsigned short ) . name() << e nd l ; cout << ” i n t e r n a l typename o f i n t : ” << typeid ( int ) . name() << e nd l ; cout << ” i n t e r n a l typename o f l o n g : ” << typeid ( long ) . name() << e nd l ; cout << ” i n t e r n a l typename o f l o n g l o n g : ” <<
64
3. Operatoren
typeid ( long long ) . name() << e nd l ; cout << ” i n t e r n a l typename o f i n t 8 : ” << typeid ( i n t 8 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f u i n t 8 : ” << typeid ( u i n t 8 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f i n t 1 6 : ” << typeid ( i n t 1 6 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f u i n t 1 6 : ” << typeid ( u i n t 1 6 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f i n t 3 2 : ” << typeid ( i n t 3 2 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f u i n t 3 2 : ” << typeid ( u i n t 3 2 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f i n t 6 4 : ” << typeid ( i n t 6 4 ) . name() << e nd l ; cout << ” i n t e r n a l typename o f u i n t 6 4 : ” << typeid ( u i n t 6 4 ) . name() << e nd l ;
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
cout << ” i n t e r n a l typename o f t e s t v a r : ” << typeid ( t e s t v a r ) . name() << e n d l ; cout << ” i n t e r n a l typename o f s t r u c t T e s t S t r u c t : ” << typeid ( struct T e s t S t r u c t ) . name() << e nd l ; cout << ” i n t e r n a l typename o f TypedefedTestStruct : ” << typeid ( TypedefedTestStruct ) . name() << e nd l ; cout << ” i n t e r n a l typename o f union TestUnion : ” << typeid ( union TestUnion ) . name() << e nd l ; cout << ” i n t e r n a l typename o f TypedefedTestUnion : ” << typeid ( TypedefedTestUnion ) . name() << e nd l ;
64 65 66 67 68 69 70 71 72 73 74
return ( 0 ) ;
75 76
}
Je nach Zielplattform, auf der das compilierte Programm ausgef¨ uhrt wird, ergibt sich dann in etwa folgender Output: internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal internal
typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename typename
of of of of of of of of of of of of of of of of of of of of of of
char : c s i g n e d char : Sc unsigned char : Uc short : s signed short : s unsigned s h o r t : Us int : i long : l long long : x i n t 8 : Sc u i n t 8 : Uc int16 : s u i n t 1 6 : Us int32 : i u i n t 3 2 : Ui int64 : x u i n t 6 4 : Ux test var : i struct TestStruct : 12 TestStruct TestStruct : 1 2 TestStruct union TestUnion : 1 1 TestUnion TestUnion : 1 1 TestUnion
Nehmen wir das Programm einmal genauer unter die Lupe. In Zeile 4 f¨allt ein Header auf, n¨ amlich typeinfo, u ¨ber den wir bisher noch nicht gestolpert sind. Dieser Header wird ben¨ otigt, wenn man explizit mit Typ-Informationen arbeiten will.
3.6 Datentypabfragen und explizite Typecasts
65
Von Zeile 10 bis Zeile 23 findet man die Deklaration einer Structure, einer Union und zwei entsprechende typedef-Statements, die sp¨ater zu Demonstrationszwecken verwendet werden. Die Variable test_var, die in Zeile 27 definiert wird, ist auch noch keine Besonderheit, allerdings das Statement in den Zeilen 29–30 enth¨alt eine Neuigkeit: • In Zeile 30 wird der typeid-Operator aufgerufen. Dieser Operator ist derjenige, der in unserer zusammenfassenden Operatoren-Tabelle als typeid(type) unter dem Begriff Type Identification verewigt wurde. • Dieser typeid-Operator liefert uns interne, Compiler-implementationsspezifische bzw., je nach Typ und System, auch OS-implementationsspezifische Information zu einem Datentyp. Diese Information wird als Objekt vom Typ type_info geliefert – hier sind wir pl¨otzlich beim OOTeil angelangt. • Um nun nicht OO-Features von C++ besprechen zu m¨ ussen, stelle ich einfach in den Raum, dass man mit einem Objekt zum Teil ¨ahnlich umgeht, wie man es mit einer Structure tut: Ein Objekt besitzt ebenfalls Members und gleich wie bei einer Structure wird auf diese mittels des PunktOperators zugegriffen. Members k¨ onnen bei einem Objekt nicht nur Variablen, sondern auch sogenannte Methoden (im Prinzip Funktionen) sein. Und genau um eine solche Methode handelt es sich bei name(), wie sie in Zeile 30 aufgerufen wird. • Die Methode name() liefert bei Aufruf einen String, der den internen, Compiler- und plattformabh¨ angigen Namen eines Typs repr¨asentiert. Dieser muss aus Implementationsgr¨ unden n¨amlich nicht derselbe sein, wie der Typname, den wir im Programm verwenden, wie man auch am Output sieht. Es verwendet z.B. das mit g++ compilierte Programm unter Linux f¨ ur einen char intern einfach den Namen c. Nat¨ urlich kann man typeid nicht nur einfach auf Typen anwenden, denn dies allein w¨ are ziemlich sinnlos. Man kann typeid auch auf beliebige Expressions, also z.B. auf Variablen anwenden, wie man z.B. im Statement in den Zeilen 64–65 sieht. Und genau dabei handelt es sich, wie auch in unserer Operatorentabelle zu sehen ist, um das so wichtige RTTI -Feature von C++: Mittels RTTI ist es in C++ m¨ oglich, zur Laufzeit den Typ, zu dem eine Expression evaluiert, abzufragen. Und damit sieht man auch, warum es notwendig ist, typeid auf einen Typ wie int anwenden zu k¨ onnen: Nachdem ja intern die Typen nicht mehr die uns bekannten Namen haben, sondern plattformabh¨angige Identifications, w¨are es ansonsten unm¨ oglich herauszufinden, ob etwas z.B. jetzt wirklich vom Typ int ist. Bestimmte Cast-Operatoren, die wir gleich in der Folge kennen lernen werden, verwenden die gelieferte Typinformation (zu der intern um einiges mehr als nur der Name geh¨ort, der hier abgefragt wird), um die Kompatibilit¨ at von Datentypen festzustellen, bevor sie ihre Umwandlung durchf¨ uhren.
66
3. Operatoren
Was sieht man an unserem kleinen Demoprogr¨ammchen noch, wenn man den Output n¨ aher betrachtet? • Die ersten 3 Zeilen des Outputs zeigen uns die schon besprochene Plattformabh¨ angigkeit des Datentyps char, n¨amlich, dass nicht gesagt ist, ob er nun intern als signed char oder unsigned char betrachtet wird. Wir sehen, dass char, signed char und unsigned char intern unter drei verschiedenen Namen bekannt sind, n¨amlich c, Sc und Uc. • Die zweiten 3 Zeilen des Outputs wiederum zeigen uns, dass dies beim Datentyp short sehr wohl wieder genau definiert ist, denn der interne Name von short und signed short ist derselbe (s), wogegen unsigned short, wie zu erwarten, den internen Namen Us tr¨agt. F¨ ur die Datentypen int, long und long long gilt dasselbe wie f¨ ur short. Aus Gr¨ unden der Platzersparnis wurden die entsprechenden Programmzeilen hier nicht verewigt. Leser, die meinen Aussagen nicht ohne Kontrolle trauen, k¨onnen gerne zur ¨ Uberpr¨ ufung die entsprechenden Zeilen im Programm erg¨anzen :-). • Vergleicht man z.B. den internen Namen unseres mittels typedef in user_types.h definierten Typs int32 mit dem internen Namen eines “echten” int, so kommt man einer Begriffs-Fehlverwendung aus den Urzeiten von C auf die Spur: Intern besitzen beide, also int32 und int denselben Typ, sie sind also nicht nur kompatibel, sondern sie sind tats¨achlich ¨aquivalent und dementsprechend zur Laufzeit nicht mehr voneinander zu unterscheiden. Was sagt uns das? Nun, ganz einfach: typedef dient nur dazu, dem Compiler einen Hinweis zu geben, dass da ein Typ int32 verwendet wird, der aber in Wirklichkeit ein int ist. Der Compiler nimmt das hin und macht intern aus jedem int32 einen “echten” int. Rekapitulieren wir nun noch kurz, was es zum Thema Definition und Deklaration zu sagen gibt, dann erkennen wir, dass es sich bei typedef nicht, wie suggeriert, um eine Typ-Definition, sondern eigentlich nur um eine Typ-Deklaration handelt! Von Rechts wegen m¨ usste es also typedecl heißen und nicht typedef. Ich gebe ja zu, dass es sich hier um eine Spitzfindigkeit handelt, aber man sieht daran, dass auch in der Computerwelt nicht immer alles so genau ist, wie es sein sollte. • In den letzten 4 Zeilen des Outputs erkennt man, was der hier verwendete Compiler aus Namen von zusammengesetzten Datentypen wie Unions und Structures macht: Der Name, der der Structure bzw. Union gegeben wurde, wird auch intern beibehalten, allerdings bekommt er eine Zahl als Pr¨afix. Diese wird bei g++ zu Laufzeit-Optimierungszwecken verwendet. Ja, ok, jetzt gehe ich endg¨ ultig zu weit. Ich h¨ore schon auf damit... :-). Mit dem Wissen u oglichkeiten des Herausfindens von Information ¨ber die M¨ u ¨ber Datentypen bewaffnet, haben wir jetzt das notwendige Handwerkszeug, um die verschiedenen Cast-Operatoren zu begreifen, die es in C++ gibt. Also st¨ urzen wir uns ins Vergn¨ ugen und widmen diesen Casts einen n¨aheren Blick.
3.6 Datentypabfragen und explizite Typecasts
67
3.6.2 Unchecked Cast Der erste Cast-Operator, den wir betrachten, repr¨asentiert den sogenannten unchecked Cast. Dieser Cast ist, wie der Name schon sagt, einer, bei dem kei¨ ne Uberpr¨ ufungen stattfinden. Daher ist er zwar einerseits der Cast, mit dem man die gefinkeltsten Konstrukte basteln kann, andererseits aber auch der gef¨ahrlichste Cast, den man anwenden kann. Beachtet man, dass der Operator dazu reinterpret_cast heißt, so kann man sich auch leicht ausmalen, was intern passiert: Was auch immer sich hinter einem Datentyp verbirgt, wird einfach anders interpretiert. Es wird nicht einmal versucht, eine echte Umwandlung durchzuf¨ uhren, sondern es wird ganz einfach nur als anderer Typ behandelt. Damit kann man im Prinzip alles in alles, ohne R¨ ucksicht auf Verluste, umwandeln. Ganz so krass ist es nicht, denn zumindest wird ¨ eine minimale Uberpr¨ ufung vom Compiler vorgenommen, n¨amlich, ob die neue Typinterpretation zumindest u onn¨berhaupt irgendeinen Sinn ergeben k¨ te. Die Gefahr, dass dabei wahnwitzigste Dinge passieren, ist nat¨ urlich riesig und die Portabilit¨ at von Programmen, die ausgiebig mit reinterpret_cast arbeiten, ist im Normalfall als nicht berauschend bis nicht gegeben zu bezeichnen. Da die tollsten Effekte (sowohl im positiven als auch im negativen Sinn) bei einem reinterpret_cast vor allem in Verbindung mit Pointern und den dahinterstehenden Daten zu erzielen sind, m¨ochte ich das Beispiel dazu bis zur Behandlung der Pointer in C++ verschieben. Kurz umrissen kann man sagen, dass reinterpret_cast dazu gedacht ist, zwischen Typen umzuwandeln, die im Prinzip keinen urs¨ achlichen Zusammenhang haben (z.B. int und pointer). Dass man von solchen Dingen im Normalfall tunlichst die Finger lassen sollte, werde ich noch ¨ofter in diesem Buch erw¨ ahnen :-). Vorsicht Falle: Dadurch, dass bei einem reinterpret_cast einfach das Bitmuster des Speicherabbilds eines Datums unangetastet bestehen bleibt und nur als anderer Datentyp interpretiert wird, ergibt sich eine ungeahnte Palette von Fehlerm¨ oglichkeiten. Fehler, die durch falsche Annahmen u ¨ber die Interpretation von Datentypen zustande kommen, sind unheimlich schwer zu finden. Jedenfalls m¨ ochte ich hier eine sehr starke Empfehlung abgeben: Finger weg von reinterpret_cast, wenn es irgendwie m¨ oglich ist!!!
3.6.3 Compiletime-checked Cast Der Compiletime-checked Cast, der durch den Operator static_cast repr¨asentiert wird, ist dazu gedacht, zwischen Typen umzuwandeln, die in einem irgendwie gearteten urs¨ achlichen Zusammenhang zueinander stehen. Ein Beispiel hierf¨ ur w¨ are die Umwandlung eines int in einen float. F¨ ur diese Umwandlung gibt es eine genaue Vorschrift, n¨amlich wie ein- und dieselbe
68
3. Operatoren
Zahl als int und als float dargestellt wird. Wendet man einen static_cast in die umgekehrte Richtung, also von float auf int an, so wird der float Wert zumindest bestm¨ oglich in einen int umgewandelt, soll heißen, es werden die Kommastellen verworfen und der ganzzahlige Anteil wandert in den int. Dieses Verhalten l¨ aßt sich einfach mit folgendem Programm demonstrieren (static_cast_demo.cpp): 1
// s t a t i c c a s t d e m o . cpp − s m a l l demo program f o r s t a t i c c a s t
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12
int main ( int a r g c , char ∗ argv [ ] ) { int32 an int var = 17; f l o a t c o n v e r t e d f l o a t v a r = s t a t i c c a s t( a n i n t v a r ) ;
13
cout << ” c o n v e r t e d f l o a t v a r : ” << c o n v e r t e d f l o a t v a r << e nd l ;
14 15
converted float var = 18.37; a n i n t v a r = s t a t i c c a s t( c o n v e r t e d f l o a t v a r ) ;
16 17 18
cout << ” 1 8 . 3 7 r e p r e s e n t e d as i n t : ” << a n i n t v a r << e nd l ;
19 20
a n i n t v a r = −1; cout << ”−1 conve rt ed to u i n t 3 2 : ” << s t a t i c c a s t( a n i n t v a r ) << e n d l ;
21 22 23 24
return ( 0 ) ;
25 26
}
Der Output, den dieses Programm generiert, sieht folgendermaßen aus: converted float var : 17 1 8 . 3 7 r e p r e s e n t e d as i n t : 1 8 −1 co nve rt ed to u i n t 3 2 : 4 2 9 4 9 6 7 2 9 5
Zwei wichtige Dinge verdienen an diesem Programm eine n¨ahere Erkl¨arung: • Die genaue Anwendung von static_cast, wie man sie in den Zeilen 12 und 17 sieht: Zuerst steht das Keyword static_cast, gefolgt vom Zieldatentyp in spitzen Klammern, gefolgt von der Expression, deren Ergebnis umgewandelt werden soll, in runden Klammern. In unserem Beispiel ist die umzuwandelnde Expression beide Male einfach ein Variablenname. Statt einer Variable w¨ are nat¨ urlich auch jede andere Expression, wie z.B. static_cast(13.25 * 17.96) g¨ ultig. Hierbei w¨ urden zun¨ achst die beiden Zahlen innerhalb der runden Klammern miteinander multipliziert werden. Danach w¨ urde das Ergebnis (das vom Typ double ist, wie wir bereits wissen) in einen int umgewandelt werden. • Die Zeilen 22–23 zeigen, dass ein static_cast nicht davor bewahrt, b¨ose Fehler zu machen! Sieht man sich die dritte Zeile des Outputs an, so sieht
3.6 Datentypabfragen und explizite Typecasts
69
man, dass -1 interpretiert als unsigned nicht wirklich so toll ist! Und hier kann auch der beste Compiler nichts machen, denn wie schon erw¨ahnt, wird der Code zur Umwandlung zur Compiletime eingesetzt. Zu dieser Zeit ist allerdings im Normalfall nicht bekannt, dass vielleicht sp¨ater zur Laufzeit ein negativer Wert in einen unsigned Wert verwandelt werden soll. Vorsicht Falle: Auch ein static_cast bewahrt nicht vor den schweren Fehlern, die beim Mischen von signed und unsigned Typen passieren k¨onnen, denn zur Compiletime, zu der der entsprechende Umwandlungscode eingesetzt wird, ist der umzuwandelnde Wert ja noch nicht u ufbar! Aus ¨berpr¨ diesem Grund muss ich zum wiederholten Mal darauf hinweisen, dass unvorsichtiges Mischen von Datentypen katastrophale Folgen haben kann und tunlichst unterlassen werden soll!
3.6.4 Runtime-checked Cast Es gibt beim Arbeiten mit Klassen und Objekten in C++ F¨alle, die wir im OO¨ Teil noch kennen lernen werden, bei denen der Compiler bei der Ubersetzung des Programms nicht wissen kann, mit welchem Typ man es zur Laufzeit in einem Ausdruck zu tun haben wird. In diesem Fall ist ein static_cast nicht verwendbar, da f¨ ur einen static_cast vom Compiler Code eingesetzt wird, der die tats¨ achliche Umwandlung vornimmt. Wenn aber der Typ zu dieser Zeit noch unbekannt ist, welchen Code soll der Compiler dann einsetzen? Der Fall, dass man erst zur Laufzeit entscheiden kann, welchen Typ ein Objekt nun wirklich hat, kommt in der OO-Programmierung relativ oft vor, also darf man nicht verhindern, dass eine eventuell zur Laufzeit korrekte Umwandlung bereits im Vorfeld vom Compiler abgelehnt wird, bloß weil er nicht feststellen kann, ob hier sp¨ ater korrekt oder inkorrekt umgewandelt w¨ urde und welchen Code er einsetzen soll. Damit w¨ urde man die Flexibilit¨at von C++ stark einschr¨ anken. Schlimmer noch, man w¨ urde sogar saubere Programmierung zugunsten von brute-force Ans¨atzen verhindern! Aus diesem Grund wurde in C++ der dynamic_cast eingef¨ uhrt. Dieser sagt dem Compiler, dass er Code einsetzen soll, der erst zur Laufzeit entscheidet, ob eine Umwandlung nun zul¨ assig ist oder nicht. Sollte sie zur Laufzeit als zul¨assig erkannt werden, so wird sie entsprechend durchgef¨ uhrt, wenn nicht, wird zur Laufzeit ein Fehler generiert. Die Syntax und Semantik eines Aufrufs von dynamic_cast sind dieselbe wie wir sie schon von static_cast kennen. Leser, die nun hoffen, dass ein dynamic_cast vor den schweren Fehlern bewahren w¨ urde, die man beim Mischen von signed und unsigned Typen machen kann, die muss ich leider entt¨auschen. Der dynamic_cast k¨ ummert sich nur um Dinge, die bei Klassenhierarchien bei der OO-Programmierung interessant sind und zu denen wir noch kommen werden. Er ist leider auf
70
3. Operatoren
primitive Datentypen nicht einmal anwendbar und der Versuch dieser Anwendung wird vom Compiler mit unfreundlichen Meldungen und Abbruch ¨ der Ubersetzung quittiert. Ich gebe zu, dass es sch¨on w¨are, einen Cast zur Verf¨ ugung zu haben, der zur Laufzeit eine Plausibilit¨atspr¨ ufung z.B. bei signed auf unsigned Casts durchf¨ uhrt, nur leider existiert dieser in C++ nicht. Durch die Natur der Anwendung von dynamic_cast im Kontext von Klassenhierarchien muss ich leider auch hier auf ein Beispiel verzichten und die Leser auf sp¨ ater vertr¨ osten. 3.6.5 Remove-const Cast Wir werden bei der Diskussion u ¨ber Funktionen und im OO-Teil bei der Beschreibung von Methoden noch eine Anwendung von const kennen lernen, die u ¨ber das reine Definieren von Konstanten hinausgeht: Man kann Parameter und return-Values als const deklarieren, womit man ein ungewolltes ¨ Andern derselben verhindert. Manchmal, aber wirklich nur manchmal, gibt es F¨alle, in denen man etwas als const deklariert, um damit auszudr¨ ucken, dass man ja wirklich nichts a¨ndern will. Bloß gibt es die Situation, in der sich aus der Sichtweise eines Entwicklers an einem Objekt zwar a ¨ußerlich nichts a¨ndert, wohl aber irgendwelche internen Kleinigkeiten umdefiniert werden. Damit sieht ein Objekt nach außen hin konstant aus und soll auch zur Beruhigung der Entwickler als solches deklariert werden. Diese Eigenschaft wird als logische Konstantheit oder auch logical Constness bezeichnet, im Gegensatz zur physischen Konstantheit oder auch physical Constness. Damit landet man allerdings in einem kleinen Dilemma: Wenn etwas als const deklariert ist, aber intern gar nicht so konstant ist, wie man es nach außen vorspielen will, wie realisiert man das? Genau dazu wurde der const_cast eingef¨ uhrt, der es erlaubt, dass ein als const gekennzeichnetes Objekt nach Umwandlung trotzdem ge¨andert werden kann. Es bleibt hierbei den Entwicklern u ¨berlassen, wie sie nach außen hin den Eindruck der Konstanz aufrecht halten. Eines ist jedoch sicher: Nach außen hin muss der Eindruck gewahrt bleiben, denn sonst erzeugt man eine ungeahnte Fehlerquelle! Auch zu diesem Cast verschiebe ich das entsprechende Beispiel auf sp¨ater, da mit den bis jetzt zur Verf¨ ugung stehenden Konstrukten die Sinnhaftigkeit und die Gefahren noch nicht besonders eindrucksvoll darstellbar sind. 3.6.6 C-Style Casts Zu guter Letzt kommen wir zu der Gattung von Casts, die bereits aus C gel¨aufig sind. Daher auch der Name C-Style Cast. Der Grund, warum diese hier als letzte anstatt als erste Variante des Casts diskutiert werden, wurde
3.6 Datentypabfragen und explizite Typecasts
71
bereits erw¨ ahnt: C-Style Casts sind eine Mischung aus den bisher erkl¨arten verschiedenen Arten von Casts. Beim C-Style Cast stellt man den gew¨ unschten Zieldatentyp in runden Klammern dem umzuwandelnden Datum voran. Es wird hierbei nicht definiert, welche Art der Umwandlung stattfindet. Ein C-Style Cast wird so lange vom Compiler ohne Kommentar akzeptiert, wie er durch irgendeine beliebige Kombination von static_cast, reinterpret_cast und const_cast durchf¨ uhrbar ist. Man sieht bei dieser Definition gleich die Gefahr: Ein Entwickler kann nicht steuern, welche Art von Cast nun vom Compiler eingesetzt wird. Es kann also passieren, dass aufgrund einer Fehlannahme eines Entwicklers u ¨ber die Natur eines Datentyps der Compiler gezwungen ist, einen reinterpret_cast einzusetzen, obwohl dies gar nicht beabsichtigt war. In diesem Fall hat man dann ein Programm erzeugt, das vielleicht gar nicht mehr wirklich portabel ist, obwohl man nach bestem Wissen und Gewissen glaubt, sauberen Code geschrieben zu haben! Ich m¨ ochte also hier nur kurz zeigen, wie ein C-Style Cast formuliert wird, jedoch nicht ohne die Warnung, dass man die Finger davon lassen sollte (c_style_cast_demo.cpp): 1
// c s t y l e c a s t d e m o . cpp − demo f o r the use o f C−s t y l e c a s t s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { unsigned char a c h a r a c t e r = ’ x ’ ;
12
// the c−s t y l e c a s t v a r i a n t cout << ” the char ’ ” << a c h a r a c t e r << ” ’ has the Character−Code ” << ( u i n t 3 2 ) a c h a r a c t e r << e nd l ;
13 14 15 16
// . . . and the c l e a n v a r i a n t cout << ” the char ’ ” << a c h a r a c t e r << ” ’ has the Character−Code ” << s t a t i c c a s t( a c h a r a c t e r ) << e n d l ;
17 18 19 20 21
}
Der Output, den dieses Programm generiert liest sich dann so: the char ’ x ’ has the Character−Code 1 2 0 the char ’ x ’ has the Character−Code 1 2 0
Man sieht, dass der (verp¨ onte) C-Style Cast in Zeile 15 k¨ urzer formulierbar ist, als der (saubere) static_cast in Zeile 20. Das mit der K¨ urze ist, polemisch formuliert, gar nicht so gut: Der Einsatz von Casts sollte wirklich wohl u ¨berlegt sein. Je mehr Tipparbeit man damit hat, desto eher beginnt man dar¨ uber nachzudenken, ob ein Cast jetzt wirklich so toll ist.
72
3. Operatoren
Wieso eigentlich kommt man im oberen Programm durch einen einfachen Cast pl¨otzlich von einem dargestellten Zeichen zu einer dargestellten Zahl? Nein, v¨ollig falsch... es ist weder weiße noch schwarze Magie im Spiel und ich habe den Output auch nicht nachtr¨ aglich im Buch ver¨andert. Bei der kurzen Beschreibung von cout in Abschnitt 2.3 habe ich bereits ganz kurz erkl¨art, dass es im Prinzip egal ist, was man ihm u ¨bergibt, denn es wird immer die Repr¨asentation gew¨ ahlt, die einem Datentyp entspricht. Bei einem char ist die default Repr¨ asentation eben, dass er als Zeichen ausgegeben wird, also hier als x. Bei einem int wird per Default die Darstellung als Zahl gew¨ahlt. Genau das haben wir uns hier zunutze gemacht: Der Cast auf int32 bewirkt, dass cout als Datentyp eben einen int32 vorgesetzt bekommt, den es nat¨ urlich als Zahl darstellen will. Dass die dargestellte Zahl dem CharacterCode von x auf der jeweiligen Zielplattform entspricht, versteht sich von selbst. Vorsicht Falle: Neben dem bereits erw¨ahnten Problem, dass man die Art der Umwandlung nicht mehr in der Hand hat, gibt es einen zweiten Grund, warum man einen C-Style Cast nicht verwenden sollte: Seine Auffindbar¨ keit (oder besser nicht-Auffindbarkeit) im Code bei nachtr¨aglichen Anderungen. Es ist ein Leichtes, automatisch nach bestimmten Vorkommen von static_cast und den anderen speziellen Operatoren zu suchen. Es ist unendlich m¨ uhsam und gleichzeitig fehlertr¨achtig, nach dem Vorkommen zweier runder Klammern mit einem Datentyp dazwischen zu suchen, denn hier kann man in vielen F¨ allen nichts mehr automatisieren. Das bedeutet, dass die Einstellung vieler Entwickler, einfach einmal einen C-Style Cast zu verwenden, weil er schneller geschrieben ist und diesen nachtr¨aglich gegen die “richtigen” Casts auszutauschen, gar nicht so toll ist. Durch die schlechte Automatisierbarkeit dieses Vorgangs bleiben im Regelfall dann die C-Style Casts einfach im Code stehen und das ist garantiert nicht das, was man erreichen will!
4. Kontrollstrukturen
Als Kontrollstrukturen bezeichnet man Konstrukte, die uns helfen, den Programmablauf zu beeinflussen, anstatt immer nur sequenziell von oben nach unten Zeile f¨ ur Zeile auszuf¨ uhren. Wie zu erwarten hat C++ alle Kontrollstrukturen (z.B. if...else, while, etc.) von C u ¨bernommen. Neu hinzugekommen ist in C++ einzig das Exception-Handling, allerdings wird dieses erst im OO-Teil des Buchs genauer behandelt. Jedoch m¨ochte ich trotzdem allen Lesern, die bereits C beherrschen, empfehlen, dieses Kapitel zumindest zu u ¨berfliegen, denn es gibt in C++ bei einzelnen Kontrollstrukturen einige kleine Feinheiten, die in C nicht vorkommen. Vor allem geht es hierbei um Aspekte zur Definition von Variablen, die ja in C++ u ¨berall im laufenden Code vorkommen k¨ onnen. Eine sehr genaue Abhandlung u ¨ber Kontrollstrukturen in C findet sich in Kapitel 7 von Softwareentwicklung in C. Allen Lesern, die in C noch nicht ganz sattelfest sind, m¨ ochte ich dieses Kapitel dringend empfehlen, da die Basics zu den Kontrollstrukturen hier nur sehr gestrafft abgehandelt werden. Ganz kurz zur Auffrischung m¨ ochte ich zwei Definitionen wiederholen, die nachfolgend gebraucht werden: • Ein atomares Statement in C++ besteht aus einer oder mehreren Expressions, gefolgt von einem Strichpunkt. Oft wird auch der Begriff Programmzeile daf¨ ur verwendet. • Es gibt die M¨ oglichkeit, mehrere atomare Statements zu einem zusammengesetzten Statement oder auch Block zusammenzufassen. Eine Zusammenfassung zu einem Block erreicht man, indem man die zusammengeh¨origen einzelnen Statements (atomare oder selbst bereits zusammengesetzte) durch geschwungene Klammern einfasst. Wo auch immer in der Folge der Begriff Statement verwendet wird, kann sowohl ein einzelnes Statement als auch ein Block stehen.
74
4. Kontrollstrukturen
4.1 Selection Statements Unter dem Oberbegriff Selection Statements sind die Statements zusammengefasst, die, je nach Ergebnis der Auswertung einer Bedingung, zu einem bestimmten Programmteil verzweigen, also eine Auswahl treffen. Wie zu erwarten treffen wir hier unsere alten Bekannten aus C, n¨amlich if...else und switch. Das if...else Konstrukt sieht formal folgendermaßen aus: if (condition) statement1 else statement2
Dazu zu sagen w¨ are noch, dass der else-Zweig optional ist, also weggelassen werden kann, wenn er nicht gebraucht wird. Ein kleines Beispiel m¨ochte ich hier auch noch zeigen, das prinzipiell einmal die Verwendung von if demonstriert (if_demo.cpp): 1
// i f d e m o . cpp − t i n y program t h a t shows the use o f i f i n C++
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13 14 15 16
const const const const const const const const
int8 int8 int8 int8 int8 int8 int8 int8
DESIRED INT8 BYTES DESIRED UINT8 BYTES DESIRED INT16 BYTES DESIRED UINT16 BYTES DESIRED INT32 BYTES DESIRED UINT32 BYTES DESIRED INT64 BYTES DESIRED UINT64 BYTES
= = = = = = = =
1; 1; 2; 2; 4; 4; 8; 8;
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
int main ( int a r g c , char ∗ argv [ ] ) { i f ( ( s i z e o f ( i n t 8 ) == DESIRED INT8 BYTES) && ( s i z e o f ( u i n t 8 ) == DESIRED UINT8 BYTES) && ( s i z e o f ( i n t 1 6 ) == DESIRED INT16 BYTES) && ( s i z e o f ( u i n t 1 6 ) == DESIRED UINT16 BYTES) && ( s i z e o f ( i n t 3 2 ) == DESIRED INT32 BYTES) && ( s i z e o f ( u i n t 3 2 ) == DESIRED UINT32 BYTES) && ( s i z e o f ( i n t 6 4 ) == DESIRED INT64 BYTES) && ( s i z e o f ( u i n t 6 4 ) == DESIRED UINT64 BYTES) ) { cout << ” u s e r−d e f i n e d type−s i z e s a r e ok” << e nd l ; return ( 0 ) ; } cout << ” t h e r e a r e wrong type−s i z e s ! Test r e s u l t : ” << e n d l ;
33 34 35 36 37 38 39 40
i f ( s i z e o f ( i n t 8 ) == DESIRED INT8 BYTES) cout << ” i n t 8 i s ok” << e nd l ; else cout << ” e r r o r : i n t 8 has ” << ( s i z e o f ( i n t 8 ) ∗ 8) << ” b i t s , but should have ” << (DESIRED INT8 BYTES ∗ 8) << e n d l ;
4.1 Selection Statements
75
// For the r e s t o f the t y p e s analogous stat em ents // have to be i n s e r t e d h e r e . I l e a v e t h i s up to you . . .
41 42 43
return (−1);
44 45
}
Ich denke, das Programm braucht wirklich keine große Erkl¨arung, deshalb m¨ochte ich an dieser Stelle nur einen kleinen Tipp anbringen, der die Lesbarkeit von Code betrifft: In den Zeilen 28–31 sieht man, dass das Programm eine alles ok Meldung ausgibt und durch Aufruf von return in Zeile 30 terminiert, indem es die main-Funktion verl¨asst. Das bedeutet, dass das Programm in diesem Fall sicherlich niemals Zeile 32 erreichen kann. Aus diesem Grund ist hier auch kein else notwendig, obwohl die Zeilen von 32 weg bis zum Ende des Programms quasi einen logischen else-Zweig darstellen. Diese Vorgangsweise ist sehr anzuraten, denn ansonsten gibt es F¨alle, in denen durch ungeschickte if...else-Konstrukte eine un¨ ubersichtliche Schachte¨ lungshierarchie von Bl¨ ocken entsteht, was die Wartbarkeit und Anderbarkeit von Code deutlich verringert. Es gibt auch einen besonderen Operator, der unter dem Namen conditional Expression bekannt ist und der im Prinzip einer sehr kurzen und pr¨agnanten Form von if...else entspricht. Die Rede ist von condition ? true-expr : false-expr Hierbei stellt condition eine Bedingung dar, die evaluiert wird. F¨ ur den Fall, dass sie zu true evaluiert, wird mit der Auswertung der true-expr fortgefahren, ansonsten mit der false-expr. Sehr oft findet man eine solche conditional expression bei bedingten Zuweisungen. Das Statement int number = orig > MAX ? MAX : orig; weist z.B. der Variablen number den Wert MAX zu, sofern orig gr¨oßer ist als dieser, ansonsten wird orig zugewiesen. F¨ ur Neulinge ist diese Schreibweise zwar eher ungewohnt, aber ich m¨ ochte im Sinne von klarem und einfach lesbarem Code allen Lesern empfehlen, sich mit diesem Statement anzufreunden und es auch wirklich zu verwenden. Hat man viele Alternativen, aus denen man aussuchen will, so kann es schnell passieren, dass eine Reihe von (vielleicht sogar geschachtelten) if...else Statements ziemlich un¨ ubersichtlich wird. Aus diesem Grund gibt es das switch Konstrukt, das sich formal folgendermaßen pr¨asentiert: switch(condition) { case const1 : statement1 case const2 : statement2 ... default: statement-xx }
76
4. Kontrollstrukturen
Ich habe hier bewusst nicht die zwar v¨ollig korrekte, aber f¨ ur Erkl¨arungszwecke ebenso v¨ ollig sinnlose Schreibweise switch(condition) statement
verwendet, denn darunter kann man sich nun wirklich nicht vorstellen, was switch eigentlich tut :-). Auch f¨ ur die kurze Erkl¨ arung von switch schreiten wir am besten gleich zum Beispiel (switch_demo.cpp): 1
// switch demo . cpp − t i n y program t h a t shows the use o f i f i n C++
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11
const char SHOW = ’ s ’ ; const char QUIT = ’ q ’ ; const char EXIT = ’ x ’ ;
12 13 14 15
int main ( int a r g c , char ∗ argv [ ] ) { char j u s t a c h a r = ’ q ’ ;
16
switch ( j u s t a c h a r ) { case SHOW: cout << ”show was s e l e c t e d ” << e n d l ; break ; case QUIT: case EXIT : cout << ” q u i t was s e l e c t e d ” << e nd l ; break ; default : cout << ”unknown s e l e c t i o n ” << e n d l ; break ; } return ( 0 ) ;
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
}
Ein paar Dinge sind bei switch beachtenswert: • switch funktioniert nur f¨ ur Ganzzahlendatentypen. Dies bedingt nat¨ urlich, dass auch jeder case-Label nur einen ganzzahligen konstanten Wert repr¨asentiert. In puncto sauberer Programmierstil empfehle ich dringend, f¨ ur die case-Labels vordefinierte Konstanten zu verwenden, anstatt hardcodierte Zahlenwerte hinzuschreiben. • Die case-Labels geben die Einsprungpunkte vor. Von dort weg allerdings l¨auft das Programm innerhalb des Blocks weiter, außer es wird durch ein explizites break der Block gezwungenermaßen verlassen. Dieses Verhalten ist auch im Prinzip recht n¨ utzlich, wie man in den Zeilen 22–25 sieht: Hier werden zwei verschiedene Werte derselben Behandlung zugef¨ uhrt.
4.1 Selection Statements
77
• Der default-Label steht speziell f¨ ur alles Andere, was nicht direkt abgedeckt wurde. Vorsicht Falle: Oft versuchen unge¨ ubte C++ Entwickler, eine Variable irgendwo mitten in der Reihe der case-Labels zu definieren und auch gleich explizit zu initialisieren, weil C++ die Definition von Variablen ja “¨ uberall” gestattet. Dies ist allerdings der direkte Weg zu einem Compilerfehler, wie man sich leicht vorstellen kann: Die Definition ist dabei nicht das wirkliche Problem, denn der Compiler k¨onnte hierbei einfach den notwendigen zus¨atzlichen Speicher gleich zu Beginn des Blocks reservieren. Das ginge also noch eventuell ohne Fehlermeldung ab. Die explizite Initialisierung bei der Definition ist das Problem! Das w¨ urde n¨amlich bedeuten, dass der Code f¨ ur die Initialisierung auch im Prinzip u ¨bersprungen werden k¨ onnte, je nachdem, bei welchem case-Label der Einsprung erfolgt. Jetzt kann man nat¨ urlich behaupten, dass ja der Code f¨ ur die Initialisierung auch zusammen mit dem Code f¨ ur die Speicherreservierung vom Compiler nach oben ger¨ uckt werden k¨onnte. Das geht aber leider nicht, denn wer sagt denn, dass zu Beginn des Blocks schon alles bekannt ist, was f¨ ur die Initialisierung gebraucht wird? Es k¨onnte ja sp¨ater noch etwas berechnet werden, was die Initialisierung ver¨ andert, weil Initialisierungswerte in C++ nicht notwendigerweise Konstanten sein m¨ ussen! Will man also eine Variable irgendwo innerhalb des Blocks definieren, der die case-Labels enth¨ alt, dann bleibt nichts anderes u ¨brig, als entweder einen eigenen Sub-Block zu schreiben, oder auf die explizite Initialisierung zu verzichten und stattdessen eine normale Wertzuweisung vorzunehmen. Einen weiteren wichtigen Punkt zum Thema der Definition von Variablen gibt es, der noch nicht von C her bekannt ist: Es ist m¨oglich, innerhalb einer condition eine Variable zu definieren! Scope und Lifetime dieser Variable erstrecken sich dann auf den gesamten vom entsprechenden Konstrukt verwalteten Block. Z.B. im Fall eines if ohne zugeh¨origes else auf den ifZweig, im Fall eines if...else auf if- und else-Zweig. Der Vorteil dieser M¨oglichkeit liegt auf der Hand: Scope und Lifetime von Variablen sind, wo notwendig, auf ihren minimalen Verwendungsbereich festgelegt, anstatt auf den gesamten umschließenden Block. Betrachten wir am besten am Beispiel, wie wir solche Variablendefinitionen innerhalb von Conditions sehr sinnvoll einsetzen k¨onnen (definition_in_condition_demo.cpp): 1 2
// d e f i n i t i o n i n c o n d i t i o n d e m o . cpp − s m a l l program to show where // a d e f i n i t i o n i n s i d e a c o n d i t i o n makes s e n s e
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7
using s t d : : cout ;
78
8
4. Kontrollstrukturen
using s t d : : e nd l ;
9 10 11 12
int main ( int a r g c , char ∗ argv [ ] ) { const i n t 3 2 MODULUS = 3 ;
13
int32 value = 8;
14 15
i f ( i n t 3 2 d i v r e s t = v a l u e % MODULUS) cout << ” t h e r e i s a d i v i s i o n r e s t and i t d i v r e s t << e nd l ; return ( 0 ) ;
16 17 18 19 20
i s ” <<
}
In Zeile 16 sieht man etwas, was allen Entwicklern immer wieder begegnet: Erstmal will man wissen, ob etwas zutrifft und im Falle, dass es zutrifft, will man eine spezifische Meldung ausgeben. In diesem Beispiel wird die Variable div_rest innerhalb der Condition definiert und ihr Scope sowie ihre Lifetime enden mit Zeile 18. Dort ist n¨amlich das von if kontrollierte Statement vorbei. G¨ abe es auch einen else-Zweig, dann w¨are div_rest auch in diesem noch g¨ ultig. M¨ usste man so etwas schreiben, ohne die M¨oglichkeit der Variablendefinition innerhalb der Condition zu haben, dann m¨ usste man einen der folgenden Wege einschlagen: • Entweder wird div_rest oberhalb der Abfrage definiert, womit die Variable von dort weg im gesamten Rest von main zu sehen w¨are. Dies ist nicht unbedingt immer w¨ unschenswert. • Es k¨onnte zuerst in der Condition einfach nur die Berechnung des Rests ¨ (value % MODULUS) zur Uberpr¨ ufung stattfinden. Danach bei der Ausgabe muss man dann dieselbe Berechnung noch einmal ausf¨ uhren, um den genauen Rest ausgeben zu k¨ onnen. Im Falle von l¨anger dauernden Berechnungen ist dies nat¨ urlich nicht unbedingt im Sinne des Erfinders. Vorsicht Falle: Obwohl ich aus der Definition dieses Verhaltens in C++ keinen Grund daf¨ ur sehen w¨ urde, ist diese Art der Variablendefinition doch durch die Implementation verschiedener Compiler teilweise recht eingeschr¨ankt. Schreibt man Zeile 16 in unserem Programm z.B. durch Hinzuf¨ ugen einer zus¨ atzlichen Klammerung folgendermaßen um: if ((int32 div_rest = current_x % MODULUS)), dann wird das von vielen Compilern bereits bem¨angelt und nicht mehr u ¨bersetzt. Es gibt noch viele andere Beispiele, was man alles nicht machen darf, ohne den Compiler gleich zu gewissen Unfreundlichkeiten zu bewegen, die ich an dieser Stelle nicht weiter anf¨ uhren m¨ochte. Ich m¨ochte hier nur allen Lesern die Warnung mit auf den Weg geben, dass hier noch Verbesserungsbedarf herrscht und dass man dieses Feature entsprechend nur in sehr einfacher Form verwenden kann.
4.2 Schleifen
79
Vorsicht Falle: Ein weiteres Problem wird durch Missverst¨andnisse und/oder Schlampigkeiten bei der Implementation gewisser Compiler hervorgerufen: Manche Compiler beschr¨ anken den Scope einer Variable, die in der condition definiert wird, f¨ alschlicherweise nicht auf das vom Konstrukt verwaltete Statement, sondern auf den umschließenden Block! Dass damit der Sinn dieser M¨ oglichkeit ad absurdum gef¨ uhrt wird, versteht sich von selbst. Ob der verwendete Compiler einer dieser nicht spezifikationsgem¨aß arbeitenden ist, l¨ aßt sich leicht mit folgendem Programm u ufen ¨berpr¨ (definition_compiler_test.cpp): 1 2
// d e f i n i t i o n c o m p i l e r t e s t . cpp − s m a l l t e s t program to f i n d out // i f a c o m p i l e r works c o r r e c t l y
3 4 5 6 7 8 9 10
int main ( int a r g c , char ∗ argv [ ] ) { i f ( char t e s t v a r = ’ x ’ ) ; char t e s t v a r = ’ y ’ ; return ( 0 ) ; }
Abgesehen von Warnings wegen unbenutzter Variablen darf der Compiler keinen Fehler erzeugen, wenn er korrekt arbeitet. Bem¨angelt der Compiler ¨ bei der Ubersetzung eine doppelte Definition von test_var, so hat man es mit einem der falsch implementierten Sorte zu tun (und sollte sich eventuell den Umstieg auf einen anderen u ¨berlegen :-)).
4.2 Schleifen Die altbekannten while, do...while und for Schleifen sind auch in C++ selbstverst¨ andlich vorhanden. Eine while Schleife sieht formal folgendermaßen aus: while(condition) statement
In einer while Schleife wird vor jedem einzelnen Durchlauf die condition u uft. Evaluiert diese zu true, so wird statement genau einmal aus¨berpr¨ gef¨ uhrt, also der Schleifenrumpf genau einmal durchlaufen. Danach findet ¨ die n¨achste Uberpr¨ ufung der condition und ein eventueller erneuter Durchlauf statt, etc., bis die condition zu false evaluiert. Sobald dies der Fall ist, wird statement nicht mehr ausgef¨ uhrt. Stattdessen wird mit der Ausf¨ uhrung beim ihm nachfolgenden Code fortgefahren. Dies bedeutet, dass es passieren kann, dass statement nicht ein einziges Mal ausgef¨ uhrt wird, n¨ amlich dann, wenn condition bereits beim ersten Mal zu false evaluiert.
80
4. Kontrollstrukturen
Im Gegensatz zu einer while Schleife wird bei einer do...while Schleife statement immer zumindest einmal ausgef¨ uhrt, wie sich leicht an der folgenden formalen Definition erkennen l¨ asst: do statement while(condition)
¨ Die Uberpr¨ ufung von condition findet erst nach dem Ausf¨ uhren von statement statt. Evaluiert sie zu true, wird statement erneut ausgef¨ uhrt, so lange, bis condition zu false evaluiert. Zu guter Letzt kommen wir noch zum m¨achtigsten Schleifenkonstrukt in C++, n¨amlich zu den for Schleifen. Ein Blick auf deren formale Definition ergibt folgendes Bild: for(init;condition;step-expr ) statement
Anstatt nur eine condition zu u ufen, macht eine for Schleife gleich ¨berpr¨ bedeutend mehr, wie man in der Definition des Schleifenkopfs sieht: Beim ersten Erreichen der Schleife wird das init Statement ausgef¨ uhrt. Danach wird die condition u uft. Evaluiert diese zu true, dann wird statement ¨berpr¨ ausgef¨ uhrt, gefolgt von step-expr. Damit sind wir am Ende eines Durchlaufs ¨ angelangt und es wird mit der Uberpr¨ ufung von condition fortgefahren. Die ¨ Schleife durchl¨ auft in gewohnter Manier die Abfolge vom Uberpr¨ ufen der condition bis zum Ausf¨ uhren der step-expr so oft, bis condition einmal zu false evaluiert. Noch eine Besonderheit zeichnet die for Schleife aus: Die einzelnen Ausdr¨ ucke init, condition und step-expr k¨onnen auch leer gelassen werden, was dann im Extremfall sogar zu einer (hoffentlich gewollten) Endlosschleife f¨ uhren kann: for(;;). Gerade for Schleifen bereiten C-Neulingen, ob der großen Flexibilit¨at in ihrer Anwendung, manchmal gewisse Probleme. Leser, die sich selbst zu den C-Neulingen z¨ ahlen, sollten aus diesem Grund wirklich die sehr genaue Behandlung dieser Schleifen in Softwareentwicklung in C in Abschnitt 7.5 nachschlagen. Die bereits angesprochene M¨ oglichkeit der Definition von Variablen an ungew¨ohnlichen Stellen ist bei for Schleifen besonders interessant: Man kann ben¨otigte Laufvariablen einfach im init Statement im Schleifenkopf definieren und sie haben damit einen Scope und eine Lifetime, die genau auf das von for kontrollierte Statement begrenzt ist. Ein kurzes Demo Programm zur Verwendung der m¨oglichen Variablendefinitionen im Schleifenkopf soll die in C++ u ¨bliche “saubere” Schreibweise z.B. einer Z¨ ahlschleife demonstrieren (for_demo.cpp):
4.2 Schleifen
1
81
// for demo . cpp − s m a l l f o r−l o o p demo program
2 3 4
#i n c l u d e < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12
int main ( int a r g c , char ∗ argv [ ] ) { for ( i n t 1 6 count = 0 ; count < 3 ; count++) cout << count << ” ” << e n d l ;
13
return ( 0 ) ;
14 15
}
Manchmal treten F¨ alle auf, bei denen man den Ablauf einer Schleife gezielt in einem gewissen Rahmen beeinflussen m¨ochte. Zum Beispiel k¨onnte es im Falle eines Fehlers w¨ unschenswert sein, eine Schleife sofort abzubrechen, egal ¨ ob nun die condition bei der n¨ achsten Uberpr¨ ufung zu true oder zu false evaluieren w¨ urde. Manchmal gibt es auch F¨alle, in denen gewisser Code im Schleifenrumpf nicht bei jedem Durchlauf ausgef¨ uhrt werden soll. Zu diesem Zweck gibt es die Statements break und continue. Eine m¨ogliche Verwendung von break haben wir bereits bei der Diskussion von switch gesehen. Dort wurde es gebraucht, damit das Programm nicht ungewollt zur Behandlung eines alternativen Labels weiterl¨auft. Ruft man break innerhalb eines Schleifenrumpfes auf, so wird die Abarbeitung der Schleife sofort abgebrochen und die Schleife verlassen. Mit einem Aufruf von continue innerhalb eines Schleifenrumpfes veran¨ lasst man, dass sofort der n¨ achste Durchlauf eingeleitet wird (incl. Uberpr¨ ufung der condition, etc.) und der Schleifenrumpf nicht mehr bis zu Ende abgearbeitet wird. Eine kleine Demonstration der Funktionsweise dieser Statements findet sich in folgendem Programm (break_continue_demo.cpp): 1
// break continue demo . cpp − demo program f o r break and c o n t i n u e
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13 14 15 16 17
int main ( int a r g c , char ∗ argv [ ] ) { for ( i n t 1 6 count = 0 ; count < 4 ; count++) { cout << count << ” ” ; i f ( ! ( count % 2 ) ) // number i s even continue ; cout << e n d l ; }
18 19
cout << e nd l ;
20 21 22
for ( i n t 1 6 count = 0 ; ; count++) {
82
4. Kontrollstrukturen
cout << count << ” ” ; i f ( count >= 4) break ;
23 24 25
}
26 27
cout << e nd l ;
28 29
return ( 0 ) ;
30 31
}
Dieses grandiose Meisterwerk macht sich am Bildschirm so bemerkbar: 0 1 2 3 0 1 2 3 4
In Zeile 15 wird mittels continue ein Neudurchlauf der Schleife erzwungen, ohne dass die verbleibenden Statements im Block (in diesem Fall nur die Ausgabe) ausgef¨ uhrt werden. Man sieht am Output, dass wirklich nur der Code des Schleifenrumpfs u ¨bersprungen wird, das Weiterz¨ahlen der step-expr findet nat¨ urlich statt. Zeile 25 demonstriert, wie man eine potentielle Endlosschleife doch noch verlassen kann, ohne das Programm mit Ctrl-C oder schlimmeren Mitteln abw¨ urgen zu m¨ ussen :-). Vorsicht Falle: So brauchbar break und continue auch sein m¨ogen, m¨ochte ich doch vor deren exzessivem Einsatz warnen. Wohl¨ uberlegt eingesetzt machen sie ein Programm oftmals leichter lesbar, als dies ohne solche State¨ ments der Fall w¨ are. Ubertrieben h¨ aufiger Einsatz von break und continue allerdings kann ein Programm v¨ ollig undurchschaubar und damit unwartbar machen.
4.3 Das unselige goto Statement ¨ Uber goto sagt eigentlich schon die Kapitel¨ uberschrift alles: Finger weg davon!!!!!! Zum Gl¨ uck hat es sich mittlerweile praktisch u ¨berall herumgesprochen, dass ein goto in keinem Programm etwas verloren hat. Genau deshalb m¨ ochte ich goto auch nicht weiter besprechen, sondern an dieser Stelle nur noch ein kleines Beispiel anf¨ uhren, wie Software keinesfalls aussehen darf (goto_demo.cpp): 1
// goto demo . cpp − Just f o r demonstration . NEVER USE GOTO ! ! ! ! !
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7 8
using s t d : : cout ; using s t d : : e nd l ;
4.3 Das unselige goto Statement
9 10 11
83
int main ( int a r g c , char ∗ argv [ ] ) { int32 spaghetti counter = 0;
12
nirvana : cout << ” I ’m i n nirvana . . . ” << e nd l ; s p a g h e t t i c o u n t e r++; i f ( s p a g h e t t i c o u n t e r > 5) goto w a l h a l l a ; goto nirvana ; walhalla : i f ( s p a g h e t t i c o u n t e r < 10) goto nirvana ; cout << ” f i n a l l y i n w a l h a l l a ” << e nd l ;
13 14 15 16 17 18 19 20 21 22 23
}
Wer Lust hat, kann sich ja einmal kurz u ¨berlegen, wie es zu folgendem Output ¨ kommt. Vor allem ist es eine Uberlegung wert, was passiert, wenn die einzelnen Sprungstellen nicht auf einige wenige Zeilen, sondern auf ein gr¨oßeres Programm verteilt sind... I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . I ’m i n nirvana . . . f i n a l l y in walhalla
Dieses Programm ist eine leicht abgewandelte Version des Wahnsinnskonstrukts, das in Softwareentwicklung in C dazu dient, die Nicht-Verwendung von goto zu motivieren. Alle Leser, die, aus welchen Gr¨ unden auch immer, genauer wissen wollen, wie goto funktioniert, m¨ochte ich auf den dortigen Abschnitt 7.7 verweisen.
5. Funktionen
Prinzipiell ist eine Funktion eine Zusammenfassung mehrerer Anweisungen zu einem aufrufbaren Ganzen. Weiters nimmt eine Funktion Parameter entgegen und liefert einen return-Wert. Die Definition einer Funktion sieht formal folgendermaßen aus: retval function-name(param-list) { block-of-code }
Hierbei bezeichnet retval den return-Wert der Funktion, wobei bei der Definition hier einfach ein Datentyp (z.B. int) angegeben wird. Der (sprechende!!!) Name einer Funktion wird durch function-name definiert und dieser ist gefolgt von einer Parameterliste, hier durch param-list gekennzeichnet, in runden Klammern. Diese gesamte erste Zeile bezeichnet man auch als Funktionskopf . Der Code, der ausgef¨ uhrt wird, wenn eine Funktion aufgerufen wird, steht als Block (also durch geschwungene Klammern eingefasst) in Anschluss an den Kopf. Diesen Block bezeichnet man auch als Funktionsrumpf . Die Parameter¨ ubergabe beim Aufruf von Funktionen erfolgt gleich wie in C immer als call-by-value. Das bedeutet, dass immer zuerst eine vollst¨andige Evaluierung der Ausdr¨ ucke stattfindet, die als Parameter u ¨bergeben werden und dass das Ergebnis dieser Evaluierung als Wert an die Funktion u ¨bergeben wird. Sollte man z.B. eine Variable als Parameter an eine Funktion u ¨bergeben, so kann innerhalb der Funktion problemlos der Wert des Parameters beliebig ver¨ andert werden, der Wert der Variable außerhalb ¨andert sich garantiert nicht! Das Liefern eines return-Wertes erfolgt u ¨ber ein explizites return Statement, das genau dort in der Funktion steht, wo der R¨ ucksprung erfolgen soll. Es kann auch mehrere return Statements in einer Funktion geben, die je nach Kontrollfluss des Programms (z.B. durch if...else) alternativ den R¨ ucksprung veranlassen. Es wird garantiert, dass der R¨ ucksprung aus einer Funktion genau an der Stelle des return Statements stattfindet. Nicht so selten gibt es auch F¨ alle, dass Funktionen gar keinen return-Wert liefern, weil sie eigentlich nur als Prozeduren gebraucht werden, die irgendeine Aufgabe (z.B. Bildschirmanzeige) erf¨ ullen. F¨ ur diese F¨alle gibt es den
86
5. Funktionen
“Datentyp” void. Daher kommt auch der in C und C++ gel¨aufige Name einer void-Funktion, womit eine reine Prozedur gemeint ist. Will man den R¨ ucksprung aus einer void Funktion veranlassen, so geschieht das mittels eines “reinen” return Statements, also ohne Angabe eines Return-Values. Im Gegensatz zu “echten” Funktionen, bei denen ja durch die Deklaration zwingend vorgeschrieben ist, dass sie einen Wert eines gewissen Typs liefern, muss bei void Funktionen kein explizites return Statement existieren. Sobald das Ende des Funktionsrumpfes erreicht ist, wird bei void Funktionen der automatische R¨ ucksprung zum Aufrufer veranlasst. Der wichtigste Punkt bei Funktionen ist nicht, wie viele glauben, die technische Abhandlung, wie man sie definiert und aufruft. Viel wichtiger ist die richtige und sinnvolle Verwendung von Funktionen, um Software sauber zu strukturieren und Abstraktionslevels zu schaffen. Um dies gut zu schaffen, braucht es einiges an Erfahrung. Man muss das Prinzip der schrittweisen Verfeinerung beim Herangehen an eine Problemstellung erst einmal wirklich verinnerlicht haben, um in der Lage zu sein, Code sauber zu strukturieren. Beim Arbeiten mit Funktionen geht es immer darum, Gemeinsamkeiten zu finden und diese auf sinnvoll parametrisierte Codest¨ ucke abzubilden. Lesern, die hier noch m¨ oglicherweise ein kleineres oder gr¨oßeres Defizit haben, m¨ ochte ich Kapitel 8 aus Softwareentwicklung in C zur Einstimmung auf das Kommende w¨ armstens empfehlen. Ich m¨ ochte mich hier absichtlich zum jetzigen Zeitpunkt nicht extrem detailliert u ¨ber Abstraktions- und Struktur-Aspekte von Funktionen auslassen, denn wir werden im OO-Teil des Buchs erst die sogenannten Methoden kennen lernen. Obwohl diese auf den ersten Blick den hier besprochenen Funktionen t¨auschend ¨ ahnlich sehen, stellen sie doch hinter den Kulissen durch ihre Kontextbezogenheit ein viel m¨ achtigeres Konstrukt als “normale” Funktionen dar. Deshalb wird dieses Kapitel vor allem auf technische Aspekte von Funktionen eingehen und nicht so sehr auf softwaretechnologische und z.T. philosophische Betrachtungen zu deren richtiger Anwendung. All das wird dann daf¨ ur umso exzessiver bei der Besprechung von Klassen, Objekten und Methoden nachgeholt :-). Allen Lesern, die ge¨ ubt im Umgang mit C sind und die jetzt denken, dass Funktionen ja sowieso ein alter Hut sind, m¨ochte ich eindringlich widersprechen! C++ hat durch das Konzept des Overloadings einige nette Features und auch Stolpersteine auf Lager, die aus C v¨ollig unbekannt sind! Wenden wir uns zuerst einmal an einem Beispiel den Features von Funktionen in C++ zu, bevor wir in die Tiefen vordringen und dort mit ein paar netten Fallen konfrontiert werden (function_demo.cpp): 1
// function demo . cpp − demonstration o f f u n c t i o n s i n C++
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5. Funktionen
87
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
// f u n c t i o n d e c l a r a t i o n s i n t 3 2 square ( i n t 3 2 num ) ; double square ( double num ) ; void show ( i n t 3 2 num ) ; void show ( double num ) ;
14 15 16 17 18 19
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { i n t 3 2 a number = 4 ; double one more number = 4 . 0 ;
20
i n t 3 2 a s q u a r e = square ( a number ) ; double one more square = square ( one more number ) ;
21 22 23
show ( a s q u a r e ) ; show ( one more square ) ;
24 25 26
// i m p l i c i t c o n v e r s i o n s can a l s o take p l a c e : i n t 1 6 and one more = 3 ; show ( square ( and one more ) ) ;
27 28 29 30
}
31 32 33 34 35 36
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− i n t 3 2 square ( i n t 3 2 num) { return (num ∗ num ) ; }
37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− double square ( double num) { return (num ∗ num ) ; }
43 44 45 46 47 48
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( i n t 3 2 num) { cout << ” showing i n t 3 2 : ” << num << e n d l ; }
49 50 51 52 53 54
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( double num) { cout << ” showing double : ” << num << e nd l ; }
Leser, denen C gel¨ aufig ist, schließen gerade Wetten ab, ob ich sturzbetrunken oder geistig umnachtet war, als ich diese Zeilen geschrieben habe, denn ¨ jeder vern¨ unftige Compiler w¨ urde mir beim Versuch der Ubersetzung dieses Codes garantiert nichts Freundliches sagen. Allen Vermutungen u ¨ber meinen geistigen Zustand zum Trotz u ¨bersetzt jedoch ein C++ Compiler dieses ¨ Programm tats¨ achlich fehlerfrei. Wenn man das Resultat der Ubersetzung startet, flimmert Folgendes u ¨ber den Bildschirm: showing i n t 3 2 : 1 6 showing double : 1 6 showing i n t 3 2 : 9
88
5. Funktionen
In den Zeilen 10–13 unseres Programms kann man erkennen, wie man Funktionen deklariert: Man schreibt die Signatur der Funktionen, gefolgt von einem Strichpunkt. Dies ist einfach ein Hinweis an den Compiler, dass es eine Funktion unter einem bestimmten Namen, mit einem bestimmten Parametersatz und mit einem bestimmten return-Wert irgendwo gibt. Genau in diesen Deklarationszeilen f¨allt auch schon auf, dass es in C++ mit Funktionen etwas auf sich hat, was es in C einfach nicht gibt: In den Zeilen 10 und 11, sowie in den Zeilen 12 und 13 sind jeweils zwei Funktionen deklariert, die denselben Namen haben! Sie unterscheiden sich nur durch ihre Parameter und durch den return-Wert. Genau diese M¨oglichkeit wird als Overloading bezeichnet. Die genauen Regeln f¨ ur Overloading von Funktionen sind Folgende: • Overloading von Funktionen bedeutet, dass es mehrere Funktionen mit demselben Namen geben kann. • Die einzelnen Funktionen, die an einem Overloading beteiligt sind, m¨ ussen sich zumindest in einem Parameter voneinander unterscheiden. • Es ist nicht von Belang, ob sich die Funktionen in der Anzahl der Parameter oder in den Typen oder in beiden Punkten unterscheiden. • Ein Overloading von Funktionen, die denselben Namen und denselben Parametersatz besitzen und sich ausschließlich im return-Wert voneinander unterscheiden, ist nicht m¨ oglich. Um Overloading u oglich zu machen, laufen schematisch ge¨berhaupt erst m¨ sehen compilerintern folgende Prozesse ab: • Bei der Deklaration von Funktionen (explizit oder implizit bei der Definition) wird das sogenannte Name-Mangling durchgef¨ uhrt. Das bedeutet, dass der Name jeder Funktion einfach intern verl¨angert wird, indem er um den Parametersatz und um den return-Wert erweitert wird. Hierbei sind allerdings nicht die Namen der Parameter, sondern nur deren Typen interessant. Wie nun genau eine Funktion intern nach dem Name-Mangling aussieht ist compilerabh¨ angig! Um sich leichter etwas vorstellen zu k¨onnen, nehmen wir ein m¨ ogliches Beispiel, wie ein Compiler das Name-Mangling durchf¨ uhren k¨ onnte. Eine Funktion int myFunction(int count,double value) k¨onnte durch das Name-Mangling intern unter dem Namen myFunctionid_i bekannt sein. Der Name ergibt sich einfach daraus, dass an den Funktionsnamen der Reihe nach die internen Bezeichner der einzelnen Datentypen angeh¨angt werden, gefolgt von einem Underline und dem internen Bezeichner f¨ ur den Typ des return-Wertes. Dies bedeutet nun, dass der Compiler tats¨achlich intern die Namensgleichheiten aufhebt und lauter Funktionen mit verschiedenen Namen erzeugt. Dies ist auch notwendig, denn ansonsten k¨ onnte der Linker seine Arbeit nicht mehr verrichten. Dieser weiß ja
5. Funktionen
89
vom Overloading in Wirklichkeit gar nichts. Ihm wird nur noch ObjectCode mit symbolischen Namen zur endg¨ ultigen Adressaufl¨osung gegeben. Deshalb muss der Compiler daf¨ ur sorgen, dass im Object-Code garantiert keine Namensgleichheiten mehr vorkommen. • Wenn eine Funktion aufgerufen wird, dann analysiert der Compiler die Aufrufparameter und sucht aus den ihm bekannten Funktionen des entsprechenden Namens diejenige, die “am besten” zum Parametersatz des Aufrufs passt. Zum Beispiel wird in unserem Demoprogramm in Zeile 21 die Funktion square mit einem int32 als Parameter aufgerufen. Dieser Aufruf passt am besten zur Variante von square, die in Zeile 10 deklariert und ab Zeile 33 definiert ist. Daher wird genau dieser Funktionsaufruf eingesetzt. • Dass nur der Parametersatz zur Unterscheidung herangezogen wird und ¨ nicht auch der return-Wert (wor¨ uber u disku¨brigens schon des Ofteren tiert wurde), ist auch leicht einzusehen. Im laufenden Code ist n¨amlich nicht wirklich immer ersichtlich, welcher return-Wert nun erwartet wird. Es ist ja z.B. auch m¨ oglich, dass jemand einfach in unserem Programm square aufruft, aber das Ergebnis keineswegs verwendet, weder durch eine Zuweisung, noch in einer Berechnung. Und woher soll der Compiler dann seine Information bekommen, welche der overloaded Funktionen er jetzt einsetzen soll? Neben dem Overloading gibt es bei Funktionen in C++ noch ein sehr angenehmes Feature, das Entwickler dabei unterst¨ utzt, sauberen Code zu schreiben: Funktionen k¨ onnen sogenannte default Parameter besitzen. Am Beispiel sieht das folgendermaßen aus (default_param_demo.cpp): 1
// default param demo . cpp − demo o f d e f a u l t parameters
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10
// f u n c t i o n d e c l a r a t i o n s void show ( i n t 3 2 num , bool s h o w p r e f i x = f a l s e ) ;
11 12 13 14 15 16 17 18
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { show ( 1 7 ) ; show ( 1 7 , f a l s e ) ; show ( 1 7 , true ) ; }
19 20 21 22 23 24 25 26
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( i n t 3 2 num , bool s h o w p r e f i x ) { i f ( show prefix ) cout << ” the v a l u e i s : ” ; cout << num << e n d l ; }
90
5. Funktionen
Dieses Demoprogr¨ ammchen erzeugt dann folgenden Output: 17 17 the v a l u e i s : 1 7
In Zeile 10 sieht man, wie man eine Funktion mit default Parametern deklariert: Man stellt einem Parameter ganz einfach ein = gefolgt vom gew¨ unschten default Wert nach. Dies liest sich quasi wie eine Zuweisung. Wirft man einen n¨aheren Blick auf Zeile 15, dann sieht man auch, was es mit den default Parametern auf sich hat. Es wird n¨amlich in Zeile 15 die Funktion show nur mit einem Parameter aufgerufen, obwohl sie aber mit zwei Parametern deklariert (und definiert) ist. Der Compiler setzt hier einfach intern f¨ ur den zweiten Parameter den in der Deklaration angegebenen default Wert (hier false) ein. Zeile 16 zeigt einen vollst¨andig expliziten Aufruf, der, ebenso wie Zeile 17, keinen Gebrauch vom default Parameter macht. Die folgenden Regeln gelten bei der Arbeit mit default Parametern: • Es kann beliebig viele default Parameter geben, diese m¨ ussen aber immer alle am Ende der Parameterliste stehen. Es ist nat¨ urlich auch problemlos m¨oglich, alle Parameter einer Funktion als default Parameter zu deklarieren. Eine Deklaration void func(int par1, int par2 = 0, int par3 = 0, int par4 = 0); ist also g¨ ultig, wohingegen die Deklaration void func(int par1, int par2 = 0, int par3, int par4 = 0); zu einem Compilerfehler f¨ uhrt, denn par2 ist ein default Parameter, der nachfolgende par3 allerdings nicht. Wie soll der Compiler also z.B. bei einem Aufruf func(11,22,33); nun erraten k¨ onnen, ob 22 nun f¨ ur par2 und 33 f¨ ur par3 genommen werden soll, oder vielleicht doch eher der default Wert f¨ ur par2, 22 f¨ ur par3 und 33 f¨ ur par4? • Neben der eben erw¨ ahnten Regel, dass default Parameter allesamt am Ende der Liste stehen m¨ ussen, gibt es noch eine zweite Regel, die Ambiguit¨aten bei der Aufl¨ osung verhindert: Default Parameter werden von links nach rechts der Reihe nach mit u ¨bergebenen Parameterwerten belegt, bis alle u ¨bergebenen Parameter aufgebraucht sind. Haben wir z.B. folgende Funktion: void func(int par1 = 0, int par2 = 0, int par3 = 0) dann f¨ uhrt ein Aufruf func(1,2); dazu, dass f¨ ur par1 der Wert 1 und f¨ ur par2 der Wert 2 u ¨bergeben wird. F¨ ur par3 wird der default Wert 0 aus der Deklaration eingesetzt. Die Angabe eines default Parameters wird nur in der Deklaration ben¨otigt, nicht aber in der Definition. Der Compiler ist daf¨ ur verantwortlich, den
5. Funktionen
91
korrekten Parameter beim Aufruf der Funktion einzusetzen. Wenn man den default Parameter auch in der Definition angibt, dann f¨ uhrt dies bei vielen Compilern zu einem Fehler. So viele M¨ oglichkeiten u ¨bersichtlichen Code zu schreiben sich in C++ durch Overloading und default Parameter ergeben, so viele Fallen tun sich auch auf, wenn man nicht ganz genau weiß, was man macht! Das folgende Programm demonstriert, wie man den Compiler so weit bringen kann, dass er freiwillig keine Aufl¨ osung gewisser Funktionsaufrufe mehr vornimmt, sondern sich beschwert, dass er ja nicht Gedanken lesen kann um zu erraten, welchen Aufruf man nun beabsichtigt hat (overloading_problems.cpp): 1
// o v e r l o a d i n g p r o b l e m s . cpp − demo o f problems with o v e r l o a d i n g
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12
// f u n c t i o n d e c l a r a t i o n s void show ( i n t 3 2 num , bool s h o w p r e f i x = f a l s e ) ; void show ( f l o a t num , bool s h o w p r e f i x = f a l s e ) ; void show ( double num ) ;
13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { float f l o a t v a l = 12.5; int32 i n t v a l = 20; int16 another int val = 10; int64 one more int val = 10;
21
show ( i n t v a l ) ; show ( f l o a t v a l ) ; show ( a n o t h e r i n t v a l ) ; show ( 1 2 . 5 ) ;
22 23 24 25 26
show ( o n e m o r e i n t v a l ) ; // ambiguity problem ! // problem s o l v e d by f o r c i n g a c e r t a i n i n t e r p r e t a t i o n show ( s t a t i c c a s t( o n e m o r e i n t v a l ) ) ;
27 28 29 30
show ( 1 2 . 5 , f a l s e ) ; // ambiguity problem ! // problem s o l v e d by f o r c i n g a c e r t a i n i n t e r p r e t a t i o n show ( s t a t i c c a s t ( 1 2 . 5 ) , f a l s e ) ; return ( 0 ) ;
31 32 33 34 35
}
36 37 38 39 40 41 42 43 44
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( i n t 3 2 num , bool s h o w p r e f i x = f a l s e ) { cout << ”show ( i n t 3 2 , bo o l ) c a l l e d ” << e nd l ; i f ( show prefix ) cout << ” the v a l u e i s : ” ; cout << num << e n d l ; }
45 46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( f l o a t num , bool s h o w p r e f i x = f a l s e ) { cout << ”show ( f l o a t , bo o l ) c a l l e d ” << e nd l ; i f ( show prefix )
92
5. Funktionen
cout << ” the v a l u e i s : ” ; cout << num << e nd l ;
51 52 53
}
54 55 56 57 58 59 60
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( double num) { cout << ”show ( double ) c a l l e d ” << e nd l ; cout << num << e nd l ; }
Gehen wir einmal die Aufrufe von show der Reihe nach durch, um zu sehen, was der Compiler daraus macht: Zeile 22: Dieser Aufruf passt perfekt zur Funktion, die in Zeile 10 definiert wurde, denn f¨ ur den ersten Parameter herrscht absolute Typen¨ ubereinstimmung und f¨ ur den zweiten wird der default Wert eingesetzt. Zeile 23: Auch hier entscheidet der Compiler aufgrund der exakten Typen¨ ubereinstimmung und setzt die Funktion ein, die in Zeile 11 deklariert wurde. Unge¨ ubte Entwickler k¨ onnten irrt¨ umlich meinen, dass der Aufruf der Funktion mit Deklaration in Zeile 12 eingesetzt wird, weil sie im Prinzip von der Typkompatibilit¨ at her passen w¨ urde. Dem Compiler ist ¨ allerdings exakte Ubereinstimmung wichtiger. Zeile 24: Hier wird es etwas trickreicher, denn es gibt keine Funktion, deren Parameter exakt auf den Aufruf mit einem int16 passen w¨ urden. Am Naheliegendsten von allen M¨ oglichkeiten ist noch eine implizite Typumwandlung von int16 auf int32. Genau das passiert auch und es wird der Aufruf der in Zeile 10 deklarierten Funktion eingesetzt. Zeile 25: Weil Gleitkommaliterale vom Compiler immer implizit als double angenommen werden, ergibt sich selbstverst¨andlich ein Aufruf der Funktion, die in Zeile 12 deklariert wurde. Zeile 27: Mit diesem Aufruf bringt man den Compiler nun endg¨ ultig in ein Dilemma, das er mit einer unfreundlichen Fehlermeldung u ber einen am¨ biguous Call quittiert. Je nach Compiler bekommt man auch noch mitgeteilt, was denn die in Frage kommenden Kandidaten w¨aren, zwischen denen er sich nicht entscheiden kann. Wie kommt es nun zu dieser Situation? • Es gibt keine Funktion, die einen int64 als ersten Parameter nehmen w¨ urde. • Eine Umwandlung eines int64 in einen int32 kann fatal sein, weil das Fassungsverm¨ ogen eines int32 ein Problem darstellen kann. • Eine Umwandlung eines int64 in einen float oder auch in einen double kann ebenso fatal sein, je nachdem, ob die Gleitkommawerte die entsprechende notwendige Precision besitzen. Wenn man kurz rekapituliert, dass auf den meisten Plattformen ein double 64 Bit lang ist, dann sieht man sofort, dass die notwendige Precision nicht f¨ ur einen 64 Bit Integer ausreicht, denn von den 64 Bits des double sind ja ein
5. Funktionen
93
paar f¨ ur den Exponenten reserviert, fehlen also bei der Mantisse und bewirken, je nach Zahl, ein Abschneiden des int64 Wertes. Zum Gl¨ uck kann man als Entwickler eingreifen und dem Compiler in seiner dunklen Stunde der Unentschlossenheit mitteilen, was man eigentlich will. Dies geschieht in Zeile 29, denn durch den expliziten Cast hat es der Compiler wieder mit einem int32 als Parameter zu tun. Was er damit anfangen soll, weiß er und setzt den Aufruf der in Zeile 10 deklarierten Funktion ein. Zeile 31: Auch diese Zeile treibt den Compiler zur Verzweiflung, denn er steht vor der Wahl, einen double entweder zu einem float oder zu einem int32 verkommen zu lassen, um eine der Funktionen deklariert in den Zeilen 10 und 11 aufrufen zu k¨ onnen. Beides ist nat¨ urlich aus bekannten Gr¨ unden nicht problemlos, also beschwert sich der Compiler lieber und u asst den Entwicklern die Entscheidung. Die L¨osung des Problems ¨berl¨ ist, wie anzunehmen war, wieder ein expliziter Cast. In Zeile 33 sieht man den entsprechenden korrigierten Aufruf. Alle Aussagen, die ich hier u ¨ber den Aufruf bestimmter Funktionen aus einer Reihe von Varianten getroffen habe, lassen sich leicht verifizieren, indem man die beiden problematischen Zeilen auskommentiert, die zum Ambiguit¨atsproblem f¨ uhren und danach das Programm u ¨bersetzt. Der Output, der dann generiert wird, sieht folgendermaßen aus: show ( i n t 3 2 , bo o l ) c a l l e d 20 show ( f l o a t , bo o l ) c a l l e d 12.5 show ( i n t 3 2 , bo o l ) c a l l e d 10 show ( double ) c a l l e d 12.5 show ( i n t 3 2 , bo o l ) c a l l e d 10 show ( f l o a t , bo o l ) c a l l e d 12.5
Das Regelwerk, nach dem der Compiler versucht, den bestm¨oglichen Funktionsaufruf aus einer Reihe von Alternativen zu finden, ist relativ komplex. Jedoch ist es so ausgelegt, dass immer die Variante eingesetzt wird, bei der keine gef¨ ahrlichen Umwandlungen auftreten. Kann der Compiler keine solche Variante finden und steht vor einer Reihe von Alternativen, die alle nicht optimal sind, so muss man als Entwickler durch einen expliziten Cast eingreifen. Vorsicht Falle: Gerade C++ Neulinge neigen oft dazu, Overloading zu u ur viele verschiedene Datentypen, die eigentlich kompatibel ¨bertreiben und f¨ w¨aren, eigene Funktionen zu schreiben, anstatt sich auf die Kompatibilit¨at ¨ zu verlassen. Dies kann schnell zu Uberraschungen f¨ uhren, wenn man sich zu wenig Gedanken u ¨ber die Auswirkungen macht. Deshalb m¨ochte ich hier drei Tipps geben:
94
5. Funktionen
• Das genaue Verstehen der Interna und der Zusammenh¨ange der einzelnen Datentypen ist eine der wichtigsten Voraussetzungen f¨ ur eine saubere Entwicklung! Viele Entwickler, nicht nur Neulinge, haben hier ein großes Wissensdefizit, weil sie die Interna als “unn¨otigen Ballast” betrachten, den man “heutzutage sowieso nicht mehr braucht”. Diese Einstellung ist grundfalsch! • Niemals das Overloading u ¨bertreiben! Im Fall unserer show Funktion w¨are es klug gewesen, eine Variante mit int64 und eine mit long double zu schreiben. Damit hat man f¨ ur Gleitkomma und Integer Typen immer den m¨achtigsten Datentyp zur Verf¨ ugung und der Compiler hat keine Probleme mehr. • Das Mischen von Overloading und default Parametern ist nach M¨oglichkeit zu vermeiden, denn es kann sehr leicht unbeabsichtigte Ambiguit¨aten und/oder unsauberen Code zur Folge haben. Ein Beispiel f¨ ur solchen schlechten Programmierstil ist auch in unserem Demoprogramm zu finden, denn es gibt eine show Funktion mit einem double Parameter und eine zweite mit einem float und einem zus¨ atzlichen default Parameter. Stattdessen w¨are eine einzige Funktion mit einem long double und zus¨atzlichem default Parameter sehr viel besser gewesen!
Vorsicht Falle: Ja, gleich noch eine. Der Deklaration von Funktionen kommt durch das Overloading in C++ eine unglaublich wichtige Rolle zu, weil erst dadurch der Compiler alle Alternativen kennt. Welche tollen Effekte das Vergessen einer einzigen Deklaration hervorrufen kann, sieht man an folgendem Programm (missing_decl_problem.cpp): 1 2
// m i s s i n g d e c l p r o b l e m . cpp − demo o f a problem t h a t a r i s e s when // a function declaration i s missing
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11
// f u n c t i o n d e c l a r a t i o n s void show ( i n t 1 6 num ) ;
12 13 14 15 16 17 18
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { show ( 1 2 3 4 5 6 7 . 5 ) ; // h e r e the c a l l to show ( i n t 1 6 ) i s taken ! ! ! ! return ( 0 ) ; }
19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( i n t 1 6 num) { cout << ”show ( i n t 1 6 ) : ” << num << e nd l ; }
25 26
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
5. Funktionen
27 28 29 30
95
void show ( double num) { cout << ”show ( double ) : ” << num << e n d l ; }
Der Output des Programms ist durch das folgenschwere Vergessen der Deklaration der double-Variante von show folgender: show ( i n t 1 6 ): −10617
Warum hier ein Ungl¨ uck passiert, ist auch leicht erkl¨arbar: Zum Zeitpunkt ¨ des Ubersetzens von Zeile 16 hat der Compiler noch keine Ahnung, dass es die Funktion show auch in einer double Variante geben wird, denn diese findet er zum ersten Mal in Zeile 27 vor. Dann ist es aber zu sp¨at, denn R¨ uckschritte kann kein Compiler machen. Dass man bei der Suche nach einem solchen Fehler lange gr¨ ubelt, was u ¨berhaupt im Programm passiert und danach verzweifelt auf die Suche nach dem Warum gehen kann, kann man sich leicht als besonders genussvolles Erlebnis im Alltag eines Entwicklers vorstellen. Nur der Vollst¨ andigkeit halber m¨ochte ich anmerken, dass der Compiler ¨ beim Ubersetzen des obigen Programms sehr wohl eine Warnung ausgibt, weil er nicht gl¨ ucklich dar¨ uber ist, einen double in einen int16 umwandeln zu m¨ ussen. Nur leider werden solche Warnings von vielen Entwicklern einfach ignoriert. Vorsicht Falle: Eine weitere habe ich noch anzubieten. Es wurde bereits darauf hingewiesen, dass man vorsichtig sein muss, wenn man Overloading und default Parameter gemeinsam f¨ ur eine Gruppe von Alternativen verwendet. Was passieren kann, wenn man nicht genau weiß, was man tut, zeigt das folgende Beispiel (overloading_default_problem.cpp): 1 2
// o v e r l o a d i n g d e f a u l t p r o b l e m . cpp − demo o f a problem t h a t a r i s e s // o v e r l o a d i n g and d e f a u l t params a r e mixed c a r e l e s s l y
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12
// f u n c t i o n d e c l a r a t i o n s void show ( double num ) ; // t h i s one i s ” hidden ” by the next one ! void show ( double num , bool def param = f a l s e ) ;
13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { show ( 1 2 3 4 5 6 7 . 5 ) ; // ambiguity cannot be r e s o l v e d ! show ( 1 2 3 4 5 6 7 . 5 , f a l s e ) ; // ok return ( 0 ) ; }
21 22 23
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( double num , bool def param )
96
24
5. Funktionen
{ cout << ”show ( double , b o o l ) : ” << num << e nd l ;
25 26
}
27 28 29 30 31 32
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( double num) { cout << ”show ( double ) : ” << num << e n d l ; }
Die Ambiguit¨ at, die in Zeile 17 auftritt, kann vom Compiler nicht mehr aufgel¨ost werden, denn woher soll er jetzt wissen, ob man nun die Funktion aufrufen will, die in Zeile 11 deklariert wird, oder doch lieber die Funktion aus Zeile 12? Beide passen perfekt! Der Aufruf der in Zeile 12 deklarierten Funktion l¨ asst sich ja noch erzwingen, wie man in Zeile 18 sieht. Der Aufruf der anderen l¨ asst sich gar nicht mehr bewerkstelligen, denn wie soll man das dem Compiler denn sagen? Den nimm-die-ohne-default-Parameter Operator hat noch niemand erfunden :-). Das bedeutet, dass die Funktion aus Zeile 11 quasi von der anderen vollst¨ andig versteckt wird und damit u ¨berhaupt nicht mehr aufrufbar ist! Seit Software entwickelt wird, gibt es ein ganz großes Thema: Performance. Programme sind, genauso wie Rechner, prinzipiell niemals schnell genug und es wird mit sinnvollen, aber auch mit vielen weniger sinnvollen Konstrukten versucht, das Letzte an Geschwindigkeit aus Software herauszuholen. Ein Feature von C++ unterst¨ utzt sehr gut die lokale Optimierung von kurzen Funktionen, die sehr oft aufgerufen werden. Es ist dies die M¨oglichkeit, Funktionen als inline zu deklarieren. Die Wirkungsweise von inline ist leicht erkl¨art: Nur der Aufruf einer Funktion allein braucht schon Zeit. Es werden dabei intern die Parameter auf den Stack gelegt, zum Code der Funktion gesprungen, dieser ausgef¨ uhrt, der return-Wert wieder u uckgegeben und zum Aufrufer zur¨ uck¨ber den Stack zur¨ gesprungen und zu guter Letzt der Stack wieder aufger¨aumt. Bei Funktionen, bei denen nur ein sehr kurzes St¨ uck Code ausgef¨ uhrt wird, ist der Overhead, der durch den Aufruf allein produziert wird, nicht ganz vernachl¨assigbar. Deklariert man eine Funktion als inline, so wird vom Compiler nicht der u ugt quasi den Code ¨bliche Code des Funktionsaufrufs generiert, sondern er f¨ der Funktion an der Stelle des Aufrufs in das Programm ein (stimmt technisch gesehen nicht ganz, aber als Modell lassen wir dies einmal so im Raum stehen). Dadurch wird der angesprochene Overhead eliminiert. Sehen wir uns das kurz am Beispiel an (inline_function_demo.cpp):
5. Funktionen
1
97
// i n l i n e f u n c t i o n d e m o . cpp − demo o f i n l i n e f u n c t i o n s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− i n l i n e double square ( double num) { return (num ∗ num ) ; }
14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { cout << square (17.3) << e nd l ; return ( 0 ) ; }
So einfach, wie auf den ersten Blick die Verwendung von inline aussieht, ist sie allerdings gar nicht, wenn man sich ein wenig n¨aher u ¨berlegt, was hinter den Kulissen passiert. Der Compiler muss ja den gesamten Code, der bei einer inline Funktion ausgef¨ uhrt werden soll, bereits beim Aufl¨osen eines Aufrufs kennen. Wie sollte er ihn auch sonst einsetzen? Im Normalfall generiert der Compiler nur symbolische Namen und der Linker setzt beim Aufruf die entsprechende Adresse ein. Hier ist es nicht genug, dass ein Compiler nur einen symbolischen Namen generiert. Das bedeutet also, dass bei inline Funktionen nicht nur die Deklaration der Funktion, sondern auch die Definition im Scope des Compilers liegen muss. In unserem Demoprogramm habe ich ganz bewusst auf eine explizite Deklaration zu Beginn des Files und eine Definition am Ende des Files verzichtet, um diesen Umstand zu signalisieren. Dies ist bei standardkonformen Compilern nicht notwendig, allerdings muss sichergestellt sein, dass die Definition im selben File zu liegen kommt, wie der Aufruf. Aus diesem Grund werden inline Funktionen auch im Normalfall in Headers deklariert und definiert. Noch eine Eigenheit haben inline Funktionen: Es wird nicht garantiert, dass eine solche Funktion tats¨ achlich vom Compiler als inline eingesetzt wird. Je nach Cleverness des Compilers und abh¨angig von ein paar anderen Umst¨anden kann es sein, dass der Compiler den inline Hinweis ignoriert und eine “echte” Funktion daraus macht. Wann genau das passiert, dazu gibt es keine bindenden Regeln, aber ich m¨ochte gerne an einem Beispiel demonstrieren, dass die Arbeit des Compilers bei inline Funktionen nicht immer ganz einfach ist (inline_func_difficult.cpp):
98
1 2
5. Funktionen
// i n l i n e f u n c d i f f i c u l t . cpp − demo f o r i n l i n e f u n c t i o n s t h a t // a r e d i f f i c u l t f o r the c o m p i l e r
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− i n l i n e u i n t 6 4 f a c t ( u i n t 6 4 num) { return (num < = 1 ? 1 : (num ∗ f a c t (num − 1 ) ) ) ; }
15 16 17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { cout << f a c t (20) << e nd l ; return ( 0 ) ; }
Der Aufruf von fact in Zeile 18 kann je nach Compiler ganz verschieden eingesetzt werden. Wenn man bedenkt, dass wir hier eine rekursive Funktion als inline deklariert haben, so kann man sich einfach vorstellen, dass nicht einfach nur automatisch Code eingesetzt werden kann. Sofern der Compiler n¨amlich nicht auch noch wirklich selbst die Abbruchbedingung pr¨ uft, w¨ urde er endlos rekursiv Object-Code erzeugen :-). Es gibt hier eine ganze Reihe von M¨ oglichkeiten, wie der Compiler mit solchen Konstrukten umgeht. Zwei ganz offensichtliche davon sind: Entweder der Compiler versucht erst gar nicht, dem inline Hinweis nachzukommen, oder er rechnet tats¨achlich selbst das Ergebnis aus, was ja hier m¨oglich w¨are, weil wir die Faktorielle einer Konstanten wissen wollen. W¨ urde in Zeile 18 anstatt fact(20) z.B. fact(my_var) stehen, wobei my_var irgendeine Variable ist, so wird es richtig kompliziert. Eine gute M¨ oglichkeit, die in einigen Compilern implementiert ist, ist die interne Erkennung einer zu tiefen Rekursion. Dabei geht der Compiler so vor, dass er tats¨ achlich den Code einfach einsetzt, wenn er jedoch entdeckt, dass er dadurch gerade in eine Schleife geraten ist, dann bricht er nach einer gewissen Anzahl von Einsetzungen (z.B. 5) ab und setzt an dieser Stelle einen echten Funktionsaufruf ein. Was ist also die Quintessenz aus diesen Betrachtungen? Es ist abh¨angig vom verwendeten Compiler, wie clever er mit Code umgehen kann. Davon h¨angt wiederum ab, ob ein inline Hinweis tats¨achlich befolgt oder einfach ignoriert wird. Es liegt auch am Compiler, z.B. einen inline Hinweis f¨ ur eine “lange” Funktion zu ignorieren, weil der Performancegewinn in keinem Verh¨ altnis zur erzeugten Menge von Object-Code steht. Es ist auch nicht gesagt, dass wirklich einfach der inline Code quasi “textuell” eingesetzt wird. Je nach Compiler kommen eventuell auch im Vergleich zum Standard-Funktionsaufruf alternative (schnellere) Aufrufmechanismen zum
5. Funktionen
99
Einsatz. Dieser Umstand ist einfach zu erkennen, wenn man bedenkt, dass auch inline Funktionen einen eindeutigen Funktionspointer besitzen und z.B. static Variablen in ihnen vorkommen d¨ urfen. Zur Verwendung von inline Funktionen m¨ochte ich folgende Vorgehensweise anregen: • Es sollen ausschließlich Funktionen als inline deklariert werden, die wirklich außerordentlich oft im Code aufgerufen werden. • Zus¨atzlich zum oftmaligen Aufruf ist auch die ausf¨ uhrbare Codemenge von Funktionen ein Thema: Es sollen ausschließlich Funktionen als inline deklariert werden, die einen sehr kurzen Funktionsrumpf besitzen. Bei “langen” Funktionen ist inline sinnlos. • Wenn eine Funktion als inline deklariert wird, sollte man sofort anhand von Tests u ufen, ob sich tats¨ achlich der gew¨ unschte Laufzeitgewinn ¨berpr¨ eingestellt hat. Wenn nicht, dann sollte man die inline Deklaration wieder r¨ uckg¨ angig machen, weil sie in diesem Fall nichts bringt. Ein kurzer Exkurs zum Thema Optimierung: Es ist mit inline Funktionen nun eine Maßnahme bekannt, wie man eine gewisse Laufzeitoptimierung erreichen kann. Jedoch muss ich unbedingt noch ein paar allgemeine Worte zum Thema Performancesteigerung verlieren, denn gerade in diesem Bereich wird unheimlich viel verbrochen, was jeglichen Regeln vern¨ unftiger Softwareentwicklung widerspricht. Prinzipiell gibt es zwei Kriterien, die Performance von Software zu beurteilen: Subjektive Performance: Bei der subjektiven Performance kommt es nur darauf an, wie schnell sich ein Programm anf¨ uhlt, nicht unbedingt, wie schnell es tats¨ achlich ist. Wenn man z.B. bei einem Programm lange warten muss, bis dann pl¨ otzlich eine vollst¨andige Darstellung von etwas Komplexem angezeigt wird, so wird dies als unangenehm langsam empfunden. Wenn allerdings dasselbe Programm St¨ uck f¨ ur St¨ uck dieses komplexe Etwas aufbaut, sodass man den Aufbau laufend mitverfolgen kann, so wird dies als schneller empfunden. Es kann sogar vorkommen, dass die Gesamtzeit, die das Programm bis zur fertigen Anzeige braucht, beim subjektiv langsameren System k¨ urzer ist. Objektiv gesehen also w¨are das als langsam empfundene System eigentlich das schnellere. Objektive Performance: Bei der objektiven Performance kommt es auf reine Fakten an, in unserem Fall eben Zeitmessungen. Die Geschwindigkeit der Abarbeitung einer Aufgabe ist ein direktes Produkt der Effizienz einer L¨osung. Beide Aspekte, subjektive und objektive Performance summieren sich zum Gesamteindruck des Systems. Maßnahmen, die die subjektive Performance steigern, werden u ¨berall dort eingesetzt, wo man keine echte, also objektive Steigerung mehr erreichen kann.
100
5. Funktionen
Wenden wir uns aber jetzt der objektiven Performance zu, denn diese ist einerseits wichtiger, andererseits schwerer zu beherrschen, wenn man nicht sauber arbeitet. Insbesondere wirken sich hier Designfehler extrem aus! Falsch gew¨ ahlte Datenstrukturen, die nicht f¨ ur das zu l¨osende Problem geeignet sind, sind die ultimativen Killer. Genauso verh¨alt es sich mit verworrenen Programmen, die nur so vor Seiteneffekten strotzen. In diesen werden z.B. dieselben Abfragen immer wieder durchgef¨ uhrt, weil man sich nie sicher sein kann, ob sich seit der letzten Abfrage vielleicht nicht doch etwas “hinten herum” ge¨ andert hat. Um jetzt nicht gleich ein ganzes Buch zum Thema Optimierung zu schreiben, will ich nur ein paar Aspekte anf¨ uhren, die man im Hinterkopf behalten sollte: • Der wichtigste Punkt bei der Optimierung ist, dass man niemals im Widerspruch zu den Grundregeln einer sauberen, intuitiven, wartbaren, erweiterbaren und modularen Softwarearchitektur stehen darf. Nur zu oft sieht man, dass Programme so lange “mit der Brechstange” optimiert wer¨ den, bis sich im Code niemand mehr auskennt. Bei der n¨achsten Anderung macht sich große Verzweiflung breit und vor lauter Seiteneffekten hat man das Resultat, das bereits zuvor angesprochen wurde: Die Software wird immer langsamer! • Ein altbekanntes Faktum bei der Optimierung ist, dass Speicherbedarfsund Rechenzeitoptimierung nur allzu oft im Widerspruch zueinander stehen. Es ist niemandem geholfen, wenn ein Programm ein wenig schneller wird, sich aber daf¨ ur als enormer Speicherfresser bemerkbar macht. Durch die Wahl geeigneter Datenstrukturen kann man allerdings eine gute Balance herstellen. • Bevor man u ¨berhaupt den Code angreift, um etwas zu optimieren, ist es notwendig zu wissen, was man eigentlich optimiert. Oftmals wird der Fehler gemacht, Programmteile auf Geschwindigkeit zu optimieren, die so selten aufgerufen werden, dass sie nur einen sehr kleinen Bruchteil der Gesamtrechenzeit ben¨ otigen. Damit hat man dann zwar sehr viel Arbeit investiert, nur der Erfolg l¨ asst zu w¨ unschen u ¨brig. Es ist unbedingt notwendig, einen objektiven Test durchf¨ uhren, um herauszufinden, welche Vorg¨ange im Programm insgesamt am meisten Laufzeit in Anspruch nehmen. Zumeist ist es so, dass zwischen 70% und 90% der Laufzeit von einer Handvoll Funktionen verbraucht werden, weil diese einfach so oft aufgerufen werden. Optimiert man an der richtigen Stelle, hat man auch schon mit kleinen Verbesserungen eine relativ große Steigerung der Gesamtperformance erreicht. • Am allerwichtigsten u uhrte Code¨berhaupt ist es, die insgesamt ausgef¨ menge kurz zu halten. Dies kann man nur durch Wahl der geeigneten Datenstrukturen und Algorithmen erreichen. Gerade hierbei werden die gravierendsten Fehler gemacht, denn oft wird um wenige Zeilen Code ge-
5. Funktionen
101
feilscht (=lineare Ersparnis), obwohl ein Algorithmus mit Komplexit¨at O(n2 ) (=quadratischer Verlust) oder schlimmer im Einsatz ist. Um das Gesagte zu untermauern, m¨ochte ich hier eine kleine Geschichte erz¨ahlen, die sich im Jahr 1990 ereignet hat: Es wurde ein Paket zur Berechnung einer speziellen Regression auf einer f¨ ur damals sehr leistungsf¨ahigen Unix-Workstation entwickelt. Leider war ein sehr naiver Entwickler am Werk, der keine R¨ ucksicht auf die Beschaffenheit der Daten nahm und wahnwitzigste Matrix-Operationen, mit einer Laufzeitkomplexit¨at von O(n2 ) bis O(n3 ), je nach Art der Berechnung, implementierte. Ergebnis: Ein Programmlauf dauerte im Durchschnitt zwei Tage. Der verzweifelte Entwickler investierte viel Zeit in sehr naive Laufzeitoptimierung (hier ein paar Statements, dort ein paar...), aber der Erfolg war nur minimal. Der beste Wert wurde mit 1 1/2 Tagen Laufzeit erreicht. Nachdem der mittlerweile v¨ ollig entnervte Entwickler aufgab, wurde das Programm auf seine Komplexit¨ at untersucht und einem entsprechenden kompletten Redesign unterzogen. Besonderes Augenmerk wurde hierbei auf interne Datenstrukturen gelegt. Der Zeitaufwand vom Beginn des Redesigns bis zur fertigen Implementation betrug weniger als die H¨alfte der zuvor zur sinnlosen Optimierung investierten Zeit. Der Erfolg war aber durchschlagend: Die Komplexit¨at aller Berechnungen lag nunmehr bei O(n). Damit wurde die Laufzeit des Programms, mit denselben Daten wie zuvor, auf weniger als eine Minute reduziert! Als kleines Detail am Rande w¨ are hier noch zu erw¨ahnen, dass das sinnvoll optimierte Programm sauber und gut lesbar war. Ganz im Gegensatz zu dem Meisterwerk, das durch den verzweifelten ersten Optimierungsversuch entstand, denn dieses war durch das Herausquetschen der letzten kleinen Instruktionen einfach gar nicht mehr lesbar. • Damit Komplexit¨ atsverbesserungen garantiert den gew¨ unschten Effekt bringen, sollte man auf jeden Fall auch auf die worst-Case Komplexit¨at eines Algorithmus losgehen, nicht nur auf den Durchschnitt (außer, man kann den worst-Case garantiert ausschalten). Das bekannteste Beispiel eines Algorithmus mit O(log(n)) average-Komplexit¨at, die allerdings zu O(n2 ) im worst-Case entarten kann, ist Quicksort. Wenn man diesem die “falschen” Daten f¨ uttert ist es ganz und gar nicht mehr so quick :-). • Performancetests sollten immer auch auf viel zu gering ausgestatteten Systemen mit gleichzeitig viel zu großer Belastung stattfinden. Auf diese Art lassen sich leichter unangenehme Effekte finden (auch sehr geeignet f¨ ur den Test auf subjektive Performance!). • W¨ahrend der Phase der Optimierungen muss man immer darauf achten, nur eine Sache auf einmal zu optimieren und dann einen Vergleichstest durchzuf¨ uhren. Der Grund daf¨ ur ist einfach zu erkennen: Wenn man in einem Programm an mehreren Baustellen gleichzeitig arbeitet, l¨asst sich nicht mehr sagen, von welcher der Maßnahmen nun welcher Effekt ausgeht.
102
5. Funktionen
Es kann sogar passieren, dass sich die Effekte verschiedener Maßnahmen gegenseitig aufheben und damit hat man viel Arbeit umsonst investiert! • Wann ist nun der Zeitpunkt f¨ ur Optimierungen gekommen? Prinzipiell bereits beim Design, denn dieses entscheidet u ¨ber die Komplexit¨at des Codes. Weitere Performancetests und Optimierungsschritte sollten bei sehr oft verwendeten Basis- und Utility-Klassen m¨oglichst fr¨ uh stattfinden. Module, die bereits einen sehr hohen Abstraktionslevel haben, kann man zum Schluss der Entwicklung optimieren.
6. Pointer und References
C ist schon ber¨ uhmt und nicht minder ber¨ uchtigt f¨ ur seine Pointer, die ein unheimlich m¨ achtiges aber auch gleichzeitig gef¨ahrliches Werkzeug darstellen. C++ hat nicht nur die Pointer von seiner Urmutter C u ¨bernommen, sondern auch den schlechten Ruf, der ihnen anlastet. Ich kann allerdings nur immer wieder betonen, dass nicht die Existenz von Pointern das Problem ist, sondern dass Unwissenheit und Unverst¨ andnis sowie Leichtsinn bei der Verwendung von Pointern die wahren Schuldigen an deren schlechtem Ruf sind. Bei Fehlverwendung von Pointern er¨offnen sich ungeahnte und besonders heimt¨ uckische Fehlerm¨ oglichkeiten! Probleme, die durch die Fehlverwendung von Pointern entstehen, sind u ¨blicherweise außerordentlich schwer zu lokalisieren, denn nur zu oft hat der Fehler selbst noch keine sofort merkbaren Auswirkungen. Die Katastrophe tritt nicht selten erst viel sp¨ater, zu irgendeinem v¨ ollig unbestimmten Zeitpunkt, ans Tageslicht. Dann n¨amlich, wenn ein Programm endg¨ ultig seinem durcheinander gekommenen Heap ohne einen direkt erkennbaren Grund zum Opfer f¨allt und entweder sinnlose Ergebnisse produziert oder abst¨ urzt. Neben den altbekannten Pointern bietet C++ auch die sogenannten References an, die es in C nicht gibt. Man k¨onnte References auch salopp als versteckte Pointer bezeichnen. In jedem Fall sind References wunderbar als Einstieg in die Welt der Adressen geeignet und werden daher in der Folge zuerst behandelt, bevor wir uns den Pointern in ihrer Hardcore-Variante zuwenden.
6.1 References Wie C, so unterst¨ utzt auch C++ bei Funktionsaufrufen ausschließlich callby-value zur Parameter¨ ubergabe. Manchmal will man allerdings auch einen call-by-reference erreichen, also einen u ¨bergebenen Parameter nachhaltig im aufrufenden Programmteil ver¨ andern. Die einzige M¨oglichkeit, dies zu bewerkstelligen, ist der Umweg u ¨ber eine Adresse, die auf die Originalvariable verweist, auf die sich der Parameter bezieht. Neben einem gewollten nachhaltigen Ver¨andern von Variablen gibt es auch noch einen anderen Grund, der einen call-by-reference notwendig macht: Ein call-by-value z.B. mit einer struct als Parameter f¨ uhrt sehr oft zu einem
104
6. Pointer und References
gewaltigen Laufzeit-Overhead, denn es muss ja der gesamte Inhalt der struct kopiert werden und das kann eine ganze Menge sein. Selbiges gilt f¨ ur den return-Wert. Es ist also auch in diesem Fall sehr sinnvoll, einfach u ¨ber eine Adresse Bezug auf das Original zu nehmen. All das, was ich hier gerade angef¨ uhrt habe, macht man in C mit Pointern “per Hand”. In C++ hat man aus mehreren Gr¨ unden, die uns z.T. im OO-Teil noch begegnen werden, den expliziten Pointern ein weiteres Konstrukt zur Seite gestellt, die sogenannten References. Diese stellen, wenn man so will, implizite Pointer dar, bei denen man sich nicht als Entwickler selbst um die Dereferenzierung bei der Zuweisung k¨ ummern muss. Diese passiert versteckt hinter den Kulissen. Auch gibt es bei References keine Pointerarithmetik. Genehmigen wir uns zum Einstieg ein erstes kleines Beispiel, das die Verwendung von References demonstriert (first_reference_demo.cpp): 1
// f i r s t r e f e r e n c e d e m o . cpp − demo o f r e f e r e n c e s i n C++
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9
void changeVariable ( i n t 3 2 &var ) ;
10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { i n t 3 2 my var = 1 2 ; i n t 3 2 & my ref = my var ;
16
// my var and my ref r e f e r to the same i n t 3 2 i n memory // t h e r e f o r e they s h a r e the same v a l u e . . . cout << ”my var : ” << my var << ” , my ref : ” << my ref << e nd l ;
17 18 19 20 21
// l e t ’ s change my var and s e e what happens my var ∗ = 2 ; cout << ”my var : ” << my var << ” , my ref : ” << my ref << e nd l ;
22 23 24 25 26
// and now l e t ’ s change my ref and s e e what happens my ref ∗ = 2 ; cout << ”my var : ” << my var << ” , my ref : ” << my ref << e nd l ;
27 28 29 30 31
// l e t ’ s t r y a c a l l−by−r e f e r e n c e with my var changeVariable ( my var ) ; cout << ”my var : ” << my var << ” , my ref : ” << my ref << e nd l ;
32 33 34 35 36
// and now l e t ’ s t r y the same with my ref changeVariable ( my ref ) ; cout << ”my var : ” << my var << ” , my ref : ” << my ref << e nd l ;
37 38 39 40 41
return ( 0 ) ;
42 43
}
44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
6.1 References
46 47 48 49
105
void changeVariable ( i n t 3 2 &var ) { var / = 2 ; }
Der Output dieses Programms zeigt sehr schnell, was es mit References so auf sich hat: my my my my my
var : var : var : var : var :
12, 24, 48, 24, 12,
my my my my my
ref : ref : ref : ref : ref :
12 24 48 24 12
Beginnen wir mit der Analyse des Programms in Zeile 14: Dort wird eine ganz normale int32 Variable namens my_var definiert. In der folgenden Zeile 15 allerdings findet sich eine Variablendefinition, die uns bisher noch nicht untergekommen ist. Genau dies ist die Definition einer Reference. Um eine Variable als Reference Variable zu definieren, wird dem Variablennamen bei der Definition ein & vorangestellt. Worauf die Reference zeigt, wird durch deren Initialisierung festgelegt. In unserem Fall referenziert my_ref die Variable my_var, das bedeutet, beide Variablen beziehen sich auf denselben Platz im Speicher! In den Zeilen 19–30 sieht man, dass man tats¨achlich beide Variablen alternativ verwenden kann. Egal, was man mit einer von ihnen macht, das Ergebnis ist immer in der jeweils anderen auch zu sehen. Was auch sonst, beziehen sich die beiden doch auf dieselbe Speicherstelle. Das Sch¨one im Gegensatz zu “echten” Pointern ist hierbei, dass man sich u ¨berhaupt nicht um die Dereferenzierung k¨ ummern muss. Eine Reference Variable wird einfach ganz gleich im laufenden Code verwendet wie eine normale Variable, der Rest passiert hinter den Kulissen. F¨ ur Entwickler, die bisher nur mit C zu tun hatten, mag dieses Verhalten ungewohnt sein, vor allem liest sich z.B. die Zeile 15 f¨ ur solche Entwickler etwas komisch. Dort wird my_ref im Prinzip auf eine Adresse initialisiert, aber die Schreibweise suggeriert irgendwie, dass eine Zuweisung des Inhalts stattfindet. Dieses komische Gef¨ uhl verschwindet allerdings sehr schnell, wenn man sich erst einmal an die Arbeit mit den wirklich sehr praktischen References gew¨ ohnt hat. Vielfach ist in der Literatur erw¨ ahnt, dass es sich bei References im Prinzip um alternative Namen handelt. Betrachtet man unser Beispiel, so versteht man auch sehr schnell, wieso es zu dieser Bezeichnung kommt. Jedoch verschleiert der Begriff der alternativen Namen v¨ollig, dass ja eigentlich eine Reference Variable viel mehr als nur quasi ein Alias ist. Sie ist tats¨achlich eine alternative Zugriffsm¨ oglichkeit. Aus diesem Grund m¨ochte ich allen Lesern ans Herz legen, den Begriff der alternativen Namen nicht zu verwenden. In den Zeilen 46–49 ist eine Funktion definiert, die von einer Reference Gebrauch macht, um einen call-by-reference, also ein nachhaltiges Ver¨andern
106
6. Pointer und References
einer Variable beim Aufrufenden zu erreichen. Wie man in Zeile 33 sieht, funktioniert dies auch wie erwartet. Dass man auch eine Reference einem call-by-reference u ¨bergeben kann und dass dabei nicht pl¨otzlich eine DoppelReference mit schlimmen Folgen daraus wird, sieht man in Zeile 38. Obwohl es aus der Besprechung des Beispielprogramms eigentlich klar sein sollte, m¨ ochte ich es hier trotzdem explizit erw¨ahnen, um keine Missverst¨andnisse aufkommen zu lassen: Kein einziger Operator wirkt auf eine Reference selbst! Egal, welche Operatoren man auf eine Reference Variable anwendet, sie wirken immer ausschließlich auf den Inhalt, der sich hinter der Reference verbirgt! Es gibt also keine M¨oglichkeit, z.B. so etwas wie Pointerarithmetik (wird sp¨ater noch genau erkl¨art) mit References durchzuf¨ uhren. In der Natur von References liegt auch, dass man sie nicht einfach z.B. mit einem Zahlenwert initialisieren kann. References sind ja quasi versteckte Zeiger auf einen Speicherplatz. Aus diesem Grund muss die Initialisierung immer mit einem sogenannten lvalue erfolgen, denn nur dieser garantiert ja, dass dahinter auch Speicherplatz existiert, der beschrieben werden kann. Und genau diese Eigenschaft f¨ uhrt uns zu einem weiteren Punkt: References m¨ ussen unbedingt immer bei ihrer Definition explizit initialisiert werden!. Der Grund ist einfach: Es wurde bereits erw¨ahnt, dass kein einziger Operator auf die Reference wirkt, sondern alle nur auf den Inhalt. Das bedeutet auch, dass es ja gar keine M¨oglichkeit gibt, das Ziel der Referenz nachtr¨aglich zu ver¨ andern, denn eine Zuweisung will ja den Inhalt und nicht die Referenz zuweisen! Also kommt der expliziten Initialisierung einer Referenz eine besondere Rolle zu: Sie ist der einzige Zeitpunkt, zu dem man in der Lage ist, ihr Ziel zu setzen. Ein kleines Detail am Rande: Es war bereits davon die Rede, dass unter dem Begriff der expliziten Initialisierung das Setzen eines Wertes bei der Definition gemeint ist und nicht das Zuweisen eines Wertes irgendwann nach der Definition. Am Verhalten der Initialisierung von References sieht man eindeutig, dass auch der Compiler intern eine explizite Initialisierung ganz anders interpretiert als eine Zuweisung. Ansonsten w¨are es n¨amlich nicht m¨oglich, eine Referenz u ¨berhaupt auf eine Speicherstelle zeigen zu lassen, denn die Zuweisung bewirkt ja etwas g¨anzlich Anderes! Vorsicht Falle: Leider sieht man immer wieder, dass durch Unwissenheit oder Schlamperei besonders tolle Zeitbomben wie die folgende implementiert werden (reference_problems_demo.cpp): 1
// r e f e r e n c e p r o b l e m s d e m o . cpp − p o s s i b l e problems with r e f e r e n c e s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6
i n t 3 2 & returnDeadVariable ( ) ;
7 8 9
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] )
6.1 References
10
107
{ i n t 3 2 my var = returnDeadVariable ( ) ; i n t 3 2 & my ref = returnDeadVariable ( ) ;
11 12 13
return ( 0 ) ;
14 15
}
16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− i n t 3 2 & returnDeadVariable ( ) { int32 an auto variable = 17;
21
return ( a n a u t o v a r i a b l e ) ; // NEVER ! ! ! ! ! ! ! ! ! ! ! ! ! !
22 23
}
Ein Blick auf die Funktion, die in den Zeilen 18–23 implementiert wird, zeigt, was man niemals tun darf: In Zeile 22 wird die Reference auf eine Variable als return-Wert geliefert, bloß hat diese Variable zum Zeitpunkt des R¨ ucksprungs ihre Lifetime bereits hinter sich! Welchen Wert die Variable my_var in Zeile 11 zugewiesen bekommt, steht in den Sternen. Leider passiert es sehr h¨aufig, dass der Speicher, der von der mittlerweile “toten” Variable benutzt wurde, noch nicht u ¨berschrieben wurde. Dementsprechend wird in vielen F¨ allen my_var tats¨ achlich noch den beabsichtigten Wert (in unserem Fall 17) enthalten. Dies ist keinesfalls garantiert! Jedoch suggeriert ein solches durch Zufall entstandenes Verhalten, dass das Programm vermeintlich fehlerlos funktioniert – das tut es aber nicht! Viel Schlimmeres noch passiert in Zeile 12: Dort wird tats¨achlich die Reference auf eine tote Variable einer anderen Reference Variable zugewiesen. Damit hat man gut versteckt einen Pointer auf “irgendetwas”. Hat man Gl¨ uck, dann verabschiedet sich das Programm beim ersten Zugriffsversuch auf diesen Speicher mit einer Segmentation Violation. Hat man Pech, was leider auch relativ oft der Fall ist, dann geh¨ ort der falsch referenzierte Speicher irgendeiner anderen Variable im eigenen Programm. Damit sieht das Betriebssystem keine Veranlassung, eine wie auch immer geartete Schutzverletzung zu melden. Und schon hat man die tollste Zeitbombe in ein Programm eingebaut, die man sich vorstellen kann. Zu irgendeinem v¨ollig unvorhersagbaren Zeitpunkt wird sich das Programm an irgendeiner v¨ollig unvorhersagbaren Stelle sehr komisch benehmen und die n¨ achste schlaflose Nacht zur Fehlersuche k¨ undigt sich an. Das schlimmste Problem bei solchen Fehlern ist, dass Ursache und Auswirkung in keinem erkennbaren Verh¨altnis zueinander stehen. Daher sind sie auch außerordentlich schwer zu lokalisieren! Zum Gl¨ uck warnen heutige Compiler bei solchen bombigen Programmen, dass eine Reference auf eine lokale Variable als return-Wert vielleicht gar keine so gute Idee ist. Bloß, was hilft die beste Warning, wenn sie von Entwicklern ignoriert wird? Sehr sinnvoll einsetzbar sind References auch, wenn man eine struct bei einem Funktionsaufruf als Parameter u ¨bergeben will. In C++ findet bekannterweise immer ein call-by-value statt. Das bedeutet, dass eine Kopie einer
108
6. Pointer und References
struct gemacht werden muss, um einen solchen durchf¨ uhren zu k¨onnen. Neben großen Einbußen bei der Performance kann man dabei auch leicht u ¨ber das deep- und shallow-clone Problem stolpern. Mit den hier besprochenen References kann man diese Probleme sehr elegant in den Griff bekommen, wie das folgende Beispiel zeigt (struct_reference_demo.cpp): 1
// s t r u c t r e f e r e n c e d e m o . cpp − demo how to use s t r u c t s as params
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
struct T e s t S t r u c t { i n t 3 2 a member ; i n t 3 2 another member ; };
14 15 16
void show ( struct T e s t S t r u c t & t h e s t r u c t ) ; void changeStruct ( struct T e s t S t r u c t & t h e s t r u c t ) ;
17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { struct T e s t S t r u c t a s t r u c t ;
22
a s t r u c t . a member = 1 7 ; a s t r u c t . another member = 2 0 ;
23 24 25
show ( a s t r u c t ) ; changeStruct ( a s t r u c t ) ; show ( a s t r u c t ) ;
26 27 28 29
return ( 0 ) ;
30 31
}
32 33 34 35 36 37 38
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( struct T e s t S t r u c t & t h e s t r u c t ) { cout << ”a member : ” << t h e s t r u c t . a member << ” , another member : ” << t h e s t r u c t . another member << e nd l ; }
39 40 41 42 43 44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void changeStruct ( struct T e s t S t r u c t & t h e s t r u c t ) { t h e s t r u c t . a member = 2 ; t h e s t r u c t . another member = 4 ; }
Wie zu erwarten, liefert das Programm folgenden Output: a member : 1 7 , another member : 2 0 a member : 2 , another member : 4
Sehen wir uns einmal die Funktion show an, wie sie in den Zeilen 34–38 definiert ist: Diese nimmt als Parameter eine Reference auf eine TestStruct. Es wird also hinter den Kulissen ein Pointer u ¨bergeben, womit man nicht mehr vor dem Problem steht, dass eine Kopie der gesamten struct gemacht
6.1 References
109
werden m¨ usste, um den Funktionsaufruf durchzuf¨ uhren. Dementsprechend findet der Aufruf dieser Funktion, z.B. in Zeile 26, ohne jegliche explizite ¨ Pointerkonstrukte einfach nur durch Ubergabe der entsprechenden struct Variable statt. Obwohl dies allein schon eine merkliche Verbesserung im Vergleich zu C darstellt, ist es allerdings noch immer nicht der Weisheit letzter Schluss, wie uns die Funktion changeStruct in den Zeilen 41–45 zeigt: Hier wird der Inhalt der u ¨bergebenen Structure nachhaltig ver¨andert. Dies funktioniert nat¨ urlich, weil wir es ja mit einem call-by-reference zu tun haben. Dadurch, dass es sich um eine Funktion mit dem Namen changeStruct handelt, ist auch f¨ ur alle Entwickler zu erwarten, dass sich etwas ¨andert und dementsprechend ist dieses Verhalten noch intuitiv. Wer aber hindert z.B. die Funktion show daran, die Structure zu ver¨ andern? M¨ ussen sich alle Entwickler ab jetzt bei jedem Aufruf einer Funktion, die einen struct Parameter nimmt, Sorgen machen, dass etwas nachhaltig ge¨ andert wird, auch wenn sie es nicht erwarten? Hoffentlich nicht, denn damit w¨ urden Unmengen an Code geschrieben werden m¨ ussen, um auf diesen Umstand auch entsprechend R¨ ucksicht zu nehmen. Tats¨ achlich gibt es eine M¨ oglichkeit, wie man eine unerwartete Ver¨anderung verhindern kann: Man verwendet eine explizit konstante Referenz. Wie das geht, zeigt das folgende Beispiel (struct_ref_clean_demo.cpp): 1
// s t r u c t r e f c l e a n d e m o . cpp − demo f o r c l e a n use o f s t r u c t r e f s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
struct T e s t S t r u c t { i n t 3 2 a member ; i n t 3 2 another member ; };
14 15
void show ( const struct T e s t S t r u c t & t h e s t r u c t ) ;
16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { struct T e s t S t r u c t a s t r u c t ;
21
a s t r u c t . a member = 1 7 ; a s t r u c t . another member = 2 0 ;
22 23 24
show ( a s t r u c t ) ; return ( 0 ) ;
25 26 27
}
28 29 30 31 32 33 34
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( const struct T e s t S t r u c t & t h e s t r u c t ) { cout << ”a member : ” << t h e s t r u c t . a member << ” , another member : ” << t h e s t r u c t . another member << e nd l ; }
110
6. Pointer und References
Bei der Deklaration von show in Zeile 15 und bei der Definition dieser Funktion in den Zeilen 30–34 sieht man, wie man ein ungewolltes nachhaltiges Ver¨andern des Inhalts einer Variable verhindern kann: Man deklariert den Reference Parameter als const. Dadurch teilt man dem Compiler mit, dass ¨ keine Anderung stattfinden wird und jeder Versuch eines schreibenden Zugriffs auf diesen Parameter wird entsprechend mit einer Fehlermeldung quittiert. Leider stehen viele Entwickler immer noch auf dem Standpunkt, dass const nicht notwendig ist, denn “sie ver¨andern ja sowieso nichts”. Dieser Standpunkt ist sehr schlecht, denn durch die Deklaration als const signalisiert man diese Absicht explizit und dadurch kann es auch bei sp¨ateren ¨ Anderungen zu keinen Missverst¨ andnissen kommen. Vom Compiler wird eine const Reference tats¨achlich als etwas v¨ollig Verschiedenes zu einer normalen Reference gesehen, wie folgendes Demoprogramm zeigt (const_ref_demo.cpp): 1
// c o n s t r e f d e m o . cpp − demo f o r c o n s t r e f e r e n c e s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
struct T e s t S t r u c t { i n t 3 2 a member ; i n t 3 2 another member ; };
14 15 16
void show ( const struct T e s t S t r u c t & t h e s t r u c t ) ; void changeStruct ( struct T e s t S t r u c t & t h e s t r u c t ) ;
17 18 19 20 21 22 23 24 25 26 27 28 29
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { i n t 3 2 my var = 1 7 ; const i n t 3 2 & f i r s t c o n s t r e f = my var ; // the f o l l o w i n g d e f i n i t i o n works , but i s de pr e c a t e d const i n t 3 2 & s e c o n d c o n s t r e f = 1 2 ; // t h i s i n i t i a l i z a t i o n r e s u l t s i n a c o m p i l e r e r r o r int32 & t h i r d c o n s t r e f = second const ref ; // t h i s bad hack i s p o s s i b l e , but . . . i n t 3 2 & h a c k c o n s t r e f = const cast(s e c o n d c o n s t r e f ) ;
30 31 32
cout << ” s e c o n d c o n s t r e f : ” << s e c o n d c o n s t r e f << ” , h a c k c o n s t r e f : ” << h a c k c o n s t r e f << e nd l ;
33 34 35
// the f o l l o w i n g statement changes a c o n s t a n t . . . ! ! ! ! ! hack const ref = 3;
36 37 38 39
cout << ” a f t e r hack . . . s e c o n d c o n s t r e f : ” << s e c o n d c o n s t r e f << ” , h a c k c o n s t r e f : ” << h a c k c o n s t r e f << e nd l ;
40 41
struct T e s t S t r u c t a s t r u c t ;
6.1 References
111
a s t r u c t . a member = 1 7 ; a s t r u c t . another member = 2 0 ;
42 43 44
show ( a s t r u c t ) ;
45 46
// assignment o f r e f e r e n c e i s f a s t e r than copying s t r u c t struct T e s t S t r u c t & a s s i g n e d s t r u c t = a s t r u c t ;
47 48 49
// t h i s assignment i s ok const struct T e s t S t r u c t & c o n s t s t r u c t r e f = a s t r u c t ;
50 51 52
// t h i s one r e s u l t s i n a c o m p i l e r e r r o r struct T e s t S t r u c t & a n o t h e r s t r u c t r e f = c o n s t s t r u c t r e f ;
53 54 55
return ( 0 ) ;
56 57
}
58 59 60 61 62 63
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( const struct T e s t S t r u c t & t h e s t r u c t ) { struct T e s t S t r u c t &bad hack = const cast<struct T e s t S t r u c t&>(t h e s t r u c t ) ;
64
bad hack . a member = 3 ;
65 66
cout << ”a member : ” << t h e s t r u c t . a member << ” , another member : ” << t h e s t r u c t . another member << e nd l ;
67 68 69
}
70 71 72 73 74 75 76
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void changeStruct ( struct T e s t S t r u c t & t h e s t r u c t ) { t h e s t r u c t . a member = 2 ; t h e s t r u c t . another member = 4 ; }
In diesem Programm ist alles, von korrekter Behandlung von const References u osen Hack, der const wieder aufhebt, bis zu Compiler¨ber einen b¨ fehlern versteckt. Will man das Programm compilieren, muss man die entsprechenden Zeilen 27 und 54 auskommentieren, sonst wird man mit netten Fehlermeldungen beworfen :-). Sehen wir uns aber einmal genau an, was das Programm an Effekten anzubieten hat: • Zeile 23 zeigt, dass eine Zuweisung einer nicht als const definierten Variable auf eine const Reference nat¨ urlich m¨oglich ist. Dies ist auch v¨ollig logisch, denn damit hat man sich ja nur selbst verboten, am Inhalt auf Umweg u ¨ber die Reference etwas zu ¨ andern. Gegen solcherlei Selbstbeschr¨ankung kann kein Compiler der Welt etwas haben. W¨ urde man allerdings versuchen, auf first_const_ref irgendetwas zuzuweisen, dann meldet der Compiler einen Fehler, weil die Reference ja explizit als unver¨anderbar definiert wurde. • Zeile 25 zeigt ein Statement, das eigentlich der Natur von References vollkommen widerspricht: Hier wird eine const Reference mit einem expliziten Zahlenwert initialisiert. Eigentlich haben wir aber schon gesagt, dass die Initialisierung nur mit einem lvalue stattfinden kann, weil ja dahinter eine Speicherstelle stehen muss. Was passiert also hier? Ganz einfach, der
112
6. Pointer und References
Compiler legt hinter den Kulissen eine tempor¨are Variable an, deren Lifetime genau der Lifetime der Reference entspricht, initialisiert diese Variable auf den angegebenen Wert und initialisiert die Reference so, dass sie auf die tempor¨ are Variable zeigt. Jetzt stellt sich nat¨ urlich die Frage, warum der Compiler bei const References so etwas macht, bei normalen References aber in so einem Fall einen Fehler meldet. Die Antwort ist simpel: Es wurde bei der Definition von C++ befunden, dass im Fall von normalen References dieses Verhalten fehleranf¨ allig und gef¨ ahrlich ist. Meine Meinung dazu ist allerdings noch etwas pragmatischer, denn ich stehe auf dem Standpunkt, dass die Sinnhaftigkeit von compilergenerierten tempor¨ aren Variablen sowieso sehr fraglich ist. Deshalb wurde dieser Fall auch im Demoprogramm als deprecated (w¨ortl.: missbilligt) gekennzeichnet. • In Zeile 27 sieht man, was man in jedem Fall nicht machen darf: Man darf eine “normale” Reference nat¨ urlich nicht mit einer const Reference initialisieren, denn damit w¨ urde man ja den Schreibschutz aufheben. Dagegen hat dann der Compiler ein wirksames Mittel, n¨amlich eine Fehlermeldung. • Zeile 29 zeigt eindrucksvoll zwei Dinge: Erstens, dass sich immer Mittel und Wege finden, wie man durch einen b¨osen Hack den Compiler u ¨berreden kann, etwas doch zu u ¨bersetzen. Zweitens, dass tats¨achlich eine tempor¨are Variable hinter der in Zeile 25 definierten const Reference existiert, denn sonst k¨ onnte man die Zuweisung in Zeile 35 nicht ungestraft durchf¨ uhren. Wie bereits erw¨ ahnt, kann man mittels eines const_cast eine schreibgesch¨ utzte Variable zwanglos von ihrem Schreibschutz befreien. Diese M¨oglichkeit sollte man allerdings nur sehr wohl u ¨ berlegt und so selten wie m¨ oglich in Anspruch nehmen. Die Verwendung, wie sie in diesem Beispiel gezeigt wird, ist absolut katastrophal und hat in keinem vern¨ unftigen Programm etwas verloren. • Bereits erw¨ ahnt wurde gerade Zeile 35 des Programms. Durch den b¨osen Hack des Eliminierens des Schreibschutzes ist es tats¨achlich m¨oglich, eine Konstante zu ver¨ andern! Hier wurde dieses Konstrukt ausschließlich deswegen verewigt, weil sich damit demonstrieren l¨asst, dass sich hinter second_const_ref tats¨ achlich eine tempor¨are Variable versteckt. Der Output aus den Zeilen 37–39 zeigt es (wenn man zuerst die richtigen Zeilen auskommentiert, damit das Programm u ¨berhaupt compiliert werden kann). Niemals darf so etwas in einem vern¨ unftigen Programm in dieser Form vorkommen!!! Vorsicht Falle: Auf das hier beschriebene Verhalten, dass sich hinter einer Konstanten eine tempor¨ are Variable versteckt, darf man sich keinesfalls verlassen. Obwohl das Verhalten so im Standard niedergeschrieben ist, kann es, je nach Compiler, sein, dass sich das Programm in diesem Punkt anders verh¨ alt, als hier beschrieben. Grunds¨atzlich l¨asst sich f¨ ur die
6.1 References
113
allt¨agliche Praxis sagen, dass das Resultat dieses b¨osen Hacks undefiniert ist. Ich habe diesen Hack hier vor allem deswegen demonstriert, weil ich bereits in vielen Programmen gesehen habe, dass durch eine unvorsichtige Anwendung des const_cast solche Operationen unabsichtlich vorkommen. • Eine weitere angenehme Seite von References ist in Zeile 48 zu sehen: F¨ uhrt man eine Referenzzuweisung einer struct durch, so ist dies nat¨ urlich deutlich effizienter, als eine Kopie der gesamten struct zu machen, was bei einer Wertzuweisung passieren w¨ urde. Jedoch muss man sich in diesem Fall immer dessen bewusst sein, dass jeder schreibende Zugriff auch eine Ver¨anderung des Originals hervorruft! Ist dies nicht erw¨ unscht, so ist die Reference entsprechend als const zu deklarieren, wie dies in Zeile 51 zu sehen ist. • Zeile 54 demonstriert, dass der Compiler sehr wohl const ernst nimmt, denn er weigert sich, diese Zuweisung einer const Structure auf eine normale durchzuf¨ uhren, obwohl hinter der const Structure ja eine normale steht und diese nur durch die Zuweisung zu const mutiert ist. • In den Zeilen 62–63 sieht man denselben Hack, der uns bereits zuvor begegnet ist: Hier wird b¨ osartigerweise durch einen const_cast der Schreibschutz von the_struct aufgehoben. Sehr, sehr selten gibt es F¨alle, wo dies aus der Programmlogik heraus legitim ist. Der hier demonstrierte Fall ist eindeutig keiner davon! Wir werden sp¨ater im OO-Teil noch einige wenige F¨alle kennen lernen, wo dieser Cast seine Berechtigung hat. Vorsicht Falle: Auch wenn sich References bei ihrer Verwendung im Code sehr ¨ ahnlich anf¨ uhlen wie normale Variablen, so verstecken sich dahinter doch Pointer, die auf eine Adresse zeigen. Damit kann man nat¨ urlich auch die entsprechenden Fehler machen, wie sie z.B. in unserem vorigen Demoprogramm gezeigt wurden. Ich kann also nur sehr nachdr¨ ucklich den Tipp geben, References vorsichtig und vorausschauend zu verwenden. Das Wichtigste bei References ist, den Mechanismus, der dahintersteckt, genauestens verstanden zu haben, um nicht in eine b¨ose Falle zu tappen. Vorsicht Falle: Obwohl der const_cast erfunden wurde, um den Schreibschutz aufzuheben, der z.B. u ¨ber eine Reference mittels const verh¨angt wurde, verbergen sich dahinter ungeahnte Fehlerm¨oglichkeiten. Welcher Entwickler ist denn schon darauf gefasst, dass sich pl¨otzlich eine als const deklarierte Variable bzw. ein Parameter nachhaltig ver¨andert? Ein solches Verhalten f¨ uhrt garantiert zu vielen Nachtschichten, die mit Fehlersuche zugebracht werden, weil sich ein Programm pl¨otzlich außerordentlich komisch verh¨alt. Deshalb hier eine lebensnotwendige Grundregel beim Arbeiten mit const_cast: Der Einsatz von const_cast ist dann und NUR DANN
114
6. Pointer und References
gestattet, wenn sich aus der ¨ außeren Sicht am Inhalt eines als const deklarierten Objekts nichts ver¨ andert! Eine typische Anwendung f¨ ur eine versteckte Ver¨anderung, die “außenstehende” Entwickler nicht bemerken, ist z.B. das Caching von Daten hinter den Kulissen. Je nachdem, was man genau bezwecken will, gibt es auch noch die alternative M¨ oglichkeit, einzelne Variablen als mutable zu deklarieren. Dies wird in Abschnitt 15.1 noch genauer behandelt. Vorsicht Falle: Wo auch immer sich aufgrund der Semantik einer Funktion an einem Parameter nach außen hin nichts ¨andern soll, aber aus technischen Gr¨ unden die Verwendung einer Reference w¨ unschenswert oder notwendig ist (z.B. bei einer Structure als Parameter), soll man diesen Parameter immer als const deklarieren. Damit signalisiert man nach außen, dass man nicht beabsichtigt, etwas zu ver¨ andern. Deklariert man einen solchen Parameter nicht als const, so werden sich alle Entwickler, die diese Funktion verwenden, auf ¨ eine m¨ogliche Anderung einstellen und auf diese Art unn¨otig komplizierten Code erzeugen. Es gibt den noch schlimmeren Fall, dass man eine Funktion, die einen Parameter irrt¨ umlich nicht als const deklariert hat, gar nicht verwenden kann, weil man selbst nur eine const Reference in H¨anden h¨alt. Das Schlimmste, was dann herauskommen kann, ist die notgedrungene Verwendung eines const_cast, um den Aufruf u ¨berhaupt t¨atigen zu k¨onnen. Haben allerdings einmal solche b¨ osen Hacks in den Code Eingang gefunden, dann bahnen sich viele unabwendbare Katastrophen an, denn wer soll sich noch auskennen, welches const ernst gemeint ist und welches nicht?
6.2 Pointer Nach der Diskussion u ultig bei der Hardcore ¨ber References sind wir nun endg¨ Variante des Hantierens mit Adressen angekommen, n¨amlich bei den Pointern. Pointer sind im Prinzip etwas unheimlich Einfaches: Es sind Variablen, deren Inhalt eine Adresse ist. Genau diese Einfachheit macht sie auch so m¨achtig, man kann n¨ amlich damit auf quasi beliebige Inhalte im Speicher zeigen. Diese Inhalte k¨ onnen verschiedenster Natur sein, n¨amlich sowohl Speicherstellen f¨ ur Daten als auch Einsprungadressen von Funktionen bzw. Methoden. Nat¨ urlich kann man mit Adressen auch beliebig rechnen und auf diese Art Bl¨ ocke verwalten. Dieselbe Einfachheit, die Pointer so m¨achtig macht, macht sie gleichzeitig auch so gef¨ ahrlich in den H¨ anden unge¨ ubter und/oder unvorsichtiger Entwickler. Wie bereits bei den Datentypen diskutiert wurde, ist intern in einem Computer praktisch alles einfach nur Interpretationssache. Im Fall von Pointern wird die Interpretation dessen, worauf die Adresse zeigt, noch bei
6.2 Pointer
115
weitem st¨ arker den Entwicklern u ¨berlassen, als dies bei anderen Datentypen der Fall ist. Neben der M¨ oglichkeit, gezielt u ¨ber Adressen Daten ansprechen zu k¨onnen, bietet die Arbeit mit Pointern noch ein ganz besonderes Feature, das ohne Pointer (bzw. zumindest ein sehr ¨ ahnliches Konstrukt) gar nicht realisierbar w¨are: Die dynamische Speicherverwaltung. Es ist m¨oglich, zur Laufzeit eines Programms zu entscheiden, wie viel Speicher man f¨ ur gewisse Aufgaben braucht, diesen Speicher anzufordern und auch wieder freizugeben. Diese M¨oglichkeit ist f¨ ur praktisch alle real-World Anwendungen lebensnotwendig und ohne diese M¨ oglichkeit ist es nicht denkbar, vern¨ unftig funktionierende Software zu entwickeln. Eines muss man aber bei dynamischem MemoryManagement in C++ unbedingt immer im Hinterkopf behalten: Man darf nicht nur einfach Speicher zur Laufzeit dynamisch anfordern, man muss auch unbedingt die Verantwortung f¨ ur die saubere Verwaltung u ¨bernehmen! Der Compiler und das Betriebssystem unterst¨ utzen Entwickler hierbei nur sehr rudiment¨ ar. Nachdem bekannt ist, was man mit Pointern machen kann und bevor ich zur genauen Beschreibung komme, wie man das machen kann, m¨ochte ich gerne allen Lesern die Gewissensfrage nach ihren Vorkenntnissen zu Pointern in C stellen. Denen, die sich nur rudiment¨ar etwas darunter vorstellen k¨onnen, m¨ochte ich eindringlichst raten, dass sie Kapitel 10 aus Softwareentwicklung in C genauestens lesen! Dort wird anf¨angergerecht und akribisch auf den sauberen Umgang mit Pointern eingegangen und es werden alle m¨oglichen Fehler skizziert, die bei unsachgem¨ aßer Handhabung passieren k¨onnen. Einiges was dort beschrieben wird, wird in C++ bereits durch References abgedeckt, das bedeutet aber nicht, dass es f¨ ur das Verst¨andnis nicht ebenso wichtig w¨are! Nat¨ urlich werde ich hier in der Folge den Umgang mit Pointern und alle Stolpersteine nochmals aufzeigen. Jedoch muss ich gerade bei Pointern erfahrungsgem¨ aß den Standpunkt vertreten: Doppelt h¨ alt besser!
6.2.1 Pointer und Adressen Wie bereits erw¨ ahnt, speichert eine Pointervariable eine Adresse. Diese Adresse zeigt auf eine Speicherstelle, genauer gesagt wird sie so interpretiert, dass sie auf die Startadresse eines Speicherblocks zeigt. Nehmen wir beispielsweise einen int32, der bei uns ja so definiert ist, dass er 4 Bytes belegt. Ein Pointer, der auf diesen int32 zeigt, zeigt also in Realit¨at auf das erste der vier f¨ ur ihn reservierten Bytes. Was sagt uns das? Egal, ob wir nun auf einen int8, int32, int64, double, eine Structure oder sonst etwas einen Pointer zeigen lassen, er speichert immer nur die Startadresse! Die Interpretation, was an dieser Stelle steht, ist mehr oder weniger den Entwicklern u ¨berlassen. Dies bringt uns auch gleich zu einer sehr wichtigen Eigenschaft von Pointern: Es gibt keine M¨ oglichkeit, zur Laufzeit herauszufinden, wie viel Speicher an der Stelle,
116
6. Pointer und References
auf die ein Pointer zeigt, regul¨ ar reserviert ist! Es gibt nicht einmal die M¨ oglichkeit, zur Laufzeit herauszufinden, ob ein Pointer u are Adresse zeigt! ¨ berhaupt auf eine regul¨ Vorsicht Falle: Schlamperei beim Entwickeln und daraus resultierende Pointer, die entweder auf Speicherstellen zeigen, auf die sie nicht zeigen sollen oder auch Fehlinterpretationen von Datentypen, geh¨oren zu den schlimmsten Fehlern, die man machen kann. Schlimm sind diese Fehler aus zwei Gr¨ unden: Erstens, weil sie sich nur allzu oft nicht sofort bemerkbar machen. Zweitens, weil im Normalfall kein direkter Zusammenhang zwischen Ursache und Wirkung erkennbar ist. Gerade Typ-Fehlinterpretationen sind außerordentlich schwer in einem fehlerhaften Programm zu lokalisieren. Als erstes Beispiel zum Thema Pointer versuchen wir einmal, den Mechanismus der References, den wir bereits kennen gelernt haben, quasi “zu Fuß” zu implementieren. In C w¨ are diese Art der Implementation sogar notwendig, denn C kennt keine References (first_pointer_demo.cpp): 1
// f i r s t p o i n t e r d e m o . cpp − s m a l l demo o f p o i n t e r s i n C++
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9
void changeVariable ( i n t 3 2 ∗ var ) ;
10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { i n t 3 2 my var = 1 2 ; i n t 3 2 ∗ my ptr = &my var ;
16 17 18 19 20
// my var and my ptr r e f e r to the same i n t 3 2 i n memory // t h e r e f o r e they s h a r e the same v a l u e . . . cout << ”my var : ” << my var << ” , ∗ my ptr : ” << ∗my ptr << e n d l ;
21 22 23 24 25
// l e t ’ s change my var and s e e what happens my var ∗ = 2 ; cout << ”my var : ” << my var << ” , ∗ my ptr : ” << ∗my ptr << e n d l ;
26 27 28 29 30 31
// and now l e t ’ s change the v a l u e behind my ptr // ( not the p o i n t e r i t s e l f ! ) and s e e what happens ∗my ptr ∗ = 2 ; cout << ”my var : ” << my var << ” , ∗ my ptr : ” << ∗my ptr << e n d l ;
32 33 34 35 36
// l e t ’ s t r y a c a l l−by−r e f e r e n c e with my var changeVariable(&my var ) ; cout << ”my var : ” << my var << ” , ∗ my ptr : ” << ∗my ptr << e n d l ;
37 38 39 40
// and now l e t ’ s t r y the same with my ptr changeVariable ( my ptr ) ; cout << ”my var : ” << my var <<
6.2 Pointer
117
” , ∗ my ptr : ” << ∗my ptr << e n d l ;
41 42
return ( 0 ) ;
43 44
}
45 46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void changeVariable ( i n t 3 2 ∗ var ) { ∗ var / = 2 ; }
Der Output dieses Programms ist dann folgender: my my my my my
var : var : var : var : var :
12, 24, 48, 24, 12,
∗ my ∗ my ∗ my ∗ my ∗ my
ptr : ptr : ptr : ptr : ptr :
12 24 48 24 12
Ich habe hier ganz bewusst dasselbe Programm verwendet, das auch schon zur Demonstration von References herangezogen wurde (dieses hatte den Namen first_reference_demo.cpp). Nur habe ich hier alle References durch Pointer und dazugeh¨ orige address-of bzw. dereference Operatoren ersetzt. In Zeile 15 finden wir die erste Definition einer Pointer Variablen: Eine solche wird definiert, indem man den Datentyp der Speicherstelle angibt, auf den die im Pointer gespeicherte Adresse einmal zeigen wird, gefolgt von einem *, gefolgt vom Namen der Variable. In unserem Fall bedeutet die Definition, dass eine Variable my_ptr angelegt wird, die ein Pointer ist, der auf einen int32 zeigen soll. Genau durch die Definition, worauf ein Pointer zeigen soll, haben wir die Interpretation festgelegt. W¨ urde dieser Variable eine Adresse zugewiesen werden, hinter der sich kein int32, sondern vielleicht nur ein int16 verbirgt, so kommt es zu einer Fehlinterpretation mit schlimmen Folgen! Auch die Initialisierung unserer Pointer Variable in Zeile 15 enth¨alt etwas, was bisher noch unbekannt ist, n¨amlich den address-of Operator. Der Ausdruck &my_var liefert die Adresse der Variable my_var im Speicher. Diese Adresse wird dem frisch definierten Pointer bei der Initialisierung zugewiesen und damit zeigt er auf die Speicherstelle, an der my_var steht. Dass beide Variablen sich auf dieselbe Speicherstelle beziehen, my_var direkt und my_ptr auf Umweg u ¨ber die Adresse, wird in der Folge demonstriert. Im Output Statement in den Zeilen 19–20 sieht man, wie man zum Wert kommt, der sich hinter der Adresse verbirgt, auf die der Pointer zeigt: Durch den dereference Operator, der durch einen * symbolisiert wird, bewirkt man die Aufl¨ osung der Adresse und den indirekten Zugriff auf den dahinterstehenden Wert, mit der durch die Variablendefinition gegebenen Interpretation. ¨ In den Zeilen 23–25 sieht man, dass eine Anderung des Inhalts von my_var sich erwartungsgem¨ aß auch beim indirekten Zugriff u ¨ber den Pointer widerspiegelt. Dass man nicht nur den Wert, der sich hinter einer Adresse verbirgt,
118
6. Pointer und References
auslesen kann, sondern ebensogut auf diesem Weg einen Wert zuweisen kann, zeigt sich in den Zeilen 29–31. Auch einen call-by-reference kann man mittels Pointern “zu Fuß” machen, wie Zeile 34 beweist: Die Funktion changeVariable nimmt als Parameter einen Pointer, der mittels &my_var u ¨bergeben wird. Damit zeigt der Parameter var der Funktion changeVariable auf die Speicherstelle von my_var ¨ und jede Anderung des Inhalts innerhalb der Funktion wirkt sich nat¨ urlich auch außerhalb aus. Eines sieht man an diesem Programm allerdings auch: Alle Konstrukte, bei denen es im Prinzip nur auf die Referenzierung von Speicherstellen ohne Aspekte dynamischer Speicherverwaltung ankommt, sind mit References bei weitem eleganter l¨ osbar. Deshalb rate ich in solchen F¨allen unbedingt dazu, f¨ ur diese Aufgaben Pointer nicht zu verwenden, auch wenn man sie aus C gewohnt ist. Vorsicht Falle: Auf eine besondere Falle m¨ochte ich hier auch noch aufmerksam machen, die wir bereits bei den References besprochen haben. Es hat auch bei Pointern katastrophale Auswirkungen, wenn ein Pointer als return-Wert aus einer Funktion geliefert wird, der auf eine Variable zeigt, die ihre Lifetime bereits hinter sich hat. Vorsicht Falle: Es wird dringend angeraten, einen Pointer, den man nicht mehr in Verwendung hat, sofort explizit auf 0 zu setzen. Da 0 per Definition garantiert keine g¨ ultige Adresse repr¨ asentiert, f¨ uhrt jeder Versuch des Dereferenzierens eines solchen Pointers garantiert zu einer Segmentation Violation. Damit bemerkt man auf jeden Fall sofort, wenn man einen Programmfehler eingebaut hat. Ohne explizites Setzen auf 0 ist dies nicht garantiert! Konvention: NULL vs. 0. Leser, die Erfahrung mit C haben, werden sich gefragt haben, warum ich hier 0 als ung¨ ultige Adresse erw¨ahne und nicht NULL, wie es in C u urgert ¨blich ist. Das NULL Macro, das sich in C eingeb¨ hat, kann in C++ ein paar gr¨ oßere oder kleinere Problemchen verursachen: Im Macro ist n¨ amlich NULL vom Typ her als void * definiert. Ein C++ Compiler mit seinen, im Vergleich zu C, besseren Typ¨ uberpr¨ ufungs- und Umwandlungsstrategien sieht das dann so: • Die Zuweisung von 0 (also der Zahl 0) ist problemlos m¨oglich, denn hierbei kann eine implizite Umwandlung stattfinden. Es ist n¨amlich keine Konversion von einem Pointertyp auf einen anderen notwendig. • Die Zuweisung von NULL w¨ urde eine implizite Umwandlung von einem Pointer-Typ auf einen anderen mit sich bringen. Dies wird definitiv als gef¨ahrlich erachtet.
6.2 Pointer
119
Wenn man aus guter alter Gewohnheit die Finger nicht vom NULL Macro lassen kann, so sollte man NULL einfach als 0 definieren (sollte dies nicht ohnehin bereits in einem der Headers zum C++ Compiler geschehen sein).
6.2.2 Dynamische Memory Verwaltung Nach der Kurzbesprechung des Referenzmechanismus “zu Fuß”, der nun wirklich nicht das Berauschendste ist, wof¨ ur man in C++ Pointer verwenden kann bzw. soll, wenden wir uns jetzt dem Thema zu, das im Prinzip die allerwichtigste Anwendung von Pointern in Programmen darstellt: Die dynamische Memory Verwaltung. Unter dem Begriff der dynamischen Memory Verwaltung versteht man das Anfordern, Verwalten und Freigeben von Speicher je nach Bedarf zur Laufzeit. Ok, je nach Bedarf stimmt vielleicht nicht ganz, jedem Computer geht einmal der Speicher aus :-). Ich m¨ ochte gleich zu Beginn auf einen ganz wichtigen Unterschied zwischen C und C++ hinweisen: In C wurden die Funktionen malloc, realloc und free im Rahmen der dynamischen Memory Verwaltung verwendet. Aus sehr guten Gr¨ unden soll man genau von diesen Funktionen in C++ tunlichst die Finger lassen, außer f¨ ur ganz spezielle Anwendungen. Dann muss man allerdings sehr genau wissen, was man tut! Eine Abhandlung zu diesem Thema findet sich in Abschnitt 12.3. Als Ersatz stehen in C++ die Operatoren new und delete zur Verf¨ ugung, die wir in K¨ urze genau unter die Lupe nehmen werden. An einem kurzen Beispiel l¨ asst sich jedenfalls am einfachsten erkl¨aren, wie man prinzipiell mit dynamischer Memory Verwaltung umgeht (first_dynamic_memory_demo.cpp): 1 2
// first dynamic memory demo . cpp − p r i n c i p l e s o f dynamic memory // management i n C++
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10
void show ( const i n t 3 2 a r r a y [ ] , u i n t 3 2 l e n g t h ) ;
11 12 13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { // r e q u e s t memory f o r a s i n g l e element i n t 3 2 ∗ dyn var = new i n t 3 2 ; ∗ dyn var = 1 0 ; cout << ”∗ dyn var : ” << ∗dyn var << e nd l ; // f r e e memory again u s i n g the s i n g l e v e r s i o n d e l e t e delete dyn var ;
21 22 23
dyn var = new i n t 3 2 ( 5 ) ; cout << ”∗ dyn var : ” << ∗dyn var << e nd l ;
120
6. Pointer und References
// f r e e memory again u s i n g the s i n g l e v e r s i o n d e l e t e delete dyn var ;
24 25 26
dyn var = 0 ;
27 28
const u i n t 3 2 ARRAY LENGTH = 1 0 ;
29 30
// r e q u e s t memory f o r the a r r a y with new i n t 3 2 ∗ dyn array = new i n t 3 2 [ARRAY LENGTH] ;
31 32 33
for ( u i n t 3 2 count = 0 ; count < ARRAY LENGTH; count++) dyn array [ count ] = count ;
34 35 36
show ( dyn array ,ARRAY LENGTH) ;
37 38
i n t 3 2 ∗ i t e r a t i o n p t r = dyn array ; cout << ” indexed a c c e s s : ” << i t e r a t i o n p t r [5] << ” , p o i n t e r a r i t h m e t i c : ” << ∗( i t e r a t i o n p t r + 5) << e nd l ;
39 40 41 42
// f r e e memory again u s i n g the a r r a y v e r s i o n d e l e t e [ ] delete [ ] dyn array ; dyn array = 0 ;
43 44 45 46
return ( 0 ) ;
47 48
}
49 50 51 52 53 54 55 56
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void show ( const i n t 3 2 a r r a y [ ] , u i n t 3 2 l e n g t h ) { while ( l e n g t h−−) cout << ” [ ” << ∗ a r r a y++ << ” ] ” ; cout << e nd l ; }
Der Output, mit dem uns der erste Versuch der dynamischen Memory Verwaltung in C++ begl¨ uckt, liest sich dann so: ∗ dyn var : 1 0 ∗ dyn var : 5 [0][1][2][3][4][5][6][7][8][9] indexed a c c e s s : 5 , p o i n t e r a r i t h m e t i c : 5
In diesem Progr¨ ammchen sind gleich sehr viele Dinge auf einmal versteckt, die neu sind: Zeile 16: Hier sehen wir gleich eine M¨ oglichkeit, wie man in C++ zur Laufzeit Speicher anfordert: Man verwendet den new Operator, gefolgt vom Typ des Elements f¨ ur das man Speicher haben will. In diesem Fall wollen wir Speicher f¨ ur genau einen int32 haben. Der Operator new ist so definiert, dass er einen Block in genau der Gr¨oße liefert, die f¨ ur den geforderten Datentyp gebraucht wird. Ganz genau formuliert bedeutet einen Block liefern, dass new einen Block allokiert und den sogenannten Base-Pointer , also einen Pointer auf die Startadresse dieses Blocks liefert. Es kann nat¨ urlich auch passieren, dass der Speicher ausgegangen ist und new beim besten Willen die Anforderung nicht mehr erf¨ ullen kann. In diesem Fall w¨ urde new eine sogenannte Exception werfen, jedoch kommen wir zu diesem Mechanismus erst in Kapitel 11. Also begn¨ ugen wir uns einstweilen (und wirklich nur einstweilen!) damit, anzunehmen, dass uns
6.2 Pointer
121
bei unseren kleinen Testprogrammen der Speicher nicht ausgehen wird und lassen diesen Aspekt außer Acht. C Programmierern, die noch malloc gewohnt sind, das die Gr¨oße in Bytes gesagt bekommen muss, wird new zu Beginn etwas ungewohnt vorkommen. Aber damit lernt man schnell zu leben, weil es doch eine deutliche Verbesserung darstellt. Vorsicht Falle: Der new Operator ist in seiner Standard-Implementation so definiert, dass eine implizite Initialisierung des Speichers nicht garantiert ist! Das bedeutet, dass unbedingt immer eine explizite Zuweisung eines Wertes erfolgen muss, da man es sonst mit irgendeinem zuf¨alligen Wert zu tun hat. Dies erfolgt z.B. f¨ ur unseren in Zeile 16 angeforderten Speicher f¨ ur einen int32 in Zeile 17. Vorsicht Falle: Entwicklern, die im Umgang mit Pointern unge¨ ubt sind, passiert es leider nur zu gerne, dass sie den dereference Operator, also den *, bei einer Zuweisung eines Wertes vergessen. Schreibt man z.B. in Zeile 17 das Statement dyn_var = 10;, so f¨ uhrt das zu einem Compilerfehler. Diese Zuweisung w¨ urde n¨amlich bedeuten, dass man dem Pointer dyn_var die Adresse 10 zuweisen will und das ist nicht zul¨assig. Zeile 20: Speicher, den man dynamisch mittels new anfordert, bleibt so lange f¨ ur das Programm reserviert, bis er explizit wieder freigegeben wird (ich klammere hier eine m¨ ogliche Implementation von C++ mittels GarbageCollector bewusst aus). Das bedeutet, dass man sich unbedingt immer selbst darum k¨ ummern muss, dass alle angeforderten Speicherbl¨ocke dem System auch wieder durch Aufruf des delete Operators zur¨ uckgegeben werden. In unserem Fall bewirkt der Aufruf delete dyn_var das Freigeben des zuvor angeforderten int32. Vorsicht Falle: Der Operator delete ist so definiert, dass er nat¨ urlich nur Speicher wieder freigeben kann, der mit new angefordert wurde. Daher darf man delete nur mit einem Base-Pointer aufrufen, den man von new bekommen hat. Ruft man delete mit irgendeinem anderen als einem von new erhaltenen Base-Pointer auf, so endet dieser Aufruf mit einer Segmentation Violation. Vorsicht Falle: Niemals darf man den Base-Pointer eines Speicherblocks verlieren! Sollte dies passieren, so kann der Speicher nicht mehr freigegeben werden (womit sollte man denn delete aufrufen?) und damit w¨achst das Programm best¨ andig! Wie bereits erw¨ahnt: Speicher, der mit new angefordert wird, wird vom System nicht automatisch wieder freigegeben! Vergisst man ein delete, so gammelt der betreffende Block bis
122
6. Pointer und References
zum Beenden des Programms im Speicher herum. So ein Beenden des Programms kann dann nat¨ urlich auch passieren, weil kein Speicher mehr da ist und das Programm deshalb vom System abgew¨ urgt wird :-). Vorsicht Falle: In C++ ist delete so definiert, dass man es auch mit einem 0 Pointer ungestraft aufrufen darf. In diesem Fall passiert einfach gar nichts. Allerdings... manche (zum Gl¨ uck wenige) Compiler bzw. Laufzeitumgebungen sehen das anders. In solchen F¨allen endet delete auf einen 0 Pointer mit einer Segmentation Violation! Das bedeutet, dass ein Aufruf von delete ohne vorherige Abfrage auf 0 auf der jeweiligen Plattform aus Sicherheitsgr¨ unden getestet werden muss. Aus diesem Grund habe ich in diesem Buch dort, wo keine besondere Un¨ ubersichtlichkeit dadurch entsteht, die Abfragen auf 0 in den Beispielen verewigt. In der Praxis wird dies normalerweise nicht gemacht. Zeile 22: Hier sieht man, dass man nat¨ urlich eine Pointer Variable beliebig oft verwenden kann. Nachdem der Speicher, der sich hinter dyn_var versteckt hat, freigegeben wurde, kann man auch wieder neuen anfordern. Die Anforderung mittels new sieht allerdings hier etwas anders aus, als zuvor in Zeile 16! Hier wird eine Anforderung von Speicher mit einer expliziten Initialisierung kombiniert. Der Wert, auf den initialisiert werden soll, wird dem Datentyp in runden Klammern nachgestellt. Dies ist auch die von mir empfohlene Art der Speicheranforderung, da hierbei immer garantiert ein initialisierter Speicherblock geliefert wird. Der Output, der in Zeile 23 generiert wird, beweist, dass die Initialisierung erfolgreich war. Zeile 27: Hier sieht man, was man unbedingt tun soll, wenn eine Pointer Variable nicht mehr auf einen existenten Speicherblock zeigt: Man setzt den Pointer explizit auf 0! Damit ist garantiert, dass jeglicher Zugriff, schreibend oder lesend, zu einer Segmentation Violation f¨ uhrt. Ansonsten k¨ onnte es sein, und das ist gar nicht so selten, dass man unbeabsichtigt irgendwann “irgendeinen” Speicher ver¨andert. Solche Fehler zu finden ist, wie schon ¨ ofter besprochen, der gr¨oßte Spaß im Alltag von Entwicklern. Zeile 32: Wenn man schon dynamisch Speicher anfordern will, dann nat¨ urlich nicht immer nur Element f¨ ur Element, das w¨are absurd. Sehr oft braucht man Platz f¨ ur viele Elemente auf einmal. Dies funktioniert nat¨ urlich auch, indem man gleich ein ganzes Array anfordert. Der entsprechende Aufruf von new[] ist intuitiv: Man stellt einfach die Anzahl der gew¨ unschten Elemente des Arrays in eckigen Klammern dem Datentypen nach. Der Base-Pointer, der von new geliefert wird, zeigt dann auf den Anfang des dynamischen Arrays, also auf das erste Element desselben.
6.2 Pointer
123
Zeilen 34–35: Hier sieht man, dass man mit einem dynamischen Array, das durch einen Pointer repr¨ asentiert wird, gleich umgehen kann, wie es schon von einem statischen Array bekannt ist: Man kann zum Zugriff auf ein bestimmtes Element einfach den Index dieses Elements in eckigen Klammern dem Pointer nachstellen. Nicht nur, dass sich ein Pointer und eine (statische) Array-Variable in dieser Beziehung gleich verhalten, sie werden intern tats¨achlich beide durch einen Pointer realisiert! Man kann also eine (statische) ArrayVariable auch einfach einem Pointer zuweisen, sofern dieser den korrekten Typ hat. Durch diese Zuweisung zeigt dann der Pointer direkt auf das statische Array. Sehr oft sieht man vor allem bei Parameterlisten, dass die beiden Arten von Arrays vom Typ her beliebig austauschbar sind. Vorsicht Falle: Auch wenn sich statische und dynamische Arrays gleich verhalten und intern gleich repr¨asentiert werden, gibt es einen riesigen Unterschied, wie (und auch wo) der Speicher f¨ ur sie bereitgestellt wird. Es darf dementsprechend niemals versucht werden, den Speicher, der f¨ ur ein statisches Array allokiert ist, mittels delete freizugeben, denn dies f¨ uhrt geradewegs ins Verderben. Vorsicht Falle: Es gibt keine M¨oglichkeit, zur Laufzeit herauszufinden, f¨ ur wie viele Elemente nun Speicher angefordert wurde! Daher muss man selbst Sorge daf¨ ur tragen, dass man nicht u ¨ber die Grenzen eines Arrays hinausschreibt. Zeilen 39–41: Auch die Pointer-Arithmetik funktioniert in C++ ganz gleich wie in C: Wenn man zu einem Pointer eine Zahl addiert, dann bewirkt man, dass der Pointer um genau so viele Elemente (nicht Bytes!!!) im Array vorr¨ uckt, wie die Zahl besagt. Subtrahiert man eine Zahl, dann geht das Spielchen nat¨ urlich in die Gegenrichtung. In Zeile 41 machen wir uns dies zunutze, indem einfach der Wert 5 zum iteration_ptr addiert wird, woraufhin der Pointer auf das sechste Element (also das mit Index 5) zeigt. Dereferenziert man diese Adresse, so kommt man zum dahinterstehenden Wert. Vorsicht Falle: Oft wird von Neulingen der Fehler gemacht, eine Anweisung wie *(iteration_ptr + 5) nicht zu klammern, also stattdessen *iteration_ptr + 5 hinzuschreiben. Diese zwei Statements haben allerdings eine grundverschiedene Bedeutung: *(iteration_ptr + 5) bedeutet, dass zuerst der Pointer um 5 Elemente versetzt wird und danach der dort gespeicherte Wert dereferenziert wird. *iteration_ptr + 5 bedeutet im Gegensatz dazu, dass zuerst der Pointer dereferenziert wird und danach zu diesem Wert 5 addiert wird.
124
6. Pointer und References
Zeile 44: Egal, ob man ein einzelnes Element oder ein Array anfordert, man muss sich um die Freigabe des Speichers k¨ ummern, wenn er nicht mehr gebraucht wird. Und hier besteht ein kleiner Unterschied zwischen einem dynamisch angeforderten Einzelelement und einem Array: Bei einem Array muss der delete[] Operator aufgerufen werden, wie in Zeile 44 zu sehen. Vorsicht Falle: Nicht nur von Neulingen wird oft der Unterschied zwischen delete und delete[] ignoriert. Dies geht auch zumeist ohne gr¨obere Probleme ab, jedoch kann das nicht garantiert werden! Hinter den beiden Formen von delete verstecken sich n¨amlich tats¨achlich verschiedene Implementationen, die auch von den Entwicklern selbst im Zuge eines Overloadings geschrieben sein k¨onnten. Deshalb m¨ ochte ich hier eine sehr eindringliche Warnung aussprechen, auch wenn mancherorts in der Literatur (unrichtigerweise) Gegenteiliges behauptet wird: delete und delete[] sind nicht ¨ aquivalent und d¨ urfen auch nicht als ¨ aquivalent angesehen werden, obwohl sie sich oberfl¨ achlich von außen betrachtet gleich zu verhalten scheinen. Durch die Verwendung von delete wird das Freigeben eines Einzelelements veranlasst, durch die Verwendung von delete[] wird das Freigeben eines Arrays veranlasst. Wo auch immer ein einfaches new verwendet wird, muss der Speicher auch mit einem einfachen delete freigegeben werden. Bei Verwendung eines new ...[...] muss der Speicher auch mit einem delete[]freigegeben werden. Vorsicht Falle: Genauso, wie man nicht feststellen kann, wie viel Speicher allokiert wurde, l¨ asst sich auch nicht feststellen, ob nun ein Array oder nur ein Einzelelement allokiert wurde. Das bedeutet, dass man ohne selbst gemerkte Zusatzinformation zur Laufzeit nicht mehr herausfinden kann, welche der delete Varianten man nun nehmen muss. Vorsicht Falle: Nicht nur Neulinge sitzen manchmal einem folgenschweren Irrtum auf: Ob etwas nun f¨ ur den Compiler bzw. zur Laufzeit ein Array darstellt oder nicht, ist durch die Art der Anforderung bestimmt, nicht durch die Anzahl der Elemente! Auch wenn new int32[1] nur Speicher f¨ ur ein Element liefert, stellt das Ergebnis doch ein dynamisches Array dar und muss mit delete[] gel¨oscht werden! Auch hier ist die Verwendung des einfachen delete nicht zul¨assig! Zeile 45: Hier treffen wir wieder Altbekanntes: Auch f¨ ur Pointer, die ein dynamisches Array repr¨ asentieren, gilt, dass sie nach Gebrauch auf 0 gesetzt werden sollten.
6.2 Pointer
125
Zeilen 51–56: Die Funktion show, die hier definiert, ist demonstriert die Austauschbarkeit von Pointer- und Array Variablen. Hier wird als erster Parameter ein Array entgegengenommen (const, denn wir wollen ja daran nichts ¨ andern!). In Zeile 54 wird durch dieses Array einfach mittels Pointerarithmetik durchgegangen. Der Grund, warum ein Array-Parameter genommen wurde, liegt auf der Hand: Man will damit signalisieren, dass man den Parameter wie ein Array behandelt, auch wenn dieses eventuell nur die L¨ange 1 h¨atte. Der Grund, warum Pointerarithmetik trotzdem funktioniert, ist die bereits erw¨ ahnte Austauschbarkeit von statischen Arrays und Pointern, da auch ein statisches Array hinter den Kulissen als Pointer dargestellt wird. Der Ausdruck *array++ bedeutet: Dereferenziere zuerst den Pointer und liefere damit seinen Wert. Danach z¨ahle den Pointer (nicht den Wert!!!) um ein Element weiter. Dadurch wird das Array Schritt f¨ ur Schritt durchgegangen. Der Grund, warum eine Ver¨ anderung des Parameters array funktioniert, obwohl er durch const mit einem Schreibschutz versehen wurde, ist einfach: const bezieht sich auf den Inhalt, der sich hinter dem Pointer verbirgt, nicht auf die Pointervariable, die ja nur eine Kopie der Adresse enth¨ alt (call-by-value!). Wollte man einem Element des u ¨bergebenen Arrays einen neuen Wert zuweisen, dann w¨ urde sich nat¨ urlich der Compiler beschweren. 6.2.3 Strings In C++ werden, gleich wie in C, Strings einfach durch statische oder dynamische char Arrays repr¨ asentiert. Eine besondere Eigenschaft von Strings ist hierbei allerdings wichtig: Sie m¨ ussen immer mit einem ’\0’ Character abgeschlossen werden! Im Prinzip war das auch schon alles, was es dazu zu sagen gibt und ich halte mich derzeit noch bewusst bedeckt mit weiteren Ausf¨ uhrungen und einem Demoprogramm dazu, denn gerade Strings sind eine der ganz typischen Anwendungen, bei denen uns durch die Objektorientiertheit von C++ die Arbeit deutlich erleichtert wird und die Gefahr von Fehlern deutlich verringert wird. Eine saubere, zeitgem¨ aße OO-Repr¨asentation von Strings wird in Abschnitt 16.5 besprochen. 6.2.4 Funktionspointer Wie bereits erw¨ ahnt wurde, kann man mit Pointern nicht nur auf Daten zugreifen, sondern man kann auch Pointer auf Funktionen zeigen lassen. Auf Umweg u ¨ber solche Pointer kann man diese Funktionen dann aufrufen. Funktionspointer sind allerdings nicht ganz ungef¨ahrlich. Genau deswegen m¨ochte ich hier nur erw¨ ahnen, dass es Funktionspointer gibt, dass jedoch
126
6. Pointer und References
durch die Objektorientierung von C++ im Gegensatz zu C nur noch sehr wenige Einsatzgebiete derselben existieren. Es gibt viel sauberere Konstrukte, die dieselbe Aufgabe erf¨ ullen. Salopp gesagt sollte man eigentlich im normalen Entwicklungsalltag die Finger von Funktionspointern lassen. Deshalb wurde die Diskussion u ¨ber sie auch sehr weit nach hinten verlegt, n¨amlich in Abschnitt 15.3 :-). 6.2.5 Besondere Aspekte von Pointern Pointer sind prinzipiell mit einem hohen Fehlerrisiko verbunden, aber sie sind aus vielerlei Gr¨ unden in C++ nicht wegzudenken. Was auch immer bisher zu Pointern gesagt wurde und in der Folge noch gesagt wird, geh¨ort zum t¨aglichen Brot von C++ Entwicklern. Allerdings m¨ochte ich an dieser Stelle noch unbedingt darauf hinweisen, dass im OO-Teil des Buchs einige M¨oglichkeiten besprochen werden, wie man potentielle Fehlerquellen eliminieren kann, indem man Pointer sauber in Classes kapselt, anstatt sie “wild” im laufenden Programmcode zu verwenden. Trotz aller Warnungen m¨ochte ich trotzdem alle Leser dazu motivieren, sich mit Pointern ausgiebig zu spielen, denn dies ist die beste M¨ oglichkeit, ein gutes Gef¨ uhl daf¨ ur zu bekommen. Es sollten jedoch die derzeitigen “Spielereien” der Leser nicht in wichtigen (also produktm¨aßigen) Programmcode einfließen. Ok, genug gewarnt, kommen wir wieder zur¨ uck zum Thema der besonderen Aspekte von Pointern. Call-by-reference auf Pointer. Auch Pointervariablen k¨onnen nat¨ urlich Teilnehmer an einem call-by-reference sein, der den Inhalt des Pointers selbst, also die Adresse auf die er zeigt, nachhaltig ver¨andert. Wie das typisch f¨ ur C++ mittels References gel¨ ost wird, sieht man an folgendem Programm (ptr_call_by_ref_demo.cpp): 1
// p t r c a l l b y r e f d e m o . cpp − c a l l by r e f e r e n c e with p o i n t e r s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9
void a s s i g n C l o n e ( i n t 3 2 ∗& d s t , const i n t 3 2 s r c [ ] , u i n t 3 2 l e n g t h ) ;
10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { const u i n t 3 2 ARRAY LENGTH = 1 0 ; i n t 3 2 ∗ s r c a r r a y = new i n t 3 2 [ARRAY LENGTH] ; int32 ∗ dst array = 0;
17 18 19
for ( u i n t 3 2 count = 0 ; count < ARRAY LENGTH; count++) s r c a r r a y [ count ] = count ;
20 21
a s s i g n C l o n e ( d s t a r r a y , s r c a r r a y ,ARRAY LENGTH) ;
22 23 24
int32 ∗ s r c i t e r a t i o n p t r = src array ; int32 ∗ d s t i t e r a t i o n p t r = dst array ;
6.2 Pointer
127
for ( u i n t 3 2 count = 0 ; count < ARRAY LENGTH; count++) cout << ” [ ” << ∗ s r c i t e r a t i o n p t r ++ << ” , ” << ∗ d s t i t e r a t i o n p t r ++ << ” ] ” ; cout << e nd l ;
25 26 27 28 29
delete [ ] s r c a r r a y ; delete [ ] d s t a r r a y ; // I know the f o l l o w i n g l i n e i s not r e a l l y n e c e s s a r y // h e r e , but i t ’ s j u s t good programming s t y l e and // who knows whether anybody changes t h i s code at // a l a t e r s t a g e so t h a t i t would become n e c e s s a r y . src array = dst array = 0; return ( 0 ) ;
30 31 32 33 34 35 36 37 38
}
39 40 41 42 43 44 45 46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void a s s i g n C l o n e ( i n t 3 2 ∗& d s t , const i n t 3 2 s r c [ ] , u i n t 3 2 l e n g t h ) { d s t = new i n t 3 2 [ l e n g t h ] ; // At t e nt i on ! need a s e p a r a t e i t e r a t o r p o i n t e r , because // d s t i s a r e f e r e n c e ! T h e r e f o r e i t e r a t i o n with d s t // would d e s t r o y the o r i g i n a l ! int32 ∗ d s t i t e r a t o r = dst ; while ( l e n g t h−−) ∗ d s t i t e r a t o r ++ = ∗ s r c ++; }
Wirklich berauschend ist nat¨ urlich der Output nicht, den dieses Programm auf den Bildschirm zaubert. Zumindest beweist er, dass tats¨achlich ein echter Clone zugewiesen wurde :-). [0,0][1,1][2,2][3,3][4,4][5,5][6,6][7,7][8,8][9,9]
Das Kernst¨ uck dieses Progr¨ ammchens ist die Funktion assignClone, die in den Zeilen 41–50 definiert ist. In der Parameterliste sieht man, wie man eine Reference auf einen Pointer deklariert: int32 *& bezeichnet eine Reference auf einen Pointer auf einen int32. Es wird also von rechts nach links vom Groben ins Feine vorgegangen. Eine Deklaration int32 &* w¨ urde der Compiler bem¨ angeln, denn das w¨ are ein Pointer auf eine Reference auf einen int32. Weil wir aber wissen, dass eine Reference niemals von Entwicklern selbst ver¨ andert werden darf, ist dies nat¨ urlich nicht gestattet. In der Funktion fordern wir nat¨ urlich zuerst einmal den notwendigen Speicherplatz an, den wir f¨ ur den Clone brauchen (Zeile 43) und danach kopieren wir den Inhalt Element f¨ ur Element (Zeilen 48–49). Die Kopie wurde absichtlich per Hand implementiert, denn hier ist ein Stolperstein und ein lustiges Konstrukt versteckt. Bleiben wir zuerst beim positiven Aspekt, also beim lustigen Konstrukt: Die Zeile *dst_iterator++ = *src++; ist ganz typisch f¨ ur C++ (und auch C). Was hier passiert ist Folgendes: 1. src wird dereferenziert. 2. dst_iterator wird dereferenziert. 3. Das Ergebnis des Dereferenzierens von src wird auf die Speicherstelle zugewiesen, die sich beim Dereferenzieren von dst_iterator ergeben hat.
128
6. Pointer und References
4. src wird um ein Element weiterger¨ uckt. 5. dst wird um ein Element weiterger¨ uckt. Und nun zum negativen Aspekt... Vorsicht Falle: Weil wir es bei dst mit einer Reference zu tun haben, d¨ urfen wir diese nat¨ urlich niemals direkt bei unseren Kopierschritten in Zeile 49 verwenden. Damit w¨ urden wir n¨amlich auch die Adresse im Original nachhaltig ver¨ andern (was ja in einem anderen Zusammenhang, n¨amlich bei der Zuweisung des angeforderten Speichers, erw¨ unscht ist). Also m¨ ussen wir eine lokale Kopie davon machen, was in Zeile 47 passiert. Vorsicht Falle: Noch etwas wird bei solcherlei Konstrukten leider nur viel zu gern vergessen: Der Speicher, der in der Funktion assignClone angefordert wird, muss nat¨ urlich auch irgendwo wieder freigegeben werden, wenn er nicht mehr gebraucht wird. Dass das nur irgendwo beim Aufrufer stattfinden kann, ist auch klar. Dass das aber wirklich unbedingt auch dort stattfinden muss, wird gerne einfach unter den Tisch gekehrt... nat¨ urlich nur in Programmen anderer Leute :-). Bei uns wird explizit der Speicher f¨ ur den Clone in Zeile 31 wieder freigegeben. Die Zeile 36, in der beide eben freigegebenen Pointer auf 0 gesetzt werden, erscheint hier als Overkill. Jedoch tut die Zeile niemandem weh und außerdem kann es ja sein, dass durch eine sp¨atere Programm¨anderung nach Zeile 33 noch einiges an Code dazukommt, der eventuell diese beiden Variablen irrt¨ umlich versuchen w¨ urde zu verwenden, im Glauben, dass dahinter noch Speicher allokiert ist. Eine solche “unn¨otige” Zeile kann also eine wertvolle Zukunftsinvestition sein! Mehrfachpointer. Wie bereits bei Arrays gezeigt wurde, dass diese mehrdimensional sein k¨ onnen, so verh¨ alt es sich nat¨ urlich auch bei den Pointern. Um dies zu demonstrieren, schnappen wir uns einfach das letzte Demoprogramm und a ¨ndern es dergestalt ab, dass wir es mit einer Matrix zu tun haben (multi_ptr_demo.cpp): 1
// multi ptr demo . cpp − a s h o r t demo f o r multi−p o i n t e r s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10
void a s s i g n C l o n e ( i n t 3 2 ∗∗& d s t , const i n t 3 2 ∗ const ∗ s r c , u i n t 3 2 num rows , u i n t 3 2 num cols ) ;
11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { const u i n t 3 2 NUMROWS = 5 ;
6.2 Pointer
129
const u i n t 3 2 NUM COLS = 7 ;
16 17
i n t 3 2 ∗∗ s r c m a t r i x = new i n t 3 2 ∗ [NUMROWS] ; for ( u i n t 3 2 row count = 0 ; row count < NUMROWS; row count++) { s r c m a t r i x [ row count ] = new i n t 3 2 [NUM COLS ] ; for ( u i n t 3 2 c o l c o u n t = 0 ; c o l c o u n t < NUM COLS; c o l c o u n t++) s r c m a t r i x [ row count ] [ c o l c o u n t ] = ( ( row count + 1 ) ∗ 1 0 0 ) + c o l c o u n t + 1 ; }
18 19 20 21 22 23 24 25 26
i n t 3 2 ∗∗ d s t m a t r i x = 0 ;
27 28
a s s i g n C l o n e ( d s t m a t r i x , s r c m a t r i x ,NUMROWS,NUM COLS) ;
29 30
for ( u i n t 3 2 row count = 0 ; row count < NUMROWS; row count++) { for ( u i n t 3 2 c o l c o u n t = 0 ; c o l c o u n t < NUM COLS; c o l c o u n t++) cout << ” [ ” << s r c m a t r i x [ row count ] [ c o l c o u n t ] << ” , ” << d s t m a t r i x [ row count ] [ c o l c o u n t ] << ” ] ” ; cout << e nd l ; }
31 32 33 34 35 36 37 38
for ( u i n t 3 2 row count = 0 ; row count < NUMROWS; row count++) { delete [ ] s r c m a t r i x [ row count ] ; delete [ ] d s t m a t r i x [ row count ] ; } delete [ ] s r c m a t r i x ; delete [ ] d s t m a t r i x ; src matrix = dst matrix = 0; return ( 0 ) ;
39 40 41 42 43 44 45 46 47 48
}
49 50 51 52 53 54 55 56 57 58 59 60 61
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void a s s i g n C l o n e ( i n t 3 2 ∗∗& d s t , const i n t 3 2 ∗ const ∗ s r c , u i n t 3 2 num rows , u i n t 3 2 num cols ) { d s t = new i n t 3 2 ∗ [ num rows ] ; for ( u i n t 3 2 row count = 0 ; row count < num rows ; row count++) { d s t [ row count ] = new i n t 3 2 [ num cols ] ; for ( u i n t 3 2 c o l c o u n t = 0 ; c o l c o u n t < num cols ; c o l c o u n t++) d s t [ row count ] [ c o l c o u n t ] = s r c [ row count ] [ c o l c o u n t ] ; } }
Nur als Beweis, dass auch wirklich die beiden Matrizen dasselbe enthalten, hier wieder der Output des Programms: [101,101][102,102][103,103][104,104][105,105][106,106][107,107] [201,201][202,202][203,203][204,204][205,205][206,206][207,207] [301,301][302,302][303,303][304,304][305,305][306,306][307,307] [401,401][402,402][403,403][404,404][405,405][406,406][407,407] [501,501][502,502][503,503][504,504][505,505][506,506][507,507]
Wenn man sich nur kurz das Prinzip u ¨berlegt, wie eigentlich ein mehrdimensionales Array angelegt werden muss, dann wird auch schnell klar, was in diesem Programm passiert: Es gibt ein Array, das die Reihen der Matrix repr¨asentiert. Jede Reihe f¨ ur sich h¨ alt wiederum alle Werte dieser Reihe, also die einzelnen Spalten. Nat¨ urlich kann man das auch umgekehrt sehen, n¨amlich dass es ein Array gibt, das die Spalten repr¨asentiert und jede Spalte
130
6. Pointer und References
f¨ ur sich h¨ alt dann die Reihen. Bleiben wir aber bei unserer ersten Definition, denn diese wurde im Programm implementiert. In Zeile 18 wird einmal das Array angelegt, das die Reihen repr¨asentiert. Wenn man sich u ¨berlegt, dass eine Reihe quasi ein Array von int32 Werten ist, also ein int32* dann ergibt sich f¨ ur die Reihen, dass diese vom Typ ein int32**, also ein Pointer auf (viele) Pointer auf int32 ist. In der ¨ außeren Schleife, deren Kopf in Zeile 19 zu finden ist, wird f¨ ur jede einzelne Reihe einmal ein Array angelegt, das die Werte halten kann, also die Spalten. Dies passiert in Zeile 21. In der inneren Schleife von Zeile 22–24 werden nur noch den einzelnen Zellen Werte zugewiesen. Lassen wir kurz noch die Funktion außer Acht, die den Clone erzeugt und wenden wir uns den Zeilen zu, die den von der Matrix belegten Speicher dann auch wieder freigeben. Dies passiert immer von innen nach außen. Zuerst werden die einzelnen Reihen in einer Schleife freigegeben. Danach erst wird das Array freigegeben, das die einzelnen Reihen gehalten hat. Dass dies nur in dieser Reihenfolge geht, l¨ asst sich leicht nachvollziehen, denn wenn man das ¨außere Array freigibt, dann hat man die Base-Pointer f¨ ur die inneren Arrays damit verloren. Vorsicht Falle: Einer der ganz typischen Fehler von Neulingen ist es, anzunehmen, dass ein delete[] auf das ¨außerste Array automatisch auch die einzelnen inneren Arrays l¨ oscht, also quasi alles aufr¨aumt. Dies ist keineswegs der Fall! Vergisst man, die einzelnen inneren Arrays zu l¨oschen, dann erzeugt man ein tolles Programm, das nach gen¨ ugend langer Laufzeit zu einem guten Test wird, wie sich ein Rechner verh¨alt, dem langsam aber sicher der Speicher ausgeht :-). Wenn wir uns nun noch kurz der assignClone Funktion in Zeile 51 zuwenden, die im Prinzip genau nach dem Prinzip funktioniert, das bereits zuvor zum Anlegen der Matrix beschrieben wurde, dann f¨allt uns noch eine Besonderheit auf: Der zweite Parameter, also das src Array ist nun wirklich sehr komisch deklariert. Was um alles in der Welt soll der Ausdruck const int32 *const *src ¨ bedeuten? Uberlegen wir einmal ganz kurz, was es z.B. mit einem Pointer auf einen int32 so auf sich hat. Im Prinzip sind ja daran zwei verschiedene Typen beteiligt, n¨ amlich einerseits ein Pointer und andererseits der int32, auf den er zeigt. Um die volle Kontrolle zu haben, will man jeden Teil f¨ ur sich konstant machen k¨ onnen oder auch nicht. Und genau dies ist in C++ auch m¨ oglich, denn eine Variable const int32 *var; stellt einen (nicht konstanten) Pointer auf einen konstanten int32 dar. Dagegen stellt eine Variable int32 *const var;
6.2 Pointer
131
einen konstanten Pointer auf einen (nicht konstante) int32 dar. Wenn man dies weiterspinnt, ist naturgem¨ aß eine Variable const int32 *const var; ein konstanter Pointer auf einen konstanten int32. Genau so etwas brauchen wir hier, nur dass var selbst ein Array ist. Wir wollen ja einen Doppelpointer an die Funktion u ¨bergeben, bei dem weder die einzelnen Werte, die er h¨alt, also die Reihen, noch die Werte, die von diesen gehalten werden, also die tats¨achlichen Zellen, ver¨ andert werden k¨onnen. Also haben wir es hier mit einem Pointer auf ein konstantes Array auf konstante Werte zu tun. Da ich weiß, dass viele Entwickler ein kleines Problem damit haben, const korrekt in komplizierteren Konstrukten einzusetzen, wie z.B. References auf Pointer-Variablen, etc. Deshalb hier ein kleiner Tipp: const ist linksbindend, soll heißen, es bezieht sich prinzipiell immer auf den links von ihm stehenden Teil des Typs. Dass man const auch quasi rechtsbindend verwenden kann, ist nur eine Konvention, die der verbesserten Lesbarkeit dienen soll. Sollte also nach dieser Konvention const ganz links stehen, so wird es so ausgewertet, als ob es rechts neben dem ersten Ausdruck vorkommen w¨ urde. Verwirrend? Kein Problem, mit einem kleinen Beispiel ist alles klar: Der Ausdruck const int32 *var ist ¨aquivalent zu int32 const *var und sollte, wenn man ganz korrekt sein will, auch so geschrieben werden. Nur hat sich in Entwicklerkreisen u ¨ber lange Zeit die erstere Schreibweise eingeb¨ urgert, deshalb m¨ ochte ich, wo es nicht notwendig ist, damit auch nicht brechen. Vorsicht Falle: Von Neulingen, aber auch leider von erfahreneren Entwicklern, werden immer wieder v¨ ollig unzul¨assige Annahmen u ¨ber die Kompatibilit¨ at von Mehrfacharrays und Mehrfachpointern getroffen. Diese beiden Typen sind nicht notwendigerweise gegeneinander austauschbar, denn es gibt keine genaue Definition, wie mehrdimensionale Arrays nun intern speicherm¨aßig organisiert sind. Vorsicht Falle: Eines sieht man am Beispiel mit den Mehrfachpointern sehr deutlich: Es ist wirklich nicht allzu schwierig, Fehler bei deren Behandlung einzubauen! Aus diesem Grund sind auch solche Konstrukte im laufenden Code von C++ Programmen verp¨ ont und sollten unbedingt durch entsprechende Classes ersetzt werden, die solches Verhalten kapseln. Zu diesem Thema werde ich im OO-Teil noch mehr als nur ein paar Worte verlieren :-). Pointer und Typecasts. Pointer halten per Definition einfach Adressen. Dass auch noch ein Typ mit angegeben wird, hat damit zu tun, dass man sie richtig behandeln will. Man will ja im Fall eines indizierten Zugriffs das
132
6. Pointer und References
richtige Element erwischen und nicht einfach irgendein Byte. Selbiges gilt f¨ ur die Pointerarithmetik, bei der auch elementweise und nicht byteweise gerechnet wird. Adressen sind immer gleich, denn dem Computer ist es vollkommen egal, ob eine Adresse nun auf einen int32, double oder sonst etwas zeigt. Die Interpretation bleibt dem Programm u ¨berlassen. Was sagt uns das? Nun, ganz einfach: Mittels Typecasts k¨ onnen wir sehr einfach eine Neuinterpretation des dahinterstehenden Werts erreichen. Allerdings hat der Compiler definitiv etwas dagegen, dass man einfach wild Pointer eines Typs Pointern eines anderen Typs zuweist, weil er ja die Gefahr der Fehlinterpretation kennt. Das Statement uint8 *my_ptr = your_ptr; wird vom Compiler nur dann u ¨bersetzt, wenn auch your_ptr ein uint8 * ist. Ist er das nicht, sondern z.B. ein int32 *, dann quittiert das der Compiler mit einer netten Meldung, dass man doch aufpassen soll, was man tut. Es wird von einem C++ Compiler z.B. nicht einmal ein impliziter Cast zwischen einem char*, unsigned char* und einem signed char* durchgef¨ uhrt, da ja nicht definiert ist, ob nun ein char signed oder unsigned ist. Wie allerdings schon bekannt ist, gibt es Mittel und Wege, den Compiler zu u ¨berreden, gewisse Dinge zu akzeptieren, n¨amlich entsprechende Casts. Im Prinzip kann man damit fast beliebigste Neuinterpretationen erzwingen, nur muss man wirklich ganz genau wissen, was man macht! Sehen wir uns einfach einmal ein Standardbeispiel an, das zeigt, wie man zu den einzelnen Bytes eines “gr¨ oßeren” Ganzzahlenwertes, also z.B. eines int32 kommt (bytes_of_int32_demo.cpp): 1
// b y t e s o f i n t 3 2 d e m o . cpp − e x t r a c t the s i n g l e b y t e s o f an i n t 3 2
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13 14 15 16 17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { int32 num for extraction = 4; uint8 ∗ i n t 3 2 i t e r a t o r = reinterpret cast(&n u m f o r e x t r a c t i o n ) ; for ( u i n t 3 2 count = 0 ; count < 4 ; count++) cout << ” [ ” << s t a t i c c a s t(∗ i n t 3 2 i t e r a t o r++) << ” ] ” ; cout << e n d l ; return ( 0 ) ; }
Der Output, der von diesem Programm erzeugt wird, ist abh¨angig von der jeweiligen Zielplattform. Auf einem Intel-Rechner ergibt sich Folgendes: [4][0][0][0]
6.2 Pointer
133
Auf einer Sun w¨ urde die Reihenfolge der Bytes genau umgekehrt sein. Dies h¨angt mit den internen Architekturen dieser Rechner zusammen, denn einem Intel liegt eine little Endian und einer Sun eine big Endian Byte Order zugrunde. Ich m¨ochte mich hier nicht n¨ aher u ¨ber little- und big Endian auslassen, wohl aber m¨ochte ich erw¨ ahnen, dass dieses Wissen sehr wichtig sein kann! Leser, die mit diesen Begriffen nichts anfangen k¨onnen, m¨ochte ich auf Kapitel 13 von Softwareentwicklung in C verweisen, wo ein sehr ¨ahnliches Beispiel mit genauerer Erkl¨ arung gebracht wird. Nach diesem kleinen Exkurs in die Prozessorarchitekturen zur¨ uck zum eigentlichen Thema, n¨ amlich dem Erzwingen einer anderen Interpretation eines Datums als der, die der Typ eigentlich vorgeben w¨ urde. Wir haben bereits den reinterpret_cast kennen gelernt und dabei erfahren, dass durch diesen eine Neuinterpretation “ohne R¨ ucksicht auf Verluste” und ohne jeglichen Umwandlungsversuch von Seiten des Compilers stattfindet. Das machen wir uns in den Zeilen 13–14 zunutze, denn dort erzwingen wir eine Neuinterpretation eines int32* als uint8*. Dies bedeutet intern, dass die Adresse, die die Variable int32_iterator h¨ alt auf num_for_extraction zeigt. Allerdings wird der Inhalt dieser Speicherstelle nicht als int32 interpretiert, sondern als uint8. Das bedeutet, dass nur ein einziges Byte genommen und als uint8 interpretiert wird anstatt aller 4 Bytes, die gemeinsam die Variable num_for_extraction ausmachen. Wenn man nun also in einer Schleife, wie in den Zeilen 15–18 gezeigt, die 4 Bytes des int32 hintereinander ausliest, dann hat man genau das erreicht, was man wollte. Nat¨ urlich ist in Zeile 17 wieder ein static_cast des uint8 auf uint32 notwendig, denn ansonsten w¨ urde keine Zahl, sondern irgendein kryptischer Character am Bildschirm dargestellt werden. Es gibt gen¨ ugend F¨ alle, bei denen zur Compiletime noch gar nicht bekannt ist, welcher Pointertyp dann zur Laufzeit als Parameter an eine Funktion u ¨bergeben wird. Im OO-Teil werden uns solche mehrfach begegnen. Jetzt wissen wir aber, dass der Compiler beim impliziten Umwandeln recht restriktiv ist und uns damit das Leben schwer machen kann, welchen Parametertyp wir denn jetzt eigentlich f¨ ur unsere Funktion deklarieren. Daf¨ ur gibt es in C++ einen ganz besonderen untypisierten Pointer, n¨amlich den void *. Dieser Pointertyp ist so definiert, dass jeder beliebige Pointer vom Compiler ohne Fehlermeldung oder Warning implizit in ihn umgewandelt werden kann. In unserer Funktion k¨ onnen wir dann zur Laufzeit (sofern wir wissen, womit wir zu tun haben :-)) mittels eines reinterpret_cast wieder die gew¨ unschte Interpretation herstellen. Dass ohne expliziten Cast bei einem void * keine Index-Operationen und auch keine Pointerarithmetik m¨ oglich sind, versteht sich von selbst. Diese Operationen sind ja so definiert, dass sie immer auf Elementbasis und nicht auf Bytebasis rechnen. Wenn man aber nicht weiß, welches Element sich
134
6. Pointer und References
dahinter versteckt, dann kann man auch nicht rechnen. Entsprechend wird sich der Compiler bei einem Versuch der Anwendung von Pointerarithmetik oder Indizierung auf einen void * beschweren. Ebenso verh¨alt es sich mit delete: Es ist nicht auf einen void * aufrufbar. Die Gr¨ unde daf¨ ur werden in Abschnitt 10.2.3 noch kurz erl¨ autert. Vorsicht Falle: Das Spielchen mit dem Erzwingen der Neuinterpretation mittels reinterpret_cast kann man nicht nur bei Pointern spielen, sondern sogar ungl¨ uckseligerweise zwischen Pointern und Ganzzahlentypen. Ist im Prinzip auch irgendwie logisch, denn eine Adresse ist ja auch nur irgendeine Ganzzahl. Nur leider funktioniert das mit dem Cast zwischen z.B. int und Pointern nicht so toll, denn wer garantiert uns, dass ein int auf einer bestimmten Plattform gleich lang ist, wie ein Pointer? Leider gibt es immer noch ziemlich viel (zumeist historische) Software, die diese Annahme einfach als gegeben hinnimmt. Allerdings ist das grundfalsch! Deshalb ist es unbedingt notwendig, folgende Regel einzuhalten: Es darf niemals zwischen einem Pointer und einem, wie auch immer gearteten Ganzzahldatentyp, z.B. int gecastet werden! Dies ergibt n¨amlich unter Garantie Software mit einer wunderbar einprogrammierten Zeitbombe.
7. Der Preprocessor
In C++ kommt dem Preprocessor eine viel kleinere Rolle zu, als dies noch in C der Fall war. Viele allt¨ agliche Dinge, die noch in C mit Preprocessor Macros erledigt werden mussten, sind in C++ in der Sprache selbst enthalten, z.B. Konstanten, inline Funktionen, etc. Es hat auch sehr gute Gr¨ unde, warum diese Konstrukte in die Sprache selbst Einzug gehalten haben. Deshalb muss ich gleich vorausschicken, dass man auf Preprocessor Macros nur so sp¨arlich wie m¨oglich zur¨ uckgreifen sollte, um nicht die Robustheit und Wartbarkeit des Codes aufs Spiel zu setzen. Prinzipiell ist der Preprocessor etwas ganz Einfaches: • Er ist ein eigenes Programm, das vor dem Compiler aufgerufen wird. • Der Preprocessor geht den Source durch und nimmt in ihm textuelle Ersetzungen nach gewissen Regeln vor. Der Output des Preprocessors ist ¨ selbst C++ Source-Code, der dann dem Compiler zur Ubersetzung gegeben wird. • Der Preprocessor weiß nichts u ¨ber Typen, Scope, Lifetime und andere sprachimmanente Regeln von C++. Aus diesem Grund entstehen auch Probleme mit der Robustheit und Wartbarkeit von Code, wenn man sich unn¨otig auf Spielchen mit Preprocessor Macros einl¨asst. Anweisungen, die f¨ ur den Preprocessor gedacht sind, beginnen immer mit einem # in der ersten Spalte einer Zeile. Ein Beispiel f¨ ur eine solche Anweisung ist die #include Anweisung, die wir bereits verwendet haben. Auff¨allig bei dieser Anweisung ist, dass sie nicht durch einen Strichpunkt abgeschlossen wird. Auch dies ist eine Eigenheit des Preprocessors: Preprocessor Anweisungen werden nicht mit einem Strichpunkt abgeschlossen, sondern enden mit dem Ende der Zeile! Sollte eine Anweisung zu lang werden, sodass man einen Zeilenumbruch machen will oder muss, so funktioniert das, indem man den Zeilenumbruch “escaped”. Dies geschieht durch einen Backslash als letztes Zeichen der Zeile. In der Folge werden die Features des Preprocessors genauer beleuchtet, die man im Programmieralltag braucht. Auf besondere Anweisungen, die z.B. erlauben, inline Assemblercode einzubinden oder die besondere compilerspezifische Dinge veranlassen (Stichwort #pragma), wird bewusst verzichtet. Erstens sind diese Anweisungen abh¨angig vom verwendeten Compiler
136
7. Der Preprocessor
und der Zielplattform, zweitens sollte man sie wirklich nur in sehr speziellen F¨allen verwenden, die im “normalen” Alltag eines Softwareentwicklers außerordentlich selten vorkommen. Leser, die bisher noch gar nichts mit dem Preprocessor in C zu tun hatten, m¨ochte ich an dieser Stelle kurz auf Kapitel 16 von Softwareentwicklung in C hinweisen.
7.1 Include Files Die Preprocessor Anweisung zum Einf¨ ugen von Include Files haben wir bereits gleich zu Beginn des Buchs kennen gelernt und durchgehend in unseren Programmen verwendet: #include file_identifier Statt dem Begriff des Files habe ich hier absichtlich den Begriff file_identifier verwendet, denn wir haben es hier mit zwei verschiedenen Formen zu tun, wie man Files einf¨ ugt. Einerseits gibt es die sogenannten System Headers, also solche, die im Suchpfad des Compilers gefunden werden. Zu diesen geh¨ oren in jedem Fall alle Standard Headers f¨ ur eine Plattform. Je nach Projekt kann man nat¨ urlich auch eines der Projekt IncludeSubdirectories in den Suchpfad aufnehmen. Damit werden die dort enthaltenen Headers auch als System Headers gefunden. Neben diesen Headers gibt es auch noch die Project Headers, also solche, die nicht im Suchpfad des Compilers stehen. Folgende Konventionen gibt es f¨ ur die entsprechenden #include Anweisungen: System Headers: Diese werden inkludiert, indem man den Filenamen des Headers in spitze Klammern einfasst. Außerdem ist in C++ die Konvention, dass man bei System Headers die Extension .h nicht angibt, wie bereits in Abschnitt 2.3 genauer erkl¨art wurde. Die Anweisung #include bewirkt also ein Einf¨ ugen des Files iostream aus dem Suchpfad des Compilers an der Stelle im File, an der das #include Statement steht. Es ist allerdings nicht gesagt, dass das File einfach nur iostream heißt, obwohl dies zumeist der Fall ist. Der Compiler k¨onnte auch intern mit Headers mit beliebigen Extensions arbeiten, die er selbst verwaltet. Project Headers: Diese werden inkludiert, indem man den Filenamen des Headers in doppelte Anf¨ uhrungszeichen einfasst. Bei diesen Headers darf die Extension .h nicht weggelassen werden. Auch dazu kennen wir bereits einen Kandidaten: #include "user_types.h" bewirkt also ein Einf¨ ugen des Files user_types.h an der Stelle im File, an der das #include Statement steht. Project Headers werden immer
¨ 7.2 Bedingte Ubersetzung
137
im aktuellen Subdirectory gesucht und unterliegen nicht der Suche im Systempfad. Wie bereits erw¨ ahnt, nimmt der Preprocessor einzig und allein textuelle Ersetzungen vor. Das bedeutet also, dass tats¨achlich an der Stelle, an der eine #include Anweisung steht, das inkludierte File textuell eingesetzt wird. Dadurch ergibt sich zweierlei: 1. Unn¨ otige #include Anweisungen sind zu vermeiden, da sie die Compiletime sehr verl¨ angern k¨ onnen. 2. In Headers d¨ urfen ausschließlich Deklarationen vorkommen, da bei Definitionen die Gefahr der duplicate Definition besteht. Solche d¨ urfen nur in .cpp Files vorkommen, die dann zum Programm dazugelinkt werden.
¨ 7.2 Bedingte Ubersetzung ¨ Der Preprocessor unterst¨ utzt auch drei verschiedene Anweisungen zum Uberpr¨ ufen von Bedingungen: #if constant true-code #else false-code #endif Mit dieser Anweisung wird die Zahlenkonstante constant auf einen Wert ungleich 0 u uft. Falls dies zutrifft, wird der Code im Programm belassen, ¨berpr¨ der oben durch true-code bezeichnet ist und false-code wird entfernt. Falls dies nicht zutrifft, dann wird true-code entfernt und false-code wird im Programm belassen. Das #else und false-code sind optional, #endif ist allerdings unbedingt vonn¨ oten, um das Ende der #if Anweisung anzuzeigen. Die Anweisung #ifdef identifier true-code #else false-code #endif u uft, ob das Preprocessor Macro identifier bereits definiert wurde (sie¨berpr¨ he unten). Wenn ja, bleibt true-code im Programm, ansonsten false-code. Auch hier wieder ist der #else Zweig optional. Analog dazu verh¨alt sich die Anweisung #ifndef identifier true-code #else false-code #endif
138
7. Der Preprocessor
Hier wird allerdings u uft, ob identifier noch nicht definiert wurde. ¨berpr¨ Die letzte Anweisung wird auch verwendet, um Headers vor unbeabsichtigter Doppelinklusion und daraus resultierenden Fehlermeldungen des Compilers zu sch¨ utzen. Header-Files sollten immer durch die folgenden Preprocessor Anweisungen eingefasst sein: #ifndef filename_h___ #define filename_h___ ... complete code of header ... #endif Hier steht filename_h___ f¨ ur den spezifischen Namen des Headers. Im Fall unseres Files user_types.h w¨ urde also das entspechende Konstrukt folgendermaßen aussehen: #ifndef user_types_h___ #define user_types_h___ ... complete code of header ... #endif
7.3 Macros Die Macros werden hier absichtlich an letzter Stelle bei der Behandlung des Preprocessors erw¨ ahnt, da sie in C++ nur noch eine untergeordnete Bedeutung besitzen. Prinzipiell gibt es drei Arten von Macros. Eine einfache Definition eines Identifiers haben wir bereits zuvor kennen gelernt, n¨amlich die Anweisung #define user_types_h___ Diese bewirkt, dass ein Identifier namens user_types_h___ definiert wird. Dieser ist dann mit #ifdef bzw. #ifndef u ufbar. Neben dieser einfa¨berpr¨ chen Definition eines Identifiers gibt es auch die Definition von nicht parametrisierten Macros. Z.B. definiert die Anweisung #define MAX_LENGTH 255 ¨ eine Constant namens MAX_LENGTH, die den Wert 255 besitzt. Uberall im Code, wo der Compiler auf eine Verwendung von MAX_LENGTH st¨oßt, wird diese textuell durch den Wert 255 ersetzt. Allerdings ist diese Art der Konstantendefinition in C++ verp¨ ont, denn es gibt das typensichere Konstrukt der const Variablen, die stattdessen verwendet werden sollen. Weiters gibt es auch noch die M¨oglichkeit, parametrisierte Macros zu definieren. Z.B. wird durch #define SWAP(a,b) { int swap = a; a = b; b = swap; } ein Macro, das das Vertauschen zweier Variableninhalte veranlasst. Jedoch wird auch von dieser Verwendung von Macros in C++ abgeraten, da es das typensichere Konstrukt der inline Funktionen als Alternative gibt. Vorsicht Falle: Das zuvor gezeigte SWAP Macro ist sogar recht b¨osartig, denn wenn man die Klammerung der Ausdr¨ ucke zu einem Block vergisst,
7.3 Macros
139
¨ dann kann man nette Uberraschungen erleben! Lesern, denen nicht gel¨aufig ist, warum dies der Fall ist, m¨ ochte ich w¨armstens die Lekt¨ ure von Abschnitt 16.1.2 aus Softwareentwicklung in C ans Herz legen!
Teil II
Objektorientierte Konzepte von C++
8. Objektorientierung Allgemein
Ich habe ganz bewusst bisher im Buch kein umfangreicheres Beispiel besprochen, da die wichtigsten Features, die C++ eigentlich erst ausmachen, in der Objektorientierung der Sprache liegen. Um nicht manche Leser dazu anzuregen, imperatives C++ zu programmieren anstatt sauberes, echt objektorientiertes C++, wollte ich mit einem gr¨ oßeren Beispiel abwarten, bis zumindest die wichtigsten OO-Konstrukte von C++ bekannt sind. Eines m¨ ochte ich gleich vorausschicken: Zu wissen, wie man Klassen und Objekte definiert und wie man technisch gesehen damit arbeitet, macht noch lange keine Objektorientierung! Objektorientierung ist ein Denkmodell, das von C++ unterst¨ utzt wird. Durch diese Unterst¨ utzung kann man ein sauberes OO-Design auch elegant in C++ implementieren. Das bedeutet aber keineswegs, dass Code, der irgendwelche OO-Features verwendet, auch wirklich sauberer objektorientierter Code ist! Genau aus diesem Grund finde ich es wichtig, erst einmal die Grundgedanken der Objektorientierung zu diskutieren und Beispiele daf¨ ur zu bringen, bevor wir u ¨berhaupt dazu kommen, wie diese Grundgedanken von der Sprache unterst¨ utzt werden. Keine Aktivit¨ at im gesamten Entwicklungszyklus beeinflusst ein Produkt so stark, wie das Erstellen der Architektur und des Designs. Im Prinzip ist dies eine Binsenweisheit, wenn man sich u ¨berlegt, wie seit Jahrtausenden Bauwerke entstehen: Zuerst wird ein Plan gemacht, der die Basis f¨ ur das Bauwerk darstellt. Der Plan ergibt sich daraus, dass die Anforderungen f¨ ur ein Bauwerk in puncto Platzbedarf, Verwendungszweck, ¨ Stabilit¨ at, u.v.m. in eine Architektur umgesetzt werden. Die Asthetik des Bauwerks ist ein direktes Produkt aus seinem Design mit den gegebenen Randbedingungen. Erst wenn der Plan gezeichnet und baustatische Berechnungen erfolgreich verlaufen sind, wird mit dem tats¨achlichen Bau begonnen. Die gesamte Struktur des Bauwerks steht zu diesem Zeitpunkt vollst¨andig fest! Software steht vom Komplexit¨ atsgrad einem Bauwerk in keinster Weise nach. Wieso also wird hier genau die wichtigste Phase des Designs und des Entwurfs der Architektur so oft nur ¨außerst rudiment¨ar durchgef¨ uhrt? Eine m¨ogliche Antwort und f¨ ur mich bisher die einzig logische Erkl¨arung ist folgende:
144
8. Objektorientierung Allgemein
Dadurch, dass man beim Schreiben eines Programms mit Text zu tun hat, den man in den Computer eintippt und den man nach Bedarf ¨andern ¨ kann, wird suggeriert, dass Anderungen problemlos sind und dass man jederzeit fehlende Teile erg¨ anzen und fehlerhafte Teile ausbessern kann. Beim Bau eines Geb¨ audes ist jedem klar, dass es nachtr¨aglich nicht m¨oglich ist, einfach mitten aus dem Geb¨ aude ein Stockwerk herauszunehmen und durch ein anderes zu ersetzen. ¨ Jedoch wird bei Betrachtungen zur leichten Anderbarkeit von Software ein Faktum u ¨bersehen: Bei einem Computerprogramm verh¨alt es sich nicht grundlegend anders als bei einem Geb¨ aude! Will man an einem Konzept eines Programms etwas ¨ andern, dann sind nicht einfach nur ein paar Zeilen Text auszubessern! Es kann passieren, dass sich eine Konzept¨anderung durch ein gesamtes Programm zieht! Hat man also beim Entwickeln die Planung vernachl¨assigt, so passiert es nur allzu oft, dass am Fundament etwas ge¨andert werden muss, wodurch man alles destabilisiert, was auf dem “alten” Fun¨ dament aufbaut. F¨ uhrt man die Anderung nicht durch, ist das Fundament ¨ auch instabil, denn die notwendige Anderung wurde ja nicht einfach zum Spaß erfunden. Somit kommt man in ein Dilemma, aus dem sich so leicht kein Ausweg finden l¨ asst, denn ein komplettes Redesign und ein Neuaufbau der bisher existierenden Software w¨ are oftmals die einzig sinnvolle L¨osung, ist aber mitunter viel zu zeitaufw¨ andig. Jede Applikation, die man entwickelt, besteht aus vielen verschiedenen Abstraktionsschichten. Die oberste Schicht ist diejenige, die die Anwender zu sehen bekommen. Betrachten wir diese oberste Schicht einmal als die tats¨achliche Applikation (z.B. eine Textverarbeitung) und betrachten wir die darunter liegenden Schichten als deren Fundament (z.B. Datenspeicherung, Netzwerk-Kommunikation, etc.). Bleiben wir weiters beim nahe liegenden Vergleich zwischen Software und Geb¨auden. Dann w¨ urde sich sauber entworfene Software mit klarer und in sich abgeschlossener Architektur ¨ahnlich wie in Abbildung 8.1 visualisieren lassen, wobei die einzelnen K¨astchen f¨ ur die entworfenen Module stehen.
Applikation
Abbildung 8.1: Saubere und robuste Softwarearchitektur
8. Objektorientierung Allgemein
145
Jedoch liegt nur allzu vielen Softwarepaketen diese saubere Architektur nicht zugrunde (auch wenn nur die Allerwenigsten dies zugeben)! Die Architektur einer L¨ osung, bei der in der Designphase gespart wurde und die daher entsprechend viele kleine und gr¨ oßere Konzept¨anderungen w¨ahrend der Entwicklung erfahren hat, mutiert zu einem instabilen Monster, bestehend aus wild angeordneten Pseudo-Modulen, wie es in Abbildung 8.2 skizziert ist.
Applikation
Abbildung 8.2: Mangelhafte (oft reale!) Softwarearchitektur
Ich w¨ urde wirklich nur allzu gern sagen, dass Abbildung 8.2 nur Polemik meinerseits ist und dass ich hier die Realit¨at stark u ¨berzeichnet habe. Leider aber ist das nicht der Fall, manchmal ist es sogar noch etwas schlimmer. Sogar sehr namhafte Softwarefirmen produzieren teure L¨osungen, deren Architektur genau wie in Abbildung 8.2 dargestellt aussieht. Und das trotz aller ISO- und anderer Qualit¨ atsrichtlinien, die sie sich auf die Fahnen heften. Nun stellt sich nat¨ urlich die Frage, wie man es besser machen kann und wie man verhindern kann, dass es zu solchen Ausw¨ uchsen kommt. Ich kann hier nur raten, wirklich ausreichend viel Zeit auf die Design- und Architekturphase zu verwenden und diese Phase erst abzuschließen, wenn das Design vollkommen schl¨ ussig ist und keine Fragen mehr offen l¨asst. Weiters ist zum Erstellen eines guten Designs auch sehr viel Erfahrung n¨otig. Um diese zu sammeln, braucht man viel Zeit und der Weg dorthin ist oft ein steiniger. H¨aufig wird man Designs verwerfen m¨ ussen, weil sie nicht tragf¨ahig sind, aber das liegt in der Natur eines jeden Lernprozesses. Ich kann in der Folge die Grundprinzipien des OO-Designs so abhandeln, wie ich glaube, dass sie am leichtesten verst¨ andlich und nachvollziehbar sind. Damit k¨onnen Leser, die sich bisher noch nicht allzu ausf¨ uhrlich mit OO-Design besch¨aftigt haben, die ersten Einstiegsh¨ urden leichter meistern. Ich m¨ochte jedoch alle Leser bitten, eines im Hinterkopf zu behalten: Es gibt keine Kochrezepte, die man einfach befolgen kann und die zwingend zu einem guten Ergebnis f¨ uhren! Der Entwurf einer sauberen Architektur ist der schwierigste und
146
8. Objektorientierung Allgemein
kreativ anspruchsvollste Prozess im gesamten Zyklus der Softwareentwicklung und geh¨ ort entsprechend lang und sorgf¨altig ge¨ ubt!
8.1 Module und Abl¨ aufe Der wichtigste Teil, der ganz am Anfang des Designprozesses steht und sich in verschiedenen Auspr¨ agungen durch den Prozess zieht, ist das Erfassen des Problems mit all seinen Abl¨ aufen und Randbedingungen. Schafft man es, ein Problem so lange in immer allgemeinere Gruppen von Teilproblemen zu zerlegen, bis man auf einer vollkommen abstrakten Ebene angekommen ist, hat man eine gute Chance, dass das Konzept tragf¨ahig ist und dass man das gesamte System sehr sauber in Module zerlegen kann. Sehr oft gibt es vor allem bei Neulingen Probleme, einen Prozess wirklich vollst¨ andig zu beschreiben und gleichzeitig auf einer Abstraktionsebene zu bleiben. Ich m¨ochte an dieser Stelle keine lange theoretische Abhandlung u ¨ber diese Prinzipien schreiben und ich m¨ ochte vor allem nicht formal oder technisch werden und mich schon gar nicht auf eine Programmiersprache festlegen. Stattdessen m¨ochte ich einfach an einem v¨ ollig allt¨ aglichen Beispiel die Phase der Analyse der Abl¨ aufe und der Identifikation von Modulen demonstrieren. Bevor ich dazu komme noch ein Tipp an alle Leser: Alles, was in der Folge beschrieben wird, klingt unglaublich banal, wenn man einfach jetzt nur weiterliest. Dass es banal klingt, wenn man das Ergebnis liest, bedeutet aber keineswegs, dass der Weg zu diesem Ergebnis ebenso banal ist! Mein Ratschlag w¨are, das folgende Beispiel zuerst selbst einmal mittels Papier und Bleistift durchzuspielen und dann das Ergebnis mit dem hier abgedruckten zu vergleichen. Mit ein wenig Selbstkritik werden sicherlich einige Leser erkennen, dass sie bei der Analyse etwas vergessen haben, Abstraktionsebenen nicht eingehalten haben (soll heißen, im Detailliertheitsgrad der Beschreibung unregelm¨aßig arbeiten) oder vielleicht sogar falsche Annahmen u ¨ber die Natur gewisser Gegebenheiten getroffen haben. 8.1.1 Der Weg zum Arbeitsplatz – ein kleines Beispiel In diesem Abschnitt soll ein ganz einfacher Prozess analysiert werden und alle Beteiligten (Personen und Dinge) sowie die Zusammenh¨ange zwischen ihnen herausgearbeitet werden. Der einfache Prozess, um den es geht, ist ein typischer Ablauf, den die meisten Leser t¨aglich hinter sich bringen: Der Beginn eines Arbeitstages, vom Klingeln des Weckers weg bis zur Ankunft am Arbeitsplatz. Ein erster schneller Durchgang liefert uns einmal eine ganz grobe Beschreibung der Beteiligten und Abl¨ aufe: 1. Der Wecker geht ab und weckt (hoffentlich :-)) die arbeitsw¨ utige Person viel zu fr¨ uh am Morgen auf.
8.1 Module und Abl¨ aufe
147
2. Die aufgeweckte Person versucht verzweifelt, den l¨astigen Wecker abzustellen und schafft dies auch nach einigen kleineren Fehlschl¨agen. 3. Mit langsamen, vorsichtigen Bewegungen steigt die mittlerweile gar nicht mehr so arbeitsw¨ utige Person aus dem Bett und tappt in Richtung Badezimmer. 4. Im Badezimmer findet die allmorgendliche Prozedur statt, um sich frisch zu machen. 5. Nach der Morgentoilette schon in etwas zurechnungsf¨ahigerem Zustand macht sich unsere Person daran, in die K¨ uche zu gehen und ein Fr¨ uhst¨ uck zu richten. 6. Das Fr¨ uhst¨ uck wird hastig verschlungen, weil es wieder einmal ein wenig zu sp¨ at ist, um es in Ruhe zu sich zu nehmen. 7. Die Person macht sich auf den Weg zum Arbeitsplatz. Hier gibt es mehrere verschiedene M¨ oglichkeiten, prinzipiell passiert dies entweder zu Fuß oder mit einem wie auch immer gearteten Fahrzeug. 8. Beim Geb¨ aude angekommen, geht unsere arbeitswillige Person hinein und steuert zielstrebig den Arbeitsplatz an. Vorsicht Falle: Einige Leser werden es schon bemerkt haben, aber ich bin mir sicher, dass es nicht allen aufgefallen ist: Die arbeitswillige Person verbreitet am Arbeitsplatz knisternde Erotik, denn sie kommt nackt dort an :-). Wie man sieht, ist es also wirklich auch bei denn banalsten Abl¨aufen m¨oglich, b¨ ose Schlampigkeitsfehler zu begehen. Aus diesem groben Ablauf kann man nun relativ einfach auch die direkten offensichtlichsten Beteiligten mit ihren Eigenschaften extrahieren: Person: In unserem Beispiel ist die Person der agierende Teil und sie hat (bezogen auf unser Beispiel) folgende Eigenschaften: • Sie kann schlafen oder munter sein. • Sie kann sitzen, liegen, stehen, gehen und sich auch mit einem Fahrzeug fortbewegen. • Sie kann Fr¨ uhst¨ uck machen und Essen zu sich nehmen. • Sie kann den Wecker abstellen und sich frisch machen. Wecker: Bezogen auf das Beispiel ist ein Wecker ein Ding, das l¨auten kann und dessen L¨ auten man abstellen kann. Badezimmer: Das Badezimmer ist ein Raum, in dem man sich frisch machen kann. K¨ uche: Die K¨ uche ist ein Raum, in dem man sich etwas zu essen richten kann. Eventuell kann man in ihr auch essen, oder man muss dazu in einen anderen Raum gehen. Fr¨ uhst¨ uck: Das Fr¨ uhst¨ uck ist eine Mahlzeit mit folgenden Eigenschaften: • Man nimmt sie nach dem Aufstehen ein. Ich habe jetzt bewusst nicht in der Fr¨ uh geschrieben, denn das Empfinden, was das nun in puncto Uhrzeit bedeutet, ist sehr subjektiv :-).
148
8. Objektorientierung Allgemein
• Sie besteht aus mehreren verschiedenen festen und fl¨ ussigen Nahrungsmitteln in verschiedenen Zust¨ anden (z.B. warm/kalt). Die Zusammenstellung ist beliebig. Fahrzeug: Die Beteiligung des Fahrzeugs wird hier erw¨ahnt f¨ ur den Fall, dass die Person nicht zu Fuß zur Arbeit geht. Es gibt verschiedenste Auspr¨ agungen von Fahrzeugen, z.B. Fahrrad, Auto, Bus, etc., die alle ganz verschiedene Eigenschaften haben. Eine Eigenschaft ist ihnen allen gemeinsam: sie sind Fortbewegungshilfen, die man entsprechend ihrer anderen Eigenschaften verwenden kann. Geb¨aude: Ein Geb¨ aude ist ein beliebiges Bauwerk mit vielen m¨oglichen Eigenschaften. Eine der Grundeigenschaften ist, dass es einen oder mehrere R¨aume beinhaltet und (hoffentlich...) zumindest einen Ein- bzw. Ausgang hat. Arbeitsplatz: Obwohl der Arbeitsplatz zuvor implizit in ein Geb¨aude verlegt wurde, muss das nat¨ urlich nicht sein. Ein Arbeitsplatz ist ein beliebiger Platz mit der einzigen Eigenschaft, dass man dort zumindest zeitweise seine Arbeit verrichtet. Ich habe zuvor bewusst ganz einfach hingeschrieben, dass man in das Geb¨aude hineingeht und den Arbeitsplatz ansteuert, um zu zeigen, wie leicht man einen Designfehler begehen kann! W¨ urde man diese Analyse hier nicht durchf¨ uhren, kann es nur allzu leicht passieren, dass eine Eigenschaft eines Arbeitsplatzes ist, dass er in einem Geb¨aude liegt. Das ist ganz einfach falsch! Man stelle sich vor, dass eine solche Fehlannahme Eingang in eine Softwarearchitektur findet und pl¨otzlich muss man mittels dieser Architektur einen F¨orster modellieren, der seine Arbeit im Wald verrichtet... womit sofort die n¨achste Fehlannahme auff¨allt: Es wurde auch impliziert, dass der Weg zur Arbeit zu einem Geb¨aude f¨ uhrt. Das ist aber auch nicht zwingend! Vorsicht Falle: Man sieht, selbst bei einer sehr groben Modellierung eines v¨ollig allt¨ aglichen Ablaufs kann es schon zu Fehlern kommen. Je detaillierter man Abl¨ aufe beschreibt, umso mehr Chancen hat man, solche Fehler auch wirklich sofort zu lokalisieren und zu korrigieren. Dies macht man so lange, bis man im Groben einmal ein schl¨ ussiges Modell von Beteiligten und Interaktionen zwischen diesen hat. Die Abstraktionsebene darf man dabei nicht ver¨andern, man steht immer noch als Beobachter außerhalb des Problems und analysiert das Gesamtmodell. Im Normalfall modelliert man auch nicht nur einen einzelnen Ablauf, wie es hier der Fall ist, sondern man modelliert eine ganze Reihe von sogenannten Use-Cases. Jeder Use-Case ist ein ganz bestimmter Prozess, der mit bzw. in einem System durchf¨ uhrbar sein muss. Aus der Analyse aller dieser UseCases ergibt sich dann das grobe Gesamtmodell. Sobald das Modell schl¨ ussig ist, geht man einen Schritt in die Tiefe. Je nach Problemstellung gibt es verschiedene Interpretationen, was nun ein
8.1 Module und Abl¨ aufe
149
Schritt ist und was in die Tiefe bedeutet. In den allermeisten F¨allen funktioniert es wunderbar, wenn man sich die einzelnen Aktoren vornimmt und Abl¨aufe der Reihe nach, detailliert aus ihrer Sicht heraus, beschreibt. Durch die Verfeinerung in der Beschreibung der Abl¨aufe findet man wieder eine F¨ ulle neuer Aktoren, die in dieser Abstraktionsebene Wichtigkeit besitzen. Um nun nicht gleich ein gesamtes Buch dar¨ uber zu schreiben, wie man in der Fr¨ uh aufsteht und zur Arbeit kommt, m¨ochte ich den Ablauf des sich frisch Machens aus Punkt 4 oben als Teilbeispiel herausgreifen. Die Vorbedingung in unserem groben Ablauf war, dass man sich im Badezimmer frisch macht. Das bedeutet, dass man dieses einmal betreten muss. Genau damit haben wir eine wichtige Eigenschaft des Badezimmers gefunden, die in Wirklichkeit eine Eigenschaft aller R¨aume ist: sie sind abgegrenzt und es gibt eine M¨ oglichkeit, sie zu betreten. Die Abgrenzung eines Raumes ist im Normalfall eine Mauer, die M¨oglichkeit, ihn zu betreten, ist eine ¨ Offnung. Eine Mauer kann wiederum aus verschiedenen Materialien beste¨ hen, verschieden dick, groß und gef¨ arbt sein, eine Offnung in der Mauer kann durch eine T¨ ur verschlossen sein, diese kann wiederum verschiedenste Auspr¨agungen haben, etc. Man kann leicht erkennen, dass man nur durch den kleinen Aspekt, dass man einen Raum betreten muss, einen Use-Case gefunden hat, der bereits wieder das Herausarbeiten vieler verschiedener Beteiligter mit ihren Eigenschaften zur Folge hat, die im u ¨bergeordneten groben Fall noch keine direkte Rolle gespielt haben. Diesen Prozess, der sich u ¨ber viele Ebenen zieht, nennt man schrittweise Verfeinerung des Modells und genau diese schrittweise Verfeinerung wendet man in der Analyse und Designphase von Software so lange an, bis man auf einem Level angelangt ist, der keine weitere Verfeinerung mehr zul¨ asst oder ben¨ otigt. Vorsicht Falle: Auch diesen Exkurs habe ich gerade absichtlich gemacht, um zu zeigen, wie schnell man ein Ziel aus den Augen verlieren kann. Die Absicht war eben noch, den Prozess des sich frisch Machens zu beschreiben. Bereits beim ersten kleinen Teilaspekt dieses Prozesses, n¨amlich dem Betreten des Badezimmers, habe ich die gerade dort noch g¨ ultige Abstraktionsebene verlassen und begonnen, u aume, Mauern und T¨ uren zu philosophieren. ¨ber R¨ Man sieht also, dass die schrittweise Verfeinerung sicherlich die Vorgangsweise der Wahl sein wird, um das Gesamtproblem in den Griff zu bekommen. Jedoch sieht man auch, dass Disziplin eine der wichtigsten Eigenschaften ist, die man braucht, um sinnvoll und zielgerichtet zu arbeiten. Man darf erst den n¨ achsten Schritt in der Verfeinerung tun, wenn man eine Ebene abgehandelt hat, sonst verliert man sich in Details und verliert damit den ¨ Uberblick u osende Problem. ¨ber das zu l¨ Also – zur¨ uck zum Ausgangspunkt! Wie macht man sich morgens frisch: 1. Man betritt das Badezimmer. 2. Man duscht sich.
150
8. Objektorientierung Allgemein
3. Man trocknet sich ab. 4. Man putzt sich die Z¨ ahne. 5. Man verl¨ asst das Badezimmer. Was erkennt man aus dieser groben Beschreibung: • Eine Eigenschaft einer Person ist es offensichtlich, dass sie Z¨ahne hat, denn was sollte man denn sonst putzen. • Eine Eigenschaft des Badezimmers ist, dass darin ein Handtuch vorhanden ist, sonst kann man sich nicht abtrocknen. Damit ist nicht ausgesagt, dass das Handtuch eine Eigenschaft des Badezimmers ist, denn man k¨onnte es ja auch beim Betreten mit hinein genommen haben. Im Prinzip ist nicht einmal gesagt, dass zum Abtrocknen ein Handtuch notwendig ist, denn man k¨ onnte ja auch so lange stehen bleiben, bis man trocken ist. Der Ablauf des Abtrocknens verdient also im Modellierungsprozess noch eine n¨ahere Betrachtung, was die Anforderungen betrifft. • Eine Eigenschaft unseres Badezimmers ist, dass es eine Dusche gibt... oje... wieder ein Fehldesign. Es gibt auch Badezimmer ohne Dusche! Dort funktioniert der gerade angef¨ uhrte Prozess zwar nicht mehr vollst¨andig, weil man sich nicht duschen, sondern nur ein Vollbad nehmen kann oder sich u ¨berhaupt nur waschen kann. Trotzdem darf man aus der Prozessbeschreibung nicht zwingend schließen, dass auch alles Geforderte eine Eigenschaft von beteiligten Parteien ist! Um wirklich einen sinnvollen Verfeinerungsschritt vollziehen zu k¨onnen, werden die einzelnen Prozesse, die vom Betreten bis zum Verlassen des Badezimmers stattfinden, einmal alle einzeln verfeinert, denn mehr Information als gerade eben l¨ asst sich aus dem groben Ablauf nicht extrahieren. Beispielhaft f¨ ur die anderen Teilprozesse nehmen wir uns einfach das Z¨ahne Putzen zur Brust: 1. Man nimmt die Zahnb¨ urste in die Hand. Wichtig hierbei ist, dass man keine direkte Aussage treffen kann, wo die Zahnb¨ urste hergenommen wird. Sie k¨ onnte einfach daliegen oder auch in einem eigenen K¨astchen sein. Egal, wie es auch immer ist, der Aufenthaltsort der Zahnb¨ urste ist keine Eigenschaft der Zahnb¨ urste selbst. Entsprechend ist auch hier ein eigener Prozess zu modellieren, der in Bezug zum Aufenthaltsort steht. 2. Man nimmt das Beh¨ altnis mit der Zahnpasta. Hier ist wieder zu bedenken, dass es keine Eigenschaft ist, dass Zahnpasta in einer Tube vorhanden ist. Auch andere Beh¨ altnisse sind m¨oglich! Wie schon bei der Zahnb¨ urste, so wird auch hier wieder keine Annahme getroffen, wo die Zahnpasta zu finden ist. 3. Man schmiert Zahnpasta auf die Zahnb¨ urste. Wie dies genau stattfindet, ist abh¨ angig vom Beh¨ altnis. Wenn wir als Beispiel eine Tube nehmen, dann muss diese zuerst ge¨ offnet werden, danach wird die Zahnpasta herausgedr¨ uckt (und landet hoffentlich auf der Zahnb¨ urste und nicht am Boden :-)) und danach wird die Tube wieder verschlossen.
8.1 Module und Abl¨ aufe
151
4. Man befeuchtet die Zahnb¨ urste mit der inzwischen darauf befindlichen Zahnpasta. Dazu braucht man Wasser, also muss es irgendwo eine Wasserleitung geben. Wie man diese nun wieder zu bedienen hat, damit man auch wirklich Wasser bekommt, ist eine der Eigenschaften der spezifischen “Implementation” der Wasserleitung mit entsprechendem Wasserhahn. 5. Man ¨ offnet den Mund. 6. Man steckt die Zahnb¨ urste in den Mund und putzt. Das Putzen allein ist wieder ein eigens zu beschreibender Prozess, denn dabei sind genau betrachtet viele Schritte vonn¨ oten: Zahnb¨ urste auf und ab bewegen, Stelle der Bewegung ¨ andern, Mund weiter ¨offnen und die Innenseite putzen, etc. 7. Nach dem Putzen sp¨ ult man den Mund mit Wasser aus. Auch dieser Prozess wird wieder extra beschrieben, denn auch hier spielt die Bedienung des Wasserhahns eine Rolle, sowie auch, ob man einen Zahnputzbecher benutzt oder nicht. 8. Man reinigt die Zahnb¨ urste mit Wasser. 9. Man stellt die Zahnb¨ urste zur¨ uck. Vorsicht Falle: Welche Leser haben es bemerkt? Ich habe zu Demonstrationszwecken wieder einen Fehler in den obigen Ablauf eingebaut... Nach der obigen Beschreibung n¨ amlich geht unsere Person mit der Zahnpasta in der Hand in die Arbeit, denn nach Schritt 3 fehlt schlicht und ergreifend das Zur¨ uckstellen der Zahnpasta an ihren Platz. Wichtig bei den einzelnen Prozessbeschreibungen ist n¨amlich, dass Vorund Nachbedingungen eingehalten werden. Die Vor- und Nachbedingungen bei genau dieser Beschreibung des Z¨ahne Putzens sind einfach, dass die Person vorher weder Zahnb¨ urste noch Zahnpasta in der Hand hat, dass es Zahnb¨ urste und Zahnpasta u ¨berhaupt gibt, dass die Zahnpasta nicht leer ist und dass nach dem Putzen wieder alles aus der Hand gelegt wird. Weiters ist nat¨ urlich eine Vorbedingung, dass die Person zumindest eine Hand frei haben muss. Man sieht, dass es auch bei den banalsten Prozessen eine F¨ ulle von Vorund Nachbedingungen gibt. Wenn man diese alle explizit in die Prozessbeschreibung aufnimmt, dann wird alles sehr schnell sehr un¨ ubersichtlich. Aus diesem Grund ist eine der u ¨blichen Vorgehensweisen, dass man die Bedingungen explizit vor der Prozessbeschreibung anf¨ uhrt und dann den Prozess auf die Einhaltung dieser Bedingungen u uft. ¨berpr¨ Durch die genauere Beschreibung des Z¨ahne Putzens haben wir wieder neue Beteiligte mit ihren Eigenschaften identifiziert: Zahnb¨ urste: Ob diese nun elektrisch ist oder nicht, spielt in unserer Betrachtung hier keine besondere Rolle. In jedem Fall hat sie wie auch immer geartete Borsten, die zum Putzen dienen und ist irgendwie vern¨ unftig
152
8. Objektorientierung Allgemein
genug mit der Hand angreifbar, dass man sie auch wirklich zum Putzen verwenden kann. Zahnpasta: Die Zahnpasta ist eine wie auch immer geartete Substanz, die entweder z¨ ah bis dickfl¨ ussig oder pulverf¨ormig sein muss, ansonsten eignet sie sich nicht zum Putzen der Z¨ahne. Nat¨ urlich muss sie auch sonstige, dem Putzvorgang zutr¨ agliche Eigenschaften haben, die sie als Reinigungssubstanz auszeichnen. Schuhcreme ist vielleicht nicht die geeignetste Substanz zum Z¨ ahne putzen, obwohl sie die Eigenschaft erf¨ ullt, z¨ah zu sein :-). Beh¨alter: In unserem Beispiel haben wir einen Beh¨alter identifiziert, in dem man Zahnpasta aufbewahren kann. Dieser muss entsprechend dicht sein ¨ und weiters einen Verschluss besitzen, um ihn zu ¨offnen. Die Offnung muss sinnvoll genug an die Eigenschaften des Beh¨alters und der Zahnpasta angepasst sein, dass man auch Zahnpasta entnehmen kann. Ich m¨ochte mir hier n¨ ahere Eigenschaften eines allgemeinen Beh¨alters ersparen, da wir in K¨ urze noch darauf kommen werden, was es damit auf sich hat. Tube: Eine Tube ist ein besonderer Beh¨alter. Je nach Gr¨oße der Tube kann man darin sinnvoll Zahnpasta aufbewahren und diese auch je nach Verschluss dosiert entnehmen. K¨astchen: Eine M¨ oglichkeit, wo man Zahnb¨ urste und Zahnpasta aufbewah¨ ren kann, ist ein K¨ astchen. Bei genauerer Uberlegung ist auch ein K¨astchen ein Beh¨ alter, nur eben mit anderen Eigenschaften als der, den wir f¨ ur die Zahnpasta brauchen. Wasser: Wasser ist eine Fl¨ ussigkeit mit besonderen Eigenschaften. Ich m¨ochte hier nicht n¨ aher darauf eingehen, welche Eigenschaften dies jetzt sind. In jedem Fall ist es eine Fl¨ ussigkeit. Wasserleitung: Dies ist eine allgemeine Leitung, durch die (¨ ublicherweise) Fl¨ ussigkeit geleitet wird, mit den entsprechenden Eigenschaften, dass sie dicht ist, dass sie auch verzweigt sein kann, etc. und vor allem, dass sie auf das Leiten von Wasser ausgelegt ist. Wasserhahn: Dieser befindet sich an zumindest einer Wasserleitung und zwar immer am Ende einer Verzweigung jeder der beteiligten Leitungen. Er besitzt die Eigenschaften, dass er auf- und zugedreht sein kann, im Falle mehrerer Wasserleitungen (z.B. kalt/warm) besitzt er auch die MischerEigenschaft, etc. Mund: Bisher haben wir identifiziert, dass es Z¨ahne gibt, die geputzt werden m¨ ussen. Jetzt haben wir auch explizit identifiziert, dass diese im Mund zu finden sind und dass eine Person einen Mund besitzt. Abgesehen von anderen Eigenschaften kann man den Mund ¨offnen und schließen und zwar stufenlos. Ich glaube, es ist beim Durchspielen des Beispiels klar geworden, wie man Schritt f¨ ur Schritt Abl¨ aufe analysiert, Beteiligte identifiziert, Eigenschaften findet und weiter schrittweise verfeinert. An dieser Stelle m¨ochte ich den
8.1 Module und Abl¨ aufe
153
Begriff des Moduls einf¨ uhren, der in der Informatik (und nicht nur dort) folgendermaßen definiert ist: • Ein Modul ist eine vollkommen in sich abgeschlossene Einheit mit genau definierter Funktionalit¨ at. • F¨ ur diese Funktionalit¨ at gilt ausnahmslos die Regel, dass ein Modul immer nur eine einzige, seinem Abstraktionslevel entsprechende Aufgabengruppe erledigen darf. Anm.: Es ist hier absichtlich von Aufgabengruppen die Rede und nicht von Einzelaufgaben, zu diesen kommen wir noch in einem anderen Zusammenhang. • Ein Modul besitzt eine genau definierte Schnittstelle zur Außenwelt, u ¨ber die (und ausschließlich nur u ¨ber die!!!) andere Module mit ihm kommunizieren k¨ onnen. • Die Schnittstelle des Moduls darf immer nur exakt seinem Aufgabengebiet innerhalb seines Abstraktionslevels entsprechen und niemals Implementationsdetails nach außen preisgeben, die in diesem Level belanglos sind. Anders betrachtet beschreibt die Modulschnittstelle, was man mit ihm machen kann, niemals aber, wie dies implementiert ist. • Durch den Begriff der vollkommen in sich abgeschlossenen Funktionalit¨ at ist zwar schon alles gesagt, aber weil es so wichtig ist, m¨ochte ich es hier noch einmal anf¨ uhren: Ein Modul darf keine direkte Abh¨angigkeit zu besonderen Implementationen von Codeteilen außerhalb des Moduls haben. Es ist ausschließlich die Verwendung von anderen Modulen u ¨ber ihre definierten Schnittstellen erlaubt. • Ein Modul kann aus beliebig vielen Submodulen bestehen, die in ihrem Abstraktionslevel um eine Stufe tiefer liegen. Das impliziert, dass alle Module immer hierarchisch nach ihren Abstraktionslevels strukturiert sind. Nehmen wir zur Demonstration das Teilbeispiel des Z¨ahneputzens und betrachten die dort identifizierten Beteiligten und Abl¨aufe. Wenn wir den hier definierten Modulbegriff auf dieses Konglomerat anwenden (soll heißen, wir suchen Funktionalit¨ atsgruppen), so ergibt sich Folgendes: Zahnpflege-Modul: In diesem ist alle Funktionalit¨at zu finden, die mit der Zahnpflege zu tun hat. Dies beinhaltet, wie man eine Zahnb¨ urste gebraucht, dass es Zahnpasta gibt und wie man mit ihr umgeht, etc. Beh¨alter-Modul: Dieses Modul fasst alle Funktionalit¨at zusammen, die mit Beh¨ altern zu tun hat, n¨ amlich, welche es gibt (Tube, Becher, Kasten), wie man mit ihnen umgeht, etc. Wasserversorgungs-Modul: Dieses Modul fasst alles zusammen, was man zur Wasserversorgung an Funktionalit¨at braucht, also Wasserleitung, Wasserhahn, wie man diesen bedient, etc. Mensch: Dieses Modul fasst das gesamte Zusammenspiel der Einzelteile des Menschen (H¨ ande, Mund, etc.) zusammen und auch, wie ein Mensch mit seiner Umwelt umgeht (greifen, sehen, etc.).
154
8. Objektorientierung Allgemein
Die Schnittstellen und Abh¨ angigkeiten der einzelnen Module sind auch leicht identifizierbar, wie am Beispiel des Menschen und der Zahnpflege kurz demonstriert werden soll: • Ein Mensch verwendet das Zahnpflege-Modul und das WasserversorgungsModul, um sich die Z¨ ahne zu putzen. Die Schnittstelle des Menschen zur Außenwelt sind seine Sinnesorgane, die H¨ande zum Greifen, die Beine und F¨ uße zum Gehen, etc. • Das Zahnpflege-Modul stellt als Schnittstelle nach außen eine Zahnb¨ urste und die Zahnpasta zur Verf¨ ugung. Weiters ist eine der Randbedingungen zur richtigen Verwendung dieses Moduls, dass es ein WasserversorgungsModul geben muss, denn sonst funktioniert die Zahnpflege nicht vern¨ unftig. Auch diese Bedingung ist im Prinzip ein Teil der Modulschnittstelle, obwohl damit nicht direkt irgendwelche m¨oglichen Aufrufe verbunden sind. Um nicht das Ziel aus den Augen zu verlieren – was haben wir bisher eigentlich erreicht, außer dass wir wissen, dass man sich in der Fr¨ uh die Z¨ahne putzt und wie das funktioniert? Wir haben das Problem in u ¨berschaubare Teilprobleme und Abl¨ aufe zerlegt. Das notwendige Wissen bei einer weiteren Zerlegung jedes gefundenen Moduls beschr¨ankt sich nur noch darauf, dass es auch andere Teile gibt, die etwas zur Verf¨ ugung stellen (=Module mit Schnittstellen), nicht aber, wie diese das intern machen. Man will ja auch nicht genau wissen, wie ein Wasserhahn intern konstruiert ist, wenn man nur das Wasser aufdrehen will. Es gen¨ ugt, dass man weiß, dass man am Hahn dreht und dann rinnt Wasser heraus. Ok, ich weiß schon, es gibt auch Einhandmischer, bei denen kein Hahn mehr zum Drehen existiert, aber das ist jetzt nicht Thema der Diskussion :-). So nett das mit den u ¨berschaubaren Teilproblemen auch klingt und so hilfreich diese Vorgangsweise auch ist, irgendwie kann das aber trotzdem noch nicht alles gewesen sein. Bei der Softwareentwicklung will man ja nicht nur ein Problem analysieren, sondern man will ja auch ein informatisches Modell bilden, in dem man die Abl¨ aufe implementiert. Am sch¨onsten w¨are es, wenn man die reale Welt gleich direkt im Computer nachbilden k¨onnte, denn dann ist das informatische Modell sicher am leichtesten zu verstehen und auch zu implementieren. Am sch¨ onsten w¨ are es doch, wenn man im Computer gleich die zu Beginn auf Seite 147 identifizierten Beteiligten modellieren k¨onnte und diese direkt miteinander interagieren lassen k¨onnte. Dann n¨amlich h¨atte man gleich in der Analysephase des Problems ein Modell geschaffen, das ohne ¨ weitere “Ubersetzung” implementierbar ist. Versuchen wir einmal ein kurzes Brainstorming, wie man sich so ein Modell vorstellen k¨onnte: • Es gibt einen Menschen. – Ein Mensch hat einen Kopf. • Im Kopf gibt es ein Gehirn. • Im Kopf gibt es Augen, mit denen der Mensch sieht.
8.2 Klassen und Objekte
155
• Im Kopf gibt es einen Mund, durch den ein Mensch Nahrung zu sich nehmen kann, atmen kann und sprechen kann. · Im Mund gibt es Z¨ ahne. Diese sind hart und man kann mit ihnen beißen. · Im Mund gibt es eine Zunge. Diese ist beweglich, usw. · etc. • etc. – Ein Mensch hat einen Rumpf. – Ein Mensch hat Arme. – Ein Mensch hat H¨ ande, wobei diese durch die Arme mit dem Rumpf verbunden sind. – Ein Mensch hat Beine. – Ein Mensch hat F¨ uße, wobei diese durch die Beine mit dem Rumpf verbunden sind. – Die T¨ atigkeiten eines Menschen werden zentral gesteuert. • Die Steuerung erfolgt durch Signale aus dem Gehirn. • Das Gehirn kann direkt mit allen einzelnen Bestandteilen des Menschen kommunizieren. Dabei k¨ onnen Bewegungen hervorgerufen werden, etc. • etc. – Ein Mensch kann gehen. – etc. • Es gibt Geb¨ aude. – Ein Geb¨ aude ist unterteilt in R¨ aume. – etc. • etc. Ich habe bewusst das Wort Brainstorming verwendet, denn dieses Modell ist noch nicht vollst¨ andig tragf¨ ahig f¨ ur eine Implementation, weil es nicht sauber genug strukturiert ist. Unter dem Begriff der sauberen Strukturierung wird im Sinne der Objektorientiertheit eine saubere semantische Strukturierung nach Einzelteilen, Eigenschaften, T¨atigkeiten und verschiedenartigen Zusammenh¨ angen verstanden, die einen gemeinsamen Abstraktionslevel besitzen. In welchen Kategorien und Modellen hierbei die Erfinder von OO Programmiersprachen gedacht haben und welche Ausdrucksmittel dabei zur Modellierung zur Verf¨ ugung stehen, ist das Thema des folgenden Abschnitts, der uns damit endlich wirklich zum Kern der OO Softwareentwicklung bringt.
8.2 Klassen und Objekte In der Natur der menschlichen Denkweise liegt das Begreifen der Welt als eine Ansammlung von einzelnen Objekten, die Eigenschaften haben und miteinander in irgendwie gearteter Verbindung stehen. Bei den Eigenschaften
156
8. Objektorientierung Allgemein
unterscheidet man im t¨ aglichen Leben v¨ollig intuitiv zwischen solchen, die in der Natur eines Objekts liegen (z.B. hat ein Mensch einen Kopf, einen Rumpf, Arme, Beine, etc.) und solchen, die derzeitig g¨ ultige, besondere Auspr¨agungen von m¨ oglichen naturgegebenen Eigenschaften sind (z.B. gute oder schlechte Laune). Objekte k¨ onnen Aktionen ausf¨ uhren, entweder selbstt¨atig oder in Verbindung mit anderen Objekten (z.B. ein Mensch f¨ahrt mit einem Auto). Um eine Aktion auszuf¨ uhren gibt es Voraussetzungen, z.B. kann nur jemand mit einem Auto fahren, der auch weiß, wie man das tut. Das bedeutet, dass die F¨ ahigkeit, mit einem Auto zu fahren, keine naturgegebene Eigenschaft eines jeden Menschen ist. Abstrahiert man diese Betrachtungen und versucht ein allgemeines Ausdrucksmittel f¨ ur solche Denkmuster zu finden, dann landet man prinzipiell einmal bei folgendem Ergebnis: • Es gibt Klassen. Diese fassen allgemein alle naturgegebenen M¨oglichkeiten einer bestimmten Gruppe von Objekten zusammen. • Es gibt Objekte. Diese sind einzelne Instanzen von bestimmten Klassen mit einem bestimmten Status. Damit stellt ein Objekt eine besondere Auspr¨ agung der durch die Klasse vorgegebenen M¨oglichkeiten dar. Nach diesem Begriff w¨ are zum Beispiel ein Mensch aus dem zuvor besprochenen Beispiel eine Klasse, die alle seine naturgegebenen Eigenschaften und F¨ahigkeiten beschreibt. Die Person, die in der Fr¨ uh aufsteht und zur Arbeit geht bzw. f¨ ahrt, ist ein eine Instanz eines Menschen, also ein Objekt. Die besonderen Eigenschaften, die diese Person aus der F¨ ulle der M¨oglichkeiten des Menschen hat, sind durch die Instanz bestimmt. Zum Beispiel kann sich unsere Person (hoffentlich :-)) die Z¨ ahne putzen. Auch ein neugeborenes Kind ist eine Person und damit der Klasse Mensch zugeh¨orig. Mit dem Z¨ahne putzen ist es allerdings beim Neugeborenen noch nicht so weit her. Damit w¨ aren wir nun endlich bei den Grundkonstrukten von OO Programmiersprachen gelandet, die es erlauben, eine f¨ ur den Menschen nat¨ urliche Modellbildung vorzunehmen. In allen OO Programmiersprachen gibt es Klassen und Objekte mit genau der Bedeutung, wie sie soeben besprochen wurde. Vorsicht Falle: Leider zieht sich durch die gesamte Literatur eine v¨ollig verwaschene Definition durch, in der die Begriffe Klasse und Objekt nur allzu oft als Synonym verwendet werden. Dies ist grundfalsch! Ich m¨ochte eindringlichst davor warnen, die Begriffe Klasse und Objekt miteinander zu vermischen, denn in diesen Begriffen liegt wichtige Semantik, die sich im Programmdesign niederschl¨ agt und damit ein v¨ollig falsches bzw. unverst¨andliches Design ergibt! Wenn eine Klasse eine Eigenschaft hat, so ist dies etwas Naturgegebenes, was alle ihre Instanzen, also Objekte, in ihren Grundanlagen besitzen. Wenn ein Objekt eine Eigenschaft hat, so ist dies eine besondere Auspr¨agung bzw. ein Status, der sich nur auf diese eine Instanz bezieht. Eine
8.2 Klassen und Objekte
157
solche Auspr¨ agung folgt immer den naturgegebenen M¨oglichkeiten, die eine Klasse vorgibt. Umgelegt auf programmiersprachliche Ebene kann man auch folgende Definition geben: • Eine Klasse entspricht einem Datentyp. • Ein Objekt entspricht einer Variable. OO Software folgt dem Modell, dass es in einem Programm beliebig viele Objekte und Interaktionen zwischen diesen Objekten gibt. Durch die Interaktionen wird der Programmfluss bestimmt. Alle grunds¨atzlich m¨oglichen Interaktionen mit einem Objekt sind durch seine Klasse vorgegeben. Um jetzt nicht gleich eine ganze Person zu modellieren, wie sie im obigen Beispiel vorkam, nehmen wir als kleines und einfaches Beispiel mein Telefon am Schreibtisch: Mein Telefon am Schreibtisch ist ein Objekt, n¨amlich eine Instanz einer allgemeinen Klasse Telefon. Diese Klasse, die die Eigenschaften eines Telefons und die m¨ oglichen Interaktionen mit einem solchen bestimmt, kann vereinfacht folgendermaßen aussehen: Bestandteile des Telefons: • Ein Telefon besitzt einen Telefonh¨orer. • Ein Telefon besitzt eine Gabel, auf der der Telefonh¨orer liegt. • Ein Telefon besitzt einen numerischen Tastenblock. • Ein Telefon besitzt eine Leitung zum Telefonnetz. • Ein Telefon besitzt eine Klingel. M¨ogliche Interaktionen mit dem Telefon aus Benutzersicht: • Man kann eine Nummer w¨ ahlen, die man anrufen will. Das geschieht u ¨ber den numerischen Ziffernblock. Damit das Telefon eine Wahl auch akzeptiert, darf der H¨ orer nicht auf der Gabel liegen. • Man kann angerufen werden. In diesem Fall macht sich das Telefon lautstark bemerkbar. Dies geschieht u ¨ber die Klingel. • Man kann einen Anruf annehmen. Dazu hebt man den H¨orer von der Gabel. • Man kann mit jemandem sprechen. Dies geschieht u ¨ber den Telefonh¨ orer, der in diesem Fall nicht auf der Gabel liegen darf. • Man kann ein Gespr¨ ach beenden. Dies geschieht, indem man den Telefonh¨ orer auf die Gabel legt. Andere m¨ ogliche Interaktionen mit dem Telefon: • Das Telefon interagiert mit dem Telefonnetz. M¨ogliche Interaktionen sind sowohl die Nummernwahl als auch ein Signal f¨ ur einen eingehenden Anruf, sowie das Gespr¨ ach selbst. • Das Telefon interagiert mit dem H¨orer. Eingehende Sprachsignale vom Telefonnetz werden an ihn weitergeleitet und Sprachsignale vom H¨orer werden an das Telefonnetz weitergereicht.
158
8. Objektorientierung Allgemein
• Das Telefon interagiert mit dem Ziffernblock. F¨ ur jede gedr¨ uckte Taste wird ein eindeutiger Impuls empfangen. • Das Telefon interagiert mit der Gabel, die sich als einfacher ein/aus Schalter pr¨ asentiert. • Das Telefon interagiert mit der Klingel. Es kann diese zum L¨auten veranlassen. Vorsicht Falle: Ich kann es einfach nicht lassen... Weil sich alles so banal liest und dazu verleitet, das Geschriebene als sowieso klar hinzunehmen, musste ich schon wieder einen groben Designfehler einbauen, der sich im Lauf einer Entwicklung als echte Zeitbombe entpuppen kann. Im Beispiel wird davon ausgegangen, dass der H¨orer nicht auf der Gabel liegen darf, wenn man mit dem Gegen¨ uber spricht oder eine Nummer w¨ahlen will. Das stimmt zwar, aber es ist nicht die ganze Wahrheit: In Wirklichkeit darf die Gabel nicht gedr¨ uckt sein, egal ob durch den H¨orer, den Finger des Telefonierenden, den Fuß des Telefonierenden, der gerade am Tisch liegt, oder sonst etwas. Modelliert man die Klasse so, dass nur der H¨orer daf¨ ur verantwortlich zeichnet, dass die Gabel gedr¨ uckt ist, dann ist das ein glattes Fehldesign, das der Realit¨ at nicht entspricht. Wir wollen in unserem Modell aber die Realit¨ at nachbilden! Solcherart Fehler schleichen sich nur allzu leicht in ein Design ein und m¨ ussen sp¨ ater teuerst mit Programm¨ anderungen oder irgendwelchen Workarounds wieder korrigiert werden. Zumeist aber werden bei solchen Korrekturen sp¨ater nur Spezialf¨ alle korrigiert, die gerade wichtig sind. Damit ist zwar ein zu dieser Zeit wichtiger Fall abgedeckt, aber leider entfernt sich das Modell noch weiter von der Realit¨ at, was die n¨achsten gr¨oberen Probleme dann gleich nach sich zieht. Aus diesem Grund kann ich nur einen ganz wichtigen Ratschlag geben: Das erarbeitete Modell muss laufend mit der Realit¨ at verglichen werden, ob es nicht vielleicht von dieser abweicht. Jede Abweichung muss sofort korrigiert werden, denn Fehler im Programmdesign potenzieren sich im Lauf der Zeit. Vor allem verliert man die Vorteile des OO Ansatzes, denn man muss pl¨ otzlich beginnen, von der Realit¨ at auf das Modell zu u ¨bersetzen, anstatt eine direkte Abbildung zu haben! Nach Korrektur des Designfehlers lesen sich die entsprechenden Punkte folgendermaßen: M¨ogliche Interaktionen mit dem Telefon aus Benutzersicht: • Man kann eine Nummer w¨ ahlen, die man anrufen will. Das geschieht u ¨ber den numerischen Ziffernblock. Damit das Telefon eine Wahl auch akzeptiert, darf die Gabel nicht gedr¨ uckt sein. • Man kann einen Anruf annehmen. Dazu hebt man den H¨orer ab. Die Gabel darf nicht gedr¨ uckt sein.
8.2 Klassen und Objekte
159
• Man kann mit jemandem sprechen. Dies geschieht u ¨ber den Telefonh¨ orer. Die Gabel darf nicht gedr¨ uckt sein. • Man kann ein Gespr¨ ach beenden. Dies geschieht, indem man die Gabel dr¨ uckt. Zur¨ uck zum Ausgangspunkt – wir haben jetzt eine primitive Klasse Telefon. Davon wird eine Instanz erzeugt, also ein Objekt, das das Telefon auf meinem Schreibtisch darstellt. Damit diese eine Instanz funktioniert, m¨ ussen folgende Dinge passieren: • Das Telefon muss an das Telefonnetz angeschlossen werden. Und schon haben wir den n¨ achsten Fehler im Design der Telefonklasse entdeckt: Es fehlt eine Interaktionsm¨ oglichkeit, die es erlauben w¨ urde, dass man das Telefon ans Netz anschließt. Diese M¨oglichkeit muss im Design der Klasse erg¨anzt werden. • Das Telefon wird auf den Schreibtisch gestellt. Das bedeutet, dass wir eine Instanz eines Schreibtischs brauchen, die genau meinen Schreibtisch repr¨asentiert. Diese Instanz ist von der Klasse Schreibtisch, die wiederum auf jeden Fall eine Interaktion “stelle ein beliebiges Objekt darauf” unterst¨ utzt. Dass das darauf gestellte Objekt zu schwer sein k¨onnte und der Schreibtisch dann als Reaktion alle vier Beine von sich streckt, lassen wir im Augenblick einmal außer Acht. Im Design des Schreibtischs geh¨ort eine solche Randbedingung allerdings sehr wohl modelliert! • Das Telefonnetz muss wissen, welche Nummer meinem Telefon zugeordnet wird und wo dieses genau am Netz h¨angt, damit es einen Anruf korrekt durchleitet. Vorsicht Falle: Nein, diesmal habe ich keinen Designfehler eingebaut. Allerdings m¨ ochte ich vor einem solchen warnen, der nur allzu leicht passiert, wenn man unge¨ ubt ist: Neulinge (und leider auch manchmal erfahrene Entwickler) begehen gerne den Fehler, dass die Telefonnummer nicht dem Telefonnetz bekannt ist, wie oben beschrieben, sondern dem Objekt des speziellen Telefons selbst zu eigen ist. Es ist allerdings keine Eigenschaft der Klasse Telefon, dass es eine Nummer besitzt. Vielmehr ist es eine Eigenschaft der Klasse Telefonnetz, dass jedem Telefon eine Nummer zugeordnet ist! Die Verantwortlichkeit liegt also beim Netz und niemals beim Telefon. Wozu es f¨ uhrt, wenn man einen so folgenschweren Fehler begeht, l¨asst sich leicht erkennen, wenn man sich u ¨berlegt, dass man ein gesamtes Netz mit vielen daran h¨ angenden Telefonen modellieren soll: Wie ruft man jemanden an? Man w¨ ahlt eine Nummer und teilt diese dem Netz mit. Und was tut das Netz dann? Wenn jedes Telefon seine eigene Nummer selbst weiß, dann muss das Netz der Reihe nach alle Telefone um die Nummern fragen, bis es das richtige gefunden hat. Dass das nicht funktionieren kann, ist leicht einzusehen.
160
8. Objektorientierung Allgemein
Es ist also zwingend notwendig, dass man w¨ahrend des gesamten Designprozesses immer einen klaren Blick f¨ ur die Verantwortlichkeiten der einzelnen Klassen und Objekte hat und diese auch immer wieder auf ihre Sinnhaftigkeit u uft. ¨berpr¨ Eine Kleinigkeit fehlt uns allerdings noch, um sinnvoll OO Design betreiben zu k¨ onnen. Es l¨ asst sich leicht u uhsam und vor ¨berlegen, das es recht m¨ allem unn¨ otig sein kann, f¨ ur jeden unterschiedlichen Telefontyp eine eigene Klasse zu designen, die im Grunde genommen fast dasselbe tut wie alle anderen speziellen Telefonklassen, nur dass ein paar Kleinigkeiten ver¨andert sind. Stellen wir uns einfach vor, dass auf einem anderen Schreibtisch ein Telefon steht, das zus¨ atzlich zum numerischen Block noch einen weiteren frei programmierbaren Tastenblock besitzt. Es w¨are doch wirklich nicht besonders sinnvoll, nur deswegen alles incl. H¨ orer, Ziffernblock, Kabel zum Telefonnetz, etc. vollst¨ andig neu zu designen, bloß weil ein Teil dazugekommen ist. Genauso sinnlos w¨ are es, das gesamte Design des einfacheren Telefons zu kopieren und beim komplizierteren Telefon dann die noch fehlende Funktionalit¨at zu erg¨ anzen. Man stelle sich nur einmal vor, das Design wurde oft genug kopiert und pl¨ otzlich ergibt sich aus welchen Gr¨ unden auch immer eine ¨ Anderung im Design der Grundfunktionalit¨at eines Telefons. Dann m¨ ussten alle Telefonklassen u ¨berarbeitet werden (von realen Implementationen und den dabei in der Natur der Sache liegenden Fehlerbehebungen will ich hier noch gar nicht sprechen :-)). Was man sich w¨ unschen w¨ urde w¨are folgende M¨oglichkeit: • Man entwirft ein Design f¨ ur eine Klasse Telefon, das die Minimalversion eines Telefons beschreibt, wie wir das zuvor gemacht haben. • Sollte man ein anderes Telefon w¨ unschen, also z.B. eines mit einem zus¨atzlichen Tastenblock oder mit einem Display oder mit beidem, dann verwendet man die bereits existente Klasse und erweitert sie nur um die zus¨atzlichen Eigenschaften und Interaktionsm¨ oglichkeiten. Diese Art der Wiederverwendbarkeit ist, wie zu erwarten, eines der essentiellen Konzepte der OO Softwareentwicklung und wird durch den Mechanismus der Ableitung realisiert. Eine solche Ableitung funktioniert folgendermaßen: • Es gibt eine Basisklasse, die eine Grundfunktionalit¨at definiert, die allen Vertretern dieser Klasse zu eigen ist. Im Falle unseres Telefons ist dies die minimale Funktionalit¨ at, die alles zum Thema anrufen und angerufen werden zur Verf¨ ugung stellt. • Es kann beliebig viele Klassen geben, die von der Basisklasse abgeleitet sind. Durch die Ableitung erben sie automatisch die Eigenschaften der Basisklasse. Genau dadurch erspart man sich das Kopieren. Anm.: In der Fachliteratur findet sich sehr oft der englische Begriff Inheritance f¨ ur die Vererbung.
8.3 Richtige Verwendung der OO Mechanismen
161
• Abgeleitete Klassen erben nicht nur die Eigenschaften der Basisklasse, sie k¨onnen auch deren Eigenschaften anpassen oder neue Eigenschaften hinzuf¨ ugen. Ich spreche hier absichtlich von hinzuf¨ ugen, denn es ist prinzipiell im OO Modell nicht vorgesehen, Eigenschaften wegzunehmen. Technisch w¨are dies zwar je nach verwendeter OO Programmiersprache mehr schlecht als recht machbar, aber es ist absolut unsauber und auch wirklich sinnlos. Denn wenn man Eigenschaften wegnehmen muss, dann hat man bereits in der Basisklasse einen Designfehler und sollte diesen dort schnellstens korrigieren! Polemisch formuliert: Man verwendet ja auch nicht einen Ferrari als Basismodell f¨ ur ein Auto und montiert dann die entsprechenden Teile ab, um daraus einen Kleinwagen zu bauen. Im Falle eines Telefons mit einem zus¨atzlichen frei programmierbaren Ziffernblock kann man dieses von der Basisklasse eines Telefons ableiten und der abgeleiteten Klasse einfach die Eigenschaft des zus¨atzlichen Ziffernblocks spendieren. Damit erh¨ alt man ohne großes Neudesign das gew¨ unschte verbesserte Telefon. Das abgeleitete Telefon funktioniert also, was die Grundeigenschaften betrifft, gleich wie jedes andere Telefon auch, nur kann es durch das Hinzuf¨ ugen des Ziffernblocks noch etwas mehr als das Standardmodell. • Es ist nicht notwendig, dass man immer von einer Basisklasse ableitet. Man kann auch von bereits abgeleiteten Klassen weiter ableiten. Auf diese Art kann man schrittweise, je nach Notwendigkeit, Eigenschaften anpassen und hinzuf¨ ugen. • Im Sinne der Allgemeinheit ist es m¨oglich, dass eine Klasse nicht nur von einer einzigen Klasse abgeleitet ist (=single Inheritance), sondern gleichzeitig von mehreren Klassen. Dies wird als Mehrfachvererbung (=multiple Inheritance) bezeichnet. C++ unterst¨ utzt multiple Inheritance, manche andere Programmiersprachen (z.B. Java) nicht. Der Grund, warum nicht alle OO Programmiersprachen multiple Inheritance unterst¨ utzen, ist die große Gefahr, die bei Fehlverwendung von diesem Konstrukt ausgeht. Im weiteren Verlauf dieses Buchs werden potentielle Stolpersteine durch multiple Inheritance noch mehrfach diskutiert werden. Als kleine Vorwarnung m¨ochte ich hier nur sagen: Multiple Inheritance, vorsichtig und gezielt eingesetzt, er¨ offnet sehr elegante M¨ oglichkeiten, zu einem sauberen Design zu kommen. Jedoch f¨ uhren schon die kleinsten Unachtsamkeiten sehr schnell zu riesigen, schwer korrigierbaren Katastrophen. Um niemanden vorzeitig in Versuchung zu f¨ uhren, erspare ich mir an dieser Stelle ein Beispiel, wie man Mehrfachvererbung einsetzen k¨onnte :-).
8.3 Richtige Verwendung der OO Mechanismen Das Schwierigste bei der OO Softwareentwicklung ist die semantisch richtige Verwendung der Mechanismen, die gerade im Rahmen der Klassen und Objekte erkl¨ art wurden. Um ein wirklich sauberes Modell zu entwickeln, das die
162
8. Objektorientierung Allgemein
Realit¨at in ein Design abbildet, ohne die zur Verf¨ ugung stehenden Mechanismen missbr¨ auchlich zu verwenden oder gar v¨ollig zu vergewaltigen, braucht es sehr viel Erfahrung. In der Folge m¨ochte ich deshalb ein paar Eckpfeiler als Startpunkt f¨ ur das Sammeln von Erfahrung diskutieren um den Lernprozess diesbez¨ uglich ein wenig zu beschleunigen. Ich m¨ochte jedoch zum ¨ wiederholten Male darauf hinweisen, dass Ubung im Umgang mit OO Softwareentwicklung das Wichtigste u ¨berhaupt ist und durch keine Erkl¨arung der Welt zu ersetzen ist! Fassen wir einmal kurz alles zusammen, was bisher bekannt ist und erg¨anzen die notwendigen Puzzleteilchen, die noch fehlen, um die Mechanismen in der Praxis anwenden zu k¨ onnen: • Module sind in sich abgeschlossene funktionale Einheiten. Sie enthalten eine Sammlung von Klassen, die diese Einheit aufspannen. • Klassen sind eine M¨ oglichkeit, die Eigenschaften eines Objekttyps in der realen Welt zu modellieren. Zur Modellierung stehen folgende Mechanismen zur Verf¨ ugung: – Klassen bestimmen, welche Daten ein Objekt dieses Typs h¨alt. Dies geschieht durch die M¨ oglichkeit der Deklaration von sogenannten MemberVariablen, also Variablen, die einer Instanz dieser Klasse zu eigen sind. Member-Variablen sind prinzipiell innerhalb eines Objekts gekapselt und von außen nicht direkt zugreifbar. Welche M¨oglichkeiten man dabei hat, die Kapselung zu beeinflussen, ist von Programmiersprache zu Programmiersprache leicht verschieden. F¨ ur das Grundprinzip ist dies derzeit allerdings noch belanglos. – Klassen bestimmen, welche Daten allen Objekten dieses Typs gemeinsam sind. Dies geschieht durch die M¨oglichkeit der Definition (nicht Deklaration!) von sogenannten Class-Member-Variablen. Solche ClassMembers haben die Eigenschaft, dass sich alle Instanzen ein und dieselbe Variable “teilen” und gemeinsam darauf zugreifen. Zum Beispiel kann es aus verschiedenen Gr¨ unden interessant sein, wie viele Instanzen einer Klasse zu einem gewissen Zeitpunkt gerade im System existieren. Dazu kann man einen Class-Member definieren, der einen Counter darstellt. Jede Instanz, die erzeugt wird, inkrementiert diesen und jede Instanz, die wieder zerst¨ ort wird, dekrementiert diesen. Anm.: Vorsicht ist in multithreaded Umgebungen geboten, damit man bei der Verwendung von Class-Members nicht in Synchronisationsprobleme l¨ auft. – Klassen bestimmen, welche Interaktionen ein Objekt dieses Typs unterst¨ utzt. Dies geschieht durch die Deklaration von sogenannten Methoden oder auch Zugriffsmethoden. Eine Methode ist, salopp gesprochen, eine Funktion, die einer Instanz einer Klasse eigen ist und direkt u ¨ber eine Instanz dieser Klasse aufgerufen wird. Manchmal werden Methoden auch als Member-Funktionen bezeichnet, allerdings ist der Begriff der Methode der h¨ aufiger verwendete, deshalb m¨ochte ich dabei bleiben.
8.3 Richtige Verwendung der OO Mechanismen
163
– Wie es bei den Daten die Class-Members gibt, so gibt es bei den Interaktionen die Class-Methods. Diese haben dieselbe Eigenschaft wie Class-Members, n¨ amlich, dass sie sich nicht auf eine bestimmte Instanz beziehen, sondern losgel¨ ost von den Einzelobjekten sind. Wie leicht einzusehen ist, kann mittels Class-Methods auch nur auf Class-Members zugegriffen werden und nicht auf die instanzbezogenen Member-Variablen, denn es fehlt ja der Bezug zu einer bestimmten Instanz einer Klasse. • Klassen k¨ onnen von einer oder mehreren sogenannten Elternklassen abgeleitet sein. Damit erben sie deren Eigenschaften und k¨onnen selbst neue hinzuf¨ ugen bzw. die geerbten Eigenschaften nach Bedarf (sinnvoll!) modifizieren. Die entstehende Ableitungshierarchie kann beliebig tief sein. • Objekte sind Instanzen von Klassen. Damit ergibt sich automatisch, dass jede Instanz einer Klasse einen eigenen Satz von Member-Variablen besitzt, wie sie durch die Klasse deklariert wurden. Außerdem beziehen sich die Methoden, die in der Klasse deklariert wurden, immer auf ein bestimmtes Objekt. Sie sind quasi lokal zum Objekt in dem Sinn, dass sie immer mit dem Variablensatz der Instanz arbeiten, auf der sie aufgerufen werden. Ich weiß schon – Einsteiger in die OO Programmierung w¨ unschen sich gerade die Instanz Klaus Schmaranz der Klasse Buchautor, die abgeleitet von der Klasse Mensch ist, um auf diesem Objekt die Methode r¨ uckt ihm den Kopf zurecht aufzurufen. Mit einem kleinen Beispiel ist die Verwirrung aber schnell zu beheben, also sehen wir uns einfach an, wie das Modell aussieht, das es unterst¨ utzt, meinen Kopf zurechtzur¨ ucken: • Es gibt eine Klasse Head. Unter anderem hat diese Klasse folgende Eigenschaften: – Eine der Membervariablen, die darin deklariert sind, ist in_place und diese ist vom Typ bool. – Eine der Methoden, die darin deklariert sind, ist putInPlace. Aufruf derselben bewirkt ein Zurechtr¨ ucken, falls dies notwendig ist. • Es gibt eine Klasse Human. Unter anderem hat diese Klasse folgende Eigenschaften: – Eine der Membervariablen, die darin deklariert sind, ist name und diese h¨alt den Namen des Menschen. – Eine weitere der Membervariablen, die darin deklariert sind, ist the_head und diese ist von der Klasse Head. – Eine der Methoden, die darin deklariert sind, ist putHeadInPlace. Aufruf derselben bewirkt ein Zurechtr¨ ucken des Kopfs, falls dies n¨otig ist. – Einer der Class-Members, die darin definiert sind, ist instance_counter. Dieser h¨ alt die derzeitige Anzahl von derzeit existierenden Menschen. • Es gibt eine Klasse Author, die von Human abgeleitet ist. In ihr werden die zus¨ atzlichen Members (Variablen und Methoden) deklariert, die einen Menschen zum Buchautor machen. Auch wenn b¨ose Zungen behaupten, dass diese Klasse nun zu Unrecht die Eigenschaft von Human geerbt hat,
164
8. Objektorientierung Allgemein
einen Kopf zu besitzen – ich habe bereits erw¨ahnt, dass es nicht m¨oglich ist, Eigenschaften einfach wegzudefinieren :-). • Neben vielen anderen Instanzen von Author gibt es auch irgendwo eine Instanz, die Klaus Schmaranz entspricht, was sich durch den entsprechenden Eintrag im name Member ¨außert. Auf dieser Instanz ruft man putHeadInPlace auf, was alle notwendigen Schritte setzt, um wieder Hoffnung zu sch¨ opfen. Um dieses Kapitel nun endg¨ ultig abzurunden, m¨ochte ich hier noch die zwingenden Grundregeln zur Verwendung der OO Mechanismen angeben: In unseren Beispielen haben wir gesehen, dass eine spezielle Telefonklasse von einer allgemeinen Klasse Telefon abgeleitet ist. Ebenso war ein Buchautor von einer Klasse Mensch abgeleitet. Klingt auch logisch, denn das spezielle Telefon ist ein Telefon, nur mit Zus¨atzen und ein Autor ist auch sicher ein Mensch, nur mit zus¨ atzlichen Eigenschaften. Das genau ist auch die semantische Bedeutung einer Ableitung: Eine Ableitung einer Klasse repr¨ asentiert eine IS-A Relation. Wir haben auch gesehen, dass der Kopf eines Menschen in einer Membervariable gespeichert wurde und das genau entspricht auch deren semantischer Bedeutung: Eine Membervariable repr¨ asentiert eine HAS-A Relation. Diese beiden Definitionen gelten nat¨ urlich auch als Umkehrschluss: • Wenn etwas durch IS-A im Modell beschreibbar ist, dann ist eine Ableitung das korrekte semantische Mittel zur Modellierung. • Wenn etwas durch HAS-A im Modell beschreibbar ist, dann ist eine Membervariable das korrekte semantische Mittel zur Modellierung. • Zu diesen beiden Regeln gibt es keine einzige Ausnahme! Jeder einzelne Verstoß gegen eine dieser beiden Regeln f¨ uhrt unweigerlich zur Katastrophe!!! Vorsicht Falle: Nicht nur von Neulingen wird allzu oft der Fehler begangen, Ableitungen falsch einzusetzen, n¨amlich in der Art, dass so etwas wie eine IS-LIKE-A Relation modelliert wird. Ein solches Konstrukt stellt einen groben Designfehler dar, der sich im Lauf der Designphase und sp¨ater bei der Implementierung bitter r¨ acht. Nur um ein Beispiel zu nennen, das ich leider sogar in der Literatur als sinnvolle Ableitung erw¨ ahnt gefunden habe: • Es gibt eine Klasse Array, die eine saubere Implementation eines dynamischen Arrays darstellt. • Es wird eine Klasse Stack entworfen. Weil es gerade so gut dazupasst, dass man die Elemente des Stacks in einem Array speichert, wird Stack von Array abgeleitet.
8.3 Richtige Verwendung der OO Mechanismen
165
Dieses Design sagt also aus, dass ein Stack ein Array ist, und genau das ist vollkommener Nonsens! Ein Stack verwendet ein Array zur Speicherung, also darf hier niemals eine Ableitung herangezogen werden! Der Stack bekommt einfach eine Membervariable der Klasse Array, die er zur Speicherung verwendet. Die wahnwitzige Idee, einen Stack von einem Array abzuleiten, f¨ uhrt dazu, dass der Stack alle Eigenschaften eines Arrays erbt, also auch solche, die u ¨berhaupt nicht in seiner Natur liegen, wie z.B. indizierte Zugriffe auf die einzelnen Elemente. Damit wird dann noch vielleicht mit viel W¨ urgen dem Stack die unerw¨ unschte Funktionalit¨ at mittels Vergewaltigung von OO Konzepten abgew¨ ohnt und schon ist der Wald-und-Wiesen-Hack perfekt, der ei¨ nem sp¨ atestens bei der n¨ achsten Anderung der Array Klasse um die Ohren fliegt.
9. Klassen in C++
Nachdem nun zumindest in der Theorie bekannt ist, was wir von Klassen und Objekten erwarten, wird es Zeit zu zeigen, wie diese Konzepte in C++ umgesetzt sind. Vorsicht Falle: Obwohl ich noch nicht einmal begonnen habe, die technischen Details von Klassen zu besprechen, m¨ochte ich schon den ersten Hinweis auf die gef¨ ahrlichste Falle u ¨berhaupt geben, die sich C++ Neulingen auftut. Leider zeigt die Erfahrung, dass sehr viele Entwickler nur Teile der Konzepte verinnerlicht haben, die C++ bietet. Speziellere Konstrukte werden als “das brauche ich nicht” abgetan und einfach ignoriert. Nur eines von vielen typischen Beispielen f¨ ur dieses Problem ist das Ignorieren der Existenz von virtual Ableitungen, zu denen wir sp¨ater noch kommen werden, mit entsprechend absolut fatalen Folgen. Die große Gefahr dabei, die sogenannten Spezialit¨ aten zu ignorieren, ist, dass dadurch oftmals Code entsteht, der aus technischen Gr¨ unden sp¨ater wieder gravierend ge¨ andert oder sogar vollst¨andig verworfen werden muss. Prominente Vertreter von kommerziell ausgelieferten Libraries, bei denen solche Pannen passiert sind, gibt es einige, allerdings m¨ochte ich hier keinen davon namentlich nennen. Es gen¨ ugt, zu wissen, dass es von praktisch allen wirklich großen Herstellern von Software und Entwicklungsumgebungen solche Wunderwerke gab und, leider muss man auch das sagen, immer noch gibt. Ich m¨ ochte also alle Leser ausdr¨ ucklichst bitten, alle in der Folge vorgestellten Konstrukte wirklich genauestens anzusehen und auch zu begreifen. Erst wenn man wirklich den Sinn hinter allem verstanden hat, ist man in der Lage, echtes C++ zu schreiben, ohne in technische Fallen zu stolpern! Man kann nicht nur im Design vieles falsch machen, auch in der Umsetzung lauern noch mannigfaltige Gefahren!!! Um nun niemanden zu entmutigen: Es ist absolut erw¨ unscht und sehr wichtig, sich mit allem einmal zu spielen und entsprechende Fehler zu machen. Aus Fehlern lernt man (manchmal sogar nur aus Fehlern). Solche Fehler sollen allerdings im Rahmen der Lern- und Spielphase passieren und nicht bei ernsthaften Projekten!
168
9. Klassen in C++
9.1 Besonderheiten von Structures in C++ Aus Abschnitt 2.4.2 sind Structures als Mittel zur Zusammenfassung von Daten zu einem Aggregat-Datentypen bekannt. Es wurde auch bereits darauf hingewiesen, dass sich noch mehr hinter Structures versteckt, als dort ¨ behandelt wurde. Genau dieses Mehr m¨ochte ich hier als Uberleitung zum ++ allgemeinen Konzept der Classes in C verwenden. Sehen wir uns dazu gleich ein kleines Beispiel an, das uns auf den Weg zu den Prinzipien der OO Entwicklung in C++ bringt (struct_method_demo.cpp): 1 2
// struct method demo . cpp − s m a l l demo , how methods can be // implemented f o r s t r u c t u r e s
3 4 5
#include < i o s t r e a m> #include < c s t r i n g>
6 7 8
using s t d : : cout ; using s t d : : e n d l ;
9 10 11 12 13
struct Name { char ∗ f i r s t n a m e ; char ∗ l a s t n a m e ;
14
void i n i t ( const char ∗ f i r s t n a m e , const char ∗ last nam e ) ; void setFirstName ( const char ∗ f i r s t n a m e ) ; void setLastName ( const char ∗ la st nam e ) ; const char ∗ getFirstName ( ) ; const char ∗ getLastName ( ) ;
15 16 17 18 19 20
};
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void Name : : i n i t ( const char ∗ f i r s t n a m e , const char ∗ la st nam e ) { i f ( ! first name ) first name = 0; else { f i r s t n a m e = new char [ s t r l e n ( f i r s t n a m e ) + 1 ] ; strcpy ( first name , first name ) ; } i f ( ! la st name ) last name = 0; else { l a s t n a m e = new char [ s t r l e n ( last nam e ) + 1 ] ; s t r c p y ( l a s t n a m e , last name ) ; } }
40 41 42 43 44 45 46 47 48 49 50 51 52
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void Name : : setFirstName ( const char ∗ f i r s t n a m e ) { i f ( first name ) delete [ ] f i r s t n a m e ; i f ( ! first name ) first name = 0; else { f i r s t n a m e = new char [ s t r l e n ( f i r s t n a m e ) + 1 ] ; strcpy ( first name , first name ) ; }
9.1 Besonderheiten von Structures in C++
53
169
}
54 55 56 57 58 59 60 61 62 63 64 65 66 67
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void Name : : setLastName ( const char ∗ l ast nam e ) { i f ( last name ) delete [ ] l a s t n a m e ; i f ( ! last name ) last name = 0; else { l a s t n a m e = new char [ s t r l e n ( last nam e ) + 1 ] ; s t r c p y ( l a s t n a m e , last name ) ; } }
68 69 70 71 72 73
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− const char ∗Name : : getFirstName ( ) { return ( f i r s t n a m e ) ; }
74 75 76 77 78 79
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− const char ∗Name : : getLastName ( ) { return ( l a s t n a m e ) ; }
80 81 82 83 84 85
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { Name one name ; one name . i n i t ( ”John” , ”Doe” ) ;
86
Name another name ; another name . i n i t ( ” Joe ” , ”Smith” ) ;
87 88 89
cout << ”one name : ” << one name . getFirstName () << ” ” << one name . getLastName () << e nd l ; cout << ” another name : ” << another name . getFirstName () << ” ” << another name . getLastName () << e n d l ;
90 91 92 93 94
one name . setFirstName ( ”Mark” ) ; another name . setLastName ( ” McHill ” ) ;
95 96 97
cout << ” a f t e r c a l l i n g the s e t . . . methods : ” << e nd l ; cout << ”one name : ” << one name . getFirstName () << ” ” << one name . getLastName () << e n d l ; cout << ” another name : ” << another name . getFirstName () << ” ” << another name . getLastName () << e n d l ; return ( 0 ) ;
98 99 100 101 102 103 104
}
Der Output, den dieses Programm erzeugt, sieht folgendermaßen aus: one name : John Doe another name : Joe Smith a f t e r c a l l i n g the s e t . . . methods : one name : Mark Doe another name : Joe McHill
Die Zeilen 15–19 sehen doch glatt so aus, als ob sie Deklarationen von Funktionen w¨ aren, allerdings, was haben diese innerhalb einer struct verloren? Nun, sehr viel, denn genau das ist es, wie man Methoden deklariert. Die-
170
9. Klassen in C++
se Art der Deklaration macht aus einer allgemein aufrufbaren Funktion eine auf die Structure bezogene Methode. Der Begriff auf die Structure bezogen bedeutet, dass eine solche Methode auch nur direkt auf eine Instanz einer solchen Structure aufgerufen werden kann. Wie ein solcher Aufruf funktioniert, l¨asst sich leicht erraten, wenn wir daran denken, wie Member-Variablen einer struct angesprochen werden. Es wird einfach der Punkt-Operator dazu herangezogen (bzw. im Falle von Pointern nat¨ urlich der Pfeil-Operator). Die entsprechenden Aufrufe der Methoden sieht man in Zeile 85 und in der Folge. Zum Beispiel bedeutet das Statement in Zeile 85: Rufe die Methode init auf der Variable one_name mit den Parametern "John" und "Doe" auf. Ein Unterschied zu C f¨ allt in Zeile 83 auf: Anstatt in gewohnter Manier f¨ ur den Typ struct Name zu schreiben, steht hier einfach Name als Datentyp. Dies funktioniert in C++ und es macht das sonst in C zur leichteren Lesbarkeit in diesem Zusammenhang verwendete typedef u ussig. ¨berfl¨ Ein wichtiges Detail zu Methoden wurde noch nicht besprochen: Die Deklaration derselben erfolgt innerhalb der struct, wie aber definiert man sie? Stellen wir uns vor, es g¨ abe in unserem Programm neben der struct Name auch noch eine struct Address. Stellen wir uns weiters vor, dass sinnvollerweise die Initialisierung bei beiden u ¨ber die Methode init erfolgt. Wie teilen wir dann dem Compiler mit, welche der Definitionen der init Methoden nun zu welcher struct geh¨ ort? Dass man dies nicht u ¨ber verschiedene Parameterlisten realisieren kann, versteht sich von selbst, denn das w¨ urde eine grobe Einschr¨ ankung darstellen. Des R¨ atsels L¨ osung zeigt sich z.B. in Zeile 23: Man verwendet den sogenannten Scope-Operator (::), der folgendermaßen funktioniert: Man gibt den Namen der struct an, gefolgt von ::, gefolgt vom Namen der Methode. Damit liest sich Zeile 23 so: Die Funktion init mit dem Scope auf die struct Name und den entsprechenden Parametern sieht folgendermaßen aus... Das bedeutet, dass man mittels des Scope Operators die entsprechende Zuordnung bei der Definition vornimmt. Bei der Deklaration ist diese Zuordnung nat¨ urlich nicht notwendig, denn durch das Auftreten der Deklaration innerhalb der struct ist dem Compiler die Zugeh¨origkeit sowieso klar. F¨ ur Leser, denen der Coding Standard, der diesem Buch zugrunde liegt, noch nicht gel¨ aufig ist, mag die Deklaration der Members in den Zeilen 12 und 13 etwas komisch anmuten. Jedoch schreibt der Coding Standard vor, dass Member Variablen mit einem Unterstrich enden, um sie sofort als Members identifizieren zu k¨ onnen und von auto Variablen auf einen Blick unterscheiden zu k¨ onnen. Sieht man sich Zeile 26 an, so sieht man auch gleich, was es mit Members so auf sich hat. In der Definition der Methode Name::init werden die beiden Members first_name_ und last_name_ verwendet, als ob sie innerhalb des Scopes der Methode definiert w¨aren. Genau das ist auch der Fall und das ist eine weitere Eigenschaft des Scope-Operators. Dadurch, dass init zum Scope von struct Name geh¨ort, geh¨oren auch die Members der struct Name zum aktuellen Scope und k¨onnen daher ohne weitere Umschwei-
9.2 Einfache Klassen
171
fe direkt angesprochen werden. Zeile 26 bedeutet also Folgendes: Weise dem Member first_name_ der struct Name den Wert 0 zu. Vorsicht Falle: Ein Teil der g¨ angigen Praxis, die aus dem Coding Standard dieses Buchs resultiert, ist, dass es problemlos vorkommen kann, dass Parameter und Members quasi denselben Namen haben, nur dass eben Members durch einen Unterstrich am Ende gekennzeichnet sind. Wenn man den Coding Standard gewohnt ist, dann ist dies ein sehr angenehmes Feature, denn man muss nicht mit Gewalt einen neuen Namen f¨ ur einen Parameter erfinden, der dann vielleicht ein wenig holprig ausf¨allt. Der Stolperstein bei dieser Praxis, der sich am Anfang auftut, ist, dass man noch nicht intuitiv beim Lesen auf den Unterstrich triggert und somit z.B. die Zeile 30 durch schlampiges Lesen auf den ersten Blick komisch aussieht. Der erste Parameter von strcpy, also first_name_, bezeichnet n¨amlich den entsprechenden Member und der zweite Parameter, also ¨ first_name, bezeichnet den Ubergabeparameter der init Methode. Ich kann allerdings versichern, dass die Gew¨ohnungsphase an diesen Coding Standard, in der man solche Fl¨ uchtigkeitsfehler beim Lesen macht, sehr kurz ist. Ich kenne viele Leute, die genau nach diesem Coding Standard arbeiten und die sehr bald nicht das geringste Problem mehr beim Lesen solcher Programmzeilen hatten, weil der Underline am Ende in Fleisch und Blut u ¨bergegangen ist. Ich m¨ ochte nun an dieser Stelle keine n¨aheren Ausf¨ uhrungen mehr u ¨ber Structures bringen, da wir alles, was hier gesagt werden k¨onnte, bei “echten” Classes wieder treffen werden. Structures sind in C++ tats¨achlich Classes, bei denen alle Members standardm¨ aßig public sind. Was das bedeutet, sehen wir im kommenden Abschnitt, in dem wir uns endlich offiziell den Klassen und Objekten, mit all ihren St¨ arken (und auch Stolpersteinen), in C++ zuwenden.
9.2 Einfache Klassen Klassen in C++ realisieren das Konzept, dass Entwickler neue Datentypen erstellen k¨ onnen, die genauso einfach und sinnvoll verwendet werden k¨onnen, wie eingebaute Datentypen (z.B. int). Hinter allen Datentypen steckt immer ein Gesamtkonzept, z.B. geh¨ oren zu einem int nat¨ urlich auch die entsprechenden Operationen +, -, *, /, etc. Weiters ist definiert, wie ein int z.B. in einen float oder double umgewandelt werden kann. Solche Gesamtkonzepte lassen sich vollst¨ andig auch mit Klassen verwirklichen. Um nun nicht die u ¨blichen Datums-, Vektor-, Matrix- und andere Klassen zur Erkl¨ arung heranzuziehen, die in verschiedensten Formen in mannigfaltigen B¨ uchern und Tutorials zu Tode diskutiert wurden, m¨ochte ich die Grundprinzipien an einem durchgehenden Beispiel aufbauend erkl¨aren. Die-
172
9. Klassen in C++
ses Beispiel soll in der Folge das allseits bekannte Spiel Memory sein. F¨ ur Leser, die dieses Spiel nicht kennen, ist es einfach erkl¨art: • Es gibt ein rechteckiges Gesamtspielfeld mit c Spalten und r Reihen, womit sich eine schachbrettartige Aufteilung in c ∗ r Einzelfelder ergibt. • Auf jedem Einzelfeld liegt eine Karte, die auf ihrer Vorderseite ein gewisses Symbol zeigt. Die R¨ uckseiten aller Karten sind gleich und machen eine Unterscheidung der Karten unm¨ oglich, wenn sie mit der R¨ uckseite nach oben zu liegen kommen. • Die Symbole aller Karten zusammen, die auf dem Gesamtspielfeld zu liegen kommen, sind so aufgeteilt, dass es jeweils genau zwei Karten mit demselben Symbol gibt. • Die Verteilung der Karten auf das Gesamtspielfeld ist zuf¨allig, es k¨onnen also die jeweils zwei zusammengeh¨origen Symbolkarten auf beliebigsten Einzelfeldern zu liegen kommen. • Ein Spiel besteht nun aus folgenden Schritten: 1. Genau so viele Karten wie es Einzelfelder gibt, werden zuf¨allig mit der R¨ uckseite nach oben auf das Gesamtspielfeld verteilt. 2. Danach werden alle Karten am Gesamtspielfeld f¨ ur einen kurzen Zeitraum umgedreht, sodass die Symbole auf ihren Vorderseiten zu sehen sind. Die Spielerin bzw. der Spieler hat in diesem kurzen Zeitraum Gelegenheit, sich die Symbole auf den einzelnen Feldern einzupr¨agen. 3. Nach dieser kurzen Einpr¨ agephase werden alle Karten erneut umgedreht, sodass sie wieder ihre nicht unterscheidbaren R¨ uckseiten zeigen. 4. Die Spielerin bzw. der Spieler muss nun immer paarweise Karten aufdecken (=umdrehen), die das gleiche Symbol zeigen sollten. 5. Zeigt ein aufgedecktes Paar auf beiden Karten das gleiche Symbol, so bleibt es aufgedeckt liegen. 6. Zeigt ein aufgedecktes Paar zwei verschiedene Symbole, so werden beide Karten wieder umgedreht. • Ziel ist es, in so wenigen Schritten wie m¨oglich, alle zusammengeh¨origen Paare gefunden zu haben und damit alle Karten umgedreht zu haben. Ich m¨ochte nun an dieser Stelle keine ausf¨ uhrliche und eingehende Analyse der gesamten Problemstellung machen, da dies hier eher st¨oren als n¨ utzen w¨ urde. Jedoch sei darauf hingewiesen, dass diese Analyse sehr wohl gemacht wurde und auch bei der Entwicklung gemacht werden muss, um auf ein vern¨ unftiges Ergebnis zu kommen. Diese Gesamtanalyse folgt zur Wiederholung des Gelernten in Kapitel 10. Im Augenblick werden einfach die einzelnen Teile h¨appchenweise vorgestellt, die sich aus der Analyse ergeben haben. Ich m¨ochte auch alle Leser bitten, die in der Folge vorgestellten H¨appchen als noch nicht perfekt im Sinne von C++ hinzunehmen. Der Grund daf¨ ur ist der, dass wirklich alle C++ Mechanismen bekannt sein m¨ ussen, um alles so sauber wie m¨oglich zu schreiben.
9.2 Einfache Klassen
173
Zur¨ uck zum Thema: Betrachten wir als erste identifizierte Klasse eine Karte. Eine M¨ oglichkeit, diese in C++ zu modellieren, mit der entsprechenden Deklaration des gefundenen Modells, findet sich in folgendem Beispiel (memory_game_card.h): 1
// memory game card . h − d e c l a r a t i o n o f the c l a s s MemoryGameCard
2 3 4
#i f n d e f memory game card h #define memory game card h
5 6 7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MemoryGameCard ∗ ∗ model o f a card as used i n the game ”memory” ∗ ∗/
13 14 15 16 17 18 19 20
c l a s s MemoryGameCard { protected : char ∗ symbol ; public : MemoryGameCard( const char ∗ symbol ) ; ˜MemoryGameCard ( ) ;
21
const char ∗ getSymbol ( ) ; void changeSymbol ( const char ∗ symbol ) ;
22 23 24
};
25 26 27
#endif // memory game card h
Ganz bewusst wurde hier gleich der richtige Weg eingeschlagen, eine Klasse in einem Header File zu deklarieren und die Definition in ein eigenes cpp File zu verlegen, anstatt alles gemeinsam in ein File zu verpacken. Um eine Klasse verwenden zu k¨ onnen, muss dann nur noch der entsprechende Header inkludiert werden, wie wir weiter unten sehen werden. Wenden wir uns aber lieber den wichtigeren Dingen zu, die hier zu finden sind. In den Zeilen 14–24 sieht man, wie man eine Klasse deklariert. Dies geschieht gleich, wie die Deklaration einer Structure, nur dass statt des Keywords struct das Keyword class verwendet wird. Wir deklarieren hier also eine Klasse MemoryGameCard, die genau unter diesem Namen als Datentyp in Programmen verwendet werden kann. Wie das genau passiert und was dabei abl¨auft, wird sp¨ ater noch n¨ aher beleuchtet werden. Dass man in einer Klasse Member-Variablen und Methoden deklarieren kann und dass dies gleich passiert, wie wir es bei einer struct bereits kennen gelernt haben, versteht sich von selbst. Viel interessanter sind da schon die Zeilen 16 und 18. In diesen Zeilen werden durch die besonderen Labels protected: bzw. public: die Zugriffsrechte auf die einzelnen Members (Variablen wie auch Methoden) definiert. F¨ ur diese besonderen Labels ist auch der Name Access Specifiers gebr¨auchlich. Folgende Access Specifiers mit entsprechender Bedeutung sind in C++ definiert:
174
9. Klassen in C++
private: Es d¨ urfen ausschließlich Methoden der eigenen Klasse und friend Funktionen bzw. Klassen, zu denen wir noch sp¨ater kommen werden, zugreifen. Der Zugriff von außen, sowie der Zugriff aus von dieser Klasse abgeleiteten Klassen heraus, ist verboten. protected: Es d¨ urfen Methoden der eigenen Klasse, friend Funktionen bzw. Klassen, sowie Methoden aus abgeleiteten Klassen zugreifen. Der Zugriff f¨ ur außenstehende Methoden und Funktionen ist nicht gestattet. public: Es d¨ urfen alle Funktionen und Methoden, egal in welchem Verh¨altnis sie zur Klasse stehen, zugreifen. Sollte kein Access Specifier explizit angegeben sein, dann gilt alles bis zum Auftreten des ersten als private. Dies ist auch einer der Unterschiede zwischen Classes und Structures: Bei Classes gilt alles grunds¨atzlich als private, ist also nicht von außen zugreifbar, bei Structures gilt alles grunds¨atzlich als public, ist also von außen uneingeschr¨ankt zugreifbar. Mir ist schon bewusst, dass bei der Beschreibung der Access Specifiers einige Dinge vorkommen, die bisher noch nicht bekannt sind. Bitte keine Panik deswegen, sobald wir zu den entsprechenden Konstrukten kommen, wird eine genauere Besprechung folgen. Ich wollte nur bereits hier ein vollst¨andiges Bild der M¨ oglichkeiten schaffen, um zu vermitteln, was es u ¨berhaupt gibt. Die G¨ ultigkeitsbereiche von Access Specifiers sind so definiert, dass ein Specifier vom Punkt, an dem er steht, bis zum n¨achsten auftretenden Access Specifier bestimmend ist. Es ist auch m¨oglich, dieselben Access Specifiers mehrfach innerhalb einer Klasse zu verwenden. Z.B. kann man zuerst einen private Block haben, danach einen public Block, gefolgt von einem erneuten private Block, etc. Der Grund f¨ ur diese Regelung ist, dass man den Entwicklern Freiraum f¨ ur die eigene Anordnung ihrer Deklarationen l¨asst und somit die Lesbarkeit erh¨ oht. Eine Einschr¨ ankung m¨ ochte ich aus Gr¨ unden der Lesbarkeit allerdings unbedingt zum Standard erheben: Member-Variablen werden immer oben und Methoden immer erst danach deklariert. Eine Mischung, in der zuerst ein paar Variablen, danach ein paar Methoden und dann wieder ein paar Variablen deklariert werden, ist absolut unleserlich! Als Entwickler will man in jedem Fall auf einen Blick erfassen k¨onnen, welche Variablen und Methoden es gibt, ohne gleich die gesamte Klassendeklaration vollst¨andig durchsuchen zu m¨ ussen und vielleicht dabei etwas zu u ¨bersehen. Durch das Wissen um die G¨ ultigkeitsbereiche der Access Specifiers ergibt sich also nun folgendes Bild: Die Member-Variable symbol_ ist nur innerhalb der eigenen Klasse und f¨ ur abgeleitete Klassen zugreifbar (und nat¨ urlich auch f¨ ur Friends). Alle ¨ Methoden in den Zeilen 19, 20, 22 und 23 sind f¨ ur die Offentlichkeit verf¨ ugbar. Sie definieren damit das sogenannte Public Interface der Klasse, also die Schnittstelle, u ¨ber die man mit der Klasse regul¨ar arbeiten kann. Ein Blick auf die Zeilen 19 und 20 legt allerdings bei manchen Lesern die Vermutung nahe, dass bei mir im Laufe des Schreibens ein gewisser Zustand
9.2 Einfache Klassen
175
geistiger Umnachtung eingesetzt hat. Irgendwie sehen die beiden Konstrukte aus wie Deklarationen von Methoden, andererseits aber fehlt Entscheidendes, n¨amlich die Deklaration des return-Values. Vor allem hat sich in Zeile 20 obendrein noch ein v¨ ollig unmotiviertes BIT-NOT vor dem Methodennamen eingeschlichen, das hier doch gar nicht erlaubt ist, oder??? Zu meiner Verteidigung kann ich nur sagen, dass ich vollkommen unschuldig bin! Diese beiden Methoden, n¨amlich Konstruktor in Zeile 19 und Destruktor in Zeile 20 sind tats¨ achlich genau so zu deklarieren. Per Definition wird der Konstruktor so deklariert (und definiert), dass er den Klassennamen als Methodennamen hat und die entsprechende Parameterliste, die zum Konstruieren eines Objekts dieses Typs notwendig ist. Der Destruktor hat den Klassennamen mit ~ als Pr¨ afix als Methodennamen und besitzt (sinnigerweise) eine leere Parameterliste. Der Konstruktor wird automatisch aufgerufen, wenn eine Instanz dieser Klasse erzeugt wird (also eine Variable angelegt wird), der Destruktor, wenn diese Instanz wieder zerst¨ ort wird (also eine Variable ihre Lifetime beendet hat). In Abschnitt 9.2.1 werden wir uns diesem Thema noch ein wenig eingehender widmen, derzeit gen¨ ugt dieses Wissen f¨ ur unsere weiteren Betrachtungen. Von außen betrachtet bietet ein Objekt vom Typ MemoryGameCard also folgende Funktionalit¨ at: • Man kann eine neue Karte erzeugen. Bei der Erzeugung muss man gleich ein entsprechendes Symbol mitgeben. Diese Eigenschaft ist gefordert, da es nur einen Konstruktor mit einem Symbol als Parameter gibt. Eine leere Karte ist also nicht erzeugbar. • Das Symbol, das zu einer Karte geh¨ort, ist in unserem Fall einfach ein String. Zugegeben, dieses Verhalten ist nicht das tollste, aber f¨ ur Demonstrationszwecke bleiben wir einmal dabei. • Man kann eine Karte nach dem Symbol fragen. • Man kann das Symbol einer Karte ¨andern. Je nach Anwendungszweck kann man nat¨ urlich nun dar¨ uber streiten, ob es u ¨berhaupt sehr klug ist, dies zuzulassen. Aber das ist eine andere Diskussion :-). Was explizit durch protected verboten ist, ist ein direkter Zugriff von außen auf die Member-Variable symbol_. Dadurch ist gew¨ahrleistet, dass symbol_ immer nur mittels Zugriffsmethoden oder von abgeleiteten Klassen heraus direkt angegriffen werden darf, was eventuelle Inkonsistenzen verhindert. Zugegeben, bei dieser Klasse erscheint dies etwas pingelig, aber wir werden noch gen¨ ugend F¨ alle kennen lernen, in denen ein solcher Schutz lebensnotwendig sein kann. Die Implementation der einzelnen Methoden, die in der Klasse deklariert wurden, erfolgt getrennt im File memory_game_card.cpp:
176
1
9. Klassen in C++
// memory game card . cpp − d e f i n i t i o n o f the methods o f MemoryGameCard
2 3
#include ”memory game card . h”
4 5
#include < c s t r i n g>
6 7 8 9 10 11
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r o f MemoryGameCard ∗ ∗ @param symbol the symbol r e p r e s e n t e d by t h i s card ∗/
12 13 14 15 16 17 18 19 20 21 22
MemoryGameCard : : MemoryGameCard( const char ∗ symbol ) { i f ( ! symbol ) { symbol = 0 ; return ; } symbol = new char [ s t r l e n ( symbol ) + 1 ] ; s t r c p y ( symbol , symbol ) ; }
23 24 25 26
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r o f MemoryGameCard ∗/
27 28 29 30 31 32
MemoryGameCard : : ˜ MemoryGameCard ( ) { i f ( symbol ) delete [ ] symbol ; }
33 34 35 36 37
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the symbol t h a t t h i s card r e p r e s e n t s ∗/
38 39 40 41 42
const char ∗MemoryGameCard : : getSymbol ( ) { return ( symbol ) ; }
43 44 45 46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param symbol the symbol r e p r e s e n t e d by t h i s card ∗/
50 51 52 53 54 55 56 57 58 59 60 61 62
void MemoryGameCard : : changeSymbol ( const char ∗ symbol ) { i f ( symbol ) delete [ ] symbol ; i f ( ! symbol ) { symbol = 0 ; return ; } symbol = new char [ s t r l e n ( symbol ) + 1 ] ; s t r c p y ( symbol , symbol ) ; }
9.2 Einfache Klassen
177
Im Prinzip treffen wir in diesem File nur alte Bekannte, dementsprechend er¨ ubrigt sich eine genaue Diskussion u ¨ber die Interna. Wie man sieht, wird der Scope Operator bei Klassen genau gleich angewandt, wie wir ihn schon bei Structures kennen gelernt haben, um die Zugeh¨origkeit einer Methode zu einer Klassendefinition zu signalisieren. Auch hier gelten dieselben Regeln f¨ ur den default Scope der Methode, wie man z.B. in Zeile 17 sieht: Die Zuweisung auf symbol_ bezeichnet nat¨ urlich eine Zuweisung auf den Member symbol_ der entsprechenden Instanz der Klasse. Wie man jetzt mit unserer fertig deklarierten, definierten und implementierten Klasse arbeiten kann, zeigt das folgende kleine Testprogr¨ammchen (memory_game_card_test.cpp): 1
// memory game card test . cpp − s i m p l e t e s t program f o r MemoryGameCard
2 3 4
#include < i o s t r e a m> #include ”memory game card . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { MemoryGameCard t e s t c a r d ( ”Symbol A” ) ;
12
cout << ” t e s t c a r d a f t e r c o n s t r u c t i o n : ” << t e s t c a r d . getSymbol () << e nd l ;
13 14 15
t e s t c a r d . changeSymbol ( ”Symbol B” ) ; cout << ” t e s t c a r d a f t e r changeSymbol : ” << t e s t c a r d . getSymbol () << e nd l ;
16 17 18 19
return ( 0 ) ;
20 21
}
In Zeile 11 sieht man, wie das mit dem Konstruktor, der einen Parameter nimmt, zu verstehen ist. Dort wird eine Variable test_card vom Typ MemoryGameCard angelegt und in runden Klammern wird quasi wie bei einem Funktions- bzw. Methodenaufruf der geforderte Parameter hinten nachgestellt. Im Zuge des Anlegens der Variable test_card wird deren Konstruktor entsprechend aufgerufen und damit die Karte auf den gew¨ unschten Wert initialisiert. Ich verwende hier aus mehreren Gr¨ unden bewusst das Wort initialisiert, denn erstens handelt es sich wirklich um eine echte Initialisierung, die also beim Anlegen passiert, zweitens ist das der einzige Ort, an dem ein Aufruf eines entsprechenden Konstruktors getriggert werden kann. Es gibt keine M¨oglichkeit, den Konstruktor irgendwie explizit einfach als Methode aufzurufen, sobald ein Objekt einmal “lebt”. In den Zeilen 13–18 sieht man, was die verschiedenen Methodenaufrufe auf das Objekt test_card bewirken. Eines allerdings bleibt verborgen, da dies wiederum nur implizit vom Compiler eingesetzt wird: der Aufruf des Destruktors. Dieser findet automatisch statt, sobald test_card ihre Lifetime
178
9. Klassen in C++
hinter sich hat, also beim Verlassen des Blocks durch das return Statement in Zeile 20. Um das Testprogramm ordnungsgem¨aß u ussen ¨bersetzen zu k¨onnen, m¨ die beiden C++ Source Files compiliert und die resultierenden Object-Files zu einem Executable gelinkt werden. Dass es nicht so toll ist, dies “zu Fuß” zu machen, versteht sich von selbst. Dementsprechend wird das Tool make mit dem folgenden Makefile verwendet (MemoryGameCardTestMakefile): 1 2 3 4 5 6 7 8 9 10
OBJS = memory game card test . o \ memory game card . o CC = g++ LD = g++ EXTRA CCINCLUDES = CC FLAGS = −c $ (EXTRA CCINCLUDES) − Wall EXTRA LIBDIRS = EXTRA LIBS = EXECUTABLE = memory game card test LD FLAGS = −o $ (EXECUTABLE) $ (EXTRA LIBDIRS ) $ (EXTRA LIBS)
11 12 13
memory game card test : $ (OBJS) $ (LD) $ (LD FLAGS) $ (OBJS)
14 15 16 17
memory game card test . o : memory game card test . cpp \ memory game card . h $ (CC) $ (CC FLAGS) memory game card test . cpp
18 19 20
memory game card . o : memory game card . cpp \ memory game card . h
21 22
a l l : memory game card test
23 24 25
clean : rm $ (OBJS) $ (EXECUTABLE)
Lesern, denen die Funktionsweise von make und das Erstellen von Makefiles nicht gel¨ aufig ist, m¨ ochte ich kurz die Lekt¨ ure von Kapitel 15 aus Softwareentwicklung in C empfehlen. Dort werden alle notwendigen Schritte genau diskutiert. Wenn alles glatt gegangen ist und wir das Programm starten, dann begl¨ uckt es uns mit folgendem, unglaublich tollen Output: t e s t c a r d a f t e r c o n s t r u c t i o n : Symbol A t e s t c a r d a f t e r changeSymbol : Symbol B
9.2.1 Konstruktor und Destruktor genauer beleuchtet Da die Aufrufe des Konstruktors und des Destruktors vom Compiler implizit eingesetzt werden, m¨ ochte ich an dieser Stelle etwas Licht auf die Vorg¨ange hinter den Kulissen werfen. Sehen wir uns dazu einmal ganz kurz den Lebenszyklus einer beliebigen Variable an:
9.2 Einfache Klassen
179
1. Es wird Speicher f¨ ur die Variable reserviert. Die Gr¨oße des Speichers ist bestimmt durch den Typ der Variable und durch gewisse andere systeminterne Gegebenheiten. In jedem Fall wird garantiert, dass genug reserviert wird, um die Daten dem verwendeten Typ gem¨aß zu halten. 2. Nach der Reservierung des Speichers folgt die Initialisierung der Variable. 3. Mit der fertig initialisierten Variable kann man ab jetzt arbeiten, wie es durch ihren Typ vorgegeben ist. 4. Wenn eine Variable ihre Lifetime beendet hat, wird sie “wegger¨aumt”. Der erste Schritt beim “Wegr¨ aumen” ist, dass eventuell notwendige explizite “Aufr¨ aumarbeiten” durchgef¨ uhrt werden. 5. Wenn alle notwendigen “Aufr¨ aumarbeiten” beendet sind, wird der Speicher, der von der Variable belegt war, wieder freigegeben. Genau die Punkte 2 und 4 sind es, die beim Arbeiten mit Klassen und Objekten f¨ ur uns hier interessant sind. Es wird n¨amlich bei Punkt 2 vom Compiler der Aufruf des entsprechenden Konstruktors eingesetzt, der der gew¨ unschten Initialisierung entspricht. Bei Punkt 4 wird der Aufruf des Destruktors eingesetzt. Haben wir es also mit einfachen (=nicht abgeleiteten) Klassen zu tun, so sieht der Lifecycle eines Objekts dieser Klasse genau folgendermaßen aus: 1. 2. 3. 4. 5.
Speicher reservieren. Entsprechenden Konstruktor aufrufen. Objekt lebt und mit ihm kann gearbeitet werden. Zu Ende der Lifetime Destruktor aufrufen. Speicher freigeben.
Das Prinzip des Overloadings gilt nat¨ urlich nicht nur f¨ ur Funktionen in C++, sondern auch f¨ ur Methoden. Da der Konstruktor eine solche darstellt, ist selbstverst¨ andlich auch hier ein Overloading m¨oglich. Man kann also verschiedene Konstruktoren mit verschiedenen Parameters¨atzen zur Verf¨ ugung stellen, die entsprechend sinnvollen Initialisierungsbed¨ urfnissen entsprechen. Eine gewisse Sonderstellung nimmt hierbei der sogenannte default Konstruktor ein, also der Konstruktor, der keinen Parameter entgegennimmt. Im weiter unten folgenden Beispiel werden wir diesen noch kurz genauer kennen lernen. Vorgreifend m¨ ochte ich auch noch erw¨ahnen, dass eine ¨ahnliche Sonderstellung vom sogenannten Copy Konstruktor eingenommen wird. Dieser wird gesondert in Abschnitt 9.2.2 behandelt um hier nicht f¨ ur Verwirrung zu sorgen. Im Gegensatz zum Konstruktor ist ein Overloading des Destruktors nicht m¨oglich. Es kann ausschließlich immer nur genau einen Destruktor geben, n¨amlich den, der keine Parameter entgegennimmt. Warum das so ist, ist auch leicht einzusehen: Der Aufruf des Destruktors wird vom Compiler am Ende der Lifetime eines Objekts eingesetzt. Bei einer auto-Variable also beim Verlassen des Blocks, in dem sie definiert wurde. Wie soll nun der
180
9. Klassen in C++
Compiler wissen, welche von verschiedenen M¨oglichkeiten des Destruktors er dort einsetzen soll? Vor allem gibt es ja auch keine M¨oglichkeit, ihm mitzuteilen, welche Parameterwerte u ussten, ¨berhaupt eingesetzt werden m¨ sollte ein Destruktor irgendwelche Parameter entgegennehmen k¨onnen. Der Compiler bestimmt ja, wann die Lifetime einer Variable zu Ende ist, dies wird (außer bei dynamischer Memory Verwaltung) niemals von den Entwicklern explizit angegeben. Wie im Rahmen des Life-Cycles eines Objekts die Aufrufe von Konstruktoren und Destruktor passieren, l¨ asst sich am einfachsten an einem Beispiel demonstrieren (simple_constr_destr_demo.cpp): 1
// s i m p l e c o n s t r d e s t r d e m o . cpp − c o n s t r u c t o r and d e s t r u c t o r demo
2 3
#include < i o s t r e a m>
4 5 6
using s t d : : cout ; using s t d : : e nd l ;
7 8 9 10 11 12 13 14
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ SimpleConstrDestrDemo ∗ ∗ Simple demo c l a s s f o r c o n s t r u c t o r s and d e s t r u c t o r ∗ ∗/
15 16 17 18 19 20 21 22 23 24 25 26
c l a s s SimpleConstrDestrDemo { private : int j u s t a n i n t v a l u e ; const char ∗ j u s t a s t r i n g v a l u e ; public : SimpleConstrDestrDemo ( ) ; SimpleConstrDestrDemo ( int a param ) ; SimpleConstrDestrDemo ( const char ∗ a param ) ; ˜ SimpleConstrDestrDemo ( ) ; };
27 28 29 30
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e f a u l t c o n s t r u c t o r ∗/
31 32 33 34 35 36 37
SimpleConstrDestrDemo : : SimpleConstrDestrDemo ( ) { cout << ” SimpleConstrDestrDemo : d e f a u l t c o n s t r u c t o r ” << e n d l ; j u s t a n i n t v a l u e = 0; j u s t a s t r i n g v a l u e = ” default constructed ” ; }
38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param a param j u s t an i n t e g e r parameter f o r demo purposes ∗/
43 44 45 46 47 48
SimpleConstrDestrDemo : : SimpleConstrDestrDemo ( int a param ) { cout << ” SimpleConstrDestrDemo : c o n s t r u c t o r with i n t ” << e n d l ; j u s t a n i n t v a l u e = a param ; j u s t a s t r i n g v a l u e = ” int constructed ” ;
9.2 Einfache Klassen
49
181
}
50 51 52 53 54
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param a param j u s t a s t r i n g parameter f o r demo purposes ∗/
55 56 57 58 59 60 61
SimpleConstrDestrDemo : : SimpleConstrDestrDemo ( const char ∗ a param ) { cout << ” SimpleConstrDestrDemo : c o n s t r u c t o r with s t r i n g ” << e nd l ; j u s t a n i n t v a l u e = 0; j u s t a s t r i n g v a l u e = a param ; }
62 63 64 65
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
66 67 68 69 70 71 72
SimpleConstrDestrDemo : : ˜ SimpleConstrDestrDemo ( ) { cout << ” SimpleConstrDestrDemo : d e s t r u c t o r ” << e nd l ; cout << ” j u s t a n i n t v a l u e : ” << j u s t a n i n t v a l u e << ” , j u s t a s t r i n g v a l u e : ” << j u s t a s t r i n g v a l u e << e n d l ; }
73 74 75 76 77 78 79 80
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { SimpleConstrDestrDemo t e s t v a r 1 ; // d e f a u l t c o n s t r u c t i o n SimpleConstrDestrDemo t e s t v a r 2 ( 1 2 ) ; // c o n s t r u c t i o n with i n t SimpleConstrDestrDemo t e s t v a r 3 ( ”abc” ) ; // c o n s t r u c t i o n with s t r i n g
81
cout << ” a l l o b j e c t s i n s t a n t i a t e d ” << e nd l ; // whatever can be done with the v a r i a b l e s i s done h e r e
82 83 84
cout << ”now the end o f the o b j e c t s ’ l i f e t i m e s i s reached ” << e n d l ;
85 86
return ( 0 ) ; // h er e the l i f e t i m e o f the v a r i a b l e s ends
87 88
}
Compiliert man dieses Programm und l¨asst es laufen, dann bekommt man ¨ etwas Ahnliches wie den folgenden Output: SimpleConstrDestrDemo : d e f a u l t c o n s t r u c t o r SimpleConstrDestrDemo : c o n s t r u c t o r with i n t SimpleConstrDestrDemo : c o n s t r u c t o r with s t r i n g a l l objects instantiated now the end o f the o b j e c t s ’ l i f e t i m e s i s reached SimpleConstrDestrDemo : d e s t r u c t o r j u s t a n i n t v a l u e : 0 , j u s t a s t r i n g v a l u e : abc SimpleConstrDestrDemo : d e s t r u c t o r j u s t a n i n t v a l u e : 1 2 , j u s t a s t r i n g v a l u e : int constructed SimpleConstrDestrDemo : d e s t r u c t o r j u s t a n i n t v a l u e : 0 , j u s t a s t r i n g v a l u e : default constructed
¨ Ein kleiner Exkurs: Ich habe hier bewusst etwas Ahnliches geschrieben, denn die Reihenfolge des Zerst¨ orens von Objekten, die ihre Lifetime beendet haben, ist nicht zwingend im Standard niedergeschrieben. Aus diesem Grund kann sich f¨ ur die Reihenfolge der Outputs der einzelnen Destruktoren auch, je nach Compiler, ein anderes Bild ergeben. In unserem Fall ist es so, dass das
182
9. Klassen in C++
Objekt, das zuletzt angelegt wurde, als Erstes zerst¨ort wird. Dies ist auch das normale Verhalten, das praktisch alle Compiler zeigen werden, denn die autoVariablen liegen ja auf dem Stack und werden entsprechend beim Abbauen desselben zerst¨ ort. ¨ Noch ein kleiner Exkurs: Aus Gr¨ unden der Ubersichtlichkeit habe ich hier die Deklaration der Klasse zusammen mit der Definition der Methoden gemeinsam in ein Source File gesteckt. Dies ist im Entwickleralltag nat¨ urlich keinesfalls nachzuahmen. Dort ist eine saubere Aufteilung in .h (f¨ ur die Klassendeklaration) und .cpp Files (f¨ ur die Definition der Methoden) in jedem Fall vorzunehmen. Nach diesen beiden kleinen Exkursen zur¨ uck zum Thema: In den Zeilen 22–24 des Programms findet man die Deklarationen der verschiedenen m¨oglichen Konstruktoren f¨ ur die Klasse SimpleConstrDestrDemo. Man kann also Objekte auf eine der dadurch festgelegten Arten anlegen: • Ohne Parameter, wobei der default Konstruktor aus Zeile 22 aufgerufen wird. • Mit einem int Parameter, wobei der Konstruktor aus Zeile 23 aufgerufen wird. • Mit einem char * als Parameter, wobei der Konstruktor aus Zeile 24 aufgerufen wird. Die Konstruktoren werden gem¨ aß den Regeln des Overloadings, die wir bereits in Kapitel 5 kennen gelernt haben, vom Compiler ausgew¨ahlt. Sollte ¨ sich keine sinnvolle Ubereinstimmung ergeben, dann f¨ uhrt dies nat¨ urlich zu einem Fehler. Es ist aufgrund des zuvor skizzierten Lifecycles von Objekten garantiert, dass ein Konstruktor erst aufgerufen wird, wenn auf jeden Fall schon der Speicher f¨ ur das Objekt reserviert wurde. Man hat es also innerhalb des Konstruktors mit einem angelegten, aber nicht initialisierten Objekt zu tun. Es wurde bereits erw¨ ahnt, dass der default Konstruktor eine gewisse Sonderstellung einnimmt. In Zeile 78 sieht man auch, was es damit auf sich hat: Man legt einfach eine Variable der gew¨ unschten Klasse an, ohne irgendwelche runden Klammern dahinter zu schreiben. Im Gegensatz dazu gibt man bei Initialisierungen mittels anderer Konstruktoren die Initialisierungsparameter in runden Klammern hinter dem Variablennamen an, wie man in den Zeilen 79 und 80 sieht. Vorsicht Falle: Die Sonderbehandlung des default Konstruktors, der ohne runde Klammern geschrieben wird, stellt eine kleine Inkonsistenz bez¨ uglich der Intuitivit¨ at und der Einheitlichkeit von Code dar. Aus diesem Grund passiert es C++ Neulingen nur allzu leicht, dass sie Statements wie z.B. das folgende schreiben: SimpleConstrDestrDemo testvar1();
9.2 Einfache Klassen
183
Leider wird dies aber vom Compiler nicht so interpretiert, dass man eine Variable testvar1 anlegen will und dazu den default Konstruktor verwenden will. Stattdessen interpretiert dies der Compiler als Funktionsdeklaration und entsprechend sehen dann auch die Fehlermeldungen sowohl des Compilers als auch danach des Linkers aus. Diese Fehlermeldungen sind auch zumeist kryptisch genug, dass man nicht sofort auf den tats¨achlichen Fehler kommt, n¨amlich, dass die runden Klammern nicht angebracht waren. Der default Konstruktor hat noch eine weitere Sonderstellung in C++: Es gibt auch die M¨ oglichkeit, f¨ ur eine Klasse keinen einzigen Konstruktor zu deklarieren und definieren. In diesem Fall wird vom Compiler implizit ein default Konstruktor erzeugt, der folgende Eigenschaften hat: • F¨ ur alle Members, denen selbst benutzerdefinierte Datentypen (=Klassen) zugrunde liegen (also keine primitiven Datentypen wie z.B. int, etc.) wird die default Konstruktion veranlasst und damit kommt es zu einer Initialisierung derselben. • F¨ ur alle Members, die primitive Datentypen sind, wird keine implizite Initialisierung vorgenommen. Diese Members enthalten also damit rein zuf¨ allige Werte. Der Grund f¨ ur dieses Verhalten ist die Angst vor unn¨otigem Runtime-Overhead durch Initialisierungsschritte. Meiner Meinung nach allerdings ist der entstehende Overhead ein viel geringeres Problem als die Gefahr, die man sich durch das Fehlen einer Initialisierung einhandelt. Vorsicht Falle: Es wird als guter Programmierstil angesehen, jeder Klasse einen default Konstruktor zu spendieren, der auf jeden Fall alle Members dieser Klasse korrekt initialisiert. Verl¨asst man sich auf den vom Compiler eingesetzten default Konstruktor, so kann es aus den zuvor genannten ¨ Gr¨ unden zu b¨ osen Uberraschungen kommen! Obwohl bereits implizit gesagt, m¨ ochte ich eine Eigenschaft des vom Compiler eingesetzten default Konstruktors hier noch einmal explizit festhalten: Er wird ausschließlich dann erzeugt, wenn f¨ ur eine Klasse u ¨ berhaupt kein Konstruktor deklariert wurde. Sollte auch nur ein einziger Konstruktor deklariert worden sein, egal, welche Parameter dieser nimmt, dann wird der default Konstruktor nicht erzeugt. Dadurch erreicht man, dass man als Designer von Klassen Entwickler zwingen kann, eine explizite Initialisierung vorzunehmen. Je nach Anwendungsfall kann dies auch ausgesprochen notwendig sein. Was f¨ ur den default Konstruktor gilt, gilt auch f¨ ur den Destruktor: Wird explizit kein Destruktor deklariert und definiert, so wird vom Compiler einer eingesetzt. Auch hier wird einfach nur Code erzeugt, der daf¨ ur sorgt, dass Members benutzerdefinierter Datentypen korrekt inklusive Destruktoraufrufen zerst¨ ort werden. Primitive Datentypen werden nicht angegriffen. Hat
184
9. Klassen in C++
man also einen Member, der dynamisch allokierten Speicher h¨alt, so wird dieser nicht automatisch freigegeben. Es wird garantiert, dass der Destruktor aufgerufen wird, solange f¨ ur das Objekt noch vollst¨ andig Speicher reserviert ist. Das Freigeben des Speichers erfolgt erst danach. Damit kann man auf jeden Fall innerhalb des Destruktors gefahrlos auf alle Members des Objekts zugreifen. Vorsicht Falle: Es wird als guter Programmierstil angesehen, jeder Klasse auf jeden Fall einen Destruktor zu spendieren, auch wenn dieser im schlimmsten Fall leer ist. Durch einen leeren Destruktor hat man zumindest offiziell festgelegt, dass nichts Besonderes zu tun ist. Vergisst man z.B. das explizite Aufr¨ aumen von dynamisch allokiertem Speicher, dann bekommt man ein wachsendes Programm, was wiederum f¨ ur einige schlaflose N¨achte der Entwickler sorgt. Wenn man sich angew¨ohnt, immer einen Destruktor zu deklarieren und definieren, dann erinnert einen ein solcher bei einer sp¨ate¨ ren Anderung des Klassencodes (z.B. Einf¨ uhren einer neuen dynamischen Variable) sofort beim Ansehen der Klassendeklaration daran, dass hier noch etwas zu tun w¨ are. Somit ist die Gefahr des Vergessens auf jeden Fall etwas geringer :-). Nur f¨ ur Leser, die diese Falle im Zuge des Nachschlagens lesen, ein kurzer Vorgriff (alle anderen k¨ onnen diesen Hinweis u ¨bergehen): Wir werden in sp¨aterer Folge noch eine besondere Eigenschaft des Destruktors kennen lernen, n¨ amlich, dass dieser unbedingt virtual sein muss. Auch dies ist in jedem Fall zu beachten, denn die reine Existenz eines Destruktors garantiert noch kein vollst¨ andig korrektes und funktionsf¨ahiges Programm! Der Vollst¨ andigkeit halber m¨ ochte ich hier noch erw¨ahnen, dass man sowohl Konstruktor als auch Destruktor nicht unbedingt public machen muss, sondern dass man sehr wohl auch andere Access Specifiers f¨ ur diese verwenden kann. Wir werden sp¨ ater noch im Zuge von Beispielen mehrere sehr sinnvolle Anwendungen daf¨ ur kennen lernen. Derzeit allerdings w¨ urde eine Diskussion dar¨ uber den Rahmen sprengen. 9.2.2 Der Copy Konstruktor Um die Sonderstellung des sogenannten Copy Konstruktors zu erkennen, sehen wir uns einmal folgenden Code an: (implicit_copy_constr_demo.cpp): 1 2
// i m p l i c i t c o p y c o n s t r d e m o . cpp − demo f o r an i m p l i c i t copy // constructor
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
9.2 Einfache Klassen
11 12 13 14 15 16
/∗ ∗ JustAClass ∗ ∗ j u s t a dummy c l a s s f o r demo purposes ∗ ∗/
17 18 19 20 21 22 23 24
c l a s s JustAClass { uint32 a value ; public : JustAClass ( ) ; JustAClass ( u i n t 3 2 v a l u e ) ; ˜ JustAClass ( ) ;
25
u i n t 3 2 getValue ( ) ;
26 27
};
28 29 30 31 32 33 34 35 36
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e f a u l t c o n s t r u c t o r ∗/ JustAClass : : JustAClass ( ) { cout << ” JustAClass : d e f a u l t c o n s t r u c t o r ” << e n d l ; a value = 0; }
37 38 39 40 41 42 43 44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ s p e c i a l c o n s t r u c t o r ∗/ JustAClass : : JustAClass ( u i n t 3 2 v a l u e ) { cout << ” JustAClass : c o n s t r u c t o r with u i n t 3 2 ” << e n d l ; a value = value ; }
46 47 48 49 50 51 52 53
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/ JustAClass : : ˜ JustAClass ( ) { cout << ” JustAClass : d e s t r u c t o r ” << e nd l ; }
54 55 56 57 58 59 60 61
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ r e t u r n s the s t o r e d v a l u e ∗/ u i n t 3 2 JustAClass : : getValue ( ) { return ( a v a l u e ) ; }
62 63 64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { JustAClass t e s t v a r 1 ;
69 70 71
// t h i s c a l l s the copy c o n s t r u c t o r ! JustAClass t e s t v a r 2 ( t e s t v a r 1 ) ;
72 73
JustAClass t e s t v a r 3 ( 1 0 ) ;
74 75 76
// t h i s c a l l s the copy c o n s t r u c t o r too ! JustAClass t e s t v a r 4 ( t e s t v a r 3 ) ;
185
186
9. Klassen in C++
77
cout << ”The ” testvar1 : ” testvar2 : ” testvar3 : ” testvar4 :
78 79 80 81 82
v a l u e s s t o r e d i n the v a r i a b l e s a r e : ” << e n d l << ” << t e s t v a r 1 . getValue () << e nd l << ” << t e s t v a r 2 . getValue () << e nd l << ” << t e s t v a r 3 . getValue () << e nd l << ” << t e s t v a r 4 . getValue () << e nd l ;
83
return ( 0 ) ;
84 85
}
Die Deklaration der Klasse in den Zeilen 18–27 und deren Definition darunter stellen noch nichts Neues dar. Das Anlegen von Variablen in den Zeilen 68 und 73 ist auch noch nicht außergew¨ ohnlich, aber ein Blick auf die Zeilen 71 und 76 verr¨ at, dass hier etwas im Gange sein muss, bei dem es nicht ganz mit rechten Dingen zugeht. Auch der Output des Programms gibt dieser Vermutung Recht, denn es werden hier definitiv nur zwei Aufrufe des Konstruktors ausgewiesen anstatt vier. Dass aber sicher vier Objekte angelegt werden, sieht man am Output der Destruktoren: JustAClass : d e f a u l t c o n s t r u c t o r JustAClass : c o n s t r u c t o r with u i n t 3 2 The v a l u e s s t o r e d i n the v a r i a b l e s a r e : testvar1 : 0 testvar2 : 0 testvar3 : 10 testvar4 : 10 JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r
Es scheinen also irgendwie die omin¨ osen Initialisierungen in den Zeilen 71 und 76 besondere Wirkung zu haben. Dieser Verdacht best¨atigt sich, wenn man sich ansieht, welchen Wert testvar4 hat. Dieser ist n¨amlich identisch zu dem von testvar3 und die Initialisierung von testvar4 in Zeile 76 spricht ja auch genau daf¨ ur, dass das so sein sollte. Jedoch haben wir ja gar keinen Konstruktor zu Verf¨ ugung, der als Parameter ein Objekt vom Typ JustAClass entgegennehmen w¨ urde. Wie also kommt der Wert in das Objekt? Ganz einfach: u ¨ber den speziellen Copy Konstruktor, der in unserem Beispiel vom Compiler implizit erzeugt wurde. Dass der Compiler bei der Erzeugung desselben nur sehr naiv vorgehen kann und eine primitive shallow Copy des Objekts durchf¨ uhrt, versteht sich von selbst. Dass dies in vielen F¨ allen gar nicht so toll ist, ist auch einleuchtend. Aber zum Gl¨ uck kann man hier eingreifen und selbst einen Co¨ py Konstruktor definieren. Andern wir also unser Beispiel entsprechend ab (explicit_copy_constr_demo.cpp): 18 19 20 21 22 23 24
c l a s s JustAClass { uint32 a value ; public : JustAClass ( ) ; JustAClass ( u i n t 3 2 v a l u e ) ; JustAClass ( const JustAClass & s r c ) ;
9.2 Einfache Klassen
187
˜ JustAClass ( ) ;
25 26
u i n t 3 2 getValue ( ) ;
27 28
};
Ich habe hier bewusst nicht noch einmal das gesamte Programm abgedruckt, ¨ sondern m¨ ochte nur auf die Anderungen eingehen. In Zeile 24 sieht man, wie ein Copy Konstruktor definiert ist: Er nimmt als Parameter eine Referenz auf ein Objekt vom “eigenen” Typ. Dass dieser Referenzparameter sinnigerweise eine const Referenz sein muss, ist auch einleuchtend, denn wie sollte sonst eine Kopie eines konstanten Objekts erstellt werden? Die Implementation des Copy Konstruktors sieht dann nat¨ urlich so aus: 51 52 53 54 55
JustAClass : : JustAClass ( const JustAClass & s r c ) { cout << ” JustAClass : copy c o n s t r u c t o r ” << e n d l ; a value = src . a value ; }
Dass nun der Compiler wirklich unseren explizit definierten Copy Konstruktor einsetzt, zeigt sich am Output des ge¨anderten Programms: JustAClass : d e f a u l t c o n s t r u c t o r JustAClass : copy c o n s t r u c t o r JustAClass : c o n s t r u c t o r with u i n t 3 2 JustAClass : copy c o n s t r u c t o r The v a l u e s s t o r e d i n the v a r i a b l e s a r e : testvar1 : 0 testvar2 : 0 testvar3 : 10 testvar4 : 10 JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r
Vorsicht Falle: Ein leider sehr typischer Fehler, der vor allem C++ Neulinge schon oft zur Verzweiflung getrieben hat, findet sich in folgendem Szenario: • Eine Klasse besitzt Members, die durch einen vom Compiler implizit generierten Copy Konstruktor nicht korrekt behandelt werden k¨onnen (z.B. Strings). • Diese Klasse besitzt jedoch keinen expliziten Copy Konstruktor, weil man diesen sowieso nirgends verwendet. • Ein Entwickler schreibt eine Methode, in der einer der Parameter ein Objekt vom Typ dieser Klasse ist (keine Referenz und auch kein Pointer, sondern wirklich einfach ein Objekt). Und schon friert dem Entwickler beim Testen dieser Methode das L¨acheln ein, weil das Programm zu den ungew¨ohnlichsten Zeitpunkten in Bodenh¨ohe fliegt!
188
9. Klassen in C++
Die Antwort, warum das passiert, findet sich in der Tatsache, dass in C++ Aufrufe von Methoden ja mittels call-by-value stattfinden. Das bedeu¨ tet, dass bei der Ubergabe des Objekts eine Kopie erstellt wird! Hierbei wird nat¨ urlich mangels eines expliziten Copy Konstruktors der implizite, vom Compiler generierte, eingesetzt. Diese m¨ orderische Gefahrenquelle l¨asst sich in ihren verschiedensten Auspr¨agungen einzig und allein durch konsequentes Einhalten der folgenden Konvention in den Griff bekommen: • Jede Klasse muss einen expliziten Copy Konstruktor besitzen!!!!! • Sollte es absolut nicht erw¨ unscht sein, dass der Copy Konstruktor aufgerufen wird, so ist dieser private zu deklarieren. Keinesfalls darf er aber weggelassen werden! Um nun b¨ osen Kommentaren vorzubeugen, dass ich mich in diesem Buch selbst nicht an diese Konvention halten w¨ urde: Je nachdem, was ein Codebeispiel in diesem Buch demonstrieren soll, habe ich immer versucht, mich auf das Wesentliche zu beschr¨ anken, das der Demonstration dient. Hierbei wurde in einigen F¨ allen auf gewissen Ballast verzichtet, um nicht unn¨otige Verwirrung zu stiften. Manchmal war auch der Copy Konstruktor ein solcher Ballast. In der Praxis allerdings bin ich bei solchen Konventionen tats¨achlich immer konsequent :-).
9.2.3 Initialisierung vs. Zuweisung Manche Leser m¨ ogen schon manchmal den Kopf dar¨ uber gesch¨ uttelt haben, dass ich so pingelig auf dem Begriff der Initialisierung herumreite und betone, dass eine Initialisierung nicht dasselbe ist, wie eine Zuweisung. Am folgenden Beispiel l¨ asst sich sehr einfach erkennen, dass der Compiler meine Meinung teilt und ebenso pingelig ist :-). Nehmen wir wieder unsere Klasse JustAClass von zuvor (die Variante incl. Copy Konstruktor) und schreiben main folgendermaßen um (initialization_demo.cpp): 76 77 78 79
int main ( int a r g c , char ∗ argv [ ] ) { JustAClass t e s t v a r 1 = 1 0 ; JustAClass t e s t v a r 2 = t e s t v a r 1 ;
80
cout << ”The v a l u e s s t o r e d i n the v a r i a b l e s a r e : ” << e n d l << ” t e s t v a r 1 : ” << t e s t v a r 1 . getValue () << e nd l << ” t e s t v a r 2 : ” << t e s t v a r 2 . getValue () << e nd l ;
81 82 83 84
return ( 0 ) ;
85 86
}
In Zeile 78 sieht man, dass hier eine Initialisierung mit 10 stattfindet, in Zeile 79 handelt es sich um eine Initialisierung mit testvar1. Wieso man
9.2 Einfache Klassen
189
diese beiden Zeilen u ¨berhaupt so hinschreiben kann, verr¨at uns der Output des Programms: JustAClass : c o n s t r u c t o r with u i n t 3 2 JustAClass : copy c o n s t r u c t o r The v a l u e s s t o r e d i n the v a r i a b l e s a r e : testvar1 : 10 testvar2 : 10 JustAClass : d e s t r u c t o r JustAClass : d e s t r u c t o r
Zeile 78 resultiert in einem Aufruf unseres speziellen Konstruktors mit dem uint32 Parameter und Zeile 79 resultiert im Aufruf unseres Copy Konstruktors. Der Compiler sucht also definitiv bei einer Initialisierung nach einem entsprechenden Konstruktor. Gibt es einen solchen, so wird er eingesetzt, gibt es ihn nicht, dann resultiert dies in einem Compilerfehler! Wir k¨onnen also bei Konstruktoren, die nur einen Parameter nehmen, wahlweise die KlammerSchreibweise oder die = Schreibweise verwenden. Das Resultat ist dasselbe. Im Rahmen des Operator Overloadings wird der Unterschied zwischen Initialisierung und Zuweisung noch einmal in einem anderen Kontext aufgegriffen (siehe Abschnitt 12.1). 9.2.4 Deklarieren von Konstruktoren als explicit Es gibt F¨ alle, in denen das implizite Einsetzen eines bestimmten Konstruktors im Rahmen einer Initialisierung mittels = als zu gef¨ahrlich und fehlertr¨achtig erscheint. Das folgende Beispiel demonstriert diesen Umstand (implicit_constructor_problem.cpp): 1 2
// i m p l i c i t c o n s t r u c t o r p r o b l e m . cpp − demo f o r problems with // implicit constructors
3 4 5 6
#include < i o s t r e a m> #include < c s t r i n g> #include ” u s e r t y p e s . h”
7 8 9
using s t d : : cout ; using s t d : : e nd l ;
10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DummyString ∗ ∗ j u s t a dummy s t r i n g c l a s s f o r demo purposes ∗ ∗/
18 19 20 21 22 23 24 25 26 27 28
c l a s s DummyString { char ∗ s t r i n g ; uint32 length ; public : DummyString ( u i n t 3 2 l e n g t h ) ; DummyString ( const char ∗ s t r ) ; DummyString ( const DummyString & s r c ) ; ˜DummyString ( ) ; };
190
9. Klassen in C++
29 30 31 32 33 34 35 36 37 38
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ s p e c i a l c o n s t r u c t o r ∗/ DummyString : : DummyString ( u i n t 3 2 l e n g t h ) { cout << ”DummyString : c o n s t r u c t o r with l e n g t h ” << e n d l ; length = length ; s t r i n g = new char [ l e n g t h ] ; }
39 40 41 42 43 44 45 46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ s p e c i a l c o n s t r u c t o r ∗/ DummyString : : DummyString ( const char ∗ s t r ) { cout << ”DummyString : c o n s t r u c t o r with s t r i n g ” << e n d l ; // dangerous , but f o r demo purposes . . . length = strlen ( str ) ; s t r i n g = new char [ l e n g t h ] ; strcpy ( s t r i n g , s t r ) ; }
51 52 53 54 55 56 57 58 59 60 61 62
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ copy c o n s t r u c t o r ∗/ DummyString : : DummyString ( const DummyString & s r c ) { cout << ”DummyString : copy c o n s t r u c t o r ” << e nd l ; length = src . length ; s t r i n g = new char [ l e n g t h ] ; // dangerous , but f o r demo purposes . . . strcpy ( s t r i n g , src . s t r i n g ) ; }
63 64 65 66 67 68 69 70 71
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/ DummyString : : ˜ DummyString ( ) { cout << ”DummyString : d e s t r u c t o r ” << e n d l ; delete [ ] s t r i n g ; }
72 73 74 75 76 77 78 79 80 81
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { DummyString s t r i n g 1 = ” o t t o ” ; DummyString s t r i n g 2 = s t r i n g 1 ; // the f o l l o w i n g l i n e w i l l u s u a l l y be a problem . . . DummyString s t r i n g 3 = ’ x ’ ;
82
return ( 0 ) ;
83 84
}
Wir haben es in unserem Beispiel mit einer ganz simplen (und nat¨ urlich in der Praxis nicht verwendbaren) String Klasse namens DummyString zu tun. Dieser DummyString ist sinnigerweise direkt mit einem const char *, mit einer einfachen L¨ angenangabe oder mit einem anderen DummyString initialisierbar. Alles nett und sch¨ on, aber jetzt werfen wir einmal einen Blick auf Zeile 81: Offensichtlich hat hier jemand angenommen, dass man diesen
9.2 Einfache Klassen
191
String auch einfach mit einem char initialisieren kann. Der Compiler hat sich auch in keinster Weise dar¨ uber beschwert, der Output des Programms zeigt jedoch, dass hier nicht wirklich das passiert, was man gerne h¨atte: DummyString : DummyString : DummyString : DummyString : DummyString : DummyString :
c o n s t r u c t o r with s t r i n g copy c o n s t r u c t o r c o n s t r u c t o r with l e n g t h destructor destructor destructor
Sieht man sich die dritte Zeile des Outputs an, so wird doch glatt hier der Konstruktor aufgerufen, der als Parameter die L¨ange des Strings enth¨alt! Was aber wahrscheinlich beabsichtigt war, war die Initialisierung des Strings, so dass er einfach ein x enthalten sollte. Der Compiler, der von der Absicht der Entwickler keine Ahnung hat, hat das allerdings ganz anders gesehen: Er hat einen Konstruktor gesucht, der als Datentyp einen char nimmt. Diesen gibt es nicht. Sehr wohl aber gibt es einen Konstruktor, der dazu kompatibel ist, n¨amlich den Konstruktor, der die L¨ange als Parameter nimmt. Ein char ist ja eine Ganzzahl und der Konstruktor nimmt eine Ganzzahl. Also ist mit einer kleinen Typumwandlung alles bestens – oder, wie in unserem Fall hier, ganz und gar nichts mehr in Ordnung. Zum Gl¨ uck gibt es einen sauberen Weg, es gleich gar nicht zu einer solchen Gefahr kommen zu lassen: Man kann dem Compiler mitteilen, dass bestimmte Konstruktoren niemals implizit eingesetzt werden sollen, sondern nur explizit aufgerufen werden d¨ urfen. Das Zauberwort dazu nennt sich intuitiverweise explicit. Vorausschauende Entwickler w¨ urden also die String Klasse folgendermaßen deklarieren (forcing_explicit_constructor.cpp): 19 20 21 22 23 24 25 26 27 28
c l a s s DummyString { char ∗ s t r i n g ; uint32 length ; public : e x p l i c i t DummyString ( u i n t 3 2 l e n g t h ) ; DummyString ( const char ∗ s t r ) ; DummyString ( const DummyString & s r c ) ; ˜DummyString ( ) ; };
In Zeile 24 wurde der gef¨ ahrliche Konstruktor als explicit deklariert. Wie man an folgender Implementation von main sieht, ist hiermit die Fehlverwendung unterbunden, denn Zeile 81 w¨ urde in einem Compilerfehler resultieren: 76 77 78 79 80 81
int main ( int a r g c , char ∗ argv [ ] ) { DummyString s t r i n g 1 = ” o t t o ” ; DummyString s t r i n g 2 = s t r i n g 1 ; // the f o l l o w i n g l i n e would r e s u l t i n a c o m p i l e r e r r o r // DummyString s t r i n g 3 = ’ x ’ ;
82 83 84
// t h i s works , because o f the e x p l i c i t c a l l DummyString s t r i n g 4 ( 1 2 ) ;
192
9. Klassen in C++
85
return ( 0 ) ;
86 87
}
Zeile 84 jedoch, die einen expliziten Konstruktoraufruf enth¨alt, funktioniert wie geplant. Es sollte also beim Deklarieren von Konstruktoren f¨ ur eine Klasse bei allen Lesern immer daran gedacht werden, unter welchen Umst¨anden der Compiler f¨ ur ein Objekt einen impliziten Konstruktoraufruf einsetzt: • Bei Verwendung von = zur Initialisierung mit einem Wert wird ein passender Konstruktor gesucht. ¨ • Bei Ubergabe als Parameter an eine Methode oder Funktion aufgrund des call-by-value Mechanismus wird der Copy Konstruktor aufgerufen. • Bei Verwendung in einem return Statement wird der Copy Konstruktor aufgerufen, denn auch der return-Value wird ja by-value geliefert. • Bei Verwendung als Exception wird der Copy Konstruktor aufgerufen (dazu kommen wir noch in Kapitel 11). Sollte in irgendeinem dieser F¨ alle der implizite Aufruf gef¨ahrlich sein, so muss ein Konstruktor als explicit deklariert werden. 9.2.5 Object- und Class-Members In Abschnitt 8.3 wurde bereits erw¨ahnt, dass es auch sogenannte ClassMembers gibt. Dies sind solche Members, die allen Instanzen einer Klasse gemeinsam sind, anstatt f¨ ur jedes Objekt extra zu existieren. Solche Members (Variablen oder auch Methoden) deklariert man dadurch, dass man sie static setzt, wie folgende kleine Ver¨anderung unserer Kartenklasse f¨ ur das Memory Spiel zeigt. Diese protokolliert selbstt¨atig mit, wie viele Karten eigentlich im Programm insgesamt existieren (memory_game_card_v2.h): 1
// memory game card v2 . h − e x t e n s i o n o f c l a s s MemoryGameCard
2 3 4
#i f n d e f memory game card v2 h #define memory game card v2 h
5 6 7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MemoryGameCard ∗ ∗ model o f a card as used i n the game ”memory” ∗ ∗/
13 14 15 16 17 18 19 20 21
c l a s s MemoryGameCard { protected : s t a t i c unsigned n u m i n s t a n c e s ; char ∗ symbol ; public : MemoryGameCard( const char ∗ symbol ) ; ˜MemoryGameCard ( ) ;
9.2 Einfache Klassen
193
22
const char ∗ getSymbol ( ) ; void changeSymbol ( const char ∗ symbol ) ; s t a t i c unsigned getNumInstances ( ) ;
23 24 25 26
};
27 28 29
#endif // memory game card v2 h
In Zeile 17 sieht man, wie man eine Variable durch eine static Deklaration zum Class-Member macht. Wie allerdings bereits aus dem Begriff Deklaration klar hervorgeht, dient Zeile 17 nur zur Beruhigung des Compilers. Man sagt ihm, dass es einen Class-Member gibt und dass dieser schon irgendwo definiert sein w¨ urde. Die tats¨ achliche Definition muss man nat¨ urlich an irgendeiner Stelle im Programm auch noch hinschreiben. Dass dies nat¨ urlich nicht im Header geschehen darf, versteht sich von selbst. Ansonsten w¨ urde man ja Mehrfachdefinitionen provozieren, auf die der Linker gar nicht so freundlich zu sprechen ist. Also wird die Definition sinnigerweise im zur Klasse geh¨origen .cpp File stattfinden, wie wir in der Folge noch sehen werden. Auf num_instances_ kann man aus allen Methoden heraus in altbekannter Manier einfach zugreifen. Egal, aus welcher Instanz der Klasse heraus man zugreift, der Wert ist immer derselbe. Jedoch kann es passieren, dass man nicht immer eine Instanz einer Klasse zur Verf¨ ugung hat, um eine Methode darauf aufzurufen. Vielleicht will man einfach an irgendeiner Stelle im Programm wissen, wie viele Karten existieren, obwohl man an dieser Stelle gar keine Instanz der Karte in der Hand hat. Zu diesem Zweck deklariert man dann eine Class Member Methode. Diese ist, wie der Name schon sagt, auch nicht an eine Instanz gebunden, sondern nur an die Klasse selbst. Intuitiverweise deklariert man eine solche Methode gleich, wie es schon von der Variable her bekannt ist, als static. Dies sieht man in Zeile 25 unseres Programms. Was wir an unserer Implementation der Kartenklasse a¨ndern m¨ ussen, um die Anzahl der Instanzen auch wirklich zu z¨ahlen und in Zugriff zu haben, sehen wir an den folgenden Ausschnitten von memory_game_card_v2.cpp: 7
unsigned MemoryGameCard : : n u m i n s t a n c e s
= 0;
Wie bereits erw¨ ahnt, muss der static Member nicht nur deklariert, sondern auch definiert werden. Dies geschieht, wie zu erwarten, indem man einfach die Variable mit dem entsprechenden Scope versieht und damit die Definition vornimmt. Hierbei wird allerdings das Keyword static nicht mehr geschrieben, denn der Compiler weiß bereits aus der Deklaration um die Natur des Members und static w¨ urde einer weiteren Deklaration entsprechen. Wir wollen allerdings eine Definition erreichen. Dass in diesem Statement eine explizite Initialisierung auf 0 vorgenommen wird, soll nur demonstrieren, wie man eine Initialisierung auf einen speziellen Wert erreicht. In diesem Fall
194
9. Klassen in C++
ist sie nicht notwendig, da der Compiler bei Fehlen derselben implizit eine Initialisierung auf 0 einsetzt, wie bereits aus Abschnitt 2.2 bekannt ist. Um nun tats¨ achlich die Anzahl der vorhandenen Instanzen zu z¨ahlen, werden Konstruktor und Destruktor entsprechend modifiziert. Wir wissen bereits, dass bei Erstellen einer neuen Instanz gesichert der Konstruktor aufgerufen wird. Also kommt ihm die Aufgabe zu, den Wert von num_instances_ zu inkrementieren, wie man in der Folge in Zeile 17 sieht: 15 16 17 18 19 20 21 22 23 24 25
MemoryGameCard : : MemoryGameCard( const char ∗ symbol ) { n u m i n s t a n c e s ++; i f ( ! symbol ) { symbol = 0 ; return ; } symbol = new char [ s t r l e n ( symbol ) + 1 ] ; s t r c p y ( symbol , symbol ) ; }
Wir wissen auch, dass beim Zerst¨ oren eines Objekts der Destruktor gesichert aufgerufen wird. Dementsprechend kommt ihm nun die Aufgabe zu, den Instanzenz¨ ahler zu dekrementieren, wie man in der Folge in Zeile 35 sieht: 31 32 33 34 35 36
MemoryGameCard : : ˜ MemoryGameCard ( ) { i f ( symbol ) delete [ ] symbol ; n u m i n s t a n c e s −−; }
Wie bereits besprochen, wollen wir auch auf die Anzahl der Instanzen zugreifen k¨ onnen, ohne dass wir direkt ein Objekt vom Typ MemoryGameCard in der Hand haben. Dazu haben wir eine static Methode deklariert. Die Definition derselben im .cpp File sieht dann aus wie folgt: 73 74 75 76
unsigned MemoryGameCard : : getNumInstances ( ) { return ( n u m i n s t a n c e s ) ; }
Man kann leicht erkennen, dass f¨ ur static Methoden dasselbe gilt, wie f¨ ur static Variablen: Das Keyword static wird bei der Definition nicht mehr geschrieben. Wie man unsere modifizierte Klasse verwenden kann, zeigt das folgende Demoprogramm (memory_game_card_v2_test.cpp): 1
// memory game card v2 test . cpp − t e s t program f o r MemoryGameCard
2 3
#include < i o s t r e a m>
9.2 Einfache Klassen
4
195
#include ”memory game card v2 . h”
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { MemoryGameCard t e s t c a r d ( ”Symbol A” ) ;
12
cout << ”number o f c a r d s ( with : : o p e r a t o r ) : ” << MemoryGameCard : : getNumInstances () << e nd l ;
13 14 15
MemoryGameCard n e x t t e s t c a r d ( ”Symbol B” ) ;
16 17
cout << ”number o f c a r d s ( c a l l e d on n e x t t e s t c a r d ) : ” << n e x t t e s t c a r d . getNumInstances () << e nd l ;
18 19 20
cout << ”number o f c a r d s ( c a l l e d on t e s t c a r d ) : ” << t e s t c a r d . getNumInstances () << e nd l ;
21 22 23
return ( 0 ) ;
24 25
}
Zeile 14 zeigt, dass der Scope Operator nicht nur zur Zuordnung von Definitionen zu Structures oder Classes verwendet werden kann, sondern auch zum direkten Zugriff auf Members. Das Statement MemoryGameCard::getNumInstances() ist so zu lesen: Rufe die Class-Member Methode getNumInstances der Klasse MemoryGameCard auf. Diese Art des Aufrufs funktioniert nat¨ urlich nur mit Class Members, also mit static Methoden. W¨ urde man versuchen, eine Instance Member Methode (also einen “normalen” Member) auf diese Art und Weise aufzurufen, dann w¨ urde sich der Compiler beschweren. Woher sollte er denn auch wissen, auf welche Instanz der Klasse diese Methode aufgerufen werden sollte? ¨ Spinnt man diese Uberlegung weiter, so kommt man gleich auf eine weitere sehr wichtige Eigenschaft von static Methoden in Klassen: Sie k¨onnen ausschließlich auf static Variablen der Klasse zugreifen und niemals auf “normale” Members. Dies ist auch klar, denn im Kontext welcher Instanz w¨ urden sie denn agieren, wo sie doch allein u ¨ber den Scope Operator ohne Objekt aufrufbar sind? Spinnt man den Gedanken nun endg¨ ultig zu Ende, so sieht man, warum die Statements in den Zeilen 19 und 22 funktionieren. Man kann eine static Methode auch im Kontext einer Instanz aufrufen, allerdings muss man es nicht. Auch dieses Verhalten ist klar, denn durch einen solchen Aufruf weiß der Compiler, um welche Klasse es sich handelt und kann intern den korrekten Bezug zur Klasse herstellen. Die Information u ¨ber die besondere Instanz der Klasse wird intern verworfen, weil sie unn¨otig ist. Dass sich alle Instanzen einer Klasse einen static Member teilen, sieht man eindeutig, denn die Aufrufe von getNumInstances in den Zeilen 19 und 22 werden zwar im Kontext verschiedener Objekte ausgef¨ uhrt, das Resultat ist aber dasselbe, wie uns der folgende Output des Programms eindeutig zeigt:
196
9. Klassen in C++
number o f c a r d s ( with : : o p e r a t o r ) : 1 number o f c a r d s ( c a l l e d on n e x t t e s t c a r d ) : 2 number o f c a r d s ( c a l l e d on t e s t c a r d ) : 2
Der Vollst¨ andigkeit halber m¨ ochte ich noch erw¨ahnen, dass das entsprechende Makefile zum Erstellen des Programms auf der beiliegenden CD-ROM mitgeliefert wird und MemoryGameCardTestV2Makefile heißt. Mit diesem Beispiel wurden nun einmal in einem ersten Durchgang die Grundmechanismen der Deklaration, Definition und Verwendung von einfachen Klassen erkl¨ art. Bevor wir zu weiteren Features, wie z.B. Operator Overloading kommen, m¨ ochte ich das Bild im Sinne der Abhandlungen aus Kapitel 8 abrunden und das Prinzip der Ableitung n¨aher erl¨autern. Jedoch m¨ochte ich f¨ ur alle C++ Neulingen an dieser Stelle die Empfehlung aussprechen, das bisher Gelernte durch Ausprobieren an kleinen Beispielen wirklich zu verinnerlichen. Ansonsten ist die Gefahr sehr groß, sich in der Folge in ¨ Details zu verlieren und den Uberblick nicht mehr bewahren zu k¨onnen. Der Phantasie, was man ausprobieren k¨onnte, sind keine Grenzen gesetzt. Z.B. k¨onnte man sich an einer kleinen Klasse zum Speichern eines Datums, einer allgemeinen Array Klasse, einer kleinen String Klasse, die die so fehleranf¨allige direkte Verwendung des char * kapselt oder an mannigfaltigen andere kleinen Helferchen versuchen. Vorsicht Falle: Bei aller Eindringlichkeit, mit der ich Leser zum Spielen und Probieren anregen m¨ ochte, soll eine große Gefahr nicht unerw¨ahnt bleiben: Es fehlt mit dem bisher vermittelten Wissen noch sehr viel und sehr wichtiges Handwerkszeug, das man f¨ ur eine “echte” und saubere OO Entwicklung in C++ braucht. Keinesfalls also sollen sich Leser nun nach dem Motto “Ich weiß, wie das funktioniert” bereits jetzt an Utilities heranmachen, deren Einsatz in auslieferbaren Produkten geplant ist. Mit dem bisher Gelernten geht ein solches Unterfangen unter Garantie schief!
9.3 Abgeleitete Klassen Wie in Kapitel 8 besprochen, kann man Klassen von anderen Klassen ableiten und erbt damit ihre Eigenschaften. Dieser Mechanismus hilft, um entsprechend der Natur der in Software modellierten Welt Klassifikationsund Abstraktionsebenen einzuziehen. Bleiben wir bei unserem begonnenen Memory Spiel und werfen einen Blick auf die dortigen Gegebenheiten: • Karten werden nicht nur bei Memory verwendet, es gibt mannigfaltige Spiele, bei denen man es mit Karten zu tun hat. • Je nach Spiel haben die Karten verschiedene Eigenschaften. Grunds¨atzlich liegt allerdings eines in der Natur von Karten: Sie haben eine Vorder- und eine R¨ uckseite, wie diese auch immer beschaffen sind.
9.3 Abgeleitete Klassen
197
• Man kann naturgem¨ aß Karten auf die Vorderseite oder auf die R¨ uckseite legen und man kann sie auch umdrehen. Was sich hierbei ver¨andert, ist einfach die Seite, die sie zeigen. Aus Gr¨ unden der Einfachheit ignoriere ich hier einmal, dass man auch Kartenh¨auser bauen kann und damit beide Seiten sieht :-). • Im Fall unseres Memory Spiels wissen wir, dass wir eine spezielle Art von Karten verwenden, n¨ amlich solche, die auf der Vorderseite ein Symbol tragen und die alle gleich aussehen, wenn man die R¨ uckseite betrachtet. Es w¨are also nett, wenn man das folgende wirklichkeitsnahe Modell in Software gießen k¨ onnte: • Es gibt Karten. • Karten kann man hinlegen und zwar sowohl auf die Vorder- als auch auf die R¨ uckseite. • Was man von den Karten zu sehen bekommt, h¨angt von der Seite ab, auf der sie liegen. • Man kann Karten umdrehen. • Es gibt f¨ ur das Memory Spiel spezielle Karten. • Die speziellen Karten haben alle Eigenschaften der allgemeinen Karten, wie sie zuerst beschrieben wurden. • Die speziellen Karten haben auf der Vorderseite ein Symbol. • Die speziellen Karten haben alle ein und dieselbe R¨ uckseite (dass das mit derselben R¨ uckseite besser von außen bestimmt wird, als zu einer Klasseneigenschaft zu werden, ist eine eigene Geschichte :-)). Nach den Prinzipien aus Kapitel 8 sieht das Modell also folgendermaßen aus: • Es gibt eine Klasse Karten mit den zuvor erw¨ahnten Eigenschaften. • Es gibt eine Klasse Memory Karten. Diese sind Karten (=IS-A Relation) mit besonderen Zusatzeigenschaften. Was liegt also n¨ aher, als eine allgemeine Klasse Karte zu schreiben und danach eine spezielle Klasse Memory Karte zu implementieren, die von dieser allgemeinen Klasse abgeleitet ist? In dieser speziellen Klasse findet dann die Memory spezifische Funktionalit¨ at Platz, die zuvor erw¨ahnt wurde. Sehen wir uns eine solche Art der Implementation also einfach einmal in C++ an, um herauszufinden, wie diese Modellierung von der Sprache unterst¨ utzt wird. Unser Startpunkt ist die Deklaration der Basisklasse (game_card.h): 1
// game card . h − d e c l a r a t i o n o f a g e n e r a l card f o r games
2 3 4
#i f n d e f g a m e c a r d h #define g a m e c a r d h
5 6 7 8 9
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ GameCard ∗
198
10 11 12
9. Klassen in C++
∗ a g e n e r a l c l a s s f o r a card f o r games ∗ ∗/
13 14 15 16 17 18 19 20 21
c l a s s GameCard { protected : unsigned v i s i b l e s i d e ; public : // c o n s t a n t s f o r the v i s i b l e s i d e s t a t i c const unsigned FRONT SIDE = 0 x01 ; s t a t i c const unsigned BACK SIDE = 0x02 ;
22
GameCard( unsigned v i s i b l e s i d e ) ; ˜GameCard ( ) ;
23 24 25
void turnCard ( ) ; void putFrontSideUp ( ) ; void putBackSideUp ( ) ; unsigned g e t V i s i b l e S i d e ( ) ; const char ∗ getDisplayRep ( ) ;
26 27 28 29 30 31
};
32 33 34
#endif // g a m e c a r d h
Die einzige Neuigkeit, die hier zu finden ist, ist die Deklaration von Konstanten in den Zeilen 20 und 21. Diese werden als static const Members deklariert. So weit w¨ are das alles ja noch nicht besonders verwunderlich. Warum aber wird ihnen hier gleich ihr Wert zugewiesen, wo es sich doch nur um eine Deklaration handelt und nicht um eine Definition? Diese Art der Initialisierung funktioniert, wenn es sich um static const Members, also praktisch um reine Konstanten handelt, denn bei diesen ist klar, dass sich deren Wert ja niemals a ¨ndern darf. Damit hat der Compiler sogar die M¨oglichkeit, direkt die entsprechenden Werte in den Code einzusetzen, anstatt immer u ¨ber einen Speicherzugriff zu arbeiten. Wie wir in der Folge sehen werden, darf aber trotzdem nicht auf die entsprechende Definition im zugeh¨origen .cpp File vergessen werden, sonst ist der Linker gar nicht gl¨ ucklich mit dem Programm, da er die entsprechenden Referenzen nicht aufl¨osen kann. Diese direkte Wertvergabe im Header ist ausschließlich f¨ ur static const Members zul¨assig. Sollte ein Member entweder nicht static oder nicht const oder beides nicht sein, dann f¨ uhrt eine solche Initialisierung zu einem Compilerfehler, weil dieser dann den Member nicht mehr als echte, unver¨anderbare Konstante betrachtet. In solchen F¨ allen muss die Initialisierung wie gewohnt im Rahmen der Definition stattfinden. Diese Art der Definition von Konstanten, die Members einer Klasse sind, ist die sauberste M¨ oglichkeit, Namenskonflikte zwischen verschiedenen Konstanten in verschiedenen Klassen zu verhindern. Ansprechbar sind solche Konstanten dann wie zu erwarten u ¨ber direkte Angabe der Klasse mittels Scope Operator, also z.B. als GameCard::FRONT_SIDE
9.3 Abgeleitete Klassen
199
Die deklarierten Methoden der Klasse repr¨asentieren, was man mit einer Karte machen kann. Man kann sie umdrehen, man kann die Vorderseite oder die R¨ uckseite explizit nach oben drehen, man kann erfahren, welche die gerade sichtbare Seite ist (und bekommt eine der vordefinierten Konstanten geliefert) und man kann um einen String zur Darstellung bitten. Der String zur Darstellung wird nat¨ urlich entweder die Repr¨asentation der Vorder- oder der R¨ uckseite sein, je nachdem, welche gerade nach oben zeigt. Die Implementation von GameCard sieht dann folgendermaßen aus (game_card.cpp): 1
// game card . cpp − implementation o f GameCard
2 3
#include ” game card . h”
4 5 6 7
// s t a t i c c o n s t member d e f i n i t i o n const unsigned GameCard : : FRONT SIDE; const unsigned GameCard : : BACK SIDE ;
8 9 10 11
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r ∗/
12 13 14 15 16 17
GameCard : : GameCard( unsigned v i s i b l e s i d e ) { // no good p r a c t i c e to a s s i g n without check , but f o r demo . . . visible side = visible side ; }
18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
22 23 24 25 26
GameCard : : ˜ GameCard ( ) { // nothing to be done h e r e }
27 28 29 30
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ t u r n s the card around ∗/
31 32 33 34 35 36
void GameCard : : turnCard ( ) { v i s i b l e s i d e = ( v i s i b l e s i d e == FRONT SIDE) ? BACK SIDE : FRONT SIDE; }
37 38 39 40
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ l a y s the card down with the f r o n t s i d e up ∗/
41 42 43 44 45
void GameCard : : putFrontSideUp ( ) { v i s i b l e s i d e = FRONT SIDE ; }
46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ l a y s the card down with the back s i d e up ∗/
50 51
void GameCard : : putBackSideUp ( )
200
52
9. Klassen in C++
{ visible side
53 54
= BACK SIDE ;
}
55 56 57 58
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ r e t u r n s the v i s i b l e (=up ) s i d e o f the card ∗/
59 60 61 62 63
unsigned GameCard : : g e t V i s i b l e S i d e ( ) { return ( v i s i b l e s i d e ) ; }
64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the d i s p l a y r e p r e s e n t a t i o n o f the card ∗/
69 70 71 72 73 74 75
const char ∗GameCard : : getDisplayRep ( ) { // very bad p r a c t i c e , but the way to handle t h i s c o r r e c t l y // i s not known yet ! return ( 0 ) ; }
In den Zeilen 6 und 7 sieht man, dass die static const Members der Klasse sehr wohl definiert werden m¨ ussen, um den Linker zu beruhigen, dass aber hierbei keine explizite Initialisierung auf bestimmte Werte mehr stattfindet. Die Initialwerte sind ja bereits im Header vorgegeben. W¨ urde man sie hier nochmals explizit hinschreiben, dann w¨ urde sich der Compiler mittels Fehlermeldung beschweren, dass man ihn doch bitte in Ruhe lassen soll, denn er weiß ja sowieso schon, welche Werte die Konstanten zu bekommen haben. Die Implementation der Methode getDisplayRep in den Zeilen 70–75 retourniert einen 0-Pointer, denn die Basisklasse besitzt keine Darstellung und w¨ usste dementsprechend auch nicht, was sie darstellen sollte. Diese Art der Implementation ist absolut nicht f¨ ur die Praxis geeignet und im Prinzip ein b¨oser Hack, jedoch ist bisher der Mechanismus noch nicht bekannt, durch den dies vermieden werden kann. Bisher haben wir nicht viel Neues kennen gelernt, vor allem noch immer nicht, wie man eigentlich eine Ableitung von einer Klasse in C++ realisiert. Das ¨andert sich jetzt mit der speziellen Klasse von Karten f¨ ur das Memory Spiel, die von der Basis GameCard abgeleitet ist. Deren Deklaration sieht folgendermaßen aus (memory_game_card_v3.h): 1
// memory game card v3 . h − d e r i v e d implementation o f MemoryGameCard
2 3 4
#i f n d e f memory game card v3 h #define memory game card v3 h
5 6
#include ” game card . h”
7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MemoryGameCard ∗ ∗ model o f a card as used i n the game ”memory”
9.3 Abgeleitete Klassen
13 14
201
∗ ∗/
15 16 17 18 19 20 21 22 23 24
c l a s s MemoryGameCard : public GameCard { protected : char ∗ f r o n t s y m b o l ; char ∗ back symbol ; public : MemoryGameCard( const char ∗ f r o n t s y m b o l , const char ∗ back symbol = 0 ) ; ˜MemoryGameCard ( ) ;
25
const char ∗ getDisplayRep ( ) ; // o v e r r i d e o f base c l a s s method
26 27
};
28 29
#endif // memory game card v3 h
In Zeile 16 zeigt sich des Pudels Kern, wie eine Ableitung zu formulieren ist: Man schreibt einfach einen Doppelpunkt hinter den Klassennamen der neu deklarierten Klasse und hinter dem Doppelpunkt f¨ uhrt man die Basisklasse an, also in unserem Fall GameCard. Und wof¨ ur steht der Access Specifier public nun in diesem Kontext? Ganz einfach: Wenn man ableitet, dann spezifiziert man auch, ob Entwickler, die die Klasse verwenden, die Basisklasse u ¨berhaupt explizit zu Gesicht bekommen oder nicht. Es wurde bereits diskutiert, dass Ableiten bedeutet, dass man alle Eigenschaften der Basisklasse erbt. Unsere Klasse MemoryGameCard erbt also in diesem Fall alle Methoden und Membervariablen und ist damit automatisch eine vollst¨andige GameCard. Wenn man, so wie hier, public ableitet, dann werden auch alle Methoden der Basisklasse mit ihren dort definierten Sichtbarkeiten nach außen weitergereicht. Es ist also z.B. m¨ oglich, die Methode turnCard auf einem Objekt vom Typ MemoryGameCard von außen aufzurufen. W¨ urde die Ableitung nun nicht public, sondern private erfolgen, dann ginge dies nicht mehr. Denn damit ist es zwar innerhalb der Klasse MemoryGameCard m¨oglich, diese Methoden aufzurufen, mitnichten aber von außen. Nat¨ urlich ist nicht nur public und private m¨ oglich, man kann ebenso protected ableiten. Erwartungsgem¨aß bekommen dann die weiteren abgeleiteten Klassen von dieser Klasse den vollen Zugriff auf die Basis, aber niemand außerhalb der Ableitungshierarchie. Vorsicht Falle: Als default Wert f¨ ur den Access der Basisklasse bei einer Ableitung wird von C++ private angenommen, falls nichts anderes explizit angegeben wurde. Oft unterl¨ auft Neulingen der Fehler, den Access Specifier zu vergessen. Dann beginnt das R¨ atselraten, warum denn eine geerbte Methode nicht aufgerufen werden kann, sondern dies vom Compiler bem¨angelt wird. In solchen F¨ allen hilft ein schneller Blick auf die Deklaration der Klasse, ob man auch mit dem richtigen Access Specifier abgeleitet hat. Wie bereits in Kapitel 8 erw¨ ahnt, leitet man ab, um die Natur von Objekten zu erben und kann dann besondere zus¨atzliche Features in der abgeleiteten Klasse dazu implementieren. In unserem Fall sind das die Vorder- und
202
9. Klassen in C++
die R¨ uckseite der Karte in den entsprechenden Members in den Zeilen 19–20. Damit aber nicht genug: Unsere besondere Karte implementiert auch einen besonderen Konstruktor, der andere Parameter nimmt als der, der von der Basis vorgegeben ist. In unserem Fall wollen wir ja sowohl die Darstellung der Vorder- als auch die R¨ uckseite der Karte von außen vorgegeben bekommen. Weil das noch nicht alles war, was wir erreichen wollen, definieren wir in der abgeleiteten Klasse noch eine Methode getDisplayRep. Diese Deklaration allerdings bewirkt etwas, was wir bisher noch nicht kennen gelernt haben: Wenn man sich die Deklaration von getDisplayRep in MemoryGameCard ansieht und mit der Deklaration in GameCard vergleicht, dann f¨allt auf, dass diese beiden Deklarationen tats¨ achlich vollkommen identisch sind! Nun haben wir aber beim Overloading gesagt, dass so etwas gar nicht sein darf, weil der Compiler dann nicht auseinander halten kann, welche Implementation er nun einsetzen soll! Um die Verwirrung perfekt zu machen: Im Falle von Ableitungen darf es doch sein und es nennt sich Overriding. Dies bedeutet, dass eine genau urlich auch Definition) in einer abgeleite¨aquivalente Deklaration (und nat¨ ten Klasse die urspr¨ ungliche Definition, die der Basisklasse zugrunde lag, versteckt. Ruft man also auf der Klasse MemoryGameCard die Methode getDisplayRep auf, so wird die Definition aus der abgeleiteten Klasse genommen und nicht die aus der Basisklasse GameCard, da diese durch die Neudeklaration und Definition zugedeckt wurde. Um ein Overriding zu erreichen, muss die Deklaration genau gleich aussehen, wie die urspr¨ ungliche, ansonsten kommt es nur zu einem Overloading, es w¨ urden also beide Methoden friedlich nebeneinander existieren. Dasselbe, was f¨ ur Methoden gilt, gilt nat¨ urlich auch f¨ ur Membervariablen. Auch hier kann man eine Variable in einer abgeleiteten Klasse definieren, die denselben Namen hat wie die in einer ihrer Basisklassen (direkt oder weiter oben in der Ableitungshierarchie). Damit wird, wie zu erwarten, die Variable der Basisklasse versteckt, existiert aber parallel zur hier definierten. Vorsicht Falle: In der Literatur werden leider nur allzu oft die Begriffe Overloading und Overriding verwechselt oder sogar austauschbar verwendet. Dies ist aber absolut falsch und sorgt leider immer wieder f¨ ur große Verwirrung und Missverst¨ andnisse. Overloading bezeichnet das gleichzeitige Existieren mehrerer Funktionen oder Methoden nebeneinander, die verschiedene Parameters¨atze haben. Overriding bezeichnet das Verstecken einer Methode bzw. Variable in einer Ableitungshierarchie durch eine Methode bzw. Variable mit genau derselben Deklaration. Das bedeutet, dass ein Overriding Einfluss auf den Scope von Methoden und Variablen nimmt. Wie wir sp¨ater noch sehen werden, ist trotz Overriding ein Zugriff auf die dadurch versteckten Definitionen mittels Scope Operator m¨ oglich.
9.3 Abgeleitete Klassen
203
Die Implementation der einzelnen Methoden der Klasse MemoryGameCard sieht folgendermaßen aus (memory_game_card_v3.cpp): 1
// memory game card v3 . cpp − Version 3 d e f i n i t i o n s o f MemoryGameCard
2 3
#include ”memory game card v3 . h”
4 5
#include < c s t r i n g>
6 7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r o f MemoryGameCard ∗ ∗ @param f r o n t s y m b o l the f r o n t symbol o f t h i s card ∗ @param back symbol the back symbol o f t h i s card ∗/
13 14 15 16 17 18 19 20 21 22 23 24
MemoryGameCard : : MemoryGameCard( const char ∗ f r o n t s y m b o l , const char ∗ back symbol ) : GameCard(BACK SIDE) { i f ( ! front symbol ) front symbol = 0; else { f r o n t s y m b o l = new char [ s t r l e n ( f r o n t s y m b o l ) + 1 ] ; strcpy ( front symbol , front symbol ) ; }
25
i f ( ! back symbol ) back symbol = 0 ; else { back symbol = new char [ s t r l e n ( back symbol ) + 1 ] ; s t r c p y ( back symbol , back symbol ) ; }
26 27 28 29 30 31 32 33
}
34 35 36 37
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r o f MemoryGameCard ∗/
38 39 40 41 42 43 44 45
MemoryGameCard : : ˜ MemoryGameCard ( ) { i f ( front symbol ) delete [ ] f r o n t s y m b o l ; i f ( back symbol ) delete [ ] back symbol ; }
46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the symbol t h a t t h i s card r e p r e s e n t s ∗/
51 52 53 54 55 56
const char ∗MemoryGameCard : : getDisplayRep ( ) { return ( ( v i s i b l e s i d e == FRONT SIDE) ? f r o n t s y m b o l : back symbol ) ; }
In den Zeilen 14–16 stoßen wir hier auf ein Konstrukt, das wir bisher noch nicht kennen gelernt haben: ein Doppelpunkt gefolgt von einem sehr abstru¨ sen Aufruf, den es eigentlich gar nicht geben kann – oder doch? Uberlegen
204
9. Klassen in C++
wir kurz: Unsere abgeleitete Klasse hat einen Konstruktor mit 2 Parametern, die Basisklasse hat nur einen mit einem Parameter. Wie also soll nun der Compiler wissen, wie er den Konstruktor der Basisklasse aufruft? Genau das ist es, was wir mit diesem Konstrukt erreichen, n¨amlich dass wir explizit den Aufruf eines bestimmten Konstruktors mit einem bestimmten von uns vorgegebenen Parameter veranlassen. In diesem Fall bewirken wir den Aufruf des Konstruktors auf die Art, dass die R¨ uckseite der Karte nach dem Konstruieren sichtbar ist. Die Regel, die der Compiler beim impliziten Einsetzen von Konstruktoraufrufen befolgt, ist einfach: Er sucht in der Basisklasse immer nach einem Konstruktor mit genau demselben Parametersatz, wie ihn der Konstruktor der abgeleiteten Klasse besitzt. Gibt es einen solchen, kann der Compiler den impliziten Aufruf einsetzen, wenn nicht, dann f¨ uhrt dies zu einem Fehler. Dann sind wir also zum expliziten Aufruf gezwungen. Vorsicht Falle: Neulinge und Entwickler, die aus der Java-Welt kommen, sitzen oft dem Irrtum auf, dass der Compiler implizit immer nur den default Konstruktor aufruft, falls ein solcher existiert. Dies ist falsch! Eine solche Fehlannahme kann nat¨ urlich zu ganz interessanten Fehlern f¨ uhren, die man lange sucht :-). Um hier nicht zu stark vom Thema abzukommen, m¨ochte ich eine genauere Diskussion u ¨ber den Aufruf von Konstruktoren und Destruktoren auf sp¨ater verlegen, ebenso wie eine Abhandlung, was man nach diesem omin¨osen Doppelpunkt noch so alles tun kann. Im Augenblick gen¨ ugt es, zu wissen, dass man auf diese Art den Compiler zufrieden stellen kann und er den richtigen Konstruktor mit unseren gew¨ unschten Parametern aufrufen wird. Vorsicht Falle: Ein oftmaliger Fehler von Neulingen in C++ besteht darin, dass der explizite Hinweis auf den aufzurufenden Konstruktor fehlt, die Basisklasse aber keinen entsprechenden Konstruktor zur Verf¨ ugung stellt, der implizit eingesetzt werden k¨ onnte. Dar¨ uber beschwert sich nat¨ urlich der Compiler und will das Programm partout nicht fertig u ¨bersetzen. Ein kurzer Blick auf die Implementation der Konstruktoren schafft hier Klarheit. Abhilfe in Form eines expliziten Hinweises an den Compiler l¨asst diesen dann in puncto Fehlermeldungen weniger lautstark ans Werk gehen :-). Interessant ist auch noch das Testprogramm, denn darin sieht man, wie man eigentlich u mit abgeleiteten Klassen umgeht ¨berhaupt (memory_game_card_v3_test.cpp): 1
// memory game card v3 test . cpp − t e s t program f o r MemoryGameCard
2 3 4
#include < i o s t r e a m> #include ”memory game card v3 . h”
5 6
using s t d : : cout ;
9.3 Abgeleitete Klassen
7
205
using s t d : : e nd l ;
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { MemoryGameCard t e s t c a r d ( ”Symbol A” , ”back” ) ;
12
cout << ” t e s t c a r d : ” << t e s t c a r d . getDisplayRep () << e nd l ;
13 14
t e s t c a r d . turnCard ( ) ; cout << ” a f t e r t u r n i n g : ” << t e s t c a r d . getDisplayRep () << e n d l ;
15 16 17
t e s t c a r d . putBackSideUp ( ) ; cout << ”back up : ” << t e s t c a r d . getDisplayRep () << e nd l ;
18 19 20
t e s t c a r d . putFrontSideUp ( ) ; cout << ” f r o n t up : ” << t e s t c a r d . getDisplayRep () << e nd l ;
21 22 23
cout << ” v i s i b l e ? −> ” << t e s t c a r d . g e t V i s i b l e S i d e () << e nd l ;
24 25
return ( 0 ) ;
26 27
}
Das Anlegen eines neuen Objekts in Zeile 11 erfolgt wie gewohnt. In Zeile 13 wird durch den Aufruf getDisplayRep die entsprechende Methode der abgeleiteten Klasse aufgerufen, die durch Overriding die Methode der Basisklasse versteckt. In den Zeilen 15, 18, 21 und 24 sieht man, dass man auf der abgeleiteten Klasse problemlos alle Methoden aufrufen kann, die in der Basisklasse deklariert und definiert wurden (sofern die Ableitung public erfolgte!). Von außen gesehen ist also eine MemoryGameCard tats¨achlich eine vollwertige GameCard, nur eben mit zus¨atzlichen Eigenschaften – genau so wollten wir das durch die Ableitung erreichen. Das dazugeh¨ orige Makefile, um dieses Progr¨ammchen korrekt bauen zu k¨onnen, findet sich wiederum auf der beiliegenden CD-ROM unter dem Namen MemoryGameCardTestV3Makefile. Baut man damit das Executable und ruft es auf, dann erscheint am Bildschirm folgender Output: t e s t c a r d : back a f t e r t u r n i n g : Symbol A back up : back f r o n t up : Symbol A v i s i b l e ? −> 1
9.3.1 Mehrfachvererbung Vom OO Design Standpunkt aus ist es manchmal sehr w¨ unschenswert, wenn eine Klasse nicht nur von einer Basisklasse, sondern von mehreren Klassen gleichzeitig abgeleitet werden kann. Dies nennt sich Mehrfachvererbung oder auch multiple Inheritance. Betrachten wir z.B. unsere letzte Version der MemoryGameCard aus Abschnitt 9.3: Dort stellen wir fest, dass die Klasse eigentlich zwei semantisch verschiedene Eigenschaften in sich vereint, n¨amlich eine Karte zu sein und anzeigbar zu sein. Nun ist es aber so, dass es nicht nur die Eigenschaft einer Karte ist, dass sie anzeigbar ist. Viele verschiedene Dinge k¨onnen zur Anzeige gebracht werden.
206
9. Klassen in C++
Die sinnvolle und richtige OO Vorgangsweise w¨are also, eine Basisklasse Karte zu definieren, die alle Eigenschaften einer Karte in sich vereint. Außerdem definiert man noch eine Basisklasse anzeigbares Objekt, die alle Eigenschaften anzeigbarer Objekte in sich vereint. Die Basis-Karte ist selbst noch nicht anzeigbar, denn man will ja nicht notwendigerweise voraussetzen, dass eine Karte immer um jeden Preis angezeigt werden muss. Man denke sich nur, dass man z.B. ein Programm schreiben will, in dem zwei Computer gegeneinander ein Kartenspiel spielen. Dazu braucht man nicht notwendigerweise eine Anzeige der Karten. Versuchen wir uns also an einem Redesign der Karte f¨ ur das Memory Spiel zusammen mit den dazugeh¨origen Basisklassen, um einmal wirklich zu einer sauberen Trennung zu kommen, wie sie auch in der Praxis geistreicherweise eingesetzt werden soll. Nehmen wir uns zuerst eine Klasse vor, die eine primitive Version von anzeigbaren Objekten repr¨asentiert (displayable_object.h): 1 2
// d i s p l a y a b l e o b j e c t . h − base c l a s s f o r o b j e c t s t h a t can be // displayed .
3 4 5
#i f n d e f d i s p l a y a b l e o b j e c t h #define d i s p l a y a b l e o b j e c t h
6 7 8 9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DisplayableObject ∗ ∗ base f o r an o b j e c t t h a t i s d i s p l a y a b l e ∗ ∗/
14 15 16 17 18 19
class DisplayableObject { public : const char ∗ getDisplayRep ( ) ; };
20 21 22
#endif // d i s p l a y a b l e o b j e c t h
Ich gebe schon zu, dass eine Klasse mit nur einer einzigen Methode, ohne Daten und ohne besondere Funktionalit¨at, nicht gerade berauschend ist. Im Normalfall w¨ urde diese Klasse auch nicht so aussehen, sondern w¨ urde ziemlich viel besondere Funktionalit¨at enthalten, die ein darstellbares Objekt auszeichnet, z.B., ob ein Objekt nun sichtbar oder versteckt ist, die Bildschirmposition, Verschiebeoperationen, etc. Allerdings w¨ urde dies den Blick f¨ ur das Wesentliche hier verschleiern und dementsprechend belassen ¨ wir es dabei. Ebenfalls nur als Gr¨ unden der Ubersichtlichkeit wurde darauf verzichtet Konstruktor und Destruktor hier explizit zu implementieren. Genauso toll wie die Deklaration der Klasse liest sich auch die Definition der einzigen Methode (displayable_object.cpp):
9.3 Abgeleitete Klassen
1
207
// d i s p l a y a b l e o b j e c t . cpp − implementation o f D i s p l a y a b l e O b j e c t
2 3
#include ” d i s p l a y a b l e o b j e c t . h”
4 5 6 7 8
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the d i s p l a y rep o f the o b j e c t ∗/
9 10 11 12 13 14 15
const char ∗ D i s p l a y a b l e O b j e c t : : getDisplayRep ( ) { // t h i s i s not c l e a n , but the mechanism how to implement // t h i s i n a c l e a n way i s not known yet . return ( 0 ) ; }
Weil diese Implementation nun wirklich keine weiteren Kommentare ben¨otigt, sehen wir uns auch gleich an, wozu unsere Basisklasse GameCard mutiert, um der hier vollzogenen Auftrennung der Eigenschaften gerecht zu werden (game_card_v2.h): 1
// game card v2 . h − d e c l a r a t i o n o f a g e n e r a l card f o r games
2 3 4
#i f n d e f g a m e c a r d v 2 h #define g a m e c a r d v 2 h
5 6 7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ GameCard ∗ ∗ a g e n e r a l c l a s s f o r a card f o r games ∗ ∗/
13 14 15 16 17 18 19 20 21
c l a s s GameCard { protected : unsigned v i s i b l e s i d e ; public : // c o n s t a n t s f o r the v i s i b l e s i d e s t a t i c const unsigned FRONT SIDE = 0 x01 ; s t a t i c const unsigned BACK SIDE = 0x02 ;
22
GameCard( unsigned v i s i b l e s i d e ) ; ˜GameCard ( ) ;
23 24 25
void turnCard ( ) ; void putFrontSideUp ( ) ; void putBackSideUp ( ) ; unsigned g e t V i s i b l e S i d e ( ) ;
26 27 28 29 30
};
31 32 33
#endif // g a m e c a r d v 2 h
Wie sich leicht erkennen l¨ asst, ist die einzige Ver¨anderung gegen¨ uber unserer ersten Version, dass die Methode getDisplayRep weggefallen ist. Aus diesem Grund erspare ich auch allen Lesern das Einbinden der dazugeh¨origen
208
9. Klassen in C++
Implementation (game_card_v2.cpp), denn diese ist bis auf das Fehlen der entsprechenden Definition von getDisplayRep identisch zur Erstversion. Unsere Klasse MemoryGameCard ver¨andert sich durch das Auftrennen der beiden semantischen Aspekte in zwei Basisklassen zu folgender Deklaration (memory_game_card_v4.h): 1
// memory game card v4 . h − m u l t i p l e i n h e r i t a n c e f o r MemoryGameCard
2 3 4
#i f n d e f memory game card v4 h #define memory game card v4 h
5 6 7
#include ” game card v2 . h” #include ” d i s p l a y a b l e o b j e c t . h”
8 9 10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MemoryGameCard ∗ ∗ model o f a card as used i n the game ”memory” ∗ ∗/
16 17 18 19 20 21 22 23 24 25 26
c l a s s MemoryGameCard : public GameCard , public D i s p l a y a b l e O b j e c t { protected : char ∗ f r o n t s y m b o l ; char ∗ back symbol ; public : MemoryGameCard( const char ∗ f r o n t s y m b o l , const char ∗ back symbol = 0 ) ; ˜MemoryGameCard ( ) ;
27
const char ∗ getDisplayRep ( ) ; // o v e r r i d e o f base c l a s s method
28 29
};
30 31
#endif // memory game card v4 h
Wie sich unschwer erkennen l¨ asst, ist die einzige Ver¨anderung hier, dass MemoryGameCard nun von zwei Klassen zugleich abgeleitet ist. Dies geschieht intuitiverweise dadurch, dass man die Ableitungen der beiden Klassen durch Beistrich getrennt angibt. Die Zeilen 17–18 demonstrieren dies. Man sieht auch, dass die Access Specifiers f¨ ur jede einzelne der Ableitungen getrennt angegeben werden. Dadurch kann man bestimmte Basisklassen nach außen zug¨anglich machen und andere nicht. Durch diese Deklaration ist nun unsere Klasse eine besondere Karte, die auch noch gleichzeitig ein anzeigbares Objekt darstellt. Genau das ist auch die semantische Bedeutung der Ableitung von mehreren Klassen gleichzeitig: Eine Klasse, die Gebrauch von Mehrfachvererbung macht, ist alles zusammen, was die einzelnen Basisklassen f¨ ur sich allein sind. Die abgeleitete Klasse erbt also alle Members, Methoden wie auch Variablen, die in jeder einzelnen der Basisklassen deklariert (und definiert) sind. Die Methode getDisplayRep ist also in unserem Fall ein Override der gleichnamigen Me-
9.3 Abgeleitete Klassen
209
thode aus DisplayableObject und nicht, wie in unserer ersten Version aus GameCard. Die Implementation dieser Version (memory_game_card_v4.cpp) ist im Prinzip vollkommen ¨ aquivalent zur vorherigen Implementation. Die einzige Ausnahme ist das Inkludieren von memory_game_card_v4.h. Aus diesem Grund erspare ich mir hier das Abdrucken des Source Codes. Auch das Testprogramm (memory_game_card_v4_test.cpp) hat sich bis auf ein ge¨andertes Inkludieren nicht ver¨ andert. Ebenso wurde auch das dazugeh¨orige Makefile (MemoryGameCardTestV4Makefile) nur entsprechend adaptiert, um auch displayable_object.cpp korrekt zu compilieren. Also m¨ochte ich auch mit diesen Files nicht sinnlos Seiten im Buch f¨ ullen. Wie leicht einzusehen ist, ist Mehrfachvererbung eine sehr saubere OO M¨oglichkeit, um verschiedene, voneinander unabh¨angige Eigenschaften in verschiedenen Ableitungshierarchien getrennt voneinander zu modellieren. Sollte eine Klasse mehrere Eigenschaften in sich vereinen, dann kann dies durch multiple Inheritance sehr elegant gel¨ost werden. Eine solchermaßen abgeleitete Klasse erbt alle Eigenschaften aller verschiedenen Basisklassen. Jedoch kann es dabei auch zu ein paar technischen Problemen kommen, wenn man nicht aufpasst! Wenden wir uns einmal einem leicht zu l¨osenden Problem zu, bevor wir in Abschnitt 9.4 zu den harten Brocken kommen: Was passiert, wenn beide Basisklassen ein- und dieselbe Methode implementieren? Der Begriff ein- und dieselbe bezieht sich nat¨ urlich auf die vollst¨andige Signatur, also auch den Parametersatz. W¨ urde der Parametersatz verschieden sein, so w¨ urde sich ja einfach eine problemlos aufzul¨osende Overloading Situation ergeben. Haben wir also wirklich komplette Gleichheit, welche der Implementationen wird nun bei einem Aufruf vom Compiler eingesetzt? Wie sich unschwer erraten l¨ asst, keine der beiden! Der Compiler wird sich einfach beschweren, dass er eine Ambiguit¨at im Aufruf entdeckt hat und damit ¨ das weitere Ubersetzen verweigern. Das bedeutet also, dass die Entwickler die Ambiguit¨ at per Hand aufl¨ osen m¨ ussen, um den Compiler zu beruhigen. Wie dies passiert, sehen wir uns am besten an einem kleinen Beispiel an (method_ambiguity_problem.cpp). Die erste Betrachtung gilt zwei Klassendeklarationen in diesem Progr¨ ammchen: 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DisplayableObject ∗ ∗ A simple displayable object ∗ ∗/
18 19 20 21 22 23 24 25 26
class DisplayableObject { protected : char ∗ s t r i n g r e p ; D i s p l a y a b l e O b j e c t ( const char ∗ s t r i n g r e p ) ; public : ˜ DisplayableObject ( ) ; const char ∗ getStringRep ( ) ;
210
27
9. Klassen in C++
};
28 29 30 31 32 33 34 35
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ PrintableObject ∗ ∗ A simple p r i n t a b l e object ∗ ∗/
36 37 38 39 40 41 42 43 44 45
class PrintableObject { protected : char ∗ s t r i n g r e p ; P r i n t a b l e O b j e c t ( const char ∗ s t r i n g r e p ) ; public : ˜ PrintableObject ( ) ; const char ∗ getStringRep ( ) ; };
Abgesehen davon, dass in Realit¨ at nat¨ urlich f¨ ur anzeigbare sowie f¨ ur druckbare Objekte bei weitem mehr vonn¨oten ist als eine einfache Methode getStringRep, ist die Intention hinter diesen beiden Klassen im Prinzip recht nett: Sie speichern die Repr¨ asentation und liefern sie direkt auf Anfrage. Klassen, die von einer der beiden ableiten, m¨ ussen also nur im Konstruktor die anf¨ angliche Repr¨ asentation mitgeben und danach bei Bedarf ¨andern (¨ uber die Member Variable string_rep_), sofern sich der anzeigbare Inhalt ge¨andert hat. Der Rest wird von der Basis u ¨bernommen. Jetzt aber wollen wir eine Klasse schreiben, die sowohl anzeigbar als auch druckbar ist. Sinnigerweise leiten wir diese Klasse von beiden ab und alles funktioniert wunderbar... oder auch nicht! Sagen wir, eine Klasse MyObject w¨ urde einfach folgendermaßen aussehen: 1 2 3 4 5 6 7
c l a s s MyObject : public D i s p l a y a b l e O b j e c t , public P r i n t a b l e O b j e c t { public : MyObject ( ) ; ˜MyObject ( ) ; };
Abgesehen davon, dass diese Klasse nicht viel darstellt, aber das tut hier nichts zur Sache, was w¨ urde passieren, wenn man irgendwo im Programm z.B. die folgenden beiden Zeilen stehen hat? MyObject my_object; my_object.getStringRep(); Richtig erkannt! Die Ambiguit¨ at ist nicht aufl¨osbar, denn woher soll der Compiler denn wissen, ob die Implementation aus DisplayableObject oder die aus PrintableObject nun die gew¨ unschte ist. Damit endet der Versuch ¨ der Ubersetzung mit einem Fehler. Irgendwie muss man also daf¨ ur sorgen, dass der Compiler wieder beruhigt wird und weiß, was er tun soll. Die einzige M¨oglichkeit, das zu erreichen, besteht darin, dass es in der Klasse MyObject
9.3 Abgeleitete Klassen
211
selbst eine Deklaration ebendieser Methode getStringRep gibt, die die entsprechenden Maßnahmen setzt. Eine m¨ogliche Deklaration dieser Klasse sieht dann folgendermaßen aus: 47 48 49 50 51 52 53
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyObject ∗ ∗ A s i m p l e c l a s s t h a t i s d i s p l a y a b l e and p r i n t a b l e ∗ ∗/
54 55 56 57 58 59 60 61 62
c l a s s MyObject : public D i s p l a y a b l e O b j e c t , public P r i n t a b l e O b j e c t { public : MyObject ( ) ; ˜MyObject ( ) ; const char ∗ getStringRep ( ) ; };
Wenn man nun getStringRep auf einer Variable vom Typ MyObject aufruft, dann wird auf jeden Fall die hier deklarierte genommen, denn diese ist nach dem Prinzip des Overridings die f¨ ur den Compiler sichtbare. Dadurch ist der Konflikt einmal f¨ ur den Compiler behoben und die Verantwortung u ¨bergeben. Nun braucht man nur noch getStringRep in MyObject korrekt implementieren und alles funktioniert wieder wie geplant – nun ja, zumindest fast wie geplant, aber dazu kommen wir weiter unten noch. Sehen wir uns einmal die Implementation der Klasse DisplayableObject an: 64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r ∗ ∗ @param s t r i n g r e p the s t r i n g r e p r e s e n t a t i o n o f the o b j e c t ∗/
69 70 71 72 73 74 75 76 77 78 79
D i s p l a y a b l e O b j e c t : : D i s p l a y a b l e O b j e c t ( const char ∗ s t r i n g r e p ) { if ( ! string rep ) { s t r i n g r e p = 0; return ; } s t r i n g r e p = new char [ s t r l e n ( s t r i n g r e p ) + 1 ] ; strcpy ( s t r i n g r e p , string rep ) ; }
80 81 82 83
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
84 85 86 87 88 89
DisplayableObject : : ˜ DisplayableObject () { if ( string rep ) delete [ ] s t r i n g r e p ; }
90 91 92
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗
212
9. Klassen in C++
∗ @return the s t r i n g to be d i s p l a y e d ∗/
93 94 95 96 97 98 99
const char ∗ D i s p l a y a b l e O b j e c t : : getStringRep ( ) { return ( s t r i n g r e p ) ; }
Die Implementation der Klasse PrintableObject ist im Prinzip ¨aquivalent dazu: 107 108 109 110 111 112 113 114 115 116
P r i n t a b l e O b j e c t : : P r i n t a b l e O b j e c t ( const char ∗ s t r i n g r e p ) { if ( ! string rep ) { s t r i n g r e p = 0; return ; } s t r i n g r e p = new char [ s t r l e n ( s t r i n g r e p ) + 1 ] ; strcpy ( s t r i n g r e p , string rep ) ; }
117 118 119 120
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
121 122 123 124 125 126
PrintableObject : : ˜ PrintableObject () { if ( string rep ) delete [ ] s t r i n g r e p ; }
127 128 129 130 131
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the s t r i n g to be d i s p l a y e d ∗/
132 133 134 135 136
const char ∗ P r i n t a b l e O b j e c t : : getStringRep ( ) { return ( s t r i n g r e p ) ; }
Zu guter Letzt sieht die Klasse MyObject nach unseren Betrachtungen von oben folgendermaßen aus: 138 139 140 141 142
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r ∗ ∗ @param s t r i n g r e p the s t r i n g r e p r e s e n t a t i o n o f the o b j e c t ∗/
143 144 145
MyObject : : MyObject ( ) : D i s p l a y a b l e O b j e c t ( ” ( d i s p ) j u s t a s t r i n g rep ” ) , P r i n t a b l e O b j e c t ( ” ( p r i n t ) j u s t a s t r i n g rep ” )
146 147 148
{ }
149 150 151 152 153
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
9.3 Abgeleitete Klassen
154 155 156
213
MyObject : : ˜ MyObject ( ) { }
157 158 159 160 161
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the s t r i n g to be d i s p l a y e d ∗/
162 163 164 165 166
const char ∗ MyObject : : getStringRep ( ) { return ( D i s p l a y a b l e O b j e c t : : getStringRep ( ) ) ; }
Und damit das Programm auch einen Output liefert, gibt’s nat¨ urlich noch ein main dazu: 168 169 170 171
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { MyObject t e s t o b j e c t ;
172
cout << t e s t o b j e c t . getStringRep () << e n d l ; return ( 0 ) ;
173 174 175
}
Der Output, den dieses Meisterwerk liefert, ist auch unschwer vorherzusagen und sieht aus wie folgt: ( d i s p ) j u s t a s t r i n g rep
Ich habe bereits erw¨ ahnt, dass die Aussage alles funktioniert wieder wie geplant mit etwas Vorsicht zu genießen ist. Warum das so ist, zeigt uns die Implementation von getStringRep in der Klasse MyObject: Dort wird mittels Scope Operator ein Aufruf von getStringRep aus DisplayableObject erzwungen. Das bedeutet nat¨ urlich, dass immer nur der String, der dort gespeichert ist, zur Anzeige kommt. Will man also die String Repr¨asentation ¨andern, dann muss dies durch eine Zuweisung der folgenden Form passieren: DisplayableObject::string_rep_ = "changed rep"; Zwei Dinge lassen sich hierbei erkennen: 1. Ambiguit¨ aten gibt es nicht nur bei Methoden, sondern auch bei Variablen. Eine einfache Zuweisung auf string_rep_ aus einer Methode von MyObject heraus w¨ urde den Compiler wieder zur Verzweiflung treiben, denn welche der beiden gleich benannten Members aus den beiden Basisklassen soll er nun f¨ ur die Zuweisung heranziehen? 2. Der Scope Operator ist erwartungsgem¨aß auch bei Variablen wieder das Mittel der Wahl, um den Compiler auf die richtige F¨ahrte zu f¨ uhren. Betrachtet man die hier skizzierte Implementation etwas kritischer, dann zeigt sich allerdings eine gr¨ oßere Problematik, die auf einen groben Designfehler hinweist und den Entwicklern das Leben schwer macht:
214
9. Klassen in C++
Bei der Implementation von MyObject wurde entschieden, dass DisplayableObject entscheidend f¨ ur die Speicherung und das Liefern der String Repr¨ asentation ist. Was passiert nun allerdings, wenn ein Objekt der Klasse PrintableObject intern mit seiner eigenen string_rep_ mehr macht, als sie nur zu speichern und auf Anfrage herzugeben? In unserem Fall wird dann alles inkonsistent, denn die Implementation von MyObject ignoriert das PrintableObject vollkommen, mit Ausnahme des Konstruktoraufrufs, zu dem es gezwungen ist. Neben diesen m¨oglichen Inkonsistenzen gibt es noch einen zweiten Aspekt, der zu sehr ungewollten Resultaten f¨ uhren kann: Es ist m¨oglich, dass von außen tats¨ achlich die Methode getStringRep aus der Basisklasse PrintableObject aufgerufen wird, anstatt die Implementation aus MyObject heranzuziehen. Wie das absichtlich (oder auch ungewollt!) passieren kann, wird noch sp¨ ater in Abschnitt 9.4 besprochen. Im Augenblick ist es ausreichend, zu wissen, dass es vorkommen kann. Das bedeutet, dass man bei der Aufl¨ osung von Ambiguit¨aten niemals einfach eine der Basisklassen ignorieren darf, sondern sich immer darum k¨ ummern muss, dass alle Basisklassen gleichermaßen konsistent behandelt werden. Der Designfehler bei unseren Klassen liegt darin versteckt, dass beide Basisklassen den Entwicklern Arbeit abnehmen wollen, indem sie die String Repr¨asentation speichern und auf Abruf den gespeicherten String liefern. Nun wissen diese beiden Klassen aber naturgem¨aß nicht, was im String enthalten sein soll, denn dies ist Aufgabe der abgeleiteten Klasse. W¨ urde das Design so aussehen, dass einfach die Methode getStringRep in den Basisklassen existiert und mittels Override von der abgeleiteten Klasse implementiert werden muss, sodass die abgeleitete Klasse selbst die Repr¨asentation speichert und liefert, dann kommt es erst gar nicht zu diesem Problem. Denn damit ist dann wieder eine einzige speichernde Stelle definiert und Inkonsistenzen werden verhindert. Bevor nun jemand die Klassen entsprechend umschreibt, m¨ochte ich noch kurz darauf hinweisen, dass in Abschnitt 9.4 ein paar zus¨atzliche Aspekte diskutiert werden, die bei einer solchen Implementation sehr hilfreich und sogar z.T. unabdingbar sind. Vorsicht Falle: Nicht nur von Neulingen wird sehr oft der Fehler begangen, dass Klassen “zu viel wissen”, obwohl dieses Wissen nicht in ihrer urs¨achlichen Natur liegt. Man will den Entwicklern Arbeit abnehmen, indem man etwas besonders sch¨ on implementiert und erreicht leider damit das Gegenteil, n¨amlich dass man ihnen das Leben besonders schwer macht. Um nicht in diese Falle zu tappen, die im vorangegangenen Beispiel skizziert wurde, muss man sich unbedingt an das folgende Designprinzip halten: Ein Objekt darf niemals “weil es gerade so praktisch ist” Daten speichern, die es nicht selbst unter Kontrolle hat! Wenn solche Daten ben¨otigt werden, dann m¨ ussen sie ausnahmslos u ¨ber Methodenaufrufe von den tats¨ achlichen speichernden Objekten angefordert werden. Wie man das genau erreicht, wird in Abschnitt 9.4 besprochen.
9.3 Abgeleitete Klassen
215
9.3.2 Konstruktoren und Destruktoren Bei den einfachen Klassen wurde bereits besprochen, dass beim Erzeugen eines Objekts der Konstruktor aufgerufen wird und bei dessen Zerst¨orung der Destruktor. Bei abgeleiteten Klassen, egal ob mittels Einfach- oder Mehrfachvererbung, sind nun zwangsweise mehrere Konstruktoren und Destruktoren im Spiel, denn alle Klassen in der Ableitungshierarchie besitzen solche. Wie diese Konstruktoren und Destruktoren nun intern behandelt und aufgerufen werden, betrachten wir am besten gleich an einem simplen Beispiel (derived_constr_destr_demo.cpp): 1 2
// d e r i v e d c o n s t r d e s t r d e m o . cpp − demo program to show what // happens with c o n s t r u c t o r s and d e s t r u c t o r s i n d e r i v e d c l a s s e s
3 4
#include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10 11 12 13 14
c l a s s RootClass { public : RootClass ( ) ; ˜ RootClass ( ) ; };
15 16 17 18 19 20 21 22
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedClassLevelOne : public RootClass { public : DerivedClassLevelOne ( ) ; ˜ DerivedClassLevelOne ( ) ; };
23 24 25 26 27 28 29 30
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedClassLevelTwo : public DerivedClassLevelOne { public : DerivedClassLevelTwo ( ) ; ˜ DerivedClassLevelTwo ( ) ; };
31 32 33 34 35 36
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− RootClass : : RootClass ( ) { cout << ” c o n s t r u c t o r o f RootClass ” << e n d l ; }
37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− RootClass : : ˜ RootClass ( ) { cout << ” d e s t r u c t o r o f RootClass ” << e n d l ; }
43 44 45 46 47 48
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedClassLevelOne : : DerivedClassLevelOne ( ) { cout << ” c o n s t r u c t o r o f DerivedClassLevelOne ” << e nd l ; }
49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
216
51 52 53 54
9. Klassen in C++
DerivedClassLevelOne : : ˜ DerivedClassLevelOne ( ) { cout << ” d e s t r u c t o r o f DerivedClassLevelOne ” << e n d l ; }
55 56 57 58 59 60
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedClassLevelTwo : : DerivedClassLevelTwo ( ) { cout << ” c o n s t r u c t o r o f DerivedClassLevelTwo ” << e nd l ; }
61 62 63 64 65 66
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedClassLevelTwo : : ˜ DerivedClassLevelTwo ( ) { cout << ” d e s t r u c t o r o f DerivedClassLevelTwo ” << e n d l ; }
67 68 69 70 71
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { DerivedClassLevelTwo t e s t o b j e c t ;
72
cout << ” ∗∗∗∗∗ now the o b j e c t i s a l i v e ” << e nd l ;
73 74
return ( 0 ) ;
75 76
}
Wir haben es hier mit einer simplen Ableitungshierarchie zu tun, die ihre Wurzel in der Klasse RootClass hat. Von dieser ist die Klasse DerivedClassLevelOne abgeleitet. Diese Klasse wiederum stellt die Basis f¨ ur die Ableitung von DerivedClassLevelTwo dar. Die jeweiligen Konstruktoren und Destruktoren geben einfach eine Meldung am Bildschirm aus, um zu zeigen, wann sie aufgerufen werden. Innerhalb von main in Zeile 71 wird ein Objekt angelegt, das nach Zeile 75 seine Lifetime beendet hat und dementsprechend wieder zerst¨ ort wird. Sehen wir uns den Output des Programms an, so zeigt sich sofort, was es mit den einzelnen Konstruktoren und Destruktoren so auf sich hat: c o n s t r u c t o r o f RootClass c o n s t r u c t o r o f DerivedClassLevelOne c o n s t r u c t o r o f DerivedClassLevelTwo ∗∗∗∗∗ now the o b j e c t i s a l i v e d e s t r u c t o r o f DerivedClassLevelTwo d e s t r u c t o r o f DerivedClassLevelOne d e s t r u c t o r o f RootClass
Beim Anlegen des Objekts werden alle Konstruktoren der Reihe nach von der Basisklasse RootClass bis zur letzten Klasse in der Ableitungshierarchie (also DerivedClassLevelTwo) aufgerufen. Erst dann ist das Objekt vollst¨andig am Leben und man kann mit ihm arbeiten. Umgekehrt geht der Weg der Aufrufe, wenn das Objekt seine Lifetime hinter sich gebracht hat. Die Destruktoren werden in der Reihenfolge von DerivedClassLevelTwo bis hinauf zur Basisklasse der Ableitungshierarchie RootClass aufgerufen.
9.3 Abgeleitete Klassen
217
Um nun nicht in begriffliche Probleme f¨ ur unsere weiteren Betrachtungen hineinzulaufen, m¨ ochte ich folgende Konvention festlegen, wie sie in der OO Softwareentwicklung u ¨blich ist: In einer Ableitungshierarchie steht die Basisklasse oben und abgeleitete Klassen stehen entsprechend unter ihr. Die Aufrufe der Konstruktoren erfolgen also von oben nach unten in der Hierarchie und die Aufrufe der Destruktoren erfolgen entsprechend umgekehrt von unten nach oben. Was bedeutet dieses Verhalten nun f¨ ur Softwareentwickler? Ganz einfach: Man kann sich immer darauf verlassen, dass ein Konstruktor erst dann aufgerufen wird, wenn die dar¨ uber liegenden Klassen in der Ableitungshierarchie bereits vollst¨ andig konstruiert wurden. Dementsprechend kann man bereits im Konstruktor auf die Funktionalit¨ at und die Daten der dar¨ uber liegenden Klassen zugreifen, ohne bef¨ urchten zu m¨ ussen, dass man auf noch uninitialisierte Werte st¨ oßt. Ok, ich geb’s ja zu – nat¨ urlich stimmt das nur dann, wenn die Konstruktoren der dar¨ uber liegenden Klassen korrekt implementiert wurden, aber davon gehen wir hier einmal aus. Im Falle einer einfachen Vererbungshierarchie ist also alles klar. Was passiert nun bei Mehrfachvererbung? Auch das ist wieder am leichtesten an einem Beispiel demonstriert (multiple_inher_constr_destr_demo.cpp): 10 11 12 13 14 15
c l a s s RootClassA { public : RootClassA ( ) ; ˜RootClassA ( ) ; };
16 17 18 19 20 21 22 23
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s RootClassB { public : RootClassB ( ) ; ˜ RootClassB ( ) ; };
24 25 26 27 28 29 30 31
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedClassLevelOneA : public RootClassA { public : DerivedClassLevelOneA ( ) ; ˜ DerivedClassLevelOneA ( ) ; };
32 33 34 35 36 37 38 39
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedClassLevelOneB : public RootClassB { public : DerivedClassLevelOneB ( ) ; ˜ DerivedClassLevelOneB ( ) ; };
40 41 42 43 44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedClassLevelTwo : public DerivedClassLevelOneA , public DerivedClassLevelOneB { public :
218
9. Klassen in C++
DerivedClassLevelTwo ( ) ; ˜ DerivedClassLevelTwo ( ) ;
46 47 48
};
Ich habe bewusst nur den Ausschnitt des Programms mit der Deklaration der entsprechenden Klassen hier abgedruckt, die Implementation ist im Prinzip ¨aquivalent mit dem vorherigen Beispiel: Konstruktoren und Destruktoren der einzelnen Klassen geben bei Aufruf eine Meldung aus. Die in diesem Programm modellierte Ableitungshierarchie hat zwei Wurzeln, von denen jeweils eine Klasse abgeleitet ist. Die Klasse DerivedClassLevelTwo schließlich ist von diesen beiden Klassen gemeinsam abgeleitet. In Abbildung 9.1 ist die Ableitungshierarchie dieses Beispiels visualisiert.
RootClassA
RootClassB
DerivedClassLevelOneA
DerivedClassLevelOneB
DerivedClassLevelTwo
Abbildung 9.1: Ableitungshierarchie des Mehrfachvererbungs-Beispiels
Die Konstruktoraufrufe beim Erzeugen und die Destruktoraufrufe beim Zerst¨oren dieser Klasse erfolgen dann so, wie man es in folgendem Output sieht: c o n s t r u c t o r o f RootClassA c o n s t r u c t o r o f DerivedClassLevelOneA c o n s t r u c t o r o f RootClassB c o n s t r u c t o r o f DerivedClassLevelOneB c o n s t r u c t o r o f DerivedClassLevelTwo ∗∗∗∗∗ now the o b j e c t i s a l i v e d e s t r u c t o r o f DerivedClassLevelTwo d e s t r u c t o r o f DerivedClassLevelOneB d e s t r u c t o r o f RootClassB d e s t r u c t o r o f DerivedClassLevelOneA d e s t r u c t o r o f RootClassA
Zuerst wird also der linke Teil der Ableitungshierarchie (siehe Abbildung 9.1) konstruiert, danach der rechte Teil und zu guter Letzt das endg¨ ultige Objekt der Klasse DerivedClassLevelTwo, die von beiden Teilen abgeleitet ist. Die Reihenfolge der Konstruktion der Basisklassen, also zuerst der linke und dann der rechte Teil, ist durch die Reihenfolge der Angabe bei der Ableitung
9.4 Weitere wichtige technische Aspekte
219
abh¨angig. W¨ urde in den Zeilen 42–43 DerivedClassLevelOneB vorher angegeben sein, dann w¨ urde sich damit auch die Reihenfolge der Konstruktoraufrufe umdrehen. Es ist nicht m¨ oglich, durch explizite Angabe der Konstruktoren die Reihenfolge zu beeinflussen. H¨atte man z.B. folgenden Konstruktor f¨ ur DerivedClassLevelTwo: DerivedClassLevelTwo::DerivedClassLevelTwo() : DerivedClassLevelOneB(), DerivedClassLevelOneA() { //... } dann w¨ urde dies bestenfalls zu einer Warnung des Compilers f¨ uhren, dass er die “verkehrte” Aufrufreihenfolge der Konstruktoren wieder umgedreht und damit korrigiert hat. Sehen wir uns die Reihenfolge der Aufrufe der Destruktoren an, so ist leicht zu erkennen, dass dieselbe Regel gilt wie bei einfacher Vererbung: Die Destruktoren werden garantiert genau in umgekehrter Reihenfolge zu den Konstruktoren aufgerufen. Es wird also zuerst die “Hauptklasse”, danach der rechte Zweig der Ableitungshierarchie von unten nach oben und zum Schluss der linke Zweig der Ableitungshierarchie destruiert.
9.4 Weitere wichtige technische Aspekte Bisher ist bekannt, wie man mit Klassen sowie einfacher und mehrfacher Vererbung arbeitet. Allerdings ist das noch nicht die ganze Wahrheit, denn es gibt einige sehr wichtige technische Aspekte, die die Funktionalit¨at von Objekten erheblich beeinflussen! Genau um diese Aspekte geht es in der Folge. 9.4.1 Static und Dynamic Binding Zu einer sehr wichtigen oder vielleicht sogar zur wichtigsten Eigenschaft von Objekten u ¨berhaupt, die saubere OO Programme erst wirklich erm¨oglicht, habe ich mich bisher noch u ¨berhaupt nicht ausgelassen: Hat man es mit einem Objekt zu tun, dem als Datentyp eine abgeleitete Klasse zugrunde liegt, dann kann man dieses Objekt auch einfach so behandeln, als ob es einfach den Typ der Basisklasse h¨ atte. Was das nun wieder bedeutet, l¨ asst sich leicht u ¨berlegen: Erinnern wir uns daran, dass in unserer bisher letzten Version 4 der Karte f¨ ur das Memory Spiel diese Karte sowohl von einer allgemeinen Kartenklasse als auch von einer allgemeinen “anzeigbaren” Klasse abgeleitet war. Nehmen wir nun an, es gibt ein allgemeines Anzeigemodul in einem Programm, das so definiert ist, dass es immer nur mit “anzeigbaren” Objekten umgeht, egal welche genaue Natur diese jetzt haben. Wozu sollte dieses Modul sich
220
9. Klassen in C++
auch darum k¨ ummern, ob es jetzt mit einer Karte oder einer Schachfigur oder einfach nur mit irgendeinem Text arbeitet. Solange sie “anzeigbar” ist, soll es der Klasse recht sein. Unsere MemoryGameCard im Beispiel war (auch) abgeleitet von DisplayableObject. Da eine Ableitung ja eine ISA Relation darstellt, bedeutet das, dass MemoryGameCard tats¨achlich ein DisplayableObject ist, also einfach als solches behandelt werden kann, weil die Typenkompatibilit¨ at gegeben ist. Sehen wir uns das einmal am Beispiel an (virtual_method_demo.cpp): 1
// virtual method demo . cpp − demo f o r the use o f v i r t u a l methods
2 3
#include < i o s t r e a m>
4 5 6
using s t d : : cout ; using s t d : : e n d l ;
7 8 9 10 11 12 13 14
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Displayable ∗ ∗ base c l a s s f o r a l l d i s p l a y a b l e o b j e c t s ∗ ∗/
15 16 17 18 19 20
class Displayable { public : virtual const char ∗ getDisplayRep ( ) const ; };
21 22 23 24 25 26 27 28
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ OneClass ∗ ∗ just a displayable class ∗ ∗/
29 30 31 32 33 34
c l a s s OneClass : public D i s p l a y a b l e { public : virtual const char ∗ getDisplayRep ( ) const ; };
35 36 37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ OneClass ∗ ∗ j u s t another d i s p l a y a b l e c l a s s ∗ ∗/
43 44 45 46 47 48
c l a s s AnotherClass : public D i s p l a y a b l e { public : virtual const char ∗ getDisplayRep ( ) const ; };
49 50 51 52 53
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DisplayHandler ∗
9.4 Weitere wichtige technische Aspekte
∗ the ha n dl e r t h a t i s r e s p o n s i b l e f o r d i s p l a y i n g o b j e c t s ∗ ∗/
54 55 56 57 58 59 60 61 62
c l a s s DisplayHandler { public : s t a t i c void d i s p l a y O b j e c t ( const D i s p l a y a b l e & o b j e c t ) ; };
63 64 65 66 67
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the d i s p l a y rep o f the o b j e c t ∗/
68 69 70 71 72
const char ∗ D i s p l a y a b l e : : getDisplayRep ( ) const { return ( ” d i s p l a y rep o f D i s p l a y a b l e ” ) ; }
73 74 75 76 77
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the d i s p l a y rep o f the o b j e c t ∗/
78 79 80 81 82
const char ∗ OneClass : : getDisplayRep ( ) const { return ( ” d i s p l a y rep o f OneClass ” ) ; }
83 84 85 86 87
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @return the d i s p l a y rep o f the o b j e c t ∗/
88 89 90 91 92
const char ∗ AnotherClass : : getDisplayRep ( ) const { return ( ” d i s p l a y rep o f AnotherClass ” ) ; }
93 94 95 96 97 98
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param o b j e c t the o b j e c t to be d i s p l a y e d ∗ @return ∗/
99 100 101 102 103 104
void DisplayHandler : : d i s p l a y O b j e c t ( const D i s p l a y a b l e & o b j e c t ) { cout << ” d i s p l a y i n g o b j e c t . . . ” << e nd l << o b j e c t . getDisplayRep () << e nd l ; }
105 106 107 108 109 110
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { OneClass a d i s p l a y a b l e o b j e c t ; AnotherClass a n o t h e r d i s p l a y a b l e o b j e c t ;
111
DisplayHandler : : d i s p l a y O b j e c t ( a d i s p l a y a b l e o b j e c t ) ; DisplayHandler : : d i s p l a y O b j e c t ( a n o t h e r d i s p l a y a b l e o b j e c t ) ;
112 113 114
return ( 0 ) ;
115 116
}
221
222
9. Klassen in C++
Der Output dieses Progr¨ ammchens zeigt, dass wir auf jeden Fall einmal erreicht haben, was wir wollten: Egal welches Objekt wir anzeigen wollen, es wird korrekt angezeigt, ohne dass der genaue Typ bekannt sein m¨ usste. Es gen¨ ugt, dass ein Objekt Displayable ist. displaying object . . . d i s p l a y rep o f OneClass displaying object . . . d i s p l a y rep o f AnotherClass
Ein Blick auf den Code, z.B. auf Zeile 19 er¨offnet Erkl¨arungsbedarf: Was bedeutet virtual und wozu steht hinter der Methode das Keyword const? Ich m¨ ochte zuerst die zweite Frage beantworten, denn diese Antwort ist einfacher zu verdauen: Das Keyword const hinter einer Methode bedeutet, dass es sich hierbei um eine sogenannte konstante Methode handelt, also um eine, die bei Aufruf nichts am Inhalt eines Objekts ¨andert. Sollte man innerhalb einer solchen Methode versuchen, irgendeine Member Variable eines Objekts zu ver¨ andern, dann regt sich der Compiler dar¨ uber auf. Und damit haben wir endlich auch die letzte Antwort, wozu ein const_cast noch gebraucht werden kann: Gesetzt den Fall, ein Objekt w¨ urde sich nach außen hin durch den Aufruf einer Methode nicht ver¨andern, allerdings intern quasi versteckt und damit f¨ ur niemanden bemerkbar trotzdem eine Status¨anderung vornehmen, dann wird eine solche Methode als const deklariert und die zu ¨ andernden Variablen in der Methode durch einen const_cast von ihrem Schreibschutz befreit. Dieses Verhalten braucht man z.B. manchmal, wenn man einen internen Cache implementiert. Alternativ zum const_cast kann man auch mit dem sch¨ oneren Konstrukt, n¨amlich den mutable Members arbeiten (siehe Abschnitt 15.1). Einen weiteren Aspekt von const gibt es noch, der in unserem Programm in der Methode displayObject in den Zeilen 100–104 zum Tragen kommt: Die Methode nimmt als Parameter eine const Reference. Auf dieser wird dann in Zeile 103 die Methode getDisplayRep aufgerufen. W¨are die Methode nicht selbst schon als const deklariert worden, so w¨ urde sich hier der Compiler beschweren, denn man riefe damit eine Methode, die etwas an einem Objekt ver¨ andern kann, auf einem konstanten Objekt auf. Das darf nat¨ urlich nicht sein! Durch die Deklaration der Methode als const ist der Compiler beruhigt. Nun zur ersten Frage: Was bedeutet virtual in Zeile 19? Um dies zu beantworten, muss ich etwas weiter ausholen und mich kurz u ¨ber technische Details von Members im Allgemeinen auslassen. Wie bereits bekannt ist, werden beim Compilieren und endg¨ ultig beim darauf folgenden Linken alle Namen durch Adressen ersetzt, mit denen der Computer arbeiten kann. Eine Variable ist u ur eine ¨ber ihre Adresse ansprechbar und dasselbe gilt auch f¨ Methode (deswegen gibt es ja auch Funktionspointer). Schr¨anken wir unsere Betrachtungen nun auf die Methoden ein, denn diese sind hier interessant: Was passiert intern, wenn man eine “normale” (also nicht virtual) Methode auf einem Objekt aufruft? Ganz einfach, es wird genau die Methode aufgeru-
9.4 Weitere wichtige technische Aspekte
223
fen, die f¨ ur die Klasse des Objekts implementiert wurde, denn der Compiler ¨ hat beim Ubersetzen die entsprechende Adresse eingesetzt. Dieses Einsetzen einer Adresse zur Compilezeit nennt man static Binding. Hierbei wird jeder Aufruf gleich beim Compilieren und Linken in eine direkte Adressreferenz umgesetzt. Allerdings ist dieses Verhalten nicht unbedingt immer erw¨ unscht. Sehen wir einfach nur unser Beispiel an: Der Methode displayObject aus DisplayHandler wird immer nur ein Objekt vom Typ Displayable u ¨bergeben. H¨ atten wir es mit static Binding zu tun, dann w¨ urde der Aufruf getDisplayRep in Zeile 103 immer direkt diese Methode aus der Klasse Displayable aufrufen, ohne sich darum zu k¨ ummern, dass ja eigentlich ein Overriding in einer Ableitung stattgefunden hat. Was wir wollen, ist aber etwas ganz Anderes: In unserem Beispiel soll immer die Methode getDisplayRep aus der abgeleiteten Klasse aufgerufen werden. Nur diese weiß ja, wie das tats¨ achliche Objekt sinnvoll dargestellt wird. Dazu brauchen wir offensichtlich einen anderen Mechanismus, denn zur Compilezeit ist nicht bekannt, um welches Objekt es sich bei einem Aufruf handelt, also kann auch keine endg¨ ultige Adresse f¨ ur die Methode eingesetzt werden. Zum Beispiel soll in Zeile 112 ein Objekt vom Typ OneClass und in Zeile 113 ein Objekt vom Typ AnotherClass angezeigt werden. F¨ ur den Aufruf getDisplayRep in Zeile 103 kann also erst zur Laufzeit eine Entscheidung getroffen werden, welche Implementation von getDisplayRep nun aufgrund des Objekttyps zum Tragen kommt. Dieses Verhalten des Einsetzens des richtigen Aufrufs zur Laufzeit nennt man dynamic Binding. Um dem Compiler mitzuteilen, dass f¨ ur eine bestimmte Methode dynamic Binding vorgenommen werden soll, verwendet man das omin¨ ose Keyword virtual. Diese Eigenschaft, dass eine Klasse sich verschiedenartig verhalten kann, je nachdem, ob man es mit einer Ableitung zu tun hat oder auch, mit welcher Ableitung man es zu tun hat, wird auch als Polymorphismus bezeichnet. Alle Methoden, die als virtual deklariert werden, werden folgendermaßen behandelt: • Objekte haben zur Laufzeit eine sogenannte virtual Table zur Verf¨ ugung, in der alle virtual Methoden mit ihren aktuellen Adressen gem¨aß der Ableitungshierarchie vermerkt sind. • Bei einem Aufruf einer virtual Methode wird ihr Eintrag in der virtual Table nachgeschlagen und die korrekte Implementation, die dem tats¨achlichen Objekt entspricht, aufgerufen. Kompliziert? Keine Sorge, es klingt schlimmer, als es in Wirklichkeit ist. Im folgenden Beispiel ist der Unterschied zwischen static und dynamic Binding schnell zu sehen (static_dynamic_binding_demo.cpp):
224
1 2
9. Klassen in C++
// s t a t i c d y n a m i c b i n d i n g d e m o . cpp − demo f o r the d i f f e r e n c e // between s t a t i c and dynamic bi n di n g o f methods
3 4
#include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Base ∗ ∗ j u s t a base c l a s s ∗ ∗/
16 17 18 19 20 21 22
c l a s s Base { public : void staticallyBoundMethod ( ) const ; virtual void dynamicallyBoundMethod ( ) const ; };
23 24 25 26 27 28 29 30
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ JustAClass ∗ ∗ j u s t a demo c l a s s ∗ ∗/
31 32 33 34 35 36 37
c l a s s JustAClass : public Base { public : void staticallyBoundMethod ( ) const ; virtual void dynamicallyBoundMethod ( ) const ; };
38 39 40 41 42 43
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void Base : : staticallyBoundMethod ( ) const { cout << ”Base : : staticallyBoundMethod c a l l e d ” << e n d l ; }
44 45 46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void Base : : dynamicallyBoundMethod ( ) const { cout << ”Base : : dynamicallyBoundMethod c a l l e d ” << e nd l ; }
50 51 52 53 54 55
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void JustAClass : : staticallyBoundMethod ( ) const { cout << ” JustAClass : : staticallyBoundMethod c a l l e d ” << e n d l ; }
56 57 58 59 60 61
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void JustAClass : : dynamicallyBoundMethod ( ) const { cout << ” JustAClass : : dynamicallyBoundMethod c a l l e d ” << e n d l ; }
62 63 64 65
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void showBindingEffect ( const Base & o b j e c t ) {
9.4 Weitere wichtige technische Aspekte
o b j e c t . staticallyBoundMethod ( ) ; o b j e c t . dynamicallyBoundMethod ( ) ;
66 67 68
225
}
69 70 71 72 73
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { JustAClass t e s t o b j e c t ;
74
cout << ” c a l l i n g the methods on the c o r r e c t type : ” << e nd l ; t e s t o b j e c t . staticallyBoundMethod ( ) ; t e s t o b j e c t . dynamicallyBoundMethod ( ) ;
75 76 77 78
cout << ” c a l l i n g the methods on the base c l a s s : ” << e n d l ; showBindingEffect ( t e s t o b j e c t ) ;
79 80 81
return ( 0 ) ;
82 83
}
Ausf¨ uhren dieses Progr¨ ammchens erfreut unser Auge mit folgendem Output: c a l l i n g the methods on the c o r r e c t type : JustAClass : : staticallyBoundMethod c a l l e d JustAClass : : dynamicallyBoundMethod c a l l e d c a l l i n g the methods on the base c l a s s : Base : : staticallyBoundMethod c a l l e d JustAClass : : dynamicallyBoundMethod c a l l e d
In unserer Basisklasse, die in den Zeilen 17–22 deklariert wird, haben wir zwei Methoden. Eine davon ist statisch gebunden, bei der anderen wird durch das Keyword virtual die dynamische Bindung gefordert. F¨ ur beide Methoden findet ein Overriding in der abgeleiteten Klasse statt, die in den Zeilen 32–37 deklariert ist. In Zeile 73 wird ein Objekt vom Typ der abgeleiteten Klasse angelegt, auf dem in Zeile 76 die statisch gebundene und in Zeile 77 die virtual Methode aufgerufen wird. Wie zu erwarten, zeigt uns der Output, dass in diesem Fall beide Implementationen der Methoden der abgeleiteten Klasse aufgerufen werden. In Zeile 80 jedoch wird die Methode showBindingEffect aufgerufen, die als Parameter ein Objekt vom Typ der Basisklasse nimmt. Dass dieser Aufruf funktioniert, versteht sich von selbst, denn JustAClass ist ja von Base abgeleitet und deshalb voll kompatibel. Sieht man sich nun den Output der Aufrufe aus den Zeilen 66–67 an, so erkennt man Interessantes: • Der Aufruf in Zeile 66 ist ein Aufruf der Implementation der Methode staticallyBoundMethod aus der Klasse Base, obwohl das u ¨bergebene Objekt aber eigentlich vom Typ JustAClass ist! Nur davon konnte der Compiler nichts wissen, denn er hat zur Compilezeit nur gesehen, dass etwas vom Typ Base u ur den ¨bergeben wird und hat die entsprechende Adresse f¨ Methodenaufruf eingesetzt. Es ist also egal, welcher Objekttyp tats¨achlich u ¨bergeben wird, der Aufruf der statisch gebundenen Methode bleibt immer derselbe, n¨ amlich der von Base. • In Zeile 67 wird die dynamisch gebundene Methode aufgerufen und man sieht, dass sich dieser Aufruf gleich ganz anders verh¨alt. Es wird n¨amlich
226
9. Klassen in C++
tats¨achlich die Implementation aus JustAClass f¨ ur den Aufruf herangezogen, denn diese ist in der virtual Table als endg¨ ultiges Overriding in der Hierarchie eingetragen. Es ist also in diesem Fall egal, ob eine virtual Methode auf der Basisklasse oder auf der endg¨ ultigen Klasse aufgerufen wird, das Ergebnis ist immer dasselbe. Vorsicht Falle: Oft wird von Neulingen der Fehler begangen, eine Methode nicht bei ihrem ersten Auftreten in der Basisklasse als virtual zu deklarieren, sondern dies erst weiter unten in der Ableitungshierarchie zu versuchen. Das funktioniert nat¨ urlich nicht und f¨ uhrt unweigerlich zu einem Compilerfehler, denn der Compiler kann nicht zuerst statisches Binding durchf¨ uhren und danach f¨ ur ein und dieselbe Methode pl¨otzlich den Rest dynamisch binden. Sobald eine Methode in der Basisklasse als virtual deklariert ist, muss im Prinzip in abgeleiteten Klassen ein Overriding nicht mehr explizit als virtual deklariert werden. Der Compiler weiß durch die erste Deklaration in der Basis Bescheid und behandelt alle Overridings korrekt. Trotzdem wird es als sauberer Programmierstil angesehen, das Keyword virtual bei allen Overridings in der Ableitungshierarchie zum Signalisieren dieser Eigenschaft hinzuschreiben. Ganz wichtig im Zusammenhang mit dynamic Binding ist es, zu wissen, wie genau die virtual Table zur Laufzeit gebaut wird. In diesem Wissen n¨amlich liegen die Antworten auf viele Fragen und das Werkzeug zur Behebung schwerer Fehler, die leider nicht nur von Neulingen gemacht werden. Greifen wir am besten also gleich in die Tasten und erstellen ein kleines Testprogramm, das sich folgendermaßen liest (virtual_table_build_demo.cpp): 1 2
// v i r t u a l t a b l e b u i l d d e m o . cpp − demo program to show how // the v i r t u a l t a b l e i s b u i l t during runtime
3 4
#include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ BaseA ∗ ∗ j u s t a base c l a s s ∗ ∗/
16 17 18 19 20 21 22 23
c l a s s BaseA { public : BaseA ( ) ; virtual ˜ BaseA ( ) ; virtual void dynamicallyBoundMethod ( ) ; };
24 25 26
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗
9.4 Weitere wichtige technische Aspekte
27 28 29 30 31
∗ BaseB ∗ ∗ j u s t another base c l a s s ∗ ∗/
32 33 34 35 36 37 38 39
c l a s s BaseB { public : BaseB ( ) ; virtual ˜ BaseB ( ) ; virtual void dynamicallyBoundMethod ( ) ; };
40 41 42 43 44 45 46 47
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DerivedA ∗ ∗ j u s t a demo c l a s s ∗ ∗/
48 49 50 51 52 53 54 55
c l a s s DerivedA : public BaseA { public : DerivedA ( ) ; virtual ˜ DerivedA ( ) ; virtual void dynamicallyBoundMethod ( ) ; };
56 57 58 59 60 61 62 63
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DerivedB ∗ ∗ j u s t a demo c l a s s ∗ ∗/
64 65 66 67 68 69 70 71
c l a s s DerivedB : public BaseB { public : DerivedB ( ) ; virtual ˜ DerivedB ( ) ; virtual void dynamicallyBoundMethod ( ) ; };
72 73 74 75 76 77 78 79
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DerivedC ∗ ∗ j u s t a demo c l a s s ∗ ∗/
80 81 82 83 84 85 86 87 88
c l a s s DerivedC : public DerivedA , public DerivedB { public : DerivedC ( ) ; virtual ˜ DerivedC ( ) ; virtual void dynamicallyBoundMethod ( ) ; };
89 90 91 92
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− BaseA : : BaseA ( ) {
227
228
9. Klassen in C++
cout << ” c o n s t r u c t o r o f BaseA” << e nd l ; dynamicallyBoundMethod ( ) ;
93 94 95
}
96 97 98 99 100 101 102
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− BaseA : : ˜ BaseA ( ) { cout << ” d e s t r u c t o r o f BaseA” << e nd l ; dynamicallyBoundMethod ( ) ; }
103 104 105 106 107 108
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void BaseA : : dynamicallyBoundMethod ( ) { cout << ”BaseA : : dynamicallyBoundMethod c a l l e d ” << e n d l ; }
109 110 111 112 113 114 115
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− BaseB : : BaseB ( ) { cout << ” c o n s t r u c t o r o f BaseB” << e n d l ; dynamicallyBoundMethod ( ) ; }
116 117 118 119 120 121 122
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− BaseB : : ˜ BaseB ( ) { cout << ” d e s t r u c t o r o f BaseB” << e n d l ; dynamicallyBoundMethod ( ) ; }
123 124 125 126 127 128
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void BaseB : : dynamicallyBoundMethod ( ) { cout << ”BaseB : : dynamicallyBoundMethod c a l l e d ” << e n d l ; }
129 130 131 132 133 134 135
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedA : : DerivedA ( ) { cout << ” c o n s t r u c t o r o f DerivedA” << e nd l ; dynamicallyBoundMethod ( ) ; }
136 137 138 139 140 141 142
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedA : : ˜ DerivedA ( ) { cout << ” d e s t r u c t o r o f DerivedA” << e n d l ; dynamicallyBoundMethod ( ) ; }
143 144 145 146 147 148
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void DerivedA : : dynamicallyBoundMethod ( ) { cout << ”DerivedA : : dynamicallyBoundMethod c a l l e d ” << e n d l ; }
149 150 151 152 153 154 155
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedB : : DerivedB ( ) { cout << ” c o n s t r u c t o r o f DerivedB” << e n d l ; dynamicallyBoundMethod ( ) ; }
156 157 158
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedB : : ˜ DerivedB ( )
9.4 Weitere wichtige technische Aspekte
159
{ cout << ” d e s t r u c t o r o f DerivedB” << e nd l ; dynamicallyBoundMethod ( ) ;
160 161 162
229
}
163 164 165 166 167 168
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void DerivedB : : dynamicallyBoundMethod ( ) { cout << ”DerivedB : : dynamicallyBoundMethod c a l l e d ” << e n d l ; }
169 170 171 172 173 174 175
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedC : : DerivedC ( ) { cout << ” c o n s t r u c t o r o f DerivedC” << e nd l ; dynamicallyBoundMethod ( ) ; }
176 177 178 179 180 181 182
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− DerivedC : : ˜ DerivedC ( ) { cout << ” d e s t r u c t o r o f DerivedC” << e n d l ; dynamicallyBoundMethod ( ) ; }
183 184 185 186 187 188
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void DerivedC : : dynamicallyBoundMethod ( ) { cout << ”DerivedC : : dynamicallyBoundMethod c a l l e d ” << e n d l ; }
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { cout << ”−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−” << e n d l << ” ∗∗∗∗∗ i n s t a n t i a t i n g t e s t o b j e c t ∗∗∗∗∗ ” << e n d l ; DerivedC t e s t o b j e c t ; cout << ”−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−” << e n d l << ” ∗∗∗∗∗ new t e s t o b j p t r ∗∗∗∗∗ ” << e n d l ; BaseA ∗ t e s t o b j p t r = new DerivedC ; cout << ”−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−” << e n d l << ” ∗∗∗∗∗ c a l l i n g method on t e s t o b j p t r ∗∗∗∗∗ ” << e n d l ; t e s t o b j p t r−>dynamicallyBoundMethod ( ) ; cout << ”−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−” << e n d l << ” ∗∗∗∗∗ d e l e t e t e s t o b j p t r ∗∗∗∗∗ ” << e n d l ; delete t e s t o b j p t r ;
207
cout << ”−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−” << e n d l << ” ∗∗∗∗∗ now t e s t o b j e c t ’ s l i f e t i m e ends ∗∗∗∗∗ ” << e n d l ;
208 209 210
return ( 0 ) ;
211 212
}
Der zugegeben etwas lange Output dieses Programms liest sich folgendermaßen: −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− ∗∗∗∗∗ i n s t a n t i a t i n g t e s t o b j e c t ∗∗∗∗∗ c o n s t r u c t o r o f BaseA BaseA : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f DerivedA DerivedA : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f BaseB
230
9. Klassen in C++
BaseB : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f DerivedB DerivedB : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f DerivedC DerivedC : : dynamicallyBoundMethod c a l l e d −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− ∗∗∗∗∗ new t e s t o b j p t r ∗∗∗∗∗ c o n s t r u c t o r o f BaseA BaseA : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f DerivedA DerivedA : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f BaseB BaseB : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f DerivedB DerivedB : : dynamicallyBoundMethod c a l l e d c o n s t r u c t o r o f DerivedC DerivedC : : dynamicallyBoundMethod c a l l e d −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− ∗∗∗∗∗ c a l l i n g method on t e s t o b j p t r ∗∗∗∗∗ DerivedC : : dynamicallyBoundMethod c a l l e d −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− ∗∗∗∗∗ d e l e t e t e s t o b j p t r ∗∗∗∗∗ d e s t r u c t o r o f DerivedC DerivedC : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f DerivedB DerivedB : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f BaseB BaseB : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f DerivedA DerivedA : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f BaseA BaseA : : dynamicallyBoundMethod c a l l e d −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− ∗∗∗∗∗ now t e s t o b j e c t ’ s l i f e t i m e ends ∗∗∗∗∗ d e s t r u c t o r o f DerivedC DerivedC : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f DerivedB DerivedB : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f BaseB BaseB : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f DerivedA DerivedA : : dynamicallyBoundMethod c a l l e d d e s t r u c t o r o f BaseA BaseA : : dynamicallyBoundMethod c a l l e d
Die erste Auff¨ alligkeit in unserem Testprogr¨ammchen zeigt sich in Zeile 21: Der Destruktor von BaseA ist virtual! Ebenso sind alle Destruktoren aller anderen Klassen als virtual deklariert. Dies hat noch nichts mit dem Bauen der virtual Table an sich zu tun, sondern hat einen ganz anderen aber sehr wichtigen Grund. Dazu werfen wir einmal einen Blick auf die Zeilen 200– 206. In diesen Zeilen wird ein Objekt vom Typ DerivedC dynamisch erzeugt, allerdings wird dieser Pointer in einer Variable vom Typ BaseA * gehalten. Dies ist, wie bereits bekannt ist, absolut zul¨assig und g¨angige Praxis, z.B. bei Parameter¨ ubergaben, wie wir sie schon kennen gelernt haben. In Zeile 204 sieht man wieder, was sowieso schon bekannt ist: Beim Call einer virtual Methode wird zur Laufzeit entschieden, welche der Implementationen in der Ableitungshierarchie nun tats¨ achlich aufgerufen wird. Zeile 206 allerdings ist nun des Pudels Kern, was die omin¨osen virtuellen Destruktoren anbelangt: Stellen wir uns einfach vor, die Destruktoren
9.4 Weitere wichtige technische Aspekte
231
w¨aren statisch und nicht dynamisch gebunden. Dadurch w¨ urde Furchtbares passieren, n¨ amlich dass ausschließlich der Destruktor von BaseA aufgerufen wird. Der Compiler hat ja statisch diesen Aufruf eingef¨ ugt. Was allerdings passieren muss, damit das Programm korrekt funktioniert, ist, dass alle Destruktoren der einzelnen Klassen in der Ableitungshierarchie korrekt aufgerufen werden. Genau das erreicht man nur, wenn man mit dynamic Binding arbeitet, also den Destruktor virtual deklariert. Damit wird zur Laufzeit entschieden, welcher der “tiefste” Destruktor des vorliegenden Objekts ist und von diesem ausgehend werden die anderen Destruktoren, die h¨oher in der Ableitungshierarchie liegen, korrekt aufgerufen. Vorsicht Falle: Die schweißtreibendsten Debug Sessions, an denen schon sehr viele Entwickler verzweifelt sind, ergeben sich bei der Suche nach v¨ollig unerkl¨arlichen Effekten, die durch einen statisch gebundenen Destruktor trotz dynamischer Verwendung von Objekten zustande kommen. Aus diesem Grund lautet die Grundregel Nummer eins beim Entwickeln von C++ Programmen: Jede Klasse muss unbedingt einen virtual Destruktor besitzen! Auch wenn dies in manchen F¨ allen aufgrund der Verwendung als nicht notwendig erscheint, so ist man auf jeden Fall bei Einhalten dieser Regel immer auf der sicheren Seite. Vor allem passiert es oft genug, dass nach Programm¨anderungen Objekte polymorph verwendet werden, obwohl das zuvor beim Grunddesign der Klasse noch gar nicht der Fall und leider auch nicht mitgeplant war. Und genau dann kommt es garantiert zur Katastrophe. Zum Gl¨ uck warnen moderne Compiler, wenn man in einer Klasse virtual Methoden hat, aber den Destruktor nicht virtual deklariert. Nun zum eigentlichen Thema unseres Testprogramms: Es galt, herauszufinden, wie die virtual Table aufgebaut (und auch wieder abgebaut) wird. Um festzustellen, welche Implementation der dynamisch gebundenen Methode gerade die “letzte aktive” in der virtual Table ist, wurden in die einzelnen Konstruktoren und in die einzelnen Destruktoren Outputs eingebaut. Betrachtet man den Output, der beim Erzeugen von test_object entsteht, so kann man Folgendes beobachten: • Im Konstruktor von BaseA wird dynamicallyBoundMethod aufgerufen. Der tats¨ achlich ausgef¨ uhrte Call ist die Implementation, die durch BaseA gegeben ist. • Im Konstruktor der davon abgeleiteten Klasse DerivedA erfolgt, wie man sieht, ein Aufruf der Implementation, die durch DerivedA vorgegeben ist. • Selbiges passiert in allen anderen Konstruktoren, die der Reihe nach bis zum fertigen Objekt aufgerufen werden. Was sagt uns das in Bezug auf die virtual Table? Nun, ganz einfach: Die virtual Table wird sukzessive gemeinsam mit dem Aufruf der Konstruktoren
232
9. Klassen in C++
aufgebaut. Sobald ein Objekt konstruiert wird, wird ein Update der virtual Table vorgenommen, das die neuen Overridings reflektiert. Es kann also niemals passieren, dass f¨ ur einen Methodenaufruf in einem Konstruktor aufgrund von dynamic Binding eine Methode eingesetzt wird, die aus einem Teil der Ableitungshierarchie kommt, der noch gar nicht konstruiert ist. Dies ist auch sehr gut so, denn stellen wir uns vor, was passieren w¨ urde, wenn die virtual Table vor dem Aufruf der einzelnen Konstruktoren bereits fertig erstellt worden w¨ are: Dann wird z.B. in BaseA unsere dynamisch gebundene Methode aufgerufen und es w¨ urde tats¨achlich der Call der Implementation aus DerivedC eingesetzt. Dieser ist ja bei fertig konstruiertem Objekt das endg¨ ultig letzte Overriding. Aber damit w¨ urde dann eine Methode aus einem Objekt aufgerufen, das noch nicht einmal fertig konstruiert ist, denn der Konstruktoraufruf von DerivedC erfolgt ja erst viel sp¨ater! Zugegeben, es gibt Situationen, in denen dieses Verhalten sogar recht gut einsetzbar w¨ are. Eine solche Situation w¨are z.B. die Implementation eines Callbacks, u ¨ber das man Daten aus abgeleiteten Klassen bekommt, die man im Konstruktor einer der Basisklassen ben¨otigt. Allerdings l¨asst sich dieses Problem auch anders l¨ osen und die Gefahr dieses Verhaltens ist bei weitem gr¨oßer als der Nutzen. Spinnt man den Gedanken zu Ende, dass eine Methode nur auf einem “lebendigen” Objekt aufgerufen werden darf, um nicht ins offene Messer zu laufen, dann kann man sich auch sofort vorstellen, wie die virtual Table beim Aufruf der einzelnen Destruktoren wieder abgebaut wird. Sobald ein Destruktor ausgef¨ uhrt wurde, wird ein Update der virtual Table vorgenommen, in dem die Overridings des nun zerst¨orten Objekts wieder entfernt werden. Auch das dient wieder der Sicherheit, denn wenn ein Objekt einmal destruiert wurde, dann darf nat¨ urlich keine Methode mehr darauf aufgerufen werden. Dieses Verhalten l¨ asst sich auch im Output unseres Programms leicht erkennen. Die Methodenaufrufe in den einzelnen Destruktoren beziehen sich immer auf das “letzte noch lebendige” Objekt in der Hierarchie. 9.4.2 Abstrakte Methoden und Klassen Bisher haben wir mehrfach kennen gelernt, dass man in einer Basisklasse verlangen will, dass eine abgeleitete Klasse eine Methode overrided, weil man in der Basisklasse gar keine vern¨ unftige Implementation einer Methode schreiben kann. Nehmen wir nur das Beispiel mit dem Displayable Objekt her: Die Eigenschaft, Displayable zu sein, bedeutet, dass eine Klasse, die davon abgeleitet ist, eine Methode getDisplayRep zur Verf¨ ugung stellt. Die Basisklasse Displayable selbst weiß nichts u ber die von ihr abgeleiteten Klassen ¨ und kann dementsprechend gar keine vern¨ unftige Implementation liefern. Was wir also haben wollen, ist eine M¨oglichkeit, in einer Basisklasse zu sagen: Die Methode getDisplayRep existiert und ist folgendermaßen aufrufbar. Sie muss allerdings immer in der abgeleiteten Klasse implementiert werden.
9.4 Weitere wichtige technische Aspekte
233
Eine solche Vorschrift nennt man eine abstrakte Methode. Von ihr ist in der Basis nur die Signatur (=das Interface) bekannt, aber es gibt dort noch keine Implementation davon. Klassen, die solche abstrakten Methoden enthalten, nennt man abstrakte Klassen. Im Extremfall kann es sogar sein, dass eine Klasse ausschließlich abstrakte Methoden enth¨alt. Dann spricht man von rein abstrakten Klassen bzw. auch in Anlehnung an Java von reinen Interfaces. Die Realisierung unseres Wunsches nach einem solchen Konstrukt ist einfach, wie man an folgendem Beispiel sieht (abstract_displayable.h): 1
// a b s t r a c t d i s p l a y a b l e . h − a b s t r a c t d i s p l a y a b l e base c l a s s
2 3 4
#i f n d e f a b s t r a c t d i s p l a y a b l e h #define a b s t r a c t d i s p l a y a b l e h
5 6 7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Displayable ∗ ∗ a b s t r a c t base c l a s s f o r d i s p l a y a b l e o b j e c t s ∗ ∗/
13 14 15 16 17
class Displayable { public : virtual ˜ D i s p l a y a b l e ( ) { }
18
virtual const char ∗ getDisplayRep ( ) const = 0 ;
19 20
};
21 22 23
#endif // a b s t r a c t d i s p l a y a b l e h
In dieser Klassendeklaration sind zwei kleine Neuigkeiten versteckt: • Zeile 19 zeigt, wie man eine abstrakte Methode deklariert: Man schreibt einfach = 0 hinter die Signatur. Wieso das so geschrieben wird, m¨ochte ich hier einfach einmal salopp erkl¨ aren: Eine Methode ist nat¨ urlich, wie auch ¨ eine Funktion, nach der Ubersetzung durch den Compiler und Aufl¨osung durch den Linker einfach ein Pointer, der die Einsprungadresse repr¨asentiert. Durch die Schreibweise = 0 teilt man dem Compiler einfach mit, dass diese Methode auf Adresse 0 zu liegen kommt, was bedeutet, dass sie nicht aufgerufen werden kann. Damit kann man sich nat¨ urlich auch die Implementation dazu sparen. Ich m¨ ochte noch dazu sagen, dass diese saloppe Erkl¨arung nicht zu 100% den Tatsachen entspricht, da noch andere Dinge hier mit ins Spiel kommen, aber als Gedankenmodell ist sie in Ordnung. • In Zeile 17 sieht man, dass es nicht nur inline Funktionen, sondern logischerweise auch inline Methoden gibt. Bei diesen kann das Keyword inline, wie hier geschehen, auch weggelassen werden, denn das Vorkommen einer Implementation (hier einer leeren Implementation) innerhalb einer Klassendeklaration ist automatisch schon ein Kennzeichen f¨ ur inline.
234
9. Klassen in C++
Das bringt uns auch gleich auf den Punkt, wie man eine inline Methode deklariert und gleichzeitig definiert: Man schreibt die Implementation direkt hinter die Deklaration. Den Strichpunkt hinter der schließenden Klammer der Implementation kann man sich nat¨ urlich sparen. Zu den abstrakten Methoden w¨ are noch zu erw¨ahnen, dass klarerweise ausschließlich virtual Methoden abstrakt sein k¨onnen. Ansonsten m¨ usste ja static Binding mit einer nicht existenten Methode stattfinden. Dass das der Compiler nicht besonders toll findet und entsprechende Kommentare von sich gibt, l¨asst sich leicht vorstellen. Aus der Eigenschaft abstrakter Methoden, dass sie auf jeden Fall virtual sein m¨ ussen, resultiert auch noch ein anderer sehr gebr¨ auchlicher Name f¨ ur sie: Man nennt sie auch pure virtual Methods. Verwendet wird eine abstrakte Basisklasse erwartungsgem¨aß gleich, wie jede andere Klasse auch. Sehen wir uns diese Verwendung einmal an (abstract_displayable_demo.cpp): 1 2
// a b s t r a c t d i s p l a y a b l e d e m o . cpp − s m a l l demo program f o r the // abstract Displayable c l a s s
3 4 5 6
#include ” a b s t r a c t d i s p l a y a b l e . h” #include < c s t r i n g> #include < i o s t r e a m>
7 8 9
using s t d : : cout ; using s t d : : e nd l ;
10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ TextObject ∗ ∗ j u s t a t e x t o b j e c t f o r demo purposes ∗ ∗/
18 19 20 21 22 23 24 25
c l a s s TextObject : public D i s p l a y a b l e { protected : char ∗ t e x t ; public : TextObject ( const char ∗ t e x t ) ; virtual ˜ TextObject ( ) ;
26
virtual const char ∗ getDisplayRep ( ) const { return ( t e x t ) ; }
27 28 29 30 31
};
32 33 34 35 36
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r ∗ @param t e x t the t e x t h el d i n t h i s o b j e c t ∗/
37 38 39 40 41 42 43
TextObject : : TextObject ( const char ∗ t e x t ) { i f ( ! text ) { text = 0; return ;
9.4 Weitere wichtige technische Aspekte
} t e x t = new char [ s t r l e n ( t e x t ) + 1 ] ; strcpy ( text , text ) ;
44 45 46 47
235
}
48 49 50 51
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
52 53 54 55 56 57
TextObject : : ˜ TextObject ( ) { if ( text ) delete [ ] t e x t ; }
58 59 60 61 62 63
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void d i s p l a y O b j e c t ( const D i s p l a y a b l e & o b j e c t ) { cout << o b j e c t . getDisplayRep () << e n d l ; }
64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { TextObject my text ( ” j u s t a t e x t ” ) ;
69
d i s p l a y O b j e c t ( my text ) ; return ( 0 ) ;
70 71 72
}
Dass von diesem Demoprogr¨ ammchen der weltbewegende Output just a text erzeugt wird, ist selbstredend. Besondere Implementationsdetails verstecken sich hier auch nicht, die gesondert besprochen werden m¨ ussten. Jedoch m¨ ochte ich auf die Zeilen 27–30 hinweisen, die eine inline Methode mit einem etwas sinnvollerem Inhalt als “nichts” darstellen. Vorsicht Falle: Wie man sich leicht vorstellen kann, kann keine Instanz einer abstrakten Basisklasse erzeugt werden. Welche Adresse sollte auch f¨ ur einen Methodenaufruf eingesetzt werden, wenn die Implementation, die aufgerufen werden sollte, gar nicht existiert? Vergisst man also z.B. in einer Ableitung, eine der abstrakten Methoden zu implementieren, so wird der Versuch des Erzeugens einer Instanz mit entsprechenden Fehlermeldungen quittiert. Selbiges passiert nat¨ urlich, wenn man versucht, eine Instanz der abstrakte Basisklasse selbst zu erzeugen. Leider gibt es immer noch Entwicklungsumgebungen, die in einem solchen Fall recht omin¨ ose Fehlermeldungen von sich geben. Schlimmstenfalls meint der Linker, dass er sogenannte unresolved externals gefunden hat. Hier hilft meist ein wenig Intuition, um zu sehen, dass das Problem am Vergessen einer Implementation f¨ ur eine abstrakte Methode liegt.
9.4.3 Virtuelle Ableitung Bisher haben wir zum Thema dynamic Binding das Keyword virtual in Zusammenhang mit Methoden kennen gelernt. Es gibt allerdings noch einen
236
9. Klassen in C++
anderen Aspekt von dynamic Binding, der beim Design von Klassenhierarchien sehr wichtig ist: virtuelle Ableitungen. Nehmen wir an, wir wollten eine Klassenhierarchie, wie in Abbildung 9.2 dargestellt, modellieren.
Displayable
TextuallyDisplayable
GraphicallyDisplayable
Printable
GameCard
Abbildung 9.2: Eine etwas kniffligere Klassenhierarchie
Die Klassenhierarchie in Abbildung 9.2 beruht auf folgendem Design: • Es gibt eine Klasse Displayable, die die Basis f¨ ur alle anzeigbaren Objekte verk¨orpert, egal auf welchem Device oder wie diese dargestellt werden. • Von Displayable abgeleitet ist eine Klasse TextuallyDisplayable, die die Basis f¨ ur alle Klassen verk¨ orpert, die textuell dargestellt werden k¨onnen. • Von Displayable abgeleitet ist eine Klasse GraphicallyDisplayable, die die Basis f¨ ur alle Klassen verk¨ orpert, die graphisch dargestellt werden k¨onnen. • Von Displayable abgeleitet ist eine Klasse Printable, die die Basis f¨ ur alle Klassen verk¨ orpert, die ausgedruckt werden k¨onnen (= am Drucker dargestellt werden k¨ onnen). • Eine Spielkarte ist nun so definiert, dass sie sowohl textuell als auch graphisch dargestellt und auch ausgedruckt werden kann. Damit ist GameCard von allen drei entsprechenden Klassen abgeleitet. Neben anderen Dingen sieht man an diesem Design auch, dass oft viele Wege nach Rom f¨ uhren. Fr¨ uher hatten wir es mit einem Design zu tun, in dem Printable und Displayable zwei voneinander unabh¨angige Basisklassen waren. Nun haben wir es mit der Interpretation zu tun, dass etwas, was druckbar ist, nat¨ urlich darstellbar ist, und zwar am Drucker. Selbiges gilt f¨ ur die Textausgabe, denn sie ist semantisch in unserem Design etwas, was darstellbar ist, und zwar auf einem Textbildschirm. Analog dazu ist die Interpretation f¨ ur etwas graphisch Darstellbares. Genau diese verschiedenen Interpretationsm¨oglichkeiten ein- und desselben Sachverhalts sind es auch, die Erfahrung und eine ausf¨ uhrliche Designphase bei der OO Softwareentwicklung so unabdingbar machen. Erfahrene und gute Softwareentwickler werden einerseits schon von Beginn an viele
9.4 Weitere wichtige technische Aspekte
237
Designaspekte im Hinterkopf haben, die sich positiv auf ein klares Design auswirken. Weiters werden solche Entwickler immer wieder u ¨berlegen, wie man ein- und denselben Sachverhalt vielleicht auch anders und damit schl¨ ussiger und einfacher darstellen k¨ onnte. Auf diese Art finden sie das (hoffentlich) sinnvollste aus mehreren verschiedenen m¨oglichen Modellen f¨ ur eine Problemstellung. Unerfahrenere Entwickler (und auch schlimme Hacker) greifen gerne zum ersten Modell, das ihnen logisch erscheint und dieses wird dann ohne R¨ ucksicht auf Verluste bis zum bitteren Ende durchgezogen. Dadurch werden allerdings Programme zumeist unglaublich kompliziert und das Resultat l¨asst jedes logische semantische Modell vermissen. Somit ist der b¨ose Hack per¨ fekt, den sich niemand mehr zu ¨ andern traut, denn jede Anderung an einem Teil der Software wirkt sich m¨ orderisch in anderen Teilen derselben aus. Ich weiß schon, dass hier wieder einmal ein bisschen der Missionar in puncto sauberes Design mit mir durchgegangen ist, aber die Gelegenheit war einfach zu gut :-). Also zur¨ uck zum Thema: Nehmen wir einfach einmal hin, dass die in Abbildung 9.2 skizzierte Klassenhierarchie f¨ ur unser Problem das schl¨ ussigste Modell darstellt. Nach dem bisherigen Wissensstand l¨asst es sich allerdings nicht in C++ Code umsetzen, ohne dass sich der Compiler ¨ f¨ urchterlich beschwert und die Ubersetzung verweigert! Den Grund f¨ ur diese Beschwerde finden wir im Speicherabbild, das der Compiler aus unseren einzelnen Klassen in der Ableitungshierarchie erzeugt: • Wir wissen, dass jede Klasse beim Ableiten alle Eigenschaften der Basis vollst¨andig erbt. • Das bedeutet, dass beim Erzeugen der Instanz eines Objekts f¨ ur alle einzelnen Teil-Objekte, die aus den Klassen in der Hierarchie resultieren, Speicher reserviert wird. Die Speicherbereiche bei Ableitungshierarchien kommen hintereinander zu liegen. • Nehmen wir nur die obersten beiden Ebenen der in Abbildung 9.2 skizzierten Hierarchie, so ergibt sich folgendes Bild: – Ein TextuallyDisplayable Objekt besteht aus zwei Teilobjekten. Es wird also Speicher f¨ ur Displayable und weiters Speicher f¨ ur alles Weitere reserviert, das TextuallyDisplayable noch an Members dazudefiniert. – F¨ ur GraphicallyDisplayable und auch f¨ ur Printable gilt dasselbe. • Beziehen wir nun auch noch die dritte Ebene der Hierarchie in unsere Betrachtungen mit ein, dann vervollst¨ andigt sich das Bild dahingehend, dass f¨ ur eine GameCard folgende Sub-Objekte ihren Speicherblock bekommen: – TextuallyDisplayable – GraphicallyDisplayable – Printable Und genau hier liegt das Problem: Zieht man in Betracht, dass jedes dieser drei Sub-Objekte selbst wieder Displayable als Sub-Objekt besitzt, dann
238
9. Klassen in C++
bedeutet das ja, dass Displayable gleich drei Mal in ein- und demselben Objekt vorkommt! Dass das nicht im Sinne des Erfinders sein kann, ist klar, denn eigentlich darf es nur einmal vorkommen, sonst ist keine Konsistenz mehr erzielbar! Abgesehen vom Speicheraspekt, den man auch mit besonderer Ber¨ ucksichtigung beim static Binding in den Griff bekommen k¨onnte, w¨ urden mit einer solchen Konstruktion noch weitere Probleme auftauchen, die nur noch durch dynamic Binding zu l¨ osen sind, z.B., dass nat¨ urlich der Konstruktor der Basisklasse nur einmal und nicht drei Mal aufzurufen ist. Es ist also in einem solchen Fall notwendig, dass die Basisklasse nur einmal im Speicherabbild existiert und dass sich Member-Zugriffe und alle Methodenaufrufe incl. Konstruktor und Destruktor konsistent auf diese Basis beziehen. Mittels dynamic Binding ist dies m¨ oglich, da in diesem Fall erst zur Laufzeit die dann bekannten und korrekten Adressen eingesetzt werden. Die daf¨ ur notwendige virtuelle Ableitung erreicht man, indem man bei der Deklaration einer abgeleiteten Klasse das Keyword virtual zur Angabe der Basisklasse hinzuf¨ ugt. Am besten, wir sehen uns eine m¨ogliche minimale Implementation unserer in Abbildung 9.2 skizzierten Hierarchie an. Die folgende Implementation wird uns auch gleich Auskunft u ¨ber eine Besonderheit beim Konstruktoraufruf geben. Da das Programm virtual_derivation_demo.cpp etwas lang ist, werden in der Folge nur die einzelnen Teile hier inkludiert und besprochen, die f¨ ur unsere Betrachtungen Relevanz haben. Zuallererst sehen wir uns einmal die Deklaration von Displayable an: 37 38 39 40 41 42
class Displayable { public : Displayable ( ) ; D i s p l a y a b l e ( int whatever ) ; virtual ˜ D i s p l a y a b l e ( ) ;
43
virtual const DisplayRep &getDisplayRep ( ) const = 0 ;
44 45
};
Nur zu Demonstrationszwecken zum Thema Besonderheit beim Konstruktoraufruf wurden in den Zeilen 40–41 zwei verschiedene Konstruktoren deklariert. In Zeile 44 wird eine const Referenz auf ein Objekt vom Typ DisplayRep retourniert. Die Klasse DisplayRep entspricht einfach einer allgemein darstellbaren Repr¨ asentation, von der dann wiederum die besonderen textuellen, graphischen, etc. Repr¨ asentationen abgeleitet werden sollen. In unserem Testprogramm ist dies nicht implementiert, hier ist einfach eine Billig-Implementation verewigt, da eine sinnvolle Implementation nichts zu den hier im Programm demonstrierten Aspekten beitr¨agt. Stellvertretend f¨ ur die verschiedenen Arten von anzeigbaren Klassen werfen wir einen Blick auf TextuallyDisplayable:
9.4 Weitere wichtige technische Aspekte
55 56 57 58 59 60
239
c l a s s T e x t u a l l y D i s p l a y a b l e : public virtual D i s p l a y a b l e { public : TextuallyDisplayable ( ) ; virtual ˜ T e x t u a l l y D i s p l a y a b l e ( ) ; };
In Zeile 55 erkennt man, wie man eine virtuelle Ableitung in C++ formuliert. Ansonsten sind hier keine erw¨ ahnenswerten Besonderheiten zu finden. Die anderen direkt abgeleiteten Klassen, also GraphicallyDisplayable und Printable sind analog deklariert. Die Klasse GameCard, die schließlich von den drei verschiedenen darstellbaren Klassen abgeleitet ist, liest sich folgendermaßen: 100 101 102 103 104 105 106 107 108
c l a s s GameCard : public T e x t u a l l y D i s p l a y a b l e , public G r a p h i c a l l y D i s p l a y a b l e , public P r i n t a b l e { protected : DisplayRep c u r r e n t d i s p l a y r e p ; public : GameCard ( ) ; virtual ˜GameCard ( ) ;
109
virtual const DisplayRep &getDisplayRep ( ) const ;
110 111
};
In den Implementationen der verschiedenen Konstruktoren und Destruktoren finden wir wieder den schon altbekannten Output, der uns anzeigt, wer nun wann aufgerufen wird. Stellvertretend f¨ ur diese werfen wir einfach einen Blick auf Konstruktor und Destruktor von TextuallyDisplayable: 140 141 142
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r ∗/
143 144 145 146 147
TextuallyDisplayable : : TextuallyDisplayable () { cout << ” c o n s t r u c t o r o f T e x t u a l l y D i s p l a y a b l e c a l l e d ” << e n d l ; }
148 149 150 151
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ d e s t r u c t o r ∗/
152 153 154 155 156
TextuallyDisplayable : : ˜ TextuallyDisplayable () { cout << ” d e s t r u c t o r o f T e x t u a l l y D i s p l a y a b l e c a l l e d ” << e n d l ; }
So weit ist noch nichts Ungew¨ ohnliches zu finden, bis wir auf die Implementation des Konstruktors der Klasse GameCard stoßen:
240
198 199 200 201
9. Klassen in C++
GameCard : : GameCard ( ) : D i s p l a y a b l e ( 1 7 ) { cout << ” c o n s t r u c t o r o f GameCard c a l l e d ” << e n d l ; }
Hier ist doch glatt ein expliziter Konstruktoraufruf f¨ ur die Klasse Displayable zu finden, obwohl GameCard nicht einmal von Displayable (direkt) abgeleitet ist. Was soll das denn nun wieder bedeuten? Falsch geraten... Es bedeutet nicht, dass ich gerade vollkommen durchgeknallt bin. Vielmehr ist das die bereits angesprochene notwendige Besonderheit beim Konstruktoraufruf, der sich durch die virtuelle Ableitung ergibt. F¨ uhrt man sich die Situationen zu Gem¨ ute, die bei einer solchen Ableitungshierarchie auftreten k¨ onnen, dann findet man schnell ein Beispiel, das den Compiler zur Verzweiflung treiben k¨ onnte: • Die Basis Displayable hat zwei verschiedene Konstruktoren. • Nehmen wir an, Printable w¨ urde den Konstruktor explizit aufrufen, der den int Parameter nimmt. • Nehmen wir weiters an, die anderen abgeleiteten Klassen w¨ urden den default Konstruktor verlangen. Nun wissen wir aber, dass nur ein einziger Konstruktoraufruf stattfinden darf, denn ein Objekt kann ja nicht mehrfach konstruiert werden. Wie soll aber nun der Compiler entscheiden, welchen der Konstruktoren er nehmen soll? Genau aus diesem Grund gibt es bei virtuellen Ableitungen eine Regel: Die Klasse in der virtuellen Ableitungshierarchie, bei der die einzelnen Zweige wieder zusammengef¨ uhrt werden, bestimmt, welcher Konstruktor der virtuellen Basisklasse aufgerufen wird. Der Output unseres Demoprogr¨ ammchens beweist, dass dies auch wirklich funktioniert, wie eben beschrieben: int constructor of Displayable c a l l e d constructor of TextuallyDisplayable c a l l e d constructor of GraphicallyDisplayable called constructor of Printable called c o n s t r u c t o r o f GameCard c a l l e d now the l i f e t i m e o f demo card ends d e s t r u c t o r o f GameCard c a l l e d destructor of Printable called destructor of GraphicallyDisplayable called destructor of TextuallyDisplayable c a l l e d destructor of Displayable c a l l e d
Vorsicht Falle: Nur zu gerne wird vergessen, dass es erstens virtuelle Ableitungen gibt und wann man diese einsetzt. Damit sind dann zu einem ¨ sp¨ateren Zeitpunkt gewisse Hierarchien ohne Anderungen in einigen Klassen nicht mehr modellierbar. Dies ist besonders heimt¨ uckisch, wenn solche Fehler in kommerziellen Libraries passieren, zu denen Kunden den SourceCode nicht im Zugriff haben. Auch mehrere namhafte Firmen haben sich
9.4 Weitere wichtige technische Aspekte
241
schon den zweifelhaften Luxus solcher schweren Designfehler geleistet und als Konsequenz zahlreiche aufw¨ andige Workarounds verursacht (z.B. durch einen solchen Fehler in einer GUI-Klassenbibliothek). Als Grundregel zur Verwendung von virtual bei einer Ableitung gilt: Leitet man getrennt voneinander mehrere Klassen von ein- und derselben Basis ab und gibt es eine sinnvolle M¨ oglichkeit, dass Mehrfachvererbung mit diesen Klassen als Basis direkt oder indirekt stattfinden k¨ onnte, dann ist unbedingt virtuell abzuleiten.
9.4.4 Downcasts von Klassen Es wurde bereits mehrfach erw¨ ahnt, dass abgeleitete Klassen vollst¨andig kompatibel zu ihrer bzw. ihren Basisklassen sind und auch so angesprochen werden k¨ onnen. Aus diesem Grund gibt es ja auch den Mechanismus des dynamic Bindings von Methoden, um sicherzustellen, dass immer die “richtige” Methode, also die der letzten Ableitung, die ein Overriding vornimmt, aufgerufen wird. Diese impliziten oder expliziten Casts von Klassen auf eine ihrer Basisklassen werden auch als Upcasts bezeichnet. Der Name kommt daher, dass Basisklassen, wie bereits bekannt, als dar¨ uber liegend in Bezug auf die Ableitungshierarchie betrachtet werden. Spinnt man diesen Gedanken weiter, dann kommt man zur Erkenntnis, dass es ja auch das Gegenteil geben muss, n¨amlich einen Downcast. Wurde z.B. eine Referenz auf ein Objekt als Parameter bei einem Methodenaufruf u ¨bergeben, wobei der Typ der Referenz einer der Basisklassen entspricht, dann muss es doch m¨ oglich sein, u ¨ber einen Downcast wieder auf den “richtigen” Typ zuzugreifen. Man m¨ usste ja nur dem Compiler erkl¨aren, dass er es in Wirklichkeit sowieso nicht mit der Basis, sondern mit einem abgeleiteten Typ zu tun hat. Ich formuliere es einmal vorsichtig: Downcasts sind nat¨ urlich m¨ oglich und auch in verschiedenen F¨allen sehr sinnvoll, aber nur, wenn man genau weiß, was man tut! Bevor ich mich aber jetzt u ¨ber die Gefahren von Downcasts auslasse, die vor allem dann ganz besonders lauern, wenn man ein Fehldesign auf diese Art zu kaschieren versucht, sehen wir uns einfach einmal an einem Beispielchen an, wie dieser Mechanismus funktioniert (downcast_demo.cpp): 1
// downcast demo . cpp − s m a l l example to demonstrate downcasts
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Event ∗ ∗ base c l a s s f o r e v e n t s
242
14 15
9. Klassen in C++
∗ ∗/
16 17 18 19 20 21 22 23
c l a s s Event { protected : int32 event type ; public : s t a t i c const i n t 3 2 KEY EVENT = 0x1 ; s t a t i c const i n t 3 2 MOUSE EVENT = 0 x2 ;
24
Event ( int e v e n t t y p e ) { e v e n t t y p e virtual ˜ Event ( ) { }
25 26
= event type ; }
27
i n t 3 2 getType ( ) const { return ( e v e n t t y p e ) ; }
28 29
};
30 31 32 33 34 35 36 37
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ KeyEvent ∗ ∗ c l a s s f o r Keyboard Events ∗ ∗/
38 39 40 41 42 43 44
c l a s s KeyEvent : public Event { protected : char key ; public : KeyEvent ( char key ) ;
45
char getKey ( ) const { return ( key ) ; }
46 47
};
48 49 50 51 52 53 54 55
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MouseEvent ∗ ∗ c l a s s f o r Mouse Events ∗ ∗/
56 57 58 59 60 61 62 63 64 65 66
c l a s s MouseEvent : public Event { protected : int32 x pos ; int32 y pos ; i n t 3 2 button mask ; public : s t a t i c const i n t 3 2 RIGHT BUTTON = 0x1 ; s t a t i c const i n t 3 2 MIDDLE BUTTON = 0 x2 ; s t a t i c const i n t 3 2 LEFT BUTTON = 0x4 ;
67
MouseEvent ( i n t 3 2 x pos , i n t 3 2 y pos , i n t 3 2 button mask ) ;
68 69
i n t 3 2 getXPos ( ) const { return ( x p o s ) ; } i n t 3 2 getYPos ( ) const { return ( y p o s ) ; } bool i s L e f t B u t t o n P r e s s e d ( ) const { return ( button mask & LEFT BUTTON) ; }
70 71 72 73 74 75 76
};
77 78 79
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗
9.4 Weitere wichtige technische Aspekte
80 81 82 83 84
∗ EventHandler ∗ ∗ c l a s s f o r Event Handlers ∗ ∗/
85 86 87 88 89
c l a s s EventHandler { public : virtual ˜ EventHandler ( ) { } ;
90
virtual void handleEvent ( const Event & event ) ;
91 92
};
93 94 95 96
const i n t 3 2 Event : : KEY EVENT; const i n t 3 2 Event : :MOUSE EVENT;
97 98 99 100
const i n t 3 2 MouseEvent : : RIGHT BUTTON; const i n t 3 2 MouseEvent : : MIDDLE BUTTON; const i n t 3 2 MouseEvent : : LEFT BUTTON;
101 102 103 104 105
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param key the key t h a t was p r e s s e d ∗/
106 107 108 109 110
KeyEvent : : KeyEvent ( char key ) : Event (KEY EVENT) { key = key ; }
111 112 113 114 115 116 117
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param x pos the x p o s i t i o n o f the mouse p o i n t e r ∗ @param y pos the y p o s i t i o n o f the mouse p o i n t e r ∗ @param button mask the buttons t h a t were p r e s s e d ∗/
118 119 120 121 122 123 124 125
MouseEvent : : MouseEvent ( i n t 3 2 x pos , i n t 3 2 y pos , i n t 3 2 button mask ) : Event (MOUSE EVENT) { x p o s = x pos ; y p o s = y pos ; button mask = button mask ; }
126 127 128 129 130 131
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param the event to be handled the event to be handled ∗ ∗/
132 133 134 135 136 137 138 139 140 141 142 143 144 145
void EventHandler : : handleEvent ( const Event & event ) { switch ( event . getType ( ) ) { case Event : : KEY EVENT: { const KeyEvent & k e y e v e n t = dynamic cast(event ) ; cout << ” got KeyEvent , key was : ” << k e y e v e n t . getKey () << e n d l ; break ; } case Event : :MOUSE EVENT:
243
244
9. Klassen in C++
{
146
const MouseEvent &mouse event = dynamic cast(event ) ; cout << ” got MouseEvent , c o o r d i n a t e s : ” << mouse event . getXPos () << ” , ” << mouse event . getYPos () << e n d l ; break ;
147 148 149 150 151 152
} default : cout << ” got unknown Event type ” << e nd l ; }
153 154 155 156 157
}
158 159 160 161 162 163 164
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { EventHandler e v e n t h a n d l e r ; KeyEvent k e y e v e n t ( ’ x ’ ) ; MouseEvent mouse event ( 1 0 , 2 0 , MouseEvent : : LEFT BUTTON) ;
165
e v e n t h a n d l e r . handleEvent ( k e y e v e n t ) ; e v e n t h a n d l e r . handleEvent ( mouse event ) ; return ( 0 ) ;
166 167 168 169
}
In diesem Programm wird folgendes Modell implementiert: • Es gibt eine Klasse Event, die die Basis f¨ ur alle verschiedenen Events darstellt, die einem Programm zugestellt werden k¨onnen (z.B. eine Taste wurde gedr¨ uckt, die Maus wurde bewegt, etc.). Das Einzige, was alle Events gemeinsam haben, ist der Event-Typ. Genau dieser wird in einer Member Variable gehalten und ist abfragbar. • Es gibt eine Klasse KeyEvent, die eine Spezialisierung der Klasse Event darstellt. Ein KeyEvent ist ein Event, der auch die Taste speichert, die auf der Tastatur gedr¨ uckt wurde. • Es gibt eine Klasse MouseEvent. Diese ist eine Analogie zum KeyEvent, nur eben f¨ ur die Maus. • Es gibt eine Klasse EventHandler. Diese ist daf¨ ur verantwortlich, alle verschiedenen Events, die hereinkommen, entsprechend zu behandeln. Die Behandlung erfolgt in der Methode handleEvent. Dieses Modell ist z.B. in praktisch allen heute existenten GUI-Klassenbibliotheken implementiert. Die Details u ¨ber die verschiedenen Events lassen wir jetzt einmal außer Acht, da sie nichts Sinnvolles zur Diskussion beitragen. Interessant f¨ ur unsere Betrachtungen bez¨ uglich Downcasts ist die Methode handleEvent in den Zeilen 133–157. Ich gebe ja zu, dass dieses switch Statement in der Realit¨ at durch eine sinnvollere Implementation, z.B. mittels Hashmaps, ersetzt werden m¨ usste, aber das tut im Augenblick nichts zur Sache. Im switch Statement wird eine Unterscheidung getroffen, ob es sich beim u ¨bergebenen Event nun um einen Key- oder um einen Mouse-Event handelt und diese beiden werden einer verschiedenen Behandlung zugef¨ uhrt. Im Fall eines Key-Events wird der entsprechende Key ausgegeben, im Fall eines Mouse-Events werden die Koordinaten ausgegeben.
9.4 Weitere wichtige technische Aspekte
245
Nun ist es aber so, dass der u ¨bergebene Parameter event vom Typ Event ist. Dort sind also die speziellen Methoden der davon abgeleiteten Klassen KeyEvent und MouseEvent noch nicht bekannt, denn diese werden ja erst in den abgeleiteten Klassen deklariert. Also k¨onnen sie direkt auf event nat¨ urlich auch nicht aufgerufen werden. Der Compiler w¨ urde ja z.B. bei einem Aufruf event.getKey(); gar nicht sehr freundlich reagieren und anmerken, dass man sich gef¨alligst einmal die Deklaration der Klasse Event ansehen soll und nur Dinge verwenden soll, die tats¨ achlich dort deklariert sind. Jedoch ist aufgrund des Event-Typs, der in Zeile 135 abgefragt wird, sehr wohl bekannt, dass es sich z.B. wirklich um einen KeyEvent handelt, auch wenn dieser nur als Reference auf einen Event u ¨bergeben wurde. Also u ¨berzeugt man den Compiler, dass man schon weiß, was man tut und setzt einen expliziten Downcast auf KeyEvent ein, wie in den Zeilen 139–140 zu sehen ist. Und genau hier sehen wir zum ersten Mal die wahre Natur von dynamic_cast: ¨ Bei einem dynamic_cast wird zur Laufzeit eine Uberpr¨ ufung vorgenommen, ob diese Umwandlung u ¨berhaupt zul¨assig ist. Wenn ja, dann wird die Umwandlung durchgef¨ uhrt, wenn nein, dann endet das Programm in einem Fehler – nun ja, muss nicht sein, wenn man weiß, wie man das Ende des Programms verhindert. In Wahrheit wird eine Exception geworfen und zwar eine bad_cast Exception. Wie man mit solchen Exceptions umgeht, ist Thema von Kapitel 11. Was es ganz genau mit dieser besonderen Exception auf sich hat, wird in Abschnitt 15.6 noch n¨aher besprochen. Zur¨ uck zum Thema: Nach einem zul¨ assigen dynamic_cast, der nicht in einer Exception endet, haben wir also sichergestellt, dass wir es tats¨achlich mit einem Objekt vom Typ KeyEvent zu tun haben und k¨onnen damit auch getKey darauf aufrufen. Zu Beginn der Diskussion u ¨ber Downcasts habe ich schon erw¨ahnt, dass man wirklich genau wissen muss, was man tut. Leider gibt es nicht allzu wenige Entwickler, bei denen dieses Wissen eher rudiment¨ar vorhanden ist, die aber trotzdem Datentypen mittels Downcasts munter durch die Gegend wandeln und so die sch¨ onsten Zeitbomben in ihre Software einbauen. In altbekannter Manier m¨ ochte ich daher die h¨aufigsten Fehlerquellen in der Folge aufzeigen. Vorsicht Falle: Downcasts sollten prinzipiell nur dann verwendet werden, wenn sie unbedingt notwendig sind. Oft passiert es, dass bereits ein Fehldesign vorliegt, das auf die Schnelle durch einen Downcast versteckt werden kann (z.B. unn¨ otig falscher Parameter in Methodenaufruf, etc.). Weil aber Downcasts prinzipiell eine Gefahr darstellen, sollte man dar¨ uber nachdenken, ob man vielleicht auch eine saubere L¨osung ohne Downcasts finden kann. Das bedeutet nat¨ urlich nicht, dass man Downcasts auf Kosten der Modularit¨at, Erweiterbarkeit und Allgemeinheit eines Programms vermeiden muss, aber
246
9. Klassen in C++
einen Gedanken ist es schon wert, ob man nicht eine genauso sch¨one L¨osung ohne Downcasts findet. Vorsicht Falle: Nur allzu oft werden abstruseste Annahmen getroffen, dass “sowieso nur ein Objekt von genau diesem Typ” hier vorkommen kann. Solange dies allerdings nicht durch irgendeine Maßnahme (z.B. in unserem Beispiel das interne Speichern des Typs) sichergestellt ist, k¨onnen durch Programmfehler oder nach Programm¨ anderungen die tollsten Effekte eintreten. Und ein Programm nach Downcast-Fehlern zu durchsuchen, hat bei Entwicklern u ¨blicherweise akute Magenschmerzen gepaart mit u ¨bertriebenem Haarausfall durch a ußere Einwirkung von stark zupackenden und an den Haaren ¨ ziehenden H¨ anden zur Folge. Vorsicht Falle: Auch wenn eine Maßnahme die korrekte Typwandlung quasi sicherstellt, ist man noch lange nicht vor “echten” Programmfehlern gesch¨ utzt. Wer sagt, dass nicht aufgrund eines Fehlers einmal ein falscher Typ in der Variable vermerkt wird (typische Copy-and-Paste Folgeerscheinung)? Solche Fehler k¨ onnen nur durch ausf¨ uhrliches und sinnvolles Testen gefunden und behoben werden. Trotzdem muss ein entsprechender Fehlerbehandlungscode immer im Programm erhalten bleiben, denn auch ausf¨ uhrliches Testen sch¨ utzt noch nicht zu 100%! Vorsicht Falle: Die Funktionsweise der verschiedenen Cast-Operatoren wird leider sehr oft nicht vollst¨ andig durchschaut, bzw. deren Existenz u ¨berhaupt ignoriert und mit dem “guten alten” C-Style Cast gearbeitet. Es gibt aber nicht von ungef¨ ahr die verschiedenen Cast-Operatoren. Durch den Einsatz der “falschen” Operatoren lassen sich herrliche Zeitbomben basteln. In der Folge wird der Unterschied zwischen den einzelnen Operatoren noch einmal genau umrissen. Als Grundregel f¨ ur Downcasts m¨ochte ich den Lesern Folgendes auf den Weg geben: Prinzipiell ist bei Downcasts ein dynamic_cast zu verwenden. Um das Bild abzurunden, hier noch einmal eine Zusammenfassung, was die einzelnen Cast-Operatoren genau tun. Ich lasse in dieser Zusammenfassung bewusst den const_cast und den C-Style Cast aus, denn diese sind f¨ ur unsere Betrachtungen hier belanglos. Leser, die sich mittlerweile nicht mehr so sicher sind, was diese beiden tun, m¨ochte ich auf Abschnitt 3.6.5 und Abschnitt 3.6.6 verweisen. dynamic_cast: Dieser Cast u uft zur Laufzeit des Programms, ob eine ¨berpr¨ Umwandlung zum gew¨ unschten Typ tats¨ achlich m¨ oglich ist. Wenn nein, dann bewirkt bereits die Anweisung mit der Anwendung des Casts einen Laufzeitfehler.
9.4 Weitere wichtige technische Aspekte
247
static_cast: Dieser Cast u uft zur Compilezeit, ob eine Umwand¨berpr¨ lung zu einem gew¨ unschten Typ theoretisch m¨ oglich sein k¨ onnte. Wenn ja, wird der entsprechende Code eingesetzt. Die Anwendung des Casts allein f¨ uhrt zur Laufzeit noch zu keinem Fehler. Erst falscher Zugriff auf Members (Variablen oder Methoden) eines illegal gewandelten Typs f¨ uhren dann direkt zu einer Segmentation Violation. ¨ reinterpret_cast: Dieser Cast nimmt u ufung ¨ berhaupt keine Uberpr¨ vor, ob eine Umwandlung u ¨berhaupt m¨oglich sein k¨onnte. Entsprechende Fehlinterpretationen fallen zur Compilezeit sicherlich nicht auf und zur Laufzeit machen sie sich in gewohnter Manier durch eine Segmentation Violation bemerkbar. Genau in diesem Verhalten der Cast-Operatoren ist bedingt, dass ich zuerst die starke Empfehlung gegeben habe, bei Downcasts in jedem Fall einen dynamic_cast zu verwenden. Sollte es hier zum Problem kommen, dann passiert der Fehler genau dort, wo die Umwandlung stattfindet. W¨ urde man stattdessen einen static_cast nehmen, dann u uft der Compiler kurz ¨berpr¨ die Ableitungshierarchie auf entsprechende M¨oglichkeiten. Sollte die Wandlung theoretisch m¨ oglich sein, so wird der entsprechende Code eingesetzt. Zur ¨ Laufzeit allerdings wird beim Umwandeln keine Uberpr¨ ufung mehr vorgenommen und das Programm f¨ allt dann unmotiviert bei irgendeiner scheinbar korrekten Anweisung auf die Nase. Das bedeutet also, dass man bei einem fehlgeschlagenen dynamic_cast Ursache und Auswirkung miteinander gekoppelt hat und solche Fehler daher leicht findet. Bei einem static_cast sind Ursache und Auswirkung entkoppelt und die Fehlersuche wird zum n¨achtlichen Vergn¨ ugen. 9.4.5 Friends von Klassen Manchmal (wirklich nur manchmal!!!) gibt es F¨alle, in denen trotz eines sauberen Designs die in C++ verf¨ ugbaren Access-Specifiers nicht ausreichen, um eine vern¨ unftige Kapselung des Zugriffs vorzunehmen. Solche F¨alle treten dann auf, wenn z.B. eine besondere Funktion (nicht Methode!) oder alle Methoden einer bestimmten Klasse Zugriff auf Interna einer bestimmten Klasse erhalten sollen. Mittels public w¨ urde man den Zugriff f¨ ur alle Außenstehenden freigeben, das will man nicht. Man will den Zugriff nur ganz gezielt erlauben. Hierzu gibt es in C++ die sogenannten friend Deklarationen. Wie der Name schon sagt, erkl¨ art man damit bestimmte Außenstehende zu freundlich Gesinnten, die schon nichts B¨oses anrichten werden und denen deshalb der Zugriff auch auf die privatesten Bereiche gestattet wird. Vorsicht Falle: Dass man mit der Annahme der freundlichen Gesinnung vorsichtig sein muss, versteht sich von selbst. Es gibt in der Softwareentwicklung, so wie auch im realen Leben, auch immer wieder falsche Freunde. Nichts l¨ asst bei Entwicklern mehr Freude aufkommen, als wenn sie Fehler
248
9. Klassen in C++
suchen m¨ ussen, die von Außenstehenden verursacht werden, denen Zugriffe gestattet wurden, vor denen man sich u ¨blicherweise aus gutem Grund sch¨ utzt. Um f¨ ur die F¨ alle, bei denen es wirklich unumg¨anglich ist, zu wissen, wie man den Zugriff f¨ ur Außenstehende gestattet, werfen wir einen Blick auf das folgende Beispiel (friend_demo.cpp): 1
// fri end dem o . cpp − demo f o r the use o f the f r i e n d d e c l .
2 3
#include < i o s t r e a m>
4 5 6
using s t d : : cout ; using s t d : : e n d l ;
7 8 9 10 11 12 13 14
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyClass ∗ ∗ j u s t a demo c l a s s ∗ ∗/
15 16 17 18 19 20 21 22 23 24 25
c l a s s MyClass { friend c l a s s MyGoodFriend ; friend void myGoodFriendFunction ( MyClass & obj ) ; private : int my private member ; int myPrivateMethod ( ) { return ( my private member ) ; } public : MyClass ( ) { my private member = 2 0 ; } };
26 27 28 29 30 31 32 33
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyDerivedClass ∗ ∗ j u s t another demo c l a s s ∗ ∗/
34 35 36 37 38 39 40 41 42
c l a s s MyDerivedClass : public MyClass { private : int r e a l l y p r i v a t e m e m b e r ; int r e a l l y P r i v a t e M e t h o d ( ) { return ( r e a l l y p r i v a t e m e m b e r ) ; } public : MyDerivedClass ( ) { r e a l l y p r i v a t e m e m b e r = 7 0 ; } };
43 44 45 46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyGoodFriend ∗ ∗ j u s t a demo f o r a f r i e n d c l a s s f o r MyClass ∗ ∗/
51 52 53 54 55
c l a s s MyGoodFriend { public : void showAccessToMyClass ( MyClass & obj )
9.4 Weitere wichtige technische Aspekte
{
56
cout << ”showAccessToMyClass : c a l l i n g myPrivateMethod : ” << obj . myPrivateMethod () << e nd l ; obj . my private member = 1 0 0 ;
57 58 59
}
60 61
249
};
62 63 64 65 66 67 68 69
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ NotAGoodFriend ∗ ∗ d e r i v e d c l a s s e s a r e not f r i e n d s although t h e i r b a s e s a r e . . . ∗ ∗/
70 71 72 73 74 75 76
c l a s s NotAGoodFriend : public MyGoodFriend { public : void showAccessToMyClass ( MyClass & obj ) { // the f o l l o w i n g would l e a d to a c o m p i l e r e r r o r :
77 78 79 80
// // // }
81 82
cout << ”showAccessToMyClass : c a l l i n g myPrivateMethod : ” << obj . myPrivateMethod () << e n d l ; obj . my private member = −2;
};
83 84 85 86
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ a f r i e n d f u n c t i o n f o r MyClass ∗/
87 88 89 90 91 92 93
void myGoodFriendFunction ( MyClass & obj ) { cout << ”myGoodFriendFunction : c a l l i n g myPrivateMethod : ” << obj . myPrivateMethod () << e n d l ; obj . my private member = −1; }
94 95 96 97 98 99 100 101
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { MyClass a n o b j e c t ; MyGoodFriend a f r i e n d ; NotAGoodFriend n o t a f r i e n d ; MyDerivedClass d e r i v e d o b j e c t ;
102
a f r i e n d . showAccessToMyClass ( a n o b j e c t ) ; myGoodFriendFunction ( a n o b j e c t ) ; myGoodFriendFunction ( a n o b j e c t ) ;
103 104 105 106
a f r i e n d . showAccessToMyClass ( d e r i v e d o b j e c t ) ; myGoodFriendFunction ( d e r i v e d o b j e c t ) ; myGoodFriendFunction ( d e r i v e d o b j e c t ) ; return ( 0 ) ;
107 108 109 110 111
}
In den Zeilen 18–19 sieht man, wie man seine Freunde bekannt gibt: Man deklariert ganz einfach innerhalb einer Klassendeklaration, wem man traut mittels friend und nachgestellter Deklaration entweder der Klasse oder der bestimmten Methode. Zeile 18 bedeutet also, dass die Klasse MyGoodFriend als freundlich gesinnt betrachtet wird und Zeile 19 bedeutet, dass man auch bereit ist, der Funktion myGoodFriendFunction das Vertrauen zu schenken.
250
9. Klassen in C++
Betrachtet man die Zeilen 55–60 und die Zeilen 88–93, dann sieht man gleich, dass es f¨ ur gute Freunde m¨ oglich ist, auch auf private Bereiche einer Klasse zuzugreifen. Eine sehr wichtige Einschr¨ ankung gilt allerdings f¨ ur friend Deklarationen: Die Eigenschaft, ein friend zu sein, wird nicht an abgeleitete Klassen vererbt, wie man an der Klasse NotAGoodFriend in den Zeilen 71–82 sehen kann. Die dort auskommentierten Statements w¨ urden in einem Compilerfehler enden. Andersrum gesehen bleibt der Zugriff auf die Basis erhalten, auch wenn eine abgeleitete Klasse gewisse Freunde nicht mehr hat, wie man in den Zeilen 107–109 sieht. Nat¨ urlich sind hierbei die Zugriffe nur auf die Bereiche der Basisklasse erlaubt, nicht auf private Bereiche der Ableitung. Dazu m¨ usste die abgeleitete Klasse selbst bestimmte friend Deklarationen enthalten. 9.4.6 Overloading von const und non-const Methoden Es wurde bereits besprochen, dass const Methoden einen Sonderstatus einnehmen, da bei Aufruf derselben das Objekt, auf dem sie aufgerufen werden, nicht ver¨ andert werden darf. Der Sonderstatus schl¨agt sich auch in der Signatur der Methoden nieder, denn const ist ein Teil derselben. Wenn man diesen Gedanken weiterspinnt, dann kann man leicht erkennen, dass ein Overloading einer const Methode durch eine non-const Methode mit genau demselben Parametersatz m¨ oglich ist. Der Compiler geht mit dieser Situation folgendermaßen um: • Wird eine Methode auf einem Objekt aufgerufen, das nicht durch const vor schreibendem Zugriff gesch¨ utzt ist, so wird versucht, eine non-const Methode zu finden, die der Signatur des Aufrufs entspricht. • Wird keine solche non-const Methode gefunden, so wird nach einer entsprechenden const Methode gesucht. Dieses Verhalten ist auch vollkommen logisch, denn eine const Methode auf einem nicht konstanten Objekt aufzurufen ist nat¨ urlich kein Problem. • Wird eine Methode auf einem const Objekt aufgerufen, dann wird nat¨ urlich nur eine entsprechende const Methode akzeptiert. Es ist also m¨ oglich, Methoden in zwei verschiedenen Auspr¨agungen zu schreiben, die, je nachdem, ob ein Objekt selbst nun const ist, oder nicht, aufgerufen werden. Auf diese Art kann man in speziellen F¨allen Optimierungsmaßnahmen implementieren. Im folgenden Beispiel sieht man, wie sich ein solches Overloading auswirkt (const_non_const_overloading_demo.cpp): 1 2
// c o n s t n o n c o n s t o v e r l o a d i n g d e m o . cpp − demo f o r o v e r l o a d i n g // o f c o n s t and non−c o n s t methods
3 4 5 6
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
9.4 Weitere wichtige technische Aspekte
7 8
251
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DummyClass ∗ ∗ j u s t a dummy f o r demo purposes ∗ ∗/
17 18 19 20 21 22 23 24
c l a s s DummyClass { public : virtual void writeOutput ( ) const { cout << ” c o n s t method c a l l e d ” << e n d l ; }
25
virtual void writeOutput ( ) { cout << ”non−c o n s t method c a l l e d ” << e nd l ; }
26 27 28 29 30
};
31 32 33 34 35 36
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { DummyClass n o n c o n s t o b j ; const DummyClass & c o n s t o b j r e f = n o n c o n s t o b j ;
37
n o n c o n s t o b j . writeOutput ( ) ; c o n s t o b j r e f . writeOutput ( ) ;
38 39 40
return ( 0 ) ;
41 42
}
Der Output dieses Programms sieht dann erwartungsgem¨aß so aus: non−c o n s t method c a l l e d c o n s t method c a l l e d
9.4.7 Besonderheiten bei der Initialisierung Zur Initialisierung von Instanzen einer Klasse bin ich noch ein paar Worte schuldig geblieben, die ich nun nachholen m¨ochte. Bisher wurde erw¨ahnt, dass man im Konstruktor einer abgeleiteten Klasse explizit bestimmte Konstruktoraufrufe von Basisklassen erwirken kann. Das ist allerdings noch nicht die ganze Wahrheit, denn man kann (und muss manchmal auch) noch ein bisschen mehr machen. Sehen wir uns das einmal schnell an (member_initializing_demo.cpp): 1 2
// m e m b e r i n i t i a l i z i n g d e m o . cpp − demo how to i n i t i a l i z e // c e r t a i n member v a r i a b l e s
3 4 5 6
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ OneClass
252
7 8 9 10
9. Klassen in C++
∗ ∗ j u s t a c l a s s with only a non−d e f a u l t c o n s t r u c t o r ∗ ∗/
11 12 13 14 15 16
c l a s s OneClass { public : OneClass ( int j u s t f o r d e m o ) { } };
17 18 19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ AnotherClass ∗ ∗ j u s t a c l a s s with only a non−d e f a u l t c o n s t r u c t o r ∗ ∗/
25 26 27 28 29 30 31
c l a s s AnotherClass { OneClass just a member ; public : AnotherClass ( ) : just a member ( 1 7 ) { } };
32 33 34 35 36 37 38
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { AnotherClass j u s t a v a r i a b l e ; return ( 0 ) ; }
Man sieht, dass OneClass in den Zeilen 12–16 so deklariert ist, dass sie keinen default Konstruktor besitzt. Die Klasse AnotherClass in den Zeilen 26–31 wiederum hat einen Member vom Typ OneClass. Jetzt stellt sich nat¨ urlich die Frage, welchen Code der Compiler beim Erzeugen einer Instanz dieses Members einsetzen soll, denn diese Instanz des Members wird ja automatisch beim Erstellen einer Instanz von AnotherClass erstellt. Irgendwo m¨ ussen wir dem Compiler also mitteilen, welchen Konstruktoraufruf er f¨ ur just_a_member_ zu t¨ atigen hat. Das geschieht nach altbekannter Manier im Konstruktor von AnotherClass, angegeben hinter dem obligatorischen Doppelpunkt, wie man in Zeile 30 sieht. Der Unterschied zum expliziten Konstruktoraufruf einer Basisklasse ist der, dass hier nat¨ urlich nicht der Klassenname der zu konstruierenden Klasse geschrieben wird, sondern der entsprechende Variablenname des zu initialisierenden Members. In unserem Fall also wird just_a_member_ mit dem Wert 17 als Parameter initialisiert. Sollte es einen default Konstruktor geben, dann h¨atte man theoretisch die Wahl zwischen dieser hier gezeigten Art, einen besonderen Konstruktor aufzurufen und der M¨ oglichkeit, es beim default Konstruktor zu belassen und danach eine explizite Zuweisung im eigenen Konstruktor vorzunehmen. Es ist allerdings guter Programmierstil, die hier gezeigte Konstruktor Aufrufschreibweise zu verwenden, da dadurch explizit klar ist, dass es sich um eine Initialisierung handelt und nicht um eine “einfache Zuweisung”.
9.4 Weitere wichtige technische Aspekte
253
Vorsicht Falle: Bei Neulingen gibt es ¨ofters großes Staunen, warum sich der Compiler beschwert, dass er einen Member einer Klasse nicht initialisieren kann. Sehr oft liegt der Grund darin, dass vergessen wurde, im Konstruktor der Klasse die notwendige explizite Initialisierung bei nicht-Vorhandensein eines default Konstruktors dieses Members anzugeben. Ein Blick auf die Deklaration der Klasse des problematischen Members lohnt sich zumeist, um hier Klarheit zu schaffen. An dieser Stelle muss noch erw¨ ahnt werden, dass die spezielle Schreibweise zur Initialisierung nicht nur f¨ ur benutzerdefinierte Objekte funktioniert, sondern auch auf primitive Datentypen anwendbar ist. Aus Gr¨ unden der Einheitlichkeit und aus anderen, noch viel wichtigeren Gr¨ unden, die in Kapitel 11 noch genauer beleuchtet werden, wird dringend empfohlen, diese Art der Initialisierung der einfachen Zuweisung innerhalb eines Konstruktors vorzuziehen. 9.4.8 Tempor¨ are Objekte Tempor¨ are Objekte sind Objekte, die nicht u ¨ber einen Variablennamen ansprechbar sind, sondern nur als Speicher f¨ ur Zwischenergebnisse bei der Abarbeitung von Expressions dienen. Sobald die entsprechende Expression ausgewertet ist, werden diese tempor¨ aren Objekte wieder verworfen. Wenn sie schon keine echten Variablen sind und auch nicht u ¨ber einen Variablennamen angesprochen werden k¨ onnen, wie entstehen und verschwinden sie dann? Ganz einfach: Daf¨ ur ist einzig und allein der Compiler verantwortlich. Am leichtesten ist dies anhand der Auswertung von mathematischen Ausdr¨ ucken zu verstehen. Analysieren wir einfach einmal die schrittweise Abarbeitung der Expression result = var1 + var2 + (var3 + var4) * var5; Es ist belanglos, welchen Datentyp unsere einzelnen Variablen und das Ergebnis haben, der Compiler wertet den Ausdruck prinzipiell so aus: 1. Es wird die Addition von var1 und var2 durchgef¨ uhrt. Das Ergebnis dieser Addition wird in einem tempor¨aren Objekt abgelegt, nennen wir es einfach einmal temp1. Was sollte der Compiler auch sonst mit dem Ergebnis tun? Er darf weder var1 noch var2 ver¨andern, also muss er das Ergebnis irgendwo anders zwischenspeichern. 2. Im n¨ achsten Schritt wird die Addition von var3 und var4 vorgenommen. Das Resultat landet zwangsweise wieder in einem tempor¨aren Objekt, das wir hier als temp2 bezeichnen wollen. 3. Das tempor¨ are Objekt temp2 wird mit var5 multipliziert, was erneut ein tempor¨ ares Objekt ergibt. Nennen wir dieses temp3. 4. Nun werden temp1 und temp3 addiert, was erneut ein tempor¨ares Objekt ergibt, das wir mit temp4 bezeichnen wollen.
254
9. Klassen in C++
5. Im letzten Schritt der Auswertung der Expression wird nun temp4 an result zugewiesen. 6. Da jetzt die gesamte Expression fertig abgearbeitet ist, sorgt der Compiler daf¨ ur, dass die Geister, die er rief, n¨amlich die tempor¨aren Objekte, auch wieder aufger¨ aumt werden. Ich m¨ochte hier noch anmerken, dass ich hier ganz bewusst den naiven Weg beschrieben habe und keinerlei R¨ ucksicht auf eventuelle Optimierungsmaßnahmen des Compilers genommen habe. Soweit es also Expressions wie die gerade analysierte betrifft, haben Entwickler mit dem Erzeugen und dem Zerst¨oren von tempor¨aren Objekten nicht wirklich sehr viel zu tun. Nun ja, beinahe – Wir werden dieses Thema in Kapitel 12 noch einmal kurz anreißen, und aus einem anderen Blickwinkel betrachten. Es gibt jedoch auch F¨ alle, in denen wir zwar dem Compiler die Aufsicht u ¨ber die Lifetime eines tempor¨aren Objekts u ¨berlassen wollen, jedoch das tempor¨ are Objekt selbst per Hand erzeugen wollen. Eine solche Situation ist an folgendem Beispiel zu sehen (temp_object_demo.cpp): 1
// temp object demo . cpp − demo how to u t i l i z e temporary o b j e c t s
2 3 4
#include < i o s t r e a m> #include < c s t r i n g>
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Message ∗ ∗ j u s t a dummy f o r demo purposes ∗ ∗/
16 17 18 19 20 21 22 23 24
c l a s s Message { protected : char ∗ message ; public : Message ( const char ∗ message ) ; Message ( const Message & s r c ) ; virtual ˜ Message ( ) ;
25
virtual char ∗ g e t S t r i n g ( ) { return ( message ) ; }
26 27 28 29 30
};
31 32 33 34 35 36 37 38 39 40
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Message : : Message ( const char ∗ message ) { cout << ”−−−−− Message : c o n s t r u c t o r with char ∗ −−−−−” << e n d l ; // h e r e a 0 p o i n t e r check should be performed , but f o r // demo purposes . . . . message = new char [ s t r l e n ( message ) + 1 ] ; s t r c p y ( message , message ) ; }
9.4 Weitere wichtige technische Aspekte
255
41 42 43 44 45 46 47 48
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Message : : Message ( const Message & s r c ) { cout << ”−−−−− Message : copy c o n s t r u c t o r −−−−−” << e nd l ; message = new char [ s t r l e n ( s r c . message ) + 1 ] ; s t r c p y ( message , s r c . message ) ; }
49 50 51 52 53 54 55
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Message : : ˜ Message ( ) { cout << ”−−−−− Message : d e s t r u c t o r −−−−−” << e n d l ; delete [ ] message ; }
56 57 58 59 60 61 62 63
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Message whateverFunction ( ) { // performs some a c t i o n h e r e and r e t u r n s a Message // Object as a r e s u l t . return ( Message ( ”my c a l c u l a t e d message s t r i n g ” ) ) ; }
64 65 66 67 68 69 70 71
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { cout << ” ∗∗∗∗∗ b e f o r e e x p r e s s i o n ∗∗∗∗∗ ” << e n d l ; cout << ” whatever Function r e t u r n s : ” << e n d l << whateverFunction ( ) . g e t S t r i n g () << e nd l ; cout << ” ∗∗∗∗∗ a f t e r e x p r e s s i o n ∗∗∗∗∗ ” << e n d l ;
72
return ( 0 ) ;
73 74
}
Die Klasse Message bietet nicht wirklich etwas Neues, deshalb erspare ich den Lesern und mir eine Beschreibung der Einzelheiten. Interessant wird es bei der Expression in den Zeilen 69–70: Hier wird whateverFunction aufgerufen, die als return-Value ein Message Objekt liefert. Um diese Message auszugeben wird die Methode getString zu Hilfe genommen, die einen char * liefert. Das Thema dieses Abschnitts waren aber eigentlich tempor¨are Objekte und ein solches wird genau in Zeile 62 erzeugt. Dies geschieht ganz einfach, indem man den entsprechenden Konstruktor aufruft. Der Output, den dieses Programm erzeugt, liest sich dann so: ∗∗∗∗∗ b e f o r e e x p r e s s i o n ∗∗∗∗∗ whatever Function r e t u r n s : −−−−− Message : c o n s t r u c t o r with char ∗ −−−−− my c a l c u l a t e d message s t r i n g −−−−− Message : d e s t r u c t o r −−−−− ∗∗∗∗∗ a f t e r e x p r e s s i o n ∗∗∗∗∗
Es wurden in den Zeilen 68 und 71 bewusst Ausgaben eingebaut, um zu zeigen, dass sich wirklich alles in der Folge Gesagte innerhalb der Expression abspielt: 1. Zuerst wird der erste Teil des Outputs aus Zeile 69 generiert. 2. Danach wird whateverFunction aufgerufen.
256
9. Klassen in C++
3. Im Zuge der Abarbeitung dieses Aufrufs wird das tempor¨are Message Objekt erzeugt. Dies sieht man am Output des entsprechenden Konstruktors. 4. Auf diesem Objekt wird die Methode getString aufgerufen, die den entsprechenden String liefert. 5. Der gelieferte String wird ausgegeben. 6. Direkt auf den String folgend wird auch noch der Zeilenumbruch ausgegeben. 7. Damit ist die Expression fertig abgearbeitet und das tempor¨are Objekt wird plangem¨ aß in die ewigen Jagdgr¨ unde geschickt, wie man am Output des Destruktors erkennt.
10. Memory – ein kleines Beispiel
Wie versprochen, m¨ ochte ich hier nach dem Kennenlernen der notwendigen Fakten zu Klassen in C++ das Beispiel des Spiels Memory noch einmal aufgreifen und vollst¨ andig von Anfang an implementieren. Am konkreten Beispiel ist es einfacher, einmal alle Gedanken zu ordnen, die vielleicht zwischendurch, bei der Lekt¨ ure so einiger technischer Details, durcheinander gekommen sind. Zu einer sauberen Entwicklung geh¨ ort nat¨ urlich ein sauberes Konzept. Dieses wird in der Praxis dadurch erreicht, dass man zuerst alle einzelnen Use-Cases St¨ uck f¨ ur St¨ uck ablaufm¨ aßig erfasst und aus diesen die User-Requirements ableitet. Diese landen dann im URD (=User Requirements Document). Um nun niemanden zu sehr zu langweilen, erspare ich mir an dieser Stelle die Abhandlung, wie die Abl¨ aufe des Dr¨ uckens auf eine Taste, des Umdrehens aller Karten, des Umdrehens einer einzelnen Karte, etc. aus Sicht des Endbenutzers aussehen. Auch das SRD (=Software Requirements Document) ist an dieser Stelle nicht besonders spannend. An dieser Stelle bedeutet in diesem Kontext hier im Buch. Im Rahmen einer Entwicklung (und ich meine jeder Entwicklung!) sind diese Dokumente sehr wohl spannend, um nicht zu sagen lebensnotwendig! F¨ ur die Beschreibung hier im Buch wird es beim ADD (=Architectural Design Document) wichtig. Dieses entsteht als Resultat aus URD und SRD und das ist auch genau der Punkt, an dem wir hier in die Entwicklung einsteigen.
10.1 Das ADD Das ADD beschreibt im Prinzip die interne Architektur der zu erstellenden Software. Der wichtigste Teil der Arbeit bei der Erstellung einer tragf¨ahigen Softwarearchitektur besteht darin, ein sauberes Modell zu finden, das modular und damit durchschaubar und beherrschbar ist. Mit durchschaubar und beherrschbar meine ich hier nat¨ urlich auch, dass diese Eigenschaften nicht nur bis zur ersten Version gegeben sind, sondern, dass sie u ¨ber alle Stufen sp¨aterer Weiterentwicklung erhalten bleiben. Je nachdem, wie weit man ¨ vorausgedacht hat, sind bei sp¨ ateren Anpassungen der Software auch Anderungen an der Architektur notwendig. Die in der Folge sehr kurz dargestellten Designentscheidungen entsprechen einer von vielen m¨oglichen sauberen
258
10. Memory – ein kleines Beispiel
L¨osungen f¨ ur eine Architektur f¨ ur Memory. Es m¨ogen aber bitte alle Leser im Hinterkopf behalten, dass wir hier noch nicht die ultimative Memory-Version entwickeln. Dazu w¨ urden noch einige Aspekte hinzukommen, die bisher nicht bekannt sind. Ich m¨ ochte nochmals betonen, dass ein ADD niemals in einem St¨ uck von Anfang bis Ende geschrieben wird und dann einfach passt. Im Normalfall sind etliche Iterationsschritte vonn¨ oten, um kleinere oder gr¨oßere Probleme befriedigend in den Griff zu bekommen. Je gr¨oßer und komplexer die zu entwickelnde Software ist, desto mehr solche Schritte m¨ ussen bis zum Erreichen des Ziels durchgef¨ uhrt werden. Es sollten sich die Leser des Buchs also nicht erwarten, dass sie zu einer geforderten Software einfach ein ADD aus dem ¨ Armel sch¨ utteln und damit gleich alles bestens erf¨ ullt haben. 10.1.1 Identifikation der Grobmodule ¨ Ein wenig Uberlegung u ¨ber die internen Gegebenheiten und das Zusammenspiel verschiedener Faktoren im Programm ergibt einmal eine Identifikation funktionaler Module, die sich im Design wiederfindet: Input Handling: Das Input Handling besteht sinnigerweise daraus, dass Keyboard Eingaben der Benutzer entgegengenommen werden. Diese werden dann zuerst auf ihre Sinnhaftigkeit untersucht. Im Fall, dass sie unsinnig sind, werden sie mit einem entsprechenden Fehler quittiert, ansonsten werden Aktionen getriggert. Das Triggern von Aktionen erfolgt u ¨ber das Modul Spielsteuerung. Spielsteuerung: Die Spielsteuerung u ¨bernimmt die gesamte Steuerung des Ablaufs einer Partie Memory. Sie triggert das Initialisieren des Spielfelds, das Auf- und Zudecken der Karten und alle Aktionen, die aufgrund von Benutzereingaben stattfinden. Output Handling: Das Output Handling u ¨bernimmt die Aufgabe, das Spielfeld bei Aufruf im aktuellen Zustand anzuzeigen. Memory Spielfeld: Das Spielfeld u ¨bernimmt die Aufgabe, die einzelnen Karten zu halten, die jedem Einzelfeld zugeordnet sind. Memory Karte: Es gibt im Spiel eine gewisse Anzahl von Memory Karten, die auf das Spielfeld verteilt werden. Commandline Handling: Dieses Modul ergibt sich aus einem Requirement, das bisher noch verschwiegen wurde. Die Gr¨oße des Spielfelds wird mittels zweier Parameter auf der Commandline beim Starten des Programms bestimmt. Der erste Parameter repr¨asentiert die Anzahl der Spalten (=Zellen pro Reihe), der zweite Parameter die Anzahl der Reihen. Man k¨onnte (und sollte sogar) noch viel ausf¨ uhrlicher die Aufgaben der Grobmodule beschreiben, aber ich glaube, die Idee hinter dieser Aufteilung ist einigermaßen klar. Deshalb ist es sinnvoller, in der Folge die einzelnen Module St¨ uck f¨ ur St¨ uck unter die Lupe zu nehmen und sie weiter zu zerlegen, um
10.1 Das ADD
259
unserem eigentlichen Ziel, der Implementation des Spiels, schneller n¨aher zu kommen. 10.1.2 Weitere Zerlegung der einzelnen Grobmodule Betrachten wir nun die einzelnen Module getrennt voneinander, um ihre Schnittstellen, sowie ihre interne Aufteilung identifizieren zu k¨onnen. Es ist in der Folge leicht erkennbar, dass durch die genauere Beschreibung gewisser Abl¨aufe und Gegebenheiten leicht eine Liste von Anforderungen der Module untereinander identifiziert werden kann, durch die die Schnittstellen definiert bzw. auf ihre Sinnhaftigkeit u uft werden k¨onnen. ¨berpr¨ Input Handling. Um eine vern¨ unftige Analyse des Input Handlings machen zu k¨onnen, muss ich hier etwas nachholen, was in der urspr¨ unglichen Angabe zum Spiel verschwiegen wurde: Wie bedient man das Spiel eigentlich? Hierzu nehmen wir die einfachst m¨ ogliche Variante: • Das Programm wartet immer auf die Eingabe eines Zahlenpaares, wobei die erste Zahl die Reihe und die zweite Zahl die Spalte darstellt. • Nachdem immer zwei zusammengeh¨orige Karten aufgedeckt werden sollen, ist die weitere Logik folgendermaßen: – Das erste Zahlenpaar wird an die Spielsteuerung weitergegeben. Diese hat dann die Aufgabe, die entsprechende Karte einmal aufzudecken und ¨ auf die Ubermittlung des zweiten Zahlenpaars zu warten. – Das zweite Zahlenpaar wird an die Spielsteuerung weitergegeben. Dadurch wird die zweite Karte aufgedeckt. Sollten die beiden Karten dasselbe Symbol zeigen, dann werden beide aufgedeckt liegen gelassen, ansonsten werden sie beide wieder umgedreht. Wenn man weiter u ¨berlegt, dann sieht man, dass die Aufgabe des Input Handlings nicht wirklich viel mit dem Spiel direkt zu tun hat, denn dieses Modul soll ja allgemein verwendbar sein. Dasselbe Input Handling wie hier kann ja auch z.B. in einem Texteditor Verwendung finden. Dementsprechend beschr¨anken wir die Aufgabe des Input Handlings darauf, Eingabeworte entgegenzunehmen und als Events an angemeldete Handlers weiterzureichen. Der bzw. die Handlers stellen die Schnittstelle zur Spielsteuerung dar. Spielsteuerung. Die Spielsteuerung wird u ¨ber die eingehenden Keyboard Events informiert und triggert die entsprechenden Aktionen am Spielfeld. Das bedeutet, dass die gesamte Logik des Spiels, also, was zu welchem Zeitpunkt zu passieren hat, in diesem Modul steckt. Dies betrifft nat¨ urlich auch die Initialisierungsphase des Spiels. Dadurch, dass nur der Spielsteuerung die Abl¨aufe bekannt sind, ist sie auch daf¨ ur verantwortlich, die Ausgabe des Spielfeldes durch das Output Handling zu veranlassen.
260
10. Memory – ein kleines Beispiel
Output Handling. Das Output Handling ist daf¨ ur verantwortlich, die Ausgabe des gesamten Spielfeldes, respektive der einzelnen Karten auf ihm, zu steuern und daf¨ ur zu sorgen, dass diese am Bildschirm erscheinen. Weil auch dieses nat¨ urlich ein allgemeines Modul sein soll, wird hier das bereits mehrfach besprochene Prinzip mittels einer abstrakten darstellbaren Klasse Anwendung finden. Da die Randbedingungen hierf¨ ur bereits mehrfach aus verschiedenen Gesichtspunkten besprochen wurden, m¨ochte ich den Lesern hier eine nochmalige genaue Beschreibung ersparen. Memory Spielfeld. Das Memory Spielfeld ist im Prinzip eine Matrix von Einzelfeldern, wobei jedes dieser Einzelfelder genau eine Karte halten kann. Im Prinzip gibt es hierf¨ ur eine besonders sinnvolle Konstruktion: Eine Matrix ist im Prinzip nichts anderes als ein Vektor von Vektoren. Die dadurch entstehenden Zellen halten jeweils eine Karte. Dazu l¨asst sich leicht ein besonders sauberes Design mittels allgemeiner Vektoren und Spezialisierung derselben auf bestimmte Daten beschreiben. Nur leider fehlen uns derzeit noch ein paar wichtige Konstrukte, die eine elegante Umsetzung dieses Designs auf C++ Sprachebene erm¨ oglichen. Aus diesem Grund bleiben wir zwar beim allgemeinen Design, die Implementation desselben sollte aber bitte nicht als Vorlage f¨ ur ein professionelles Vektor-Design dienen. Memory Karte. Zu den Aspekten des Designs einer Memory Karte m¨ochte ich mich hier auch nicht weiter auslassen, denn diese wurden bereits mehrfach aus verschiedenen Blickwinkeln besprochen. Eine Wiederholung an dieser Stelle w¨ are dementsprechend langweilig. Commandline Handling. Das Commandline Handling bekommt die dem Programm u ur, dass das ent¨bergebenen Parameter u ¨berreicht und sorgt daf¨ sprechende Bootstrapping des Systems stattfindet. Im Bootstrapping werden die entsprechenden notwendigen Instanzen der Klassen erzeugt und zwar in der Form, wie sie den eingegebenen Parametern (Zeilen, Spalten) entspricht.
10.2 Das DDD Viele Entwickler empfinden die Erstellung eines DDD (=Detailed Design Document) als l¨ astig und zeitraubend und deshalb wird diese Aufgabe oft nur sehr halbherzig gemacht oder einfach vollst¨andig u ¨bersprungen. In Summe erspart man sich durch diese Herangehensweise aber keine Zeit, ganz im Gegenteil! Durch das Fehlen des DDD fallen viele Probleme erst beim Implementieren der Software auf und f¨ uhren zu dementsprechend vielen kleineren ¨ und gr¨oßeren Anderungen derselben in dieser Phase. Das Schlimmste bei ¨ diesen Anderungen ist, dass man es in Bezug auf die Schnittstellen mit einem moving Target zu tun hat. Dadurch werden mit wachsender Gr¨oße der Software die Fehler immer h¨ aufiger und schwerer zu lokalisieren, denn man
10.2 Das DDD
261
kann sich sicher nicht immer ganz genau erinnern, wer jetzt eine Klasse wie verwendet hat. Allen Lesern, die sich bisher immer gestr¨aubt haben, ein DDD zu erstellen, bevor sie zu implementieren begonnen haben, m¨ochte ich in der Folge eine Kompromissl¨ osung vorstellen, wie sie in kleinen Projekten recht brauchbar eingesetzt werden kann. Sie ist nicht vollkommen gleichwertig zu einem echten DDD, aber sie stellt zumindest eine deutliche Verbesserung im Vergleich zur Vorgehensweise dar, gar kein DDD zu schreiben. Außerdem bekommt man durch diese Methode einigermaßen dokumentierten Source Code, ohne hinterher m¨ uhsam dokumentieren zu m¨ ussen. 10.2.1 Klassendiagramm Im ersten Schritt nimmt man das ADD zur Hand. Aus der darin umrissenen Architektur ergibt sich in der Folge durch mehrere Analyse- und Verfeinerungsschritte das grobe Klassendiagramm, wie es in Abbildung 10.1 dargestellt ist. Die Ableitungspfeile sind ja schon bekannt, die strichlierten Pfeile in diesem Diagramm stehen f¨ ur entsprechende Referenzen der Klassen untereinander (=HAS-A Relationen). 10.2.2 Klassendeklarationen Mit Hilfe des Klassendiagramms erstellt man die vollst¨andigen Deklarationen f¨ ur die einzelnen Klassen. Hierbei werden gleich die entsprechenden Headers geschrieben, die auch in der fertigen Implementation verwendet werden. Der Trick bei diesem Schritt ist, dass alle Variablen, Methoden und Funktionen gleich im Header genau beschrieben werden. Dadurch hat man erstens die M¨oglichkeit, zu pr¨ ufen, ob das Design funktionieren kann oder ob man vielleicht etwas vergessen hat. Auch fallen Fehldesigns sehr schnell auf, weil diese schon in der Beschreibung recht umst¨andlich werden. Dadurch aber, ¨ dass man noch keinen Code geschrieben hat, sind Anderungen noch relativ problemlos m¨ oglich. Weil die in der Designphase geschriebenen Headers auch wirklich in der Implementation verwendet werden, sind in der hier abgedruckten Version auch ein paar Inline Methoden zu finden, die nicht aus der Designphase, sondern aus der Implementation kommen. Ein ganz großer Vorteil bei dieser Vorgehensweise ist auch, dass man die Headers an andere Entwickler zur Implementation weitergeben kann. Durch die genaue Beschreibung m¨ ussen diese dann nicht selbst alles noch einmal von vorn durchdenken (und dabei vielleicht das Design verletzen), sondern k¨onnen nach genauer Vorgabe zur Implementation schreiten. Schreiten wir also zur Tat... Ein keiner Exkurs: Im Anschluss an die dokumentierten Klassendeklarationen finden sich noch ausgew¨ ahlte Teile des Source Codes. Diese sollen
262
10. Memory – ein kleines Beispiel
SimpleOutputHandling
OutputContext
Displayable
GameCard
TextOutputContext
MemoryGameboard
MemoryGameCard
Vector
SimpleInputHandling
Event used by many classes
WordEvent EventHandler
MemoryGameControl
MemoryCardSymbolGenerator
MemoryCardpair
CommandlineHandling
ArgumentHandler
MemoryCommandlineArgumentHandler
Abbildung 10.1: grobes Klassendiagramm
einerseits die internen Abl¨ aufe im Programm verdeutlichen, andererseits werden ein paar wichtige Details besprochen, u ¨ber die man sonst im Programmieralltag stolpern k¨ onnte. Ich m¨ ochte deshalb wirklich allen Lesern nahe legen, die folgenden Teile aufmerksam zu lesen und zu versuchen, ein tieferes Verst¨andnis f¨ ur das Programm zu bekommen. Die Methode, einfach unaufmerksam quer zu lesen, f¨ uhrt mit ziemlicher Sicherheit zur Verwirrung und das geht dann garantiert am Sinn dieses Kapitels vorbei. Obwohl konzentriertes Lesen bereits sehr viel bringt, m¨ochte ich zus¨atzlich noch allen Lesern ans Herz legen, mit dem Programm ein wenig zu spielen
10.2 Das DDD
263
¨ und eigene Anderungen und Erweiterungen zu versuchen. Auf diese Art lernt man unter Garantie am meisten, auch wenn man sich vielleicht den einen oder anderen Absturz einhandelt :-).
10.2.3 Vector Beginnen wir unser detailed Design mit der Klasse, die durch ihre Natur als Utility an vielen Stellen gebraucht wird, selbst aber wenig Direktes mit dem Rest des Programms zu tun hat: mit unserer Klasse Vector. Der Header f¨ ur diesen hat hier bezeichnenderweise den Namen simple_vector.h bekommen, weil er zu Demonstrationszwecken so einfach wie m¨oglich gehalten ist. ¨ Eine wichtige Uberlegung spielt in das Design dieses Vektors hinein: Auf welche Art soll man Elemente im Vektor speichern? Im Sinne der Allgemeinheit soll es keine Rolle spielen, ob man nun Elemente eines primitiven Datentyps oder benutzerdefinierte Objekte speichert. Im Augenblick fehlt uns noch das Wissen um Templates, deshalb ist der allgemeinst m¨ogliche Ansatz, der uns zur Verf¨ ugung steht, das Speichern der Elemente u ¨ber void *. Dadurch, dass nicht die Elemente selbst, sondern nur Pointer auf die Elemente im Vektor gehalten werden, stellt sich gleich eine ganz wichtige Frage: Was soll man mit diesen im Destruktor tun? Soll man delete auf die Elemente aufrufen oder nicht? Leider ist keine der beiden Methoden im Sinne der Allgemeinheit befriedigend. Ruft man im Destruktor kein delete f¨ ur die einzelnen Elemente auf, dann hat man die Verantwortung f¨ ur die Freigabe delegiert. Das jedoch kann, je nach Verwendung, zu sinnlosen Mehrfachspeicherungen und w¨ usten Seiteneffekten f¨ uhren. Ruft man jedoch im Destruktor immer ein delete f¨ ur die Elemente auf, dann hat man dadurch erfolgreich verhindert, dass im Vektor auch Elemente gespeichert werden k¨onnen, die nicht dynamisch allokiert wurden. Der sauberste Ausweg aus diesem Dilemma ist, den Benutzern des Vektors die Entscheidung zu u ¨berlassen und sie w¨ahlen zu lassen, welche der beiden Methoden sie nun bevorzugen. Diese Entscheidung f¨ uhrt dann zu folgendem Design des Vektors: 1
// s i m p l e v e c t o r . h − a s i m p l e v e c t o r c l a s s
2 3 4
#i f n d e f s i m p l e v e c t o r h #define s i m p l e v e c t o r h
5 6 7
#include ” u s e r t y p e s . h” #include ” o b j e c t d e l e t o r . h”
8 9 10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A very s i m p l e v e c t o r c l a s s t h a t i s a b l e to s t o r e a number o f ∗ elements as g i v e n i n the c o n s t r u c t o r . The number o f elements ∗ cannot be changed during runtime . ∗ When c o n s t r u c t i n g a v e c t o r i t has to be chosen , whether ∗ the elements s t o r e d i n i t a r e d e l e t e d on d e s t r u c t i o n . ∗/
264
17 18 19
10. Memory – ein kleines Beispiel
c l a s s Vector { private :
20 21 22 23
/∗ Copy−c o n s t r u c t i o n i s not a l l o w ed ∗/ Vector ( const Vector & s r c ) ;
24 25
protected :
26 27 28 29 30 31
/∗ The a r r a y o f p o i n t e r s t h a t hold the elements . I f the ∗ number o f elements f o r t h i s v e c t o r i s 0 the p o i n t e r ∗ is also 0. ∗/ void ∗∗ e l e m e n t s ;
32 33 34 35 36
/∗ The number o f elements t h a t can be h e l d . The a r r a y o f ∗ p o i n t e r s h o l d i n g the elements i s a l l o c a t e d to t h i s s i z e . ∗/ u i n t 3 2 num elements ;
37 38 39 40 41 42 43 44
/∗ The d e l e t o r f o r elements . I t i s s e t as a parameter ∗ i n the c o n s t r u c t o r i f d e l e t i o n o f o b j e c t s i s d e s i r e d when ∗ e i t h e r the v e c t o r i s d e s t r u c t e d or the element i s o v e r w r i t t e n . ∗ I f i t i s not s e t , no d e l e t i o n o f elements w i l l be done . This ∗ o b j e c t i t s e l f w i l l not be d e l e t e d by the d e s t r u c t o r . ∗/ ObjectDeletor & d e l e t o r ;
45 46
public :
47 48 49 50 51 52 53 54 55 56 57 58 59
/∗ Standard c o n s t r u c t o r ∗ A l l o c a t e s e l e m e n t s , s e t s num elements and ∗ del elements if necessary accordingly . ∗ @param num elements The number o f elements t h a t the v e c t o r ∗ can hold . ∗ @param d e l e t o r The d e l e t o r i s c a l l e d whenever an element i s ∗ e i t h e r o v e r w r i t t e n or the d e s t r u c t o r i s c a l l e d . I f ∗ d e l e t i o n o f elements i s not d e s i r e d t h e r e i s a l s o ∗ a s p e c i a l DontDelete d e l e t o r ( sounds s t r a n g e , eh ? : − ) ) ∗ t h a t does nothing ∗/ Vector ( u i n t 3 2 num elements , O b j e c t D e l e t o r & d e l e t o r ) ;
60 61 62 63 64 65
/∗ D e s t r u c t o r ∗ d e l e t e s e l e m e n t s and i f d e l e l e m e n t s i f n e c e s s a r y is set ∗ i t a l s o d e l e t e s every s i n g l e element s t o r e d i n the v e c t o r ∗/ virtual ˜ Vector ( ) ;
66 67 68 69 70 71 72 73 74 75 76 77
/∗ S e t s the g i v e n element at the g i v e n index p o s i t i o n . I s a l s o ∗ r e s p o n s i b l e to perform a check to determine i f index i s ∗ i n s i d e the a l l o w ed range . I f the index i s out o f range the ∗ c a l l i s i g n o r e d ( only because e x c e p t i o n s a r e not known yet ) ∗ I f t h e r e i s an element at the g i v e n index and i f the ∗ del elements if necessary f l a g i s s e t t h i s element i s ∗ d e l e t e d b e f o r e i t s p o i n t e r i s o v e r w r i t t e n by the new one . ∗ @param index The index f o r the element ( s t a r t i n g at 0 ) . ∗ @param element A p o i n t e r to the element t h a t has to be s e t . ∗/ virtual void setElementAt ( u i n t 3 2 index , void ∗ element ) ;
78 79 80 81 82
/∗ ∗ ∗ ∗
Returns the element at the g i v e n index p o s i t i o n . I s a l s o r e s p o n s i g l e to perform a range−check o f index . I f the index i s o u t s i d e the a l l o w ed range i t r e t u r n s 0 ( only because e x c e p t i o n s a r e not known yet ) .
10.2 Das DDD
83 84 85 86 87
265
∗ @param index The index o f the d e s i r e d element ( s t a r t i n g at 0 ) . ∗ @return The s t o r e d p o i n t e r to the element or 0 ∗ i f o u t s i d e range . ∗/ virtual void ∗ getElementAt ( u i n t 3 2 index ) const ;
88 89 90 91 92 93 94 95 96 97 98
/∗ Returns the element at the g i v e n index p o s i t i o n and removes ∗ i t from the v e c t o r without c a l l i n g the d e l e t o r . I s a l s o ∗ r e s p o n s i g l e to perform a range−check o f index . I f the index ∗ i s o u t s i d e the a l l o w ed range i t r e t u r n s 0 ( only because ∗ e x c e p t i o n s a r e not known yet ) . ∗ @param index The index o f the d e s i r e d element ( s t a r t i n g at 0 ) . ∗ @return The s t o r e d p o i n t e r to the element or 0 ∗ i f o u t s i d e range . ∗/ virtual void ∗ getAndRemoveElementAt ( u i n t 3 2 index ) const ;
99 100 101 102 103 104 105 106 107 108
/∗ Returns the number o f elements t h a t the v e c t o r can hold as ∗ s t o r e d i n num elements . ∗ @return The number o f elements t h a t the v e c t o r can hold . ∗/ virtual u i n t 3 2 getMaxNumElements ( ) const { return ( num elements ) ; } };
109 110 111
#endif // s i m p l e v e c t o r h
In Zeile 44 sieht man, welche Konsequenz die Entscheidung hat, das Freigeben von Elementen den Benutzern des Vektors zu u ¨berlassen: Der Vektor kann nicht einfach selbst ein delete f¨ ur ein Element aufrufen, denn die Elemente sind ja als void * gespeichert und delete ist nicht auf einem void * aufrufbar. Man kann sich auch sehr schnell u ¨berlegen, warum das so ist: Es ist delete ja daf¨ ur verantwortlich, eventuell existente Destruktoren aufzurufen. Das geht aber nicht, wenn man ihm nicht mitteilt, um welchen Typ es sich handelt. Aus diesem Grund wurde eine spezielle ObjectDeletor Klasse eingef¨ uhrt, von der die konkreten Implementationen f¨ ur die verschiedenen Elementtypen abgeleitet werden. Diese Klasse wurde aus Gr¨ unden der ¨ Ubersichtlichkeit nicht in das Klassendiagramm aufgenommen. Mittels Templates, die in Kapitel 13 besprochen werden, kann man dieses Problem bei weitem sauberer, durchsichtiger, einfacher und effizienter in den Griff bekommen. Deshalb m¨ ochte ich es hier dabei belassen und keine besonderen Abhandlungen zum Thema Deletor schreiben. Leser, die, von C kommend, in Funktionspointer verliebt sind, k¨onnen sich nat¨ urlich auch eine solche L¨ osung durch den Kopf gehen lassen. Zu den Funktionspointern werde ich noch in Abschnitt 15.3 ein paar Worte verlieren. Vorsicht Falle: Eine weitere Konsequenz hat die Entscheidung, den Vektor mit Hilfe des Deletors Objekte l¨ oschen zu lassen: Wird ein Element im Vektor u berschrieben, so wird das zuvor an dieser Stelle befindliche Objekt ¨ gel¨oscht. Ist auch klar, sonst w¨ urde ja ein Speicherloch entstehen, wenn man ¨ den Pointer durch Uberschreiben verliert. Was passiert allerdings, wenn man
266
10. Memory – ein kleines Beispiel
z.B. zwei Elemente im Vektor vertauschen will? Man ruft getElementAt f¨ ur das erste Element auf und merkt sich den retournierten Pointer. Dann ruft man getElementAt f¨ ur das zweite Element auf, um es gleich wieder mit setElementAt an die Stelle des ersten Elements zu schreiben. Dann schreibt man das zwischengespeicherte erste Element an die Stelle, an der vorher das zweite Element stand. Und dann sucht man beim n¨achsten Zugriff auf eines der beiden Elemente ziemlich lange nach einer v¨ollig unerkl¨arbaren Segmentation Violation... In diesem Beispiel wird n¨ amlich bei den beiden setElementAt Operationen von der Vektor Klasse das jeweils vorher dort gespeicherte Element durch einen entsprechenden Deletor Aufruf u ¨ber den Jordan geschickt! Woher sollte der Vektor denn auch ahnen, dass wir die Elemente auch wirklich nicht “verlieren”? Um dieses Problem in den Griff zu bekommen, wurde die Methode getAndRemoveElementAt eingef¨ uhrt, die das gelieferte Objekt aus dem Vektor entfernt, indem an diese Stelle ein 0 Pointer gesetzt wird.
10.2.4 ObjectDeletor Das allgemeine Interface f¨ ur einen ObjectDeletor ist denkbar einfach, wie man in der Folge sieht: 1 2
// o b j e c t d e l e t o r . h − the i n t e r f a c e f o r an o b j e c t d e l e t o r // as i t i s used by the s i m p l e v e c t o r c l a s s
3 4 5
#i f n d e f o b j e c t d e l e t o r h #define o b j e c t d e l e t o r h
6 7 8 9
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ The i n t e r f a c e f o r an o b j e c t d e l e t o r ∗/
10 11 12 13 14 15 16 17
class ObjectDeletor { public : /∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ O b j e c t D e l e t o r ( ) { }
18 19 20 21 22 23 24 25
/∗ I s c a l l e d with a p o i n t e r to the o b j e c t and has to d e l e t e ∗ i t . P l e a s e note t h a t i t can a l s o happen t h a t a 0 p o i n t e r ∗ i s g i v e n as a parameter ∗ @param obj The p o i n t e r to the o b j e c t t h a t has to be d e l e t e d ∗/ virtual void d e l e t e O b j e c t ( void ∗ obj ) = 0 ; };
26 27
#endif // o b j e c t d e l e t o r h
10.2 Das DDD
267
10.2.5 Konkrete Deletors F¨ ur die verschiedenen Elementtypen, die in Vektoren gespeichert werden, m¨ ussen nat¨ urlich die entsprechenden konkreten Deletors implementiert werden. Diese wurden gleich kurzerhand alle gemeinsam in ein File verpackt. Der einzig interessante Aspekt bei diesen konkreten Implementationen ist, dass es sich hier um eine leichte Abwandlung des Singleton Design Patterns handelt: Es gibt von jedem dieser konkreten Deletors im gesamten System nur jeweils eine einzige Instanz. Dies ist deshalb sinnvoll, weil diese Deletors keine Statusinformation speichern. Und man will ja nicht f¨ ur jeden einzelnen Vektor immer wieder unn¨ otig einen neuen Deletor anlegen, den man eigentlich sowieso schon hat. Um nun nicht mit irgendwelchen Merkervariablen Abh¨angigkeiten und Seiteneffekte zu erzeugen, gibt es bei unseren konkreten Deletors immer eine static Methode getInstance, u ¨ber die man eine Referenz auf die einzige existente Instanz des Deletors erh¨alt. Der erste Deletor in den Zeilen 17–54 ist auch gleich ein nettes Paradoxon: Wie der Name DontDelete schon sagt, ist er ein Deletor, der gar nicht daran denkt, etwas zu l¨ oschen :-). Der Grund f¨ ur dessen Existenz ist einfach: Aus Sicherheitsgr¨ unden wollte ich nicht mit Pointers auf Deletors arbeiten, denn dabei kann man groben Unfug treiben (z.B. die einzige Instanz l¨oschen, etc.). Daher wird im Programm immer nur mit Referenzen auf Deletors gearbeitet, so auch im Vektor selbst. Mit Referenzen hat man allerdings im Vektor die M¨oglichkeit nicht, einfach einen 0 Pointer als Signal f¨ ur “nichts l¨oschen” zu verwenden. Deshalb ruft der Vektor immer den ihm u ¨bergebenen Deletor auf und im Fall, dass man nichts l¨ oschen will, u ¨bergibt man eben die Instanz des DontDelete Deletors. Durch diese Maßnahme wird das Programm auch bei ¨ sp¨ateren Anderungen robuster gegen schwer zu findende Pointerfehler. Es ¨ ist nichts leichter, als im Vektor bei einer Anderung eine 0-Abfrage auf den Deletor Pointer zu vergessen... 1 2 3
// c o n c r e t e o b j e c t d e l e t o r s . h − a c o l l e c t i o n o f the c o n c r e t e // o b j e c t d e l e t o r s t h a t a r e used f o r the v e c t o r s i n the // memory game
4 5 6
#i f n d e f c o n c r e t e o b j e c t d e l e t o r s h #define c o n c r e t e o b j e c t d e l e t o r s h
7 8 9 10 11
#include #include #include #include
” o b j e c t d e l e t o r . h” ” u s e r t y p e s . h” ” s i m p l e v e c t o r . h” ” s i m p l e d i s p l a y a b l e . h”
12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A c o n c r e t e d e l e t o r t h a t does not d e l e t e anything : − ) ∗/
16 17 18 19 20 21 22
c l a s s DontDelete : public O b j e c t D e l e t o r { private : /∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ DontDelete ( const DontDelete& s r c ) { }
268
23
10. Memory – ein kleines Beispiel
protected :
24 25 26 27 28
/∗ This v a r i a b l e h o l d s the s i n g l e i n s t a n c e o f t h i s d e l e t o r . ∗ To o b t a i n t h i s i n s t a n c e , g e t I n s t a n c e has to be c a l l e d ∗/ s t a t i c DontDelete d e l e t o r ;
29 30 31 32 33
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the c o n s t r u c t o r ∗ must not be p u b l i c . ∗/ DontDelete ( ) { }
34 35 36 37 38 39
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the d e s t r u c t o r ∗ must not be p u b l i c . ∗/ virtual ˜ DontDelete ( ) { } public :
40 41 42 43 44 45 46
/∗ This method i s used to o b t a i n an i n s t a n c e o f the d e l e t o r ∗/ s t a t i c DontDelete & g e t I n s t a n c e ( ) { return ( d e l e t o r ) ; }
47 48 49 50 51 52
/∗ This i s the dummy c a l l b a c k implementation t h a t does not ∗ d e l e t e anything . . . ∗ @param obj The p o i n t e r to the o b j e c t to be d e l e t e d ∗/ virtual void d e l e t e O b j e c t ( void ∗ obj ) { }
53 54
};
55 56 57 58 59
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A c o n c r e t e d e l e t o r f o r char ∗ elements ∗/
60 61 62 63 64 65 66 67
c l a s s CharDeletor : public O b j e c t D e l e t o r { private : /∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ CharDeletor ( const CharDeletor& s r c ) { } protected :
68 69 70 71 72
/∗ This v a r i a b l e h o l d s the s i n g l e i n s t a n c e o f t h i s d e l e t o r . ∗ To o b t a i n t h i s i n s t a n c e , g e t I n s t a n c e has to be c a l l e d ∗/ s t a t i c CharDeletor d e l e t o r ;
73 74 75 76 77
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the c o n s t r u c t o r ∗ must not be p u b l i c . ∗/ CharDeletor ( ) { }
78 79 80 81 82 83
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the d e s t r u c t o r ∗ must not be p u b l i c . ∗/ virtual ˜ CharDeletor ( ) { } public :
84 85 86 87 88
/∗ This method i s used to o b t a i n an i n s t a n c e o f the d e l e t o r ∗/ s t a t i c CharDeletor & g e t I n s t a n c e ( ) {
10.2 Das DDD
return ( d e l e t o r ) ;
89 90
}
91 92 93 94 95 96 97 98 99
/∗ This i s the c a l l b a c k implementation ∗ @param obj The p o i n t e r to the o b j e c t to be d e l e t e d ∗/ virtual void d e l e t e O b j e c t ( void ∗ obj ) { delete s t a t i c c a s t( obj ) ; } };
100 101 102 103
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A c o n c r e t e d e l e t o r f o r u i n t 3 2 ∗ elements ∗/
104 105 106 107 108 109 110 111
c l a s s Ui nt 3 2De leto r : public O b j e c t D e l e t o r { private : /∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ U i nt 3 2 Del et or ( const Uint3 2Deleto r & s r c ) { } protected :
112 113 114 115 116
/∗ This v a r i a b l e h o l d s the s i n g l e i n s t a n c e o f t h i s d e l e t o r . ∗ To o b t a i n t h i s i n s t a n c e , g e t I n s t a n c e has to be c a l l e d ∗/ s t a t i c Ui nt 32 De l et or d e l e t o r ;
117 118 119 120 121
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the c o n s t r u c t o r ∗ must not be p u b l i c . ∗/ U i nt 3 2De let or ( ) { }
122 123 124 125 126 127
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the d e s t r u c t o r ∗ must not be p u b l i c . ∗/ virtual ˜ Uint 32D el e t or ( ) { } public :
128 129 130 131 132 133 134
/∗ This method i s used to o b t a i n an i n s t a n c e o f the d e l e t o r ∗/ s t a t i c Uint 32D e let or & g e t I n s t a n c e ( ) { return ( d e l e t o r ) ; }
135 136 137 138 139 140 141 142 143
/∗ This i s the c a l l b a c k implementation ∗ @param obj The p o i n t e r to the o b j e c t to be d e l e t e d ∗/ virtual void d e l e t e O b j e c t ( void ∗ obj ) { delete s t a t i c c a s t( obj ) ; } };
144 145 146 147
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A c o n c r e t e d e l e t o r f o r Vector ∗ elements ∗/
148 149 150 151 152 153 154
c l a s s V e c t o r D e l e t o r : public O b j e c t D e l e t o r { private : /∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ V e c t o r D e l e t o r ( const V e c t o r D e l e t o r & s r c ) { }
269
270
155
10. Memory – ein kleines Beispiel
protected :
156 157 158 159 160
/∗ This v a r i a b l e h o l d s the s i n g l e i n s t a n c e o f t h i s d e l e t o r . ∗ To o b t a i n t h i s i n s t a n c e , g e t I n s t a n c e has to be c a l l e d ∗/ static VectorDeletor d e l e t o r ;
161 162 163 164 165
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the c o n s t r u c t o r ∗ must not be p u b l i c . ∗/ VectorDeletor () {}
166 167 168 169 170 171
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the d e s t r u c t o r ∗ must not be p u b l i c . ∗/ virtual ˜ V e c t o r D e l e t o r ( ) { } public :
172 173 174 175 176 177 178
/∗ This method i s used to o b t a i n an i n s t a n c e o f the d e l e t o r ∗/ static VectorDeletor & getInstance ( ) { return ( d e l e t o r ) ; }
179 180 181 182 183 184 185 186 187
/∗ This i s the c a l l b a c k implementation ∗ @param obj The p o i n t e r to the o b j e c t to be d e l e t e d ∗/ virtual void d e l e t e O b j e c t ( void ∗ obj ) { delete s t a t i c c a s t( obj ) ; } };
188 189 190 191
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A c o n c r e t e d e l e t o r f o r D i s p l a y a b l e ∗ elements ∗/
192 193 194 195 196 197 198 199
c l a s s D i s p l a y a b l e D e l e t o r : public O b j e c t D e l e t o r { private : /∗ Copy c o n s t r u c t i o n i s not a l l o w e d ∗/ D i s p l a y a b l e D e l e t o r ( const D i s p l a y a b l e D e l e t o r & s r c ) { } protected :
200 201 202 203 204
/∗ This v a r i a b l e h o l d s the s i n g l e i n s t a n c e o f t h i s d e l e t o r . ∗ To o b t a i n t h i s i n s t a n c e , g e t I n s t a n c e has to be c a l l e d ∗/ static DisplayableDeletor d e l e t o r ;
205 206 207 208 209
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the c o n s t r u c t o r ∗ must not be p u b l i c . ∗/ DisplayableDeletor () {}
210 211 212 213 214 215
/∗ This c l a s s i s implemented as a s i n g l e t o n , so the d e s t r u c t o r ∗ must not be p u b l i c . ∗/ virtual ˜ D i s p l a y a b l e D e l e t o r ( ) { } public :
216 217 218 219 220
/∗ This method i s used to o b t a i n an i n s t a n c e o f the d e l e t o r ∗/ static DisplayableDeletor &getInstance () {
10.2 Das DDD
return ( d e l e t o r ) ;
221 222
271
}
223 224 225 226 227 228 229 230 231
/∗ This i s the c a l l b a c k implementation ∗ @param obj The p o i n t e r to the o b j e c t to be d e l e t e d ∗/ virtual void d e l e t e O b j e c t ( void ∗ obj ) { delete s t a t i c c a s t( obj ) ; } };
232 233 234
#endif // c o n c r e t e o b j e c t d e l e t o r s h
10.2.6 ArgumentHandler Neben dem Vector ist auch die Basisklasse ArgumentHandler und alles, was sich rund um diese Klasse herum abspielt, ein relativ abgeschlossenes Thema f¨ ur sich. Also wenden wir uns ihr gleich einmal zu. Der ArgumentHandler ist ganz einfach nur eine abstrakte Basis, u ¨ber die das CommandlineHandling die einzelnen Parameter zur Auswertung weitergeben kann. Dementsprechend einfach ist auch dieses Interface: 1
// s i m p l e a r g u m e n t h a n d l e r . h − i n t e r f a c e f o r an argument h a n d l e r
2 3 4
#i f n d e f s i m p l e a r g u m e n t h a n d l e r h #define s i m p l e a r g u m e n t h a n d l e r h
5 6 7 8 9 10
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ An a b s t r a c t base c l a s s o f a very s i m p l e argument h a n d l e r . ∗ A c o n c r e t e implementation o f a ha n d l e r has to be d e r i v e d from ∗ t h i s base and o b t a i n s n o t i f i c a t i o n s f o r every s i n g l e argument . ∗/
11 12 13 14
c l a s s ArgumentHandler { public :
15 16 17 18 19 20
/∗ D e s t r u c t o r ∗ The d e s t r u c t o r i s j u s t implemented to make s u r e t h a t a v i r t u a l ∗ destructor exists ∗/ virtual ˜ ArgumentHandler ( ) { }
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
/∗ This method i s a c a l l b a c k used to n o t i f y the h a n d l e r o f an ∗ argument t h a t was obtained . ∗ @param index The index o f the argument . A l l arguments a r e ∗ numbered i n a c o n s e c u t i v e o r d e r s t a r t i n g at 0 . ∗ @param arg A p o i n t e r to the argument t h a t was obtained . This ∗ parameter i s d e c l a r e d as a void ∗ , because argument ∗ n o t i f i c a t i o n s a r e performed i n a typed manner . ∗ Depending on the expected argument type the t r i g g e r ∗ c l a s s o f a n o t i f i c a t i o n has to perform the a c c o r d i n g ∗ c o n v e r s i o n . I f e . g . an i n t argument i s expected ∗ the n o t i f i c a t i o n i s done with an i n t ∗ . ∗/ virtual void a r g u m e n t N o t i f i c a t i o n ( u i n t 3 2 index , const void ∗ arg ) = 0 ; };
272
10. Memory – ein kleines Beispiel
36 37 38
#e n d i f // s i m p l e a r g u m e n t h a n d l e r h
10.2.7 MemoryCommandlineArgumentHandler Da wir gerade das entsprechende Interface kennen gelernt haben, sehen wir uns auch gleich die entsprechende Implementation an, die in unserem Spiel Anwendung finden wird. 1 2
// memory commandline arg handler . h − s p e c i a l argument h a n d l e r // f o r the memory game
3 4 5
#i f n d e f memory commandline arg handler h #define memory commandline arg handler h
6 7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ The commandline argument ha n dl e r f o r the memory game ∗ I t i s implemented as a s i m p l e c o n t a i n e r t h a t s t o r e s the number ∗ o f rows and columns t h a t a r e s e t v i a c a l l b a c k s from the ∗ CommandlineHandling or a compatible c l a s s . ∗/
13 14 15 16
c l a s s MemoryCommandlineArgumentHandler : public ArgumentHandler { protected :
17 18 19 20 21 22 23
/∗ The i n d i c e s o f the number o f rows and columns a c c o r d i n g to ∗ the commandline s p e c i f i c a t i o n o f the game ( argv [ 0 ] = progname , ∗ argv [ 1 ] = rows , argv [ 2 ] = c o l s ) . ∗/ s t a t i c const u i n t 3 2 ROW DEF INDEX = 1 ; s t a t i c const u i n t 3 2 COL DEF INDEX = 2 ;
24 25 26 27
/∗ The number o f rows o f the gameboard ∗/ u i n t 3 2 num rows ;
28 29 30 31
/∗ The number o f columns o f the gameboard ∗/ u i n t 3 2 num cols ;
32 33
public :
34 35 36 37 38 39
/∗ D e f a u l t c o n s t r u c t o r ∗ I n i t i a l i z e s a l l v a r i a b l e s to 0 ∗/ MemoryCommandlineArgumentHandler ( ) : num rows ( 0 ) , num cols ( 0 ) { }
40 41 42 43 44
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t t h e r e i s a v i r t u a l d e s t r u c t o r ∗/ virtual ˜ MemoryCommandlineArgumentHandler ( ) { }
45 46 47 48 49 50 51
/∗ ∗ ∗ ∗ ∗ ∗
Callback f o r a n o t i f i c a t i o n a c c o r d i n g to the base i n t e r f a c e I f e v e r y t h i n g i s ok t h i s c a l l b a c k w i l l be c a l l e d f o r two arguments : The number o f rows and the number o f columns . According to the convention both arguments w i l l be passed as p o i n t e r s to u i n t 3 2 . See a l s o c l a s s CommandlineHandling @param index The index o f the argument i n the commandline .
10.2 Das DDD
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
273
∗ As i s the s p e c i f i c a t i o n the program name has index 0 ∗ and the arguments s t a r t with index 1 . ∗ @param arg The argument s t r i n g as g i v e n i n the commandline ∗/ virtual void a r g u m e n t N o t i f i c a t i o n ( u i n t 3 2 index , const void ∗ arg ) { switch ( index ) { case ROW DEF INDEX: num rows = ∗( s t a t i c c a s t(arg ) ) ; break ; case COL DEF INDEX : num cols = ∗( s t a t i c c a s t(arg ) ) ; break ; default : break ; } }
70 71 72 73 74 75 76 77
/∗ Returns the number o f rows as s e t from the commandline ∗ @return The v a l u e o f the rows−parameter ∗/ virtual u i n t 3 2 getRows ( ) { return ( num rows ) ; }
78 79 80 81 82 83 84 85 86
/∗ Returns the number o f columns as s e t from the commandline ∗ @return The v a l u e o f the c o l s−parameter ∗/ virtual u i n t 3 2 g e t C o l s ( ) { return ( num cols ) ; } };
87 88 89
#endif // memory commandline arg handler h
10.2.8 CommandlineHandling Um das Kapitel des Argument Handlings abzurunden, werfen wir einen Blick auf die Klasse CommandlineHandling. Diese Klasse ist daf¨ ur verantwortlich, die Argumente aus der Commandline entgegenzunehmen und an die entsprechende Instanz eines Argument Handlers weiterzuleiten. Um das Handling der Commandline m¨ oglichst offen und wiederverwendbar zu gestalten, implementiert die hier definierte Klasse CommandlineHandling folgendes Schema: • Jedes einzelne Argument, das ein Programm erwartet, wird explizit deklariert. • Eine solche Deklaration enth¨ alt den Index des erwarteten Arguments, den Typ, der erwartet wird und den Handler, der f¨ ur das Argument schlussendlich verantwortlich ist. • Nachdem alle erwarteten Argumente deklariert wurden, wird die Commandline an das Handling u ur ¨bergeben und entsprechend ausgewertet. F¨ alle zuvor deklarierten Argumente, die auf der Commandline spezifiziert wurden, wird der entsprechend registrierte Handler aufgerufen.
274
10. Memory – ein kleines Beispiel
Ich m¨ochte hier gleich betonen, dass diese Art des Handlings einer Commandline wirklich nur f¨ ur sehr einfache Programme sinnvoll ist. Jedoch geht die Implementation eines wirklich in der Praxis einsetzbaren Commandline Handlings hier bei weitem am Sinn des kleinen Beispiels vorbei. Sehen wir uns also an, wie diese Klasse unter den gegebenen Voraussetzungen aussieht: 1 2
// simple commandline handling . h − a s i m p l e c l a s s f o r handling // commandline arguments
3 4 5
#i f n d e f s i m p l e c o m m a n d l i n e h a n d l i n g h #define s i m p l e c o m m a n d l i n e h a n d l i n g h
6 7 8 9 10
#include #include #include #include
” u s e r t y p e s . h” ” s i m p l e v e c t o r . h” ” s i m p l e a r g u m e n t h a n d l e r . h” ” c o n c r e t e o b j e c t d e l e t o r s . h”
11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ Every s i n g l e argument t h a t has to be handled has to be ∗ d e c l a r e d with a type and an a c c o r d i n g h a n d l e r h e r e . I t i s ∗ not mandatory t h a t a l l arguments a r e d e c l a r e d , t h e r e may ∗ a l s o be un de c l a r e d ones t h a t a r e simply skipped . ∗/
18 19 20 21
c l a s s CommandlineHandling { protected :
22 23 24 25 26 27 28 29 30
/∗ The t y p e s o f a l l d e c l a r e d arguments . I f an argument i s ∗ d e c l a r e d f o r a c e r t a i n index , the type i s r e g i s t e r e d h e r e . ∗ I f t h e r e i s no argument d e c l a r e d f o r a c e r t a i n index then ∗ t h i s element i s a 0 p o i n t e r . This i s i n t e r p r e t e d as ” i g n o r e ∗ the argument ” and the c l a s s has to r e a c t a c c o r d i n g l y . An ∗ u n de c l a r e d argument i s d e f i n i t e l y a s p e c i f i e d s i t u a t i o n . ∗/ Vector t y p e s ;
31 32 33 34 35 36 37 38
/∗ The h a n d l e r s f o r a l l d e c l a r e d arguments . The same r u l e s ∗ f o r ” m i s s i n g ” p o s i t i o n s a r e v a l i d as f o r the t y p e s f i e l d . ∗ The only d i f f e r e n c e i s t h a t h a n d l e r s f o r the ” m i s s i n g ” ∗ p o s i t i o n s a r e 0 p o i n t e r s . The s i n g l e h a n d l e r s w i l l not ∗ be d e l e t e d on d e s t r u c t i o n . ∗/ Vector h a n d l e r s ;
39 40 41 42 43
/∗ The maximum number o f d e c l a r a b l e arguments . I f more arguments ∗ a r e passed on from the commandline they a r e simply i g n o r e d . ∗/ i n t 3 2 max num args ;
44 45
public :
46 47 48 49 50
/∗ The c o n s t a n t s f o r a l l the argument t y p e s . Add a d d i t i o n a l ∗ known t y p e s h e r e . ∗/ s t a t i c const u i n t 3 2 UINT32 ARG = 0 x01 ;
51 52
protected :
53 54 55 56 57 58
/∗ ∗ ∗ ∗ ∗
This c o n s t a n t h o l d s the index f o r the h i g h e s t d e c l a r e d argument type c o n s t a n t . I f c o n s t a n t s f o r new t y p e s a r e added i t has to be adopted a c c o r d i n g l y . A l l argument t y p e s with v a l u e s h i g h e r than t h i s one a r e c o n s i d e r e d e r r o r s and i g n o r e d a c c o r d i n g l y ( j u s t because e x c e p t i o n s
10.2 Das DDD
59 60 61
275
∗ a r e not known yet ) ∗/ s t a t i c const u i n t 3 2 HIGHEST ARG TYPE = UINT32 ARG ;
62 63
private :
64 65 66 67 68 69
/∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ CommandlineHandling ( const CommandlineHandling & s r c ) : t y p e s ( 0 , DontDelete : : g e t I n s t a n c e ( ) ) , h a n d l e r s ( 0 , DontDelete : : g e t I n s t a n c e ( ) ) { }
70 71
public :
72 73 74 75 76 77 78 79 80
/∗ Standard Constructor ∗ @param max num args S p e c i f i e s the maximum number o f arguments ∗ t h a t may be d e c l a r e d f o r t h i s i n s t a n c e o f the h a n d l e r ∗/ e x p l i c i t CommandlineHandling ( i n t 3 2 max num args ) : t y p e s ( max num args , Uint 32 D el et or : : g e t I n s t a n c e ( ) ) , h a n d l e r s ( max num args , DontDelete : : g e t I n s t a n c e ( ) ) , max num args ( max num args ) { }
81 82 83 84 85
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ CommandlineHandling ( ) { }
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
/∗ This method i s used to d e c l a r e an argument t h a t has to be ∗ handled . ∗ @param index The index o f the argument to be handled . I f ∗ the index i s out o f range i t i s i g n o r e d ( j u s t because ∗ e x c e p t i o n s a r e not known yet ) . ∗ @param type The type o f the argument . Valid t y p e s a r e the ∗ ones t h a t e x i s t as c o n s t a n t s above . ∗ @param ha n dl e r The ha n dl e r t h a t has to be c a l l e d f o r the ∗ d e c l a r e d argument . I f t h i s ha n d l e r i s a 0 p o i n t e r ∗ then the o p e r a t i o n i s i g n o r e d ( j u s t because ∗ e x c e p t i o n s a r e not known yet ) . ∗/ virtual void declareArgument ( u i n t 3 2 index , u i n t 3 2 type , ArgumentHandler ∗ ha n d l e r ) ;
101 102 103 104 105 106 107 108 109 110 111
/∗ This method i s c a l l e d a f t e r the i n i t i a l i z a t i o n phase to ∗ t r i g g e r handling o f the commandline . I t t a k e s the arguments ∗ one by one , c o n v e r t s them to the t y p e s d e c l a r e d f o r them ∗ and c a l l s the a p p r o p r i a t e h a n d l e r s . ∗ @param num args The number o f arguments passed on ∗ @param a r g s The arguments as obtained from the commandline ∗/ virtual void handleCommandline ( i n t 3 2 num args , char ∗ a r g s [ ] ) ; };
112 113 114
#endif // s i m p l e c o m m a n d l i n e h a n d l i n g h
10.2.9 SimpleOutputHandling Bevor wir die Klasse SimpleOutputHandling n¨aher betrachten, m¨ochte ich noch anmerken, dass diese deshalb in der hier angef¨ uhrten Form geschrieben wurde, da der Umgang mit allgemeinen Output Streams derzeit noch nicht
276
10. Memory – ein kleines Beispiel
bekannt ist. Ansonsten w¨ urden diese sich im Design dieser Klasse und der von ihr verwalteten Displayable Klassen niederschlagen. Die Idee hinter der Klasse SimpleOutputHandling ist nun, dass alle Teile des Spiels, die etwas zur Ausgabe beizutragen haben, von Displayable abgeleitet sind und beim Handler explizit angemeldet werden. Wenn ein Output generiert werden soll, so werden die angemeldeten darstellbaren Objekte genau in der Reihenfolge ihrer Anmeldung zum Schreiben desselben aufgefordert. Im Gegensatz zu den Beispielen, die in Kapitel 9 besprochen wurden, haben wir es hier mit einer kleinen Abwandlung der Idee mit den darstellbaren Objekten zu tun: Es wird nicht mehr eine Display Repr¨asentation verlangt, die dann vom Handler auf den Output geschrieben wird. Dies ist vor allem in Bezug auf Container, die selbst darstellbare Objekte halten, nicht besonders effizient und verleitet auch in diesen F¨allen zur Implementation von Seiteneffekten. Hier wird ein anderer Weg gegangen: Das Callback, das aufgerufen wird, enth¨ alt eine Referenz auf einen OutputContext. Dieser enth¨alt die notwendigen Methoden, die es einem darstellbaren Objekt erlauben, seinen Output zu schreiben. Wohin dieser Output nun geschrieben wird, ist durch die spezielle Auspr¨ agung des Contexts bestimmt. In unserem Fall ist dies ein simpler textueller Context, der die Ausgabe auf cout weiterreicht. Noch eine wichtige Entscheidung wurde getroffen, die sich im Displayable niederschl¨ agt: Wenn ein Displayable beim Output Handling angemeldet wird, so wird es durch Aufruf eines Callbacks von dieser Anmeldung unterrichtet. Dadurch k¨ onnen eventuelle Initialisierungen zum richtigen Zeitpunkt stattfinden. Diese Ideen sehen nun in eine Klasse gegossen so aus: 1
// s i m p l e o u t p u t h a n d l i n g . h − s i m p l e v e r s i o n o f output handling
2 3 4
#i f n d e f s i m p l e o u t p u t h a n d l i n g h #define s i m p l e o u t p u t h a n d l i n g h
5 6 7 8
#include ” s i m p l e d i s p l a y a b l e . h” #include ” s i m p l e v e c t o r . h” #include ” c o n c r e t e o b j e c t d e l e t o r s . h”
9 10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ A s i m p l e output handling c l a s s f o r t e x t based output . Elements ∗ o f c l a s s D i s p l a y a b l e a r e added one by one and when an output ∗ o p e r a t i o n i s t r i g g e r e d they a r e asked i n e x a c t l y the same o r d e r ∗ as they have been r e g i s t e r e d to d e l i v e r t h e i r d i s p l a y ∗ r e p r e s e n t a t i o n . This r e p r e s e n t a t i o n i s then w r i t t e n to cout . ∗/
18 19 20 21
c l a s s SimpleOutputHandling { protected :
22 23 24 25 26
/∗ ∗ ∗ ∗
This v e c t o r has space f o r the maximum number o f d i s p l a y a b l e s as g i v e n i n the c o n s t r u c t o r . The o r d e r i n which d i s p l a y a b l e s a r e s t o r e d i n t h i s v e c t o r i s the o r d e r i n which they w i l l be c a l l e d when output i s w r i t t e n .
10.2 Das DDD
27 28
∗/ Vector d i s p l a y a b l e s ;
29 30 31 32
/∗ The number o f d i s p l a y a b l e s c u r r e n t l y s t o r e d i n the v e c t o r ∗/ uint32 num displayables ;
33 34 35 36 37 38
/∗ The output c o n t e x t which d i s p l a y a b l e s o b t a i n through the ∗ c a l l b a c k to w r i t e t h e i r output . This c o n t e x t i s s e t by ∗ an a p p r o p r i a t e parameter i n the c o n s t r u c t o r . ∗/ OutputContext & o u t p u t c o n t e x t ;
39 40
private :
41 42 43 44 45 46
/∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ SimpleOutputHandling ( const SimpleOutputHandling & s r c ) : d i s p l a y a b l e s ( 0 , DontDelete : : g e t I n s t a n c e ( ) ) , output context ( src . output context ) {}
47 48
public :
49 50 51 52 53 54 55 56 57 58 59 60
/∗ Standard c o n s t r u c t o r ∗ @param max num displayables The maximum number o f d i s p l a y a b l e s ∗ t h a t s h a l l be handled by an i n s t a n c e o f t h i s c l a s s ∗ @param o u t p u t c o n t e x t The output c o n t e x t t h a t the d i s p l a y a b l e s ∗ have to use . ∗/ SimpleOutputHandling ( u i n t 3 2 max num displayables , OutputContext & o u t p u t c o n t e x t ) : d i s p l a y a b l e s ( max num displayables , DontDelete : : g e t I n s t a n c e ( ) ) , num displayables ( 0 ) , output context ( output context ) {}
61 62 63 64 65
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ SimpleOutputHandling ( ) { }
66 67 68 69 70 71 72 73 74 75 76 77 78
/∗ Adds a d i s p l a y a b l e f o r handling . I f the maximum number o f ∗ d i s p l a y a b l e s i s reached the d i s p l a y a b l e i s simply i g n o r e d ∗ ( j u s t because e x c e p t i o n s a r e not known yet ) . The r e g i s t e r e d ∗ d i s p l a y a b l e i s a l s o n o t i f i e d through a c a l l to the method ∗ d i s p l a y a b l e R e g i s t e r e d o f the c l a s s D i s p l a y a b l e . ∗ @param d i s p l a y a b l e A r e f e r e n c e to the d i s p l a y a b l e which i s ∗ added f o r handling . Although i t should be c l e a r ∗ because o f the r e f e r e n c e : d i s p l a y a b l e s which a r e ∗ r e g i s t e r e d a r e NOT d e l e t e d when the h a n d l e r i s ∗ destructed ! ∗/ virtual void addDisplayable ( D i s p l a y a b l e & d i s p l a y a b l e ) ;
79 80 81 82 83 84 85 86
/∗ This method i s c a l l e d to t r i g g e r w r i t i n g o f the output . I t ∗ c a l l s the c a l l b a c k methods o f the r e g i s t e r e d d i s p l a y a b l e s ∗ one by one i n e x a c t l y the same o r d e r i n which they were ∗ r e g i s t e r e d . This o r d e r i s r e f l e c t e d by the o r d e r o f the ∗ d i s p l a y a b l e s i n the v e c t o r . ∗/ virtual void writeOutput ( ) ;
87 88 89 90 91 92
/∗ ∗ ∗ ∗ ∗
Returns a c l o n e o f the c u r r e n t output c o n t e x t . P l e a s e note t h a t the c a l l e r i s r e s p o n s i b l e to d e l e t e i t i f no l o n g e r needed . @return A p o i n t e r to a dynamically a l l o c a t e d c l o n e o f t h i s instance
277
278
93 94 95 96 97 98
10. Memory – ein kleines Beispiel
∗/ virtual OutputContext ∗ getOutputContextClone ( ) { return ( o u t p u t c o n t e x t . getClone ( ) ) ; } };
99 100 101
#endif // s i m p l e o u t p u t h a n d l i n g h
10.2.10 Displayable Die Klasse SimpleOutputHandling arbeitet ausschließlich mit Elementen der Klasse Displayable. Diese ist eine abstrakte Basisklasse, von der man darstellbare Klassen entsprechend ableiten muss. Das Interface, das solche Klassen dann implementieren m¨ ussen, ist sehr einfach: 1 2
// s i m p l e d i s p l a y a b l e . h − a s i m p l e a b s t r a c t base f o r a // displayable object
3 4 5
#i f n d e f s i m p l e d i s p l a y a b l e h #define s i m p l e d i s p l a y a b l e h
6 7 8
#include ” s i m p l e o u t p u t c o n t e x t . h” c l a s s SimpleOutputHandling ;
9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ The a b s t r a c t base c l a s s f o r t e x t u a l l y d i s p l a y a b l e o b j e c t s , ∗ as i t i s used by SimpleOutputHandling ∗/
14 15 16 17
class Displayable { public :
18 19 20 21 22
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ D i s p l a y a b l e ( ) { }
23 24 25 26 27 28 29 30 31 32
/∗ This c a l l b a c k i s used to n o t i f y a d i s p l a y a b l e t h a t i t ∗ has j u s t been r e g i s t e r e d with an output h a n d l e r . I t can ∗ perform a l l n e c e s s a r y o p e r a t i o n s i n t h i s method to ∗ be prepared to g e n e r a t e output . ∗ @param h an dl e r The ha n dl e r t h a t t h i s d i s p l a y a b l e was ∗ r e g i s t e r e d with . ∗/ virtual void d i s p l a y a b l e R e g i s t e r e d ( SimpleOutputHandling & h a nd l er ) = 0 ;
33 34 35 36 37 38
/∗ The c a l l b a c k t h a t i s c a l l e d by SimpleOutputHandling when ∗ o u t p u t t i n g the d i s p l a y a b l e s i s t r i g g e r e d . ∗ @param c o n t e x t The output c o n t e x t to w r i t e to . ∗/ virtual void writeDisplayRep ( OutputContext & c o n t e x t ) = 0 ;
39 40
};
41 42
#endif // s i m p l e d i s p l a y a b l e h
10.2 Das DDD
279
10.2.11 OutputContext Abh¨angig davon, ob es sich nun um einen Text-, Graphik- oder anderen Kontext handelt, stehen verschiedene Methoden in diesem Kontext zur Verf¨ ugung. Diese k¨ onnen nicht wirklich in einer Basisklasse deklariert werden, denn sie sind einfach zu unterschiedlich und nicht vorhersehbar. Aus diesem Grund ist die abstrakte Basisklasse OutputContext auch dementsprechend primitiv gestaltet: 1 2 3
#i f n d e f s i m p l e o u t p u t c o n t e x t h #define s i m p l e o u t p u t c o n t e x t h
4 5 6 7 8
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ The base c l a s s f o r a l l d i f f e r e n t output c o n t e x t s . I t i s ∗ used i n c o n t e x t with the D i s p l a y a b l e i n t e r f a c e . ∗/
9 10 11 12
c l a s s OutputContext { public :
13 14 15 16 17
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ OutputContext ( ) { }
18 19 20 21 22 23 24 25 26 27 28 29
/∗ Returns a c l o n e o f the output c o n t e x t . Has to be o v e r r i d d e n ∗ by a l l d e r i v e d c l a s s e s to d e l i v e r a c l o n e o f the c o r r e c t ∗ type . This c l o n e has to be dynamically a l l o c a t e d v i a new ∗ and the c a l l e r i s r e s p o n s i b l e to d e l e t e i t i f no l o n g e r ∗ needed . ∗ @return A p o i n t e r to the dynamically g e n e r a t e d c l o n e o f t h i s ∗ OutputContext . This c l o n e has to have e x a c t l y the ∗ type o f the d e r i v e d c l a s s . ∗/ virtual OutputContext ∗ getClone ( ) = 0 ; };
30 31 32
#endif // s i m p l e o u t p u t c o n t e x t h
10.2.12 TextOutputContext Die Auspr¨ agung des Output Contexts, die im Spiel Verwendung findet, ist ein ganz primitiver TextOutputContext, der gerade eben einmal ein paar Methoden besitzt, u ¨ber die man Daten ausgeben kann. Diese werden einfach auf cout geschrieben. Mit dem Wissen um Streams, die noch in Abschnitt 16.6 kurz umrissen werden, k¨ onnte man diesen Kontext nat¨ urlich sauberer gestalten. 1
// s i m p l e t e x t o u t p u t c o n t e x t . h − a s i m p l e t e x t output c o n t e x t
2 3 4
#i f n d e f s i m p l e t e x t o u t p u t c o n t e x t h #define s i m p l e t e x t o u t p u t c o n t e x t h
280
10. Memory – ein kleines Beispiel
5 6
#include < i o s t r e a m>
7 8
#i n c l u d e ” s i m p l e o u t p u t c o n t e x t . h”
9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A very s i m p l e t e x t output c o n t e x t ∗/
13 14 15 16
c l a s s TextOutputContext : public OutputContext { public :
17 18 19 20 21
/∗ D e f a u l t c o n s t r u c t o r ∗ Nothing has to be done i n i t ∗/ TextOutputContext ( ) { }
22 23 24 25 26
/∗ Copy c o n s t r u c t o r ∗ Just because i t has to be made e x p l i c i t ∗/ TextOutputContext ( const TextOutputContext & s r c ) { }
27 28 29 30 31
/∗ D e s t r u c t o r ∗ Just because o f the convention . . . ∗/ virtual ˜ TextOutputContext ( ) { }
32 33 34 35 36 37 38
/∗ This method i s used to w r i t e t e x t to the output . ∗/ virtual void w r i t e ( const char ∗ t e x t ) { s t d : : cout << t e x t ; }
39 40 41 42 43 44 45
/∗ This method i s used to w r i t e ( s i g n e d ) i n t e g e r s to the output . ∗/ virtual void w r i t e ( i n t 6 4 num) { s t d : : cout << num ; }
46 47 48 49 50 51 52 53 54 55
/∗ Returns a c l o n e o f the t e x t output c o n t e x t . P l e a s e note ∗ t h a t the c a l l e r i s r e s p o n s i b l e to d e l e t e i t i f no l o n g e r ∗ needed . ∗/ virtual OutputContext ∗ getClone ( ) { return (new TextOutputContext (∗ th is ) ) ; } };
56 57 58
#endif // s i m p l e t e x t o u t p u t c o n t e x t h
¨ F¨ ur Leser, die sich gerne spielen wollen, h¨atte ich eine kleine Ubungsaufgabe anzubieten: Es w¨ are doch nett, einen textuellen Output Kontext zu haben, der es erlaubt, Text u ¨ber Koordinaten an einer gewissen Stelle zu platzieren. Man muss nur im Hintergrund eine virtuelle Zeichenfl¨ache zur Verf¨ ugung stellen und die notwendigen Methoden dazu schreiben. Die tats¨achliche Ausgabe des Textes auf dem Bildschirm erfolgt, nachdem alle darstellbaren Objekte ihren Teil geschrieben haben.
10.2 Das DDD
281
10.2.13 GameCard Die Basisklasse f¨ ur unsere Spielkarte ist eine weitere kleine Variation der im letzten Kapitel besprochenen Karten, die folgendermaßen aussieht: 1
// game card v3 . h − d e c l a r a t i o n o f a g e n e r a l card f o r games
2 3 4
#i f n d e f g a m e c a r d v 3 h #define g a m e c a r d v 3 h
5 6
#include ” u s e r t y p e s . h”
7 8 9 10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A g e n e r a l c l a s s o f a card f o r games . I t has two s i d e s and can ∗ be turned around . One way o f t u r n i n g i t i s to d e l i b e r a t e l y put ∗ the f r o n t or the back s i d e up . The o t h e r way i s to f l i p the ∗ card . This c l a s s does not handle any o t h e r i n f o r m a t i o n than ∗ the v i s i b l e s i d e . I t has nothing to do with d i s p l a y ∗ r e p r e s e n t a t i o n s and o t h e r s t u f f . ∗/
16 17 18 19
c l a s s GameCard { protected :
20 21 22 23
/∗ S t o r e s the s i d e o f the card t h a t i s v i s i b l e at the moment . ∗/ uint8 v i s i b l e s i d e ;
24 25
public :
26 27 28 29 30 31
/∗ The f o l l o w i n g two c o n s t a n t s d e f i n e the p o s s i b l e v a l u e s f o r ∗ the v i s i b l e s i d e o f the card . ∗/ s t a t i c const u i n t 8 FRONT SIDE = 0 x01 ; s t a t i c const u i n t 8 BACK SIDE = 0x02 ;
32 33 34 35 36 37 38 39 40 41 42
/∗ Standard Constructor ∗ @param v i s i b l e s i d e Has to be e i t h e r o f the two c o n s t a n t s ∗ d e f i n e d above . The parameter i s checked f o r ∗ v a l i d i t y and i f i t i s i n v a l i d the i n i t i a l l y ∗ v i s i b l e s i d e i s the f r o n t s i d e . ∗/ e x p l i c i t GameCard( u i n t 8 v i s i b l e s i d e ) : v i s i b l e s i d e ( ( ( v i s i b l e s i d e == FRONT SIDE ) | | ( v i s i b l e s i d e == BACK SIDE) ) ? v i s i b l e s i d e : FRONT SIDE) { }
43 44 45 46 47 48 49 50 51
/∗ Copy Constructor ∗ Implemented e x p l i c i t l y to make s u r e t h a t i t cannot be ∗ f o r g o t t e n i n f u t u r e changes . ∗/ GameCard( const GameCard & s r c ) { v i s i b l e s i d e = src . v i s i b l e s i d e ; }
52 53 54 55 56
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜GameCard ( ) { }
57 58 59 60
/∗ Turns the card to the o t h e r s i d e . No checks f o r the ∗ v a l i d i t y o f the s t o r e d s i d e a r e n e c e s s a r y because i t s ∗ v a l i d i t y i s f u l l y guaranteed by the c o n s t r u c t o r and
282
61 62 63 64 65 66 67
10. Memory – ein kleines Beispiel
∗ the o t h e r methods . ∗/ virtual void turnCard ( ) { v i s i b l e s i d e = ( v i s i b l e s i d e == FRONT SIDE) ? BACK SIDE : FRONT SIDE; }
68 69 70 71 72 73 74 75
/∗ Puts the f r o n t s i d e o f the card up ( i . e . makes i t v i s i b l e ) , ∗ r e g a r d l e s s o f which s i d e i s v i s i b l e at the moment . ∗/ virtual void putFrontSideUp ( ) { v i s i b l e s i d e = FRONT SIDE ; }
76 77 78 79 80 81 82 83
/∗ Puts the back s i d e o f the card up ( i . e . makes i t v i s i b l e ) , ∗ r e g a r d l e s s o f which s i d e i s v i s i b l e at the moment . ∗/ virtual void putBackSideUp ( ) { v i s i b l e s i d e = BACK SIDE ; }
84 85 86 87 88 89 90 91 92 93
/∗ Returns the v i s i b l e s i d e o f the card . ∗ @return The v i s i b l e s i d e o f the card i n the form o f one o f ∗ the two c o n s t a n t s FRONT SIDE or BACK SIDE . ∗/ virtual u i n t 8 g e t V i s i b l e S i d e ( ) { return ( v i s i b l e s i d e ) ; } };
94 95 96
#endif // g a m e c a r d v 3 h
10.2.14 MemoryGameCard Die Klasse MemoryGameCard ist ebenfalls eine weitere kleine Variation der im letzten Kapitel besprochenen Memory Spielkarten. Sie ist eine einfache, darstellbare Spielkarte. Als solche ist sie von Displayable und von GameCard abgeleitet und speichert ein Symbol f¨ ur die Vorderseite und ein Symbol f¨ ur die R¨ uckseite. Nachdem die Karte keine Kristallkugel befragen kann, welches Symbol sie f¨ ur die Vorder- und welches f¨ ur die R¨ uckseite speichern soll, m¨ ussen diese beiden von außen gesetzt werden. Dies muss nat¨ urlich im Konstruktor geschehen, denn es muss ja verhindert werden, dass jemand schummelt, indem einfach das Symbol umgesetzt wird :-). Dieses Meisterwerk hat dann die folgende Form: 1
// memory game card v5 . h − memory game card as used i n the example
2 3 4
#i f n d e f memory game card v5 h #define memory game card v5 h
5 6 7
#include ” game card v3 . h” #include ” s i m p l e d i s p l a y a b l e . h”
10.2 Das DDD
8
#include ” s i m p l e t e x t o u t p u t c o n t e x t . h”
9 10 11 12 13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A game card f o r the memory game . I t i s a b l e to s t o r e symbols ∗ f o r both s i d e s . Both a r e s t o r e d as a r b i t r a r y char p o i n t e r s . ∗ The card i s compatible to the mechanism d e f i n e d by the ∗ SimpleOutputHandling c l a s s because i t i s d e r i v e d from ∗ D i s p l a y a b l e . I t i s a l s o a standard GameCard . ∗/ c l a s s MemoryGameCard : public GameCard , public D i s p l a y a b l e { protected :
21 22 23 24 25 26
/∗ The ∗ the ∗ the ∗/ char
s t r i n g s t o r i n g the f r o n t s i d e symbol . I t i s a copy o f f r o n t s i d e symbol t h a t i s passed on as a parameter o f c o n s t r u c t o r and has to be d e l e t e d i n the d e s t r u c t o r . ∗ front symbol ;
27 28 29 30 31 32
/∗ The ∗ the ∗ the ∗/ char
s t r i n g s t o r i n g the back s i d e symbol . I t i s a copy o f back s i d e symbol t h a t i s passed on as a parameter o f c o n s t r u c t o r a n d has to be d e l e t e d i n the d e s t r u c t o r . ∗ back symbol ;
33 34
private :
35 36 37 38 39
/∗ Copy c o n s t r u c t i o n i s f o r b i d d e n ∗/ MemoryGameCard( const MemoryGameCard & s r c ) : GameCard(BACK SIDE) { }
40 41
public :
42 43 44 45 46 47 48 49 50 51 52 53
/∗ Standard c o n s t r u c t o r ∗ I t has to c a l l the c o n s t r u c t o r f o r the base c l a s s e s . The ∗ c o n s t r u c t o r f o r GameCard i s c a l l e d so t h a t the back s i d e i s ∗ i n i t i a l l y the v i s i b l e s i d e . ∗ The s t r i n g s passed on f o r the symbols have to be c o p i e d to the ∗ a p p r o p r i a t e members . I f 0 p o i n t e r s a r e passed then an empty ∗ s t r i n g i s g e n e r a t e d f o r the i n t e r n a l r e p r e s e n t a t i o n ( j u s t ∗ because e x c e p t i o n s a r e not known yet ) . ∗/ MemoryGameCard( const char ∗ f r o n t s y m b o l , const char ∗ back symbol ) ;
54 55 56 57 58 59
/∗ D e s t r u c t o r ∗ Has to d e l e t e the c o p i e d s t r i n g s f o r the f r o n t and the ∗ back symbols ∗/ virtual ˜MemoryGameCard ( ) ;
60 61 62 63 64 65 66
/∗ Nothing s p e c i a l to be done when a card i s r e g i s t e r e d ∗ @param h a n dl e r The ha n dl e r t h a t t h i s d i s p l a y a b l e was ∗ r e g i s t e r e d with . ∗/ virtual void d i s p l a y a b l e R e g i s t e r e d ( SimpleOutputHandling & h a nd l er ) { }
67 68 69 70 71 72 73
/∗ Writes e i t h e r the f r o n t or the back symbol a c c o r d i n g to the ∗ s i d e t h a t i s v i s i b l e at the moment . ∗/ virtual void writeDisplayRep ( OutputContext & c o n t e x t ) { dynamic cast(c o n t e x t ) .
283
284
10. Memory – ein kleines Beispiel
w r i t e ( ( v i s i b l e s i d e == FRONT SIDE) ? f r o n t s y m b o l : back symbol ) ;
74 75 76
}
77 78 79 80 81 82 83 84
/∗ Returns the f r o n t symbol o f the card ∗ @return The f r o n t symbol ∗/ virtual const char ∗ getFrontSymbol ( ) { return ( f r o n t s y m b o l ) ; }
85 86 87 88 89 90 91 92 93
/∗ Returns the back symbol o f the card ∗ @return The back symbol ∗/ virtual const char ∗ getBackSymbol ( ) { return ( f r o n t s y m b o l ) ; } };
94 95
#endif // memory game card v5 h
10.2.15 MemoryGameboard Die Klasse MemoryGameboard ist f¨ ur alles verantwortlich, was mit der Anzeige des Spielbretts und der darauf liegenden Karten zu tun hat. Aus diesem Grund ist sie auch die Drehscheibe f¨ ur alles, was mit dem Auflegen und Umdrehen von Karten zu tun hat. Um der Spielsteuerung zu erm¨oglichen, herauszufinden, wie viele Karten bereits umgedreht wurden, hat diese Klasse auch die entsprechenden Abfragemethoden zu implementieren. Diese Methoden d¨ urfen vor allem nicht naiv implementiert werden, denn je nach Gr¨oße des Spielfelds kann ein oftmaliges Abfragen jeder einzelnen Karte zu erheblichen Performanceproblemen f¨ uhren. Das Memory Spielbrett ist nicht daf¨ ur verantwortlich, die Karten, die auf ihm liegen, zu generieren. Es ist nur daf¨ ur verantwortlich, Karten, die aufgelegt werden, auch entsprechend zu speichern. Auch bei der Darstellung des Spielfelds ist noch eine Kleinigkeit zu beachten: Es ist nicht genug, einfach nur die auf dem Feld liegenden Karten bei der verantwortlichen Instanz von SimpleOutputHandling anzumelden. Um sinnvoll spielen zu k¨ onnen, m¨ ussen auch die Reihen und Spalten des Spielfelds mit entsprechenden K¨ opfen versehen werden, die die Koordinaten anzeigen. Um nun keine Seiteneffekte und k¨ unstliche Abh¨angigkeiten in das Programm einzubauen, ist der einzig sinnvolle Weg, auch diese K¨opfe u ¨ber entsprechende darstellbare Objekte zu realisieren, die von Displayable abgeleitet sind. Diese sind im u ¨berblicksartigen Klassendiagramm noch nicht enthalten, werden aber nun als notwendige Hilfsklassen eingef¨ uhrt (siehe Abschnitt 10.2.16 und Abschnitt 10.2.17). Ein kleiner Exkurs: Manche Leser werden es schon bemerkt haben, dass ich hier eine Kleinigkeit demonstrieren m¨ochte. Der Schritt, einfach so eine
10.2 Das DDD
285
kleine Hilfsklasse einzuf¨ uhren, die im Klassendiagramm noch nicht enthalten ist, ist schlimm genug. In der Praxis muss nun unbedingt ein Schritt zur¨ uck gemacht werden und das Klassendiagramm erg¨anzt werden. Das ist jedoch noch nicht alles: Im Normalfall w¨are jetzt sogar der Zeitpunkt gekommen, zu dem man allgemeing¨ ultig u ¨ber simple darstellbare Objekte und u ¨ber Container von darstellbaren Objekten nachdenken muss. Das Spielbrett ist n¨ amlich von seiner Logik her genau ein solcher Contai¨ ner. Das w¨ urde dann eine weitere Anderung im Klassendiagramm bedeuten: Ein darstellbarer Container ist abgeleitet von Displayable und das MemoryGameboard ist dann von diesem abgeleitet. Um hier nicht zur absoluten Verwirrung beizutragen, f¨ uhre ich diesen allgemeinen Container nicht ein. Stattdessen wird das MemoryGameboard dergestalt entworfen, dass es die Containerfunktionalit¨ at besitzt. Alle Leser m¨ ogen sich aber nun unbedingt ein paar Gedanken machen, was diese Vorgangsweise, die leider auch in der Praxis sehr oft praktiziert wird, bedeutet! Hier bahnt sich bereits ein b¨oser Hack an, denn es ist zu ¨ erwarten, dass im Lauf der Zeit das Spiel mehrere Anderungen erf¨ahrt und dass wahrscheinlich auch dann an mehreren Stellen ein allgemeiner Container gebraucht w¨ urde. In jedem Fall muss dann eine erhebliche Menge an Code umgeschrieben werden, wenn man ihn erst versp¨atet einf¨ uhrt. Oder, noch schlimmer, alle Klassen, die ihn brauchen, implementieren die Funktio¨ nalit¨at selbst. Dass das dann auf Kosten der Ubersichtlichkeit, Wartbarkeit, ¨ Anderbarkeit und Intuitivit¨ at geht, l¨asst sich leicht nachvollziehen. Nach diesem Exkurs, der etwas zum Nachdenken anregen sollte, sieht das mit Vorsicht zu genießende Resultat f¨ ur unser Spielbrett folgendermaßen aus: 1
// memory gameboard . h − a s i m p l e gameboard f o r memory
2 3 4
#i f n d e f memory gameboard h #define memory gameboard h
5 6 7 8 9 10
#include #include #include #include #include
” s i m p l e d i s p l a y a b l e . h” ” s i m p l e o u t p u t h a n d l i n g . h” ” s i m p l e v e c t o r . h” ”memory game card v5 . h” ” u s e r t y p e s . h”
11 12 13 14 15 16 17 18 19
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ The c l a s s f o r the gameboard . This c l a s s i s r e s p o n s i b l e f o r ∗ the management o f the c a r d s and has to take c a r e f o r ∗ r e g i s t e r i n g them at the output ha n d l e r . I t a l s o has to keep ∗ t r a c k o f the number o f c a r d s on the board and o f the number ∗ o f c a r d s with f r o n t and back s i d e s up . Turning c a r d s i s ∗ performed v i a a c a l l to t h i s c l a s s . ∗/
20 21 22 23
c l a s s MemoryGameboard : public D i s p l a y a b l e { protected :
24 25 26
/∗ S t o r e s the number o f rows o f the board a c c o r d i n g to the ∗ parameter passed to the c o n s t r u c t o r . In p r i n c i p l e t h i s
286
27 28 29 30 31
10. Memory – ein kleines Beispiel
∗ i s a reduncancy , because the v e c t o r o f v e c t o r s used to ∗ s t o r e the c a r d s a l s o keeps t r a c k o f i t , but i t ’ s much ∗ more r e a d a b l e t h i s way . ∗/ u i n t 3 2 num rows ;
32 33 34 35 36 37 38
/∗ S t o r e s the number o f columns o f the board a c c o r d i n g to the ∗ parameter passed to the c o n s t r u c t o r . This i s the same ∗ reduncancy as s t o r i n g the number o f rows , but i t ’ s a l s o ∗ done f o r r e a d a b i l i t y r e a s o n s . ∗/ u i n t 3 2 num cols ;
39 40 41 42 43 44
/∗ The number o f c a r d s on the board t h a t a r e l y i n g f r o n t ∗ s i d e up . This member i s updated on every s i n g l e o p e r a t i o n ∗ t h a t i s performed on a card on the board . ∗/ uint32 num cards front side up ;
45 46 47 48 49 50
/∗ The row v e c t o r t h a t s t o r e s a l l the column v e c t o r s used ∗ to hold the c a r d s . This v e c t o r i s i n i t i a l i z e d to d e l e t e ∗ the c a r d s i n the d e s t r u c t o r . ∗/ Vector ∗ r o w v e c t o r ;
51 52 53 54 55 56
/∗ The v e c t o r s t o r i n g a l l the d i s p l a y a b l e s t h a t a r e r e s p o n s i b l e ∗ f o r the row headings . I t i s i n i t i a l i z e d to d e l e t e the ∗ d i s p l a y a b l e s i n the d e s t r u c t o r . ∗/ Vector ∗ r o w h e a d i n g d i s p l a y a b l e s ;
57 58 59 60 61 62
/∗ The v e c t o r s t o r i n g a l l the d i s p l a y a b l e s t h a t a r e r e s p o n s i b l e ∗ f o r the column headings . I t i s i n i t i a l i z e d to d e l e t e the ∗ d i s p l a y a b l e s i n the d e s t r u c t o r . ∗/ Vector ∗ c o l h e a d i n g d i s p l a y a b l e s ;
63 64 65 66 67 68 69
/∗ The v e c t o r s t o r i n g a l l the d i s p l a y a b l e s t h a t a r e r e s p o n s i b l e ∗ f o r the end o f l i n e a f t e r each column i n c l u d i n g the column ∗ heading l i n e . I t i s i n i t i a l i z e d to d e l e t e the d i s p l a y a b l e s ∗ i n the d e s t r u c t o r . ∗/ Vector ∗ e o l d i s p l a y a b l e s ;
70 71 72 73 74 75
/∗ This i n s t a n c e o f the output ha n d l e r i s needed to implement ∗ the c o n t a i n e r ’ s d i s p l a y f u n c t i o n a l i t y . Here a l l the c a r d s , ∗ row and column headings a r e r e g i s t e r e d . On a c a l l to ∗/ SimpleOutputHandling ∗ c o n t a i n e r o u t p u t h a n d l i n g ;
76 77 78 79 80
/∗ This h o l d s the c l o n e o f the output c o n t e x t to be a b l e to ∗ d e l e t e i t i n the d e s t r u c t o r . ∗/ OutputContext ∗ c o n t a i n e r o u t p u t c o n t e x t ;
81 82
private :
83 84 85 86
/∗ Copy c o n s t r u c t i o n i s not a l l o w ed ∗/ MemoryGameboard( const MemoryGameboard& s r c ) { }
87 88
public :
89 90 91 92
/∗ Standard c o n s t r u c t o r ∗ I n i t i a l i z e s the v e c t o r s f o r the c a r d s , the row and column ∗ headings and s e t s the output h a n dl e r to 0 . The output
10.2 Das DDD
93 94 95 96 97 98 99 100 101 102 103
∗ h a n dl e r i s then s e t when the gameboard i t s e l f i s r e g i s t e r e d ∗ with the ” master ” output ha n dl e r . ∗ The number o f rows and the number o f columns a r e checked i n ∗ a way t h a t they both have to be non−z e r o and t h a t the number ∗ o f f i e l d s o v e r a l l i s even ( t h e r e i s always an even number o f ∗ c a r d s ! ) . I f something i s wrong the v a l u e s have to be c o r r e c t e d ∗ a c c o r d i n g l y ( j u s t because e x c e p t i o n s a r e not known yet ) . ∗ @param num rows The number o f rows f o r the f i e l d ∗ @param num cols The number o f columns f o r the f i e l d ∗/ MemoryGameboard( u i n t 3 2 num rows , u i n t 3 2 num cols ) ;
104 105 106 107 108 109
/∗ D e s t r u c t o r ∗ Has to d e l e t e a l l v e c t o r s and the output ha n d l e r f o r the ∗ container . ∗/ virtual ˜ MemoryGameboard ( ) ;
110 111 112 113 114 115 116 117 118 119 120 121
/∗ Because the gameboard does not produce any s p e c i a l i z e d ∗ output i t s e l f ( a l l t e x t f i e l d s a r e r e g i s t e r e d as t e x t ∗ d i s p l a y a b l e s ) i t only t r i g g e r s output o f the c o n t a i n e d ∗ d i s p l a y a b l e elements . ∗ @param c o n t e x t The output c o n t e x t to w r i t e to ∗/ virtual void writeDisplayRep ( OutputContext & c o n t e x t ) { i f ( container output handling ) c o n t a i n e r o u t p u t h a n d l i n g −>writeOutput ( ) ; }
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
/∗ When t h i s method i s c a l l e d a l l n e c e s s a r y t e x t d i s p l a y a b l e s ∗ have to be g e n e r a t e d and s t o r e d i n the row and column heading ∗ v e c t o r s . A d d i t i o n a l l y the c o n t a i n e r output ha n d l e r has to ∗ be i n s t a n t i a t e d and t h e s e g e n e r a t e d d i s p l a y a b l e s have to ∗ be r e g i s t e r e d t h e r e t o g e t h e r with a l l the c a r d s . ∗ ATTENTION: This behaviour means t h a t a l l c a r d s a l r e a d y ∗ have to be on the gameboard b e f o r e r e g i s t e r i n g i t with the ∗ d i s p l a y h an dl e r ! ! ! ! Otherwise t h i s method ’ s t a s k s a r e not ∗ f u l f i l l a b l e correctly ! ! ! ∗ ATTENTION: I f t h i s board i s r e g i s t e r e d with more than one ∗ d i s p l a y ha n dl e r the i n i t i a l i z i n g s t e p s must not be done ∗ again , because e v e r y t h i n g i s a l r e a d y setup c o r r e c t l y . ∗ T h e r e f o r e t h i s method has to check t h i s b e f o r e . ∗ @param ha n dl e r The ha n dl e r f o r which t h i s d i s p l a y a b l e was ∗ registered . ∗/ virtual void d i s p l a y a b l e R e g i s t e r e d ( SimpleOutputHandling & h a nd l er ) ;
141 142 143 144 145 146 147 148 149 150
/∗ Turns a l l the c a r d s so t h a t they show the f r o n t s i d e . This ∗ method does not t r i g g e r any output , so f o r the r e s u l t o f ∗ the o p e r a t i o n to be shown the output has to be t r i g g e r e d ∗ e x p l i c i t l y by an a c c o r d i n g c a l l to the d i s p l a y h a n d l e r . ∗ This method a l s o updates the i n t e r n a l cache f o r the number ∗ o f c a r d s t h a t show t h e i r f r o n t s i d e at the moment ( t h i s ∗ i s the v a r i a b l e n u m c a r d s f r o n t s i d e u p ) . ∗/ virtual void putAllCardsFrontSideUp ( ) ;
151 152 153 154 155 156 157 158
/∗ ∗ ∗ ∗ ∗ ∗ ∗
Turns a l l the c a r d s so t h a t they show the back s i d e . This method does not t r i g g e r any output , so f o r the r e s u l t o f the o p e r a t i o n to be shown the output has to be t r i g g e r e d e x p l i c i t l y by an a c c o r d i n g c a l l to the d i s p l a y ha n d l e r . This method a l s o updates the i n t e r n a l cache f o r the number o f c a r d s t h a t show t h e i r f r o n t s i d e at the moment ( t h i s i s the v a r i a b l e n u m c a r d s f r o n t s i d e u p ) .
287
288
159 160
10. Memory – ein kleines Beispiel
∗/ virtual void putAllCardsBackSideUp ( ) ;
161 162 163 164 165 166 167 168 169 170 171
/∗ Turns the card g i v e n by the c o o r d i n a t e s , so t h a t i t shows ∗ the f r o n t s i d e . I f n e c e s s a r y ( i . e . the card showed i t s ∗ back s i d e b e f o r e ) the v a r i a b l e n u m c a r d s f r o n t s i d e u p ∗ has to be updated . ∗ @param row The row o f the card to be turned s t a r t i n g ∗ with 0 f o r the f i r s t row . ∗ @param c o l The column o f the card to be turned s t a r t i n g ∗ with 0 f o r the f i r s t column . ∗/ virtual void putCardFrontSideUp ( u i n t 3 2 row , u i n t 3 2 c o l ) ;
172 173 174 175 176 177 178 179 180 181 182
/∗ Turns the card g i v e n by the c o o r d i n a t e s , so t h a t i t shows ∗ the back s i d e . I f n e c e s s a r y ( i . e . the card showed i t s ∗ f r o n t s i d e b e f o r e ) the v a r i a b l e n u m c a r d s f r o n t s i d e u p ∗ has to be updated . ∗ @param row The row o f the card to be turned s t a r t i n g ∗ with 0 f o r the f i r s t row . ∗ @param c o l The column o f the card to be turned s t a r t i n g ∗ with 0 f o r the f i r s t column . ∗/ virtual void putCardBackSideUp ( u i n t 3 2 row , u i n t 3 2 c o l ) ;
183 184 185 186 187 188 189 190 191 192 193
/∗ Returns a p o i n t e r to the card at the g i v e n p o s i t i o n . ∗ I f t h e r e i s no card at t h i s p o s i t i o n a 0 p o i n t e r i s ∗ r e t u r n e d ( t h i s happens i m p l i c i t l y because o f the kind o f ∗ s t o r a g e o f the c a r d s i n the v e c t o r ) . ∗ @param row The row o f the d e s i r e d card s t a r t i n g with 0 ∗ f o r the f i r s t row . ∗ @param c o l The column o f the d e s i r e d card s t a r t i n g ∗ with 0 f o r the f i r s t column . ∗/ virtual MemoryGameCard ∗ getCard ( u i n t 3 2 row , u i n t 3 2 c o l ) const ;
194 195 196 197 198 199 200 201 202 203 204
/∗ Returns the number o f rows . This may be a number t h a t i s ∗ d i f f e r e n t to the one g i v e n i n the c o n s t r u c t o r , because i t ∗ could have happened t h a t i t was c o r r e c t e d i n t e r n a l l y ( s e e ∗ a l s o : d e s c r i p t i o n o f the c o n s t r u c t o r ) ∗ @return The a c t u a l number o f rows ∗/ virtual u i n t 3 2 getNumRows ( ) const { return ( num rows ) ; }
205 206 207 208 209 210 211 212 213 214 215
/∗ Returns the number o f columns . This may be a number t h a t i s ∗ d i f f e r e n t to the one g i v e n i n the c o n s t r u c t o r , because i t ∗ could have happened t h a t i t was c o r r e c t e d i n t e r n a l l y ( s e e ∗ a l s o : d e s c r i p t i o n o f the c o n s t r u c t o r ) ∗ @return The a c t u a l number o f columns ∗/ virtual u i n t 3 2 getNumCols ( ) const { return ( num cols ) ; }
216 217 218 219 220 221 222 223 224
/∗ Returns the number o f c a r d s t h a t c u r r e n t l y a r e l y i n g on the ∗ board with t h e i r f r o n t s i d e s up . ∗ @return The a c t u a l number o f c a r d s t h a t show t h e i r f r o n t s i d e s ∗/ virtual u i n t 3 2 getNumCardsFrontSideUp ( ) const { return ( n u m c a r d s f r o n t s i d e u p ) ; }
10.2 Das DDD
289
225 226 227 228 229 230 231 232 233
/∗ Returns the number o f c a r d s t h a t c u r r e n t l y a r e l y i n g on the ∗ board with t h e i r back s i d e s up . ∗ @return The a c t u a l number o f c a r d s t h a t show t h e i r back s i d e s ∗/ virtual u i n t 3 2 getNumCardsBackSideUp ( ) const { return ( ( num rows ∗ num cols ) − n u m c a r d s f r o n t s i d e u p ) ; }
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
/∗ This method i s c a l l e d to put a card on the board . I f a card ∗ a l r e a d y o c c u p i e s the d e s i r e d p l a c e t h i s c a l l i s i g n o r e d . ∗ P l e a s e note t h a t the card has to be d e l e t e d then , because ∗ the c a l l e r s r e l y on t h i s f a c t ! ( j u s t because e x c e p t i o n s a r e ∗ not known yet ) ∗ A range−check has to be performed on the c o o r d i n a t e s . I f they ∗ a r e out o f range the c a l l i s i g n o r e d and the card i s d e l e t e d , ∗ because c a l l e r s r e l y on t h i s f a c t ! ( j u s t because e x c e p t i o n s ∗ a r e not known yet ) ∗ @param card A p o i n t e r to the card t h a t has to be put on the ∗ board . The card w i l l be d e l e t e d i n the d e s t r u c t o r ! ∗ @param row The row , where the card has to be put . ∗ @param c o l The column , where the card has to be put . ∗/ virtual void putCardOnBoard (MemoryGameCard ∗ card , u i n t 3 2 row , u i n t 3 2 c o l ) ; protected : /∗ An i n t e r n a l c o n v e n i e n c e method used to o b t a i n a card . I t ∗ does not perform any checks because i t assumes t h a t they ∗ have a l r e a d y been performed b e f o r e . ∗ @param row The row o f the d e s i r e d card . ∗ @param c o l The column o f the d e s i r e d card . ∗/ MemoryGameCard ∗ internalGetCard ( u i n t 3 2 row , u i n t 3 2 c o l ) const { return ( s t a t i c c a s t<MemoryGameCard∗>( s t a t i c c a s t( r o w v e c t o r −>getElementAt ( row))−>getElementAt ( c o l ) ) ) ; } };
265 266 267
#endif // memory gameboard h
Dass es einen Vektor f¨ ur die einzelnen Displayables der Reihenk¨opfe (Zeile 56) und Spaltenk¨ opfe (Zeile 62) gibt, ist ja zu erwarten. Was aber soll im Vektor in Zeile 69 bloß gehalten werden? Ganz einfach: Die Displayables, die nach jeder Spielfeldzeile den Zeilenumbruch bewirken! Ansonsten w¨are das Spielfeld ja ziemlich lang und flach :-). 10.2.16 IntDisplayable Die Klasse IntDisplayable, die hier vorgestellt wird, braucht eigentlich keine besondere Erkl¨ arung: 1
// s i m p l e i n t d i s p l a y a b l e . h − a d i s p l a y a b l e f o r i n t e g r a l numbers
2 3 4
#i f n d e f #define
simple int displayable h simple int displayable h
290
10. Memory – ein kleines Beispiel
5 6 7
#include ” s i m p l e d i s p l a y a b l e . h” #i n c l u d e ” s i m p l e t e x t o u t p u t c o n t e x t . h”
8 9 10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A s i m p l e d i s p l a y a b l e f o r s i g n e d i n t e g e r s o f 6 4 B i t s maximum l e n g t h . ∗ I t implements the D i s p l a y a b l e i n t e r f a c e and i s c o n s t r u c t e d with an ∗ a p p r o p r i a t e number . When the c a l l b a c k to g e n e r a t e output i s c a l l e d , ∗ the i n t e g e r i s w r i t t e n . ∗/ c l a s s I n t D i s p l a y a b l e : public D i s p l a y a b l e { protected :
18 19 20 21
/∗ The i n t e g e r t h a t s h a l l be d i s p l a y e d as passed on i n the c o n s t r u c t o r . ∗/ i n t 6 4 num ;
22 23
private :
24 25 26 27
/∗ Copy c o n s t r u c t i o n i s f o r b i d d e n ∗/ I n t D i s p l a y a b l e ( const I n t D i s p l a y a b l e & s r c ) { }
28 29
public :
30 31 32 33 34 35 36
/∗ Standard c o n s t r u c t o r ∗ I t has to c a l l the c o n s t r u c t o r f o r the base c l a s s and s t o r e the ∗ given i n t e g e r ∗/ e x p l i c i t I n t D i s p l a y a b l e ( i n t 6 4 num ) : D i s p l a y a b l e ( ) , num (num) { }
37 38 39 40 41
/∗ D e s t r u c t o r ∗ Just to meet the convention ∗/ virtual ˜ I n t D i s p l a y a b l e ( ) { }
42 43 44 45 46 47 48
/∗ Nothing s p e c i a l has to be done upon r e g i s t r a t i o n ∗ @param h a n dl e r The ha n dl e r t h a t t h i s d i s p l a y a b l e was ∗ r e g i s t e r e d with . ∗/ virtual void d i s p l a y a b l e R e g i s t e r e d ( SimpleOutputHandling & h a nd l er ) { }
49 50 51 52 53 54 55 56 57
/∗ Writes the number to the c o n t e x t . This method can only work ∗ with a t e x t c o n t e x t . ∗ @param c o n t e x t The output c o n t e x t to w r i t e to . ∗/ virtual void writeDisplayRep ( OutputContext & c o n t e x t ) { dynamic cast(c o n t e x t ) . w r i t e (num ) ; }
58 59
};
60 61
#endif //
simple int displayable h
10.2.17 TextDisplayable Analog zur Darstellung von Ganzzahlen mittels eines besonderen Displayables gibt es auch eine entsprechende Klasse TextDisplayable, die sich f¨ ur Strings verantwortlich f¨ uhlt:
10.2 Das DDD
1
291
// s i m p l e t e x t d i s p l a y a b l e . h − a d i s p l a y a b l e f o r t e x t
2 3 4
#i f n d e f #define
simple text displayable h simple text displayable h
5 6 7 8
#include < c s t r i n g> #include ” s i m p l e d i s p l a y a b l e . h” #include ” s i m p l e t e x t o u t p u t c o n t e x t . h”
9 10 11 12 13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A s i m p l e d i s p l a y a b l e f o r t e x t ∗ I t implements the D i s p l a y a b l e i n t e r f a c e and i s c o n s t r u c t e d with an ∗ a p p r o p r i a t e s t r i n g . When the c a l l b a c k to ∗ o b t a i n the d i s p l a y r e p r e s e n t a t i o n i s c a l l e d t h i s s t r i n g i s w r i t t e n . ∗ The s t r i n g may be any s t a t i c a l l y or dynamically a l l o c a t e d char ∗ , ∗ because i t i s c o p i e d . ∗/ c l a s s TextDisplayable : public D i s p l a y a b l e { protected :
21 22 23 24 25 26
/∗ The s t r i n g s t o r i n g the t e x t to be d i s p l a y e d . I t i s a copy o f the ∗ s t r i n g t h a t i s passed on as a parameter o f the c o n s t r u c t o r . ∗ T h e r e f o r e i t has to be d e l e t e d i n the d e s t r u c t o r . ∗/ char ∗ t e x t ;
27 28
private :
29 30 31 32
/∗ Copy c o n s t r u c t i o n i s f o r b i d d e n ∗/ TextDisplayable ( const TextDisplayable & s r c ) { }
33 34
public :
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
/∗ Standard c o n s t r u c t o r ∗ I t has to c a l l the c o n s t r u c t o r f o r the base c l a s s . ∗ The t e x t passed on i s c o p i e d to the t e x t member . I f a 0 p o i n t e r ∗ i s g i v e n as a parameter t e x t i s i n i t i a l i z e d to an empty ∗ s t r i n g ( j u s t because e x c e p t i o n s a r e not known yet ) . ∗/ e x p l i c i t TextDisplayable ( const char ∗ t e x t ) : Displayable ( ) , text (0) { i f ( text ) { t e x t = new char [ s t r l e n ( t e x t ) + 1 ] ; strcpy ( text , text ) ; } }
51 52 53 54 55 56 57 58
/∗ D e s t r u c t o r ∗ Just to meet the convention ∗/ virtual ˜ TextDisplayable ( ) { delete t e x t ; }
59 60 61 62 63 64 65
/∗ Nothing s p e c i a l has to be done upon r e g i s t r a t i o n ∗ @param h a n dl e r The ha n dl e r t h a t t h i s d i s p l a y a b l e was ∗ r e g i s t e r e d with . ∗/ virtual void d i s p l a y a b l e R e g i s t e r e d ( SimpleOutputHandling & h a nd l er ) { }
292
10. Memory – ein kleines Beispiel
66 67 68 69 70 71 72 73 74
/∗ Writes the number to the c o n t e x t . This method can only work ∗ with a t e x t c o n t e x t . ∗ @param c o n t e x t The output c o n t e x t to w r i t e to . ∗/ virtual void writeDisplayRep ( OutputContext & c o n t e x t ) { dynamic cast(c o n t e x t ) . w r i t e ( t e x t ) ; }
75 76
};
77 78
#endif //
simple text displayable h
10.2.18 Event Da die Entscheidung beim Reagieren auf den User-Input zugunsten eines sauberen Event Modells gefallen ist, brauchen wir einmal eine Basisklasse Event. Diese ben¨ otigt eigentlich keine besondere, tiefergehende Erkl¨arung: 1
// s i m p l e e v e n t . h − s i m p l e c l a s s f o r e v e n t s
2 3 4
#i f n d e f s i m p l e e v e n t h #define s i m p l e e v e n t h
5 6
#include ” u s e r t y p e s . h”
7 8 9 10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A very s i m p l e base c l a s s f o r e v e n t s . A l l e v e n t s have to be ∗ d e r i v e d from i t and f o r each event type a c o r r e s p o n d i n g ∗ c o n s t a n t has to be p l a c e d h e r e . This base does nothing but ∗ s t o r e the type o f the event so t h a t i t can be asked f o r i t . ∗ A l l f u n c t i o n a l i t y f o r s p e c i a l e v e n t s has to be implemented ∗ i n the a p p r o p r i a t e d e r i v e d c l a s s e s . ∗/
16 17 18 19
c l a s s Event { protected :
20 21 22 23
/∗ The type o f the event . Must be one o f the c o n s t a n t s below ∗/ int32 event type ;
24 25
public :
26 27 28 29 30
/∗ Constants f o r p o s s i b l e event t y p e s . I f a new type i s ∗ i n t r o d u c e d an a c c o r d i n g c o n s t a n t has to be p l a c e d h e r e . ∗/ s t a t i c const i n t 3 2 WORD EVENT = 0 x1 ;
31 32 33 34 35 36 37 38 39 40 41
/∗ Standard c o n s t r u c t o r ∗ Has to s t o r e the type i n t e r n a l l y . No checks f o r v a l i d t y p e s ∗ a r e performed because e v e n t s a r e u s u a l l y g e n e r a t e d extremely ∗ o f t e n and t h e r e f o r e performance i s an i s s u e . ∗ @param e v e n t t y p e The type o f the event . Has to be one o f ∗ the c o n s t a n t s above ∗/ e x p l i c i t Event ( int e v e n t t y p e ) { event type = event type ;
10.2 Das DDD
42
293
}
43 44 45 46 47 48 49
/∗ Copy c o n s t r u c t o r ∗/ Event ( const Event & s r c ) { event type = src . event type ; }
50 51 52 53 54
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ Event ( ) { }
55 56 57 58 59 60 61 62 63
/∗ Returns the type o f the event ∗ @return The type o f the event as g i v e n i n the c o n s t r u c t o r ∗/ i n t 3 2 getType ( ) const { return ( e v e n t t y p e ) ; } };
64 65 66
#endif // s i m p l e e v e n t h
10.2.19 WordEvent Der hier vorgestellte WordEvent stellt eine Spezialisierung des allgemeinen Events von zuvor dar. Er wird dazu verwendet, den erhaltenen User Input Wort f¨ ur Wort an die Applikation weiterzureichen: 1
// s i m p l e w o r d e v e n t . h − a word event c l a s s
2 3 4
#i f n d e f s i m p l e w o r d e v e n t h #define s i m p l e w o r d e v e n t h
5 6
#include < c s t r i n g>
7 8 9 10 11
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A c l a s s f o r a s p e c i f i c word event . I s used f o r words t h a t ∗ a r e input by u s e r s and have to be passed on . ∗/
12 13 14 15
c l a s s WordEvent : public Event { protected :
16 17 18 19 20 21
/∗ The word t h a t a r r i v e d as a u s e r input and has to be ∗ passed on . This i s a copy and has to be d e l e t e d i n ∗ the d e s t r u c t o r . Also a 0 p o i n t e r i s p o s s i b l e . ∗/ char ∗ word ;
22 23
public :
24 25 26 27 28 29
/∗ Standard c o n s t r u c t o r ∗ c o p i e s the word t h a t i s passed on to i t ∗/ e x p l i c i t WordEvent ( const char ∗ word ) : Event (WORD EVENT) ,
294
10. Memory – ein kleines Beispiel
word ( 0 )
30 31
{ i f ( ! word ) return ; word = new char [ s t r l e n ( word ) + 1 ] ; s t r c p y ( word , word ) ;
32 33 34 35 36
}
37 38 39 40 41 42 43 44 45 46 47 48 49
/∗ Copy c o n s t r u c t o r ∗ c o p i e s the word from the o r i g i n a l ∗/ WordEvent ( const WordEvent & s r c ) : Event (WORD EVENT) , word ( 0 ) { i f ( ! s r c . word ) return ; word = new char [ s t r l e n ( s r c . word ) + 1 ] ; s t r c p y ( word , s r c . word ) ; }
50 51 52 53 54 55 56 57
/∗ D e s t r u c t o r ∗ d e l e t e s the copy o f the word ∗/ virtual ˜ WordEvent ( ) { delete [ ] word ; }
58 59 60 61 62 63 64 65 66
/∗ Returns the word t h a t i s s t o r e d i n the event ∗ @return The word s t o r e d i n the event ∗/ virtual const char ∗ getWord ( ) const { return ( word ) ; } };
67 68 69
#endif // s i m p l e w o r d e v e n t h
10.2.20 SimpleInputHandling Im Prinzip implementiert die Klasse SimpleInputHandling einen einfachen Dispatcher Loop, der auch nur einen einzigen Event Handler kennt. Diesen muss man explizit registrieren und danach kann man den Loop u ¨ber die entsprechende Methode starten. Es wurde hier keinerlei Multithreading implementiert, deshalb kehrt die Methode runDispatcher auch nicht zur¨ uck, bis man den Loop gestoppt hat. Es ist allerdings garantiert, dass der Loop ohne einen zuvor gesetzten Handler sich nicht erh¨angt oder endlos l¨auft. Falls kein Handler gesetzt wurde, kehrt diese Methode sofort zur¨ uck. Diese grandiose Klasse hat dann die folgende Form: 1
// s i m p l e i n p u t h a n d l i n g . h − a s i m p l e input handling c l a s s
2 3 4 5
#i f n d e f s i m p l e i n p u t h a n d l i n g h #define s i m p l e i n p u t h a n d l i n g h
10.2 Das DDD
6
#include ” s i m p l e e v e n t h a n d l e r . h”
7 8 9 10 11 12
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ This c l a s s r e a d s the u s e r input from s t d i n and p a s s e s i t on ∗ to the r e g i s t e r e d ha n dl e r word by word i n the form o f ∗ WordEvent i n s t a n c e s . ∗/
13 14 15 16
c l a s s SimpleInputHandling { protected :
17 18 19 20 21
/∗ The maximum l e n g t h o f accepted input words . I f an input word ∗ i s l o n g e r i t w i l l be s p l i t up i n t o two input words . ∗/ s t a t i c const u i n t 3 2 MAX WORD LENGTH = 2 5 6 ;
22 23
private :
24 25 26 27 28
/∗ This i s a pure s t a t i c method c o l l e c t i o n . T h e r e f o r e f o r b i d ∗ c o n s t r u c t i n g an i n s t a n c e . ∗/ SimpleInputHandling ( ) { } ;
29 30 31 32 33
/∗ I f c o n s t r u c t i o n i s not p o s s i b l e , a l s o d e s t r u c t i o n does ∗ not make any s e n s e at a l l . ∗/ virtual ˜ SimpleInputHandling ( ) { } ;
34 35
protected :
36 37 38 39 40 41
/∗ This v a r i a b l e i s used i n t e r n a l l y to sto p the d i s p a t c h e r ∗ l o o p . I f i t i s s e t the l o o p w i l l sto p a f t e r the next ∗ word t h a t has been obtained from the u s e r input . ∗/ s t a t i c bool s t o p d i s p a t c h e r l o o p ;
42 43 44 45 46
/∗ This i s the event ha n dl e r t h a t o b t a i n s the n o t i f i c a t i o n s ∗ about the u s e r input words . ∗/ s t a t i c EventHandler ∗ e v e n t h a n d l e r ;
47 48
public :
49 50 51 52 53 54 55 56 57 58 59 60
/∗ This method i s used to s e t the r e s p o n s i b l e event h a n d l e r . ∗ I f t h e r e i s a l r e a d y a ha n dl e r s e t , the new one w i l l r e p l a c e ∗ the o l d one . P l e a s e note t h a t the o l d event h a n d l e r w i l l ∗ NOT be d e l e t e d ! ∗ @param ha n dl e r The event h a n dl e r t h a t i s r e s p o n s i b l e f o r ∗ e v e n t s from now on . ∗/ s t a t i c void setEventHandler ( EventHandler ∗ ha n d l e r ) { e v e n t h a n d l e r = ha n d l er ; }
61 62 63 64 65 66 67 68 69 70 71
/∗ This method i s c a l l e d to s t a r t the d i s p a t c h e r l o o p . I t w i l l ∗ NOT r e t u r n u n t i l the l o o p i s stopped because m u l t i t h r e a d i n g ∗ i s not implemented h e r e . To st op the l o o p the method ∗ s t o p D i s p a t c h e r has to be c a l l e d . Then t h i s method w i l l r e t u r n ∗ a f t e r the next word t h a t has been d i s p a t c h e d . ∗ I f no h an dl e r i s s e t t h i s method w i l l r e t u r n immediately ( j u s t ∗ because e x c e p t i o n s a r e not known yet ) . ∗/ s t a t i c void runDispatcher ( ) ;
295
296
72 73 74 75 76 77 78 79 80 81 82 83
10. Memory – ein kleines Beispiel
/∗ C a l l t h i s method to st op the d i s p a t c h e r l o o p . I f i t i s c a l l e d ∗ from w i t h i n the event ha n dl e r w h i l e i t i s j u s t handling an ∗ event , the l o o p w i l l be stopped as soon as the ha n d l e r ∗ n o t i f i c a t i o n method r e t u r n s . I f i t i s c a l l e d from any o t h e r ∗ p o i n t i n the program , the d i s p a t c h e r l o o p w i l l be stopped ∗ a f t e r the next word event has been p r o c e s s e d . ∗/ s t a t i c void s t o p D i s p a t c h e r ( ) { s t o p d i s p a t c h e r l o o p = true ; } };
84 85 86
#endif // s i m p l e i n p u t h a n d l i n g h
10.2.21 SimpleEventHandling Wo Events durch die Gegend geschickt werden, braucht es auch einen Handler dazu. Die Basisklasse daf¨ ur, von der alle M¨ochtegern-Event-Handler abgeleitet sein m¨ ussen, sieht so aus: 1
// s i m p l e e v e n t h a n d l e r . h − a s i m p l e a b s t r a c t event ha n d l e r
2 3 4
#i f n d e f s i m p l e e v e n t h a n d l e r h #define s i m p l e e v e n t h a n d l e r h
5 6
#include ” s i m p l e e v e n t . h”
7 8 9 10 11
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A s i m p l e base c l a s s . A l l event h a n d l e r s t h a t s h a l l be used ∗ with SimpleInputHandling have to be d e r i v e d from i t ∗/
12 13 14 15
c l a s s EventHandler { public :
16 17 18 19 20
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ EventHandler ( ) { }
21 22 23 24 25 26 27 28
/∗ This method i s the c a l l b a c k t h a t i s c a l l e d f o r every s i n g l e ∗ event t h a t i s d i s p a t c h e d . ∗ @param event The event t h a t i s d i s p a t c h e d . I t can be an ∗ a r b i t r a r y s u b c l a s s o f Event . ∗/ virtual void handleEvent ( const Event & event ) = 0 ; };
29 30 31
#endif // s i m p l e e v e n t h a n d l e r h
10.2.22 MemoryGameControl Die Klasse MemoryGameControl ist die Drehscheibe, die alles steuert, was den Spielablauf betrifft. Sie ist einerseits daf¨ ur verantwortlich, das Spielfeld mit
10.2 Das DDD
297
entsprechenden Karten zu f¨ ullen, andererseits ist sie Handler f¨ ur die Events, die aufgrund des User Inputs generiert werden. Als Handler hat diese Klasse daf¨ ur zu sorgen, dass die Karten gem¨aß den W¨ unschen der Spieler umgedreht werden und, falls sie ein passendes Paar waren, umgedreht bleiben. Falls sie kein passendes Paar dargestellt haben, werden sie wieder verdeckt. Damit das Spiel ein Ende haben kann, ist ¨ MemoryGameControl nat¨ urlich auch daf¨ ur verantwortlich, immer den Uberblick dar¨ uber zu behalten, ob es u ¨berhaupt noch Karten gibt, die umgedreht werden k¨ onnten. Die Verteilung der Karten soll folgendermaßen vor sich gehen: 1. Es wird eine entsprechende Anzahl von Paaren generiert. Dazu wird ein entsprechender Symbolgenerator verwendet, der die Symbole gleich in einer zuf¨ alligen Reihenfolge liefert. 2. Vom Symbolgenerator werden die generierten und bereits zuf¨allig verteilten Symbole abgeholt und wiederum nach dem Zufallsprinzip je zwei Karten mit dem entsprechenden Symbol am Spielfeld platziert. Beim Behandeln des User Inputs passiert Folgendes: 1. Es werden pro Karte, die umgedreht werden soll, immer zwei Wort Events erwartet. Jeder dieser Events enth¨alt eine Koordinate. Die erste Koordinate entspricht jeweils der Reihe, die zweite der Spalte der Karte, die umgedreht werden soll. 2. Sobald beide Koordinaten f¨ ur die erste Karte eingelangt sind, wird diese umgedreht. 3. Sobald dann noch beide Koordinaten f¨ ur die zweite Karte eingelangt sind, wird auch diese umgedreht. 4. Jetzt wird entschieden, ob die beiden Karten zueinander passen. Wenn ja, bleiben sie umgedreht. Wenn nein, werden sie wieder verdeckt. ¨ 5. Eine letzte Uberpr¨ ufung muss noch stattfinden, falls ein passendes Paar erwischt wurde, n¨ amlich, ob es u ¨berhaupt noch weitere verdeckte Karten gibt. Wenn nein, wird das Spiel beendet. Bei jeder einzelnen Aktion, in der Karten ihre sichtbare Seite wechseln, muss die Spielsteuerung den Output Handler veranlassen, eine Anzeige zu triggern. Ansonsten w¨ urden die Spieler nicht wirklich viel von ihrem Erfolg zu sehen bekommen. Bei mir w¨ are das zwar egal, weil ich bestenfalls durch Zufall ein richtiges Paar erwische, aber es soll ja auch andere Spieler geben :-). Außer der Steuerung des “normalen” Spielablaufs hat die Spielsteuerung noch eine andere Aufgabe: Sie muss es erm¨oglichen, dass zu Beginn des Spiels alle Karten einmal mit der Symbolseite nach oben zu liegen kommen, damit sich die Spieler das Feld einpr¨ agen k¨onnen. Die einfachste Variante hierf¨ ur, die auch implementiert werden soll, ist folgende: 1. Durch Eingabe von s (=show ) werden alle Karten umgedreht, sodass ihre Symbole sichtbar sind.
298
10. Memory – ein kleines Beispiel
2. Durch Eingabe von h (=hide) werden sie wieder verdeckt. Um nun nicht mit zu vielen Spezialf¨allen k¨ampfen zu m¨ ussen, nehmen wir einfach einmal an, dass alle Spieler sich an die Regeln halten und zu Beginn einmal s dr¨ ucken, sich die Karten kurz einpr¨agen, dann h dr¨ ucken und danach regul¨ar spielen. Es werden daher keine Sonderabfragen eingebaut, ob ein Spiel bereits in Gange ist und deshalb s nicht mehr zul¨assig ist, etc... Eine kleine Anregung meinerseits: Interessierte Leser k¨onnten das Programm z.B. um diese Funktionalit¨ at erg¨anzen. Dem Spieltrieb sind nat¨ urlich keine Grenzen gesetzt und man k¨ onnte auch noch eine Hilfe implementieren, die durch Eingabe von h angezeigt wird. Im Rahmen des Buchs bleiben wir jedoch am Boden und bei der Grundfunktionalit¨at, die sich in der folgenden Klasse niederschl¨ agt: 1
// memory game control . h − the main c o n t r o l o f the memory game
2 3 4
#i f n d e f memory game control h #define memory game control h
5 6 7 8 9 10 11 12 13
#include #include #include #include #include #include #include #include
” s i m p l e e v e n t h a n d l e r . h” ” memory game control . h” ” s i m p l e e v e n t h a n d l e r . h” ”memory gameboard . h” ” s i m p l e o u t p u t h a n d l i n g . h” ” memory cardpair . h” ”memory game card v5 . h” ” u s e r t y p e s . h”
14 15 16 17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MemoryGameControl ∗ ∗ the main c o n t r o l o f the memory game ∗ ∗/
22 23 24 25
c l a s s MemoryGameControl : public EventHandler { protected :
26 27 28 29 30 31 32 33
/∗ The p o s s i b l e event handling s t a t e s f o r every s i n g l e card . ∗ Because the event ha n dl e r d e l i v e r s input word by word , the ∗ c o o r d i n a t e s come one a f t e r the o t h e r . T h e r e f o r e i t i s ∗ n e c e s s a r y to keep t r a c k o f what i s expected next . ∗/ s t a t i c const u i n t 8 WAITING FOR ROW AND COL = 0 x0 ; s t a t i c const u i n t 8 WAITING FOR COL = 0x1 ;
34 35 36 37
/∗ This i s the back s i d e symbol o f the c a r d s ∗/ s t a t i c const char CARD BACK SYMBOL[ ] = ” . ” ;
38 39 40 41 42
/∗ The gameboard t h a t t h i s i n s t a n c e o f the game c o n t r o l i s ∗ responsible for . ∗/ MemoryGameboard &gameboard ;
43 44 45 46
/∗ The output ha n dl e r f o r the game . I f c a r d s a r e turned the ∗ r e q u e s t to redraw the board has to go to t h i s h a n d l e r . ∗/
10.2 Das DDD
47
SimpleOutputHandling & o u t p u t h a n d l e r ;
48 49 50 51 52 53 54
/∗ Whenever a card i s s e l e c t e d f o r t u r n i n g i t i s r e g i s t e r e d ∗ i n the c u r r e n t c a r d p a i r . This v a r i a b l e can be asked i f i t ∗ a l r e a d y h o l d s both c a r d s or only one and then the game ∗ c o n t r o l can r e a c t a c c o r d i n g l y . ∗/ MemoryCardpair c u r r e n t c a r d p a i r ;
55 56 57 58 59 60 61 62 63
/∗ This i s the i n t e r n a l event handling s t a t e and the v a r i a b l e ∗ can take one o f the two v a l u e s o f the c o n s t a n t s above . I t ∗ i s always s t o r e d h e r e , what the c o n t r o l i s w a i t i n g f o r i n ∗ r e s p e c t to the c o o r d s . E i t h e r i t w a i t s f o r both c o o r d s ∗ o f a card or the row a l r e a d y a r r i v e d and i t w a i t s only f o r ∗ the column o f a s e l e c t e d card . ∗/ uint8 coord wait status ;
64 65 66 67 68
/∗ I f the row was a l r e a d y passed as an event i t i s i n the ∗ meantime s t o r e d i n t h i s v a r i a b l e u n t i l the column a r r i v e s . ∗/ u i n t 3 2 stored row num ;
69 70
public :
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
/∗ Standard Constructor ∗ The gameboard t h a t t h i s c o n t r o l i n s t a n c e i s r e s p o n s i b l e f o r ∗ and the output ha n dl e r f o r d i s p l a y i n g o f the gameboard ∗ a r e passed to i t and s t o r e d i n the a p p r o p r i a t e i n t e r n a l ∗ v a r i a b l e s . A l l o t h e r v a r i a b l e s have to be i n i t i a l i z e d to ∗ t h e i r according 0 values ∗ @param gameboard The gameboard to be c o n t r o l l e d . I t comes ∗ without any c a r d s on i t . A c a l l to the method ∗ putCardsOnGameboard then f i l l s i t with a p p r o p r i a t e ∗ cardpairs . ∗ @param o u t p u t h a n d l e r The output h a n d l e r t h a t i s r e s p o n s i b l e ∗ f o r d i s p l a y i n g the gameboard . ∗/ MemoryGameControl (MemoryGameboard &gameboard , SimpleOutputHandling & o u t p u t h a n d l e r ) : gameboard ( gameboard ) , output handler ( output handler ) , c o o r d w a i t s t a t u s (WAITING FOR ROW AND COL) , stored row num ( 0 ) { }
91 92 93 94 95 96
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ MemoryGameControl ( ) { }
97 98 99 100 101 102 103
/∗ This method ∗ u t i l i z e s an ∗ distributes ∗ board . ∗/ virtual void
i s c a l l e d to t r i g g e r f i l l i n g o f the gameboard . I t i n s t a n c e o f MemoryCardSymbolGenerator and then a p a i r o f c a r d s f o r each symbol randomly on the
putCardsOnGameboard ( ) ;
104 105 106 107 108 109 110 111 112
/∗ This i s the c a l l b a c k f o r word e v e n t s t h a t a r e a r e s u l t o f the ∗ u s e r s ’ input . Here a l l the a c t i o n s a r e t r i g g e r e d a c c o r d i n g to ∗ the game ’ s l o g i c . ∗ @param event An event coming from the input h a n d l e r . Only ∗ e v e n t s o f c l a s s WordEvent a r r i v e h e r e . ∗ @param d i s p a t c h e r The Input h an dl e r t h a t c a l l e d t h i s ∗ method to d i s p a t c h the event . ∗/
299
300
113
10. Memory – ein kleines Beispiel
virtual void handleEvent ( const Event & event ) ;
114 115
protected :
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
/∗ I n t e r n a l method t h a t i s c a l l e d from w i t h i n handleEvent i f a ∗ c o o r d i n a t e was passed . This method has to keep t r a c k o f ∗ the c o n t r o l f l o w t h a t two c o o r d i n a t e s make one card . I t does ∗ not keep t r a c k o f p a i r s , t h i s i s done by the i n t e r n a l method ∗ actOnCard below , which has to be c a l l e d f o r each p a i r o f ∗ coordinates . ∗ P l e a s e note t h a t i t i s not guaranteed t h a t the word r e a l l y ∗ c o n t a i n s a v a l i d numeric word , t h e r e f o r e t h i s has to be checked . ∗ P l e a s e a l s o note the the u s e r input c o n t a i n s c o o r d i n a t e s with ∗ a range o f 1 . . . n , whereas i n t e r n a l l y the range i s 0 . . . ( n−1). ∗ T h e r e f o r e the t r a n s f o r m a t i o n o f c o o r d i n a t e s has to take p l a c e ∗ h e r e when c a l l i n g actOnCard . ∗ @param word The s t r i n g r e p r e s e n t a t i o n o f the ( maybe ) coord ∗/ virtual void coordWasPassed ( const char ∗ word ) ;
132 133 134 135 136 137 138 139 140
/∗ I n t e r n a l method t h a t i s c a l l e d f o r each p a i r o f row/ c o l ∗ to perform an a p p r o p r i a t e a c t i o n on a card . This method has ∗ to keep t r a c k o f p a i r s o f c a r d s as w e l l and a c t a c c o r d i n g l y . ∗ Also the end o f the game has to be d e t e c t e d h e r e . ∗ @param row The row o f the card on the board s t a r t i n g at 0 ∗ @param c o l The column o f the card on the board s t a r t i n g at 0 ∗/ virtual void actOnCard ( u i n t 3 2 row , u i n t 3 2 c o l ) ;
141 142 143 144 145 146 147 148 149 150 151 152
/∗ I n t e r n a l h e l p e r method t h a t i s c a l l e d to put one card on ∗ the board randomly . This method i s c a l l e d f o r each card ∗ during the card d i s t r i b u t i o n p r o c e s s . ∗ @param card The card to be put onto the board ∗ @param num rows The number o f rows o f the board ∗ @param num cols The number o f columns o f the board ∗/ void putCardOnBoardRandomly (MemoryGameCard ∗ card , u i n t 3 2 num rows , u i n t 3 2 num cols ) ; };
153 154 155
#endif // memory game control h
10.2.23 MemoryCardSymbolGenerator Beim Symbolgenerator bleiben wir hier bei der einfachsten Variante: Wir brauchen f¨ ur unser einfaches Spiel nur einen Generator, der Symbole im Bereich A–Z, a–z und 0–9 erzeugt. Man sagt dem Generator einfach, wie viele Symbole man haben will, er generiert diese und danach liefert er auf Anfrage Symbol f¨ ur Symbol in zuf¨ alliger Reihenfolge. Es wird garantiert, dass jedes Symbol, das der Generator liefert, nur ein einziges Mal vorkommen kann. Hier k¨ onnte man sich nat¨ urlich im Sinne der Wahrscheinlichkeitstheorie ziemlich austoben, aber das werden wir in diesem Beispiel zugunsten der ¨ Ubersichtlichkeit bleiben lassen. Die folgende, einfache Variante dieser Klasse gen¨ ugt ja wirklich f¨ ur unsere Zwecke:
10.2 Das DDD
1
// memory card symbol generator . h − s i m p l e symbol g e n e r a t o r
2 3 4
#i f n d e f m e m o r y c a r d s y m b o l g e n e r a t o r h #define m e m o r y c a r d s y m b o l g e n e r a t o r h
5 6 7
#include ” s i m p l e v e c t o r . h” #include ” c o n c r e t e o b j e c t d e l e t o r s . h”
8 9 10 11 12 13 14
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ A s i m p l e symbol g e n e r a t o r t h a t g e n e r a t e s up to 6 2 d i f f e r e n t ∗ 1− c h a r a c t e r symbols [A−Za−z0 −9] i n a random manner . The ∗ symbols can be obtained one by one by subsequent c a l l s to ∗ a method . ∗/
15 16 17 18
c l a s s MemoryCardSymbolGenerator { public :
19 20 21 22 23
/∗ The maximum number o f symbols t h a t can be g e n e r a t e d by ∗ this class ∗/ s t a t i c const u i n t 3 2 MAX NUM SYMBOLS = 6 2 ; // [A−Za−z0 −9]
24 25 26 27 28 29
/∗ I n t e r n a l c o n s t a n t f o r random d i s t r i b u t i o n . The number ∗ o f swap o p e r a t i o n s i s c a l c u l a t e d by m u l t i p l y i n g the ∗ number o f symbols g e n e r a t e d by t h i s f a c t o r . ∗/ s t a t i c const u i n t 3 2 SYMBOL SWAP FACTOR = 3 ;
30 31
protected :
32 33 34 35
/∗ The number o f symbols t h a t have not been f e t c h e d yet ∗/ u i n t 3 2 num symbols remaining ;
36 37 38 39 40 41
/∗ The i n t e r n a l v e c t o r s t o r i n g the symbols t h a t have been ∗ g e n e r a t e d . The s i n g l e symbols a r e d e l e t e d i n the ∗ destructor . ∗/ Vector symbols ;
42 43 44 45 46 47 48 49
/∗ I n t e r n a l method t h a t f i l l s the v e c t o r with symbols . ∗ A f t e r f i l l i n g the v e c t o r , the symbols a r e i n a ∗ c o n s e c u t i v e o r d e r and they have to be randomly ∗ d i s t r i b u t e d by a c a l l to ∗ distributeSymbolsRandomlyInVector ( s e e below ) ∗/ void f i l l V e c t o r W i t h S y m b o l s ( ) ;
50 51 52 53 54 55 56 57
/∗ I n t e r n a l method t h a t d i s t r i b u t e s the symbols i n ∗ the v e c t o r randomly u s i n g swap o p e r a t i o n s . The ∗ number o f swap o p e r a t i o n s performed i s c a l c u l a t e d ∗ by m u l t i p l y i n g the number o f symbols with the ∗ SYMBOL SWAP FACTOR c o n s t a n t . ∗/ void distributeSymbolsRandomlyInVector ( ) ;
58 59
private :
60 61 62 63 64 65
/∗ Copy c o n s t r u c t i o n i s f o r b i d d e n ∗/ MemoryCardSymbolGenerator ( const MemoryCardSymbolGenerator & s r c ) : symbols ( 0 , DontDelete : : g e t I n s t a n c e ( ) ) { }
301
302
10. Memory – ein kleines Beispiel
66 67
public :
68 69 70 71 72 73 74 75 76 77 78 79 80 81
/∗ Standard Constructor ∗ The c o n s t r u c t o r has to c a l l the a p p r o p r i a t e methods to ∗ f i l l the v e c t o r and d i s t r i b u t e the symbols randomly i n ∗ i t . D i r e c t l y a f t e r c o n s t r u c t i o n the symbols can be ∗ obtained by subsequent c a l l s to getNextSymbol . ∗ I f the number o f symbols to be g e n e r a t e d i s g r e a t e r than ∗ MAX NUM SYMBOLS i t i s c o r r e c t e d a c c o r d i n g l y ( j u s t because ∗ e x c e p t i o n s a r e not known yet ) . ∗ @param num symbols The number o f symbols t h a t have to ∗ be g e n e r a t e d i n a range from 1 . . .MAX NUM SYMBOLS ∗ @return ∗/ e x p l i c i t MemoryCardSymbolGenerator ( u i n t 3 2 num symbols ) ;
82 83 84 85 86
/∗ D e s t r u c t o r ∗ Just to make s u r e a v i r t u a l c o n s t r u c t o r e x i s t s ∗/ virtual ˜ MemoryCardSymbolGenerator ( ) { }
87 88 89 90 91 92 93
/∗ D e l i v e r s the symbols one a f t e r the o t h e r . I f no more ∗ symbols e x i s t i n the v e c t o r i t r e t u r n s 0 ( j u s t because ∗ e x c e p t i o n s a r e not known yet ) . ∗/ virtual char getNextSymbol ( ) ; };
94 95 96
#endif // m e m o r y c a r d s y m b o l g e n e r a t o r h
10.2.24 MemoryCardpair Die Hilfsklasse MemoryCardpair dient zur Vereinfachung der Logik in der Spielsteuerung. Sie ist daf¨ ur verantwortlich, sich die Karten eines Paars zu merken und zus¨ atzlich noch zu speichern, was mit ihnen passiert ist, also ob sie umgedreht wurden oder nicht. Dadurch, dass hier die entsprechende Undo Information vorhanden ist, unterst¨ utzt diese Klasse sinnigerweise Methoden wie Originalzustand wiederherstellen und man kann sie auch fragen, ob die Symbole zueinander passen. Dies liest sich dann als Source Code so: 1
// memory cardpair . h − d e c l a r a t i o n o f a p a i r o f c a r d s f o r memory
2 3 4
#i f n d e f m e m o r y c a r d p a i r h #define m e m o r y c a r d p a i r h
5 6 7
#include ” u s e r t y p e s . h” #include ”memory gameboard . h”
8 9 10 11
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ U t i l i t y c l a s s f o r managing c a r d p a i r s i n the memory game . ∗/
12 13 14 15
c l a s s MemoryCardpair { public :
16 17
/∗ S t a t u s f l a g s to be a b l e to f i n d out whether no card , one
10.2 Das DDD
18 19 20 21
∗ card or both c a r d s have a l r e a d y been s e t . ∗/ const s t a t i c u i n t 8 CARD ONE SET = 0x01 ; const s t a t i c u i n t 8 CARD TWO SET = 0x02 ;
22 23
protected :
24 25 26 27 28 29
/∗ I n t e r n a l s t a t u s f l a g s to f i n d out whether e i t h e r o f the ∗ c a r d s has been turned . ∗/ const s t a t i c u i n t 8 CARD ONE TURNED = 0 x40 ; const s t a t i c u i n t 8 CARD TWO TURNED = 0 x80 ;
30 31 32 33 34 35
/∗ Bitmasks f o r the card−s e t and the card−turned b i t s . Both ∗ s t a t u s f l a g s a r e s e t i n the same v a r i a b l e ( s e e below ) ∗/ const s t a t i c u i n t 8 CARD SET BITS = 0x03 ; const s t a t i c u i n t 8 CARD TURN BITS = 0 xc0 ;
36 37 38 39 40
/∗ I n t e r n a l v a r i a b l e to s t o r e the s t a t u s f l a g s . Only the ∗ c o n s t a n t s above may be s e t . ∗/ uint8 c a r d s e t s t a t u s ;
41 42 43 44 45 46 47 48
/∗ S t a t u s v a r i a b l e s ∗/ uint32 card1 row uint32 c a r d 1 c o l uint32 card2 row uint32 c a r d 2 c o l public :
f o r rows and columns o f the two c a r d s ; ; ; ;
49 50 51 52 53 54 55 56
/∗ D e f a u l t c o n s t r u c t o r ∗ S e t s a l l s t a t u s v a r i a b l e s to t h e i r 0 s t a t e ∗/ MemoryCardpair ( ) : card set status (0), card1 row ( 0 ) , c a r d 1 c o l ( 0 ) , card2 row ( 0 ) , c a r d 2 c o l (0) {}
57 58 59 60 61
/∗ D e s t r u c t o r ∗ Just to make s u r e t h a t a v i r t u a l d e s t r u c t o r e x i s t s ∗/ virtual ˜ MemoryCardpair ( ) { }
62 63 64 65 66 67 68 69 70 71 72 73
/∗ C a l l e d a f t e r the f i r s t card was chosen . This method s t o r e s ∗ the c o o r d i n a t e s o f the card and t u r n s i t f r o n t s i d e up , i f ∗ i t i s not a l r e a d y v i s i b l e . I t f u r t h e r s t o r e s , whether the ∗ card was i n i t i a l l y f r o n t or back s i d e up to be a b l e to ∗ to perform an undo i f the p a i r does not match . ∗ @param row The row o f the card to turn . ∗ @param c o l The column o f the card to turn . ∗ @param gameboard The gameboard to work on . ∗/ virtual void turnCardOneFrontSideUp ( u i n t 3 2 row , u i n t 3 2 c o l , MemoryGameboard &gameboard ) ;
74 75 76 77 78 79
/∗ Analogous to the method above , j u s t f o r the second card ∗ o f the p a i r . ∗/ virtual void turnCardTwoFrontSideUp ( u i n t 3 2 row , u i n t 3 2 c o l , MemoryGameboard &gameboard ) ;
80 81 82 83
/∗ Returns the s t a t u s f l a g s t h a t co rr espo n d to the b i t s t h a t ∗ determine which c a r d s were s e t . ∗ @return A b i t−combination o f the two bitmask c o n s t a n t s t h a t
303
304
84 85 86 87 88 89
10. Memory – ein kleines Beispiel
∗ a r e used to s t o r e which c a r d s a r e s e t ∗/ virtual u i n t 8 whichCardsAreSet ( ) { return ( c a r d s e t s t a t u s & CARD SET BITS ) ; }
90 91 92 93 94 95 96 97 98 99 100
/∗ Returns , whether both c a r d s were i n i t i a l l y l y i n g on the f i e l d ∗ with t h e i r back s i d e s up ∗ @return t r u e i f both c a r d s were i n i t i a l l y hidden , f a l s e ∗ otherwise ∗/ virtual bool wereBothCardsBackSideUp ( ) { return ( ( c a r d s e t s t a t u s & CARD TURN BITS) == CARD TURN BITS ) ; }
101 102 103 104 105 106 107 108 109
/∗ Returns , whether the symbols o f the two c a r d s a r e matching . ∗ I f not both c a r d s were a l r e a d y s e l e c t e d , the method r e t u r n s ∗ f a l s e ( j u s t because e x c e p t i o n s a r e not known yet ) . ∗ @param gameboard The gameboard to work on ∗ @return t r u e i f the symbols a r e matching , f a l s e o t h e r w i s e ∗/ virtual bool cardSymbolsMatching ( MemoryGameboard &gameboard ) ;
110 111 112 113 114 115 116
/∗ Undo . . . r e s t o r e s the o r i g i n a l v i s i b l e s t a t e o f each o f the ∗ two c a r d s ∗ @param gameboard The gameboard to work on . ∗/ virtual void turnCardsBackToTheirOriginalState ( MemoryGameboard &gameboard ) ;
117 118 119 120 121
/∗ C l e a r s a l l the s t a t u s f i e l d s o f t h i s i n s t a n c e ∗/ virtual void c l e a r ( ) ; };
122 123 124
#endif // m e m o r y c a r d p a i r h
10.3 Auszu ¨ ge aus der Implementation Um das Verst¨ andnis f¨ ur die Zusammenh¨ange und die Abl¨aufe im Spiel etwas zu vertiefen, m¨ ochte ich an dieser Stelle noch eine paar Ausz¨ uge aus den Implementationen der Klassen ganz kurz besprechen. Der vollst¨andige Code ist in Anhang B zu finden. Am schnellsten kommt man durch einen kurzen Blick auf das Hauptprogramm zum tieferen Verst¨ andnis: 1
//memory main . cpp − main program t h a t s t a r t s memory
2 3
#include < i o s t r e a m>
4 5 6 7
#include ” simple commandline handling . h” #include ” memory commandline arg handler . h” #include ”memory gameboard . h”
10.3 Ausz¨ uge aus der Implementation
8 9 10 11
#include #include #i n c l u d e #include
” s i m p l e o u t p u t h a n d l i n g . h” ” s i m p l e d i s p l a y a b l e . h” ” memory game control . h” ” s i m p l e i n p u t h a n d l i n g . h”
12 13 14
using s t d : : c e r r ; using s t d : : e nd l ;
15 16 17 18 19 20 21 22 23 24 25 26 27
int main ( int a r g c , char ∗ argv [ ] ) { // i n s t a n t i a t e the commandline h a n d l e r , d e c l a r e the // r e q u i r e d arguments ( row / column ) and l e t i t p a r s e // the commandline . CommandlineHandling commandline handler ( 3 ) ; MemoryCommandlineArgumentHandler a r g h a n d l e r ; commandline handler . declareArgument ( 1 , CommandlineHandling : : UINT32 ARG,& a r g h a n d l e r ) ; commandline handler . declareArgument ( 2 , CommandlineHandling : : UINT32 ARG,& a r g h a n d l e r ) ; commandline handler . handleCommandline ( a r g c , argv ) ;
28 29 30 31 32 33 34 35 36 37 38
// a n a l y s e the number o f rows and columns u i n t 3 2 rows = a r g h a n d l e r . getRows ( ) ; uint32 c o l s = arg handler . getCols ( ) ; i f ( ( rows < = 0 ) | | ( c o l s < = 0 ) | | ( rows > 9 ) | | ( c o l s > 9 ) ) { c e r r << ” usage : ” << argv [0] << ” ” << e nd l << ” both must be between 1−9” << e n d l ; return (−1); }
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
i f ( ( rows ∗ c o l s ) % 2 ) // must be an even number ! { i f ( rows < 9) { rows++; c e r r << ” with the g i v e n rows and columns t h e r e would be” e nd l << ”an odd number o f c a r d s on the board . Corrected ” e nd l << ” rows a c c o r d i n g l y . ” << e n d l ; } else { i f ( c o l s < 9) c o l s ++; else c o l s −−; c e r r << ” with the g i v e n rows and columns t h e r e would be” e nd l << ”an odd number o f c a r d s on the board . Corrected ” e nd l << ” c o l s a c c o r d i n g l y . ” << e nd l ; } }
<< <<
<< <<
66 67 68 69 70 71 72 73
// I n s t a n t i a t e a l l modules TextOutputContext t e x t o u t p u t c o n t e x t ; SimpleOutputHandling o u t p u t h a n d l e r ( 1 , t e x t o u t p u t c o n t e x t ) ; MemoryGameboard gameboard ( rows , c o l s ) ; MemoryGameControl g am e c o nt r o l ( gameboard , o u t p u t h a n d l e r ) ; g a m e c ont r o l . putCardsOnGameboard ( ) ; o u t p u t h a n d l e r . addDisplayable ( gameboard ) ;
305
306
10. Memory – ein kleines Beispiel
o u t p u t h a n d l e r . writeOutput ( ) ; SimpleInputHandling : : setEventHandler(&g a m e co nt ro l ) ; SimpleInputHandling : : runDispatcher ( ) ; return ( 0 ) ;
74 75 76 77 78
}
Gem¨aß unserer Designentscheidungen l¨auft hier Folgendes ab: 1. Zuerst wird die Commandline ausgewertet. Dazu wird in Zeile 21 eine Instanz des entsprechenden Handlers erzeugt. Dass diese f¨ ur drei Argumente initialisiert wird, versteht sich auch von selbst, denn wir erwarten ja den Programmnamen, die Anzahl der Reihen und die Anzahl der Spalten als Parameter. Die Anzahl der Reihen und Spalten werden auch als Argumente in den Zeilen 23–26 deklariert und f¨ ur beide ist unser besonderer Argument Handler aus Zeile 22 verantwortlich. Die tats¨achliche Auswertung findet dann durch den Aufruf in Zeile 27 statt. Danach hat der Handler die erhaltenen Werte gespeichert und sie stehen zur Abfrage in den Zeilen 30–31 bereit. In den Zeilen 32–65 findet nur eine Plausibilit¨ atskontrolle und eventuelle Korrektur der Anzahl der Reihen und Spalten statt. 2. Richtig interessant wird es wieder in Zeile 68: Dort wird der Output Kontext erzeugt, den das Programm zum Schreiben verwendet. 3. In Zeile 69 wird der Output Handler mit dem entsprechenden Kontext generiert. Dieser wird nur f¨ ur ein Displayable initialisiert, denn mehr als das Spielbrett selbst bekommt dieser Handler nicht zu sehen. 4. Das Spielbrett wird in Zeile 70 angelegt. 5. In Zeile 71 wird die Spielsteuerung erzeugt und mit dem Spielbrett und dem Output Handler initialisiert. Damit w¨are eigentlich bereits das ganze Spiel von der Systematik her am Laufen. 6. Da es sich ohne Karten schlecht spielt, werden diese durch den Aufruf in Zeile 72 am Brett verteilt. 7. In Zeile 73 wird das Spielbrett endg¨ ultig beim Output Handler angemeldet. Dies darf aus internen Gr¨ unden nicht vor dem Verteilen der Karten passieren, da sonst dessen Containerfunktionalit¨at gest¨ort w¨are. Ja, ja, ich weiß, das sollte man besser machen, genau deshalb m¨ochte ich es gerne interessierten Lesern als Aufgabe zum Spielen u ¨berlassen :-). 8. Der Aufruf in Zeile 74 sorgt daf¨ ur, dass das Brett in seinem Initialzustand einmal am Bildschirm angezeigt wird. 9. In den Zeilen 75–76 wird nur noch der Event Handler initialisiert und der Dispatcher gestartet. Dieser Dispatcher l¨auft so lange, bis das Spiel vorbei ist, dann wird er aus der Spielsteuerung heraus gestoppt. Damit wird dann auch gleich automatisch das Programm beendet, denn in Zeile 77 steht ja das entsprechende return, das die main Funktion verl¨asst. Leser, die dieses Spiel nun selbst ausprobieren wollen, k¨onnen das ganz einfach tun:
10.3 Ausz¨ uge aus der Implementation
307
1. make -f MemoryMakefile aufrufen (oder in ihrer eigenen Umgebung entsprechend das Programm compilieren). 2. memory 6 6 oder ¨ ahnlich starten. Die Anzahl der Reihen und der Spalten kann jeweils zwischen 1–9 sein. 3. Wenn das Feld mit den verdeckten Karten (dargestellt als .) erscheint, s eingeben, gefolgt von Return. Dadurch werden alle Karten angezeigt. 4. Nach dem Einpr¨ agen der Karten h eingeben, gefolgt von Return. Dadurch werden die Karten wieder umgedreht. 5. Koordinatenpaare durch Leerzeichen bzw. Return getrennt eintippen, die jeweils eine Karte aufdecken sollen. Z.B. deckt die Eingabe von 2 3 Return die Karte in Reihe 2 und Spalte 3 auf. 6. Wenn man das Spiel gewinnt, so beendet es sich automatisch. Sollte man mitten unter dem Spiel die Lust verlieren, so hilft die Eingabe von q Return. Ganz ungeduldige Spieler k¨onnen auch mittels Control-c dem Gemetzel ein Ende bereiten :-). Von der Logik her interessant und deshalb einen Blick wert ist auch die Implementation der Klasse MemoryGameboard: Wie bereits bei den Deklarationen besprochen wurde, werden alle darstellbaren Objekte in Vektoren gespeichert, damit sie im Destruktor auch sicher wieder freigegeben werden. Die Vektoren bekommen dazu den entsprechenden DisplayableDeletor spendiert. Wirft man jedoch einen Blick auf den Konstruktor der Klasse, der in der Folge abgedruckt ist, so ist leicht zu erkennen, dass hierbei noch u ¨berhaupt keine Displayables angelegt werden: 10 11 12 13 14 15 16 17 18 19 20 21 22 23
MemoryGameboard : : MemoryGameboard( u i n t 3 2 num rows , u i n t 3 2 num cols ) : num rows ( num rows ) , num cols ( num cols ) , num cards front side up ( 0 ) , row vector ( 0 ) , row heading displayables (0), col heading displayables (0), eol displayables (0), container output handling ( 0 ) , container output context (0) { i f ( ( num rows ∗ num cols ) % 2 ) // not an even number num rows ++;
24
row vector
= new Vector ( num rows , VectorDeletor : : getInstance ( ) ) ; for ( u i n t 3 2 c u r r e n t r o w = 0 ; c u r r e n t r o w < num rows ; c u r r e n t r o w ++) r o w v e c t o r −>setElementAt ( c u r r e n t r o w ,new Vector ( num cols , DisplayableDeletor : : getInstance ( ) ) ) ;
25 26 27 28 29 30 31 32
}
Des R¨atsels L¨ osung, wann die darstellbaren Objekte erzeugt werden, ergibt sich aus einem Blick auf die Methode displayableRegistered:
308
59 60 61 62 63 64 65
10. Memory – ein kleines Beispiel
void MemoryGameboard : : d i s p l a y a b l e R e g i s t e r e d ( SimpleOutputHandling & h a nd l er ) { // a l r e a d y r e g i s t e r e d with some ha n d l e r , no f u r t h e r // setup n e c e s s a r y i f ( row heading displayables ) return ;
66 67 68 69 70 71 72 73 74 75 76 77 78 79
u i n t 3 2 n u m d i s p l a y a b l e s = ( num rows ∗ num cols ) + // c a r d s num rows + 1 + // EOL elements num rows + 1 + // c o l headings num cols ; // row headings r o w h e a d i n g d i s p l a y a b l e s = new Vector ( num cols , D i s p l a y a b l e D e l e t o r : : g e t I n s t a n c e ( ) ) ; c o l h e a d i n g d i s p l a y a b l e s = new Vector ( num rows + 1 , D i s p l a y a b l e D e l e t o r : : g e t I n s t a n c e ( ) ) ; e o l d i s p l a y a b l e s = new Vector ( num rows + 1 , D i s p l a y a b l e D e l e t o r : : g e t I n s t a n c e ( ) ) ; c o n t a i n e r o u t p u t c o n t e x t = ha n d l er . getOutputContextClone ( ) ; c o n t a i n e r o u t p u t h a n d l i n g = new SimpleOutputHandling ( num displayables ,∗ container output context ) ;
80 81 82 83 84
// // uint32 uint32
j u s t because some c o m p i l e r s have a wrong treatment f o r v a r i a b l e s i n c o n t r o l st at em ents . . . row count = 0 ; col count = 0;
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
// g e n e r a t e the top l i n e with the column headings and // r e g i s t e r i t I n t D i s p l a y a b l e ∗ heading = 0 ; TextDisplayable ∗ t e x t = 0 ; // a space needs to be the f i r s t element o f the headings t e x t = new TextDisplayable ( ” ” ) ; c o l h e a d i n g d i s p l a y a b l e s −>setElementAt ( 0 , t e x t ) ; c o n t a i n e r o u t p u t h a n d l i n g −>addDisplayable (∗ t e x t ) ; // the r e s t o f the heading l i n e c o n t a i n s the c o n s e c u t i v e // numbers o f the columns for ( c o l c o u n t = 1 ; c o l c o u n t <= num cols ; c o l c o u n t++) { heading = new I n t D i s p l a y a b l e ( c o l c o u n t ) ; c o l h e a d i n g d i s p l a y a b l e s −>setElementAt ( c o l c o u n t , heading ) ; c o n t a i n e r o u t p u t h a n d l i n g −>addDisplayable (∗ heading ) ; } // an e o l i s p l a c e d at the end o f the l i n e t e x t = new TextDisplayable ( ”\n” ) ; e o l d i s p l a y a b l e s −>setElementAt ( 0 , t e x t ) ; c o n t a i n e r o u t p u t h a n d l i n g −>addDisplayable (∗ t e x t ) ; // now f o r each row : // ( 1 ) g e n e r a t e the row heading and r e g i s t e r i t // ( 2 ) r e g i s t e r a l l c a r d s i n the row . // ( 3 ) g e n e r a t e an EOL d i s p l a y a b l e and r e g i s t e r i t for ( row count = 0 ; row count < num rows ; row count++) { heading = new I n t D i s p l a y a b l e ( row count + 1 ) ; r o w h e a d i n g d i s p l a y a b l e s −>setElementAt ( row count , heading ) ; c o n t a i n e r o u t p u t h a n d l i n g −>addDisplayable (∗ heading ) ; for ( c o l c o u n t = 0 ; c o l c o u n t < num cols ; c o l c o u n t++) { // not very e f f i c i e n t but e a s i e r to read c o n t a i n e r o u t p u t h a n d l i n g −>addDisplayable ( ∗dynamic cast( internalGetCard ( row count , c o l c o u n t ) ) ) ; } t e x t = new TextDisplayable ( ”\n” ) ; e o l d i s p l a y a b l e s −>setElementAt ( row count + 1 , t e x t ) ;
10.3 Ausz¨ uge aus der Implementation
c o n t a i n e r o u t p u t h a n d l i n g −>addDisplayable (∗ t e x t ) ;
124
}
125 126
309
}
Hier wird in Zeile 64 zuerst kontrolliert, ob u ¨berhaupt noch etwas getan werden muss, oder ob bereits alles fertig angelegt ist. Das ist notwendig, denn es k¨ onnte ja diese Klasse auch bei zwei Handlers angemeldet werden. Dann w¨ urde ohne diese Abfrage ein Speicherloch entstehen. In den Zeilen 67–70 wird ausgerechnet, mit wie vielen darstellbaren Objekten wir es hier u ¨berhaupt zu tun haben. Danach werden die entsprechenden Vektoren in den Zeilen 71–76 angelegt. In Zeile 77 wird ein Clone des Output Contexts besorgt, der dem neu angelegten Output Handler f¨ ur diesen Container in den Zeilen 78–79 spendiert wird. In den Zeilen 88–125 werden dann die entsprechenden Reihen- und Spaltenk¨opfe, sowie die Zeilenumbr¨ uche angelegt und gemeinsam mit den Spielkarten beim Output Handler genau in der Reihenfolge registriert, in der sie dargestellt werden sollen. In der Deklaration dieser Klasse war schon zu sehen, dass dieser Handler jedes Mal in der inline Methode writeDisplayRep aufgefordert wird, seinen Output zu schreiben. Interessierte Leser k¨ onnen jetzt gleich zur Programm¨anderung schreiten und einen entsprechenden DisplayableContainer implementieren, der als Basis f¨ ur alle Displayables mit Containerfunktionalit¨at dient. Von diesem w¨are dann das MemoryGameboard abzuleiten, um zu einer sauberen L¨osung zu kommen. Noch ein kleiner Tipp: Die Idee, selbst den Output Handler zu vergewaltigen und ihn mit einem Clone des Output Contexts ans Werk zu schicken ist auch nicht ganz sauber und entsprechend verbesserungsw¨ urdig. Mit diesem hier kurz zusammengefassten Wissen u ber die Systematik ¨ im Programm sollte der restliche Source Code leicht verst¨andlich sein. Die gesamte Implementation, wie sie in Anhang B abgedruckt ist, ist straightforward. Aus diesem Grund m¨ ochte ich hier auch keinen weiteren Platz daf¨ ur verschwenden.
11. Exceptions
In den bisherigen Beispielen war es ¨ ofters der Fall, dass z.B. eine Methode mit unzul¨assigen Parametern aufgerufen wird. Ein typisches Beispiel findet sich in der Implementation der allgemeinen Vector Klasse aus unserem MemorySpiel. Dort gibt es z.B. eine Methode getElementAt, die das Element liefert, das an einer bestimmten Stelle im entsprechenden Vector Objekt steht. Wenn allerdings der geforderte Index außerhalb des erlaubten Bereichs liegt, ¨ dann wird in dieser Implementation kurzerhand 0 geliefert. Ahnliches passiert dort in der Methode setElementAt: Liegt der Index außerhalb des erlaubten Bereichs, dann wird diese Operation einfach kommentarlos nicht durchgef¨ uhrt. Dass dieses Verhalten nicht wirklich zur Stabilit¨at von Software beitr¨ agt, versteht sich von selbst, denn wie soll man denn einen Fehler finden, wenn dieser nicht einmal gemeldet wird? Und genau damit, wie man einen solchen Fehler ordnungsgem¨ aß und sauber mitteilt, um ihn auch einer sauberen Behandlung zuzuf¨ uhren, besch¨aftigt sich dieses Kapitel: Das Mittel der Wahl f¨ ur diese F¨ alle sind die sogenannten Exceptions, durch die, wie der Name schon sagt, Ausnahmezust¨ ande in einem Programm signalisiert und damit auch sauber behandelt werden k¨onnen. Vor der Besprechung der technischen Details von Exceptions in C++ m¨ochte ich noch unbedingt ein paar Worte zu deren sinnvoller und sauberer Anwendung verlieren. Oft wird n¨ amlich von Entwicklern die von den Erfindern beabsichtigte Semantik von Exceptions missverstanden, wodurch sich einige Ungereimtheiten ergeben k¨ onnen. Prinzipiell ist die Verwendung von Exceptions f¨ ur den Fall gedacht, dass Entwickler zu einem bestimmten Zeitpunkt zur Laufzeit feststellen k¨onnen, dass ein Problem vorliegt, jedoch nicht wissen k¨onnen, wie sie dieses Problem behandeln sollen. Im oben zitierten Beispiel mit der Vector Klasse kann man leicht einen unerlaubten Index feststellen. Weil aber nicht bekannt ist, wof¨ ur ein Vector Objekt zur Laufzeit gerade verwendet wird und wodurch also der unerlaubte Index zustande kommt, kann man keine Behandlung durchf¨ uhren. Jedoch kann man davon ausgehen, dass der Teil des Programms, der den falschen Aufruf abgesetzt hat, auf das Problem reagieren kann. Folgende Logik versteckt sich nun hinter dem Exception Mechanismus, um die Problemerkennung von der Problembehandlung zu trennen:
312
11. Exceptions
1. Der Teil der Software, der ein Problem erkennt, wirft eine Exception (daher kommt auch das Keyword throw, das wir noch kennen lernen werden). 2. Der Teil der Software, der sich bereit erkl¨art, bestimmte Probleme behandeln zu k¨ onnen, signalisiert diese Bereitschaft durch eine Deklaration, eine Exception fangen zu k¨ onnen (daher kommt auch das Keyword catch, das wir noch kennen lernen werden). 3. Wenn also eine Exception geworfen wird, dann wird in der Methodenund Funktionen-Aufrufhierarchie des Programms so lange zur¨ uck zum Aufrufenden gesprungen (dies nennt sich auch Stack Unwinding), bis ein fangender Teil gefunden wird. Diesem wird die Exception dann zur Behandlung zugef¨ uhrt. Um nun eine gezielte Fehlerbehandlung zu gestatten, gibt es beliebige, durch die Entwickler definierbare Typen von Exceptions. Es ist auch leicht einzusehen, warum man solche Typen unterscheidet, denn wenn ein Programmteil z.B. weiß, wie ein Zugriffs-Indexfehler zu behandeln ist, dann muss derselbe Teil noch lange nicht wissen, wie man mit einem Out-of-Memory Fehler umgeht. Mit einem verbreiteten Missverst¨andnis m¨ochte ich an dieser Stelle auch noch aufr¨ aumen: Eine Exception zu werfen bedeutet nicht, dass unbedingt etwas v¨ ollig Katastrophales passiert w¨are. Es ist auch keineswegs gesagt, dass Exceptions “so gut wie nie” auftreten bzw. auftreten d¨ urfen. Exceptions werden immer dann gebraucht, wenn etwas passiert, was dem regul¨ar geplanten Programmablauf zuwiderl¨auft. Es gibt noch einige Aspekte, die unbedingt Beachtung finden m¨ ussen, um Exceptions wirklich sinnvoll einzusetzen. Um diese besprechen zu k¨onnen, m¨ochte ich einmal an einem kleinen Beispiel die Realisierung des ExceptionMechanismus in C++ zeigen. Hierzu ziehen wir am besten gleich unsere Klasse Vector heran und arbeiten sie entsprechend um. Das Resultat liest sich dann wie folgt (first_exception_demo.cpp): 1 2
// f i r s t e x c e p t i o n d e m o . cpp − a s m a l l demo , how the e x c e p t i o n // mechanism works i n C++
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Exception ∗ ∗ base c l a s s f o r a l l e x c e p t i o n s ∗ ∗/
17 18 19 20 21
c l a s s Exception { protected : static uint32 c u r r e n t i d ;
11. Exceptions
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
u i n t 3 2 my id ; public : Exception ( ) { my id = c u r r e n t i d ++; cout << ” d e f a u l t − c o n s t r u c t i n g Exception , i d = ” << my id << e nd l ; } Exception ( const Exception& exc ) { my id = c u r r e n t i d ++; cout << ”copy − c o n s t r u c t i n g Exception , i d = ” << my id << e nd l ; } virtual ˜ Exception ( ) { cout << ” d e s t r u c t i n g Exception , i d = ” << my id << e nd l ; } };
42 43 44 45 46
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ IndexOutOfBoundsException ∗/
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
c l a s s IndexOutOfBoundsException : public Exception { public : IndexOutOfBoundsException ( ) { cout << ” d e f a u l t − c o n s t r u c t i n g IndexOutOfBoundsException , i d = ” << my id << e nd l ; } IndexOutOfBoundsException ( const IndexOutOfBoundsException &exc ) : Exception ( exc ) { cout << ”copy − c o n s t r u c t i n g IndexOutOfBoundsException , i d = ” << my id << e nd l ; } virtual ˜ IndexOutOfBoundsException ( ) { cout << ” d e s t r u c t i n g IndexOutOfBoundsException , i d = ” << my id << e nd l ; } };
70 71 72 73 74 75 76 77 78
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Vector ∗ ∗ j u s t a simple vector c l a s s ∗ ∗/
79 80 81 82 83 84 85 86 87
c l a s s Vector { protected : void ∗∗ e l e m e n t s ; u i n t 3 2 num elements ; public : Vector ( u i n t 3 2 num elements ) ; virtual ˜ Vector ( ) { }
313
314
11. Exceptions
88
virtual void setElementAt ( u i n t 3 2 index , void ∗ element ) throw( IndexOutOfBoundsException ) ;
89 90 91
virtual void ∗ getElementAt ( u i n t 3 2 index ) const throw( IndexOutOfBoundsException ) ;
92 93 94
virtual u i n t 3 2 getMaxNumElements ( ) const { return ( num elements ) ; }
95 96 97 98 99
};
100 101
u i n t 3 2 Exception : : c u r r e n t i d
= 0;
102 103 104 105 106 107
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ c o n s t r u c t o r ∗ ∗ @param num elements the number o f elements t h a t the v e c t o r can hold ∗/
108 109 110 111 112 113 114 115 116 117 118 119 120
Vector : : Vector ( u i n t 3 2 num elements ) { num elements = num elements ; i f ( num elements ) { e l e m e n t s = new void ∗ [ num elements ] ; while ( num elements−−) e l e m e n t s [ num elements ] = 0 ; } else elements = 0; }
121 122 123 124 125 126 127 128 129
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ s e t s the element at the d e s i r e d index ∗ ∗ @param index the index o f the element to s e t ∗ @param element the element to s e t ∗ @exception IndexOutOfBoundsException i f the index i s o u t s i d e ∗ o f the a l l o w ed range ∗/
130 131 132 133 134 135 136 137
void Vector : : setElementAt ( u i n t 3 2 index , void ∗ element ) throw( IndexOutOfBoundsException ) { i f ( index >= num elements ) throw IndexOutOfBoundsException ( ) ; e l e m e n t s [ index ] = element ; }
138 139 140 141 142 143 144 145
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ @param index the index o f the element to be r e t u r n e d ∗ @return the d e s i r e d element ∗ @exception IndexOutOfBoundsException i f the index i s o u t s i d e ∗ o f the a l l o w ed range ∗/
146 147 148 149 150 151 152 153
void ∗ Vector : : getElementAt ( u i n t 3 2 index ) const throw( IndexOutOfBoundsException ) { i f ( index >= num elements ) throw IndexOutOfBoundsException ( ) ; return ( e l e m e n t s [ index ] ) ; }
11. Exceptions
315
154 155 156 157 158 159
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { Vector t e s t v e c t o r ( 3 ) ; int32 just an element = 0;
160
try { cout << ” s e t t i n g element at index 0 ” << e nd l ; t e s t v e c t o r . setElementAt (0,& j u s t a n e l e m e n t ) ; cout << ” t r y i n g to s e t element at index 5 ” << e nd l ; // the f o l l o w i n g c a l l ends i n an e x c e p t i o n t e s t v e c t o r . setElementAt (5,& j u s t a n e l e m e n t ) ; cout << ” t h i s output must never be v i s i b l e ! ! ! ” << e n d l ; } catch ( IndexOutOfBoundsException &exc ) { cout << ” oops − caught an IndexOutOfBoundsException ” << e nd l ; } cout << ”program f i n i s h e d ” << e nd l ; return ( 0 ) ;
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
}
Startet man dieses Programm, so wird man mit folgendem Output begl¨ uckt: s e t t i n g element at index 0 t r y i n g to s e t element at index 5 d e f a u l t − c o n s t r u c t i n g Exception , i d = 0 d e f a u l t − c o n s t r u c t i n g IndexOutOfBoundsException , i d = 0 copy − c o n s t r u c t i n g Exception , i d = 1 copy − c o n s t r u c t i n g IndexOutOfBoundsException , i d = 1 d e s t r u c t i n g IndexOutOfBoundsException , i d = 0 d e s t r u c t i n g Exception , i d = 0 oops − caught an IndexOutOfBoundsException d e s t r u c t i n g IndexOutOfBoundsException , i d = 1 d e s t r u c t i n g Exception , i d = 1 program f i n i s h e d
Und genau an diesem Output sieht man auch, warum ich im Demoprogramm den Output in die Konstruktoren und Destruktoren eingebaut habe: Es passiert ein wenig mehr, als dass nur eine einzige Exception erzeugt, geworfen und irgendwo gefangen wird! Dieses “etwas mehr” tr¨agt auch die Schuld daran, dass wir bei Exceptions einmal mehr mit dem impliziten Aufruf des Copy-Constructors sowie mit tempor¨ aren Objekten konfrontiert werden. Gehen wir das alles einmal St¨ uck f¨ ur St¨ uck durch, um zu verstehen, was hier genau passiert: • Exceptions, die geworfen werden, sind beliebige Objekte. Die Klassen dazu k¨onnen in einer beliebigen Hierarchie stehen. In unserem Fall haben wir es mit einer Basisklasse Exception und einer davon abgeleiteten Klasse IndexOutOfBoundsException zu tun. Es ist zwar nicht notwendig, aber um die Lesbarkeit zu erh¨ ohen, enden alle Klassen, die als Exceptions verwendet werden, mit dem Suffix Exception (genau richtig erkannt: Hier wurde bei einer Konvention Anleihe gezeichnet, die in Java derzeit Verwendung findet :-)). • In der Klasse Vector sieht man in den Zeilen 89–90 und 92–93, wie man deklariert, dass eine Methode eine bestimmte Exception werfen kann. Man
316
11. Exceptions
deklariert sie mittels Angabe von throw(...). M¨ochte man mehrere Exceptions deklarieren, dann werden diese innerhalb der Klammern durch Komma getrennt angegeben. Weil throw, gleich wie z.B. const, zum Teil der Signatur einer Methode wird, muss dieselbe Angabe auch in der Definition der Methode mit angef¨ uhrt werden. Dies sieht man z.B. in den Zeilen 131–132. Dass diese Art der Deklaration nat¨ urlich nicht nur f¨ ur Methoden, sondern auch f¨ ur Funktionen gilt, versteht sich von selbst. Schreibt man zur Deklaration einer Methode keine throw Angabe, so bedeutet dies (leider) nicht, dass keine Exception geworfen werden kann, sondern genau das Gegenteil: Es k¨onnen in diesem Fall beliebigste Exceptions geworfen werden! Diese Konvention wurde in C++ aus Gr¨ unden der Kompatibilit¨ at zu altem Source-Code gew¨ahlt. M¨ochte man explizit deklarieren, dass keine Exception geworfen werden kann, so muss man dies durch ein explizites throw() ohne Angabe von Exceptions tun. Es gibt immer wieder Diskussionen, was die explizite throw Angabe betrifft. Ein Teil der Entwickler meint, man solle per Coding Standard auf eine explizite Angabe bestehen, ein anderer Teil meint, man solle auf diese verzichten. Die Hauptargumente der beiden Fraktionen sind folgende: Gegen explizite Angabe: – Bei Programm¨ anderungen kann es in Ableitungshierarchien passieren, dass man neu hinzugekommene Exceptions in der gesamten Hierarchie erg¨ anzen muss. – Je nach Compiler kann die explizite Angabe von Exceptions das Programm verlangsamen, da zur Zusicherung der Einhaltung der Angabe hinter den Kulissen versteckte try ... catch Bl¨ocke erzeugt werden. – Wenn wirklich eine Exception geworfen wird, die nicht angegeben wurde, ist die Standardbehandlung, die vom Compiler eingesetzt wird ein abort(). Dies kann zu unerw¨ unschten Effekten f¨ uhren, da autoVariablen in diesem Fall nicht mehr ordnungsgem¨aß destruiert werden. – Beim Erstellen von Templates (siehe Kapitel 13) kann es Probleme geben, weil man gewisse datentypabh¨angige Verhaltensweise beim besten Willen nicht voraussehen kann. F¨ ur explizite Angabe: – Der Code wird robuster, da keine unerwarteten Exceptions geworfen werden und man immer weiß, was man eventuell zu behandeln hat. Da ich auf dem Standpunkt stehe, dass saubere, robuste Software das oberste Ziel in einer Entwicklung sein muss, bin ich pers¨onlich ein Mitglied der Fraktion, die f¨ ur die explizite Angabe pl¨adiert. Zu den beiden Hauptargumenten der anderen Fraktion m¨ochte ich Folgendes anmerken: Wenn man in der Designphase vorausschauend genug arbeitet, dann pas¨ sieren Anderungen, die sich durch die gesamte Hierarchie ziehen sehr selten bis gar nicht. Der Performanceverlust durch implizite try ... catch Bl¨ocke ist so minimal, dass er in den allermeisten F¨allen ohne besondere Messung u ¨berhaupt nicht bemerkbar ist. Einzig in absoluten Hoch-
11. Exceptions
317
geschwindigkeitsroutinen k¨ onnte (!!!) es sein, dass das allerletzte kleine Bisschen an Performance noch um jeden Preis herausgequetscht werden muss. Dort k¨ onnte (!!!) man in einem solchen Fall u ¨ber einen begrenzten Bruch mit den expliziten Angaben nachdenken. Meine Erfahrung spricht allerdings dagegen. Das Problem beim Erstellen von Templates muss man zum Teil akzeptieren und hier ist im Spezialfall wirklich eine Ausnahme zu machen. Das Argument jedoch, dass abort() zu Problemen f¨ uhrt, ist zwar korrekt, aber ich kann diese Argumentation nicht akzeptieren. Dieser Fall darf bei sauberer Entwicklung nur in der Testphase auftreten, nicht in einem Produkt! Alle Leser m¨ ogen nun selbst entscheiden, welchen Standpunkt sie einsichtiger finden. Im Sinne eines guten Programmierstils und im Sinne der Lesbarkeit, Wartbarkeit und Robustheit von Software m¨ochte ich jedoch raten, Exceptions auch wirklich explizit mittels throw anzuf¨ uhren. Per Konvention sollte man dann auch konsequenterweise keine Exceptions aus Methoden oder Funktionen werfen, bei denen keine throw Angabe existiert. Nebenbei bemerkt: Eine allgemein u ur das unsch¨one ¨bliche Bezeichnung f¨ Werfen von Exceptions, obwohl keine solchen deklariert wurden, ist der Begriff silent throw . • In Zeile 135 sieht man, wie eine Exception geworfen wird: Es wird hierzu ein tempor¨ ares Objekt erzeugt. Warum wir hier ausgerechnet mit einem tempor¨ aren Objekt arbeiten und was bei Abarbeitung des throw genau hinter den Kulissen passiert, wird in der Folge noch besprochen werden. • In den Zeilen 161–173 sieht man schlussendlich, wie man sich bereit erkl¨art, Exceptions auch zu fangen, die auftreten k¨onnten: Der Teil des Codes, f¨ ur den man sich in puncto Exceptions zust¨andig f¨ uhlt, wird durch einen try Block umschlossen. Direkt im Anschluss an diesen stehen dann ein oder mehrere catch Bl¨ ocke in denen die entsprechenden Exceptions auch behandelt werden k¨ onnen. In unserem Beispiel ist dies genau ein einziges catch, das auf eine IndexOutOfBoundsException reagiert. Das try-catch Konstrukt ist folgendermaßen zu verstehen: In welcher Zeile auch immer innerhalb des try Blocks eine Exception geworfen wird, wird sie dem entsprechenden catch zugef¨ uhrt. Sollte keine Exception auftreten, so wird auch keiner der catch Bl¨ ocke angesprungen und das Programm l¨ auft erst unterhalb aller dieser Bl¨ ocke weiter. Die Exception, die in unserem Demoprogramm auftritt, wird verursacht durch den Aufruf von setElementAt in Zeile 167. Dort wird n¨amlich mit einem illegalen Index auf den Vektor zugegriffen. Dadurch, dass dort eine Exception geworfen wird, wird das Statement in Zeile 168 nicht mehr erreicht, denn das Auftreten der Exception bewirkt, dass das Programm mit der Behandlung der Exception im catch Block in den Zeilen 170–173 fortf¨ahrt.
318
11. Exceptions
Vorsicht Falle: Leider wird bei der Behandlung von Exceptions sehr oft ein gravierender Fehler gemacht, der zum Teil auf Schlampigkeit, zum Teil aber auch auf Unwissenheit beruht: Jede Exception, die innerhalb eines try Blocks auftritt, bewirkt sofort das Anspringen des entsprechenden catch. Das bedeutet, dass alle Statements, die unterhalb der Zeile stehen, in der die Exception aufgetreten ist, nicht mehr ausgef¨ uhrt werden. Sollte dort Code stehen, der unbedingt immer ausgef¨ uhrt werden muss (z.B. irgendein delete von dynamisch allokiertem Speicher), dann hat man ein Problem, denn bis zu dieser Zeile kommt man im Falle einer Exception ja nicht! Aus diesem Grund muss man sich sehr gut u ¨berlegen, wie man try Bl¨ocke setzt, damit man nicht ins offene Messer l¨ auft und einerseits zwar eine “sch¨one” Fehlerbehandlung implementiert, die aber selbst gleich noch schlimmere Fehler macht und z.B. wachsende Programme oder sonstige schlimme Inkonsistenzen verursacht! Es stellt sich nun die Frage, wie wir die Exception, die in Zeile 135 geworfen wurde, durch catch an einer ganz anderen Stelle im Programm fangen k¨onnen. Eigentlich m¨ usste das Exception Objekt ja zu diesem Zeitpunkt schon lange tot sein, da die throw Expression, in deren Rahmen das tempor¨are Objekt generiert wurde, bereits fertig abgearbeitet ist. Die Antwort darauf kl¨ art auch gleich das Ph¨anomen der zwei erzeugten Objekte auf: Wird eine Exception geworfen, so wird immer (!) automatisch eine Kopie dieser Exception generiert. Genau diese Kopie ist es, die wir auch durch unser catch fangen und nicht, wie viele Entwickler annehmen, das Original! Die Kopie ist ebenfalls ein tempor¨ares Objekt und die Lifetime dieses Objekts endet, sobald der catch Block verlassen wurde, denn dort ist f¨ ur den Compiler dann diese Expression zu Ende. Jetzt, wo diese Eigenheit gekl¨ art ist, k¨onnen wir den Output zu Ende analysieren: 1. In Zeile 167 wird ein Aufruf auf setElementAt mit Index 5 als Parameter durchgef¨ uhrt. ¨ 2. In Zeile 134, in der Implementation von setElementAt, wird eine Uberpr¨ ufung vorgenommen, ob dieser Index auch wirklich zul¨assig ist. Weil wir aber die Instanz von Vector so angelegt haben, dass sie maximal 3 Elemente halten kann, wird entschieden, dass es mit der Zul¨assigkeit gar nicht so weit her ist. 3. Es wird also entsprechend in Zeile 135 eine IndexOutOfBoundsException als tempor¨ ares Objekt geworfen und dadurch wird die Methode an dieser Stelle auch verlassen. Der Konstruktoraufruf des tempor¨aren Objekts ¨außert sich im Output durch die Meldungen default - constructing Exception, id = 0 default - constructing IndexOutOfBoundsException, id = 0 4. Wie bereits erw¨ ahnt, wird von throw sofort eine Kopie dieses Objekts angelegt. Da wir einen Copy Constructor bereitgestellt haben, wird die-
11. Exceptions
319
ser auch verwendet. Dies sieht man im Output an den Meldungen copy - constructing Exception, id = 1 copy - constructing IndexOutOfBoundsException, id = 1 5. Nach Anlegen dieser Kopie ist die throw Expression abgearbeitet. Es wird also das urspr¨ ungliche Exception Objekt damit zerst¨ort. Dies ¨außert sich an den Meldungen destructing IndexOutOfBoundsException, id = 0 destructing Exception, id = 0 Dieses Zerst¨ oren ist auch der Grund, warum ich im Demoprogramm den einzelnen Objekten den Member my_id_ verpasst habe: Man sieht, welche Instanz zerst¨ ort wird. Bei uns hat das Original die Id 0 und die Kopie hat die Id 1. 6. Nach dem Zerst¨ oren des Originals beginnt das Stack Unwinding, also das Zur¨ uckgehen in der Aufrufhierarchie am Stack, bis ein entsprechender try Block mit zugeh¨ origem catch gefunden wird. Hierbei wird setElementAt verlassen und das erste passende catch findet sich direkt in der aufrufenden main Funktion in Zeile 170. Genau dort l¨auft das Programm dann weiter, wie sich am Output leicht erkennen l¨asst. 7. Beim Verlassen des catch Blocks ist auch die Lifetime der Kopie des tempor¨ aren Objekts zu Ende (diese war nat¨ urlich selbst ein tempor¨ares Objekt, wie sich leicht u ¨berlegen l¨asst). Entsprechend finden wir auch im Output die entsprechenden Meldungen zu den Destruktoraufrufen destructing IndexOutOfBoundsException, id = 1 destructing Exception, id = 1 Das Grundprinzip des Exception Mechanismus ist im Prinzip also recht klar und einfach nachvollziehbar. Es wurde auch bereits erw¨ahnt, dass es verschiedene catch Bl¨ ocke f¨ ur verschiedene Exceptions geben kann. Nun stellt sich nur noch die Frage, wie diese verschiedenen Exceptions unterschieden werden. Die Antwort ist einfach: aufgrund ihres Typs. Ein bestimmter catch Block wird angesprungen, wenn die Exception, die geworfen wurde, eine der folgenden Bedingungen erf¨ ullt: • Die geworfene Exception hat genau denselben Typ, wie im catch Statement als Parameter angegeben. • Die im catch als Parameter angegebene Exception ist eine public Basisklasse der geworfenen Exception. Dann sind diese beiden Typen ja bekannterweise voll kompatibel. • Wenn die geworfene Exception ein Pointer ist (ja, auch das geht!) und dieser Pointer zu einem Pointer-Parameter in einem catch Statement typkompatibel ist. • Wenn die geworfene Exception eine Referenz ist (wie zu erwarten, geht das auch) und diese Referenz zu einem Referenz-Parameter in einem catch Statement typkompatibel ist.
320
11. Exceptions
Außerdem gilt noch, dass immer der erste catch Block angesprungen wird, der eine dieser Bedingungen erf¨ ullt. Sollten also aus Gr¨ unden der Typenkompatibilit¨ at mehrere catch Bl¨ ocke in Frage kommen, so wird der erste davon genommen. Das bedeutet also, dass auch die Reihenfolge, in der die catch Bl¨ ocke vorkommen, eine große Bedeutung spielt. Sehen wir uns das am besten an einem Beispiel an (second_exception_demo.cpp): 1 2
// second exception demo . cpp − another demo , how the e x c e p t i o n // mechanism works i n C++
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Exception { public : Exception ( ) { } virtual ˜ Exception ( ) { } };
17 18 19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedAException : public virtual Exception { public : DerivedAException ( ) { } virtual ˜ DerivedAException ( ) { } };
25 26 27 28 29 30 31 32
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedBException : public virtual Exception { public : DerivedBException ( ) { } virtual ˜ DerivedBException ( ) { } };
33 34 35 36 37 38 39 40
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedCException : public DerivedAException { public : DerivedCException ( ) { } virtual ˜ DerivedCException ( ) { } };
41 42 43 44 45 46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DerivedDException : public DerivedAException , public DerivedBException { public : DerivedDException ( ) { } virtual ˜ DerivedDException ( ) { } };
50 51 52 53 54 55 56
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void demoFuncException ( ) throw( Exception ) { throw Exception ( ) ; }
11. Exceptions
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void demoFuncDerivedAException ( ) throw( DerivedAException ) { throw DerivedAException ( ) ; } //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void demoFuncDerivedBException ( ) throw( DerivedBException ) { throw DerivedBException ( ) ; } //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void demoFuncDerivedCException ( ) throw( DerivedCException ) { throw DerivedCException ( ) ; } //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void demoFuncDerivedDException ( ) throw( DerivedDException ) { throw DerivedDException ( ) ; }
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { try { u i n t 3 2 num runs = 5 ; while ( num runs−−) { try { switch ( num runs ) { case 4 : demoFuncDerivedDException ( ) ; break ; case 3 : demoFuncDerivedCException ( ) ; break ; case 2 : demoFuncDerivedBException ( ) ; break ; case 1 : demoFuncDerivedAException ( ) ; break ; case 0 : demoFuncException ( ) ; break ; } } catch ( DerivedCException &exc ) { cout << ” caught DerivedCException ” << e n d l ; } catch ( DerivedAException &exc ) { cout << ” caught DerivedAException ” << e nd l ; } catch ( DerivedBException &exc ) { cout << ” caught DerivedBException ” << e nd l ;
321
322
11. Exceptions
} catch ( DerivedDException &exc ) { // ATTENTION ! ! ! This one w i l l never become a c t i v e ! cout << ” caught DerivedDException ” << e n d l ; }
123 124 125 126 127 128
} } catch ( Exception &exc ) { cout << ” caught Exception ” << e n d l ; } cout << ”program f i n i s h e d ” << e n d l ; return ( 0 ) ;
129 130 131 132 133 134 135 136 137
}
Der Output dieses Programms, der das bildschirmgetr¨ ubte Auge erfreut, sieht dann folgendermaßen aus: caught DerivedAException caught DerivedCException caught DerivedBException caught DerivedAException caught Exception program f i n i s h e d
In unserem Beispiel haben wir es mit einer Hierarchie von Exceptions zu tun, in der alles inklusive virtual Ableitung bei besonderen Mehrfachvererbungssituationen zu finden ist. Zu Demonstrationszwecken wurden 5 verschiedene Funktionen definiert, die jeweils eine der verschiedenen Exceptions werfen. In unserer main Funktion in den Zeilen 84–137 sieht man auf den ersten Blick zwei Dinge: 1. Man kann try-catch Bl¨ ocke beliebig ineinander schachteln. 2. Man kann beliebig viele catch Bl¨ ocke zu einem try definieren. Auf den zweiten Blick sieht man auch, dass man bei Unachtsamkeit einen groben Unfug implementieren kann: Das catch der DerivedDException in den Zeilen 124–128 ist unerreichbar! Diese Exception ist ja sowohl von DerivedAException als auch von DerivedBException abgeleitet. Und f¨ ur jede der beiden existiert bereits davor ein entsprechendes catch. Also wird in jedem Fall eines dieser beiden angesprungen werden, denn das erste passende wird genommen! Dass das wirklich so ist, sieht man auch am Output des Programms, denn die erste Exception, die geworfen wird, ist eine DerivedDException und sie wird in Zeile 116 gefangen, weil hier die erste ¨ Ubereinstimmung gefunden wird. Zum Gl¨ uck warnen moderne Compiler vor solchen ungl¨ uckseligen Fehlern. Die reine Exception, die beim Funktionsaufruf in Zeile 108 geworfen wird, landet erst in Zeile 131, also im ¨außeren, ¨ umschließenden try-catch Block. Es kann n¨amlich innen keine Ubereinstimmung gefunden werden und es wurde ja bereits diskutiert, dass eine Exception immer so weit geworfen wird, bis ein passendes catch gefunden wird.
11. Exceptions
323
Vorsicht Falle: Wie bereits demonstriert wurde, kann es durch Unachtsamkeiten bei der Anordnung der catch Bl¨ocke dazu kommen, dass manche davon nicht erreichbar sind. Dieses Problem kann man leicht vermeiden, indem man Klassen, die tiefer in der Ableitungshierarchie stehen, weiter oben f¨angt und solche, die h¨ oher stehen, entsprechend weiter unten, um “den Rest” der Exceptions, die nicht explizit mit ihrer genauen Klasse angegeben wurden, auch noch sinnvoll zu fangen. Das Verhalten von Exceptions, dass sie entsprechend ihrer Typenhierarchie behandelbar sind, hilft enorm beim Design großer Libararies. Es hat sich sinnvollerweise eingeb¨ urgert, dass es f¨ ur eine Library einen bestimmten Basis Exceptiontyp gibt, von dem alle anderen abgeleitet sind. Zum Beispiel k¨onnte man in einer Library mit mathematischen Klassen eine Basis Exception MathException definieren. Davon abgeleitet w¨are dann z.B. eine DivisionByZeroException, eine VectorException, eine MatrixException, etc. Sollte man bestimmte Exceptions gesondert behandeln wollen, f¨angt man diese speziell, wenn nicht, reicht ein catch auf eine MathException und dadurch f¨ angt man automatisch alle m¨oglicherweise auftretenden Exceptions aus dieser Library. Jetzt bleibt noch eine Frage offen: Was passiert, wenn eine Exception geworfen wird, aber kein passendes catch dazu auffindbar ist? Irgendwann ist ja das Stack Unwinding einmal am Ende des Stacks angelangt. In diesem Fall wird eine Standard Behandlung durchgef¨ uhrt, die u ¨blicherweise zum Programmabbruch mittels abort() f¨ uhrt. Wie dieser Mechanismus genau funktioniert und wie man sich z.B. zu Debugging Zwecken noch in diesen einklinken kann, wird in Abschnitt 15.7 besprochen. Im Augenblick gen¨ ugt es, zu wissen, wie man Exceptions sinnvoll f¨angt und ich m¨ochte allen Lesern unbedingt anraten, die Behandlung von Exceptions auch gewissenhaft durchzuf¨ uhren, damit es gar nicht erst zu sogenannten uncaught Exceptions kommen kann. Eine Anmerkung h¨ atte ich noch zur Deklaration von m¨oglichen Exceptions, die aus einer Methode oder Funktion geworfen werden, mittels throw(...): Ebenso wie beim Fangen von Exceptions, bei der nicht der genaue Typ, sondern die Typenkompatibilit¨at z¨ahlt, verh¨alt es sich bei der Angabe von Exceptions, die geworfen werden k¨onnen. In der Angabe mit throw(...) muss nur ein kompatibler Typ, also entweder die genaue Exception oder eine ihrer Basisklassen angegeben sein. Man k¨onnte also zum Beispiel in unserem Demoprogramm in Zeile 78 auch einfach throw(Exception) schreiben und w¨ urde damit kein Problem heraufbeschw¨oren, denn DerivedDException ist ja von Exception abgeleitet. Im Falle, dass man ein Overriding einer Methode macht, bei der bereits ein throw deklariert wurde, kann diese Deklaration in der abgeleiteten Klasse entweder gleich sein, oder die Originaldeklaration einschr¨ anken. Das ist auch v¨ollig logisch, denn damit erreicht man, dass immer maximal die Exceptions geworfen
324
11. Exceptions
werden k¨ onnen, die urspr¨ unglich deklariert waren. Dadurch ist sichergestellt, dass keine neuen Exceptions geworfen werden k¨onnen, auf die Code, der mit der Basisklasse zu tun hatte, eventuell gar nicht reagieren kann. Es wurde bereits erw¨ ahnt, dass man Exceptions auch “weiterwerfen” kann. Ich m¨ ochte gleich vorausschicken, dass dieses Konstrukt in nicht allzu vielen F¨ allen als sch¨ on bezeichnet werden kann, denn entweder kann man eine Exception behandeln, dann soll man sie fangen, oder nicht, dann soll man sie in Ruhe lassen. Eine der ganz wenigen Ausnahmen zu dieser Regel ist der Fall, dass man f¨ ur eine Exception zwar keine Behandlung durchf¨ uhren kann, aber noch ein paar Dinge aufr¨ aumen muss, bevor diese Exception dann ihrer tats¨achlichen Behandlung zugef¨ uhrt wird. Um z.B. f¨ ur diesen Fall zu wissen, wie man dies in C++ bewerkstelligt, sehen wir uns am besten ein kleines Beispiel an (rethrow_exc_demo.cpp): 1 2
// rethrow exc demo . cpp − a demo , how r e−throwing an e x c e p t i o n // works i n C++
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Exception { public : Exception ( ) { cout << ” d e f a u l t c o n s t r u c t i n g exc ” << e n d l ; } Exception ( const Exception& exc ) { cout << ”copy c o n s t r u c t i n g exc ” << e n d l ; } virtual ˜ Exception ( ) { cout << ” d e s t r u c t i n g exc ” << e n d l ; } };
24 25 26 27 28 29 30
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void demoFuncException ( ) throw( Exception ) { throw Exception ( ) ; }
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { try { try { demoFuncException ( ) ; } catch ( Exception &exc ) { cout << ” i n n e r catch ” << e n d l ; throw ; // r e−throw i t . . . }
11. Exceptions
} catch ( Exception &exc ) { cout << ” o u t e r catch ” << e nd l ; } cout << ”program f i n i s h e d ” << e n d l ; return ( 0 ) ;
46 47 48 49 50 51 52 53
325
}
Der Output dieses Programms sieht dann folgendermaßen aus: d e f a u l t c o n s t r u c t i n g exc copy c o n s t r u c t i n g exc d e s t r u c t i n g exc i n n e r catch o u t e r catch d e s t r u c t i n g exc program f i n i s h e d
In Zeile 44 sieht man, wie man eine Exception einfach “weiterwirft”: Man ruft throw allein stehend auf. Der Sinn der Sache wird klar, wenn man sich den Output genauer ansieht: Es wird hierbei keine weitere Kopie der Exception angelegt, sondern die Kopie, die bereits existiert, einfach weitergeworfen. In diesem inneren catch kann dann das zuvor erw¨ahnte Aufr¨aumen stattfinden. Bisher haben wir in unseren Demoprogrammen Exceptions immer aus “regul¨aren” Methoden bzw. Funktionen geworfen. Was passiert allerdings, wenn wir in einem Konstruktor eine Exception werfen? Genau dabei k¨onnen n¨amlich sehr merkw¨ urdige Dinge passieren, u ubter ¨ber die so manch unge¨ C++ Entwickler stolpern kann. Das folgende Beispiel soll dies demonstrieren (exc_in_constructor_demo.cpp): 1 2
// e x c i n c o n s t r u c t o r d e m o . cpp − what happens when throwing an // exception in a constructor ?
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Exception { public : Exception ( ) { } Exception ( const Exception& exc ) { } virtual ˜ Exception ( ) { } };
18 19 20 21 22 23 24 25 26 27 28
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Dummy { protected : u i n t 3 2 ∗ dummy array ; public : Dummy( ) throw( Exception ) ; virtual ˜Dummy( ) ; };
326
11. Exceptions
29 30 31 32 33 34 35 36 37
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Dummy: :Dummy( ) throw( Exception ) { cout << ” c o n s t r u c t i n g Dummy” << e n d l ; dummy array = new u i n t 3 2 [ 1 0 ] ; throw Exception ( ) ; }
38 39 40 41 42 43 44
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Dummy: : ˜Dummy( ) { cout << ” d e s t r u c t i n g Dummy” << e nd l ; delete [ ] dummy array ; }
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { try { Dummy a dummy instance ; } catch ( Exception &exc ) { cout << ” catch . . . ” << e nd l ; } cout << ”program f i n i s h e d ” << e n d l ; return ( 0 ) ; }
Der Output dieses Programms liest sich f¨ ur Uneingeweihte etwas u ¨berraschend: c o n s t r u c t i n g Dummy catch . . . program f i n i s h e d
Hier wird doch glatt eine Instanz von Dummy in Zeile 51 erzeugt, der Konstruktor wird auch noch erwartungsgem¨aß aufgerufen, aber es wird nirgends ein Destruktor aufgerufen! Das bedeutet also, dass hier ein wundersch¨ones Speicherloch implementiert wurde! Warum das der Fall ist, l¨asst sich einfach erkl¨aren: In C++ gilt ein Objekt dann und nur dann als ordnungsgem¨aß erzeugt, wenn auch der Konstruktor korrekt zu Ende l¨auft. Dann wird auch ein Destruktor aufgerufen. Sollte der Konstruktor durch eine Exception nicht zu Ende laufen, dann wird auch kein Destruktor aufgerufen. Dies ist auch logisch, denn wozu sollte man ein Objekt ordnungsgem¨aß destruieren, wenn man es gar nicht korrekt konstruiert hat? Also wird in einem solchen Fall zu Ende der Lifetime eines Objekts nur noch den Speicher freigegeben, aber kein Destruktor mehr aufgerufen. Vorsicht Falle: Wird entweder im Konstruktor selbst oder von einer aus dem Konstruktor heraus aufgerufenen Methode bzw. Funktion eine Exception geworfen, die das “gewaltsame” Verlassen des Konstruktors bewirkt, so hat man unbedingt daf¨ ur zu sorgen, dass alle notwendigen Schritte des Aufr¨aumens durchgef¨ uhrt werden. Niemals darf man teilweise konstruierte
11. Exceptions
327
Objekte in ihrem “halbtoten” Zustand belassen, denn das resultiert in ganz tollen und langen Debugging-Sessions, in denen man teuflische Gespenster jagen darf. In unserem Fall ist dieses Problem leicht in den Griff zu bekommen, man braucht nur ein entsprechendes delete durchzuf¨ uhren, bevor man die Exception im Konstruktor wirft. Damit hat man sinnvoller- und robusterweise kein “halbtotes” Objekt konstruiert, sondern die “halbe” Konstruktion wieder r¨ uckg¨ angig gemacht. Noch ein Punkt kommt hier ins Spiel: Es wurde bereits gesagt, dass alle Objekte, die fertig konstruiert wurden, auch wieder destruiert werden. Dies gilt nat¨ urlich auch f¨ ur alle Member Variablen eines Objekts, selbst wenn im Konstruktor des Objekts selbst eine Exception geworfen wurde. Sobald diese fertig konstruiert waren, stellen sie keine Gefahr mehr dar. Selbiges gilt auch in Ableitungshierarchien: Sobald der Konstruktor einer Basisklasse korrekt abgearbeitet war, wird auch deren Destruktor aufgerufen, selbst wenn im Konstruktor einer abgeleiteten Klasse eine Exception in Bodenh¨ohe geflogen kommt. Den Beweis f¨ ur diese Aussagen tritt das folgende Beispiel an (another_exc_in_constructor_demo.cpp): 1 2
// a n o t h e r e x c i n c o n s t r u c t o r d e m o . cpp − what happens when throwing an // exception in a constructor ?
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Exception { public : Exception ( ) { } Exception ( const Exception& exc ) { } virtual ˜ Exception ( ) { } };
18 19 20 21 22 23 24 25
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Whatever { public : Whatever ( ) { cout << ” c o n s t r u c t i n g Whatever” << e nd l ; } virtual ˜ Whatever ( ) { cout << ” d e s t r u c t i n g Whatever” << e n d l ; } };
26 27 28 29 30 31 32 33
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DummyBase { public : DummyBase ( ) { cout << ” c o n s t r u c t i n g DummyBase” << e n d l ; } virtual ˜DummyBase ( ) { cout << ” d e s t r u c t i n g DummyBase” << e n d l ; } };
34 35 36 37
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Dummy : public DummyBase {
328
38 39 40 41 42 43 44
11. Exceptions
protected : Whatever a v a r i a b l e ; public : Dummy( ) throw( Exception ) ; virtual ˜Dummy( ) ; };
45 46 47 48 49 50 51 52
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Dummy: :Dummy( ) throw( Exception ) { cout << ” c o n s t r u c t i n g Dummy” << e nd l ; throw Exception ( ) ; }
53 54 55 56 57 58
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− Dummy: : ˜Dummy( ) { cout << ” d e s t r u c t i n g Dummy” << e n d l ; }
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { try { Dummy a dummy instance ; } catch ( Exception &exc ) { cout << ” catch . . . ” << e nd l ; } cout << ”program f i n i s h e d ” << e n d l ; return ( 0 ) ; }
Dieser Output sollte wieder alle Leser beruhigen, denen zuvor bereits tiefe Sorgenfalten auf der Stirn standen: c o n s t r u c t i n g DummyBase c o n s t r u c t i n g Whatever c o n s t r u c t i n g Dummy d e s t r u c t i n g Whatever d e s t r u c t i n g DummyBase catch . . . program f i n i s h e d
Was bedeutet das also f¨ ur Entwickler? Ganz einfach: Hat man es mit Members zu tun, die Objekte darstellen, dann kann man sich auf das ordnungsgem¨aße Destruieren verlassen. Legt man selbst Speicher an, dann muss man auch selbst daf¨ ur Sorge tragen, dass der Konstruktor nicht in einem Zustand verlassen wird, in dem man ein “halbtotes” Objekt erzeugt hat, das ja nicht mehr destruiert wird. Im Konstruktor kann man also schon Probleme bekommen, wenn man unvorsichtig mit Exceptions umgeht. Viel schlimmer wird es allerdings, wenn man im Destruktor mit Exceptions um sich wirft! Ich schicke daher gleich eine starke Empfehlung voraus, die aus den Standard Library Requirements kommt: In einem Destruktor darf niemals eine Exception geworfen werden!
11. Exceptions
329
Der Grund hierf¨ ur ist leicht einzusehen, wenn man sich kurz u ¨berlegt, was bei Exceptions passiert. Wir haben bereits diskutiert, dass bei einer Exception das sogenannte Stack Unwinding stattfindet. Dabei werden nat¨ urlich alle auto-Variablen aus dem Speicher entfernt (und nat¨ urlich destruiert), die ihre Lifetime hinter sich haben, wenn der Block, in dem sie definiert wurden, verlassen wird. Und jetzt stellen wir uns vor, dass eine Variable im Zuge des Stack Unwindings destruiert wird und selbst eine Exception wirft. Welche der beiden Exceptions soll jetzt behandelt werden? Die erste, die das Stack Unwinding in Gang gesetzt hat oder die zweite, die w¨ahrend des Unwindings passiert ist? Beide gleichzeitig behandeln geht nicht, eine davon einfach verwerfen geht auch nicht, ein tolles Dilemma! Es wird in diesem Fall einfach der brutale Weg gegangen und das Programm intern mittels abort abgew¨ urgt. Das wollen wir nat¨ urlich alle nicht, dementsprechend ist die starke Empfehlung von oben unbedingt einzuhalten! Vorsicht Falle: Wenn man unvorsichtig ist, kann es passieren, dass in einem Destruktor eine Methode bzw. Funktion aufgerufen wird, die selbst eine Exception werfen kann. In solchen F¨ allen darf niemals u ¨bersehen werden, dass man im Destruktor selbst ein entsprechendes try-catch Konstrukt unterbringt, ansonsten w¨ urde ja unabsichtlich die Exception aus dem Destruktor hinaus geworfen werden! Vorsicht Falle: Leider gibt es immer wieder Entwickler, die der Meinung sind, dass man Exceptions am besten einfach ignoriert, um sie nach außen zu verstecken. Zu diesem Zweck bauen sie sogenannte silent Catches in ihren Code ein, also solche, die einfach einen leeren catch Block besitzen. Dies darf man niemals machen, denn damit gelingt es bravour¨os, dass man Fehler, die auftreten und sauber gemeldet w¨ urden, kommentarlos ignoriert. Damit k¨onnen sich dann Programme unheimlich komisch verhalten, weil sie eigentlich intern in einem Fehlerzustand sind, aber trotzdem weiterlaufen, als w¨are nichts geschehen. Die allerschlimmsten Exemplare von silent Catches, die man immer wieder findet, sind solche, bei denen im leeren catch Block ein Kommentar `a la // this exception can never happen steht. Der richtige Standpunkt dazu ist allerdings: Wenn die Exception nicht auftreten kann, dann muss man zumindest einen Output einbauen, der den “unm¨oglichen” Fall meldet. Hatte man Recht und die Exception tritt nie auf, dann st¨ ort auch der Output nicht. Hatte man allerdings unrecht (sehr oft, v.a. nach Programm¨ anderungen!!!), dann sieht man wenigstens, dass ein Fehler aufgetreten ist! Vor allem sollte man sich wirklich u ¨berlegen, ob man ein solches catch nicht gleich bleiben l¨ asst und jemandem “weiter oben” die Behandlung u ur den Fall, dass die Exception doch auftritt. ¨berl¨asst, f¨
330
11. Exceptions
Vorsicht Falle: Manche Entwickler verwenden Exceptions bewusst zum Modellieren von bestimmten Control-Flows, z.B. zur Steuerung von Schleifen. Erstens stellt dies einen groben Widerspruch zur Semantik von Exceptions dar, zweitens kann man damit besonders h¨ ubsche Zeitbomben bauen, die viele Leute an den Rand der Verzweiflung treiben k¨onnen. Nehmen wir zum Beispiel an, dass ein Entwickler in einer Schleife ein Vektor-Objekt Element f¨ ur Element durchlaufen will und innerhalb der Schleife irgendwelche Methoden auf diesen einzelnen Objekten aufruft. Nehmen wir weiters an, dass der Vektor so implementiert ist, dass bei unerlaubter Indizierung eine IndexOutOfBoundsException geworfen wird. Der Entwickler kommt auf die glorreiche Idee, eine Endlosschleife zu implementieren und diese in ein try mit zugeh¨ origem catch auf die IndexOutOfBoundsException einzubetten. Sobald die Exception geworfen wird, wird angenommen, dass alle Elemente im Vektor abgearbeitet wurden, denn man hat mit einem zu großen Index zugegriffen. Jetzt passiert allerdings Folgendes: In einer der Methoden, die auf ein Einzelobjekt aufgerufen werden, tritt aufgrund eines Softwarefehlers eine ganz anders begr¨ undete IndexOutOfBoundsException auf, als die, die erwartet wird. Diese bewirkt dann, dass die Schleife unabsichtlich verlassen wird, bevor noch alle Objekte abgearbeitet wurden! Die Implementation geht aber davon aus, dass die Exception dadurch entstand, dass der Vektor fertig abgearbeitet war. Somit hat man tollerweise nur einen Teil des Vektors abgearbeitet und sucht verzweifelt nach irgendwelchen verlorenen Objekten! Vorsicht Falle: Wie bereits erw¨ ahnt, k¨onnen nicht nur Objekte, sondern auch Pointer auf Objekte als Exceptions verwendet werden. Aus Performancegr¨ unden kann dies auch sehr geistreich sein, denn z.B. eine Kopie eines Pointers anzulegen, ist im Regelfall deutlich schneller, als eine Kopie eines gesamten Objekts anzulegen. Nur leider u ¨bersehen manche Entwickler, dass eine dynamisch angelegte Exception auch sinnigerweise wieder irgendwo freigegeben werden muss, wie man an folgendem Beispiel beobachten kann (exception_memory_leak_problem.cpp): 1 2
// exception memory leak problem . cpp − demo , how a memory l e a k // can be caused by bad e x c e p t i o n throwing .
3 4 5 6
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
7 8 9
using s t d : : cout ; using s t d : : e nd l ;
10 11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s Exception { public : Exception ( ) { cout << ” c o n s t r u c t o r c a l l e d ” << e nd l ; }
11. Exceptions
virtual ˜ Exception ( ) { cout << ” d e s t r u c t o r c a l l e d ” << e n d l ; }
16 17
331
};
18 19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void funcThrowingAnException ( ) throw( Exception ∗) { throw new Exception ; }
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { try { funcThrowingAnException ( ) ; } catch ( Exception ∗ exc ) { cout << ” catch . . . ” << e nd l ; } cout << ”program f i n i s h e d ” << e nd l ; return ( 0 ) ; }
Der Output dieses Programms enth¨ ullt auch gleich das Problem: constructor called catch . . . program f i n i s h e d
Tollerweise wird die dynamisch angelegte Exception nirgends mehr freigegeben und schon haben wir ein wachsendes Programm! Diese Art, Exceptions u ¨ber Pointer zu werfen, um Performance zu gewinnen, ist nur dann sinnvoll, wenn der “werfende” Teil einer Applikation sich sowohl um die Erzeugung, als auch um die Zerst¨ orung selbst k¨ ummert, bzw. wenn mittels ReferenceCounting oder mit einem Smart-Pointer Mechanismus gearbeitet wird. Der Ausweg, dass n¨ amlich innerhalb des catch Blocks ein delete aufgerufen wird, ist auch nicht wirklich robust. Er setzt n¨amlich voraus, dass alle Aufrufenden auch wirklich wissen, dass sie die Exception freigeben m¨ ussen. Das ist allerdings eine sehr mutige Annahme, die kein auch noch so gutgl¨aubiger Entwickler treffen sollte! Vorsicht Falle: Auch Referenzen auf Objekte k¨onnen nat¨ urlich als Exceptions Verwendung finden. Wie immer bei Referenzen ist nat¨ urlich auch hier darauf zu achten, dass das Exception Objekt, dessen Reference man wirft, seine Lifetime nicht im Zuge des Stack Unwindings hinter sich bringt. Denn sollte das passieren, dann endet eine gut gemeinte Exception beim Handling in einer b¨ osartigen Segmentation Violation! Auch das ist sicher nicht im Sinne der Erfinder. Vorsicht Falle: Kein gef¨ ahrliches Problem, aber von der Performance her nicht so toll ist folgendes Szenario: Man f¨angt eine Exception nicht als Referenz, sondern ganz einfach als Objekt. Dann passiert es zwangsweise, dass
332
11. Exceptions
noch eine zus¨ atzliche Kopie der Exception angelegt wird, da ja call-by-value in C++ der Standardmechanismus ist. Um nicht missverstanden zu werden: Die Fallen, in die man beim Exception Handling stolpern kann, sollen niemanden abschrecken, sondern nur warnen, worauf man aufpassen muss. Exceptions sind etwas sehr Brauchbares und sollen auch unbedingt verwendet werden! Die Behandlung von irgendwelchen “Ausnahmezust¨ anden” ist seit den Urzeiten der Softwareentwicklung ein heißes Thema und Exceptions sind die sauberste und robusteste M¨oglichkeit, mit unerwarteten Ereignissen umzugehen. Code, der robust und fehlertolerant ist, ist eben bei weitem komplexer als Code, der nur so lange funktioniert, wie alles im “Normalzustand” bleibt. Wer kann zum Beispiel behaupten, dass im Falle von Netzwerken der andere Rechner immer erreichbar ist oder u ¨berhaupt existiert? Wer kann vorherbestimmen, ob ein File, das man o ¨ffnen will, garantiert vorhanden ist? Es ist also notwendig, eine Fehlererkennung und entsprechende Behandlung in den Code einzubauen. Es kursiert leider auch noch immer die große Angst vor Performanceproblemen bei der Verwendung von Exceptions, da ein try ... catch ein wenig ineffizienter ist, als z.B. eine Abfrage von return Werten. Diese Angst ist allerdings im Normalfall unbegr¨ undet. Man darf n¨amlich nicht außer Acht lassen, dass eine saubere und u bersichtliche Programmstruktur in der Regel ¨ eine effizientere Implementation von Algorithmen beg¨ unstigt. Ein kurzer Vergleich des Exception Mechanismus mit alternativen Techniken zur Fehlerbehandlung zeigt seine St¨arken: • Man kann einen Fehler ignorieren und einfach probieren, weiterzumachen. Dass das katastrophale Folgen haben kann, versteht sich von selbst und deswegen m¨ ochte ich diese Art der “Fehlerbehandlung” gar nicht n¨aher kommentieren. • Man kann einfach bei einem Fehler das Programm beenden. Dieses Verhalten f¨ uhrt fr¨ uher oder sp¨ ater sicher zu einem gr¨oßeren Desaster. Erstens ist ein Programm, das sich bei jeder Gelegenheit beendet, eine Katastrophe f¨ ur die Benutzer. Zweitens bedeutet dieses Verhalten, dass intern durch das willk¨ urliche Unterbrechen von Abl¨ aufen schlimmste Inkonsistenzen entstehen k¨ onnen. Sobald dann einmal aufgrund eines solchen Verhaltens eine gr¨oßere Datenbank inkonsistent ist, weil einfach beim Beenden nur die halben Daten geschrieben werden konnten, kann man sich das Lachen kaum noch verhalten. Dieses Verhalten ist also absolut unbrauchbar! • Man kann im Fehlerfall eine Meldung z.B. auf Standard-Error schreiben. Das Problem dabei ist, dass man ja trotzdem irgendwie mit dem Programm fortfahren muss. Es muss also sowieso eine der anderen, hier erw¨ahnten, Strategien zus¨ atzlich implementiert werden, denn sonst k¨ame dieses Verhalten dem Ignorieren des Fehlers gleich. • Man kann aus allen Methoden und Funktionen im Fehlerfall bewusst “unm¨ ogliche” Ergebnisse zum Signalisieren eines Fehlers liefern. Dies war
11. Exceptions
333
lange Zeit g¨ angige Praxis, vor allem in C-Programmen. Allerdings hat dieses Verhalten auch große Nachteile. Einerseits muss man bei jedem einzelnen Aufruf den return-Wert abfragen und entsprechenden Error-Handling Code schreiben, zweitens gibt es genug F¨alle, in denen ein “unm¨oglicher” Wert gar nicht wirklich vorhanden ist! Auch das Speichern eines Fehlers in einer globalen Variable (z.B. errno) ist nicht so toll, denn z.B. im Falle von Multithreading Umgebungen ist dies nicht synchronisierbar. Außerdem hat man das Problem, dass das Melden von Fehlern und die Behandlung derselben nicht sauber getrennt vonstatten gehen kann, denn der Fehlerbehandlungscode kommt immer inmitten irgendwelcher Bl¨ocke vor. Dieses Verhalten ist also auch nicht so toll. • Man kann mittels Callbacks (z.B. u ¨ber Funktionspointer) Fehler melden. Das funktioniert zwar im Prinzip nicht so schlecht, leider hat es den entscheidenden Nachteil, dass im Fehlerfall der Ausstieg aus einer Methode bzw. Funktion und das Resume im Error-Handling Code nicht sauber passieren kann. Hier kommt es dann zu schlimmen Orgien mit Status- und Merkervariablen, die erstens ungewollte Abh¨angigkeiten und Seiteneffekte nach sich ziehen und zweitens den Code relativ undurchschaubar machen. • Man kann im Fehlerfall einen Dialog mit dem User beginnen, der entscheiden soll, wie jetzt fortgefahren wird. Dieses Verhalten ist absolut katastrophal, denn was soll den ein Benutzer mit der Frage anfangen, wie jetzt bei einem Illegalen Zugriff auf Adresse 0x17a5972b vorzugehen ist??? Sinnigerweise werden Exceptions also auch in der Standard-Library von C++ verwendet. Aus diesem Grund m¨ ochte ich hier noch ein paar Kleinigkeiten erg¨anzen, die ich bisher schuldig geblieben bin: • Der Operator new wirft eine Exception vom Typ bad_alloc, wenn beim Anfordern von Speicher etwas schief geht. Um mit dieser Exception umgehen zu k¨ onnen, ist ein #include im Code notwendig. • Der dynamic_cast Operator wirft eine Exception vom Typ bad_cast, wenn der Cast fehlschl¨ agt. Ein #include ist zum Umgang mit dieser notwendig. • Der typeid Operator wirft eine Exception vom Typ bad_typeid, wenn etwas fehlschl¨ agt. Auch hier ist #include zum Umgang mit demselben notwendig. Viele andere Methoden, Operatoren und Funktionen aus der Standard Library werfen Exceptions im Fehlerfall. Welche, kann man den entsprechenden Manual Pages entnehmen. Leider folgen die Standard Exceptions keinem ein¨ deutigen Namensschema, aber damit muss man wohl leben, denn eine Ande++ rung dieser Exceptions w¨ urde weltweit eine Unmenge von C Entwicklern zur Verzweiflung treiben.
12. Operator Overloading
Bei den bisherigen Betrachtungen zu Klassen und Objekten haben wir bereits die verschiedenen Arten von Members, also Variablen und Methoden, kennen gelernt. Eines fehlt noch, um Klassen und Objekte zu einem Gesamtkonzept zu machen, n¨ amlich Operatoren! Je nachdem, mit welchen Objekten man es zu tun hat, ist eine Verkn¨ upfung derselben mittels Operatoren oft ein sehr logischer Zugang zu deren Konzept. Wenn man zwei Ganzzahlenwerte addieren kann, wieso sollte man das mit “h¨oheren” Datentypen (z.B. Vektoren) nicht tun k¨ onnen?
12.1 Grundprinzipien des Operator Overloadings Um nun nicht gleich zu abstrakt zu werden, nehmen wir einmal als Einstiegsdroge f¨ ur das Operator Overloading eine einfache (!) mathematische Vektor-Klasse. Ich gebe schon zu, dass dies wirklich nicht das spannendste Beispiel ist und dass es bereits vielfach in der Literatur in verschiedensten Formen abgehandelt ist. F¨ ur den Einstieg ist es allerdings eines der demonstrativsten Beispiele. Sobald wir u ¨ber die Grundprinzipien hinaus sind, wird es dann wieder spannender. Von unserer Minimalversion eines Vektors erwarten wir folgende Dinge: • Ein Vektor-Objekt speichert eine gewisse Anzahl von double Elementen. Beim Erzeugen eines solchen Objekts wird diese Anzahl (durch entsprechenden Konstruktoraufruf) festgelegt. • Der Zugriff auf die einzelnen Elemente eines Vektors soll mittels Angabe eines Index in eckigen Klammern hinter einer Vektor-Variable erfolgen, wie es auch bei “normalen” Arrays in C++ u ¨blich ist. • Zwei Vektoren sollen addierbar sein. Dies soll intuitiverweise einfach durch ein + zwischen zwei Vektoren geschehen. Die Addition von zwei Vektoren soll mathematisch korrekt durchgef¨ uhrt werden, indem einfach die jeweiligen Einzelelemente an jeder Index-Position miteinander addiert werden. • Es soll eine Subtraktion eines Vektors von einem anderen m¨oglich sein. Dazu will man einfach ein - zwischen diese beiden Vektoren schreiben. Die Subtraktion eines Vektors von einem anderen soll mathematisch korrekt geschehen, indem einfach die jeweiligen Einzelelemente subtrahiert werden.
336
12. Operator Overloading
• Positives und negatives Vorzeichen sollen im Zusammenhang mit Vektoren verwendet werden k¨ onnen. • Ein Vektor soll mit einem Skalar (also bei uns mit einem double Wert) multiplizierbar sein. Dabei soll mathematisch korrekt jedes Einzelelement des Vektors mit dem Skalar multipliziert werden. • Die Zuweisung eines Vektors auf einen anderen mittels = Operator soll m¨oglich sein. • F¨ ur Addition, Subtraktion und Multiplikation soll es auch die ZuweisungsKurzformen +=, -= und *= geben. • Der Vergleich zweier Vektoren auf Gleichheit sowie auf Ungleichheit soll intuitiverweise mittels == bzw. != m¨oglich sein. Im Prinzip stellt sich also hier nur die Frage, wie man in C++ dem Compiler erkl¨art, was ein Operator zu tun hat. Denkt man ein wenig n¨aher u ¨ber die Eigenschaften von Operatoren nach, so l¨asst sich leicht erkennen, dass sie eigentlich auch nichts anderes als Methoden bzw. Funktionen sein m¨ ussten. Und bis auf die spezielle Syntax, die Operatoren zugrunde liegt, ist das auch so. Nehmen wir z.B. einmal den folgenden Ausdruck her, bei dem wir annehmen, dass beide Variablen vom selben Typ MathVector sind: vector1 + vector2 Das bedeutet f¨ ur den Compiler salopp gesprochen: Ich habe einen bin¨ aren Plus-Operator, der als seine Operanden jeweils etwas vom Typ MathVector oder etwas Kompatibles nimmt. Oder nehmen wir folgenden Ausdruck unter denselben Typ-Voraussetzungen: -vector1 Das bedeutet f¨ ur den Compiler salopp gesprochen: Ich habe einen un¨ aren Minus-Operator, der als Operanden etwas vom Typ MathVector oder etwas Kompatibles nimmt. Solche Betrachtungen des Compilers gelten nat¨ urlich f¨ ur alle Operatoren, die in C++ definiert sind, also auch Index- und Typumwandlungs-Operatoren und z.B. sogar f¨ ur die Operatoren new und delete. Der Operator ist in allen F¨ allen f¨ ur den Compiler irgendetwas Aufrufbares, das die Aufgabe schon erledigen wird, also ausf¨ uhrbarer Code, der die Implementation des Operators repr¨ asentiert. Wenn wir nun noch weiter in Betracht ziehen, dass der Compiler Ausdr¨ ucke von links nach rechts auswertet, dann kann man auch einen sinnvollen Platz definieren, an dem der Compiler diese Definition sucht: • Bei bin¨ aren Operatoren in der Deklaration der Klasse, der der linke Operand angeh¨ ort. • Bei un¨ aren Operatoren in der Klasse, der der (einzige) Operand angeh¨ort. Bei einem un¨ aren Minus also ist dies der Operand rechts neben dem Minus, beim Index Operator ist es der Operand links davon. Allgemein gesprochen sucht der Compiler immer in der Klasse des Operanden nach der Operator-Definition, auf die sich der Operator bezieht. Im Falle,
12.1 Grundprinzipien des Operator Overloadings
337
dass sich ein Operator auf zwei Operanden bezieht, also bei allen bin¨aren Operatoren, sucht der Compiler in der Klasse des linken Operanden. Ich schicke gleich voraus, dass dies nur ein Teil der Wahrheit ist, den Rest werden wir in K¨ urze noch besprechen. F¨ ur den Augenblick reicht diese Betrachtung einmal aus. Es ist also f¨ ur unsere Beispiele nur notwendig, dass wir in der Klasse MathVector die geforderten Operatoren als Members deklarieren und definieren und schon funktioniert das Spielchen. Die Deklaration einer MathVector Klasse, die die oben angesprochenen Forderungen erf¨ ullt und entsprechende Operatoren implementiert, sieht dann am Beispiel folgendermaßen aus (math_vector_v1.h): 1 2
// math vector v1 . h − a s i m p l e mathematical v e c t o r c l a s s as a // demo f o r o p e r a t o r o v e r l o a d i n g
3 4 5
#i f n d e f m a t h v e c t o r v 1 h #define m a t h v e c t o r v 1 h
6 7 8 9
#include < s t d e x c e p t> #include #include ” u s e r t y p e s . h”
10 11 12 13
using s t d : : i n v a l i d a r g u m e n t ; using s t d : : b a d a l l o c ; using s t d : : r a n g e e r r o r ;
14 15 16 17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MathVector ∗ ∗ A s i m p l e mathematical v e c t o r c l a s s ∗ ∗/
22 23 24 25 26
c l a s s MathVector { protected : static uint32 c u r r e n t i d ;
27 28 29 30
uint32 i n t e r n a l i d ; u i n t 3 2 num elements ; double ∗ e l e m e n t s ;
31 32 33
virtual void performAddOperation ( const MathVector & v e c t o a d d ) throw ( ) ;
34 35 36 37 38 39 40
virtual void performSubtractOperation ( const MathVector & v e c t o s u b t r a c t ) throw ( ) ; public : MathVector ( u i n t 3 2 num elements ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
41 42 43
MathVector ( const MathVector &vec ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
44 45 46
virtual ˜ MathVector ( ) throw ( ) ;
47 48
virtual double &operator [ ] ( u i n t 3 2 index ) const
338
12. Operator Overloading
throw( r a n g e e r r o r ) ;
49 50
virtual MathVector operator + ( const MathVector & v e c t o a d d ) const throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
51 52 53
virtual MathVector &operator + ( ) const throw ( ) ;
54 55 56
virtual MathVector &operator += (const MathVector & v e c t o a d d ) throw( i n v a l i d a r g u m e n t ) ;
57 58 59
virtual MathVector operator − ( const MathVector & v e c t o s u b t r a c t ) const throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
60 61 62
virtual MathVector operator − ( ) const throw ( ) ;
63 64 65
virtual MathVector &operator −= (const MathVector & v e c t o s u b t r a c t ) throw( i n v a l i d a r g u m e n t ) ;
66 67 68
virtual MathVector operator ∗ ( double num to multiply by ) const throw( b a d a l l o c ) ;
69 70 71
virtual MathVector &operator ∗= ( double num to multiply by ) throw ( ) ;
72 73 74
virtual MathVector &operator = ( const MathVector & v e c t o a s s i g n ) throw( i n v a l i d a r g u m e n t ) ;
75 76 77
virtual bool operator == (const MathVector & v e c t o c o m p a r e w i t h ) const throw ( ) ;
78 79 80
virtual bool operator ! = ( const MathVector & v e c t o c o m p a r e w i t h ) const throw ( ) ;
81 82 83
};
84 85 86
#endif // m a t h v e c t o r v 1 h
In den Zeilen 48–82 sind die einzelnen Operatoren deklariert, die von dieser Version des Vektors unterst¨ utzt werden. Wie sich leicht erkennen l¨asst, funktioniert die Deklaration von Operatoren genau gleich wie die Deklaration von Methoden, nur dass statt des Methodennamens das Keyword operator, gefolgt vom gew¨ unschten Operator geschrieben wird. Nat¨ urlich gelten auch f¨ ur Operatoren genau dieselben Regeln f¨ ur Overloading, Overriding, static und dynamic Binding, wie wir sie bereits bei Methoden kennen gelernt haben. Gehen wir die Operatoren also einmal der Reihe nach durch um zu sehen, wann und wie sie vom Compiler eingesetzt werden: • In den Zeilen 48–49 ist der Index-Operator deklariert. Dieser nimmt einen uint32 als Parameter und liefert eine Reference auf einen double. Im Fall, dass der Index ung¨ ultig ist, wird ein range_error geworfen. Ich habe mich hier bewusst f¨ ur die Verwendung der vordefinierten Exceptions aus der Standard-Library entschieden, da diese Exceptions allen C++ Entwicklern gel¨aufig sind (obwohl ich z.B. den Namen range_error nicht so toll finde). Wenn der Compiler also irgendwo im Code z.B. das Statement vector1[5]
12.1 Grundprinzipien des Operator Overloadings
339
findet, dann wird er, unter der Voraussetzung, dass vector1 vom Typ MathVector ist (was ab jetzt vorausgesetzt wird), die Implementation dieses Operators mit 5 als Parameter aufrufen. Die Implementation hat dann die Aufgabe, eine Referenz auf das Element mit Index 5 zu liefern oder eine Exception zu werfen, falls der vector1 z.B. nur f¨ ur 3 Elemente angelegt wurde. F¨ ur alle Leser, die sich nun fragen, warum hier eine Referenz geliefert wird und nicht einfach nur ein double, soll folgendes Beispiel Klarheit schaffen: Mit dem Statement vector1[5] = 17.5; will man diesem Element ja einen Wert zuweisen. Und das geht nur, wenn man mit Referenzen arbeitet. Wie sollte man sonst auch auf die richtige Speicherstelle zugreifen? Vorsicht Falle: Das in Abschnitt 9.4.6 besprochene Overloading f¨ ur const und non-const Methoden gilt nat¨ urlich in derselben Form f¨ ur Operatoren. Viele C++ Neulinge glauben allerdings, dass sie hiermit zwei verschiedene Index-Operatoren so definieren k¨onnten, dass die const Variante nur zum Lesen dient und die non-const Variante auch zum Schreiben. Der Compiler kann aber gar nicht wissen, was man mit dem Ergebnis des Operators machen will, woher denn auch? Die einzige Basis, auf der er seine Entscheidung trifft, ist die Konstantheit des Objekts selbst, auf das der Operator angewandt wird. Ist dieses konstant, so wird die const Variante des Operators aufgerufen, sonst nicht (alles nat¨ urlich genau den Regeln aus Abschnitt 9.4.6 entsprechend). • In den Zeilen 51–52 ist der Additions-Operator deklariert, der zwei Vektoren addieren kann. Das Ergebnis dieser Operation ist ein neuer Vektor, denn man will die beiden Operanden nat¨ urlich nicht ver¨andern. Aus diesem Grund wird auch ein Objekt vom Typ MathVector als return Wert geliefert und keine Referenz auf einen Vektor. Das Statement vector1 + vector2 wird also folgendermaßen vom Compiler aufgel¨ost: Es wird der AdditionsOperator von vector1 mit vector2 als Parameter aufgerufen. Dieser muss ein Objekt liefern, das durch die Durchf¨ uhrung der Addition entsteht. Noch etwas l¨ aßt sich hier leicht erkennen: Das Resultat der Operation ist ein neues Objekt. Dieses wird allerdings nur tempor¨ar gebraucht und darf nicht “f¨ ur ewig” im Speicher liegen bleiben. Wir haben es also hier wieder mit einem tempor¨ aren Objekt zu tun, das nach Abschluss der Expression verworfen wird. Bei der Implementation dieses Operators werden wir uns dieser Eigenschaft dann noch n¨ aher zuwenden. • In den Zeilen 54-55 ist der un¨ are Plus-Operator definiert. Dieser ist daran zu erkennen, dass er keinen Parameter nimmt, weil er ja un¨ar ist :-). Der Grund, warum hier eine Referenz als return Wert geliefert wird, ist in Performancebetrachtungen zu finden: Wozu soll man ein neues Objekt
340
•
•
•
•
12. Operator Overloading
erzeugen, das ja nur tempor¨ ar ist und danach sowieso wieder weggeworfen ¨ wird? Das un¨ are Plus bewirkt ja u am Inhalt des ¨berhaupt keine Anderung Vektors. Also kann die Implementation gleich eine Referenz auf sich selbst liefern um unn¨ otiges Erzeugen und wieder Wegwerfen zu verhindern. Das Statement +vector1 wird also vom Compiler der Behandlung durch diesen Operator zugef¨ uhrt. In den Zeilen 57–58 findet sich der Additions-Kurzzuweisungs-Operator. Eine Anwendung dieses Operators bewirkt nat¨ urlich eine Ver¨anderung des Inhalts des Objekts. Deshalb kann auch gleich eine Referenz auf das ge¨anderte Objekt als return-Wert geliefert werden. Findet der Compiler also irgendwo im Code das Statement vector1 += vector2; dann wird dieser Operator auf vector1 mit vector2 als Parameter aufgerufen. Lesern, die sich nun fragen, warum dieser Operator u ¨berhaupt einen return-Wert liefert, m¨ ochte ich folgendes Beispiel zeigen, das absolut regul¨ ar ist: vector3 = vector1 += vector2; Hier wird zuerst eine Additions-Kurzzuweisung von vector2 auf vector1 gemacht und das Ergebnis dieser Operation wird dann vector3 zugewiesen (siehe auch Zuweisungs-Operator weiter unten). W¨are der Operator als void Operator definiert (was theoretisch m¨oglich ist), dann w¨are ein solches Statement hierdurch mit Erfolg verhindert worden und der Compiler w¨ urde sich beschweren. In den Zeilen 60–67 finden sich die verschiedenen Minus-Operatoren. Diese funktionieren analog zu den soeben besprochenen Plus-Operatoren, allerdings mit einer Ausnahme: Das un¨ are Minus liefert als return-Wert keine Referenz, sondern ein Objekt. Muss es auch, denn hier wird ja der entsprechende negative Vektor erwartet, allerdings ohne das Original zu ¨andern. In den Zeilen 69–73 sind die entsprechenden Multiplikations-Operatoren deklariert. Ich denke, zu diesen kann ich mir die Erkl¨arung jetzt auch ersparen, denn sie funktionieren praktisch analog zu den Additions- und Subtraktionsoperatoren. Nur nehmen sie als zweites Argument eben keinen MathVector, sondern einen double Wert. Wer Lust hat, kann ja auch die Multiplikation von zwei Vektoren (also das sog. Kreuzprodukt) implementieren. Dies geschieht durch ein einfaches Overloading des entsprechenden Multiplikations-Operators durch einen, der als Parameter ein Element vom Typ MathVector nimmt. In den Zeilen 75–76 ist der Zuweisungs-Operator definiert. Wie dieser funktioniert, l¨ asst sich leicht vorstellen. Dass auch dieser Operator einen return-Wert liefert ist ebenso logisch, denn Statements wie vector1 = vector2 = vector3; sollen nat¨ urlich auch unterst¨ utzt werden.
12.1 Grundprinzipien des Operator Overloadings
341
• In den Zeilen 78–82 sind die logischen Vergleichsoperatoren deklariert. Auch hier denke ich mir, dass eine genaue Erkl¨arung u ussig ist. ¨berfl¨ Da die Implementation relativ lang ist, f¨ uhren wir uns diese h¨appchenweise zu Gem¨ ute. Gleich vorausschicken m¨ ochte ich, dass aus Gr¨ unden der Lesbarkeit bei der Implementation nicht das letzte Bisschen m¨oglicher Performance herausgequetscht wird. Vielmehr soll der hier abgedruckte Code u ¨bersichtlich sein. Beginnen wir die Betrachtungen am besten bei den beiden Konstruktoren und dem Destruktor (math_vector_v1.cpp): 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ standard c o n s t r u c t o r ∗/ MathVector : : MathVector ( u i n t 3 2 num elements ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) { i n t e r n a l i d = c u r r e n t i d ++; cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : standard c o n s t r u c t o r ” << e nd l ; num elements = num elements ; i f ( ! num elements ) throw i n v a l i d a r g u m e n t ( ” c o n s t r u c t i o n o f MathVector with 0 elements not a l l o w e d ” ) ; e l e m e n t s = new double [ num elements ] ; while ( num elements−−) e l e m e n t s [ num elements ] = 0 . 0 ; }
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ copy c o n s t r u c t o r ∗/ MathVector : : MathVector ( const MathVector &vec ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) { i n t e r n a l i d = c u r r e n t i d ++; cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : copy c o n s t r u c t o r , o t h e r i d = ” << vec . i n t e r n a l i d << e nd l ; num elements = vec . num elements ; i f ( ! num elements ) throw i n v a l i d a r g u m e n t ( ”copy−c o n s t r u c t i n g MathVector from i n v a l i d s r c not a l l o w e d ” ) ; e l e m e n t s = new double [ num elements ] ; u i n t 3 2 count = num elements ; double ∗ s r c = vec . e l e m e n t s ; double ∗ d s t = e l e m e n t s ; while ( count−−) ∗ d s t++ = ∗ s r c ++; }
50 51 52 53 54 55 56 57 58 59 60
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ MathVector : : ˜ MathVector ( ) throw ( ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : d e s t r u c t o r ” << e nd l ; delete [ ] e l e m e n t s ; }
342
12. Operator Overloading
In den Zeilen 17–19 sowie in den Zeilen 35–38, sieht man, dass jede Instanz eines Vektors eine eigene interne Identifikation bekommt und ein Output erzeugt wird, der es erlaubt, am Bildschirm zu sehen, was gerade passiert. Dies wurde deshalb eingebaut, um sehen zu k¨onnen, wann tempor¨are Objekte erzeugt und wieder verworfen werden. Dadurch l¨asst sich am Output leicht erkennen, welcher Schritt einer Aufl¨ osung von Ausdr¨ ucken gerade abgearbeitet wird. In den Zeilen 21–23 und 40–42 ist zu erkennen, dass der Versuch der Erzeugung eines Vektors mit 0 Elementen nicht erlaubt ist und mit einer entsprechenden Exception quittiert wird. Die Abfrage auf die Anzahl der Elemente ist nat¨ urlich auch im Copy-Constructor notwendig, denn wer hindert jemanden daran, zu versuchen, von einem “halbtoten” Objekt (siehe Kapitel 11) eine Kopie anzulegen? Dass beim Werfen von Exceptions aus dem Konstruktor nat¨ urlich im Hinterkopf behalten werden muss, dass dadurch ja niemals wieder der Destruktor dieses Objekts aufgerufen wird (siehe Kapitel 11), versteht sich von selbst. Eine Frage stellt sich allerdings noch: Es wird eine bad_alloc Exception deklariert, aber nirgends im Code wird tats¨achlich eine geworfen. Was soll das nun wieder? Ganz einfach: Man deklariert nicht nur Exceptions, die man selbst wirft, sondern nat¨ urlich auch solche, die geworfen werden k¨ onnen und die man nicht selbst behandelt. Diese fallen dadurch praktisch weiter nach oben durch. Genau das passiert bei new: Wenn nicht mehr gen¨ ugend Speicher vorhanden ist, um den Request zu erf¨ ullen, dann wird eine bad_alloc Exception geworfen. Unser Vektor kann weder dagegen etwas tun, noch weiß er, wie mit einem solchen Fehler umgegangen werden k¨ onnte, also u ¨berlassen wir das dem aufrufenden Codeteil. Der Destruktor in den Zeilen 54–60 bietet erwartungsgem¨aß keine beson¨ deren Uberraschungen. Nat¨ urlich wird auch hier entsprechender Output erzeugt, um die Lifetime aller Objekte am Bildschirm nachverfolgen zu k¨onnen. Sehen wir uns noch schnell die Implementation des Index-Operators an, bevor wir zum ersten Demoprogramm schreiten, das unser Meisterwerk verwendet: 65 66 67 68 69 70 71 72 73
double &MathVector : : operator [ ] ( u i n t 3 2 index ) const throw( r a n g e e r r o r ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r [ ] ” << e n d l ; i f ( index >= num elements ) throw r a n g e e r r o r ( ”MathVector : index out o f bounds” ) ; return ( e l e m e n t s [ index ] ) ; }
Wie sich leicht erkennen l¨ asst, passiert hier nichts Weltbewegendes. Es wird nur zuerst u uft, ob der Index g¨ ultig ist und dann entsprechend entweder ¨berpr¨ eine Referenz auf das Objekt retourniert oder eine Exception geworfen. Es wird also Zeit, zum ersten Test zu schreiten (math_vector_v1_test1.cpp):
12.1 Grundprinzipien des Operator Overloadings
1
// m a t h v e c t o r v 1 t e s t 1 . cpp − f i r s t
343
t e s t program f o r math v e c t o r s
2 3
#include < i o s t r e a m>
4 5
#include ” math vector v1 . h”
6 7 8 9
using s t d : : cout ; using s t d : : c e r r ; using s t d : : e nd l ;
10 11 12 13 14 15 16 17
int main ( int a r g c , char ∗ argv [ ] ) { try { MathVector v e c t o r 1 ( 3 ) ; MathVector v e c t o r 2 ( 3 ) ; MathVector v e c t o r 3 ( 3 ) ;
18
u i n t 3 2 index = 3 ; while ( index−−) v e c t o r 1 [ index ] = v e c t o r 2 [ index ] = v e c t o r 3 [ index ] = index ;
19 20 21 22
cout << ” Vectors i n i t i a l i z e d to : ” << e nd l ; cout << ” v e c t o r 1 : ” ; for ( index = 0 ; index < 3 ; index++) cout << v e c t o r 1 [ index ] << ” ” ; cout << e nd l ; cout << ” v e c t o r 2 : ” ; for ( index = 0 ; index < 3 ; index++) cout << v e c t o r 2 [ index ] << ” ” ; cout << e nd l ; cout << ” v e c t o r 3 : ” ; for ( index = 0 ; index < 3 ; index++) cout << v e c t o r 3 [ index ] << ” ” ; cout << e nd l ;
23 24 25 26 27 28 29 30 31 32 33 34 35
} catch ( i n v a l i d a r g u m e n t &exc ) { c e r r << ” oops , i n v a l i d argument e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( b a d a l l o c &exc ) { c e r r << ” oops , bad a l l o c e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( r a n g e e r r o r &exc ) { c e r r << ” oops , range e r r o r e x c e p t i o n : ” << exc . what() << e nd l ; } return ( 0 ) ;
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
}
¨ Um bei der Besprechung den Uberblick u ¨ber den Output behalten zu k¨onnen, m¨ochte ich ihn dieses Mal ausnahmsweise mit vorangestellten Zeilennummern einbinden: 1 2 3 4
MathVector , MathVector , MathVector , MathVector ,
id id id id
= = = =
0: 1: 2: 0:
standard standard standard operator
constructor constructor constructor []
344
5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
12. Operator Overloading
MathVector , i d = 1 : o p e r a t o r [ ] MathVector , i d = 2 : o p e r a t o r [ ] MathVector , i d = 0 : o p e r a t o r [ ] MathVector , i d = 1 : o p e r a t o r [ ] MathVector , i d = 2 : o p e r a t o r [ ] MathVector , i d = 0 : o p e r a t o r [ ] MathVector , i d = 1 : o p e r a t o r [ ] MathVector , i d = 2 : o p e r a t o r [ ] Vectors i n i t i a l i z e d to : v e c t o r 1 : MathVector , i d = 0 : o p e r a t o r [ ] 0 MathVector , i d = 0 : o p e r a t o r [ ] 1 MathVector , i d = 0 : o p e r a t o r [ ] 2 v e c t o r 2 : MathVector , i d = 1 : o p e r a t o r [ ] 0 MathVector , i d = 1 : o p e r a t o r [ ] 1 MathVector , i d = 1 : o p e r a t o r [ ] 2 v e c t o r 3 : MathVector , i d = 2 : o p e r a t o r [ ] 0 MathVector , i d = 2 : o p e r a t o r [ ] 1 MathVector , i d = 2 : o p e r a t o r [ ] 2 MathVector , i d = 2 : d e s t r u c t o r MathVector , i d = 1 : d e s t r u c t o r MathVector , i d = 0 : d e s t r u c t o r
Im Testprogramm in den Zeilen 15–17 werden drei Vektoren erzeugt, jeder von ihnen kann drei Elemente halten. Am Output erkennen wir dies in den Zeilen 1–3 und sehen, dass vector1 die Id 0, vector2 die Id 1 und vector3 die Id 2 bekommen hat. In den Zeilen 20–21 des Testprogramms werden den einzelnen Elementen der einzelnen Vektoren Werte zugewiesen. Hierbei kommt erwartungsgem¨aß unser selbstdefinierter Index-Operator zum Tragen, wie sich am Output in den Zeilen 4–12 zeigt. Die Zeilen 23–35 demonstrieren nur noch, dass der Index-Operator auch wirklich funktioniert hat, indem die einzelnen Elemente der einzelnen Vektoren ausgegeben werden. Im Output vermischt sich dadurch die Anzeige des Operator-Aufrufs und des Ergebnisses. Die Zeilen 14–17 des Outputs zeigen f¨ ur vector1, dass immer zuerst unser Index-Operator aufgerufen wird, der den entsprechenden double Wert liefert, der wiederum ausgegeben wird. Selbiges gilt f¨ ur die Zeilen 18–21 (=vector2) und f¨ ur die Zeilen 22–25 (=vector3). Da sich nach Zeile 35 die Lifetime unserer Vektoren ihrem Ende zuneigt, sieht man im Output in den Zeilen 26–28, wie sie ordnungsgem¨aß destruiert werden. In den Zeilen 40, 45 und 50 sieht man, wie man bei den StandardExceptions zum Text kommt, der ihnen beim Konstruieren u ¨bergeben wurde: Man ruft einfach die Methode what auf. Lesern, die sich mit diesem Testprogramm spielen wollen, m¨ochte ich nat¨ urlich nicht vorenthalten, dass das Makefile dazu unter dem Namen MathVectorV1Test1Makefile auf der beiliegenden CD-ROM zu finden ist. Nach diesem Einstieg in die Welt der Operatoren u ¨ber den Index-Operator wird es Zeit, dass wir zu den interessanteren Vertretern der OperatorenSpezies kommen. Nehmen wir also die verschiedenen Minus-Operatoren einmal ein wenig genauer unter die Lupe:
12.1 Grundprinzipien des Operator Overloadings
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ MathVector MathVector : : operator − ( const MathVector & v e c t o s u b t r a c t ) const throw( i n v a l i d a r g u m e n t , b a d a l l o c ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r − , o t h e r i d = ” << v e c t o s u b t r a c t . i n t e r n a l i d << e n d l ; i f ( v e c t o s u b t r a c t . num elements ! = num elements ) throw i n v a l i d a r g u m e n t ( ”MathVector : i n v a l i d − with v e c t o r s o f d i f f e r e n t s i z e ” ) ; MathVector r e s u l t (∗ this ) ; r e s u l t . performSubtractOperation ( v e c t o s u b t r a c t ) ; return ( r e s u l t ) ; }
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ MathVector MathVector : : operator − ( ) const throw ( ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r unary −” << e nd l ; MathVector r e s u l t (∗ t h is ) ; u i n t 3 2 count = num elements ; double ∗ c u r r e n t e l e m e n t = r e s u l t . e l e m e n t s ; while ( count−−) ∗ c u r r e n t e l e m e n t = −∗ c u r r e n t e l e m e n t++; return ( r e s u l t ) ; }
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
266 267 268 269 270 271 272 273 274 275 276 277 278
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ MathVector &MathVector : : operator −= ( const MathVector & v e c t o s u b t r a c t ) throw( i n v a l i d a r g u m e n t ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r −= , o t h e r i d = ” << v e c t o s u b t r a c t . i n t e r n a l i d << e n d l ; i f ( v e c t o s u b t r a c t . num elements ! = num elements ) throw i n v a l i d a r g u m e n t ( ”MathVector : i n v a l i d −= with v e c t o r s o f d i f f e r e n t s i z e ” ) ; performSubtractOperation ( v e c t o s u b t r a c t ) ; return (∗ th is ) ; } //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void MathVector : : performSubtractOperation ( const MathVector & v e c t o s u b t r a c t ) throw ( ) { u i n t 3 2 count = num elements ; double ∗ s r c = v e c t o s u b t r a c t . e l e m e n t s ; double ∗ d s t = e l e m e n t s ; while ( count−−) ∗ d s t++ −= ∗s r c ++; }
345
346
12. Operator Overloading
In den Zeilen 122–135 ist der “normale” Subtraktions-Operator definiert. ¨ Dass vor der Subtraktion eine Uberpr¨ ufung stattfindet, ob die beiden Vektoren dieselbe Gr¨ oße haben, ist nicht weiter neu. Genauso wenig Neuigkeiten bietet die Exception, die geworfen wird, falls ebendiese Gr¨oße nicht u ¨bereinstimmt. Auch die Deklaration der bad_alloc Exception ist klar, denn in Zeile 132 kann es ja passieren, dass beim Anlegen des Resultat Vektors die Speicheranforderung schief geht. Weil es keine sinnvolle M¨oglichkeit gibt, dieses Problem innerhalb unseres Operators zu beheben, lassen wir die Exception nach oben durchfallen. In Zeile 132 kommt allerdings jetzt etwas vor, das bisher noch unbekannt ist: this. Das Keyword this steht f¨ ur den Pointer auf sich selbst, auch self Pointer oder this Pointer genannt. Dadurch haben alle Objekte die M¨oglichkeit innerhalb ihrer Methoden auf die Adresse ihrer eigenen Instanz zuzugreifen. Nat¨ urlich gilt das nur f¨ ur Methoden, die nicht static sind, denn diese geh¨ oren ja zur Klasse und nicht zu einer Instanz. Zeile 132 legt also eine Kopie der aktuellen Instanz an, auf der der Operator aufgerufen wurde. Von dieser Kopie wird dann in Zeile 133 durch den Aufruf von performSubtractOperation der andere Vektor abgezogen. Das daraus entstehende Resultat wird als return-Wert geliefert. Genau in Zeile 134 sieht man, wann der Block, der einen Funktionsrumpf umschließt, als geschlossen betrachtet wird und damit die Lifetime von Variablen in diesem zu Ende ist: Erst, wenn return abgeschlossen und das Resultat “verarbeitet” wurde. Ansonsten m¨ usste ja direkt beim R¨ ucksprung bereits result destruiert werden, bloß dann w¨ urde man ein totes Objekt retournieren. Das w¨are nat¨ urlich nicht im Sinne des Erfinders. Die Methode performSubtractOperation wurde bewusst aus dem Operator herausgezogen, da sie auch beim Kurzzuweisungs-Subtraktionsoperator gebraucht wird. Oft wird dies anders modelliert und ein Operator verwendet einfach einen anderen Operator, z.B. - legt ein result Objekt an und wendet auf dieses dann den -= Operator an, bevor es retourniert wird. Davor m¨ochte ich allerdings abraten, denn damit kommt es fast garantiert entweder ¨ zu doppelten Uberpr¨ ufungen ein- und derselben Einschr¨ankungen, oder es wird unn¨ otigerweise ein Objekt angelegt, das im Fall einer Exception wieder verworfen wird oder andere unn¨ otige Dinge passieren. Als Grundregel m¨ochte ich f¨ ur solche F¨ alle sagen, dass es immer besser ist, eine public Methode bzw. ¨ einen public Operator so zu schreiben, dass alle Uberpr¨ ufungen darin vorgenommen werden und danach wird eine interne Methode aufgerufen, die nur ¨ noch die Operation ohne weitere Uberpr¨ ufungen ausf¨ uhrt. Die Implementation des Subtraktions-Kurzzuweisungs-Operators in den Zeilen 156–168 ist im Prinzip dieselbe, wie die des “normalen” SubtraktionsOperators. Der einzige Unterschied besteht darin, dass kein result Objekt angelegt wird, sondern das Objekt selbst, auf dem der Operator aufgerufen wurde, ver¨ andert wird.
12.1 Grundprinzipien des Operator Overloadings
347
Die Implementation des un¨ aren Minus-Operators in den Zeilen 140–151 ist straightforward: Es wird ein result Objekt angelegt, in dem jedes einzelne Element durch sein negatives Pendant ersetzt wird. Dies geschieht in der Schleife in den Zeilen 148–149. Die Additions-Operatoren sind analog zu den Subtraktions-Operatoren implementiert. Aus diesem Grund erspare ich den Lesern hier das Abdrucken derselben. Wenden wir uns nun also lieber unseren MultiplikationsOperatoren zu: 173 174 175 176 177 178 179 180 181 182 183 184
MathVector MathVector : : operator ∗ ( double num to multiply by ) const throw( b a d a l l o c ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r ∗ ” << e n d l ; MathVector r e s u l t (∗ this ) ; u i n t 3 2 count = num elements ; double ∗ c u r r e n t e l e m e n t = r e s u l t . e l e m e n t s ; while ( count−−) ∗ c u r r e n t e l e m e n t ++ ∗= num to multiply by ; return ( r e s u l t ) ; }
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ MathVector &MathVector : : operator ∗= ( double num to multiply by ) throw ( ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r ∗=” << e nd l ; u i n t 3 2 count = num elements ; double ∗ c u r r e n t e l e m e n t = e l e m e n t s ; while ( count−−) ∗ c u r r e n t e l e m e n t ++ ∗= num to multiply by ; return (∗ this ) ; }
Wie sich leicht erkennen l¨ asst, ist die Implementation der Multiplikation im Prinzip analog zu den zuvor besprochenen Operatoren. Der einzige Unterschied besteht darin, dass der Parameter ein double Wert ist, mit dem jedes einzelne Element multipliziert wird. Auch der folgende Zuweisungs-Operator bietet nichts wirklich Neues: 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ MathVector &MathVector : : operator = ( const MathVector & v e c t o a s s i g n ) throw( i n v a l i d a r g u m e n t ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r = , o t h e r i d = ” << v e c t o a s s i g n . i n t e r n a l i d << e n d l ; i f ( v e c t o a s s i g n . num elements ! = num elements ) throw i n v a l i d a r g u m e n t ( ”MathVector : i n v a l i d = with v e c t o r s o f d i f f e r e n t s i z e ” ) ; u i n t 3 2 count = num elements ; double ∗ s r c = v e c t o a s s i g n . e l e m e n t s ;
348
12. Operator Overloading
double ∗ d s t = e l e m e n t s ; while ( count−−) ∗ d s t++ = ∗ s r c ++; return (∗ t h is ) ;
216 217 218 219 220
}
Nur um ein kleines bisschen interessanter sind die beiden Vergleichsoperatoren, denn dort scheint sich ein Widerspruch zur oben erw¨ahnten Grundregel mit internen Methoden und public Operatoren und deren Verwendung bzw. Durchmischung eingeschlichen zu haben: 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ bool MathVector : : operator == ( const MathVector & v e c t o c o m p a r e w i t h ) const throw ( ) { i f ( v e c t o c o m p a r e w i t h . num elements ! = num elements ) return ( f a l s e ) ; u i n t 3 2 count = num elements ; double ∗ s r c = v e c t o c o m p a r e w i t h . e l e m e n t s ; double ∗ d s t = e l e m e n t s ; while ( count−−) { i f ( ∗ d s t ++ != ∗ s r c++) return ( f a l s e ) ; } return ( true ) ; }
241 242 243 244 245 246 247 248 249 250
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ bool MathVector : : operator ! = ( const MathVector & v e c t o c o m p a r e w i t h ) const throw ( ) { return ( ! ( ∗ t h is == v e c t o c o m p a r e w i t h ) ) ; }
In Zeile 249 wird jetzt pl¨ otzlich der == Operator auch intern verwendet, obwohl ich gemeint habe, dass dies verp¨ ont sei. Der Grund daf¨ ur ist einfach: In der Implementation des != Operators wird wirklich absolut nichts gemacht, ¨ als das Ergebnis des == Operators zu negieren. Es finden keine Uberpr¨ ufungen statt und es gibt auch sonst keinen Code in dieser Methode. Aus diesem Grund ist der Regelbruch hier zul¨ assig. Im Normalfall w¨ urde man diesen Operator außerdem aufgrund seiner K¨ urze als inline implementieren. Um uns nun noch ein kurzes Bild von der Verwendung der Operatoren und den internen Vorg¨ angen machen zu k¨onnen, werfen wir einen Blick auf das folgende Testprogramm (math_vector_v1_test2.cpp):
12.1 Grundprinzipien des Operator Overloadings
1
349
// m a t h v e c t o r v 1 t e s t 2 . cpp − another t e s t program f o r math v e c t o r s
2 3
#include < i o s t r e a m>
4 5
#include ” math vector v1 . h”
6 7 8 9
using s t d : : cout ; using s t d : : c e r r ; using s t d : : e nd l ;
10 11 12 13 14 15 16 17
int main ( int a r g c , char ∗ argv [ ] ) { try { MathVector v e c t o r 1 ( 3 ) ; MathVector v e c t o r 2 ( 3 ) ; MathVector v e c t o r 3 ( 3 ) ;
18
u i n t 3 2 index = 3 ; while ( index−−) v e c t o r 2 [ index ] = v e c t o r 3 [ index ] = index ;
19 20 21 22
// h er e the copy c o n s t r u c t o r i s c a l l e d ! MathVector v e c t o r 4 = v e c t o r 3 ;
23 24 25
vector1 = ( vector2 ∗ 2) + ( vector3 ∗ 2 ) ; v e c t o r 4 −= v e c t o r 3 ;
26 27
} catch ( i n v a l i d a r g u m e n t &exc ) { c e r r << ” oops , i n v a l i d argument e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( b a d a l l o c &exc ) { c e r r << ” oops , bad a l l o c e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( r a n g e e r r o r &exc ) { c e r r << ” oops , range e r r o r e x c e p t i o n : ” << exc . what() << e nd l ; } return ( 0 ) ;
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
}
Auch hier m¨ ochte ich den Output des Programms wieder mit Zeilennummern ¨ versehen, da ansonsten der Uberblick nicht wirklich leicht zu bewahren ist: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector ,
id id id id id id id id id id id id id id
= = = = = = = = = = = = = =
0: 1: 2: 1: 2: 1: 2: 1: 2: 3: 1: 4: 2: 5:
standard c o n s t r u c t o r standard c o n s t r u c t o r standard c o n s t r u c t o r operator [ ] operator [ ] operator [ ] operator [ ] operator [ ] operator [ ] copy c o n s t r u c t o r , o t h e r i d = 2 operator ∗ copy c o n s t r u c t o r , o t h e r i d = 1 operator ∗ copy c o n s t r u c t o r , o t h e r i d = 2
350
15 16 17 18 19 20 21 22 23 24 25
12. Operator Overloading
MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector , MathVector ,
id id id id id id id id id id id
= = = = = = = = = = =
4: 6: 0: 6: 5: 4: 3: 3: 2: 1: 0:
operator + , other id = 5 copy c o n s t r u c t o r , o t h e r i d = 4 operator = , other id = 6 destructor destructor destructor o p e r a t o r −= , o t h e r i d = 2 destructor destructor destructor destructor
Die Zeilen 15–17 des Testprogramms erzeugen wie gehabt die Zeilen 1–3 im Output. Die Zuweisungs-Orgie in den Zeilen 20–21 findet sich im Output in den Zeilen 4–9 wieder. In Zeile 24 des Programms zeigt sich erneut, was bereits in Abschnitt 9.2.3 erw¨ ahnt wurde: Die Initialisierung einer Variable ist definitiv nicht dasselbe, wie eine Zuweisung eines Wertes an eine Variable. Sobald eine Instanz einer Klasse erzeugt wird und in der Initialisierung eine “Zuweisung” eines Wertes stattfindet, wird vom Compiler nachgesehen, ob es einen passenden Konstruktor gibt. Wenn ja, wird dieser verwendet, wie es bei uns mit dem Copy-Constructor passiert. Gibt es keinen, der genau passt, dann wird nach den u ¨blichen Regeln derjenige ausgesucht, der “am besten” passt. Was nicht passiert, ist, dass zuerst der default Konstruktor verwendet wird und danach ein Aufruf des Zuweisungs-Operators erfolgt! Findet der Compiler keinen passenden Konstruktor, quittiert er dies mit einer Fehlermeldung! Das bedeutet, dass die beiden folgenden Codest¨ ucke MyClass var1 = 17; und MyClass var1; var1 = 17; absolut nicht dasselbe bewirken, obwohl dies oft f¨alschlich angenommen wird! Im ersten Fall wird ein Konstruktor gesucht, der als Parameter einen int nehmen kann. Im zweiten Fall wird zuerst das Objekt u ¨ber den default Konstruktor konstruiert und danach wird u ¨ber den Zuweisungsoperator 17 zugewiesen (falls ein solcher Operator existiert). Genau dieser Unterschied ist es auch, warum man immer die Konstruktion und die damit einhergehende Initialisierung von Members im Initialisierungsteil des Konstruktors (also nach dem : und vor dem Rumpf) vornehmen sollte, anstatt erst im Rumpf des Konstruktors eine Zuweisung zu machen. Nat¨ urlich funktioniert das nur, wenn ein entsprechender Konstruktor zur Verf¨ ugung steht :-). Jetzt aber wieder zur¨ uck zu unserem Programm: Sehen wir uns an, was alles passiert, wenn Zeile 26 ausgewertet wird: Im Output schl¨agt sich diese Zeile n¨amlich gleich mit vielen Meldungen zu Buche, die von Zeile 11–20 reichen: • Zu Beginn wird der Operator * aufgerufen, der vector2 mit 2 multipliziert, wie in Zeile 11 des Outputs zu sehen ist.
12.1 Grundprinzipien des Operator Overloadings
351
• Die Meldung u ¨ber den Aufruf des Copy-Constructors in Zeile 12 des Outputs stammt von der Implementation dieses Operators, der ja ein result Objekt anlegt. • Die Zeilen 13–14 geben dar¨ uber Auskunft, dass gerade die zweite Multiplikation, also vector3 mit 2, stattfindet. • Zeile 15 meldet den Aufruf des + Operators auf dem Resultat der ersten Multiplikation (also dem tempor¨ aren Objekt). Diese Addition wird erwartungsgem¨ aß mit dem zweiten tempor¨aren Objekt, also dem Resultat aus der zweiten Multiplikation, durchgef¨ uhrt. • In Zeile 16 des Outputs sieht man, dass bei dieser Addition der beiden tempor¨ aren Objekte nat¨ urlich gem¨aß der Implementation des + Operators ein weiteres tempor¨ ares Objekt erzeugt wird. • Dieses weitere tempor¨ are Objekt wird dann endlich unserem vector1 zugewiesen, wie Zeile 17 zeigt. • In den Zeilen 18–20 sieht man, dass nach Ende der Operation auch alles wieder brav aufger¨ aumt wird. Vergleicht man diese tempor¨ are Objektorgie mit dem Aufruf des Operators -=, dessen Output in Zeile 21 erscheint, dann erkennt man einen “geringf¨ ugigen” Unterschied: Hierbei wird n¨ amlich kein einziges tempor¨ares Objekt angelegt und wieder verworfen! Die Zeilen 22–25 zeigen nur noch das Destruieren unserer vier Vektoren am Ende ihrer Lifetime. Vorsicht Falle: Die zuvor demonstrierte Operation, in der massig tempor¨are Objekte angelegt und wieder zerst¨ ort werden, zeigt, dass man wirklich immer bedenken sollte, wie man gewisse Codezeilen schreibt! Es ist ja nicht nur bei unserem kleinen Demo-Vektor so, dass je nach Operation tempor¨are Objekte gebraucht werden. Leider wird von allzu vielen Entwicklern immer noch die Existenz der Zuweisungs-Kurzformen missachtet und das kann in enorm ineffizientem Code enden, obwohl sich die Entwickler keiner Schuld bewusst sind. Allerdings ist es nun einmal so, dass die folgenden zwei Zeilen var1 = var1 + var2; var1 += var2; keinesfalls dasselbe bedeuten und intern auch zu v¨ollig verschiedenem Verhalten f¨ uhren, auch wenn das Ergebnis in beiden F¨allen gleich aussieht! In der ersten Zeile wird ein tempor¨ ares Objekt erzeugt, zugewiesen und wieder verworfen. In der zweiten Zeile wird einfach die Addition durchgef¨ uhrt und das war’s. Was sieht man hier? Auch bei scheinbar belanglosen Dingen ist ein tieferes Grundverst¨ andnis f¨ ur den Computer, die verwendete Sprache und allgemeine Restriktionen absolut unabdingbar um saubere Software zu schreiben! Ich habe es bereits mehrfach erw¨ahnt, aber aus gutem Grund tue ich es hier noch einmal: Ich m¨ ochte mit den Hinweisen auf solche Fallen keinesfalls jemanden verschrecken, ganz im Gegenteil! Ich m¨ochte nur alle Leser darauf aufmerksam machen, dass ausprobieren, spielen und verstehen gefragt sind!
352
12. Operator Overloading
Je mehr man probiert und analysiert, desto besser wird man gewisse Ph¨anomene kennen lernen und mit ihnen umgehen lernen. Das ist ja gerade der Grund, warum Erfahrung in der Softwareentwicklung so viel wert ist! Ein kleiner Exkurs: Sieht man sich z.B. die Implementation des Subtraktionsoperators an, so wird hierbei ein Objekt result geliefert. In unserem Fall zieht dieses der Compiler direkt f¨ ur die Weiterverarbeitung heran. Je nach Compiler k¨ onnte es in diesem Fall auch passieren, dass er naiverweise eine tempor¨ ares Objekt als Kopie von result f¨ ur die Weiterverarbeitung anlegt. Darunter w¨ urde die Performance dieses Operators im Vergleich zum entsprechenden Kurzzuweisungs Operator nat¨ urlich noch zus¨atzlich leiden. Ich w¨ urde allen Lesern anraten, entsprechende Tests mit ihrer verwendeten Entwicklungsumgebung durchzuf¨ uhren. Vorsicht Falle: Ich habe es hier beim Vektor nicht implementiert, um nicht v¨ollige Verwirrung zu stiften, aber eine große Falle tut sich, je nach Implementation, beim Zuweisungs- und beim Vergleichsoperator auf: Was passiert bei einer Selbstzuweisung bzw. bei einem Vergleich mit sich selbst? In unserem Beispiel gibt es hierbei ein Problem. Deshalb sollte man in diesen F¨allen immer gleich als erste Zeile in den entsprechenden Operatoren durch einen Vergleich des Parameters mit this feststellen, ob man nicht gerade mit sich selbst in einer Operation konfrontiert ist. Ist man hierbei unvorsichtig, dann k¨onnen sich daraus sehr interessante Debugging Sessions ergeben :-). Alle Leser, die sp¨ atestens jetzt bedingt durch Angst (hoffentlich nicht) oder bedingt durch Neugierde (hoffentlich schon) der Spieltrieb gepackt hat, finden das Makefile zu diesem Programm unter dem Namen MathVectorV1Test2Makefile auf der beiliegenden CD-ROM. Ein kleineres Problemchen gilt es noch bei unserer Klasse MathVector zu l¨osen: Das folgende Statement 2.0 * DEC1 kann vom Compiler in der jetzigen Version der Klasse nicht verarbeitet werden. Nach unseren Regeln, die wir oben definiert haben, m¨ usste er jetzt n¨amlich im double nach einem Operator suchen, der diesen mit einem MathVector multipliziert. Den gibt es nat¨ urlich nicht. Genau hier kommt ins Spiel, warum ich u ¨ber die Suche nach den Operatoren gesagt habe, dass das noch nicht die ganze Wahrheit w¨are. Es gibt n¨amlich sehr wohl eine M¨oglichkeit einen Operator zu definieren, der diesen Fall abdeckt. Jedoch ist dies keine Methode unserer Klasse MathVector mehr, sondern eine Funktion, die zwei Parameter nimmt: einen linken und einen rechten Operanden. Erg¨anzen wir also unseren Header um die Deklaration dieser Funktion und f¨ ugen die Implementation derselben zu unserer Implementation des restlichen Vektors hinzu, dann funktioniert auch das. Das Ergebnis dieses Umbaus sind
12.1 Grundprinzipien des Operator Overloadings
353
die Files math_vector_v2.h und math_vector_v2.cpp, von denen ich hier nur die erg¨ anzten Ausschnitte inkludiere: 84 85 86
283 284 285 286 287 288
MathVector operator ∗ ( double num to multiply by , const MathVector& vec ) throw( b a d a l l o c ) ;
MathVector operator ∗ ( double num to multiply by , const MathVector& vec ) throw( b a d a l l o c ) { return ( vec ∗ num to multiply by ) ; }
Um kontrollieren zu k¨ onnen, ob alles erwartungsgem¨aß funktioniert, gibt es das folgende Testprogramm (math_vector_v2_test.cpp): 1
// m a t h v e c t o r v 2 t e s t . cpp − t e s t program f o r extended math v e c t o r s
2 3
#include < i o s t r e a m>
4 5
#include ” math vector v2 . h”
6 7 8 9
using s t d : : cout ; using s t d : : c e r r ; using s t d : : e nd l ;
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
int main ( int a r g c , char ∗ argv [ ] ) { try { MathVector v e c t o r 1 ( 3 ) ; MathVector v e c t o r 2 = 3 ∗ v e c t o r 1 ; } catch ( i n v a l i d a r g u m e n t &exc ) { c e r r << ” oops , i n v a l i d argument e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( b a d a l l o c &exc ) { c e r r << ” oops , bad a l l o c e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( r a n g e e r r o r &exc ) { c e r r << ” oops , range e r r o r e x c e p t i o n : ” << exc . what() << e nd l ; } return ( 0 ) ; }
Der Output, den dieses Testprogramm liefert, braucht im Prinzip auch keine n¨ahere Erkl¨ arung, denn er entspricht genau den Dingen, die bisher besprochen wurden:
354
12. Operator Overloading
MathVector , MathVector , MathVector , MathVector , MathVector ,
id id id id id
= = = = =
0: 0: 1: 1: 0:
standard c o n s t r u c t o r operator ∗ copy c o n s t r u c t o r , o t h e r i d = 0 destructor destructor
Das Makefile zu diesem Meisterwerk findet man erwartungsgem¨aß auf der beiliegenden CD-ROM unter dem Namen MathVectorV2TestMakefile. Vorsicht Falle: Immer wenn man glaubt, endlich eine gute L¨osung gefunden zu haben, stolpert man u ¨ber einen weiteren Aspekt, der die gute L¨osung dann doch nicht so gut aussehen l¨asst. So auch bei unserer Operatorfunktion: Nehmen wir einfach an, wir w¨ urden eine spezielle Vektor-Klasse implementieren, die von unserem Basis Vektor abgeleitet ist und die aus irgendwelchen Gr¨ unden eine geringf¨ ugig andere Implementation der Subtraktion brauchen w¨ urde als die Basis. Nun gut, m¨ogen jetzt viele Leser sagen, dann implementieren wir eben f¨ ur die Ableitung eine neue Operatorfunktion f¨ ur die Subtraktion und die Sache ist gegessen. Leider ist aber die Situation nicht ganz so einfach. Erinnern wir uns daran, dass wir aus Gr¨ unden der Polymorphismus-Eigenschaft von Klassen das dynamic Binding mittels virtual kennen gelernt haben. Man kann aber nur Methoden als virtual deklarieren, mit Funktionen geht das nicht! Das bedeutet, dass unsere Operatorfunktion den Polymorphismus durchbricht! In unserem Fall haben wir Gl¨ uck, denn die Operatorfunktion ist so implementiert, dass sie in Wirklichkeit nicht selbst rechnet, sondern den virtual Operator * aufruft. Das muss aber nicht immer der Fall sein. Wie sorgen wir also nun daf¨ ur, dass dieses Loch in der Implementation gestopft wird? Ganz einfach: Wir f¨ uhren die Konvention ein, dass eine Operatorfunktion niemals selbst eine Operation durchf¨ uhren darf, sondern immer nur eine Delegation an eine virtual Methode vornehmen darf. In dieser steckt dann erst wirklich die Logik der Operation. Ein weiteres Beispiel zu diesem Thema, bei dem es essentiell ist, diese Konvention einzuhalten, findet sich in Abschnitt 16.6.
12.2 Typumwandlungen Oft passiert es, dass verschiedene Datentypen zueinander bis zu einem gewissen Grad kompatibel sind und dementsprechend auch sinnvoll ineinander umgewandelt werden k¨ onnen. Nehmen wir als kleines Beispiel zur Demonstration eine Klasse RangeControlledInt, die einen Ganzzahlenwert repr¨asentiert, dessen g¨ ultiger Wertebereich von der Applikation kontrolliert werden kann. Einem Objekt dieser Klasse wird beim Konstruieren der gew¨ unschte g¨ ultige Wertebereich u ¨bergeben. Ansonsten soll ein solches Objekt voll kompatibel zu einem int sein, mit der einzigen Ausnahme, dass es die Zuweisung von Werten, die außerhalb des erlaubten Bereichs liegen, nicht zul¨asst und mit
12.2 Typumwandlungen
355
einer entsprechen Exception quittiert. Der springende Punkt hierbei liegt in der Forderung der vollen Kompatibilit¨ at zu einem int. Es sollen also nicht nur alle Operationen gleich funktionieren wie bei einem int, sondern ein solches Objekt soll auch in Operationen beliebig mit int-Variablen gemischt werden k¨ onnen. Eine brute-force Variante diese Klasse zu implementieren w¨ urde darin bestehen, alle Operatoren, die f¨ ur int definiert sind, auch tats¨achlich f¨ ur diese Klasse als entsprechende Operatoren zu definieren. Diese Variante ist aber sehr unsch¨ on, da in jedem einzelnen Operator sowieso wieder nur der entsprechende systeminterne Operator f¨ ur int aufgerufen w¨ urde. Besser w¨ are da schon die M¨ oglichkeit, unser Objekt zu einem “echten” int wandeln zu k¨ onnen. Dann n¨ amlich w¨ urden alle f¨ ur int definierten Operatoren automatisch funktionieren und br¨auchten nicht extra implementiert ¨ werden. Die Uberpr¨ ufung des vorgegebenen Wertebereichs m¨ usste nur in den entsprechenden Zuweisungsoperatoren erfolgen und alle unsere W¨ unsche w¨aren erf¨ ullt. Genau diese M¨ oglichkeit gibt es, wenn man ein Overloading der entsprechenden Operatoren zur Typumwandlung vornimmt, wie im folgenden Beispiel gezeigt wird (range_controlled_int.cpp): 1 2
// r a n g e c o n t r o l l e d i n t . cpp : demo f o r type c o n v e r s i o n o p e r a t o r s
3 4 5 6
#include < s t d e x c e p t> #include #include < i o s t r e a m>
7 8
#include ” u s e r t y p e s . h”
9 10 11 12
using s t d : : i n v a l i d a r g u m e n t ; using s t d : : b a d a l l o c ; using s t d : : r a n g e e r r o r ;
13 14 15 16
using s t d : : cout ; using s t d : : c e r r ; using s t d : : e nd l ;
17 18 19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ RangeControlledInt ∗ ∗ implements i n t e g e r s with a c o n t r o l l e d range ∗ ∗/
25 26 27 28 29 30 31 32 33 34
c l a s s RangeControlledInt { protected : i n t 3 2 min ; i n t 3 2 max ; int32 value ; public : RangeControlledInt ( i n t 3 2 min , i n t 3 2 max , i n t 3 2 v a l u e = 0) throw( i n v a l i d a r g u m e n t , r a n g e e r r o r ) ;
35 36
RangeControlledInt ( const RangeControlledInt & s r c ) ;
37 38
virtual ˜ RangeControlledInt ( ) { }
356
12. Operator Overloading
39
virtual RangeControlledInt &operator = ( const RangeControlledInt & s r c ) throw( r a n g e e r r o r ) ;
40 41 42 43
virtual RangeControlledInt &operator = ( i n t 3 2 v a l u e ) throw( r a n g e e r r o r ) ;
44 45 46
virtual operator i n t 3 2 ( ) const ;
47 48
};
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ RangeControlledInt : : RangeControlledInt ( i n t 3 2 min , i n t 3 2 max , int32 value ) throw( i n v a l i d a r g u m e n t , r a n g e e r r o r ) { cout << ” RangeControlledInt : standard c o n s t r u c t o r ” << e n d l ; i f ( max < min ) throw i n v a l i d a r g u m e n t ( ”max must not be < min” ) ; i f ( ( v a l u e < min ) | | ( v a l u e > max) ) throw r a n g e e r r o r ( ” v a l u e out o f range ” ) ; min = min ; max = max ; value = value ; }
67 68 69 70 71 72 73 74 75 76 77 78
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ RangeControlledInt : : RangeControlledInt ( const RangeControlledInt & s r c ) { cout << ” RangeControlledInt : copy c o n s t r u c t o r ” << e n d l ; min = s r c . min ; max = s r c . max ; value = src . value ; }
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ RangeControlledInt & RangeControlledInt : : operator = ( const RangeControlledInt & s r c ) throw( r a n g e e r r o r ) { cout << ” RangeControlledInt : = with RangeControlledInt ” << e nd l ; i f ( ( s r c . v a l u e < min ) | | ( s r c . v a l u e > max ) ) throw r a n g e e r r o r ( ” v a l u e out o f range ” ) ; value = src . value ; return (∗ t his ) ; }
95 96 97 98 99 100 101 102 103 104
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ RangeControlledInt & RangeControlledInt : : operator = ( int32 value ) throw( r a n g e e r r o r ) { cout << ” RangeControlledInt : = with i n t 3 2 ” << e n d l ; i f ( ( v a l u e < min ) | |
12.2 Typumwandlungen
( v a l u e > max ) ) throw r a n g e e r r o r ( ” v a l u e out o f range ” ) ; value = value ; return (∗ th is ) ;
105 106 107 108 109
357
}
110 111 112 113 114 115 116 117 118
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ RangeControlledInt : : operator i n t 3 2 ( ) const { cout << ” ∗∗∗∗ RangeControlledInt : c o n v e r s i o n to i n t 3 2 ” << e n d l ; return ( v a l u e ) ; }
119 120 121 122 123 124 125 126 127 128
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { try { RangeControlledInt c o n t r o l l e d v a r 1 ( 0 , 1 0 , 5 ) ; RangeControlledInt c o n t r o l l e d v a r 2 ( − 1 0 , 1 0 ) ;
129
controlled var1 = 3; controlled var2 = 10; c o n t r o l l e d v a r 1 = 15 − c o n t r o l l e d v a r 2 ; controlled var1 = controlled var2 − 5; controlled var1 = 1 + controlled var2 − 5; cout << ”what happens h e r e ? . . . ” << c o n t r o l l e d v a r 1 << e n d l ; controlled var1 = controlled var2 + 100;
130 131 132 133 134 135 136
} catch ( r a n g e e r r o r &exc ) { c e r r << ”Oops − caught r a n g e e r r o r : ” << exc . what() << e nd l ; return (−1); } catch ( i n v a l i d a r g u m e n t &exc ) { c e r r << ”Oops − caught i n v a l i d a r g u m e n t : ” << exc . what() << e nd l ; return (−1); } return ( 0 ) ;
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
}
Der Output, den dieses Programm erzeugt, enth¨ ullt sehr deutlich, dass der ¨ Compiler beim Ubersetzen recht exzessiv Gebrauch von unserem definierten Typumwandlungs-Operator macht. Um die nachfolgende Erkl¨arung zu erleichtern, wurde dieser Output mit Zeilennummern versehen: 1 2 3 4 5 6 7 8 9 10 11
RangeControlledInt : standard c o n s t r u c t o r RangeControlledInt : standard c o n s t r u c t o r RangeControlledInt : = with i n t 3 2 RangeControlledInt : = with i n t 3 2 ∗∗∗∗ RangeControlledInt : c o n v e r s i o n to i n t 3 2 RangeControlledInt : = with i n t 3 2 ∗∗∗∗ RangeControlledInt : c o n v e r s i o n to i n t 3 2 RangeControlledInt : = with i n t 3 2 ∗∗∗∗ RangeControlledInt : c o n v e r s i o n to i n t 3 2 RangeControlledInt : = with i n t 3 2 what happens he r e ? . . . ∗ ∗ ∗ ∗ RangeControlledInt : c o n v e r s i o n to i n t 3 2
358
12 13 14 15
12. Operator Overloading
6 ∗∗∗∗ RangeControlledInt : c o n v e r s i o n to i n t 3 2 RangeControlledInt : = with i n t 3 2 Oops − caught r a n g e e r r o r : v a l u e out o f range
Aufmerksame Leser haben sicherlich bereits erkannt, dass die Deklaration des Typumwandlungs-Operators in Zeile 46 zu finden ist. Allerdings liest sich dieser Operator doch etwas komisch, wenn man ihn mit den anderen Operatoren vergleicht. Der Name des Operators ist int32 und das Fehlen von Parametern zwischen den Klammern sagt uns, dass er ein un¨arer Operator ist. Allerdings ist im Gegensatz zu anderen Operatoren kein expliziter return-Value angegeben! Wenn man kurz nachdenkt, wird auch klar warum: Was soll eine Umwandlung auf einen int32 denn auch anderes liefern als einen int32? Definiert man also einen Typumwandlungs-Operator, so schreibt man einfach das Keyword operator, gefolgt vom Datentyp, in den man umwandeln will, gefolgt von ¨ offnender und schließender runder Klammer, das war’s. Dass sich hinter der Syntax mit dem fehlenden return-Value eine Erleichterung f¨ ur die Compilerbauer versteckt, ist eine andere Geschichte :-). Die Implementation des Typumwandlungs-Operators in den Zeilen 113– 117 braucht sicherlich keine n¨ ahere Erkl¨arung. Um genau verfolgen zu k¨onnen, was hinter den Kulissen passiert, wurde in gewohnter Weise in alle Methoden ein entsprechender Output eingebaut. Werfen wir also nun einen Blick auf denselben: Die Zeilen 1–2 des Outputs werden beim Anlegen der entsprechenden Variablen in den Zeilen 126–127 des Programms erzeugt. Dass die Zeilen 3–4 des Outputs ihren Ursprung in den Zuweisungen aus den Zeilen 129– 130 des Programms haben, ist auch nicht weiter verwunderlich. Zeile 131 des Programms wird in dieser Beziehung schon interessanter, denn von ihr r¨ uhren die Zeilen 5–6 des Outputs her, die zeigen, dass hier zum ersten Mal unser Typumwandlungs-Operator ins Spiel kommt: Hier wird zuerst controlled_var2 in einen int32 verwandelt, danach wird die Subtraktion durchgef¨ uhrt und das Ergebnis wird dann entsprechend zugewiesen. Man sieht also, dass der Compiler aus dem Subtraktionsoperator und dem links von ihm stehenden int32 den Schluss gezogen hat, dass rechts wohl auch ein int32 stehen sollte, damit die Operation ordnungsgem¨aß ausgef¨ uhrt werden kann. In unserer Klassendeklaration hat er den entsprechenden Typumwandlungs-Operator gefunden und automatisch eingesetzt. Dass dies nicht nur funktioniert, wenn links des Operators ein int32 steht, sondern auch umgekehrt und sogar in beliebiger Kombination, beweisen die Zeilen 132–133 des Programms, die im Output die Zeilen 7–10 erzeugen. Salopp gesagt arbeitet der Compiler also so, dass er zuerst einen Ausdruck daraufhin analysiert, was denn sinnvolle Kombinationen von Datentypen w¨ aren, um die gew¨ unschten Operationen auszuf¨ uhren. Findet er dann entsprechende Typumwandlungen, so werden diese eingesetzt und alles geht seinen erwarteten Weg.
12.2 Typumwandlungen
359
Dass dieses Verhalten nicht nur auf mathematische Operationen beschr¨ankt ist, zeigt sich sehr sch¨ on in Zeile 134 des Programms: Wir k¨onnen einfach controlled_var1 mittels << zur Ausgabe bringen, denn der Compiler setzt auch hier unseren Typumwandlungs-Operator ein, wie sich am Output in den Zeilen 11–12 zeigt. Wieso allerdings macht er das hier auch? Ganz einfach: F¨ ur Output Streams in C++ (und genau ein solcher ist cout) ist der Operator << f¨ ur alle primitiven Datentypen (und noch ein paar andere) definiert. Der Compiler nimmt sich also der Reihe nach alle definierten Overloadings von << vor und sieht nach, welchen Parametertyp sie erwarten. Kann das Objekt, das ausgegeben wird, in einen der entsprechenden Typen verwandelt werden, so wird implizit die entsprechende Umwandlung eingesetzt und der Fall ist erledigt. Genau das passiert hier auch. Vorsicht Falle: Denkt man u ¨ber dieses Verhalten weiter nach, dann erkennt man auch gleich die Gefahr, die man mit unvorsichtiger Definition von Typumwandlungs-Operatoren heraufbeschw¨ort: Es kann sehr leicht zu Ambiguit¨ aten kommen! Nehmen wir nur einmal an, wir h¨atten nicht nur die Umwandlung auf int32, sondern auch eine weitere Umwandlung, z.B. auf double, implementiert. Dann h¨ atte der Compiler die M¨oglichkeit, entweder die eine oder die andere Umwandlung einzusetzen, denn sowohl f¨ ur int32 als auch f¨ ur double gibt es eine Entsprechung bei den <<-Operatoren der Output Streams. Das f¨ uhrt dann nat¨ urlich zu einer entsprechenden Fehlermeldung, denn wie soll der Compiler erraten k¨onnen, welche der beiden M¨oglichkeiten er nun nehmen soll. In diesem Fall hilft dann nur noch ein expliziter Typecast, der dem Compiler die Entscheidung abnimmt. Vor allem unerfahrene C++ Entwickler sind sehr oft ziemlich verliebt in die netten M¨ oglichkeiten, die sich durch die Definition von Typumwandlungen ergeben. Dadurch allerdings tappen sie in sehr vielen F¨allen in die Ambiguit¨atsfalle, die zuerst angesprochen wurde. Deshalb m¨ochte ich allen Lesern eine Grundregel aus der Praxis mit auf den Weg geben: Overloading von Typumwandlungs-Operatoren ist sehr sparsam, vorsichtig und vor allem umsichtig zu verwenden! Nur in F¨allen, wo Typumwandlungen wirklich an vielen Stellen im Code die Implementation erleichtern und die Lesbarkeit erh¨ ohen, sind solche einzusetzen. Ansonsten ist es oftmals besser, eine Methode zur Umwandlung bereitzustellen, die explizit aufgerufen werden muss (z.B. toInt32). Sollte sich sp¨ater herausstellen, dass der explizite Aufruf einer solchen Methode im Lauf der Zeit l¨astig wird, kann man immer noch nachtr¨ aglich einen entsprechenden Typumwandlungs-Operator implementieren. Wenden wir uns mit dem Wissen um die Probleme von TypumwandlungsOperatoren noch einmal kurz unserem Vektor-Beispiel aus Abschnitt 12.1 zu: Auf den ersten Blick w¨ are es eigentlich w¨ unschenswert, wenn wir unseren Vektor kompatibel zu einem double-Array machen w¨ urden. Ein Typumwandlungs-Operator auf double * ist schnell implementiert, wie in
360
12. Operator Overloading
der Folge zu sehen ist. Die Deklaration findet sich in math_vector_v3.h und sieht folgendermaßen aus: 48 49
virtual operator double ∗ ( ) const throw( bad cast ) ;
Implementiert wird der Operator in math_vector_v3.cpp: 65 66 67 68 69 70 71 72 73 74 75
MathVector : : operator double ∗ ( ) const throw( bad cast ) { cout << ”MathVector , i d = ” << i n t e r n a l i d << ” : o p e r a t o r double ∗ ( ) ” << e n d l ; i f ( ! num elements ) throw bad cast ( ) ; // BAD PRACTICE ! ! ! The f o l l o w i n g l i n e breaks the o b j e c t // e n c a p s u l a t i o n ! ! ! ! For demonstration only ! ! ! ! return ( e l e m e n t s ) ; }
Ich habe zuvor ganz bewusst erw¨ ahnt, dass die Implementation dieses Operators auf den ersten Blick w¨ unschenswert w¨are. Warum das auf den zweiten Blick gar nicht mehr so ist, zeigt das folgende Testprogramm f¨ ur unseren erg¨anzten Vektor (math_vector_v3_test.cpp): 1
// m a t h v e c t o r v 3 t e s t . cpp − t e s t program f o r extended math v e c t o r s
2 3
#include < i o s t r e a m>
4 5
#include ” math vector v3 . h”
6 7 8 9
using s t d : : cout ; using s t d : : c e r r ; using s t d : : e nd l ;
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
int main ( int a r g c , char ∗ argv [ ] ) { try { MathVector my vector ( 3 ) ; // The f o l l o w i n g l i n e s would r e s u l t i n an ambiguity problem ! // my vector [ 0 ] = 1 0 . 0 ; // my vector [ 1 ] = 2 0 . 0 ; // my vector [ 2 ] = 3 0 . 0 ; double ∗ my array = my vector ; } catch ( i n v a l i d a r g u m e n t &exc ) { c e r r << ” oops , i n v a l i d argument e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( b a d a l l o c &exc ) { c e r r << ” oops , bad a l l o c e x c e p t i o n : ” << exc . what() << e nd l ; } catch ( r a n g e e r r o r &exc ) { c e r r << ” oops , range e r r o r e x c e p t i o n : ” <<
12.2 Typumwandlungen
exc . what() << e nd l ; } return ( 0 ) ;
35 36 37 38
361
}
Die Zeilen 17–19 im Testprogramm wurden bewusst auskommentiert, denn diese f¨ uhren b¨ osartigerweise zu Compiler-Errors! Mehr als nur einmal habe ich erlebt, dass auch erfahrenere Entwickler sehr lange ger¨atselt haben, was denn hier das Problem sein k¨ onnte und dann zu dem Schluss gekommen sind, dass doch wohl der Compiler fehlerhaft sei. Dies ist aber keineswegs der Fall und Versuche mit anderen Compilern haben die fehlgeleiteten Entwickler auch rasch wieder in die raue Wirklichkeit zur¨ uckgeholt, denn der Fehler blieb bestehen! Die L¨ osung des R¨ atsels ist im Prinzip ganz einfach: Sehen wir uns stellvertretend f¨ ur die drei Zeilen einmal einfach nur Zeile 17 an. Was hier eigentlich beabsichtigt ist, ist Folgendes: Es soll unser Index-Operator aus dem Vektor aufgerufen werden und eine Referenz auf die entsprechende Stelle im internen Array zur¨ uckliefern. Auf diese Stelle wird dann der Wert 10.0 zugewiesen. Der Compiler sieht den linken Teil der Zuweisung aber etwas anders, denn er hat pl¨otzlich zwei M¨oglichkeiten, die Aufl¨osung vorzunehmen: 1. Er kann, wie es im Prinzip beabsichtigt w¨are, unseren selbstdefinierten Zuweisungsoperator heranziehen. 2. Er kann aber auch unseren Typumwandlungs-Operator einsetzen. Dieser liefert ja ein double-Array, auf das dann wie u ¨blich der Index-Operator angewendet werden kann. Wir haben es also tats¨ achlich geschafft, durch das Definieren eines Typumwandlungs-Operators eine Ambiguit¨ at zum Index-Operator zu erzeugen, die der Compiler nicht mehr automatisch aufl¨osen kann! Neben diesem offensichtlichen Problem gibt es noch ein weiteres, allerdings weniger leicht bemerkbares, das eine wundersch¨one Zeitbombe darstellt: Der Typumwandlungs-Operator liefert einen Pointer auf das interne Array nach außen, wie in Zeile 74 unseres Programms zu sehen ist. Der Kommentar in den Zeilen 72–73 verr¨at schon, dass dies gar keine so gute Idee ist, weil die Datenkapselung damit grob verletzt wird. Es ist damit dem Missbrauch T¨ ur und Tor ge¨ offnet, denn wer garantiert, dass nicht b¨osartige Manipulationen unseres Arrays stattfinden? Zum Beispiel kann jemand von außen ein delete[] auf den Pointer aufrufen und schon ist die innere Struktur des Objekts zerst¨ ort! Ich glaube, es ist f¨ ur alle Leser einsichtig, dass diese Implementation sehr schlecht ist. Was ist jetzt allerdings die Alternative? Da der Base-Pointer des Arrays niemals nach außen geliefert werden darf, bleibt wohl nur der Ausweg, eine Kopie des Arrays zu liefern. Bloß ist das auch nicht die L¨ osung, denn dadurch haben wir einfach nur ein neues Problem geschaffen: Hierdurch ist n¨ amlich die Verantwortung f¨ ur das Freigeben der Kopie mittels delete[] auf den aufrufenden Code u ¨bergegangen. Wird dies
362
12. Operator Overloading
vergessen, so haben wir ein wunderbar wachsendes Programm erzeugt, wobei die Ursache f¨ ur das entstandene Speicherloch ausgesprochen schwer zu finden ist und den Entwicklern sicherlich einige lange N¨achte beschert. Vorsicht Falle: Wie wir gesehen haben, sind selbstdefinierte Typumwandlungs-Operatoren auf Pointer-Typen eine ziemlich heikle Angelegenheit, bei der sehr viel schief gehen kann. In unserem Fall wird durch die Definition einer expliziten Umwandlungsmethode, wie weiter oben vorgeschlagen wurde, auch nur ein Teil des Problems gel¨ ost, die Zeitbombe “Array” bleibt allerdings in einer oder der anderen Form bestehen. Ich kann also allen Lesern hier nur Folgendes raten: Das Mischen von Objekten mit dazu (teil-)kompatiblen Array-Datentypen ist prinzipiell zu vermeiden! Sollte man aus irgendwelchen Gr¨ unden auf eine solche Funktionalit¨at nicht verzichten k¨ onnen, so sollte man daf¨ ur eine eigene Zugriffsmethode implementieren (und ihre Gefahren gut dokumentieren :-)). Vorsicht Falle: Manchmal wird in gewissen Designs dem Compiler zu viel zugetraut, haben doch implizite Typumwandlungen durch den Compiler auch ihre Grenzen. Der Compiler weigert sich (zum Gl¨ uck), mehrere verschiedene Umwandlungen hintereinander auszuf¨ uhren. Was das bedeutet, kann man leicht am folgenden Beispiel erkennen: Nehmen wir an, wir h¨ atten eine Klasse IntStore, die eine Typumwandlung auf int32 besitzt: 1 2 3
class IntStore { // Constructor , D e s t r u c t o r , e t c . . .
4
virtual operator i n t 3 2 ( ) const ;
5 6
};
Weiters h¨ atten wir eine Klasse CommonStore, die eine Typumwandlung auf IntStore unterst¨ utzt: 1 2 3
c l a s s CommonStore { // Constructor , D e s t r u c t o r , e t c . . .
4
virtual operator I n t S t o r e ( ) const ;
5 6
};
Und nun schreiben wir Folgendes: CommonStore my_store; int32 my_var = 10 + my_store; Manche Leser m¨ ogen nun glauben, dass die Berechnung funktionieren sollte, denn der Compiler kann ja zuerst die Umwandlung von my_store auf ein Objekt vom Typ IntStore einsetzen. Da dieses dann eine Umwandlung auf int32 unterst¨ utzt, w¨ are der Fall erledigt. Der Compiler jedoch sieht das anders, denn er betrachtet nur die Umwandlungen, die von CommonStore selbst zur Verf¨ ugung gestellt werden. Da
12.3 Speicherverwaltung
363
¨ er dort keine passende Umwandlung findet, endet der Versuch der Ubersetzung des obigen Statements in einem Fehler. Es gibt auch zwei sehr gute Gr¨ unde f¨ ur dieses Verhalten: 1. W¨ urde der Compiler Umwandlungsketten suchen (und finden), so kann ¨ es leicht zu b¨ osen Uberraschungen bez¨ uglich Ambiguit¨aten oder auch bez¨ uglich Vertr¨ aglichkeit von Datentypen kommen, die sich eigentlich beabsichtigterweise nicht vertragen sollten. 2. M¨ usste der Compiler auf die Suche nach Umwandlungsketten gehen, dann w¨ urde ihn dies nicht wirklich performanter machen, denn daf¨ ur w¨are die Implementation von einigen sehr komplexen graphentheoretischen Algorithmen notwendig. Und die durch Umwandlungen aufgespannten Graphen k¨ onnen groß werden...
12.3 Speicherverwaltung Waren schon selbstdefinierte Typumwandlungs-Operatoren mit Vorsicht anzuwenden, so kommen wir jetzt zu einem Thema, das wirklich als for Experts only ausgewiesen werden muss: Overloading der new und delete Operatoren, um gewisse Teile der Speicherverwaltung selbst in die Hand zu nehmen. Im Prinzip kann man damit sehr sinnvolle Konstrukte implementieren, aber die M¨ oglichkeiten, ungeahnte Zeitbomben in die eigene Software einzubauen, sind auch nicht zu verachten! Man muss wirklich ganz genau wissen, was man tut, wenn man ein Overloading von new und delete sinnvoll und robust einsetzen will! Genug gewarnt – wenden wir uns also nun wirklich den Grundprinzipien einer selbstgebastelten Speicherverwaltung zu. 12.3.1 Einfaches new und delete Bisher haben wir damit gelebt, dass bei einem Aufruf von new einfach hinter den Kulissen gen¨ ugend Speicher reserviert wurde und wir keinen Einfluss darauf hatten, wo dieser Speicherblock hergenommen wird und wo er endg¨ ultig zu liegen kommt. In den allermeisten F¨allen ist das auch vollkommen ausreichend. Stellen wir uns aber nun z.B. vor, dass wir es mit einem System zu tun h¨atten, in dem gewisse Objekte in einem besonderen Speicherbereich liegen m¨ ussen, um eine saubere Funktionalit¨at zu gew¨ahrleisten. In embedded Systems kann es schon einmal vorkommen, dass man es mit zwei verschiedenen Arten von Speichern zu tun hat. Die eine Art von Speicher gestattet einen schnellen Zugriff, ist aber fl¨ uchtig. Die andere Art von Speicher ist langsam, daf¨ ur aber nicht fl¨ uchtig und gestattet daher das Speichern von Objekten, die auch nach dem “Ausschalten” des Systems erhalten bleiben. Es ginge jetzt
364
12. Operator Overloading
zu weit, solche Systeme einer genaueren Analyse zu unterziehen, eines ist allerdings offensichtlich: Objekte, auf die man oft zugreift, m¨ ussen im schnellen Speicher zu liegen kommen. Objekte, deren Zustand erhalten bleiben muss, m¨ ussen wohl oder u uchtigen, daf¨ ur aber langsamen Speicher zu ¨bel im nicht fl¨ liegen kommen. Daneben gibt es noch die typischen Hybridobjekte, auf die einerseits oft zugegriffen wird, die andererseits aber trotzdem nicht fl¨ uchtig sein d¨ urfen. Diese lassen wir im Augenblick außer Acht, werden aber sp¨ater noch einmal darauf zur¨ uckkommen. Was wollen wir also erreichen? Der Aufruf von new soll den Speicherblock f¨ ur das entsprechende Objekt automatisch entweder im schnellen oder im langsamen Teil des Speichers ablegen, je nachdem wie es die Natur des Objekts verlangt. Damit eine Klasse kundtun kann, in welchem Teil des Speichers sie zu liegen kommen will, schreiben wir zwei Basisklassen, von denen entsprechend abgeleitet werden muss: Eine Klasse FastAccessibleObject und eine Klasse NonVolatileObject. Je nachdem, von welcher Klasse abgeleitet wird, kommt ein Objekt bei seiner dynamischen Erzeugung im einen oder anderen Teil des Speichers zu liegen. Bevor das Ganze nun zu theoretisch wird, sehen wir uns das am besten gleich an einem ersten kleinen Beispiel an (first_new_delete_overloading_demo.cpp). In diesem Beispiel ist zur Demonstration eine Klasse MemoryProvider implementiert, die nur als Platzhalter f¨ ur eine “richtige” Speicherverwaltung dient, die in solchen Systemen gebraucht wird. Diese Klasse sieht folgendermaßen aus: 23 24 25 26 27 28 29
c l a s s MemoryProvider { private : MemoryProvider ( ) ; // f o r b i d i n s t a n t i a t i o n public : s t a t i c const u i n t 3 2 FAST = 0x01 ; s t a t i c const u i n t 3 2 NON VOLATILE = 0 x02 ;
30
s t a t i c void ∗ allocMemBlock ( u i n t 3 2 mem type , s i z e t s i z e ) throw( b a d a l l o c ) ; s t a t i c void freeMemBlock ( void ∗ b a s e p t r ) throw ( ) ;
31 32 33 34 35
};
Wie sich leicht erkennen l¨ asst, kann man u ¨ber diese Klasse die entsprechenden (uninitialisierten!) Speicherbl¨ ocke anfordern und nicht mehr ben¨otigte Speicherbl¨ ocke wieder freigeben lassen. Beim Anfordern gibt man den Typ des ben¨ otigten Blocks sowie seine Gr¨oße in Bytes an und bekommt einen entsprechenden Base-Pointer geliefert. Beim Freigeben u ¨bergibt man einfach den Base-Pointer. Die Implementation der Methoden liest sich so: 182 183 184 185 186
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ This i s j u s t a dummy implementation f o r demo purposes . . . ∗/ void ∗ MemoryProvider : : allocMemBlock ( u i n t 3 2 mem type , s i z e t s i z e ) throw( b a d a l l o c )
12.3 Speicherverwaltung
187
{ switch ( mem type ) { case FAST: case NON VOLATILE: return ( malloc ( s i z e ) ) ; } return ( 0 ) ;
188 189 190 191 192 193 194 195
365
}
196 197 198 199 200 201 202 203 204 205
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ This i s j u s t a dummy implementation f o r demo purposes . . . ∗/ void MemoryProvider : : freeMemBlock ( void ∗ b a s e p t r ) throw ( ) { i f ( base ptr ) free ( base ptr ) ; }
So einige Leser werden sich jetzt fragen, ob ich vielleicht zu lange in der Sonne war, weil ich hier doch tats¨ achlich die Funktionen malloc und free verwende. In Abschnitt 6.2.2 habe ich noch ausdr¨ ucklich erkl¨art, dass man von diesen Funktionen tunlichst die Finger lassen soll und stattdessen new und delete verwenden soll. Der Grund f¨ ur diesen vermeintlichen Regelverstoß ist einfach zu erkl¨aren: Wenn man schon new und delete implementiert, dann bewegt man sich ja bereits eine Ebene tiefer unten und muss einfach irgendwo her einen nicht initialisierten Speicherblock bekommen. Deshalb ist es in diesem Kontext (und ausschließlich in diesem! ) erlaubt und zumeist notwendig, mit malloc und free zu arbeiten. Folgende von C u ¨bernommene Funktionen werden angeboten, wenn man inkludiert: void *malloc(size_t size): Allokiert Speicher der Gr¨oße size und liefert den Base-Pointer darauf. Der Speicher, der allokiert wird, ist nicht initialisiert. void *calloc(size_t num,size_t size): Allokiert Speicher der Gr¨oße num * size, initialisiert diesen mit 0 und liefert den Base-Pointer darauf. void free(void *base_ptr): Gibt Speicher, der entweder u ¨ber Aufruf von malloc oder calloc allokiert wurde, wieder frei. ¨ void *realloc(void *base_ptr,size_t size): Andert die Gr¨oße des durch base_ptr referenzierten Speicherblocks auf size. Da nicht garantiert werden kann, dass der Block nicht z.B. im Zuge eines Vergr¨oßerns verschoben werden muss, wird der neue Base-Pointer des Blocks zur¨ uckgeliefert. Der neu hinzugekommene Speicher ist nicht initialisiert. Vorsicht Falle: Man sieht, es gibt also auch tats¨achlich ein realloc, obwohl ich es bisher konsequent unter den Tisch gekehrt habe. Allerdings wird auch auf dieser unteren Ebene dringendst von dessen Verwendung abgeraten! Das Problem bei realloc ist, dass der Block im Speicher verschoben
366
12. Operator Overloading
werden kann. Hat man nun z.B. einen Pointer, der auf ein Objekt zeigt, das in einem solchen Speicherblock liegt, dann zeigt nach einer Verschiebung des Blocks dieser Pointer ins Nirvana. Welche lustigen Folgen das haben kann, l¨asst sich leicht vorstellen. Vorsicht Falle: Es wurde bereits implizit erw¨ahnt, aber weil es derartig gef¨ahrlich ist, m¨ ochte ich es hier noch einmal explizit anf¨ uhren: • Die Funktion malloc allokiert nur Speicher. Sie weiß nichts von Objekten oder sonstigen C++ Features. Es wird also bei Verwendung von malloc niemals ein Konstruktor aufgerufen. • Die Funktion free gibt nur Speicher frei. Auch sie weiß nichts von Objekten. Es wird also bei Verwendung von free niemals ein Destruktor aufgerufen. Das Schlimmste, was man machen kann (und ich habe so etwas bereits in mehr als einem C++ Programm gesehen!), ist also Folgendes: Man legt ein Objekt ordnungsgem¨ aß mit new an. Anstatt allerdings das Objekt dann mit delete wieder freizugeben, ruft man free mit dem Base-Pointer des Objekts auf. Dadurch wird kein Destruktor aufgerufen und was das bedeutet, kann man sich ausmalen! Vorsicht Falle: Um den Blick nicht vom Wesentlichen abzulenken, habe ich im Beispiel den return-Value von malloc nicht u uft. Die Funktion ¨berpr¨ malloc ist allerdings gleich definiert wie in C: Wenn der Speicher ausgeht, dann retourniert sie einen 0-Pointer! Diese Funktion weiß nichts von Exceptions und wirft dementsprechend selbstt¨atig keine bad_alloc Exception, wenn nicht mehr genug Speicher da ist. Aus diesem Grund muss bei einer ernsthaften Implementation von new immer der return-Value von malloc abgefragt und entsprechend reagiert werden. Allerdings ist dies allein bei einer ernsthaften Implementation auch nicht genug! Per Konvention darf man nicht blindlings eine bad_alloc Exception werfen, denn es gibt da noch eine weitere Konvention in C++, die man zu beachten hat. Wie man nun korrekt nach Standard die Behandlung von Speicherknappheit u uhrlich in Abschnitt Verhalten bei ¨bernimmt, wird ausf¨ “Ausgehen” des Speichers diskutiert. Der Vollst¨ andigkeit halber m¨ ochte ich an dieser Stelle auch noch erw¨ahnen, dass bei Inkludieren von auch noch die aus C bekannten Funktionen memcpy, memmove, memchr, memcmp und memset zur Verf¨ ugung stehen. Und nat¨ urlich muss ich auch hier gleich vor deren unsachgem¨aßen Verwendung dringendst warnen. Nach diesem kurzen aber notwendigen Ausflug in die Welt der Low-Level Speicherverwaltung wird es Zeit zum eigentlichen Thema dieses Kapitels
12.3 Speicherverwaltung
367
zur¨ uckzukehren: Overloading von new und delete. Sehen wir uns dazu die Deklaration der Klasse FastAccessibleObject an: 46 47 48 49 50 51 52
class FastAccessibleObject { public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) ; void operator delete ( void ∗ b a s e p t r ) throw ( ) ;
53
FastAccessibleObject () {}
54 55
virtual ˜ F a s t A c c e s s i b l e O b j e c t ( ) { }
56 57
};
In den Zeilen 49–50 sehen wir, wie ein new Operator definiert ist: Es wird ein Parameter u oße des ben¨otigten Speicherblocks in Bytes ¨bergeben, der die Gr¨ angibt. Der erwartete return-Value ist dann der Base-Pointer des allokierten Speicherblocks. Im Falle, dass etwas schief geht, muss eine bad_alloc Exception geworfen werden. In den Zeilen 51–52 sehen wir, wie ein delete Operator definiert ist: Es wird einfach der Base-Pointer des freizugebenden Speicherblocks u ¨bergeben. Die Implementation dieser beiden Operatoren liest sich dann folgendermaßen: 138 139 140 141 142 143 144 145 146 147 148
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ F a s t A c c e s s i b l e O b j e c t : : operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new o f F a s t A c c e s s i b l e O b j e c t c a l l e d with s i z e ” << s i z e << e nd l ; return ( MemoryProvider : : allocMemBlock ( MemoryProvider : : FAST, s i z e ) ) ; }
149 150 151 152 153 154 155 156 157 158
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void F a s t A c c e s s i b l e O b j e c t : : operator delete ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e o f F a s t A c c e s s i b l e O b j e c t c a l l e d ” << e n d l ; MemoryProvider : : freeMemBlock ( b a s e p t r ) ; }
Man sieht hier, dass man sich bei Implementation der Operatoren nur um die Verwaltung der Low-Level Speicherbl¨ocke k¨ ummern muss. Alle anderen Aktionen, wie Konstruktor- und Destruktoraufrufe, werden vom Compiler u ¨bernommen. Um die Aufrufe nachverfolgen zu k¨onnen wird nach gewohnter Manier von jeder Methode ein entsprechender Output generiert. Die Klasse NonVolatileObject ist erwartungsgem¨aß genau gleich implementiert. Deshalb erspare ich mir hier das Abdrucken derselben.
368
12. Operator Overloading
Eine Klasse, die ein FastAccessibleObject repr¨asentiert, wird entsprechend von dieser Basis abgeleitet und kommt damit automatisch im schnellen Teil des Speichers zu liegen. Als Beispiel hierf¨ ur dient die Klasse MyFastObject: 89 90 91 92 93 94 95 96 97 98 99 100
c l a s s MyFastObject : public F a s t A c c e s s i b l e O b j e c t { protected : i n t 3 2 some data ; uint32 some other data ; public : MyFastObject ( ) { cout << ” c o n s t r u c t o r o f MyFastObject” << e n d l ; some data = 0 ; some other data = 0; }
101
virtual ˜ MyFastObject ( ) { cout << ” d e s t r u c t o r o f MyFastObject” << e nd l ; }
102 103 104 105 106 107
};
Um zu sehen, was mit unseren speziellen Objekten genau passiert, werfen wir kurz einen Blick auf unsere main Funktion: 210 211 212 213 214 215 216
int main ( int a r g c , char ∗ argv [ ] ) { cout << ” dynamically c r e a t i n g o b j e c t s . . . ” << e n d l ; MyFastObject ∗ f a s t o b j = new MyFastObject ; MyNonVolatileObject ∗ n o n v o l a t i l e o b j = new MyNonVolatileObject ; delete f a s t o b j ; delete n o n v o l a t i l e o b j ;
217
cout << ” c r e a t i n g with e x p l i c i t c o n s t r u c t o r c a l l s . . . ” << e n d l ; MyFastObject e x p l f a s t = MyFastObject ( ) ; MyNonVolatileObject e x p l n o n v o l a t i l e = MyNonVolatileObject ( ) ;
218 219 220 221
cout << ” s t a t i c a l l y a l l o c a t e d o b j e c t s . . . ” << e nd l ; MyFastObject s t a t i c f a s t o b j ; MyNonVolatileObject s t a t i c n o n v o l a t i l e o b j ;
222 223 224 225
cout << ” l e a v i n g main” << e nd l ; return ( 0 ) ;
226 227 228
}
Der Output, der von diesem Programm erzeugt wird, verr¨at Einiges u ¨ber die Natur der Operatoren und u ¨ber die Abl¨aufe, die hinter den Kulissen passieren: 1 2 3 4 5 6 7
dynamically c r e a t i n g o b j e c t s . . . new o f F a s t A c c e s s i b l e O b j e c t c a l l e d with s i z e 1 2 c o n s t r u c t o r o f MyFastObject new o f NonVolatileObject c a l l e d with s i z e 1 6 c o n s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyFastObject delete of FastAccessibleObject c a l l e d
12.3 Speicherverwaltung
8 9 10 11 12 13 14 15 16 17 18 19 20
369
d e s t r u c t o r o f MyNonVolatileObject d e l e t e o f NonVolatileObject c a l l e d c r e a t i n g with e x p l i c i t c o n s t r u c t o r c a l l s . . . c o n s t r u c t o r o f MyFastObject c o n s t r u c t o r o f MyNonVolatileObject s t a t i c a l l y allocated objects . . . c o n s t r u c t o r o f MyFastObject c o n s t r u c t o r o f MyNonVolatileObject l e a v i n g main d e s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyFastObject d e s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyFastObject
Die Zeilen 1–9 repr¨ asentieren den Output, der durch die Zeilen 212–216 unseres Programms erzeugt wird. Im Falle des dynamischen Erzeugens eines neuen Objekts, wie es z.B. in Zeile 213 unseres Programms gemacht wird, passiert Folgendes: 1. Es wird intern ausgerechnet, wie viel Speicherplatz das Objekt ben¨otigt. Darauf haben wir in unserem Programm u ¨berhaupt keinen Einfluss, denn dies wird einzig und allein vom Compiler erledigt. 2. Danach wird unser selbstdefinierter new Operator aufgerufen und ihm die ben¨ otigte Gr¨ oße mitgeteilt (siehe Zeile 2 des Outputs). Im Operator selbst muss der Speicherblock bereitgestellt werden und ein Base-Pointer darauf zur¨ uckgeliefert werden. 3. Nachdem der Speicher zur Verf¨ ugung steht, wird intern der entsprechende Konstruktor aufgerufen (siehe Zeile 3 des Outputs). Auch darauf haben wir als Entwickler nat¨ urlich keinen Einfluss. Wird ein Objekt mittels delete in die ewigen Jagdgr¨ unde geschickt, dann passiert dies folgendermaßen: 1. Zuerst wird intern der entsprechende Destruktor aufgerufen. Darauf haben wir nat¨ urlich keinen Einfluss. 2. Danach wird der nun nicht mehr ben¨otigte Speicherblock zur Freigabe an unseren selbstdefinierten delete Operator u ur das ¨bergeben, der f¨ Aufr¨ aumen zu sorgen hat. Man sieht also ganz deutlich, dass man durch das Overloading von new und delete einzig und allein Einfluss auf die Beschaffung und Freigabe von Speicher nehmen kann. Es ist nat¨ urlich in keinster Weise festgelegt, woher man den Speicher beim Anfordern bekommt bzw. wie man ihn wieder freigibt. Das einzig Wichtige ist, dass man beim Anfordern einen entsprechenden Block liefern kann und den Hinweis zur Freigabe korrekt behandelt. Es ist auch nicht festgelegt, dass der von new gelieferte Block genau die angeforderte Gr¨ oße haben muss, wichtig ist nur, dass er mindestens die geforderte Speicherkapazit¨ at besitzt. Genau diese Definition wird von erfahrenen Entwicklern so ausgen¨ utzt, dass sie nicht jeden Block einzeln mit malloc anfordern und dann mit free wieder dem System u ¨berantworten. Stattdessen wird f¨ ur Objekte, die sehr oft erzeugt und wieder zerst¨ort werden, ein
370
12. Operator Overloading
Pool von vorallokierten Bl¨ ocken verwaltet (bzw. auch nur ein einziger großer Block, in den viele kleinere hineinpassen). Einzelne Bl¨ocke aus diesem Pool werden dann nur als benutzt oder unbenutzt markiert. Sinnvoll und richtig angewandt bringt diese Art der Verwaltung in gewissen F¨allen enorme Performancesteigerungen der Software mit sich. Noch eine Eigenschaft der beiden Operatoren wird hier deutlich: beide sind implizit static, obwohl sie explizit nicht so deklariert wurden. Das ist auch absolut lebensnotwendig, denn new beschafft erst den Speicher f¨ ur eine Instanz, kann also niemals selbst zu dieser Instanz geh¨oren. Zum Zeitpunkt des Aufrufs existiert diese ja noch gar nicht. Ebenso verh¨alt es sich mit delete, denn dieses wird ja erst nach dem Abarbeiten des Destruktors aufgerufen, die Instanz ist also nicht mehr wirklich lebendig. Welchen Einfluss haben unsere selbstdefinierten new und delete Operatoren nun auf das Verhalten eines nicht dynamisch angelegten Objekts? Die Zeilen 10–20 des Outputs, die von den Zeilen 218–227 des Programms her r¨ uhren, zeigen es ganz deutlich: u ¨berhaupt keinen! Dies ist auch vollkommen verst¨ andlich, wenn man sich einmal vor Augen f¨ uhrt, welche verschiedenen Speicherbereiche innerhalb eines laufenden Programms existieren und wie sie verwaltet werden: Static Memory: Dies ist der Bereich in dem Variablen mit globaler Lifetime abgelegt werden. F¨ ur diesen Bereich ist der Linker zust¨andig. Automatic Memory: Dies ist der Bereich in dem die sogenannten autoVariablen, also lokale Variablen, abgelegt werden. Jeder Eintritt in eine Methode, Funktion oder einfach in einen Block bekommt einen eigenen Bereich im automatic Memory. F¨ ur diesen Bereich ist der Compiler zust¨ andig und der Bereich wird als Stack verwaltet und deshalb auch oft einfach als Stack bezeichnet. Anm.: Es gibt in C++ sogar das (redundante!) Keyword auto, wenn jemand Lust hat, es explizit zu schreiben. Das Keyword hat allerdings u ¨berhaupt keinen Einfluss auf die Verwaltung, sondern ist nur als EyeCatcher f¨ ur die Entwickler gedacht. Free Memory: Dies ist der (ausschließlich) von Entwicklern selbst verwaltete Speicherbereich eines Programms, der auch oft als Heap bezeichnet wird. Dieser Teil des Memories wird f¨ ur die mittels new und delete explizit dynamisch verwalteten Daten herangezogen. Nachdem wir weder dem Linker noch dem Compiler ins Handwerk pfuschen d¨ urfen, denn dies h¨ atte fatale Folgen, haben wir also sowohl auf das static als auch auf das automatic Memory keinen Einfluss. Unsere new und delete Operatoren kommen ausschließlich dann zum Tragen, wenn wir Objekte am Heap ablegen, also dynamische Speicherverwaltung verwenden. Dass dies aber z.B. f¨ ur unser Beispiel keine Einschr¨ankung bedeutet, l¨asst sich auch schnell erkennen: Will man, dass ein Objekt im nicht fl¨ uchtigen Teil des Speichers zu liegen kommt, dann will man es ja offensichtlich u ¨ber
12.3 Speicherverwaltung
371
l¨angere Zeit speichern. Das ist bei lokalen Variablen sowieso nicht der Fall, also betrifft uns dies nicht. Sehr wohl w¨ urde es uns betreffen, wenn wir einfach eine Variable mit globaler Lifetime anlegen wollten und diese im nicht fl¨ uchtigen Speicher zu liegen kommen sollte. In diesem Fall ist es dann so, dass entweder die Compiler/Linker Kombination f¨ ur unser System sowieso das static Memory im nicht fl¨ uchtigen Teil verewigt oder wir eben einfach per Konvention nur mittels dynamischer Memory-Verwaltung arbeiten d¨ urfen. 12.3.2 Array new und delete Es ist bereits bekannt, dass es verschiedene new und delete Operatoren gibt, je nachdem, ob man ein einzelnes Objekt oder ein Array von Objekten anlegen will. Genau diese Unterscheidung trifft uns auch beim Overloading dieser Operatoren, denn f¨ ur Arrays gibt es tats¨achlich explizit die alternativen Operatoren new[] und delete[]. Man sieht also, dass der Aufruf von delete[] statt delete f¨ ur Arrays nicht nur reine Kosmetik ist, sondern dass sich dahinter sehr wohl ein eigener, separater Operator mit besonderen Eigenschaften verbirgt. Erg¨ anzen wir also unser Beispielprogramm um die entsprechenden Array-Operatoren um zu sehen, wie sich diese verhalten (second_new_delete_overloading_demo.cpp). Die ge¨anderte Klasse FastAccessibleObject sieht dann folgendermaßen aus: 48 49 50 51 52
class FastAccessibleObject { public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) ;
53
void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) ;
54 55 56
void operator delete ( void ∗ b a s e p t r ) throw ( ) ;
57 58 59
void operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) ;
60 61 62
FastAccessibleObject () {}
63 64
virtual ˜ F a s t A c c e s s i b l e O b j e c t ( ) { }
65 66
};
Die Implementation der beiden dazugekommenen Operatoren birgt nichts wirklich Berauschendes, wie sich leicht erkennen l¨asst: 169 170 171 172 173 174 175 176
void ∗ F a s t A c c e s s i b l e O b j e c t : : operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new [ ] o f F a s t A c c e s s i b l e O b j e c t c a l l e d with s i z e ” << s i z e << e nd l ; return ( MemoryProvider : : allocMemBlock ( MemoryProvider : : FAST ARRAY, s i z e ) ) ; }
372
191 192 193 194 195 196
12. Operator Overloading
void F a s t A c c e s s i b l e O b j e c t : : operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e [ ] o f F a s t A c c e s s i b l e O b j e c t c a l l e d ” << e nd l ; MemoryProvider : : freeMemBlock ( b a s e p t r ) ; }
Erg¨anzend sei noch gesagt, dass in die Klasse MemoryProvider konsequenterweise die zus¨ atzlichen Konstanten FAST_ARRAY und NON_VOLATILE_ARRAY aufgenommen wurden. Diese Maßnahme soll nur widerspiegeln, dass auch z.B. die Low-Level Speicherverwaltung eigene Bereiche f¨ ur Einzelvariablen und Arrays von Variablen verwalten k¨onnte. Sieht man sich an, dass auch der new[] Operator wieder nur eine Gr¨oße des gew¨ unschten Speicherblocks als Parameter u ¨bergeben bekommt und nicht die Anzahl der angeforderten Elemente, erkennt man, dass man die Verwaltung der Interna eines Arrays dem Compiler u ¨berlassen muss und selbst keinen Einfluss darauf hat. Dies ist auch logisch, denn ansonsten k¨ onnte man die gesamte Pointerarithmetik wildest durcheinander bringen. Das ist garantiert nicht im Sinne des Erfinders. Trotzdem muss man new[] als eigenen Operator definieren und es wird bei Fehlen desselben niemals new als Ersatz herangezogen. Wie bereits erw¨ahnt: Man k¨ onnte ja auch im Low-Level Bereich Arrays auf eine andere Art allokieren wollen als Einzelobjekte. Deshalb ist es nur konsequent, diese Trennung auch wirklich einzuhalten. Die main Funktion des Programms beinhaltet wieder den Testcode: 272 273 274 275 276 277 278
int main ( int a r g c , char ∗ argv [ ] ) { cout << ” c r e a t i n g i n d i v i d u a l o b j e c t s . . . ” << e n d l ; MyFastObject ∗ f a s t o b j = new MyFastObject ; MyNonVolatileObject ∗ n o n v o l a t i l e o b j = new MyNonVolatileObject ; delete f a s t o b j ; delete n o n v o l a t i l e o b j ;
279
cout << ” c r e a t i n g a r r a y s o f o b j e c t s . . . ” << e nd l ; f a s t o b j = new MyFastObject [ 3 ] ; n o n v o l a t i l e o b j = new MyNonVolatileObject [ 3 ] ; delete [ ] f a s t o b j ; delete [ ] n o n v o l a t i l e o b j ;
280 281 282 283 284 285
cout << ” ∗∗∗∗∗ and now the c a t a s t r o p h e ! ! ! ! ! ! ∗ ∗ ∗ ∗ ∗ ” << e nd l ; f a s t o b j = new MyFastObject [ 3 ] ; n o n v o l a t i l e o b j = new MyNonVolatileObject [ 3 ] ; // i n the f o l l o w i n g two l i n e s the i n d i v i d u a l d e l e t e // o p e r a t o r s a r e c a l l e d f o r a r r a y s . . . with c a t a s t r o p h i c // r e s u l t s ! ! ! ! ! ! ! ! delete f a s t o b j ; delete n o n v o l a t i l e o b j ;
286 287 288 289 290 291 292 293 294
return ( 0 ) ;
295 296
}
Daraus resultiert dann der Output des Programms: 1 2
creating individual objects . . . new o f F a s t A c c e s s i b l e O b j e c t c a l l e d with s i z e 1 2
12.3 Speicherverwaltung
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
373
c o n s t r u c t o r o f MyFastObject new o f NonVolatileObject c a l l e d with s i z e 1 6 c o n s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyFastObject delete of FastAccessibleObject c a l l e d d e s t r u c t o r o f MyNonVolatileObject d e l e t e o f NonVolatileObject c a l l e d creating arrays of objects . . . new [ ] o f F a s t A c c e s s i b l e O b j e c t c a l l e d with s i z e 4 0 c o n s t r u c t o r o f MyFastObject c o n s t r u c t o r o f MyFastObject c o n s t r u c t o r o f MyFastObject new [ ] o f NonVolatileObject c a l l e d with s i z e 5 2 c o n s t r u c t o r o f MyNonVolatileObject c o n s t r u c t o r o f MyNonVolatileObject c o n s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyFastObject d e s t r u c t o r o f MyFastObject d e s t r u c t o r o f MyFastObject delete [ ] of FastAccessibleObject called d e s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyNonVolatileObject d e l e t e [ ] o f NonVolatileObject c a l l e d ∗∗∗∗∗ and now the c a t a s t r o p h e ! ! ! ! ! ! ∗ ∗ ∗ ∗ ∗ new [ ] o f F a s t A c c e s s i b l e O b j e c t c a l l e d with s i z e 4 0 c o n s t r u c t o r o f MyFastObject c o n s t r u c t o r o f MyFastObject c o n s t r u c t o r o f MyFastObject new [ ] o f NonVolatileObject c a l l e d with s i z e 5 2 c o n s t r u c t o r o f MyNonVolatileObject c o n s t r u c t o r o f MyNonVolatileObject c o n s t r u c t o r o f MyNonVolatileObject d e s t r u c t o r o f MyFastObject delete of FastAccessibleObject c a l l e d d e s t r u c t o r o f MyNonVolatileObject d e l e t e o f NonVolatileObject c a l l e d
Die einzige wirkliche Besonderheit beim Anlegen von Arrays, die im Output augenf¨allig wird, ist die angeforderte Gr¨oße des Speicherblocks: In unserem Fall ist der angeforderte Speicherblock f¨ ur ein Array n¨amlich in beiden F¨allen um 4 Bytes gr¨ oßer als erwartet! Man sieht z.B., dass f¨ ur ein MyFastObject 12 Bytes angefordert werden, f¨ ur 3 Objekte dieses Typs allerdings mitnichten 36 Bytes, sondern 40! Ganz gleich verh¨alt es sich bei MyNonVolatileObject. Das ist darauf zur¨ uckzuf¨ uhren, dass im angeforderten Block auch administrative Daten abgelegt werden, die allerdings f¨ ur die verschiedenen Systeme weder in ihrer Gr¨ oße noch in ihrem Inhalt standardisiert und daher den Entwicklern nicht explizit zug¨ anglich sind. Bewusst wurde in das Programm in den Zeilen 286–293 zu Demonstrationszwecken auch eine absolute Katastrophe eingebaut, die furchtbare Folgen hat. Es werden dort n¨ amlich Arrays mittels new[] angelegt. Diese werden allerdings schlimmerweise nicht korrekt mit delete[], sondern nur mit delete wieder freigegeben. Das wahre Ausmaß dieses Harakiri-Konstrukts wird deutlich, wenn man sich die Zeilen 27–39 des Outputs ansieht: Es werden die beiden Arrays ordnungsgem¨ aß angelegt, allerdings passiert beim Freigeben Grauenvolles: Erstens werden nicht alle Objekte ordnungsgem¨aß destruiert, sondern immer nur eines von dreien! Zweitens wird der falsche Operator
374
12. Operator Overloading
aufgerufen, der sich z.B. auf einen anderen Speicherbereich beziehen k¨onnte – mit entsprechend fatalen Folgen. Wie lang man suchen kann, bis man einen solchen Fehler in einem verr¨ uckt gewordenen Programm findet, kann man sich leicht ausmalen. 12.3.3 Placement Operator new Neben den beiden new Operatoren f¨ ur individuelle Objekte und f¨ ur Arrays gibt es noch einen dritten, den sogenannten Placement Operator new(). Bei diesem u atzlich als zweiten Parameter einen Poin¨bergibt man noch zus¨ ter, der einen Hinweis darauf gibt, wo der Speicher allokiert werden soll. ¨ Nehmen wir am besten eine kleine Anderung an unserem Beispiel vor, um zu sehen, was dieser Operator bietet und wie man mit ihm umgeht ¨ (third_new_delete_overloading_demo.cpp). Die erste Anderung betrifft den MemoryProvider: Anstatt nur statische Methoden zur Verf¨ ugung zu stellen, muss man nun eine Instanz erzeugen. Die Verwendung ist so gedacht, dass man f¨ ur jeden spezifischen Teil des Memories eine eigene Instanz hat. Bei uns also eine f¨ ur den schnellen und eine f¨ ur den nicht fl¨ uchtigen Teil des Memories. Die umgeschriebene Klasse liest sich dann so: 24 25 26 27 28 29 30 31
c l a s s MemoryProvider { public : // u s u a l l y the c o n s t r u c t o r has a parameter f o r the s t a r t // a d d r e s s o f the managed p o o l , the s i z e , e t c . . . but f o r // demo purposes I don ’ t want to w r i t e a ” r e a l ” memory // management c l a s s . So i t ’ s j u s t a dummy . . . MemoryProvider ( ) { } ;
32
void ∗ allocMemBlock ( s i z e t s i z e ) throw( b a d a l l o c ) ;
33 34 35
void freeMemBlock ( void ∗ b a s e p t r ) throw ( ) ;
36 37 38
};
Die Implementationen der beiden Methoden zum Allokieren und Freigeben sind in unserem Demoprogramm wieder reine Dummies, die mit malloc und free geschrieben sind. Deshalb erspare ich mir das Abdrucken derselben an dieser Stelle. Anstatt zwei verschiedene Klassen, eine f¨ ur das schnelle und eine f¨ ur das langsame Memory zu haben, brauchen wir hier nur noch eine einzige Klasse, denn beim Aufruf von new wird jetzt ein Pointer auf die gew¨ unschte Instanz des Memory Managers mitgegeben. Also haben wir es hier mit der Klasse SpecialMemoryManagedObject zu tun: 49 50 51 52
c l a s s SpecialMemoryManagedObject { private : // f o r b i d standard i n s t a n t i a t i o n
12.3 Speicherverwaltung
375
void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) { return ( ( void ∗ ) 0 ) ; }
53 54 55 56 57 58 59 60 61
// f o r b i d a r r a y i n s t a n t i a t i o n void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { return ( ( void ∗ ) 0 ) ; } public : void ∗ operator new( s i z e t s i z e , MemoryProvider ∗ mem provider ) throw( b a d a l l o c ) ;
62
s t a t i c void d e s t r o y ( SpecialMemoryManagedObject ∗ obj , MemoryProvider ∗ mem provider ) throw ( ) ;
63 64 65 66
SpecialMemoryManagedObject ( ) { }
67 68
virtual ˜ SpecialMemoryManagedObject ( ) { }
69 70
};
In dieser Klasse wurden bewusst die Standard-Operatoren new und new[] als private deklariert, um ihre Verwendung zu verhindern. Daf¨ ur gibt es jetzt den speziellen Placement Operator new(), dessen Deklaration man in den Zeilen 60–61 sieht. Wenn man schon mit Placement arbeitet, so sollte man (je nach Anwendung) auch das Standard delete nicht mehr verwenden. Deshalb wurde eine static Methode destroy eingef¨ uhrt, die diese Aufgabe u bernimmt. Diese sieht man in den Zeilen 63–65. ¨ ¨ Die Implementation des Placement Operators birgt auch keine Uberraschungen. Es wird einfach der Speicher vom mit¨ ubergebenen Provider angefordert, wie man in der Folge sieht: 105 106 107 108 109 110 111 112
void ∗ SpecialMemoryManagedObject : : operator new( s i z e t s i z e , MemoryProvider ∗ mem provider ) throw( b a d a l l o c ) { cout << ”new o f SpecialMemoryManagedObject c a l l e d , s i z e : ” << s i z e << e nd l << ” p r o v i d e r : ” << (void ∗) mem provider << e n d l ; return ( mem provider−>allocMemBlock ( s i z e ) ) ; }
Zeile 110 verdient noch einen kurzen Kommentar: Es ist hier beabsichtigt, einfach die Adresse von mem_provider auszugeben. Das wird auch von cout problemlos gemacht. Um allerdings sicherzustellen, dass wir nicht mit irgendeinem selbstdefinierten Typumwandlungs-Operator in Konflikt kommen k¨onnen und der Compiler sich u ¨ber Ambiguit¨aten beschwert, wurde hier ein expliziter Cast auf void * eingesetzt (in der Hoffnung, dass niemand diese besondere Typumwandlung im Rahmen eines Overloadings missbraucht und uns einen Strich durch die Rechnung macht :-)). Viel interessanter ist da schon die static Methode destroy, die f¨ ur das Freigeben eines solcherart verwalteten Objekts zust¨andig ist: 117 118 119
void SpecialMemoryManagedObject : : d e s t r o y ( SpecialMemoryManagedObject ∗ obj , MemoryProvider ∗ mem provider )
376
12. Operator Overloading
throw ( )
120 121
{ cout << ” d e s t r o y o f SpecialMemoryManagedObject c a l l e d , p r o v i d e r : ” << ( void ∗) mem provider << e nd l ; obj−>˜SpecialMemoryManagedObject ( ) ; mem provider−>freeMemBlock ( obj ) ;
122 123 124 125 126 127
}
Dadurch, dass bei einem solchen Objekt kein delete aufgerufen wird, wird auch beim Zerst¨ oren der Destruktor nicht automatisch aufgerufen. Also m¨ ussen wir das selbst in die Hand nehmen. Genau das passiert in Zeile 125. Um keine Missverst¨ andnisse aufkommen zu lassen: Destruktoren werden vom Compiler so erzeugt, dass garantiert wird, dass der Aufruf des Destruktors einer abgeleiteten Klasse nat¨ urlich auch alle Destruktoren der Basisklassen in entsprechend korrekter Reihenfolge bewirkt. Deshalb brauchen wir uns um dieses Detail hier nicht zu k¨ ummern. Wir m¨ ussen nur daf¨ ur sorgen, dass die Aufrufkette der Destruktoren u ¨berhaupt angestoßen wird und das erledigen wir mit diesem einen Aufruf. Hier sieht man vor allem einmal explizit, warum Destruktoren unbedingt virtual sein m¨ ussen: W¨are das nicht der Fall, dann w¨ urde nur der Destruktor der Klasse SpecialMemoryManagedObject aufgerufen werden, nicht aber der Destruktor einer davon abgeleiteten Klasse. Nach dem Aufruf des Destruktors triggern wir nach altbew¨ahrter Manier wieder das Freigeben des Speicherblocks. Vorsicht Falle: Explizite Aufrufe des Destruktors gelten prinzipiell als verp¨ont und sind nur in ausgesprochenen Einzelf¨allen, so wie hier, ein not¨ wendiges Ubel. In jedem Fall ist daf¨ ur Sorge zu tragen, dass Elemente, auf denen bereits der Destruktor aufgerufen wurde, garantiert danach aus dem Speicher entfernt werden! Man kann sich leicht vorstellen, was passiert, wenn zwar der Destruktor aufgerufen wird, aber danach aus irgendwelchen Gr¨ unden die Operation des Freigebens nicht beendet wird. Dann gammeln halbtote Objekte im Speicher herum, die sich garantiert nicht mehr sehr freundlich verhalten, wenn man versucht, mit ihnen weiterzuarbeiten :-). Jede Klasse, die auf diese Art speziell im Speicher platziert werden soll, muss entsprechend von dieser Basisklasse abgeleitet werden. Dies k¨onnte z.B. folgendermaßen aussehen: 81 82 83 84 85 86 87 88 89 90 91 92
c l a s s MyMemManagedObject : public SpecialMemoryManagedObject { protected : i n t 3 2 some data ; uint32 some other data ; public : MyMemManagedObject ( ) { cout << ” c o n s t r u c t o r o f MyMemManagedObject” << e n d l ; some data = 0 ; some other data = 0; }
12.3 Speicherverwaltung
377
93
virtual ˜ MyMemManagedObject ( ) { cout << ” d e s t r u c t o r o f MyMemManagedObject” << e n d l ; }
94 95 96 97 98 99
};
Um das ganze Spielchen zu testen, gibt es in unserer main Funktion wieder den entsprechenden Code: 151 152 153 154
int main ( int a r g c , char ∗ argv [ ] ) { MemoryProvider ∗ f a s t p o o l = new MemoryProvider ( ) ; MemoryProvider ∗ n o n v o l a t i l e p o o l = new MemoryProvider ( ) ;
155
cout << ” c r e a t i n g o b j e c t i n f a s t memory . . . ” << e nd l ; MyMemManagedObject ∗ f a s t o b j = new( f a s t p o o l ) MyMemManagedObject ;
156 157 158 159
cout << ” c r e a t i n g o b j e c t i n non v o l a t i l e memory . . . ” << e nd l ; MyMemManagedObject ∗ n o n v o l a t i l e o b j = new( n o n v o l a t i l e p o o l ) MyMemManagedObject ;
160 161 162 163
cout << ” d e s t r o y i n g o b j e c t i n f a s t memory . . . ” << e nd l ; SpecialMemoryManagedObject : : d e s t r o y ( f a s t o b j , f a s t p o o l ) ;
164 165 166
cout << ” d e s t r o y i n g o b j e c t i n non v o l a t i l e memory . . . ” << e nd l ; SpecialMemoryManagedObject : : d e s t r o y ( non volatile obj , non volatile pool );
167 168 169 170
return ( 0 ) ;
171 172
}
Man sieht, dass in den Zeilen 153–154 zwei verschiedene Memory Pools erzeugt werden (o.k., es sind nur Dummies, aber hier geht es um das Prinzip). Will man ein Objekt im schnellen Teil des Speichers anlegen, dann u ¨bergibt man dem new() Operator einen Pointer auf den fast_pool, wie in den Zeilen 157–158 zu sehen ist. Analog funktioniert nat¨ urlich das Placement im nicht fl¨ uchtigen Teil des Speichers, wie man in den Zeilen 161–162 erkennt. Das Freigeben von Objekten erfolgt u ¨ber den Aufruf von destroy, wie man in den Zeilen 165 bzw. 168–169 sieht. Der Output, den das Programm liefert, liest sich dann folgendermaßen und zeigt, dass unser Placement funktioniert wie geplant: c r e a t i n g o b j e c t i n f a s t memory . . . new o f SpecialMemoryManagedObject c a l l e d , s i z e : 1 2 p r o v i d e r : 0 x804a688 c o n s t r u c t o r o f MyMemManagedObject c r e a t i n g o b j e c t i n non v o l a t i l e memory . . . new o f SpecialMemoryManagedObject c a l l e d , s i z e : 1 2 p r o v i d e r : 0 x804a698 c o n s t r u c t o r o f MyMemManagedObject d e s t r o y i n g o b j e c t i n f a s t memory . . . d e s t r o y o f SpecialMemoryManagedObject c a l l e d , p r o v i d e r : 0 x804a688 d e s t r u c t o r o f MyMemManagedObject d e s t r o y i n g o b j e c t i n non v o l a t i l e memory . . . d e s t r o y o f SpecialMemoryManagedObject c a l l e d , p r o v i d e r : 0 x804a698 d e s t r u c t o r o f MyMemManagedObject
378
12. Operator Overloading
Was sind nun die Vor- und Nachteile dieser Art der Speicherverwaltung mittels Placement Operator? • Der große Vorteil liegt darin, dass man nicht zur Compiletime durch entsprechende Ableitung festlegen muss, wo ein Objekt genau zu liegen kommt. Stattdessen kann man dies zur Laufzeit bestimmen. Vor allem f¨ ur die Implementation von bestimmten Container-Objekten ist dies ein essentielles Feature. • Der erste Nachteil ist das Fehlen eines speziellen Placement Operators f¨ ur Arrays. Im Prinzip kann man diesen Nachteil bis zu einem gewissen Grad umgehen, denn man kann ja einfach “gen¨ ugend” Platz f¨ ur ein Array anfordern und den Rest irgendwie intern verwalten. Allerdings ist das dann wirklich nicht mehr sehr sauber und v.a. f¨ ur Entwickler sehr ungewohnt, denn sie k¨ onnen nun nicht mehr einfach ein Array anlegen, sondern m¨ ussen explizit zur Selbsthilfe greifen. Deshalb w¨ urde ich wirklich empfehlen, die Placement-Konstruktion niemals zu verwenden, wenn man Arrays brauchen k¨ onnte, um keine Zeitbomben zu basteln. • Der zweite Nachteil ist das Fehlen eines speziellen Placement delete() Operators als Gegenst¨ uck zum Placement new() Operator. Dadurch ist man n¨ amlich gezwungen, sich selbst zu merken, wo ein Objekt angelegt wurde, um dies auch durch einen entsprechenden destroy Aufruf wieder ordnungsgem¨ aß freizugeben. Was passieren kann, wenn man dem falschen aus einer Menge von Pools sagt, dass er ein Objekt freigeben soll, kann man sich ja denken. • Der dritte Nachteil ist, dass man das in diesem Fall zumeist verbotene delete nicht wirklich als private deklarieren kann und damit gesichert dessen Aufruf verhindern kann. Ein solcher Versuch wird n¨amlich im Normalfall mit einer Warning des Compilers quittiert. Obwohl ich ein sehr entschiedener Gegner von schmutzigen Tricks bin, m¨ochte ich trotzdem hier eine abgewandelte Implementation unseres Beispiels vorstellen, die zumindest die leidigen delete Probleme ausschließt. Allerdings m¨ochte ich gleich vorausschicken, dass das delete Problem durch einen wirklich sehr schmutzigen Trick eliminiert wurde, der selbst wieder eine Zeitbombe darstellen kann. Warum ich die L¨ osung trotzdem vorstelle hat einen einfachen Grund: Die hier eingebaute Zeitbombe existiert genau an einer Stelle im Programm und sie ist auf jeder Plattform f¨ ur sich testbar. Funktioniert alles wie geplant, ist es o.k., wenn nicht, weiß man genau, wo man zu suchen hat. Die Zeitbombe, die entsteht, wenn man destroy falsch anwendet, verteilt sich wunderh¨ ubsch im gesamten Programm, ist also bei weitem gef¨ahrlicher. ¨ Die erste Anderung im Programm betrifft unsere Basisklasse f¨ ur die selbst verwalteten Objekte. Diese wurde um eine Variable mem_provider_ und um den Operator delete erg¨ anzt. Auch wurde konsequenterweise der delete[] Operator verboten. Außerdem wurde plangem¨aß die Methode destroy eliminiert:
12.3 Speicherverwaltung
49 50 51 52 53 54
379
c l a s s SpecialMemoryManagedObject { private : // f o r b i d standard i n s t a n t i a t i o n void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) { return ( ( void ∗ ) 0 ) ; }
55
// f o r b i d a r r a y i n s t a n t i a t i o n void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { return ( ( void ∗ ) 0 ) ; }
56 57 58 59
// as a consequence a l s o f o r b i d a r r a y d e l e t i o n void operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) { }
60 61 62 63 64 65
protected : MemoryProvider ∗ mem provider ;
66 67 68 69
public : void ∗ operator new( s i z e t s i z e , MemoryProvider ∗ mem provider ) throw( b a d a l l o c ) ;
70
void operator delete ( void ∗ b a s e p t r ) throw ( ) ;
71 72 73
SpecialMemoryManagedObject ( ) { }
74 75
virtual ˜ SpecialMemoryManagedObject ( ) { }
76 77
};
Die Variable mem_provider_ dient dazu, sich intern zu merken, von welchem Provider der Speicherblock angefordert wurde. Damit braucht sich die verwendende Applikation nicht mehr selbst bei der Freigabe darum zu k¨ ummern. Die Implementation von new() wurde entsprechend abge¨andert. Sie sorgt jetzt daf¨ ur, dass der u ¨bergebene Placement Parameter auch im erzeugten Objekt gespeichert wird: 112 113 114 115 116 117 118 119 120 121 122 123
void ∗ SpecialMemoryManagedObject : : operator new( s i z e t s i z e , MemoryProvider ∗ mem provider ) throw( b a d a l l o c ) { cout << ”new o f SpecialMemoryManagedObject c a l l e d , s i z e : ” << s i z e << e nd l << ” p r o v i d e r : ” << (void ∗) mem provider << e n d l ; void ∗ b l o c k = mem provider−>allocMemBlock ( s i z e ) ; // very d i r t y t r i c k ! ! ! Be c a r e f u l ! ! ! reinterpret cast<SpecialMemoryManagedObject∗>( b l o c k)−> mem provider = mem provider ; return ( b l o c k ) ; }
Genau hier ist auch schon der erste Teil des schmutzigen Tricks versteckt: Es werden Daten in den Zeilen 120–121 in den neu angelegten Speicherblock geschrieben. Allerdings passiert dies, bevor noch irgendein Konstruktor aufgerufen wurde, denn dieser kommt ja erst nach unserem new() Operator dran. Die Gefahr besteht hier darin, dass man im Prinzip nicht damit rechnet, dass bereits vor Aufruf des Konstruktors Nutzdaten im Speicher existieren.
380
12. Operator Overloading
Sollte also nun jemand auf die fatale Idee kommen, im Konstruktor dieses Objekts die Variable mem_provider_ initialisieren zu wollen, dann geht Gewaltiges schief. Allerdings muss man sagen, dass alle Entwickler, die sich an einer existenten Klasse zur Speicherverwaltung vergreifen wollen, wirklich genau wissen m¨ ussen, was sie tun. Ansonsten sollten sie sowieso gef¨alligst die Finger davon lassen. Insofern ist die Gefahr (hoffentlich) nur noch halb so groß :-). Der zweite Teil des schmutzigen Tricks findet sich im delete Operator wieder: 128 129 130 131 132 133 134 135 136 137 138
void SpecialMemoryManagedObject : : operator delete ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e o f SpecialMemoryManagedObject c a l l e d ” ; // very d i r t y t r i c k ! ! ! Be c a r e f u l ! ! ! MemoryProvider ∗ p r o v i d e r = reinterpret cast<SpecialMemoryManagedObject∗>( b a s e p t r)−> mem provider ; cout << ” , p r o v i d e r : ” << (void ∗) p r o v i d e r << e nd l ; p r o v i d e r−>freeMemBlock ( b a s e p t r ) ; }
Hier wird der gespeicherte Provider herangezogen, um die Freigabe des Speichers zu triggern. Warum auch dies ein sehr schmutziger Trick ist, l¨asst sich leicht erkennen: Unser delete Operator kommt ja erst nach Aufruf des Destruktors dran, der in diesem Fall nat¨ urlich vom Compiler eingesetzt wird und nicht von uns per Hand u ¨bernommen werden muss. Das bedeutet, dass wir es eigentlich hier mit einem bereits toten Objekt zu tun haben. Dies ist so lange unkritisch, wie niemand auf die dumme Idee kommt, im Destruktor unserer Klasse die Variable mem_provider_ umzusetzen. Der Speicherblock ist ja noch vorhanden, also ist der Zugriff darauf gestattet und mittels reinterpret_cast k¨ onnen wir auch korrekt die Variable referenzieren. Wie man in unserer main Funktion sieht, kann man jetzt vollkommen entspannt den Placement Operator zum Anlegen und ein ganz normales delete zum Freigeben eines solchen Objekts verwenden: 163 164 165 166
int main ( int a r g c , char ∗ argv [ ] ) { MemoryProvider ∗ f a s t p o o l = new MemoryProvider ( ) ; MemoryProvider ∗ n o n v o l a t i l e p o o l = new MemoryProvider ( ) ;
167 168 169 170
cout << ” c r e a t i n g o b j e c t i n f a s t memory . . . ” << e nd l ; MyMemManagedObject ∗ f a s t o b j = new( f a s t p o o l ) MyMemManagedObject ;
171 172 173 174
cout << ” c r e a t i n g o b j e c t i n non v o l a t i l e memory . . . ” << e n d l ; MyMemManagedObject ∗ n o n v o l a t i l e o b j = new( n o n v o l a t i l e p o o l ) MyMemManagedObject ;
175 176 177
cout << ” d e l e t i n g o b j e c t from f a s t memory . . . ” << e nd l ; delete f a s t o b j ;
178 179
cout << ” d e l e t i n g o b j e c t from non v o l a t i l e memory . . . ” << e n d l ;
12.3 Speicherverwaltung
381
delete n o n v o l a t i l e o b j ;
180 181
return ( 0 ) ;
182 183
}
Der Beweis, dass dieses Meisterwerk trotz aller schmutzigen Tricks auch wirklich funktioniert, findet sich im folgenden Output. Man sieht deutlich, dass f¨ ur die beiden Objekte, die von verschiedenen Providern erzeugt wurden, auch dieselben Provider zum Freigeben wieder angesprochen werden. Um b¨osen Zungen zuvorzukommen: Ich verspreche, dass dieser Output kein Fake ist :-). c r e a t i n g o b j e c t i n f a s t memory . . . new o f SpecialMemoryManagedObject c a l l e d , s i z e : 1 6 p r o v i d e r : 0 x804a6d0 c o n s t r u c t o r o f MyMemManagedObject c r e a t i n g o b j e c t i n non v o l a t i l e memory . . . new o f SpecialMemoryManagedObject c a l l e d , s i z e : 1 6 p r o v i d e r : 0 x804a6e0 c o n s t r u c t o r o f MyMemManagedObject d e l e t i n g o b j e c t from f a s t memory . . . d e s t r u c t o r o f MyMemManagedObject d e l e t e o f SpecialMemoryManagedObject c a l l e d , p r o v i d e r : 0 x804a6d0 d e l e t i n g o b j e c t from non v o l a t i l e memory . . . d e s t r u c t o r o f MyMemManagedObject d e l e t e o f SpecialMemoryManagedObject c a l l e d , p r o v i d e r : 0 x804a6e0
12.3.4 delete mit zwei Parametern Bisher haben wir delete so kennen gelernt, dass einfach der Base-Pointer des freizugebenden Blocks als Parameter u ur unsere ¨bergeben wurde. F¨ F¨alle war das auch kein Problem, denn wir haben immer nur eine DummySpeicherverwaltung verwendet, anstatt uns wirklich um den Speicher selbst zu k¨ ummern. Schreibt man aber tats¨achlich selbst eine Speicherverwaltung, so hat man zwei M¨ oglichkeiten: 1. Man merkt sich zu jedem angeforderten Block intern selbst die Gr¨oße, die angefordert wurde. Damit braucht man beim Freigeben keine weitere Information als den Base-Pointer, um den Rest erledigen zu k¨onnen, denn man weiß ja u ¨ber den Block genau Bescheid. 2. Man verl¨ asst sich auf das System und fordert, dass man beim Freigeben gef¨alligst mitgeteilt bekommt, wie groß denn der Block ist, den man freigeben soll. Vorausschicken m¨ ochte ich, dass die erste Methode immer die Methode der Wahl sein sollte, denn diese ist die sichere Variante (es wird weiter unten noch besprochen, warum). Im Normalfall werden auch alle Memory Verwaltungen nach dieser ersten Methode implementiert. In den seltenen Ausnahmef¨ allen, in denen man dies, aus welchen Gr¨ unden auch immer, nicht tun will und das Merken der Blockgr¨oße dem System u ¨berl¨asst,
382
12. Operator Overloading
hilft ein spezieller delete Operator, der zwei Parameter u ¨bergeben bekommt, n¨ amlich den Base-Pointer und die Gr¨oße. Im folgenden Beispiel (fifth_new_delete_overloading_demo.cpp) wurde eine Abwandlung unsere selbstverwalteten Objekte implementiert. Wiederum gibt es eine Basisklasse, die entsprechende new, new[], delete und delete[] Operatoren implementiert. Die Deklaration sieht folgendermaßen aus: 23 24 25 26 27
c l a s s MemoryManagedObject { public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) ;
28
void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) ;
29 30 31
void operator delete ( void ∗ b a s e p t r , s i z e t s i z e ) throw ( ) ;
32 33 34
void operator delete [ ] ( void ∗ b a s e p t r , s i z e t s i z e ) throw ( ) ;
35 36 37
virtual ˜ MemoryManagedObject ( ) { }
38 39
};
Bei den beiden delete Operatoren ist nun jeweils ein zweiter Parameter dabei, der Auskunft u oße des freizugebenden Blocks gibt. Die Im¨ber die Gr¨ plementation der Operatoren in unserem Beispiel birgt nichts wirklich Neues und liest sich so: 70 71 72 73 74 75 76 77 78 79
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ MemoryManagedObject : : operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new o f MemoryManagedObject c a l l e d with s i z e ” << s i z e << e nd l ; return ( malloc ( s i z e ) ) ; }
80 81 82 83 84 85 86 87 88 89 90
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ MemoryManagedObject : : operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new [ ] o f MemoryManagedObject c a l l e d with s i z e ” << s i z e << e nd l ; return ( malloc ( s i z e ) ) ; }
91 92 93 94 95 96 97 98
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void MemoryManagedObject : : operator delete ( void ∗ b a s e p t r , size t size ) throw ( ) {
12.3 Speicherverwaltung
cout << ” d e l e t e o f MemoryManagedObject c a l l e d with base : ” << b a s e p t r << e nd l << ” and s i z e : ” << s i z e << e n d l ; free ( base ptr ) ;
99 100 101 102
383
}
103 104 105 106 107 108 109 110 111 112 113 114
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void MemoryManagedObject : : operator delete [ ] ( void ∗ b a s e p t r , size t size ) throw ( ) { cout << ” d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : ” << b a s e p t r << e nd l << ” and s i z e : ” << s i z e << e n d l ; free ( base ptr ) ; }
Es wurde hier zur Reduktion der L¨ ange des Beispiels absichtlich auf die Implementation eines eigenen Providers verzichtet. Stattdessen werden einfach in den entsprechenden Operatoren die Funktionen malloc und free direkt aufgerufen. Eine Klasse, die spezielles Memory Management haben will, wird in gewohnter Weise wieder einfach von unserer Basis abgeleitet. Das kann zum Beispiel so aussehen: 50 51 52 53 54 55 56 57 58 59 60 61
c l a s s MyMemoryManagedObject : public MemoryManagedObject { protected : i n t 3 2 some data ; uint32 some other data ; public : MyMemoryManagedObject ( ) { cout << ” c o n s t r u c t o r o f MyMemoryManagedObject” << e n d l ; some data = 0 ; some other data = 0; }
62
virtual ˜ MyMemoryManagedObject ( ) { cout << ” d e s t r u c t o r o f MyMemoryManagedObject” << e nd l ; }
63 64 65 66 67 68
};
Um zu zeigen, dass die speziellen delete Operatoren auch wirklich funktionieren wie erwartet, sieht unsere main Funktion folgendermaßen aus: 120 121 122 123
int main ( int a r g c , char ∗ argv [ ] ) { cout << ” c r e a t i n g i n d i v i d u a l o b j e c t . . . ” << e n d l ; MyMemoryManagedObject ∗ obj = new MyMemoryManagedObject ;
124 125 126
cout << ” d e l e t i n g i n d i v i d u a l o b j e c t . . . ” << e n d l ; delete obj ;
127 128 129
cout << ” c r e a t i n g a r r a y . . . ” << e n d l ; obj = new MyMemoryManagedObject [ 3 ] ;
384
12. Operator Overloading
130
cout << ” d e l e t i n g a r r a y . . . ” << e nd l ; delete [ ] obj ;
131 132 133
return ( 0 ) ;
134 135
}
Der Output, den dieses Programm liefert, zeigt deutlich, dass wir erreicht haben, was beabsichtigt war. Wir bekommen bei Aufruf von delete bzw. delete[] tats¨ achlich die Gr¨ oße des Blocks mit u ¨bergeben: creating individual object . . . new o f MemoryManagedObject c a l l e d with s i z e 1 2 c o n s t r u c t o r o f MyMemoryManagedObject deleting individual object . . . d e s t r u c t o r o f MyMemoryManagedObject d e l e t e o f MemoryManagedObject c a l l e d with base : 0 x804a800 and s i z e : 1 2 cr ea t i ng array . . . new [ ] o f MemoryManagedObject c a l l e d with s i z e 4 0 c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g array . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : 0 x804a800 and s i z e : 4 0
Vorsicht Falle: Das ist doch wirklich alles sehr praktisch, warum also habe ich zuvor gewarnt, sich einfach auf diese Operatoren zu verlassen und stattdessen darauf bestanden, sich die Gr¨oßen von Bl¨ocken in einer eigenen Speicherverwaltung selbst zu merken? Der Grund wird offensichtlich, wenn man u ¨berlegt, wie der Compiler zur Gr¨oßeninformation kommt, die er uns mitteilt: Er rechnet sie aufgrund des Objekts bzw. des Arrays von Objekten aus, das freigegeben werden soll! Das passiert eben zur Compilezeit (nicht zur Laufzeit!) und die entsprechende Information wird in den Destruktoren vermerkt. Damit verfolgt uns wieder die Forderung nach einem virtual Destruktor, denn nur dann bekommen wir auch sicher immer die richtige Gr¨ oße mitgeteilt! Es ist schon genug, wenn eine Basisklasse einen solchen bereitstellt, denn durch dessen Existenz sorgt der Compiler daf¨ ur, dass auch in allen abgeleiteten Klassen ein solcher existiert. Es ist nicht einmal notwendig, dass diese explizit in den abgeleiteten Klassen implementiert wird, denn bei Fehlen desselben setzt der Compiler automatisch einen impliziten virtual Destruktor ein. Vorsicht Falle: Nat¨ urlich gibt es auch noch einen weiteren Effekt, wenn wir uns u oße des freizugebenden Objekts durch den Com¨berlegen, dass die Gr¨ piler in den Destruktoren verewigt wird. Es wurde bereits erw¨ahnt, dass bei Aufruf von new bzw. new[] als Parameter die ben¨otigte Mindestgr¨oße eines
12.3 Speicherverwaltung
385
Blocks u unden ¨bergeben wird. Niemand hindert uns daran, aus internen Gr¨ ein wenig mehr Speicher f¨ ur einen Block zu allokieren. Davon weiß allerdings der Compiler nichts, wie sollte er auch? Das bedeutet aber, dass der bei delete bzw. delete[] u ¨bergebene Gr¨oßenparameter wieder nur dem geforderten Mindestwert entspricht, anstatt unsere tats¨achlich allokierte Gr¨oße zu reflektieren! Wie man sich leicht vorstellen kann, erzeugt man durch dieses Verhalten wunderbar tief fliegende Programme, die einem viele angenehme N¨achte der Fehlersuche bescheren :-).
12.3.5 Globale new und delete Operatoren Bisher haben wir uns nur damit besch¨aftigt, wie man auf Klassenbasis die Speicherverwaltung beeinflussen kann. Was aber, wenn man wirklich die komplette Speicherverwaltung in einem Programm auf einen Schlag austauschen will? Auch daran wurde in C++ gedacht und aus diesem Grund gibt es die Operatoren auch in globaler Variante, also quasi als Funktionen anstatt als Members. Wie man im folgenden Beispiel sieht, funktionieren die globalen Operatoren im Prinzip ganz gleich wie die Members, die nur f¨ ur einzelne Klassen gelten (global_new_delete_overloading.cpp). Die globalen Operatoren lesen sich erwartungsgem¨ aß so: 14 15 16 17 18 19 20 21 22
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ” g l o b a l new c a l l e d with s i z e ” << s i z e << e nd l ; return ( malloc ( s i z e ) ) ; }
23 24 25 26 27 28 29 30 31 32
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ” g l o b a l new [ ] c a l l e d with s i z e ” << s i z e << e n d l ; return ( malloc ( s i z e ) ) ; }
33 34 35 36 37 38 39 40 41 42 43
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void operator delete ( void ∗ b a s e p t r ) throw ( ) { cout << ” g l o b a l d e l e t e c a l l e d with base : ” << b a s e p t r << e nd l ; free ( base ptr ) ; }
44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
386
46 47 48 49 50 51 52 53 54
12. Operator Overloading
/∗ ∗/ void operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) { cout << ” g l o b a l d e l e t e [ ] c a l l e d with base : ” << b a s e p t r << e nd l ; free ( base ptr ) ; }
Bei dieser Gelegenheit m¨ ochte ich gleich darauf aufmerksam machen, dass es keinen globalen Placement Operator new() gibt. Der Versuch, einen solchen zu implementieren, endet damit, dass sich der Compiler u ¨ber eine Redefinition beschwert. Dieses Placement Verhalten kann man also nur bezogen auf einzelne Klassen implementieren und nicht gleich in einem Rutsch global f¨ ur alle Klassen. Meiner Meinung ist das auch sehr gut so, denn Placement ist bei einzelnen Objekten bereits gef¨ ahrlich genug. Ohne Wissen u ¨ber die Spezifika bestimmter Objekte ein globales Placement einzuf¨ uhren w¨are gleichbedeutend damit, Harakiri mit Anlauf zu machen. In unserem Beispielprogramm finden wir noch eine “ganz normale” Klasse, die selbst mit jeglicher Art von Speicherverwaltung nichts am Hut hat: 64 65 66 67 68 69 70 71 72 73
c l a s s SimpleObject { protected : int32 j u s t a v a r i a b l e ; public : SimpleObject ( ) { cout << ” c o n s t r u c t o r o f SimpleObject ” << e nd l ; j u s t a v a r i a b l e = 17; }
74
virtual ˜ SimpleObject ( ) { cout << ” d e s t r u c t o r o f SimpleObject ” << e n d l ; }
75 76 77 78 79 80
};
Um zu zeigen, wie sich die globalen Operatoren in Bezug auf klassenspezifische Members verhalten, wurde auch in dieses Beispiel wieder eine Klasse aufgenommen, die selbst die Speicherverwaltung durch Overloading der new und delete Operatoren u ¨bernimmt: 91 92 93 94 95
c l a s s MemoryManagedObject { public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) ;
96 97 98
void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) ;
99 100
void operator delete ( void ∗ b a s e p t r )
12.3 Speicherverwaltung
387
throw ( ) ;
101 102
void operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) ;
103 104 105
MemoryManagedObject ( ) { cout << ” c o n s t r u c t o r o f MemoryManagedObject” << e nd l ; }
106 107 108 109 110
virtual ˜ MemoryManagedObject ( ) { cout << ” d e s t r u c t o r o f MemoryManagedObject” << e nd l ; }
111 112 113 114 115
};
Die Implementation der einzelnen Operatoren dieser Klasse ist altbekannt, ein erneutes Abdrucken w¨ are also reine Platzverschwendung. Auch altbekannt ist die Klasse, die sich durch Ableitung dem speziellen Memory Management unterwirft: 126 127 128 129 130 131 132 133 134 135 136 137
c l a s s MyMemoryManagedObject : public MemoryManagedObject { protected : i n t 3 2 some data ; uint32 some other data ; public : MyMemoryManagedObject ( ) { cout << ” c o n s t r u c t o r o f MyMemoryManagedObject” << e n d l ; some data = 0 ; some other data = 0; }
138
virtual ˜ MyMemoryManagedObject ( ) { cout << ” d e s t r u c t o r o f MyMemoryManagedObject” << e nd l ; }
139 140 141 142 143 144
};
In der Implementation der Funktion main findet sich der Testcode, der jeweils eine individuelle Instanz sowie ein Array von der “ganz normalen” und auch von der selbst-speicherverwaltenden Klasse erzeugt und auch wieder freigibt: 194 195 196 197
int main ( int a r g c , char ∗ argv [ ] ) { cout << ” c r e a t i n g SimpleObject . . . ” << e n d l ; SimpleObject ∗ s i m p l e o b j = new SimpleObject ;
198 199 200
cout << ” d e l e t i n g SimpleObject . . . ” << e n d l ; delete s i m p l e o b j ;
201 202 203
cout << ” c r e a t i n g SimpleObject Array . . . ” << e nd l ; s i m p l e o b j = new SimpleObject [ 3 ] ;
204 205 206 207
cout << ” d e l e t i n g SimpleObject Array . . . ” << e nd l ; delete [ ] s i m p l e o b j ;
388
12. Operator Overloading
cout << ” c r e a t i n g MyMemoryManagedObject” << e n d l ; MyMemoryManagedObject ∗ mem managed obj = new MyMemoryManagedObject ;
208 209 210 211
cout << ” d e l e t i n g MyMemoryManagedObject . . . ” << e nd l ; delete mem managed obj ;
212 213 214
cout << ” c r e a t i n g MyMemoryManagedObject a r r a y . . . ” << e nd l ; mem managed obj = new MyMemoryManagedObject [ 3 ] ;
215 216 217
cout << ” d e l e t i n g MyMemoryManagedObject a r r a y . . . ” << e nd l ; delete [ ] mem managed obj ;
218 219 220
return ( 0 ) ;
221 222
}
Ein Blick auf den Output, der von diesem Programm erzeugt wird, verr¨at uns, wie sich die globalen Operatoren verhalten: c r e a t i n g SimpleObject . . . g l o b a l new c a l l e d with s i z e 8 c o n s t r u c t o r o f SimpleObject d e l e t i n g SimpleObject . . . d e s t r u c t o r o f SimpleObject g l o b a l d e l e t e c a l l e d with base : 0 x804b2f0 c r e a t i n g SimpleObject Array . . . g l o b a l new [ ] c a l l e d with s i z e 2 8 c o n s t r u c t o r o f SimpleObject c o n s t r u c t o r o f SimpleObject c o n s t r u c t o r o f SimpleObject d e l e t i n g SimpleObject Array . . . d e s t r u c t o r o f SimpleObject d e s t r u c t o r o f SimpleObject d e s t r u c t o r o f SimpleObject g l o b a l d e l e t e [ ] c a l l e d with base : 0 x804b2f0 c r e a t i n g MyMemoryManagedObject new o f MemoryManagedObject c a l l e d with s i z e 1 2 c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g MyMemoryManagedObject . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e l e t e o f MemoryManagedObject c a l l e d with base : 0 x804b2f0 c r e a t i n g MyMemoryManagedObject a r r a y . . . new [ ] o f MemoryManagedObject c a l l e d with s i z e 4 0 c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g MyMemoryManagedObject a r r a y . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : 0 x804b2f0
Im Falle der Klasse SimpleObject werden unsere globalen Operatoren sowohl f¨ ur die individuelle Instanz als auch f¨ ur das Array eingesetzt. Sobald aber eine Klasse selbst definierte Operatoren hat, werden nicht mehr unsere globalen Implementationen herangezogen, sondern die individuellen Varianten. Das
12.3 Speicherverwaltung
389
bedeutet, dass wir durch Overloading der globalen Operatoren ausschließlich die Verwaltung des Speichers f¨ ur Objekte u ¨bernehmen k¨onnen, deren Klassen selbst noch keine besondere Speicherverwaltung implementieren. Vorsicht Falle: Da die globalen Operatoren nur dann herangezogen werden, wenn eine Klasse nicht bereits selbst die entsprechenden new und delete Operatoren implementiert, hat das Austauschen der Speicherverwaltung f¨ ur ein gesamtes Programm nach dieser Methode mit ¨außerster Umsicht zu geschehen. Sonst kann es leicht zu sehr unangenehmen Interferenzen kommen, sollten die Prinzipien der beiden Speicherverwaltungsalgorithmen nicht kompatibel zueinander sein! Besonders b¨ osartig wird dieser Fehler, wenn z.B. eine Klasse, aus welchen Gr¨ unden auch immer, nur die new Operatoren implementiert, aber delete dem System u asst. Dann passiert n¨amlich der Fall, dass das klassen¨berl¨ interne new und als Gegenst¨ uck dazu das globale delete aufgerufen wird. Pl¨otzlich bekommt also das globale delete einen Block zur Freigabe, den es selbst gar nicht bereitgestellt hat! Je nachdem, welcher Algorithmus f¨ ur die globale Speicherverwaltung implementiert wurde, kann dies dazu f¨ uhren, dass sich die Entwickler des Programms mit erheblichen Tiefflugversuchen desselben konfrontiert sehen, die sie sich beim besten Willen nicht erkl¨aren k¨onnen. Daher m¨ ochte ich hier ganz eindringlich einen Ratschlag anbringen: Wenn man eine individuelle Speicherverwaltung f¨ ur eine Klasse schreibt, dann m¨ ussen unbedingt immer die Operatoren paarweise implementiert werden. Niemals darf ein new ohne zugeh¨ origes delete implementiert werden oder umgekehrt! Je nachdem, was man mit individueller Speicherverwaltung in Klassen bezweckt, kann man vorausschauend genug arbeiten, um dieses Problem zumindest in einigen F¨ allen gar nicht erst aufkommen zu lassen. Nehmen wir an, die new und delete Operatoren wurden deshalb in einer Klasse individuell implementiert, weil zus¨ atzlich zum Beschaffen und Freigeben von Speicher noch etwas anderes stattfinden soll (z.B. inkrementieren bzw. dekrementieren eines Reference Counters). Wo der Speicher herkommt, ist allerdings den Operatoren egal, sie w¨ urden sowieso einfach nur malloc und free verwenden. F¨ ur diesen Fall kann man den zuvor erw¨ahnten Stolperstein umgehen, indem man nicht malloc und free aufruft, sondern stattdessen, mittels Scope-Operator, die globalen Operatoren new und delete bzw. new[] und delete[]. Solange diese nicht von uns besonders implementiert sind, werden automatisch die systeminternen Operatoren herangezogen, sonst unsere besonderen Varianten. Schreiben wir also unsere Implementation der Operatoren f¨ ur das MemoryManagedObject den neuen Erkenntnissen entsprechend um (global_new_delete_overloading_version2.cpp):
390
146 147 148 149 150 151 152 153 154 155
12. Operator Overloading
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ MemoryManagedObject : : operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new o f MemoryManagedObject c a l l e d with s i z e ” << s i z e << e nd l ; return ( : : operator new( s i z e ) ) ; }
156 157 158 159 160 161 162 163 164 165 166
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void ∗ MemoryManagedObject : : operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new [ ] o f MemoryManagedObject c a l l e d with s i z e ” << s i z e << e nd l ; return ( : : operator new [ ] ( s i z e ) ) ; }
167 168 169 170 171 172 173 174 175 176 177
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void MemoryManagedObject : : operator delete ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e o f MemoryManagedObject c a l l e d with base : ” << b a s e p t r << e nd l ; : : operator delete ( b a s e p t r ) ; }
178 179 180 181 182 183 184 185 186 187 188
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void MemoryManagedObject : : operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : ” << b a s e p t r << e nd l ; : : operator delete [ ] ( b a s e p t r ) ; }
Der Output des abge¨ anderten Programms beweist, dass nun die beiden Speicherverwaltungen nicht mehr gegeneinander, sondern miteinander arbeiten. Beim Allokieren und Freigeben von Speicherbl¨ocken wird auf unsere globalen Operatoren zur¨ uckgegriffen: c r e a t i n g SimpleObject . . . g l o b a l new c a l l e d with s i z e 8 c o n s t r u c t o r o f SimpleObject d e l e t i n g SimpleObject . . . d e s t r u c t o r o f SimpleObject g l o b a l d e l e t e c a l l e d with base : 0 x804b2f0 c r e a t i n g SimpleObject Array . . . g l o b a l new [ ] c a l l e d with s i z e 2 8 c o n s t r u c t o r o f SimpleObject c o n s t r u c t o r o f SimpleObject c o n s t r u c t o r o f SimpleObject d e l e t i n g SimpleObject Array . . . d e s t r u c t o r o f SimpleObject
12.3 Speicherverwaltung
391
d e s t r u c t o r o f SimpleObject d e s t r u c t o r o f SimpleObject g l o b a l d e l e t e [ ] c a l l e d with base : 0 x804b2f0 c r e a t i n g MyMemoryManagedObject new o f MemoryManagedObject c a l l e d with s i z e 1 2 g l o b a l new c a l l e d with s i z e 1 2 c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g MyMemoryManagedObject . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e l e t e o f MemoryManagedObject c a l l e d with base : 0 x804b2f0 g l o b a l d e l e t e c a l l e d with base : 0 x804b2f0 c r e a t i n g MyMemoryManagedObject a r r a y . . . new [ ] o f MemoryManagedObject c a l l e d with s i z e 4 0 g l o b a l new [ ] c a l l e d with s i z e 4 0 c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g MyMemoryManagedObject a r r a y . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : 0 x804b2f0 g l o b a l d e l e t e [ ] c a l l e d with base : 0 x804b2f0
12.3.6 Weitere Aspekte der eigenen Speicherverwaltung Um die Abhandlung u ¨ber die handgestrickte Speicherverwaltung abzurunden, m¨ochte ich hier noch kurz auf ein paar kleine Aspekte eingehen, die in der Praxis Relevanz besitzen. Vererbung von new und delete. Es ist nat¨ urlich nicht nur m¨oglich, die new und delete Operatoren einfach einmal mittels Overloading f¨ ur individuelle Klassen zu implementieren und diese Implementation zu vererben. Auch hier gelten die u ur das Overriding. Sollte man also von einer ¨blichen Regeln f¨ Basisklasse ableiten, die individuelle Speicherverwaltung macht, und sollte man weiters mit diesem Verhalten nicht zufrieden sein, so kann man in der Ableitung selbst ein Overriding vornehmen und damit die Operatoren aus der Basisklasse außer Kraft setzen. Im folgenden Beispiel ist dies kurz demonstriert (new_delete_overriding.cpp): 1 2
// n e w d e l e t e o v e r r i d i n g . cpp − a demo f o r o v e r r i d i n g // i n d i v i d u a l new and d e l e t e o p e r a t o r s i n s u b c l a s s e s
3 4 5 6 7 8 9
#include #include #include #include #include
< i o s t r e a m> < s t d e x c e p t> < c s t d l i b> ” u s e r t y p e s . h”
392
10 11 12
12. Operator Overloading
using s t d : : b a d a l l o c ; using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16 17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MemoryManagedObject ∗ ∗ j u s t an o b j e c t with i t s own memory management ∗ ∗/
22 23 24 25 26 27 28 29 30 31 32
c l a s s MemoryManagedObject { public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new o f MemoryManagedObject c a l l e d with s i z e ” << s i z e << e nd l ; return ( : : operator new( s i z e ) ) ; }
33
void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ”new [ ] o f MemoryManagedObject c a l l e d with s i z e ” << s i z e << e nd l ; return ( : : operator new [ ] ( s i z e ) ) ; }
34 35 36 37 38 39 40 41
void operator delete ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e o f MemoryManagedObject c a l l e d with base : ” << b a s e p t r << e nd l ; : : operator delete ( b a s e p t r ) ; }
42 43 44 45 46 47 48 49
void operator delete [ ] ( void ∗ b a s e p t r ) throw ( ) { cout << ” d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : ” << b a s e p t r << e nd l ; : : operator delete [ ] ( b a s e p t r ) ; }
50 51 52 53 54 55 56 57
MemoryManagedObject ( ) { cout << ” c o n s t r u c t o r o f MemoryManagedObject” << e n d l ; }
58 59 60 61 62
virtual ˜ MemoryManagedObject ( ) { cout << ” d e s t r u c t o r o f MemoryManagedObject” << e nd l ; }
63 64 65 66 67
};
68 69 70 71 72 73 74 75
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyMemoryManagedObject ∗ ∗ demo f o r a f a s t a c c e s s i b l e o b j e c t ∗
12.3 Speicherverwaltung
393
∗/
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
c l a s s MyMemoryManagedObject : public MemoryManagedObject { protected : i n t 3 2 some data ; uint32 some other data ; public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ” o v e r r i d d e n new ! ! ! ! ! s i z e = ” << s i z e << e nd l ; return ( : : operator new( s i z e ) ) ; }
91
void operator delete ( void ∗ b a s e p t r ) throw ( ) { cout << ” o v e r r i d d e n d e l e t e ! ! ! ! ! base = ” << b a s e p t r << e nd l ; : : operator delete ( b a s e p t r ) ; }
92 93 94 95 96 97 98 99
MyMemoryManagedObject ( ) { cout << ” c o n s t r u c t o r o f MyMemoryManagedObject” << e nd l ; some data = 0 ; some other data = 0; }
100 101 102 103 104 105 106
virtual ˜ MyMemoryManagedObject ( ) { cout << ” d e s t r u c t o r o f MyMemoryManagedObject” << e nd l ; }
107 108 109 110 111 112
};
113 114 115 116 117 118 119 120 121
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { cout << ” c r e a t i n g MyMemoryManagedObject” << e n d l ; MyMemoryManagedObject ∗ mem managed obj = new MyMemoryManagedObject ;
122
cout << ” d e l e t i n g MyMemoryManagedObject . . . ” << e nd l ; delete mem managed obj ;
123 124 125
cout << ” c r e a t i n g MyMemoryManagedObject a r r a y . . . ” << e nd l ; mem managed obj = new MyMemoryManagedObject [ 3 ] ;
126 127 128
cout << ” d e l e t i n g MyMemoryManagedObject a r r a y . . . ” << e nd l ; delete [ ] mem managed obj ;
129 130 131
return ( 0 ) ;
132 133
}
Um das Beispiel so kurz wie m¨ oglich zu halten, wurden alle Operatoren und Methoden kurzerhand inline implementiert. Dass dies in der Praxis nicht ratsam ist, versteht sich von selbst, hier hilft es allerdings Platz zu sparen :-).
394
12. Operator Overloading
Das MemoryManagedObject hat sich bis auf den Umbau zu inline Methoden in Bezug auf den bereits bekannten Vorg¨anger nicht ver¨andert. Sehr wohl aber ist MyMemoryManagedObject ein wenig mutiert: Es sind jetzt in dieser Klasse die Operatoren new und delete f¨ ur individuelle Objekte overridden. Die entsprechenden Array-Operatoren wurden beim Alten belassen. Der Output des Programms zeigt auch gleich, dass dieser Umbau den erwarteten Effekt hat: c r e a t i n g MyMemoryManagedObject o v e r r i d d e n new ! ! ! ! ! s i z e = 12 c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g MyMemoryManagedObject . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject o v e r r i d d e n d e l e t e ! ! ! ! ! base = 0 x804ab68 c r e a t i n g MyMemoryManagedObject a r r a y . . . new [ ] o f MemoryManagedObject c a l l e d with s i z e 4 0 c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject c o n s t r u c t o r o f MemoryManagedObject c o n s t r u c t o r o f MyMemoryManagedObject d e l e t i n g MyMemoryManagedObject a r r a y . . . d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e s t r u c t o r o f MyMemoryManagedObject d e s t r u c t o r o f MemoryManagedObject d e l e t e [ ] o f MemoryManagedObject c a l l e d with base : 0 x804ab68
Dass nat¨ urlich beim Overriding auch wieder ¨außerste Vorsicht geboten ist, um nicht die Speicherverwaltung des Originals v¨ollig durcheinander zu bringen, versteht sich von selbst. Manchmal gibt es auch F¨ alle, in denen es absolut nicht erw¨ unscht ist, dass eine abgeleitete Klasse die Operatoren new und delete erbt, da sie aus irgendwelchen Gr¨ unden sehr speziell implementiert sind und nur f¨ ur die Basisklasse korrekt funktionieren w¨ urden. In solchen F¨allen wird oft folgende Implementation gew¨ ahlt (new_and_delete_only_for_base.cpp): 1 2 3
// n e w a n d d e l e t e o n l y f o r b a s e . cpp − a demo , how to implement // new and d e l e t e i n a way t h a t they do not i n f l u e n c e memory // management o f d e r i v e d c l a s s e s
4 5 6 7 8 9
#include #include #include #include #include
< i o s t r e a m> < s t d e x c e p t> < c s t d l i b> ” u s e r t y p e s . h”
10 11 12 13
using s t d : : b a d a l l o c ; using s t d : : cout ; using s t d : : e nd l ;
14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗
12.3 Speicherverwaltung
18 19 20 21 22 23
∗ MemoryManagedObject ∗ ∗ an o b j e c t with i t s own memory management which checks f o r ∗ inheritance situations ∗ ∗/
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
c l a s s MemoryManagedObject { public : void ∗ operator new( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ” o p e r a t o r new o f MemoryManagedObject” << e nd l ; i f ( s i z e ! = s i z e o f ( MemoryManagedObject ) ) // d e r i v e d c l a s s . . . { cout << ” i t ’ s an i n h e r i t a n c e s i t u a t i o n ” << e n d l ; return ( : : operator new( s i z e ) ) ; } // h e r e the very s p e c i a l implementation f o r the // o b j e c t would be implemented cout << ” i t ’ s the base c l a s s . . . ” << ” performing s p e c i a l o p e r a t i o n s ” << e n d l ; return ( : : operator new( s i z e ) ) ; // j u s t a dummy . . . }
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void operator delete ( void ∗ b a s e p t r , s i z e t s i z e ) throw ( ) { cout << ” o p e r a t o r d e l e t e o f MemoryManagedObject” << e n d l ; i f ( ! base ptr ) return ; i f ( s i z e ! = s i z e o f ( MemoryManagedObject ) ) { cout << ” i t ’ s an i n h e r i t a n c e s i t u a t i o n ” << e nd l ; : : operator delete ( b a s e p t r ) ; return ; } // h e r e the very s p e c i a l implementation f o r the // o b j e c t would be implemented cout << ” i t ’ s the base c l a s s . . . ” << ” performing s p e c i a l o p e r a t i o n s ” << e nd l ; : : operator delete ( b a s e p t r ) ; // j u s t a dummy . . . }
63 64 65 66
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual ˜ MemoryManagedObject ( ) { } };
67 68 69 70 71 72 73 74 75
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyMemoryManagedObject ∗ ∗ t h i s c l a s s would i n h e r i t the o p e r a t o r s . . . . ∗ ∗/
76 77 78 79 80 81 82 83
c l a s s MyMemoryManagedObject : public MemoryManagedObject { protected : i n t 3 2 some data ; uint32 some other data ; public : MyMemoryManagedObject ( ) : some data ( 0 ) , s o m e o t h e r d a t a ( 0 ) { }
395
396
84
12. Operator Overloading
};
85 86 87 88 89 90 91 92 93
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { cout << ” c r e a t i n g MemoryManagedObject” << e nd l ; MemoryManagedObject ∗ mem managed obj = new MemoryManagedObject ;
94
cout << ” c r e a t i n g MyMemoryManagedObject” << e nd l ; MyMemoryManagedObject ∗ my mem managed obj = new MyMemoryManagedObject ;
95 96 97 98
cout << ” d e l e t i n g MemoryManagedObject . . . ” << e nd l ; delete mem managed obj ;
99 100 101
cout << ” d e l e t i n g MyMemoryManagedObject . . . ” << e nd l ; delete my mem managed obj ;
102 103 104
return ( 0 ) ;
105 106
}
Man sieht, dass in Zeile 32 ein Gr¨ oßenvergleich zwischen erwarteter und angeforderter Speichermenge stattfindet. Genau das ist der Trick, denn eine Ableitung braucht im Normalfall nicht dieselbe Speichermenge, wie die Basis. Nat¨ urlich darf man nicht nur bei new darauf reagieren. Man muss ebenfalls daf¨ ur sorgen, dass delete nur aktiv wird, wenn es sich um die eigene Klasse handelt. Dies ist erreichbar, wenn man die zwei-Parameter-Version von delete implementiert und ebenfalls den Gr¨oßenvergleich durchf¨ uhrt. Der Output, den unser Programm erzeugt, best¨atigt die Theorie: c r e a t i n g MemoryManagedObject o p e r a t o r new o f MemoryManagedObject i t ’ s the base c l a s s . . . performing s p e c i a l o p e r a t i o n s c r e a t i n g MyMemoryManagedObject o p e r a t o r new o f MemoryManagedObject i t ’ s an i n h e r i t a n c e s i t u a t i o n d e l e t i n g MemoryManagedObject . . . o p e r a t o r d e l e t e o f MemoryManagedObject i t ’ s the base c l a s s . . . performing s p e c i a l o p e r a t i o n s d e l e t i n g MyMemoryManagedObject . . . o p e r a t o r d e l e t e o f MemoryManagedObject i t ’ s an i n h e r i t a n c e s i t u a t i o n
Vorsicht Falle: Der Trick mit der Gr¨oßenabfrage ist leider kein sehr sauberer! Ich habe bewusst zuerst geschrieben, dass eine Ableitung im Normalfall nicht dieselbe Speichermenge braucht. Was aber ist der Normalfall? Diese Frage l¨asst sich nicht allgemeing¨ ultig beantworten, da hierbei Compilereigenheiten eine Rolle spielen. Im Prinzip l¨asst sich aber sagen, dass eine Ableitung, die keine zus¨ atzlichen Member-Variablen einf¨ uhrt und nur statisch gebundene Methoden zur Basis hinzuf¨ ugt, mit an Sicherheit grenzender Wahrscheinlichkeit gleich viel Speicher braucht, wie die Basis. Werden in der Ableitung zus¨ atzliche virtual Methoden eingef¨ uhrt, dann ist es m¨ oglich, aber nicht garantiert, dass sich die Gr¨oße ¨andert.
12.3 Speicherverwaltung
397
Wenn in der Ableitung Member-Variablen definiert werden, dann ist es beinahe sicher, dass sich die Gr¨ oße der Ableitung im Vergleich zur Basis unden ¨andert. Jedoch kann man es aus verschiedenen compilerinternen Gr¨ heraus auch nicht wirklich garantieren. Im Sinne einer robusten Entwicklung kann ich daher nur sagen, dass Probleme wie dieses per Konvention und nicht u ¨ber einen Trick gel¨ost werden sollten. Nur dann bewegt man sich immer auf der sicheren Seite. Verhalten bei “Ausgehen” des Speichers. In Abschnitt 12.3.1 wurde bereits darauf hingewiesen, dass erstens malloc keine Exception wirft, falls nicht mehr gen¨ ugend Speicher vorhanden ist, sondern einen 0-Pointer liefert. Zweitens d¨ urfen selbstdefinierte new Operatoren auch nicht einfach blindlings eine bad_alloc Exception werfen, sondern m¨ ussen per Konvention dem Entwickler zuvor noch eine besondere Chance geben. Diese Chance ist der besondere new_handler. Sehen wir uns das ganze Spielchen einmal aus Sicht der Applikationsentwickler an, die mit speziellen Implementationen von new und delete nichts am Hut haben. Eine Applikation hat per Konvention in C++ die M¨oglichkeit, eine Funktion set_new_handler aufzurufen, die als Parameter einen Funktionspointer nimmt, der auf eine Funktion zeigt, die keine Parameter nimmt und keinen return-Value liefert. Dieser spezielle Handler kann z.B. daf¨ ur sorgen, dass auf irgendeinem Weg Speicher freigemacht wird oder er kann auch einfach eine bad_alloc Exception werfen, wenn er keine Chance mehr sieht. Im Prinzip muss er sogar eine bad_alloc Exception werfen, wenn es keine weitere Chance mehr gibt, denn sonst kommt es zu einer Endlosschleife, doch dazu sp¨ ater. Sehen wir uns dazu kurz ein Beispielprogr¨ammchen an (first_new_handler_demo.cpp): 1 2
// f i r s t n e w h a n d l e r d e m o . cpp − a demo f o r s e t t i n g a // from w i t h i n an a p p l i c a t i o n
new handler
3 4 5 6 7 8
#include #include #include #include #include
< i o s t r e a m> < s t d e x c e p t> < c s t d l i b> ” u s e r t y p e s . h”
9 10 11 12 13 14
using using using using using
std std std std std
:: :: :: :: ::
bad alloc ; cout ; cerr ; e nd l ; set new handler ;
15 16
i n t 3 2 ∗ dummy memory consumer ;
17 18 19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void memoryExhaustedHook ( ) throw( b a d a l l o c ) { c e r r << ”memoryExhaustedHook c a l l e d ” << e n d l ;
398
12. Operator Overloading
i f ( dummy memory consumer ) // t h e r e i s something to g e t r i d o f { delete [ ] dummy memory consumer ; dummy memory consumer = 0 ; c e r r << ” could f r e e some memory −> t r y again ” << e n d l ; return ; } // Oops − t h e r e i s r e a l l y no more memory a v a i l a b l e c e r r << ”no more memory a v a i l a b l e −> b a d a l l o c ” << e nd l ; throw b a d a l l o c ( ) ;
25 26 27 28 29 30 31 32 33 34 35
}
36 37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { dummy memory consumer = new i n t 3 2 [ 2 0 0 0 0 0 ] ;
43
s e t n e w h a n d l e r ( memoryExhaustedHook ) ;
44 45
for ( ; ; ) new i n t 3 2 [ 1 0 0 0 0 0 ] ;
46 47 48
return ( 0 ) ;
49 50
}
Der Output zeigt, dass tats¨ achlich unser memoryExhaustedHook aufgerufen wird und auch einmal Abhilfe schaffen kann. Beim zweiten Versuch m¨ ussen dann endg¨ ultig die Segel gestrichen werden, was zum Programmabbruch f¨ uhrt: memoryExhaustedHook c a l l e d could f r e e some memory −> t r y again memoryExhaustedHook c a l l e d no more memory a v a i l a b l e −> b a d a l l o c
Per Definition liefert die Funktion set_new_handler den zuletzt aktiven Handler. Je nach Applikation sollte man sich diesen merken und wieder einsetzen, wenn man seinen eigenen Handler nicht mehr im Programm haben will. Nachdem wir jetzt das erwartete Verhalten bei Speicherknappheit kennen, wissen wir endlich auch, wie sich ein eigener new Operator korrekt zu verhalten hat, um dieser Konvention zu entsprechen. Zur Demonstration habe ich das obige Beispiel um eine korrekt funktionierende Definition eines globalen new[] Operators erweitert (correct_new_operator_demo.cpp). Bis auf den neu hinzugekommenen new[] Operator hat sich das Programm nicht ver¨andert, also m¨ ochte ich auch nur diesen hier abdrucken: 23 24 25 26 27 28 29 30 31
void ∗ operator new [ ] ( s i z e t s i z e ) throw( b a d a l l o c ) { cout << ” g l o b a l new [ ] c a l l e d with s i z e ” << s i z e << e n d l ; for ( ; ; ) { i f ( void ∗ mem block = malloc ( s i z e ) ) return ( mem block ) ; // u s u a l l y one can use the v a r i a b l e new handler , which
12.3 Speicherverwaltung
399
// i s the standard p l a c e , where i t i s s t o r e d . However , // t h i s i s C++−Compiler Implementation dependent and may // not work . T h e r e f o r e t h e r e i s a more p o r t a b l e // t r i c k to o b t a i n the new handler : new handler i n s t a l l e d h a n d l e r = s e t n e w h a n d l e r ( 0 ) ; set new handler ( i n s t a l l e d h a n d l e r ) ;
32 33 34 35 36 37 38
if ( ! installed handler ) throw b a d a l l o c ( ) ; // no h a n d l e r i n s t a l l e d , s o r r y . . . i n s t a l l e d h a n d l e r ( ) ; // ha n dl e r e x i s t s − t h e r e i s hope : − )
39 40 41
}
42 43
}
Man sieht hier ganz deutlich, warum ich zuvor gemeint habe, dass ein eigener Handler unbedingt eine bad_alloc Exception werfen muss, wenn er beschließt, dass gar nichts mehr geht: solange er dies n¨amlich nicht tut, signalisiert er, dass es m¨ oglich w¨ are, dass er vielleicht doch noch irgendwo ein wenig Speicher auftreiben kann. Es ist also durchaus legitim, dass ein solcher Handler nicht auf einen Schlag einen riesigen Brocken Memory frei macht. Stattdessen kann er bei jedem Aufruf ein wenig mehr Speicher frei machen, bis new endlich zufrieden ist. Deshalb l¨auft die Speicherbeschaffung auch in einer Endlosschleife, wie man in den Zeilen 27–42 sehen kann. Diese wird nur dadurch beendet, dass entweder genug Speicher da ist oder eine Exception das Gemetzel beendet. In den Zeilen 36–37 wird ganz bewusst ein kleiner Trick implementiert, um zum entsprechenden Handler zu kommen: Im Normalfall ist der Handler in der globalen Variable _new_handler gespeichert. Allerdings sind Variablen, die mit einem Underline beginnen, per Konvention besondere Systemvariablen, die von Applikationsentwicklern gef¨alligst nicht verwendet werden sollen. Nebenbei haben solche Variablen auch die nicht gerade verwunderliche Eigenschaft, ohne vorherige Ank¨ undigung ihren Namen oder Platz zu uhrt zu einer nicht besonders portablen Implementation. ¨andern. Alles das f¨ Wir wissen aber, dass die Funktion set_new_handler als return-Value den zuvor installierten Handler liefert. Genau diese Eigenschaft n¨ utzen wir hier aus, indem wir einen neuen Handler installieren (einfach einen 0-Handler). Dadurch bekommen wir den tats¨ achlich installierten, den wir gleich in Zeile 37 wieder einsetzen, damit alles beim Alten bleibt. Und schon haben wir durch diesen kleinen Trick unser Programm viel portabler gemacht, als es sonst der Fall w¨ are :-). Der Output des Programms zeigt, dass diese Implementation genau das tut, was wir von ihr erwarten. Je nach Rechner f¨allt die Anzahl der Zeilen, die vor dem ersten Fehlschlag von malloc ausgegeben wird ziemlich lang aus. Aufgrund der Wertlosigkeit und der gleichzeitigen hohen Anzahl dieser Zeilen wurde der Output entsprechend gek¨ urzt :-). g l o b a l new [ ] c a l l e d with s i z e 800000 g l o b a l new [ ] c a l l e d with s i z e 400000 [...
l o t s of equal l i n e s deleted
g l o b a l new [ ] c a l l e d with s i z e 400000
...]
400
12. Operator Overloading
memoryExhaustedHook c a l l e d could f r e e some memory −> t r y again g l o b a l new [ ] c a l l e d with s i z e 400000 g l o b a l new [ ] c a l l e d with s i z e 400000 memoryExhaustedHook c a l l e d no more memory a v a i l a b l e −> b a d a l l o c
12.4 Abschließendes zu overloadable Operators Nachdem nun Operator Overloading im Allgemeinen und Spezialit¨aten sowie T¨ ucken der Typumwandlungs-Operatoren und der Speicherverwaltung mittels new und delete im Speziellen besprochen wurden, m¨ochte ich an dieser Stelle noch schnell ein paar abschließende Worte zu diesem Thema verlieren. Vor allem geh¨ ort erw¨ ahnt, dass nicht wirklich alle Operatoren, die in C++ zur Verf¨ ugung stehen (siehe Tafel 3.1), auch wirklich selbst definiert werden k¨onnen. Die wenigen Ausnahmen hierbei bilden der Scope-Operator (::), der Member-Zugriffsoperator (.) und der Member-Zugriff u ¨ber das Dereferenzieren eines Pointers auf einen Member (.*). Um Missverst¨ andnisse zu vermeiden: Den Pfeil-Operator (->) kann man sehr wohl selbst definieren. Diese M¨oglichkeit kann man sehr gut zum Einf¨ uhren einer automatischen Indirektion auf ein anderes Objekt gebrauchen, wie es f¨ ur verschiedene Konzepte der Delegation notwendig ist. Eine allzu genaue Abhandlung zu solchen Themen geh¨ort allerdings in die weiterf¨ uhrende Literatur und w¨ urde den Rahmen dieses Buchs sprengen. Da jedoch ein Overloading des -> Operators ein Verhalten zeigt, das f¨ ur Neulinge nicht auf den ersten Blick einsichtig ist, m¨ochte ich hierzu noch ein kurzes Beispiel besprechen (member_access_overloading_demo.cpp): 1 2
// member access overloading demo . cpp − s h o r t demo f o r // o v e r l o a d i n g o f the −> o p e r a t o r
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyObject ∗ ∗ Just a dummy c l a s s ∗ ∗/
17 18 19 20 21 22 23
c l a s s MyObject { protected : uint32 value ; public : MyObject ( u i n t 3 2 v a l u e )
12.4 Abschließendes zu overloadable Operators
throw ( ) { v a l u e
24
401
= value ; }
25
virtual ˜ MyObject ( ) throw ( ) { }
26 27 28
virtual u i n t 3 2 getValue ( ) { cout << ”MyObject : : getValue ” << e nd l ; return ( v a l u e ) ; }
29 30 31 32 33 34
};
35 36 37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ MyObjectPtr ∗ ∗ Just a dummy pseudo−s m a r t p o i n t e r ∗ ∗/
43 44 45 46 47 48 49 50
c l a s s MyObjectPtr { protected : MyObject ∗ t h e o b j e c t ; public : MyObjectPtr ( MyObject ∗ obj ) throw ( ) : t h e o b j e c t ( obj ) { }
51
virtual ˜ MyObjectPtr ( ) throw ( ) { delete t h e o b j e c t ; }
52 53 54 55 56 57
virtual MyObject ∗ operator −> () { cout << ”MyObjectPtr : : o p e r a t o r −>” << e n d l ; return ( t h e o b j e c t ) ; }
58 59 60 61 62 63
};
64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { MyObjectPtr o b j p t r = MyObjectPtr (new MyObject ( 5 ) ) ;
69
cout << ” c a l l i n g getValue ( ) : ” << e nd l << o b j p t r−>getValue () << e nd l ;
70 71 72
return ( 0 ) ;
73 74
}
Wir haben es hier mit einer Klasse MyObjectPtr zu tun, die nach außen hin so tun soll, als ob eine Variable dieses Typs ein Pointer auf MyObject ist. Was das bedeutet, sieht man in Zeile 71: Hier wird mittels -> die Methode getValue aufgerufen. Was man will, ist der Aufruf von getValue aus MyObject. Genau das erreicht man auch, wie man im folgenden Output sieht: c a l l i n g getValue ( ) : MyObjectPtr : : o p e r a t o r −> MyObject : : getValue 5
402
12. Operator Overloading
Interessant ist jetzt nur, was hier intern passiert. Es ist n¨amlich -> ein un¨arer Operator, wie in Zeile 58 zu erkennen ist. Dieser Operator liefert allerdings wirklich einen Pointer auf das gew¨ unschte Objekt. So weit, so gut, bloß damit ist doch eigentlich -> bereits abgearbeitet, also quasi “aufgebraucht”. Wieso kommt es dann tats¨ achlich zu einem Aufruf von getValue? Das liegt daran, dass C++ diesen Operator zweistufig behandelt: 1. Zuerst wird unser definierter Operator aufgerufen. 2. Auf dem return-Value wird dann der “echte” Memberzugriff gem¨aß -> durchgef¨ uhrt. Der Ausdruck obj_ptr->getValue() wird also vom Compiler so behandelt, als ob hier (obj_ptr.operator->())->getValue() st¨ unde. Dieses Verhalten ist auch das einzig sinnvolle, denn ansonsten w¨ urde ein Overloading keinen Wert haben. Ein Objekt kann nur durch diese Behandlung des Operators nach außen hin so tun, als ob es ein Pointer auf ein anderes Objekt w¨ are. Vorsicht Falle: Drei Operatoren gibt es, bei denen ich allen Lesern den guten Tipp geben m¨ ochte, die Finger von einem Overloading zu lassen, außer es geht gar nicht anders. Dies ist einerseits der , (=Komma) Operator, andererseits && und ||. Das Problem dabei ist, dass hier nicht mehr gesagt ist, ob standardm¨ aßig eine short Evaluation des Ausdrucks stattfindet. Der Compiler kann sehr wohl entscheiden, dass hier auf jeden Fall den selbstgebastelten Operator aufruft, weil er diesem ja nicht ins Handwerk pfuschen will. Das kann alle m¨ oglichen Probleme, vom einfachen Performancemord bis hin zur Segmentation Violation in gewissen Situationen nach sich ziehen. Sollte man also wirklich diese Operatoren selbst definieren wollen, so muss man auf allen verwendeten Plattformen testen, welche Art der Evaluierung vom Compiler herangezogen wird und entsprechend darauf reagieren. Vorsicht Falle: Neben den bereits erw¨ahnten Fallen gibt es noch eine ganz besondere, in die vor allem Neulinge tappen, wenn sie beginnen, sich beim Operator Overloading wohl zu f¨ uhlen: Die u ¨bertriebene und damit oft nicht mehr intuitive Anwendung des Operator Overloadings! Operatoren sind nur dann eine Hilfe f¨ ur Entwickler, wenn sie intuitiv sind. Zum Beispiel ist ein + Operator vollkommen intuitiv, wenn er zur Addition von z.B. Vektoren oder Matrizen definiert ist. Auch zum aneinander Reihen von Strings (=concatenation) ist dessen Einsatz noch sinnvoll. Gar nicht mehr intuitiv ist dieser Operator, wenn er z.B. f¨ ur ein Objekt Auto als Gas geben definiert wird und vielleicht auch dann noch der Operator - als bremsen dazukommt. Klar wird man nach Lekt¨ ure der Dokumentation wissen, dass diese Operatoren so verwendet werden, aber trotzdem ist in diesem Fall die Implementation u ¨ber Methoden bei weitem intuitiver und leichter lesbar.
12.4 Abschließendes zu overloadable Operators
403
Mir ist schon bewusst, dass die Grenze zwischen sinnvoller und schlechter Anwendung fließend ist. Es ist aber garantiert besser, im Fall des geringsten Zweifels an der Intuitivit¨ at eines Operators, diesen nicht zu definieren, sondern stattdessen eine entsprechende Methode zu implementieren.
13. Templates
Bevor wir u ¨berhaupt in das Thema der Templates eintauchen m¨ochte ich hier aus gutem Grund etwas vorausschicken: Templates sind eines der m¨achtigsten Konzepte von C++. Gleichzeitig sind sie auch eines der am schwierigsten zu begreifenden Konzepte, vor allem f¨ ur Leute, die sich noch nicht so gut in C++ oder in der Softwareentwicklung im Allgemeinen auskennen. Folgende Steigerung der Schwierigkeitsgrade ist in der Diskussion der Templates in der Folge zu finden • Abschnitt 13.1 ist sehr leicht zu verdauen. • Abschnitt 13.2 und Abschnitt 13.3 sind auch noch keine allzu schwere Kost und deshalb f¨ ur Neulinge genießbar. • Bei Abschnitt 13.4 wird es schon etwas haariger und Abschnitt 13.5 w¨ urde ich als for Experts only bezeichnen. Meine Empfehlung ist daher, dass sich Einsteiger als Versuch noch an Abschnitt 13.4 versuchen sollten. Sollte es hierbei schon zu Verst¨ andnisproblemen kommen, kann man guten Gewissens dieses Kapitel auslassen und dessen Lekt¨ ure auf einen sp¨ateren Zeitpunkt verlegen, zu dem man dann schon mehr Erfahrung im Umgang mit C++ hat. Keinesfalls w¨ urde ich Neulingen anraten, Abschnitt 13.5 zu lesen, denn ohne gr¨ oßere Erfahrung mit Templates in der Praxis ist er wirklich praktisch unverdaulich. Mit Erfahrung zu einem sp¨ateren Zeitpunkt ist dieser Abschnitt allerdings Goldes wert und dann ist der Augenblick gekommen, wo man dieses Buch noch einmal aufschlagen sollte. So, jetzt aber wirklich – die Templates wollen behandelt werden. Als sauberes und klares Abstraktionskonzept haben wir bisher das Konzept von Klassen kennen gelernt, das es erlaubt Daten, Operatoren und Methoden zu einem vollwertigen Typ zu vereinen. Nun gibt es aber F¨alle, in denen selbst dies nicht genug ist und in denen wir am liebsten keine konkreten, sondern generische Datentypen zur Verf¨ ugung h¨atten. Was bedeutet das nun schon wieder? Wenden wir uns kurz einem Beispiel aus der Welt der immer wieder gebrauchten Utilities zu und betrachten wir einen Buffer: In einen solchen wollen wir nach dem FIFO (=First In First Out) Konzept im einfachsten Fall mittels einer Methode put Daten hineinstellen und mittels get diese in derselben Reihenfolge wieder herausholen. Welchen Typ diese Daten haben,
406
13. Templates
ist f¨ ur das generelle Konzept eines Buffers nicht wichtig, denn sein Verhalten ¨andert sich ja nicht, egal ob wir nun int Werte oder char Werte hineinstellen und wieder herausholen wollen. Nicht nur bei vollst¨ andigen Klassen findet man diese F¨alle, in denen der Datentyp eigentlich f¨ ur die Funktionalit¨at nicht so wichtig ist, denselben Fall gibt es auch bei simplen Funktionen. Nehmen wir einfach nur an, dass es eine Funktion geben soll, die ein Array von Daten sortiert. Egal, welchen tollen Sortieralgorithmus diese Funktion nun implementiert, eines ist sicher: Solange der Datentyp, der in diesem Array gespeicherten Elemente die Operatoren >, und < (evtl. auch ==, je nach Algorithmus) und den Zuweisungsoperator = unterst¨ utzt, ist dieses sortierbar. Genau hier sind wir also beim Prinzip der generischen Programmierung gelandet: Wir schreiben eine Klasse oder auch einen Algorithmus, ohne in der Implementierung auf konkrete Datentypen einzugehen. Stattdessen parametrisieren wir unsere Implementierung bei deren Verwendung mit diesen Datentypen um vom generischen Fall zur konkreten Implementation zu kommen. Im Fall unseres Buffers von zuvor bedeutet das im Prinzip Folgendes: 1. Wir schreiben eine generische Klasse Buffer, die so allgemein formuliert ist, dass ihr der Datentyp egal ist. 2. Bei Verwendung dieser Klasse wird u ¨ber einen konkreten Typ-Parameter (z.B. char) ihr Einsatzgebiet festgelegt. In diesem Fall h¨atten wir also einen Buffer, der nur mit Characters umgehen kann. Dasselbe passiert im Fall unserer Funktion zum Sortieren eines Arrays. Auch hier wird der Algorithmus implementiert, ohne auf den echten Typ der zu sortierenden Elemente R¨ ucksicht zu nehmen. Im speziellen Fall kann diese Funktion dann entweder ein int32-Array, ein char-Array oder auch ein beliebiges Objekt-Array sortieren, wenn nur der Datentyp die entsprechenden Operatoren implementiert. In C++ sind die sogenannten Templates der Mechanismus, der es erlaubt, generische Datentypen zu schreiben und diese dann durch TypParametrisierung in konkreten Varianten zum Einsatz zu bringen. ¨ Vorsicht Falle: Um einigen Lesern komische Uberraschungen zu ersparen m¨ochte ich gleich eine wichtige Warnung vorwegnehmen: Nicht alle Compiler k¨onnen mit allen in der Folge vorgestellten Template-Aspekten umgehen. Es kann also passieren, dass das eine oder andere Testprogr¨ammchen, das in der Folge vorgestellt wird, nicht compiliert. ¨ Ublicherweise ist es so, dass im Prinzip kein heutzutage verwendeter Compiler ein Problem mit Function Templates (siehe Abschnitt 13.1) und mit Class Templates (siehe Abschnitt 13.3) hat. So einige Compiler k¨onnen allerdings gewisse Aspekte der Spezialisierung von Templates nicht (siehe Abschnitt 13.5).
13.1 Function Templates
407
13.1 Function Templates Um Lesern, die bisher noch keine Erfahrung mit generischer Programmierung sammeln konnten, einen sanften Einstieg zu bieten, wenden wir uns zuerst dem leichter genießbaren Thema der generischen Funktionen zu, die in C++ auch als Function Templates bezeichnet werden. Ziehen wir zu Erkl¨arungszwecken ein ganz einfaches Beispiel heran: Wir wollen eine Funktion implementieren, die als Parameter ein beliebiges Array nimmt und als return-Value den Index des wertm¨ aßig gr¨ oßten Elements in diesem Array liefert. Sollte der Maximalwert nicht eindeutig bestimmbar sein, weil mehrere Elemente gleich groß sind, so wird der Index des ersten der m¨oglichen Elemente geliefert. Das Template zu dieser Funktion, das der generischen Implementation entspricht, findet sich in find_max_template_function.h und sieht so aus: 1 2
// f i n d m a x t e m p l a t e f u n c t i o n . h − implementation o f a template // f u n c t i o n t h a t f i n d s the g r e a t e s t element i n an a r b i t r a r y a r r a y
3 4 5
#i f n d e f f i n d m a x t e m p l a t e f u n c t i o n h #define f i n d m a x t e m p l a t e f u n c t i o n h
6 7
#include ” u s e r t y p e s . h”
8 9 10 11 12
template < c l a s s ElementType> u i n t 3 2 findMax ( const ElementType ∗ elements , u i n t 3 2 num elements ) { u i n t 3 2 current max = 0 ;
13
for ( u i n t 3 2 index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
14 15 16 17 18 19 20
}
21 22
#endif // f i n d m a x t e m p l a t e f u n c t i o n h
Um zu verstehen, was hier passiert, werfen wir einmal einen Blick auf Zeile 9, denn dort ist der Kern dessen versteckt, was Templates in C++ ausmacht: Die Deklaration des generischen Typs. Die Deklaration template bedeutet in diesem Kontext Folgendes: ElementType ist in diesem Kontext ein generischer Typ, der bei Verwendung durch einen konkreten Typ ersetzt werden muss. Wo auch immer also im Rahmen der Deklaration und Definition der Funktion ElementType als Typ verwendet wird, steht dieser also als Platzhalter f¨ ur den konkreten Typ. Das Keyword template und die spitzen Klammern, die den bzw. die generischen Typen einfassen, sind in der C++ Syntax so definiert. Betrachten wir den Rest der Deklaration der Funktion in den Zeilen 9– 10, dann sehen wir, dass ElementType gleich einmal als generischer Typ in der Parameterliste der Funktion verwendet wird. Dadurch ist die genaue
408
13. Templates
Bedeutung der Deklaration einmal folgendermaßen zu lesen: Es wird hier eine Funktion findMax deklariert, die als return-Value einen uint32 liefert und die als Parameter ein Array beliebiger Elemente sowie die Anzahl der in diesem Array enthaltenen Elemente nimmt. Dazu sei noch anzumerken, dass hier der generische Parameter class ElementType nicht zwangsweise f¨ ur “echte” Klassen steht, es sind auch primitive Datentypen, wie z.B. int, zul¨assig. In der Implementation der Funktion in den Zeilen 11–20 sieht man, dass der Parameter elements ganz genau so verwendet wird, als w¨ urde es sich hier um einen konkreten Datentyp handeln. In Zeile 16 erkennt man, dass vom konkreten Datentyp nur eines verlangt wird: Instanzen dieses Datentyps m¨ ussen mittels des > Operators miteinander vergleichbar sein. Jetzt stellt sich nur noch die Frage, wie man mit einem solchen Template arbeitet. Irgendwann muss ja wohl aus unserer generischen Deklaration und Definition auch einmal eine konkret aufrufbare Funktion werden. Wie man in der Folge erkennen kann, m¨ ussen wir uns nicht im Besonderen darum k¨ ummern, sondern k¨ onnen das einfach dem Compiler u ¨berlassen. Wir tun einfach so, als w¨ are diese Funktion f¨ ur einen bestimmten Datentyp existent. Werfen wir also kurz einen Blick auf unser Testprogramm, das dieses Verhalten demonstriert (find_max_test.cpp): 1
// f i n d m a x t e s t . cpp − demo , how the template works
2 3 4
#include ” f i n d m a x t e m p l a t e f u n c t i o n . h” #include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12
int main ( int a r g c , char ∗ argv [ ] ) { int32 test array1 [ ] = { 4 , 1 , 2 , 5 , 3 } ; double t e s t a r r a y 2 [ ] = { 1 7 . 0 , 1 2 . 0 , 1 3 . 4 , − 7 . 5 , 1 1 . 3 } ;
13
cout << ”findMax f o r i n t 3 2 a r r a y . . . max v a l u e i s at index ” << findMax ( t e s t a r r a y 1 ,5) << e nd l ;
14 15 16
cout << ”findMax f o r double a r r a y . . . max v a l u e i s at index ” << findMax ( t e s t a r r a y 2 ,5) << e nd l ;
17 18 19
return ( 0 ) ;
20 21
}
In den Zeilen 11–12 definieren wir zwei Arrays, eines mit int32 und eines mit double Werten. Mittels unserer Funktion findMax, die wir als Template definiert haben, wollen wir jeweils den Index des gr¨oßten Elements im Array finden. Betrachtet man die Zeilen 15 und 18, so sieht man, dass die Verwendung eines solchen Funktionstemplates gar keine besondere Hexerei ist: Es wird einfach die Funktion aufgerufen, als ob sie f¨ ur diesen Typ sowieso definiert w¨ are. Dass die Funktion in beiden F¨allen korrekt arbeitet, sieht man am Output:
13.1 Function Templates
409
findMax f o r i n t 3 2 a r r a y . . . max v a l u e i s at index 3 findMax f o r double a r r a y . . . max v a l u e i s at index 0
Was tut der Compiler also hinter den Kulissen, dass wir eine generische Funktion einfach so aufrufen k¨ onnen und in Wirklichkeit den Aufruf einer konkreten Funktion damit erreichen? Prinzipiell geht er nach folgendem Schema vor: 1. Beim Aufruf einer Template Funktion wirft er einen Blick auf deren Deklaration, um herauszufinden, was denn die generischen Teile des Templates sind (davon kann es nat¨ urlich mehrere geben...). In unserem Fall gibt es den Datentyp ElementType, der eine konkrete Entsprechung braucht. 2. Die Aufrufparameter werden mit den generischen Teilen des Templates verglichen. Dadurch erf¨ ahrt der Compiler, welche Entsprechung er konkret nehmen soll. In Zeile 15 unseres Testprogramms findet der Compiler heraus, dass ElementType wohl int32 sein muss, denn die Funktion wird mit einem int32 * aufgerufen und im Template steht als Parameter ElementType *. Wenn ElementType also f¨ ur int32 steht, passt alles wunderbar zusammen. 3. Jetzt, wo der Compiler herausgefunden hat, was ElementType ist, geht er daran, im gesamten Funktionsrumpf alle Vorkommen dieses generischen Typs durch den konkreten Typ int32 zu ersetzen. Er bastelt also eine konkrete Funktion f¨ ur diesen Typ. 4. Die resultierende konkrete Funktion wird danach vom Compiler u ¨bersetzt, als ob wir selbst eine Funktion geschrieben h¨atten, die mit einem int32 Array arbeiten w¨ urde. 5. Der Aufruf der konkreten Funktion wird nun endg¨ ultig an der Stelle eingesetzt, an der wir mit findMax in dieser Auspr¨agung arbeiten. Ganz genau dasselbe passiert in Zeile 18, nur dass hier die Analyse der generischen Parameter ergibt, dass der konkrete Datentyp ein double ist. Der Compiler erzeugt also eine neue konkrete Auspr¨agung der Funktion, die mit einem double Array arbeiten kann und setzt diesen Aufruf ein. Nat¨ urlich wird nicht jedes Mal, wenn der Compiler z.B. auf einen double st¨oßt, wieder eine neue Auspr¨ agung der Funktion erzeugt, denn die besondere Auspr¨agung mit double existiert ja nach dem ersten Vorkommen schon. Es wird ab dem zweiten Vorkommen einfach diese existente Auspr¨agung herangezogen. Genaueres dazu wird noch in Abschnitt 13.7 behandelt. Geht man dieses Schema, wie der Compiler mit Templates arbeitet, noch einmal im Kopf durch, dann erkennt man schnell, warum ich hier die gesamte Definition der Funktion einmal kurzerhand quasi als inline Code in das Header File geschrieben habe: Der Compiler muss die Definition der Funktion kennen, um aus dieser generischen Definition auch eine konkrete Auspr¨agung durch Ersetzen des Typs bauen und danach u ¨bersetzen zu k¨onnen. Ich m¨ochte allerdings hier gleich vorausschicken, dass dies im Prinzip notgedrungen so sein muss, dass es aber zum Gl¨ uck trotzdem M¨oglichkeiten gibt, wie man die
410
13. Templates
Deklaration und die Definition von Templates voneinander trennen kann und damit wieder unsere saubere Aufteilung in cpp und h Files erreichen kann. Wie man dabei vorgeht, wird in Abschnitt 13.7 noch genauer behandelt. Um den Blick auf das Wesentliche nicht zu verlieren, begn¨ ugen wir uns derzeit einfach einmal mit dieser etwas unsch¨onen Organisation des Source Codes und lassen Templates vollst¨ andig inklusive Definitionen im Header stehen. Nur so als kleines Gedankenexperiment m¨ochte ich hier noch folgende Frage in den Raum stellen: Was muss man tun, wenn man bereits im generischen Typ selbst verankern will, dass es sich um einen Pointer handelt? Als Parameter will man also nicht mehr const ElementType *element stehen haben, sondern z.B. const ElementTypePtr element Nun, im Prinzip ist das ganz einfach, wie die folgende Modifikation beweist (find_max_template_function_v2.h): 1 2
// f i n d m a x t e m p l a t e f u n c t i o n v 2 . h − a s l i g h t change o f the f i r s t // implementation t h a t i n c l u d e s the p o i n t e r i n the g e n e r i c type
3 4 5
#i f n d e f f i n d m a x t e m p l a t e f u n c t i o n h #define f i n d m a x t e m p l a t e f u n c t i o n h
6 7
#include ” u s e r t y p e s . h”
8 9 10 11 12
template < c l a s s ElementTypePtr> u i n t 3 2 findMax ( const ElementTypePtr elements , u i n t 3 2 num elements ) { u i n t 3 2 current max = 0 ;
13
for ( u i n t 3 2 index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
14 15 16 17 18 19 20
}
21 22
#endif // f i n d m a x t e m p l a t e f u n c t i o n h
Es wurde ganz einfach der Name des generischen Parameters ge¨andert, um Entwicklern, die Zeile 9 lesen, zu signalisieren, dass hier ein Pointer erwartet wird. Weiters wurde beim ersten Parameter in Zeile 10 der * weggelassen, denn wir wollen ja, dass der Pointer bereits Teil des generischen Parameters ist. Beim Aufl¨ osen kommt der Compiler ja schon automatisch darauf, dass mit ElementTypePtr z.B. in unserem Testprogramm entweder ein int32 * oder eben ein double * gemeint ist, denn nur so kann er ein korrektes Matching vornehmen. Das Testprogramm dazu hat sich bis auf das Inkludieren des modifizierten Headers gar nicht ver¨ andert. Deshalb m¨ochte ich es auch hier nicht abdrucken. Es ist unter dem Namen find_max_test_v2.cpp auf der beiliegenden
13.1 Function Templates
411
CD-ROM verewigt. Auch den Output erspare ich hier den Lesern, denn auch dieser hat sich in keinster Weise ver¨ andert. Leider hat es sich sowohl im Alltag als auch in der Literatur eingeb¨ urgert, die Typ-Parameter von Templates unseligerweise mit m¨oglichst kurzen und nichts sagenden Bezeichnern, wie z.B T, zu versehen. Von dieser Praxis kann ich nur energisch abraten, denn dies f¨ uhrt zu sehr schwer lesbaren Programmen. Deshalb habe ich auch ganz bewusst den generischen Typnamen im abge¨anderten Beispiel mitge¨ andert. Damit wird einfach klar signalisiert, dass man hier mit einem Pointer auf irgendetwas zu tun hat. W¨ urde hier einfach ¨ T oder Type oder Ahnliches stehen, so m¨ usste man erst die Implementation lesen um u ¨berhaupt eine Idee zu bekommen, dass hier ein Array erwartet wird. Gehen wir in unseren Gedankenexperimenten zu generischen Typen noch einen Schritt weiter indem wir behaupten, dass der Parameter, der die Anzahl der Elemente angibt, eigentlich auch von seinem Typ her gar nicht so wirklich festgelegt ist. Es k¨ onnte sich hier, je nach Anwendung, um einen uint32, einen int32, einen long oder sonst einen ganzzahligen Datentyp handeln. Es k¨ onnte sich sogar um ein besonderes Objekt handeln, das nach außen eine Zahl repr¨ asentieren kann, das aber selbst keineswegs ein primitiver Datentyp ist. So ein Beispiel haben wir ja bereits in Abschnitt 12.2 in Form unserer Range-Controlled Ganzzahl kennen gelernt. Was also, wenn wir auch diesen zweiten Parameter generisch definieren wollen? Nun, ganz einfach: Es ist nat¨ urlich in C++ erlaubt, beliebig viele generische Typen f¨ ur ein Template festzulegen. Also schreiten wir zur Tat und ¨andern unser Template entsprechend ab (find_max_template_function_v3.h): 1 2
// f i n d m a x t e m p l a t e f u n c t i o n v 3 . h − a s l i g h t change t h a t a l s o // makes the num elements parameter g e n e r i c
3 4 5
#i f n d e f f i n d m a x t e m p l a t e f u n c t i o n h #define f i n d m a x t e m p l a t e f u n c t i o n h
6 7
#include ” u s e r t y p e s . h”
8 9 10 11 12
template < c l a s s ElementType , c l a s s IntType> IntType findMax ( const ElementType ∗ elements , IntType num elements ) { IntType current max = 0 ;
13
for ( IntType index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
14 15 16 17 18 19 20
}
21 22
#endif // f i n d m a x t e m p l a t e f u n c t i o n h
In den Zeilen 9–10 erkennt man, dass man innerhalb der spitzen Klammern auch mehrere generische Typbezeichner angeben kann. In unserem Fall ha-
412
13. Templates
ben wir es hier also mit dem bereits bekannten ElementType sowie auch mit einem IntType zu tun. Wenn wir schon f¨ ur die Anzahl der Elemente einen generischen Typ verwenden, dann ist es nat¨ urlich auch nur recht und billig, f¨ ur den return-Value der Funktion denselben generischen Typ zu verwenden. Also haben wir hier auch die Deklaration des return-Values u ¨ber unseren generischen Typ vorgenommen. Das bringt uns genau zur Eigenschaft von generischen Typen in Templates, die wir bisher implizit vorausgesetzt haben: Innerhalb einer Template Deklaration und Definition wird ein generischer Typ ganz genau gleich verwendet, wie wir sonst einen konkreten Typ verwenden w¨ urden. Das Ersetzen durch einen konkreten Typ nimmt dann der Compiler f¨ ur uns vor. Der G¨ ultigkeitsbereich der generischen Typbezeichner ist selbstverst¨ andlich auf die Deklaration und Definition des Templates selbst ¨ limitiert, ansonsten w¨ urde es ja zu sehr b¨osen Uberraschungen kommen. Außer im return-Value und im zweiten Parameter findet sich der generische Typ IntType nat¨ urlich auch in der Definition der Funktion wieder, denn current_max muss nat¨ urlich auch diesen Typ haben, ebenso unsere Laufvariable index. ¨ Andern wir nun wiederum unser Testprogramm so ab, dass es unsere modifizierte Version des Templates inkludiert, so zeigt sich, dass diese Version genau dasselbe Resultat liefert, wie die beiden Versionen zuvor. Der Compiler betrachtet hier einfach nur IntType als zus¨atzlichen generischen Parameter, zu dem er eine konkrete Entsprechung finden muss. Im Fall unseres Testprogramms wird als Entsprechung int gefunden, denn eine Ganzzahlkonstante wird vom Compiler nat¨ urlich implizit als int betrachtet. Das ge¨ anderte Testprogramm findet sich auf der beiliegenden CD-ROM unter dem Namen find_max_test_v3.cpp und der Output, den dieses Programm liefert, ist erwartungsgem¨ aß wieder derselbe, den auch die Vorg¨angervarianten produziert haben. Auf den ersten Blick scheint diese Version des Templates also die Erf¨ ullung aller Tr¨ aume zu sein: Wir implementieren einmal den Algorithmus und er funktioniert unabh¨ angig vom Typ der Elemente und auch unabh¨angig vom Typ der L¨ angenangabe wunderbar. H¨ atten wir keine Function Templates zur Verf¨ ugung, so m¨ ussten wir f¨ ur jeden bestimmten Elementtyp eine eigene overloaded Function schreiben. Außerdem m¨ ussten wir auch den num_elements Parameter immer an den Typ anpassen oder gleich den gr¨oßtm¨oglichen Ganzzahldatentyp verwenden, um auf der sicheren Seite zu bleiben. Da wir im Vorhinein nicht einmal wissen, mit welchen Parametertypen und Kombinationen derselben wir es zu tun bekommen, schreiben wir gleich eine unendlich lange Latte von verschiedenen Overloadings und hoffen, dass eines davon schon auf die sp¨ atere Verwendung passen wird. Die hier angesprochene Erf¨ ullung aller Tr¨aume birgt allerdings auch ein paar Albtr¨ aume, die man erst nach der anf¨anglichen Euphorie bemerkt. Um auch u ¨ber diese Bescheid zu wissen, betrachten wir einmal ein paar M¨oglich-
13.1 Function Templates
413
keiten etwaiger Fehlverwendungen unseres sch¨onen Templates etwas n¨aher (template_usage_dangers.cpp): 111 112 113 114
int main ( int a r g c , char ∗ argv [ ] ) { int32 test array [ ] = { 4 , 1 , 2 , 5 , 3 } ; RangeControlledInt num with range ( 0 , 5 0 , 5 ) ;
115
// the f o l l o w i n g l i n e r e s u l t s i n a c o m p i l e r e r r o r , because // i n the template the second parameter i s used f o r // a r r a y i n d e x i n g findMax ( t e s t a r r a y , 5 . 5 ) ;
116 117 118 119 120
// the f o l l o w i n g l i n e r e s u l t s i n a c o m p i l e r e r r o r , because // o f two r e a s o n s : // ( 1 ) no c o n s t r u c t o r with an i n t parameter i s d e f i n e d // ( 2 ) the ++ o p e r a t o r i s not implemented findMax ( t e s t a r r a y , num with range ) ; return ( 0 ) ;
121 122 123 124 125 126 127
}
Im obigen Beispiel wurde bewusst nur main abgedruckt. Das Template entspricht der zuletzt betrachteten Variante, die beide Parameter generisch definiert. Die Klasse RangeControlledInt wurde direkt aus dem entsprechenden Beispiel in Abschnitt 12.2 u ¨bernommen. Wenn wir versuchen, das obige Beispiel zu compilieren, dann kommen wir leider zu keinem lauff¨ahigen Programm, denn der Compiler hat hier ein paar Dinge zu bem¨angeln. Erstens passt ihm der Aufruf von findMax aus Zeile 119 ganz und gar nicht. Es wird n¨ amlich hier die Funktion mit einem double als L¨angenparameter aufgerufen. Dies veranlasst den Compiler, eine entsprechende konkrete Implementation mit einem double zu erzeugen. Dieser wird allerdings in unserem Template als Index f¨ ur das Array verwendet, was nat¨ urlich nicht gestattet ist. Zweitens passt dem Compiler auch der Aufruf von findMax aus Zeile 125 nicht, denn hier wird ein RangeControlledInt als L¨angenparameter verwendet. Das bedeutet, dass dieser Typ auch f¨ ur den return-Value und ebenso f¨ ur die Laufvariable in unserem Template verwendet wird. Leider aber fehlt f¨ ur diese Verwendung Entscheidendes: Es gibt keinen Konstruktor, der einfach nur einen einzigen Parameter, n¨amlich den Initialwert, nehmen w¨ urde. Außerdem ist der ++ Operator nicht definiert. Beides wird aber im Template beim Arbeiten mit der Laufvariable sowie mit dem return-Value gebraucht. Man kann nun sagen, dass der Aufruf mit dem double wirklich ein grober Unfug ist und dass man daher froh sein kann, dass der Compiler darauf hinweist. Weiters kann man behaupten, dass man beim RangeControlledInt selbst die Schuld tr¨ agt, weil man auf den entsprechenden Konstruktor und den ++ Operator h¨ atte denken sollen. Also erg¨anzt man diese Klasse und kommt damit auch wirklich problemlos durch den Compiler. Damit hat man sich aber ein wunderbares verstecktes Problem eingehandelt, das verh¨angnisvoll sein kann: Beim Schreiben des Templates hat niemand damit gerechnet, ¨ dass beim Uberbzw. Unterschreiten eines vorgegebenen Wertes eine Excep-
414
13. Templates
tion geworfen wird (was ja die Eigenschaft unseres speziellen Typs ist)! Das kann, je nach Funktion, zu groben Inkonsistenzen f¨ uhren. In unserem Fall passiert es “nur”, dass eine Exception geworfen wird, die durch das Funktionstemplate nicht explizit deklariert wurde – schlimm genug! In anderen F¨allen kann es nat¨ urlich auch zu einer echten Katastrophe kommen. In unserem Fall der RangeControlledInt Klasse kann man sich auch noch auf eine andere Art schnell und sicher die Probleme vom Hals schaffen. Man braucht nur Zeile 125 umschreiben auf: findMax(test_array,static_cast(num_with_range)); Damit hat man die Verwendung unseres selbstdefinierten Cast-Operators erzwungen, wodurch das Template vom Compiler mit einem int32 als zweiten Parameter erzeugt wird. Gleich vorausschicken m¨ochte ich, dass so eine L¨osung nicht immer so leicht zu finden ist, wie man sich ja ausmalen kann. Leider gibt es in C++ keine M¨ oglichkeit, einen generischen Parameter auf einen genau definierten Satz von M¨ oglichkeiten einzuschr¨anken. Daher fehlt uns hier das programmiersprachliche Werkzeug, das notwendig w¨are, um eine Fehlverwendung zu verhindern. Von der anderen Seite betrachtet ist es nat¨ urlich auch nicht immer m¨ oglich bzw. w¨ unschenswert, alle m¨oglichen Kombinationen von Typen auf einen erlaubten Satz einzuschr¨anken, denn damit hat man bereits im Vorfeld Typen ausgeschlossen, die man zum Zeitpunkt der Entwicklung eines Templates noch nicht gekannt hat. Abhilfe dazu w¨ urde das Unterst¨ utzen von sogenannten Contracts in C++ schaffen, aber eine Diskussion dar¨ uber geht an dieser Stelle zu weit. Deshalb m¨ochte ich es hier bei folgender Warnung belassen: Vorsicht Falle: Bevor man in seiner Software ein Template verwendet, muss man sich sehr genau ansehen, welche Anwendung die Entwickler des Templates im Sinn hatten und auf welche Umst¨ande sie R¨ ucksicht genommen haben. Die Verwendung von Templates außerhalb dieser Grenzen kann ungeahnte Fehler zur Folge haben! Leider kann man sehr oft die Details, die helfen, diese Frage zu beantworten, nicht aus der Dokumentation entnehmen. In diesem Fall hilft wirklich nur eine Lekt¨ ure des Template Source Codes um absolute Sicherheit zu haben, dass man nicht in eine Falle tappt.
13.2 Overloading Aspekte von Function Templates Im Prinzip kann man Function Templates auch so betrachten, dass sie durch ihren generischen Typmechanismus viele verschiedene Overloadings f¨ ur eine Funktion automatisch bereitstellen. Was passiert nun, wenn man dazu noch ein “echtes” Overloading implementiert, das eine bestimmte Auspr¨agung eines solchen Function Templates explizit implementiert? Ich kann gleich vorwegnehmen, was die meisten Leser vermuten werden: es funktioniert. Al-
13.2 Overloading Aspekte von Function Templates
415
lerdings erwartungsgem¨ aß nicht ohne gewisse Fallen, die sich aufgrund von Ambiguit¨ aten auftun, wie wir noch sehen werden. Werfen wir also am besten einen Blick auf ein Beispiel(templates_and_overloading.cpp): 1 2
// t e m p l a t e s a n d o v e r l o a d i n g . cpp − o v e r l o a d i n g f u n c t i o n t e m p l a t e s // with c o n c r e t e f u n c t i o n s
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14
template < c l a s s ElementType , c l a s s IntType> IntType findMax ( const ElementType ∗ elements , IntType num elements ) { cout << ” template implementation o f findMax” << e nd l ; IntType current max = 0 ;
15
for ( IntType index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
16 17 18 19 20 21 22
}
23 24 25 26 27 28 29 30
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ u i n t 3 2 findMax ( double ∗ elements , u i n t 3 2 num elements ) { cout << ” c o n c r e t e implementation o f findMax” << e n d l ; u i n t 3 2 current max = 0 ;
31
for ( u i n t 3 2 index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
32 33 34 35 36 37 38
}
39 40 41 42 43 44 45 46
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ int main ( int a r g c , char ∗ argv [ ] ) { int32 test array1 [ ] = { 4 , 1 , 2 , 5 , 3 } ; double t e s t a r r a y 2 [ ] = { 1 7 . 0 , 1 2 . 0 , 1 3 . 4 , − 7 . 5 , 1 1 . 3 } ;
47
findMax ( t e s t a r r a y 1 , 5 ) ; findMax ( t e s t a r r a y 2 , s t a t i c c a s t ( 5 ) ) ; return ( 0 ) ;
48 49 50 51
}
Hier haben wir es einerseits mit unserem bereits bekannten Template zu tun, das in den Zeilen 10–22 zu finden ist. Andererseits gibt es in den Zeilen 27–38 auch eine konkrete Implementation von findMax f¨ ur ein Array von double Elementen und einen uint32 als L¨ angenparameter.
416
13. Templates
In Zeile 48 wird findMax mit einem int32 Array und einem int L¨angenparameter aufgerufen, was nat¨ urlich den Compiler dazu bewegt, eine konkrete Auspr¨ agung aus dem Template zu generieren. Anders verh¨alt es sich da schon mit Zeile 49, in der findMax mit einem double Array und einem uint32 L¨angenparameter aufgerufen wird. Da unsere konkrete Implementierung von findMax haarscharf diesen Parametersatz deklariert, sieht der Compiler u ¨berhaupt keine Veranlassung, aus dem Template eine konkrete Auspr¨agung zu basteln, denn es gibt ja bereits eine Implementation der Funktion, die alle W¨ unsche erf¨ ullt. Der Beweis dieser Aussage findet sich im Output des Programms: template implementation o f findMax c o n c r e t e implementation o f findMax
Das Angenehme an dieser Tatsache ist, dass wir also offensichtlich einen gewissen Einfluss auf bestimmte Auspr¨agungen eines Templates nehmen k¨onnen. Wenn z.B. eine besondere Auspr¨agung eines Templates mit einem ganz bestimmten Datentyp, aus welchem Grund auch immer, eine besondere Implementation ben¨ otigt, dann k¨ onnen wir diese explizit schreiben. Hiermit kann man z.B. sehr gut die Behandlung von Sonderf¨allen innerhalb von Templates in Grenzen halten. Wo es eine angenehme Seite gibt, ist leider oft die unangenehme Seite auch nicht weit. Nat¨ urlich gibt es bei dieser Vorgehensweise auch nette Stolpersteine: Vorsicht Falle: Sollte der Parametersatz beim Aufruf nicht vollkommen exakt auf die konkrete Implementation der Funktion passen, dann kann man den Compiler in ein Ambiguit¨ atsproblem st¨ urzen, das er nicht mehr automatisch aufl¨ osen kann. Nehmen wir nur einmal an, dass in Zeile 49 der Aufruf folgendermaßen lauten w¨ urde: findMax(test_array2,5) dann bedeutet dies f¨ ur den Compiler einen Aufruf mit einem double Array und mit einem int Parameter. Das passt nicht vollkommen exakt auf die konkrete Implementation, ist aber auch noch nicht so weit daneben, dass nicht mit einer impliziten Typumwandlung noch etwas zu machen w¨are. Je nach Compiler passiert es hierbei, dass er entweder eine Warning von sich gibt, oder sich sogar vollst¨ andig weigern kann, die Entscheidung f¨ ur eine der beiden Varianten selbstt¨ atig zu treffen. Es kann dann nur noch mit einem expliziten Cast Abhilfe geschaffen werden. Wenn man allerdings mit einer fremden Library arbeitet, kann das schon einiges an R¨atselraten zur Folge haben, bis man endlich durchschaut hat, warum sich der Compiler an einer bestimmten Stelle schlicht und ergreifend weigert, seine Arbeit zu erledigen. Bisher sind wir immer davon ausgegangen, dass der Compiler durch Analyse der Aufrufparameter implizit eine passende konkrete Auspr¨agung einer Funktion erzeugt. Manchmal sind wir allerdings interessiert daran, dies selbst in die Hand zu nehmen. Betrachten wir nur einfach den Fall, dass wir in ei-
13.2 Overloading Aspekte von Function Templates
417
nem Programm immer das Maximum aus einem int32 Array finden wollen. Andererseits jedoch haben wir es in den verschiedenen Programmteilen einmal mit einem signed char, ein andermal mit einem int32 und wieder ein andermal mit einem long Parameter als Index zu tun. Der Compiler w¨ urde nun drei verschiedene konkrete Auspr¨agungen der Funktion erzeugen, was nicht immer w¨ unschenswert ist. In diesem Fall w¨are es genug, wenn einfach eine Version mit einem long als zweiten Parameter erzeugt w¨ urde, denn die anderen verwendeten Typen sind ja kompatibel dazu. Zu diesem Zweck gibt es die M¨ oglichkeit der expliziten Angabe, mit welchen Typen man arbeiten will. Dies sieht dann folgendermaßen aus: 1
// f i n d m a x t e s t v 4 . cpp − adapted t e s t f o r V3 o f the template
2 3 4
#include ” f i n d m a x t e m p l a t e f u n c t i o n v 3 . h” #include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { int32 test array [ ] = { 4 , 1 , 2 , 5 , 3 } ;
12
cout << ” c o m p i l e r−i m p l i c i t non−g e n e r i c f u n c t i o n g e n e r a t i o n : ” << findMax ( t e s t a r r a y ,5) << e nd l ;
13 14 15
cout << ” e x p l i c i t non−g e n e r i c f u n c t i o n g e n e r a t i o n : ” << findMax( t e s t a r r a y ,5) << e n d l ;
16 17 18
return ( 0 ) ;
19 20
}
In Zeile 17 erkennt man, dass man nur direkt nach dem Funktionsnamen die gew¨ unschten Typen der konkreten Auspr¨agung in spitze Klammern eingefasst schreiben muss, womit man dem Compiler diese Auspr¨agung aufzwingt. Die Parameter, die tats¨ achlich der Funktion u ussen dann ¨bergeben werden, m¨ vom Compiler eventuell einer entsprechenden Typwandlung unterzogen werden, aber die von uns explizit erzwungene Auspr¨agung wird vom Compiler garantiert nicht mehr ge¨ andert. Durch eine solche explizite Angabe der einzusetzenden Funktionsvariante kann man auch die zuvor beschriebene Falle beim Overloading in den Griff bekommen: Entweder die Parameter passen ganz genau auf die konkret implementierte Funktion, dann braucht man nichts zu tun, oder man erzwingt das Generieren einer bestimmten Auspr¨agung eines Templates, dann braucht der Compiler nicht zu erraten, was wir von ihm haben wollen. Dass das Overloading nicht nur zwischen einem Function Template und einer konkreten Funktion funktioniert, sondern auch zwischen Function Templates, die verschieden parametrisiert sind, soll das folgende Beispiel zeigen (find_max_template_function_v4.h):
418
1 2
13. Templates
// f i n d m a x t e m p l a t e f u n c t i o n v 4 . h − a f u r t h e r m o d i f i c a t i o n // with two t e m p l a t e s t h a t o v e r l o a d each o t h e r
3 4 5
#i f n d e f f i n d m a x t e m p l a t e f u n c t i o n h #define f i n d m a x t e m p l a t e f u n c t i o n h
6 7
#include ” u s e r t y p e s . h”
8 9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− template < c l a s s ElementType> u i n t 3 2 findMax ( const ElementType ∗ elements , u i n t 3 2 num elements ) { u i n t 3 2 current max = 0 ;
14
for ( u i n t 3 2 index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
15 16 17 18 19 20 21
}
22 23 24 25 26 27
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− template < c l a s s ElementType , c l a s s IntType> IntType findMax ( const ElementType ∗ elements , IntType num elements ) { IntType current max = 0 ;
28
for ( IntType index = 0 ; index < num elements ; index++) { i f ( elements [ index ] > elements [ current max ] ) current max = index ; } return ( current max ) ;
29 30 31 32 33 34 35
}
36 37
#endif // f i n d m a x t e m p l a t e f u n c t i o n h
Hier haben wir es mit einem Overloading von zwei Function Templates zu tun, wobei das eine Template nur einen, das andere zwei Typ-Parameter entgegennimmt. Auf diese Art kann man quasi einen default Typ-Parameter ¨ realisieren. Uber explizites Angeben der Auspr¨agung kann man dann jeweils die eine oder die andere Variante verwenden, wie das folgende Beispiel zeigt (find_max_test_v5.cpp): 1
// f i n d m a x t e s t v 5 . cpp − adapted t e s t f o r V4 the template
2 3 4
#include ” f i n d m a x t e m p l a t e f u n c t i o n v 4 . h” #include < i o s t r e a m>
5 6 7
using s t d : : cout ; using s t d : : e n d l ;
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { int32 test array [ ] = { 4 , 1 , 2 , 5 , 3 } ;
12 13 14 15
cout << ”Taking the one−param template : ” << findMax( t e s t a r r a y ,5) << e n d l ;
13.3 Class Templates
419
cout << ”Taking the two−param template : ” << findMax( t e s t a r r a y ,5) << e n d l ;
16 17 18
return ( 0 ) ;
19 20
}
Der Output dieses Programms u ¨berrascht nicht weiter: Taking the one−param template : 3 Taking the two−param template : 3
Ein kleiner Exkurs: Obwohl das Thema schon etwas an Haarspalterei grenzt, m¨ ochte ich einen Aspekt des hier besprochenen speziellen Overloadings von bestimmten Auspr¨ agungen eines Templates mit einer konkreten Implementation herausgreifen: Handelt es sich hierbei tats¨achlich um ein Overloading oder handelt es sich um ein Overriding? Eigentlich wird ja eine bestimmte Auspr¨ agung eines Templates durch die konkrete Implementation u usste es sich doch um ein Overriding handeln. Ande¨bersteuert. Also m¨ rerseits hat der Begriff des Overridings seinen Ursprung in der Eigenschaft des Polymorphismus von Objekten. Und genau mit Klassen und Objekten haben wir an dieser Stelle nichts zu tun, denn es geht ja einzig und allein um Function Templates. Vor allem kann man die Situation auch von einer anderen Seite betrachten: Eine konkrete Implementation ist eines von vielen m¨oglichen Overloadings einer Funktion. Der Unterschied zwischen dieser konkreten Auspr¨agung der Funktion und einer aus einem Template entstandenen ist einzig und allein der, dass die eine per Hand geschrieben wird und die andere automatisch vom Compiler erzeugt wird. Ich pers¨ onlich bin ein Anh¨ anger der soeben beschriebenen Sichtweise, denn diese erscheint mir bei weitem klarer, logischer und vor allem konsequenter zu sein. Also werde ich in der Folge beim Begriff des Overloadings bleiben, wenn solche Aspekte ins Spiel kommen.
13.3 Class Templates Nach dem Einstieg in die generische Programmierung u ¨ber Function Templates kommen wir nun zu einer noch viel brauchbareren Anwendung von Templates, n¨ amlich zu den Class Templates. Wie zu Beginn dieses Kapitels schon am Beispiel eines Buffers besprochen wurde, gibt es viele Anwendungen, bei denen es w¨ unschenswert ist, einen kompletten, generischen Datentyp zu implementieren. Um hier nicht zwanghaft ein weiteres Beispiel einzuf¨ uhren, bleiben wir einfach einmal bei unserem Buffer und schreiben eine Implementation desselben in Form eines Templates (buffer_class_template.h). Die Deklaration des Class Templates liest sich folgendermaßen:
420
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
13. Templates
template < c l a s s ContentType> c l a s s B u f f e r { public : s t a t i c const u i n t 3 2 MAX NUM ELEMENTS = 8 ; private : // f o r b i d assignment by making o p e r a t o r p r i v a t e const B u f f e r& operator = ( const B u f f e r&) { return (∗ t h is ) ; } protected : u i n t 3 2 num elements ; uint32 num elements allocated ; uint32 read index ; uint32 w r i t e i n de x ; ContentType ∗ e l e m e n t s ; public : Buffer () throw( b a d a l l o c ) ;
44
B u f f e r ( const B u f f e r &s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
45 46 47
virtual ˜ B u f f e r ( ) throw ( ) ;
48 49 50
virtual void put ( ContentType const &element ) throw( r a n g e e r r o r ) ;
51 52 53
virtual ContentType getNext ( ) throw( r a n g e e r r o r ) ;
54 55 56
};
Wie bereits von den Function Templates her gewohnt, beginnt man mit dem Keyword template, gefolgt von der Liste der generischen Typenparameter in spitzen Klammern (siehe Zeile 27). In unserem Fall geht es hier um den Typ der einzelnen Elemente, die im Buffer zwischengespeichert werden sollen. Zeile 30 zeigt gleich sehr deutlich, dass dieses Template wirklich nur zu Demonstrationszwecken geschrieben wurde und dass es in dieser Form nicht sinnvoll in Programmen eingesetzt werden kann. Die Anzahl der Elemente, die im Buffer zwischengelagert werden k¨onnen, ist n¨amlich auf großartige acht beschr¨ ankt. Die Zuweisung eines Buffers auf einen anderen wurde in den Zeilen 33–34 verhindert, indem der Zuweisungsoperator private gesetzt wurde. Das zugegebenermaßen schwachsinnige Statement in Zeile 34 existiert nur, um der Deklaration Gen¨ uge zu tun. Da der Operator sowieso nie aufgerufen wird, erw¨ achst hierbei keine Gefahr. Eine Auff¨ alligkeit erkennt man allerdings in den Zeilen 33–34: Sowohl der return-Type als auch der Parameter sind besondere Buffers, n¨amlich Buffers vom selben Typ, wie diese Instanz auch. Die Schreibweise Classname ist n¨amlich genau die Art, wie man eine konkrete Auspr¨agung eines Class Templates erzeugt. Dadurch, dass hier innerhalb der spitzen Klammern selbst wieder der generische Typ ContentType steht, erreicht man, dass “dieselbe” Auspr¨ agung angenommen wird, wie sie auch der konkreten Aus-
13.3 Class Templates
421
pr¨agung entspricht, auf der eine Methode oder ein Operator aufgerufen wurde. Ganz genau dasselbe findet sich in Zeile 45 wieder: Der Copy Constructor ist nat¨ urlich nur f¨ ur ¨ aquivalente konkrete Auspr¨agungen von Buffers definiert, es h¨atte wirklich keinen Sinn z.B. einen Copy Constructor zwischen verschiedenen Auspr¨ agungen zuzulassen. Vorsicht Falle: Vor allem Entwicklern, die noch unge¨ ubt im Erstellen von Templates sind, passiert es ¨ ofters, dass sie bei Parametern bzw. bei return-Values vergessen, die konkrete Auspr¨agung des Templates anzugeben. F¨alschlicherweise k¨ onnte man f¨ ur unseren Copy Constructor aus Zeile 45 Folgendes schreiben: Buffer(const Buffer &src) Diese Deklaration hat jedoch eine ganz andere Bedeutung: src ist n¨amlich irgendeine Auspr¨ agung eines Buffers, allerdings bestimmt das erste Vorkommen im Source-Code, welche konkrete Auspr¨agung gemeint ist. Von dort weg wird angenommen, dass src genau diese Auspr¨agung hat. Zum Gl¨ uck f¨ uhrt ¨ das dann zumeist aus anderen Gr¨ unden zu Compilerfehlern beim Ubersetzen des Templates. Jedoch sind die entsprechenden Fehlermeldungen manchmal ziemlich kryptisch. Dazu muss auch noch gesagt werden, dass einige Compiler sich sowieso partout weigern, die obige Zeile zu u ¨bersetzen und stattdessen auf die Angabe der Auspr¨ agung bestehen – meiner Meinung nach das sinnvollste Verhalten. Andere Compiler setzen wiederum implizit voraus, dass sicher dieselbe Auspr¨agung gemeint ist und u ¨bersetzen das Template dementsprechend. Dieses Verhalten f¨ uhrt zwar zu keinen Problemen, allerdings bin ich kein besonderer Freund davon, dass Compiler die Fehler von Entwicklern stillschweigend korrigieren anstatt sie zu melden. Die Methoden, die hier in der Klasse deklariert waren, m¨ ussen nat¨ urlich auch noch definiert werden. Aus bereits besprochenen Gr¨ unden beschr¨anken wir uns auch hier im Augenblick darauf, dass die Definition ebenfalls im Header steht, um dem Compiler eine faire Chance zu geben, eine entsprechende konkrete Auspr¨ agung der Klasse mit allen zugeh¨origen Methoden erzeugen zu k¨onnen. Folgende Definitionen sind also in unserem Header noch enthalten: 58 59 60 61 62 63 64 65 66 67 68 69 70
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> B u f f e r : : B u f f e r ( ) throw( b a d a l l o c ) { num elements = 0 ; read index = 0; write index = 0; n u m e l e m e n t s a l l o c a t e d = 0 ; // j u s t i n c a s e new f a i l s . . . e l e m e n t s = new ContentType [MAX NUM ELEMENTS] ; n u m e l e m e n t s a l l o c a t e d = MAX NUM ELEMENTS;
422
71
13. Templates
}
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> B u f f e r : : B u f f e r ( const B u f f e r &s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) { cout << ”copy c o n s t r u c t o r ” << e nd l ; i f ( s r c . n u m e l e m e n t s a l l o c a t e d <= 0) throw i n v a l i d a r g u m e n t ( ” c o n s t r u c t i o n from i n v a l i d s r c ” ) ; num elements = s r c . num elements ; read index = src . read index ; write index = src . write index ; n u m e l e m e n t s a l l o c a t e d = 0 ; // j u s t i n c a s e new f a i l s . . . e l e m e n t s = new ContentType [ s r c . n u m e l e m e n t s a l l o c a t e d ] ; num elements allocated = src . num elements allocated ; for ( u i n t 3 2 count = 0 ; count < num elements ; count++) e l e m e n t s [ count ] = s r c . e l e m e n t s [ count ] ; }
92 93 94 95 96 97 98 99 100 101
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> B u f f e r::˜ B u f f e r ( ) throw ( ) { delete [ ] e l e m e n t s ; }
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> void B u f f e r : : put ( ContentType const &element ) throw( r a n g e e r r o r ) { i f ( num elements >= n u m e l e m e n t s a l l o c a t e d ) throw r a n g e e r r o r ( ” b u f f e r o v e r f l o w ” ) ; num elements ++; e l e m e n t s [ w r i t e i n d e x ++] = element ; i f ( w r i t e i n d e x >= n u m e l e m e n t s a l l o c a t e d ) write index = 0; }
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> ContentType B u f f e r : : getNext ( ) throw( r a n g e e r r o r ) { i f ( num elements <= 0) throw r a n g e e r r o r ( ” b u f f e r underflow ” ) ; num elements −−; ContentType &element = e l e m e n t s [ r e a d i n d e x ++]; i f ( r e a d i n d e x >= n u m e l e m e n t s a l l o c a t e d ) read index = 0; return ( ContentType ( element ) ) ; }
In den Zeilen 61–63 sieht man, wie man dem Compiler die Zugeh¨origkeit einer Methodendefinition zu einem Class Template mitteilt: Zuallererst steht wie-
13.3 Class Templates
423
der die altbekannte template Deklaration und anstatt nur den Klassennamen als Scope f¨ ur die Methode zu verwenden, verwendet man bei Templates eine besondere Auspr¨ agung des Templates. In unserem Fall ist es Buffer. Dies bewirkt, dass f¨ ur jede Auspr¨agung entsprechend auch eine neue Auspr¨ agung der Methode erzeugt wird, was nat¨ urlich notwendig ist. Nur um einem oft gemachten Denkfehler vorzubeugen: Manche Leser k¨onnten jetzt anmerken, dass es ja F¨alle geben kann, in denen eine Methode von der Auspr¨ agung des Templates unabh¨angig ist. Eine Methode, die bei unserem Buffer die Anzahl der gespeicherten Elemente liefert, w¨are theoretisch ein solcher Fall, denn diese Anzahl ist immer einfach in num_elements_ gespeichert. Jedoch, wer garantiert uns, dass das innere Layout der Klasse in den verschiedenen Auspr¨ agungen ¨ aquivalent ist? Die Offsets der verschiedenen Variablen k¨ onnen sich sehr wohl u ¨ber verschiedene Auspr¨agungen hinweg ver¨andern und damit ist es mit der Gleichheit von Methoden vorbei! Vorsicht Falle: In den Zeilen 89–90 sieht man im Copy Constructor unseres Buffers, dass in einer Schleife alle Elemente einzeln kopiert werden. Manche Leser m¨ ogen nun geneigt sein, zu sagen, dass dies doch nicht besonders performant ist und dass man durch Verwendung von memcpy hier eine deutliche Performancesteigerung erreichen kann. Dies jedoch w¨ urde in bestimmten F¨ allen zur Katastrophe f¨ uhren, denn damit umgeht man den Copy Constructor der Elemente! In der “langsamen” Implementation, die hier geschrieben wurde, werden diese jedoch garantiert aufgerufen. Es wurde bereits vorweggenommen, wie man bestimmte Auspr¨agungen von Class Templates erzeugt: Man stellt einfach den Typ bzw. die Typen der gew¨ unschten Auspr¨ agung in spitzen Klammern dem Klassennamen nach. Genau dies wird auch im folgenden Testprogramm gemacht, um einerseits einen Buffer f¨ ur char Daten und andererseits einen Buffer f¨ ur float Daten zur Verf¨ ugung zu haben (buffer_class_test.cpp): 1 2
// b u f f e r c l a s s t e s t . cpp − demo how to work with c l a s s t e m p l a t e s
3 4
#include < i o s t r e a m>
5 6
#include ” b u f f e r c l a s s t e m p l a t e . h”
7 8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { B u f f e r c h a r b u f f e r ; B u f f e r f l o a t b u f f e r ;
12 13 14 15 16
char char char char
buffer buffer buffer buffer
. put ( ’ a ’ . put ( ’ b ’ . put ( ’ c ’ . put ( ’ d ’
); ); ); );
17 18 19 20
f l o a t b u f f e r . put ( 1 . 0 f ) ; f l o a t b u f f e r . put ( 2 . 0 f ) ; f l o a t b u f f e r . put ( 3 . 0 f ) ;
424
13. Templates
f l o a t b u f f e r . put ( 4 . 0 f ) ;
21 22
cout << ” co n t en t o f the c h a r b u f f e r . getNext () c h a r b u f f e r . getNext () c h a r b u f f e r . getNext () c h a r b u f f e r . getNext ()
23 24 25 26 27
char b u f f e r : ” << << ” , ” << << ” , ” << << ” , ” << << e n d l ;
28
cout << float float float float
29 30 31 32 33
” c o n t en t o f the b u f f e r . getNext () b u f f e r . getNext () b u f f e r . getNext () b u f f e r . getNext ()
f l o a t b u f f e r : ” << << ” , ” << << ” , ” << << ” , ” << << e nd l ;
34
return ( 0 ) ;
35 36
}
Um zu zeigen, dass unser erstes Class Template auch wirklich funktioniert, werfen wir einen Blick auf den Output, den unser Testprogramm liefert, und dieser sieht erwartungsgem¨ aß so aus: co n t e n t o f the char b u f f e r : a , b , c , d co n t e n t o f the f l o a t b u f f e r : 1 , 2 , 3 , 4
In unserem Buffer haben wir unsch¨ onerweise eine konstante maximale Anzahl von Elementen festgelegt. Nun wollen wir aber z.B. beim Erzeugen der Instanz eines Buffers diese Anzahl selbst bestimmen. Nat¨ urlich kann man dies durch einen besonderen Konstruktor erreichen, aber im Falle von Templates haben wir auch eine andere M¨ oglichkeit. Sehen wir uns diese einfach am Beispiel eines modifizierten Buffers an (buffer_class_template_v2.h): 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
template < c l a s s ContentType , u i n t 3 2 MAX NUM ELEMENTS> c l a s s B u f f e r { private : // f o r b i d assignment by making o p e r a t o r p r i v a t e const B u f f e r& operator = ( const B u f f e r&) { return (∗ t h is ) ; } protected : u i n t 3 2 num elements ; uint32 num elements allocated ; uint32 read index ; uint32 w r i t e i n de x ; ContentType ∗ e l e m e n t s ; public : Buffer () throw( b a d a l l o c ) ;
42
B u f f e r ( const B u f f e r &s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
43 44 45
virtual ˜ B u f f e r ( ) throw ( ) ;
46 47 48
virtual void put ( ContentType const &element ) throw( r a n g e e r r o r ) ;
49 50 51
virtual ContentType getNext ( ) throw( r a n g e e r r o r ) ;
52 53 54
};
13.3 Class Templates
425
Ein Blick auf Zeile 26 verr¨ at, dass zwischen den spitzen Klammern bei einer Template Deklaration nicht ausschließlich nur generische Typenbezeichnungen als Parameter stehen k¨ onnen. Es k¨onnen auch ganz normale typisierte Parameter in dieser Liste enthalten sein, wie hier unser Parameter MAX_NUM_ELEMENTS, der vom Typ uint32 ist. In unserem Fall wird damit die maximale Anzahl von Elementen festgelegt, die ein Buffer halten kann und dieser Parameter ist in der Implementation ¨aquivalent zu einer Konstante ¨ verwendbar. Stellvertretend f¨ ur die Anderungen, die sich in der Definition der Methoden ergeben, m¨ ochte ich in der Folge den Copy-Constructor abdrucken: 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
template < c l a s s ContentType , u i n t 3 2 MAX NUM ELEMENTS> B u f f e r : : B u f f e r ( const B u f f e r &s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) { cout << ”copy c o n s t r u c t o r ” << e nd l ; i f ( s r c . n u m e l e m e n t s a l l o c a t e d <= 0) throw i n v a l i d a r g u m e n t ( ” c o n s t r u c t i o n from i n v a l i d s r c ” ) ; num elements = s r c . num elements ; read index = src . read index ; write index = src . write index ; n u m e l e m e n t s a l l o c a t e d = 0 ; // j u s t i n c a s e new f a i l s . . . e l e m e n t s = new ContentType [ s r c . n u m e l e m e n t s a l l o c a t e d ] ; num elements allocated = src . num elements allocated ; for ( u i n t 3 2 count = 0 ; count < num elements ; count++) e l e m e n t s [ count ] = s r c . e l e m e n t s [ count ] ; }
Man sieht, bis auf die Angabe des zweiten Template Parameters hat sich an der Implementation nichts Entscheidendes ge¨andert. Sehr wohl aber hat sich an der Semantik einer konkreten Auspr¨agung des Typs etwas ge¨andert, das abh¨angig von der Art des Templates sehr wichtig sein kann. In unserem Buffer ist jetzt die Anzahl der Elemente ein Teil des konkreten Typs geworden. Es sind also zwei Buffers, die Elemente vom Typ char halten k¨onnen nicht mehr unbedingt typgleich. Sollte ein Buffer mit einem maximalen Fassungsverm¨ ogen von 8 Elementen und ein anderer mit einem maximalen Fassungsverm¨ ogen von 5 Elementen angelegt werden, so sind diese nicht mehr gegeneinander austauschbar. Genau dieses Verhalten ist einer der Gr¨ unde, warum ich den CopyConstructor als Beispiel f¨ ur die Definition einer Funktion herangezogen habe: Es kann n¨ amlich in diesem Fall nicht mehr passieren, dass zur Laufzeit eine Exception geworfen wird, weil man versucht, Buffers mit verschiedenem Fassungsverm¨ ogen in einer Operation zu durchmischen. Dagegen hat bereits der Compiler seine Einw¨ ande. Dies zeigt sich auch an unserem Testprogramm f¨ ur die angepasste Version des Templates (buffer_class_test_v2.cpp): 1
// b u f f e r c l a s s t e s t v 2 . cpp − another demo f o r c l a s s t e m p l a t e s
2 3 4
#include < i o s t r e a m>
426
13. Templates
5 6
#i n c l u d e ” b u f f e r c l a s s t e m p l a t e v 2 . h”
7 8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { B u f f e r c h a r b u f f e r ; B u f f e r f l o a t b u f f e r ;
12
char char char char
13 14 15 16
buffer buffer buffer buffer
. put ( ’ a ’ . put ( ’ b ’ . put ( ’ c ’ . put ( ’ d ’
); ); ); );
17
float float float float
18 19 20 21
buffer buffer buffer buffer
. put ( 1 . 0 f . put ( 2 . 0 f . put ( 3 . 0 f . put ( 4 . 0 f
); ); ); );
22
cout << ” c o n t e n t o f the c h a r b u f f e r . getNext () c h a r b u f f e r . getNext () c h a r b u f f e r . getNext () c h a r b u f f e r . getNext ()
23 24 25 26 27
char b u f f e r : ” << << ” , ” << << ” , ” << << ” , ” << << e nd l ;
28
cout << float float float float
29 30 31 32 33
” c o n t en t o f the b u f f e r . getNext () b u f f e r . getNext () b u f f e r . getNext () b u f f e r . getNext ()
f l o a t b u f f e r : ” << << ” , ” << << ” , ” << << ” , ” << << e n d l ;
34
// i n the f o l l o w i n g l i n e the copy c o n s t r u c t o r works // p e r f e c t l y because o f the template parameter e q u i v a l e n c e B u f f e r dup buf1 = c h a r b u f f e r ;
35 36 37 38 39 40
//
// t h i s l i n e would r e s u l t i n a c o m p i l e r e r r o r ! B u f f e r dup buf2 = c h a r b u f f e r ;
41 42 43 44
uint32 b u f s i z e = 17; // t h i s l i n e would r e s u l t i n a c o m p i l e r e r r o r ! // B u f f e r b a d t r y ;
45
return ( 0 ) ;
46 47
}
Die Zeilen 10–11 zeigen, dass nun eine konkrete Auspr¨agung eines Buffers durch zus¨ atzliche Angabe seiner Gr¨oße angefordert wird. Dass man auch mit diesen Buffers ganz gleich arbeiten kann wie zuvor, zeigt sich in den Zeilen 13–33, die in Bezug auf die erste Version nicht ver¨andert wurden. In Zeile 37 kommt der Copy-Constructor zum Einsatz. Dies funktioniert auch klaglos, denn dup_buf1 und char_buffer haben exakt denselben Typ. Ganz im Unterschied zu Zeile 40, die ich auskommentiert habe, weil sie einen Compilerfehler zur Folge h¨ atte. Dort wird n¨amlich der Versuch gestartet, den Copy-Constructor eines Buffers mit einem Fassungsverm¨ogen von 5 Elementen mit einem anderen Buffer mit dem Fassungsverm¨ogen von 10 Elementen aufzurufen. Da aber die Anzahl der Elemente ein Teil der konkreten Auspr¨ agung ist, sind diese beiden verschiedenen Auspr¨agungen nicht typkompatibel und der Compiler verweigert diesen Aufruf.
13.3 Class Templates
427
Wenn man sich u ¨berlegt, dass hier ein konkretes Fassungsverm¨ogen zum Teil des Typs wird, so erkennt man sofort, dass nat¨ urlich dieses Fassungsverm¨ogen unbedingt u ¨ber eine Konstante angegeben werden muss. Es ist ja schließlich der Compiler, der die konkrete Auspr¨agung aus einem Template erzeugt und dieser hat nat¨ urlich vom Inhalt einer Variable keine Ahnung, denn dieser wird ja erst zur Laufzeit festgelegt. Diese logische Schlussfolgerung wird durch Zeile 44 untermauert, die bei Entfernen der Kommentarzeichen in einem Compilerfehler resultieren w¨ urde. F¨ uhrt man den Gedanken zu Ende, was sonst noch alles zur Compilezeit bekannt ist, dann erkennt man, dass auch bestimmte Adressen in diese Kategorie fallen: Es sind dies die Adressen, die sich auf Daten mit sogenanntem external Linkage beziehen, also solche, die auch außerhalb ihres Files sichtbar sind, in dem sie definiert wurden. Im Klartext bedeutet das: Adressen von globalen Variablen und Funktionen k¨onnen auch als Template Parameter Anwendung finden. Obwohl ich hier erw¨ahnen m¨ ochte, dass diese Art von Parametrisierung nur sehr selten sinnvoll ist und nur noch von wirklichen Template Experten vorgenommen werden soll, m¨ochte ich zumindest eine sinnvolle Anwendung anf¨ uhren: Templates, die u ¨ber einen Funktionspointer typisiert werden, der einen bestimmten Algorithmus ausf¨ uhrt. Zum Beispiel k¨onnte man ein Template SortedList erstellen, das als Parameter den Funktionspointer auf eine bestimmte Sortierfunktion nimmt. Dadurch kann man beim Festlegen der Auspr¨agung die Reihenfolge der Sortierung und den gew¨ unschten Algorithmus einfach durch Angabe der richtigen Sortierfunktion festlegen. Das hat den Vorteil, dass eine aufsteigend und eine absteigend sortierte Liste mit Elementen desselben Datentyps nicht typkompatibel sind und ein falscher Umgang mit solchen Listen hierbei schon vom Compiler bemerkt wird. Der Output des Testprogramms beweist, dass sich am Verhalten der Buffers in Bezug zur vorangegangenen Version nichts ver¨andert hat und dass der Copy-Constructor auch wirklich aufgerufen wird: c o n t en t o f the char b u f f e r : a , b , c , d c o n t en t o f the f l o a t b u f f e r : 1 , 2 , 3 , 4 copy c o n s t r u c t o r
Dass man auch eine bestimmte Auspr¨agung eines Templates selbst wieder als Template Parameter verwenden kann, zeigt sich im folgenden Beispiel, in dem wir einen Buffer zum Zwischenspeichern von Elementen verwenden, die selbst konkrete Auspr¨ agungen eines Templates sind. Das Class Template f¨ ur den Buffer entspricht vollst¨ andig der obigen Version und wird deshalb hier nicht abgedruckt. Die Daten, die im Buffer gespeichert werden sollen, sind u unden ¨ber das Template GenericStorage realisiert, das aus Einfachheitsgr¨ gleich im Testprogramm verewigt wurde (buffer_class_test_v3.cpp): 1
// b u f f e r c l a s s t e s t v 3 . cpp − another demo f o r c l a s s t e m p l a t e s
2 3 4 5
#include < i o s t r e a m>
428
6
13. Templates
#include ” b u f f e r c l a s s t e m p l a t e v 2 . h”
7 8 9 10 11 12 13
template < c l a s s DataType> c l a s s G e n e r i c S t o r a g e { protected : DataType d a t a ; public : GenericStorage ( ) { }
14
G e n e r i c S t o r a g e ( const DataType &data ) { d a t a
15
= data ; }
16
G e n e r i c S t o r a g e ( const G e n e r i c S t o r a g e &s r c ) { data = s r c . data ; }
17 18 19 20 21
virtual G e n e r i c S t o r a g e &operator = ( const G e n e r i c S t o r a g e &s r c ) { data = s r c . data ; return (∗ t h is ) ; }
22 23 24 25 26 27 28
virtual operator DataType ( ) { return ( d a t a ) ; }
29 30
};
31 32 33 34
int main ( int a r g c , char ∗ argv [ ] ) { B u f f e r,10> a b u f f e r ;
35
a a a a
36 37 38 39
buffer buffer buffer buffer
. put ( G e n e r i c S t o r a g e ( 1 ) ) ; . put ( G e n e r i c S t o r a g e ( 2 ) ) ; . put ( G e n e r i c S t o r a g e ( 3 ) ) ; . put ( G e n e r i c S t o r a g e ( 4 ) ) ;
40
cout << ” c o n t en t o f a b u f f e r . getNext () a b u f f e r . getNext () a b u f f e r . getNext () a b u f f e r . getNext ()
41 42 43 44 45
the b u f f e r : ” << << ” , ” << << ” , ” << << ” , ” << << e nd l ;
46
return ( 0 ) ;
47 48
}
Dass dieses Programm auch funktioniert, sieht man am Output, den dieses grandiose Meisterwerk liefert: co n t e n t o f the b u f f e r : 1 , 2 , 3 , 4
13.4 Ableiten von Class Templates Wie von “normalen” Klassen, so kann man nat¨ urlich auch von Class Templates ableiten. Es u ¨berrascht auch nicht weiter, dass die Ableitungen sowohl konkrete Klassen als auch selbst wieder Templates sein k¨onnen. Ebenso kann man entweder vom allgemeinen Template als auch von einer besonderen Auspr¨agung desselben ableiten. Wir wissen ja, dass die Kombination aus einem Namen gefolgt von Parametern in spitzen Klammern einen konkreten Typ
13.4 Ableiten von Class Templates
429
ergibt. Genau so verwenden wir Templates auch, wenn wir von ihnen ableiten. Versuchen wir uns gleich einmal an unserem Buffer und implementieren wir eine Variante, die uns auch Auskunft u ¨ber den inneren Zustand desselben geben kann, also wie viele Elemente im Augenblick zwischengespeichert werden und wie hoch die Gesamtkapazit¨at ist (buffer_class_template_v3.h). In dieser Abwandlung wurde aus Gr¨ unden der Einfachheit auf die erste Variante des Buffers zur¨ uckgegriffen, die nur den Typ und nicht die Anzahl der Elemente als Parameter nimmt. 66 67 68 69 70 71
template < c l a s s ContentType> c l a s s QueryableBuffer : public B u f f e r { public : QueryableBuffer ( ) throw( b a d a l l o c ) : B u f f e r() {}
72
QueryableBuffer ( const QueryableBuffer &s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) : B u f f e r( s r c ) { }
73 74 75 76
// a l s o support copying from a ” normal ” b u f f e r QueryableBuffer ( const B u f f e r &s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) : B u f f e r( s r c ) { }
77 78 79 80 81
virtual ˜ QueryableBuffer ( ) throw ( ) { }
82 83 84
virtual u i n t 3 2 getNumBufferedElements ( ) { return ( num elements ) ; }
85 86 87 88 89
virtual u i n t 3 2 g e t O v e r a l l C a p a c i t y ( ) { return ( n u m e l e m e n t s a l l o c a t e d ) ; }
90 91 92 93 94
};
In den Zeilen 66–67 sieht man, dass das Ableiten eines Class Templates von einem anderen keine Hexerei ist: Hier wird einfach von einem Buffer abgeleitet, der dieselbe konkrete Auspr¨ agung hat, wie unser QueryableBuffer. In den Initialisierungen, z.B. in den Zeilen 70–71, wo der default Konstruktor der Basis explizit aufgerufen wird, wird ebenfalls diese Auspr¨agung des Buffers eingesetzt. Neben dem Copy-Constructor in den Zeilen 73–75 wurde zu Demonstrationszwecken auch noch ein anderer Konstruktor in den Zeilen 78–80 eingef¨ uhrt, der es zul¨ asst, dass ein QueryableBuffer auch mit einem “normalen” Buffer als Parameter konstruiert werden kann. Um zu zeigen, dass das ganze Spielchen, das hier implementiert wurde, auch funktioniert, werfen wir noch einen kurzen Blick auf das dazugeh¨orige Testprogr¨ ammchen (buffer_class_test_v4.cpp):
430
13. Templates
1 2
// b u f f e r c l a s s t e s t v 4 . cpp − demo f o r d e r i v e d t e m p l a t e s
3 4
#include < i o s t r e a m>
5 6
#include ” b u f f e r c l a s s t e m p l a t e v 3 . h”
7 8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { QueryableBuffer b u f f e r 1 ; QueryableBuffer b u f f e r 2 = b u f f e r 1 ;
12
// j u s t to show t h a t the c o n s t r u c t o r with a // standard b u f f e r works . . . B u f f e r b u f f e r 3 ; QueryableBuffer b u f f e r 4 = b u f f e r 3 ;
13 14 15 16 17
buffer buffer buffer buffer
18 19 20 21
1 1 1 1
. put ( 1 ) ; . put ( 2 ) ; . put ( 3 ) ; . put ( 4 ) ;
22
cout << ” the c a p a c i t y o f b u f f e r 1 i s : ” << b u f f e r 1 . g e t O v e r a l l C a p a c i t y () << e nd l ;
23 24 25
cout << ” the number o f elements h e l d at the moment i s : ” << b u f f e r 1 . getNumBufferedElements () << e nd l ;
26 27 28
cout << ” c o n t e n t o f b u f f e r 1 . getNext () b u f f e r 1 . getNext () b u f f e r 1 . getNext () b u f f e r 1 . getNext ()
29 30 31 32 33
b u f f e r 1 : ” << << ” , ” << << ” , ” << << ” , ” << << e nd l ;
34
cout << ” the number o f elements he l d at the moment i s : ” << b u f f e r 1 . getNumBufferedElements () << e nd l ;
35 36 37
return ( 0 ) ;
38 39
}
Ich denke, dieses Programm braucht wirklich keine n¨ahere Erkl¨arung mehr, deshalb m¨ ochte ich nur noch den Output dazu hier abdrucken, der zeigt, dass unsere Ableitung vom Template auch wirklich das tut, was von ihr erwartet wird: copy c o n s t r u c t o r copy c o n s t r u c t o r the c a p a c i t y o f b u f f e r 1 i s : 8 the number o f elements h el d at the moment i s : 4 c o n t en t o f b u f f e r 1 : 1 , 2 , 3 , 4 the number o f elements h el d at the moment i s : 0
Einleitend wurde zum Thema Ableiten von Templates gesagt, dass man sowohl generisch als auch von einer konkreten Auspr¨agung ableiten kann. Ich m¨ochte zu diesem Thema hier keine vollst¨andigen Programme mehr verewigen, denn ich denke, es gen¨ ugt, wenn wir uns nur kurz ansehen, wie eine solche Ableitung theoretisch aussehen w¨ urde. Stellen wir uns einfach vor, wir wollten einen ganz speziellen QueryableFloatBuffer implementieren, dann h¨atten wir z.B. folgende M¨ oglichkeit:
13.5 Explizite Spezialisierungen
1 2 3 4 5 6
431
class QueryableFloatBuffer : public B u f f e r { public : QueryableFloatBuffer ( ) throw( b a d a l l o c ) : B u f f e r();
7
virtual ˜ Q u e r y a b l e F l o a t B u f f e r ( ) throw ( ) ;
8 9 10
virtual u i n t 3 2 getNumBufferedElements ( ) ;
11 12
virtual u i n t 3 2 g e t O v e r a l l C a p a c i t y ( ) ;
13 14
};
Man sieht in den Zeilen 1–2, dass man eine “ganz normale” Klasse auch von einer konkreten Auspr¨ agung eines Templates ableiten kann, indem man einfach nur die gew¨ unschte Auspr¨ agung entsprechend einsetzt. In unserem Fall leiten wir von der konkreten Auspr¨agung eines Buffers ab, die float Elemente speichern kann. Nat¨ urlich funktioniert auch der umgekehrte Weg, n¨amlich ein Class Template zu schreiben, das selbst von einer “ganz normalen” Klasse abgeleitet ist. Ebenso kann man ein Class Template schreiben, dass von einer bestimmten Auspr¨agung eines anderen Class Templates abgeleitet ist. Ich denke, die Beispiele hierzu kann ich wirklich auslassen, denn der Mechanismus ist immer derselbe: • Bezieht man sich auf eine “normale” Klasse, dann wird als Typ der Klassenname verwendet. • Bezieht man sich auf eine Auspr¨ agung eines Templates, dann ist noch die Angabe dieser Auspr¨ agung in spitzen Klammern hinter dem Namen des Class Templates vonn¨ oten.
13.5 Explizite Spezialisierungen Ein sehr interessantes Thema bei Templates, von dem bisher nicht die Rede war, ist die Menge des erzeugten Codes. Um zu erkennen, wodurch man die Menge des vom Compiler f¨ ur bestimmte Auspr¨agungen erzeugten Codes beeinflussen kann, f¨ uhren wir einmal eine kurze Analyse durch. Nehmen wir der Einfachheit halber wieder unsere ein-Parameter Variante des Buffers und nehmen wir an, dass in unserem Code irgendwo die folgenden Zeilen vorkommen: Buffer char_ptr_buf; Buffer uint32_ptr_buf; Buffer<double*> double_ptr_buf; Hierdurch werden drei verschiedene konkrete Auspr¨agungen von Buffers erzeugt, eine die ein Array von char * h¨alt, eine die ein Array von uint32 *
432
13. Templates
h¨alt und schließlich noch eine f¨ ur double *. Vom Standpunkt des Laufzeitverhaltens, der Typkompatibilit¨ at und der Typsicherheit ist ja alles in Ordnung. Bei genauerem Nachdenken erkennt man allerdings schnell, dass die ganze Angelegenheit doch einen gr¨oberen Haken hat: In allen drei F¨allen speichert man einfach nur Pointer. Worauf diese zeigen, ist unserem Buffer intern v¨ ollig egal, denn dieser interessiert sich nur f¨ ur die interne Organisation des Speichers zum Ablegen derselben. Mit den Werten, auf die sie zeigen, hat ein Buffer nichts am Hut. Pointer sind einfach immer gleich groß, egal, worauf sie zeigen, denn Computer haben eben einmal eine fixe Adressl¨ange. F¨ ur den Compiler gibt es aber nicht einfach nur Adressen, sondern Pointer sind auch typisiert. Deshalb wird er f¨ ur jede der obigen Auspr¨agungen eines Buffers eine vollst¨ andige konkrete Auspr¨agung des Templates erzeugen. Dass das dazu f¨ uhren kann, dass Unmengen an quasi gleichem Code erzeugt werden, ist leicht einzusehen. Es w¨ are also sch¨ on, wenn wir eine einfache M¨oglichkeit h¨atten, alle Auspr¨agungen von Buffers, die irgendwelche Pointer halten, unter einer bestimmten Auspr¨ agung des Templates zusammenzufassen. Alle anderen Auspr¨agungen sollen wie gewohnt einzeln und getrennt erzeugt werden. Damit sind wir endlich bei der Erkl¨arung der Kapitel¨ uberschrift: F¨ ur diese Art des Zusammenfassens mehrerer Template-Auspr¨agungen wird oft der Begriff der Spezialisierung verwendet. Bevor wir zur L¨osung des gerade eben angerissenen Problems mit den Pointern kommen, m¨ochte ich einmal kurz das Prinzip zeigen, durch das man Spezialisierungen erzeugt. Um genau zu sehen, was nun aus dem originalen Template kommt und was aus der Spezialisierung, wurde das urspr¨ ungliche Template leicht ver¨andert und um entsprechende Outputs erg¨ anzt. Jede Methode (inklusive Konstruktoren, etc.) meldet sich nun mit dem String generic ... wenn sie aufgerufen wird. Ansonsten ist diese Version vollkommen identisch zur Urversion und dementsprechend ist es sinnlos, sie hier abzudrucken. Die Deklaration der besonderen Spezialisierung auf void * sieht folgendermaßen aus (buffer_class_void_ptr_specialization.h): 7 8 9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ s p e c i a l i z a t i o n o f the B u f f e r template f o r void p o i n t e r s ∗ ∗/
14 15 16 17 18 19 20 21 22 23 24
template<> c l a s s B u f f e r { public : s t a t i c const u i n t 3 2 MAX NUM ELEMENTS = 8 ; private : // f o r b i d assignment by making o p e r a t o r p r i v a t e const B u f f e r& operator = ( const B u f f e r&) { return (∗ th is ) ; } protected : u i n t 3 2 num elements ;
13.5 Explizite Spezialisierungen
25 26 27 28 29 30 31
433
uint32 num elements allocated ; uint32 read index ; uint32 w r i t e i n de x ; void ∗∗ e l e m e n t s ; public : Buffer () throw( b a d a l l o c ) ;
32
B u f f e r ( const B u f f e r & s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
33 34 35
virtual ˜ B u f f e r ( ) throw ( ) ;
36 37 38
virtual void put ( void ∗ const &element ) throw( r a n g e e r r o r ) ;
39 40 41
virtual void ∗ getNext ( ) throw( r a n g e e r r o r ) ;
42 43 44
};
In Zeile 15 sieht man, dass das Keyword template nur noch von einer ¨offnenden und einer schließenden spitzen Klammer gefolgt ist und dass kein generischer Typ mehr angegeben wird. Das kommt daher, dass man dem Compiler auf diese Art mitteilt, f¨ ur welchen bzw. welche der Template Parameter die Spezialisierung stattfindet. Die Parameter, f¨ ur die man spezialisiert, kommen in der Spezialisierung nicht mehr vor. Die anderen Parameter, die weiterhin vom Compiler behandelt werden sollen, bleiben erhalten. W¨ urde man also z.B. eine Spezialisierung unserer Variation des Templates mit den zwei Parametern machen, jedoch nur den Typ, nicht aber die Anzahl der Elemente spezialisieren wollen, so m¨ usste hier Folgendes stehen: template class Buffer In der Folge sieht man, dass in der gesamten Deklaration alle Vorkommen des generischen Parameters durch die spezielle Auspr¨agung void * ersetzt wurden. Was haben wir also bisher erreicht? • Wir haben dem Compiler mitgeteilt, dass es eine handgeschriebene Spezialisierung des Buffers f¨ ur den Typ void * gibt und dass er diese besondere Auspr¨ agung nicht mehr zu generieren braucht. • Wir haben die Deklaration dieser Spezialisierung auch wirklich von Hand vorgenommen und entsprechend angepasst. Je nach Spezialisierung wird die Anpassung verschieden ausfallen. Bei uns hat sich diese darauf beschr¨ankt, dass wir einfach nur den entsprechenden Typ eingesetzt haben, wo ansonsten der generische Typ stehen w¨ urde. Wenn wir schon dem Compiler mitteilen, dass er mit dieser Auspr¨agung unseres Templates nichts mehr zu tun hat, dann sind wir auch wirklich vollst¨andig daf¨ ur verantwortlich. Das bedeutet, dass wir nat¨ urlich auch die entsprechenden Auspr¨ agungen der Methoden selbst schreiben m¨ ussen. Genau das passiert auch in der Folge. Stellvertretend f¨ ur die ganzen Methoden, die f¨ ur
434
13. Templates
die Spezialisierung implementiert wurden, m¨ochte ich hier einfach nur den default Konstruktor abdrucken: 52 53 54 55 56 57 58 59 60 61 62
B u f f e r:: B u f f e r ( ) throw( b a d a l l o c ) { cout << ” s p e c i a l i z e d < void ∗> d e f a u l t c o n s t r u c t o r ” << e nd l ; num elements = 0 ; read index = 0; write index = 0; n u m e l e m e n t s a l l o c a t e d = 0 ; // j u s t i n c a s e new f a i l s . . . e l e m e n t s = new void ∗ [MAX NUM ELEMENTS] ; n u m e l e m e n t s a l l o c a t e d = MAX NUM ELEMENTS; }
Aufmerksame Leser glauben nun, einen Fehler gefunden zu haben, denn hier steht kein Wort mehr von einem Template. Stattdessen haben wir es einfach mit einer Definition einer “ganz normalen” Methode zu tun. Das Einzige, was noch auf ein Template hinweist, ist der Scope, der sich auf einen Buffer bezieht. Leider muss ich die aufmerksamen Leser entt¨auschen, denn diese Definition ist korrekt. Da es keinen generischen Parameter mehr gibt, ist auch die Angabe der Zugeh¨origkeit zu einem Template erstens unn¨ otig und zweitens w¨ urde sogar die Angabe template<>, wie wir sie aus der Spezialisierung der Klasse kennen, zu einem Compilerfehler f¨ uhren. Hierin liegt auch der Unterschied zwischen der Deklaration einer Spezialisierung und der Definition der Member-Methoden. Bei der Deklaration muss man dem Compiler noch mitteilen, dass man etwas spezialisiert. Die Definition wird dadurch einfach als gegeben hingenommen. Nur f¨ ur den Fall, dass noch Template-Parameter u urden, die nicht spezialisiert sind, ¨brig bleiben w¨ m¨ usste man diese hier explizit angeben. In Zeile 55 sieht man, dass sich die spezialisierte Methode entsprechend meldet, damit wir im Testprogramm unterscheiden k¨onnen, welche der Methoden (generisch oder spezialisiert) nun aufgerufen wurde. Die hier verwendete Version des Templates, die in buffer_class_template_v4.h zu finden ist, meldet den Aufruf der entsprechenden generischen Methode. Dieses Template ist bis auf diese Outputs identisch zu dem in buffer_class_template.h und deshalb erspare ich mir hier das Abdrucken desselben. Nun aber wirklich zum Testprogramm (buffer_class_test_v5.cpp): 1
// b u f f e r c l a s s t e s t v 5 . cpp − demo f o r template s p e c i a l i z a t i o n
2 3 4
#include < i o s t r e a m>
5 6 7
#include ” b u f f e r c l a s s t e m p l a t e v 4 . h” #include ” b u f f e r c l a s s v o i d p t r s p e c i a l i z a t i o n . h”
8 9 10 11
int main ( int a r g c , char ∗ argv [ ] ) { B u f f e r i n t 3 2 b u f f e r ;
12 13
B u f f e r v o i d p t r b u f f e r ;
13.5 Explizite Spezialisierungen
435
14
int32 integers [ ] = { 1 , 2 , 3 , 4 } ;
15 16
int32 int32 int32 int32
17 18 19 20
buffer buffer buffer buffer
. put ( 1 ) ; . put ( 2 ) ; . put ( 3 ) ; . put ( 4 ) ;
21
void void void void
22 23 24 25
ptr ptr ptr ptr
buffer buffer buffer buffer
. put ( reinterpret . put ( reinterpret . put ( reinterpret . put ( reinterpret
cast( i n t e g e r s cast( i n t e g e r s cast( i n t e g e r s cast( i n t e g e r s
)); + 1)); + 2)); + 3));
26
cout << int32 int32 int32 int32
27 28 29 30 31
” co n t e n t o f i n t 3 2 b u f f e r : ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << e nd l ;
32
cout << ” c o n t e n t o f v o i d p t r b u f f e r : ∗ reinterpret cast( v o i d p t r ” , ” << ∗ reinterpret cast( v o i d p t r ” , ” << ∗ reinterpret cast( v o i d p t r ” , ” << ∗ reinterpret cast( v o i d p t r e nd l ;
33 34 35 36 37 38 39 40 41
” << b u f f e r . getNext ()) << b u f f e r . getNext ()) << b u f f e r . getNext ()) << b u f f e r . getNext ()) <<
42
return ( 0 ) ;
43 44
}
In Zeile 11 wird wie gewohnt eine Auspr¨agung des Buffers f¨ ur int32 Elemente angelegt und in Zeile 13 dann eine Auspr¨agung f¨ ur Elemente vom Typ void *. Genau dieser Buffer sollte durch unsere Spezialisierung abgedeckt sein und dem Compiler keinen Anlass geben, selbst eine Auspr¨agung des Buffers zu erzeugen. Dass das auch wirklich so ist, sieht man deutlich am Output des Programms: generic default constructor s p e c i a l i z e d < void ∗> d e f a u l t c o n s t r u c t o r g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d < void ∗> put method co n t e n t o f i n t 3 2 b u f f e r : g e n e r i c getNext method 1 , g e n e r i c getNext method 2 , g e n e r i c getNext method 3 , g e n e r i c getNext method 4 c o n t en t o f v o i d p t r b u f f e r : s p e c i a l i z e d < void ∗> getNext method 1 , s p e c i a l i z e d < void ∗> getNext method 2 , s p e c i a l i z e d < void ∗> getNext method 3 , s p e c i a l i z e d < void ∗> getNext method 4 s p e c i a l i z e d < void ∗> d e s t r u c t o r generic destructor
436
13. Templates
Alles, was mit dem Buffer f¨ ur int32 geschieht, kommt aus dem generischen Typ, f¨ ur den der Compiler eine Auspr¨agung erzeugt hat. Alles jedoch, was mit dem Buffer f¨ ur void * geschieht, bezieht sich auf unsere handgestrickte Spezialisierung. Was sieht man also daran? Es ist m¨oglich, verschiedene Spezialisierungen per Hand zu implementieren. Die Gr¨ unde hierf¨ ur sind mannigfaltig. Ein Hauptgrund ist die M¨ oglichkeit des Finetunings durch spezielle Implementationsmaßnahmen, die nur f¨ ur bestimmte Datentypen Geltung haben, bei anderen Typen aber zu Problemen f¨ uhren w¨ urden. Was sieht man an unserer speziellen Implementation f¨ ur void *? Wir haben leider unser Traumziel, die verschiedenen Pointer-Typen hinter den Kulissen zu einer gemeinsamen Auspr¨agung zusammenzufassen, nicht wirklich so erreicht, wie wir das gerne h¨ atten. In den Zeilen 22–41 sieht man sehr deutlich, dass wir einfach per Hand alle Pointer “gleichmachen”, indem wir sie auf void * und wieder zur¨ uck casten. Dazu h¨atten wir eigentlich im Prinzip nicht einmal eine Spezialisierung gebraucht, denn unsere Spezialisierung macht ja nichts Besondereres als die vollkommen generische Version. Aber, Kopf hoch, es gibt eine L¨ osung, die wirklich das macht, was wir gerne h¨atten. Es gibt n¨ amlich eine M¨oglichkeit, eine einzige Spezialisierung zu schreiben, die alle Pointer-Typen gemeinsam erfasst. Wir m¨ ussen nur die Spezialisierung ein wenig anders deklarieren als im obigen Beispiel und einen kleinen weiteren Kunstgriff anwenden, um nicht alles komplett neu zu implementieren (buffer_class_ptr_specialization.h): 17 18 19 20 21 22 23 24 25 26 27
template c l a s s B u f f e r : private B u f f e r { private : // f o r b i d assignment by making o p e r a t o r p r i v a t e const B u f f e r& operator = ( const B u f f e r&) { return (∗ th is ) ; } public : Buffer () throw( b a d a l l o c ) ;
28
B u f f e r ( const B u f f e r & s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) ;
29 30 31
virtual ˜ B u f f e r ( ) throw ( ) ;
32 33 34
virtual void put ( ContentType ∗ const &element ) throw( r a n g e e r r o r ) ;
35 36 37
virtual ContentType ∗ getNext ( ) throw( r a n g e e r r o r ) ;
38 39 40
};
In Zeile 18 ist des Pudels Kern Teil eins versteckt, wie wir eine Spezialisierung schreiben k¨ onnen, die f¨ ur alle Pointer Typen gleichermaßen vom Compiler herangezogen wird: Es wird f¨ ur die spezielle Auspr¨agung des Buffers der Typ
13.5 Explizite Spezialisierungen
437
ContentType * gew¨ ahlt. Das bedeutet, dass der Compiler bis zum * beim Aussuchen einer Auspr¨ agung ein Matching vornehmen kann, wodurch alle Pointer Typen gemeinsam erfasst werden. Einem Blick auf Zeile 17 entnehmen wir allerdings, dass wir damit allein noch nicht wirklich eine Reduktion des erzeugten Source Codes erreicht haben. Die Parametrisierung auf einzelne Typen (in unserem Fall Pointer-Typen) existiert ja nach wie vor. Also wird der Compiler auch nach wie vor f¨ ur jeden einzelnen Pointer-Typ eine konkrete Auspr¨ agung erzeugen. Was soll dann das Ganze nun u ¨berhaupt? Genau hier kommt des Pudels Kern Teil zwei ins Spiel: In Zeile 18 sieht man, dass unsere Spezialisierung von einer konkreten Auspr¨agung des Buffers, n¨amlich von einem Buffer, abgeleitet ist. Egal also, mit welchem Typ von Pointer wir es zu tun haben, dieser Buffer ist schon konkret und damit gibt es zumindest von ihm keine neuen Auspr¨agungen. Wir brauchen also nur noch unsere Methoden so zu implementieren, dass sie diejenigen des Buffer aufrufen und schon haben wir, was wir wollen. Also schreiten wir zur Tat und tun das. Das Ergebnis sieht dann so aus: 48 49 50 51 52 53
template < c l a s s ContentType> B u f f e r:: B u f f e r ( ) throw( b a d a l l o c ) : B u f f e r() { cout << ” s p e c i a l i z e d d e f a u l t c o n s t r u c t o r ” << e nd l ; }
54 55 56 57 58 59 60 61 62 63 64
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> B u f f e r:: B u f f e r ( const B u f f e r & s r c ) throw( i n v a l i d a r g u m e n t , b a d a l l o c ) : B u f f e r ( reinterpret cast( s r c ) ) { cout << ” s p e c i a l i z e d copy c o n s t r u c t o r ” << e n d l ; }
65 66 67 68 69 70 71 72 73 74
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> B u f f e r::˜ B u f f e r ( ) throw ( ) { cout << ” s p e c i a l i z e d d e s t r u c t o r ” << e n d l ; }
75 76 77 78 79 80 81 82 83 84 85
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ template < c l a s s ContentType> void B u f f e r:: put ( ContentType ∗ const &element ) throw( r a n g e e r r o r ) { cout << ” s p e c i a l i z e d put method” << e nd l ; B u f f e r:: put ( reinterpret cast(element ) ) ; }
86 87 88
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗
438
89 90 91 92 93 94 95 96 97
13. Templates
∗/ template < c l a s s ContentType> ContentType ∗ B u f f e r:: getNext ( ) throw( r a n g e e r r o r ) { cout << ” s p e c i a l i z e d getNext method” << e n d l ; return ( reinterpret cast( B u f f e r:: getNext ( ) ) ) ; }
Im Gegensatz zu unserer vollst¨ andigen Spezialisierung auf void *, bei der f¨ ur den Compiler nichts mehr an Typinformation u ¨brig geblieben ist, was er h¨atte einsetzen sollen, haben wir es hier bei der Definition der einzelnen Methoden sehr wohl wieder mit richtigen Templates zu tun. Deshalb sind die Definitionen der einzelnen Methoden hier auch wieder als template ausgewiesen, was zuvor ja nicht mehr der Fall war. Es wurden auch hier wieder die entsprechenden Outputs in die Methoden eingebaut, um nachvollziehen zu k¨ onnen, welche der Methoden denn nun wirklich aufgerufen wird. Dass wir mit dieser Implementation wieder arbeiten k¨onnen, wie wir es eigentlich urspr¨ unglich wollten (=keine expliziten Casts per Hand), sieht man am folgenden Testprogramm (buffer_class_test_v6.cpp): 1
// b u f f e r c l a s s t e s t v 6 . cpp − demo f o r template s p e c i a l i z a t i o n
2 3 4
#include < i o s t r e a m>
5 6 7 8
#include ” b u f f e r c l a s s t e m p l a t e v 4 . h” #include ” b u f f e r c l a s s v o i d p t r s p e c i a l i z a t i o n . h” #include ” b u f f e r c l a s s p t r s p e c i a l i z a t i o n . h”
9 10 11 12 13
int main ( int a r g c , char ∗ argv [ ] ) { B u f f e r i n t 3 2 b u f f e r ; B u f f e r f l o a t b u f f e r ;
14 15 16 17
B u f f e r i n t 3 2 p t r b u f f e r ; B u f f e r f l o a t p t r b u f f e r ; B u f f e r c h a r p t r b u f f e r ;
18 19 20 21
int32 integers [ ] = { 1 , 2 , 3 , 4 } ; float f l o a t s [ ] = { 1 . 0 f , 2 . 0 f , 3 . 0 f , 4 . 0 f } ; char c h a r s [ ] = { ’ a ’ , ’ b ’ , ’ c ’ , ’ d ’ } ;
22 23 24 25 26
int32 int32 int32 int32
buffer buffer buffer buffer
. put ( 1 ) ; . put ( 2 ) ; . put ( 3 ) ; . put ( 4 ) ;
float float float float
buffer buffer buffer buffer
. put ( 1 . 0 ) ; . put ( 2 . 0 ) ; . put ( 3 . 0 ) ; . put ( 4 . 0 ) ;
27 28 29 30 31 32 33 34
i n t 3 2 p t r b u f f e r . put ( i n t e g e r s ) ; i n t 3 2 p t r b u f f e r . put ( i n t e g e r s + 1 ) ;
13.5 Explizite Spezialisierungen
439
i n t 3 2 p t r b u f f e r . put ( i n t e g e r s + 2 ) ; i n t 3 2 p t r b u f f e r . put ( i n t e g e r s + 3 ) ;
35 36 37
float float float float
38 39 40 41
ptr ptr ptr ptr
buffer buffer buffer buffer
. put ( f l o a t s . put ( f l o a t s . put ( f l o a t s . put ( f l o a t s
); + 1); + 2); + 3);
42
char char char char
43 44 45 46
ptr ptr ptr ptr
buffer buffer buffer buffer
. put ( c h a r s ) ; . put ( c h a r s + 1 ) ; . put ( c h a r s + 2 ) ; . put ( c h a r s + 3 ) ;
47 48 49 50 51 52
cout << int32 int32 int32 int32
” c o n t en t o f i n t 3 2 b u f f e r : ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << e nd l ;
cout << float float float float
” c o n t e n t o f f l o a t b u f f e r : ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << ” , ” << b u f f e r . getNext () << e n d l ;
53 54 55 56 57 58 59
cout << ” c o n t en t o f i n t 3 2 p t r b u f f e r : ” << ∗ i n t 3 2 p t r b u f f e r . getNext () << ” , ” << ∗ i n t 3 2 p t r b u f f e r . getNext () << ” , ” << ∗ i n t 3 2 p t r b u f f e r . getNext () << ” , ” << ∗ i n t 3 2 p t r b u f f e r . getNext () << e nd l ;
60 61 62 63 64 65
cout << ” c o n t e n t o f f l o a t p t r ∗ f l o a t p t r b u f f e r . getNext () ∗ f l o a t p t r b u f f e r . getNext () ∗ f l o a t p t r b u f f e r . getNext () ∗ f l o a t p t r b u f f e r . getNext ()
66 67 68 69 70
b u f f e r : ” << << ” , ” << << ” , ” << << ” , ” << << e nd l ;
71
cout << ∗char ∗char ∗char ∗char
72 73 74 75 76
” c o n t e n t o f c h a r p t r b u f f e r : ” << p t r b u f f e r . getNext () << ” , ” << p t r b u f f e r . getNext () << ” , ” << p t r b u f f e r . getNext () << ” , ” << p t r b u f f e r . getNext () << e nd l ;
77 78
return ( 0 ) ;
79 80
}
Und der Beweis, dass tats¨ achlich unsere Spezialisierung f¨ ur alle beliebigen Pointer-Typen gleichermaßen gilt, findet sich im Output des Programms: generic default constructor generic default constructor s p e c i a l i z e d < void ∗> d e f a u l t s p e c i a l i z e d d e f a u l t s p e c i a l i z e d < void ∗> d e f a u l t s p e c i a l i z e d d e f a u l t s p e c i a l i z e d < void ∗> d e f a u l t s p e c i a l i z e d d e f a u l t g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method
constructor constructor constructor constructor constructor constructor
440
13. Templates
g e n e r i c put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method s p e c i a l i z e d put method s p e c i a l i z e d < void ∗> put method co n t en t o f i n t 3 2 b u f f e r : g e n e r i c getNext method 1 , g e n e r i c getNext method 2 , g e n e r i c getNext method 3 , g e n e r i c getNext method 4 c o n t en t o f f l o a t b u f f e r : g e n e r i c getNext method 1 , g e n e r i c getNext method 2 , g e n e r i c getNext method 3 , g e n e r i c getNext method 4 c o n t e n t o f i n t 3 2 p t r b u f f e r : s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 1 , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 2 , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 3 , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 4 c o n t e n t o f f l o a t p t r b u f f e r : s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 1 , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 2 , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 3 , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method 4 c o n t e n t o f c h a r p t r b u f f e r : s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method a , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method b , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method c , s p e c i a l i z e d getNext method s p e c i a l i z e d < void ∗> getNext method d s p e c i a l i z e d d e s t r u c t o r s p e c i a l i z e d < void ∗> d e s t r u c t o r s p e c i a l i z e d d e s t r u c t o r s p e c i a l i z e d < void ∗> d e s t r u c t o r
13.5 Explizite Spezialisierungen
441
s p e c i a l i z e d d e s t r u c t o r s p e c i a l i z e d < void ∗> d e s t r u c t o r generic destructor generic destructor
Es ist hier auch sehr sch¨ on am Output zu erkennen, dass durch das Inkludieren des Headers mit unserer ersten Spezialisierung des Buffers auf void * auch wirklich diese Spezialisierung bei der Ableitung herangezogen wird. Um zu zeigen, dass man unsere jetzige Spezialisierung nicht unbedingt zweistufig vornehmen muss, sondern diese auch direkt aus dem generischen Code erzeugen kann, ¨ andern wir unser Testprogramm ein wenig ab, sodass es die void * Spezialisierung nicht mehr inkludiert. Das bedeutet, dass unsere allgemeine Pointer-Spezialisierung nun direkt von einer vom Compiler generierten Auspr¨ agung des Buffers abgeleitet ist. Abgesehen von einem kleinen Stolperstein funktioniert dies nat¨ urlich auch... Vorsicht Falle: Leitet man eine Spezialisierung von einer bestimmten Auspr¨agung eines Templates ab, die vom Compiler generiert werden muss, so kann es je nach Compiler zu einem auf den ersten Blick unerkl¨arlichen Effekt kommen: Der Compiler weigert sich die Spezialisierung zu u ¨bersetzen mit dem Hinweis darauf, dass z.B. in unserem Fall Buffer keine Basisklasse der Spezialisierung sei. Bei n¨ aherem Nachdenken wird dieser Effekt erkl¨arbar: Wenn man von einer Klasse ableitet, dann geht der Compiler davon aus, dass die Basisklasse auch existiert. Viele Compiler ziehen gar nicht in Betracht, dass sie vielleicht selbst daf¨ ur verantwortlich sein k¨ onnten, diese Basisklasse zuerst einmal aus einem Template selbst zu generieren, bevor sie weitermachen. Soll heißen, rekursives Generieren von Template-Auspr¨agungen wird nicht wirklich immer unterst¨ utzt. Zum Gl¨ uck k¨ onnen wir im Notfall selbst daf¨ ur sorgen, dass der Compiler eine existierende Instanz vorfindet: Wir brauchen ihn nur fr¨ uh genug dazu zu zwingen, eine solche zu erzeugen und schon ist er zufrieden. Wie erzeugt man eine solche? Ganz einfach: Man legt eine entsprechende Variable an. Im Fall unseres Programms w¨ urde dies z.B. folgendermaßen aussehen: 6 7 8
#include ” b u f f e r c l a s s t e m p l a t e v 4 . h” s t a t i c B u f f e r d u m m y v o i d p t r b u f f e r ; #include ” b u f f e r c l a s s p t r s p e c i a l i z a t i o n . h”
In Zeile 7 wird eine sinnlose Variable angelegt, der Compiler erzeugt dadurch den notwendigen Code und schon funktioniert die Ableitung klaglos. Vorsicht Falle: Ich gebe ja zu, dass es ziemlich dumm aussieht, wenn die L¨osung zu einer Falle gleich selbst wieder eine andere Falle beinhaltet, aber leider ist es in diesem Fall so: Manche Leser m¨ ogen sich denken, dass es wirklich Verschwendung ist, gleich eine globale Variable mit einem Buffer anzulegen, der ja doch nie gebraucht wird. Viel weniger Speicher braucht da schon ein Pointer auf einen
442
13. Templates
solchen Buffer, also nimmt man einen solchen zur Hand und definiert ganz einfach so etwas wie: Buffer *dummy_ptr__; Leider bleibt der Compiler dadurch unbeeindruckt, denn ihm gen¨ ugt das Wissen, dass es sich um einen Pointer handelt und dass er diesen irgendwann einmal zu gegebener Zeit typsicher behandeln muss. Zu diesem Zweck braucht er allerdings jetzt noch keine konkrete Auspr¨agung des Buffers zu erzeugen, denn die gegebene Zeit ist noch nicht gekommen. Niemand will derzeit n¨amlich mit dem Pointer wirklich arbeiten. Das bedeutet: Verschwendung oder nicht, wir brauchen eine “echte” Variable! Jetzt aber zur¨ uck zum Thema: Wir haben die H¨ urde des Erzwingens der Erzeugung einer Auspr¨ agung durch unsere sinnlose Variable genommen und den Compiler u ¨berzeugt, dass er das Programm nun wirklich u ¨bersetzen soll. Damit bekommen wir beim Starten unseres Meisterwerks nun folgenden Output: generic default constructor generic default constructor generic default constructor generic default constructor s p e c i a l i z e d d e f a u l t c o n s t r u c t o r generic default constructor s p e c i a l i z e d d e f a u l t c o n s t r u c t o r generic default constructor s p e c i a l i z e d d e f a u l t c o n s t r u c t o r g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method s p e c i a l i z e d put method g e n e r i c put method co n t e n t o f i n t 3 2 b u f f e r : g e n e r i c getNext method 1 , g e n e r i c getNext method
13.5 Explizite Spezialisierungen
443
2 , g e n e r i c getNext method 3 , g e n e r i c getNext method 4 co n t en t o f f l o a t b u f f e r : g e n e r i c getNext method 1 , g e n e r i c getNext method 2 , g e n e r i c getNext method 3 , g e n e r i c getNext method 4 c o n t e n t o f i n t 3 2 p t r b u f f e r : s p e c i a l i z e d getNext method g e n e r i c getNext method 1 , s p e c i a l i z e d getNext method g e n e r i c getNext method 2 , s p e c i a l i z e d getNext method g e n e r i c getNext method 3 , s p e c i a l i z e d getNext method g e n e r i c getNext method 4 c o n t en t o f f l o a t p t r b u f f e r : s p e c i a l i z e d getNext method g e n e r i c getNext method 1 , s p e c i a l i z e d getNext method g e n e r i c getNext method 2 , s p e c i a l i z e d getNext method g e n e r i c getNext method 3 , s p e c i a l i z e d getNext method g e n e r i c getNext method 4 c o n t en t o f c h a r p t r b u f f e r : s p e c i a l i z e d getNext method g e n e r i c getNext method a , s p e c i a l i z e d getNext method g e n e r i c getNext method b , s p e c i a l i z e d getNext method g e n e r i c getNext method c , s p e c i a l i z e d getNext method g e n e r i c getNext method d s p e c i a l i z e d d e s t r u c t o r generic destructor s p e c i a l i z e d d e s t r u c t o r generic destructor s p e c i a l i z e d d e s t r u c t o r generic destructor generic destructor generic destructor generic destructor
Vorsicht Falle: Neben allen Aspekten der Spezialisierung, die hier angerissen wurden, k¨ onnen vor allem Neulinge auf dem Gebiet der Templates oft einer Versuchung nicht widerstehen: Sie wollen eine “normale” Klasse implementieren, die denselben Namen hat, wie ein Class Template. Dieses Unterfangen geht unweigerlich schief, denn Overloading ist nur f¨ ur Funktionen und Methoden definiert, nicht aber f¨ ur Klassen! Deshalb gilt folgende Grundregel: Reale Klassen und Class Templates d¨ urfen niemals denselben Namen haben! Um das Thema der Spezialisierungen abzurunden m¨ochte ich hier nur noch kurz anmerken, dass man solche Spezialisierungen in genau derselben Form auch mit Function-Templates durchf¨ uhren kann und dass diese nicht auf Class Templates beschr¨ ankt sind. Eine explizite Besprechung von Bei-
444
13. Templates
spielen m¨ ochte ich allen Lesern an dieser Stelle ersparen, denn das Schema dahinter ist wirklich genau dasselbe, wie wir es eben bei den Class Templates besprochen haben.
13.6 Verschiedenes zu Templates Nach dieser langen Abhandlung zum Thema Templates gibt es, neben den Aspekten zur Organisation des Source Codes, die in Abschnitt 13.7 noch genauer beleuchtet werden, noch zwei Kleinigkeiten, die erw¨ahnt werden sollten: 1. Ein generischer Typ-Parameter kann bereits innerhalb des Parametrisierungsteils eines Templates (also innerhalb der spitzen Klammern) als existenter Typ verwendet werden, nachdem er dort drinnen deklariert wurde. 2. Es ist auch m¨ oglich, dass Methoden innerhalb eines Class-Templates selbst wieder Template-Methoden (=Function Templates) sind. Zur Verwendung von Typ-Parametern innerhalb des Parametrisierungsteils werfen wir am besten einen kurzen Blick auf das folgende Programm (generic_param_in_decl_use.cpp): 1 2
// g e n e r i c p a r a m i n d e c l u s e . cpp − s h o r t demo t h a t a g e n e r i c type // can be used i n s i d e the type d e c l a r a t i o n p a r t o f a template
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17 18 19
template < c l a s s DataType , DataType i n i t i a l v a l u e> class GenericStorage { protected : DataType d a t a ; public : GenericStorage ( ) { data = i n i t i a l v a l u e ; }
20 21 22 23 24
G e n e r i c S t o r a g e ( const DataType &data ) { d a t a = data ; }
25 26 27 28 29
G e n e r i c S t o r a g e ( const G e n e r i c S t o r a g e &s r c ) { data = s r c . data ; }
30 31 32 33 34
virtual G e n e r i c S t o r a g e &operator = ( const G e n e r i c S t o r a g e &s r c ) { data = s r c . data ;
13.6 Verschiedenes zu Templates
445
return (∗ th is ) ;
35
}
36 37
virtual operator DataType ( ) { return ( d a t a ) ; }
38 39 40 41 42
};
43 44 45 46 47
int main ( int a r g c , char ∗ argv [ ] ) { G e n e r i c S t o r a g e dummy;
48
cout << ”dummy’ s v a l u e i s : ” << dummy << e nd l ; return ( 0 ) ;
49 50 51
}
Zeile 10 zeigt, wie eine solche Verwendung aussieht: Es wird ein Template mit einem bestimmten generischen Typ versehen und, aufgrund welcher ¨ Uberlegungen auch immer, wurde befunden, dass der Initialwert ein Teil des Typs sein muss. Es wird also gleich der generische Typ verwendet, um den Initialwertparameter zu deklarieren. Das war auch schon die ganze Hexerei. In der main Funktion in den Zeilen 46–52 sieht man noch, wie man dieses Template dann verwendet. Auch zum zweite Aspekt, der oben genannt wurde, also zu expliziten Template-Methoden innerhalb von Template Klassen, sehen wir uns am besten gleich ein Beispiel an: 1 2
// m e t h o d t e m p l a t e i n c l a s s t e m p l a t e . cpp − s h o r t demo f o r a method // template t h a t i s n e s t e d i n s i d e a c l a s s template
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17 18 19 20 21
template < c l a s s NumericType> c l a s s ComplexNumber { protected : NumericType r e a l p a r t ; NumericType i m a g i n a r y p a r t ; public : ComplexNumber ( ) throw ( ) : r e a l p a r t ( 0 ) , i m a g i n a r y p a r t ( 0 ) { cout << ” d e f a u l t c o n s t r u c t o r ” << e n d l ; }
22 23 24 25 26 27 28 29
ComplexNumber ( const NumericType & r e a l p a r t , const NumericType & i m a g i n a r y p a r t ) throw ( ) : r e a l p a r t ( r e a l p a r t ) , imaginary part ( imaginary part ) { cout << ” c o n s t r u c t o r with p a i r o f v a l u e s ” << e nd l ; }
30 31
ComplexNumber ( const ComplexNumber &s r c )
446
13. Templates
throw ( ) : r e a l p a r t ( s r c . r e a l p a r t ) , imaginary part ( src . imaginary part )
32 33
{
34
cout << ”copy c o n s t r u c t o r ” << e n d l ;
35
}
36 37
template < c l a s s OtherNumericType> ComplexNumber ( const ComplexNumber &s r c ) throw ( ) : r e a l p a r t ( s r c . getRealPart ( ) ) , i m a g i n a r y p a r t ( s r c . getImaginaryPart ( ) ) { cout << ” template−g e n e r a t e d c o n s t r u c t o r ” << e nd l ; }
38 39 40 41 42 43 44 45
virtual NumericType getRealPart ( ) const { return ( r e a l p a r t ) ; }
46 47 48 49 50
virtual NumericType getImaginaryPart ( ) const { return ( i m a g i n a r y p a r t ) ; }
51 52 53 54 55 56
};
57 58 59 60 61 62 63
int main ( int a r g c , char ∗ argv [ ] ) { ComplexNumber f l o a t c o m p l e x ( 1 2 . 0 f , 3 . 5 f ) ; ComplexNumber<double> double complex = f l o a t c o m p l e x ; ComplexNumber a n o t h e r f l o a t c o m p l e x = f l o a t c o m p l e x ;
64
cout << ” the t h r e e numbers a r e : ( ” << f l o a t c o m p l e x . getRealPart () << ” , ” << f l o a t c o m p l e x . getImaginaryPart () << ” ) ( ” << double complex . getRealPart () << ” , ” << double complex . getImaginaryPart () << ” ) ( ” << a n o t h e r f l o a t c o m p l e x . getRealPart () << ” , ” << a n o t h e r f l o a t c o m p l e x . getImaginaryPart () << ” ) ” << e n d l ;
65 66 67 68 69 70 71 72
return ( 0 ) ;
73 74
}
Bei primitiven Datentypen sind wir ja gewohnt, dass der Compiler entsprechende implizite Typumwandlungen macht, solange die Typen kompatibel zueinander sind. Wir haben es in unserem Beispielprogramm mit einem Class Template f¨ ur komplexe Zahlen zu tun. Auch hier wollen wir diese Eigenschaft behalten, denn sowohl Realteil als auch Imagin¨arteil einer solchen Zahl sind jeweils einzelne Zahlen. Wenn also diese beiden Teile einer komplexen Zahl zueinander kompatibel sind, so soll es auch die gesamte Zahl sein. Es ist aber nun nicht m¨ oglich, einen Konstruktor mit entsprechendem Parameter zu schreiben, denn wir k¨ onnen ja nicht alle m¨oglichen Kompatibilit¨aten zwischen allen nur erdenklichen (auch nicht primitiven) Zahlentypen vorhersehen. Also schreiben wir f¨ ur diesen besonderen Konstruktor ein Template innerhalb des Templates, das die verschiedenen m¨oglichen Parametertypen abdeckt. Dieser Konstruktor ist in den Zeilen 38–44 zu finden.
13.7 Source Code Organisation
447
Dass ein Konstruieren von einer anderen Auspr¨agung von ComplexNumber nur m¨oglich ist, wenn Real- und Imagin¨arteil zueinander kompatibel sind, ist durch die Initialisierung der Members in den Zeilen 40–41 sichergestellt. Sollten diese nicht kompatibel sein, dann beschwert sich nat¨ urlich sofort der Compiler. Auff¨ allig an diesem Beispiel ist jedoch der extra implementierte CopyConstructor in den Zeilen 31–36. Wozu brauchen wir diesen, wo doch bereits ein Template existiert, von dem eine konkrete Auspr¨agung der CopyConstructor sein k¨ onnte? Die Antwort ist einfach: Der Copy-Constructor ist f¨ ur den Compiler etwas ganz Besonderes und er denkt nicht im Traum daran, diesen aus dem Template zu erzeugen. Entweder er existiert explizit, so wie hier, oder er wird vom Compiler implizit nach seinen eigenen Regeln erzeugt.
13.7 Source Code Organisation Bisher war immer davon die Rede, dass der Compiler nur dann den Code f¨ ur eine konkrete Auspr¨ agung eines Templates generieren kann, wenn er ihn auch vollst¨andig sieht. Deshalb waren Deklaration und Definition aller Templates bisher immer vollst¨ andig in unseren Header Files enthalten. Sch¨oner w¨are es allerdings, wenn wir wieder zu unserer guten alten Methode greifen und die Deklarationen in einen Header, die Definitionen jedoch in ein cpp File schreiben k¨ onnten. Einen weiteren Aspekt gibt es noch, der bei Templates zu beachten ist: Der Scope des Compilers ist immer auf eine einzige sogenannte Compilation Unit beschr¨ ankt, also salopp gesprochen auf ein cpp File. Das bedeutet bei der Verwendung von Templates Folgendes: Wenn ein Header mit Template Deklarationen und Definitionen in mehreren Files inkludiert ist, dann erzeugt der Compiler bei jedem einzelnen File, das er getrennt u ¨bersetzt, alle ben¨otigten konkreten Auspr¨ agungen der Templates von Neuem. Nehmen wir als Beispiel an, dass wir in einem Programm vier verschiedene cpp Files haben. In jedem einzelnen dieser Files w¨ urde unser Buffer Template Header inkludiert werden und in jedem einzelnen File w¨ urde ein Buffer gebraucht werden. So, wie wir bisher mit Templates gearbeitet haben, w¨ urde der Compiler nun auch f¨ ur jedes der einzelnen Files eine neue, identische konkrete Auspr¨ agung ein- und desselben Buffers f¨ ur int32 erzeugen. Zum Gl¨ uck ist von Seiten des Compilers daf¨ ur gesorgt, dass diese identischen Auspr¨agungen nur innerhalb einer Compilation Unit sichtbar sind, sonst w¨ urde sich der Linker beschweren. Nichts desto Trotz haben wir es hier mit sinnlos erzeugtem Code zu tun, denn es w¨ urde ja reichen, dass eine Auspr¨agung einmal erzeugt wird und dann einfach in den anderen Compilation Units wiederverwendet wird, wenn sie schon existiert. Leider habe ich an dieser Stelle mehr schlechte als gute Nachrichten. Es ist n¨amlich im Prinzip nicht m¨ oglich, die Definition eines Templates einfach in ein getrenntes File zu schreiben, das f¨ ur sich allein u ¨bersetzt und dann zum
448
13. Templates
Executable dazugelinkt wird. Das Problem hierbei ist, dass sich der Compiler f¨ ur jede einzelne u usste, welche ¨bersetzte Compilation Unit merken m¨ konkreten Auspr¨ agungen eines Templates er jetzt braucht. Nachdem er auf diese Art alle Files u usste er die Liste der gebrauchten Aus¨bersetzt hat, m¨ pr¨agungen nehmen. Mit Hilfe dieser Liste m¨ usste er die Compilation Unit u ¨bersetzen, welche die entsprechenden Definitionen enth¨alt, damit er auch wirklich den gesamten ben¨ otigten Code erzeugt. Leider tut das der Compiler nicht und es gibt auch keinen vorgegebenen Weg im Standard, wie er das tun sollte. Das bedeutet, dass wir die Geschichte mit dem Aufteilen in Deklarationen und Definitionen im Prinzip vergessen k¨onnen. Es gibt jedoch zum Gl¨ uck auch eine gute Nachricht: Im C++ Standard ist ein Weg vorgegeben, wie der Compiler daf¨ ur sorgen kann und soll, dass nicht ein und dieselbe Auspr¨ agung eines Templates f¨ ur jede einzelne Compilation Unit immer wieder von Neuem gebaut wird und damit den Code unn¨otig aufbl¨ast: Man kann Definitionen mit dem Keyword export versehen. Der Compiler reagiert laut Standard, wenn er auf eine gew¨ unschte Auspr¨agung eines Templates st¨ oßt folgendermaßen: 1. Er sieht nach, ob es schon eine zuvor erzeugte, exportierte Auspr¨agung in der Form gibt, wie sie gerade gebraucht wird. 2. Wenn ja, wird sie einfach verwendet. 3. Wenn nein, wird eine neue erzeugt und f¨ ur eventuellen sp¨ateren Gebrauch exportiert. Leider hat auch das einen Haken: Nur ausgesprochen wenige Compiler beherrschen den Export und die Wiederverwendung von speziellen Auspr¨agungen außerhalb der Compilation Unit, in der sie erzeugt wurden :-(. Zumindest aber ist die Existenz eines Standard-Vorgehens ein gutes Zeichen, dass sich die Situation diesbez¨ uglich bald zum Guten ¨andern k¨onnte. Deshalb m¨ ochte ich f¨ ur Templates die folgende Vorgehensweise anregen: • Man schreibt einen Header, in dem nur die Deklarationen enthalten sind. Das ist im Prinzip der einzige Teil, den Benutzer von Templates wirklich zu sehen bekommen sollen. • Man schreibt ein cpp File, in dem alle ben¨otigten Definitionen enthalten sind. Hierbei ist jede Definition mit export zu versehen, in der Hoffnung, dass in K¨ urze die Compiler damit wirklich etwas anfangen k¨onnen :-). • Man inkludiert dieses cpp File am Ende des Headers. Mir ist schon bewusst, dass sich nun bei vielen Lesern die Zehenn¨agel aufrollen, denn ein cpp File sollte man beim besten Willen nicht inkludieren. Alle Leser, die dies wirklich außerordentlich st¨ ort, k¨onnen auch gerne eine andere Extension, wie z.B. tcpp (f¨ ur Template-cpp) oder die auch gebr¨auchliche Extension tcc verwenden. In pers¨ onlich bevorzuge die Methode, alles, was mit Templates zu tun hat, getrennt vom Rest der Headers und Sources, in einem eigenen Subdirectory zu halten und cpp zu verwenden.
13.7 Source Code Organisation
449
Dadurch ist zwar weiterhin alles, also auch die Definition, inkludiert, jedoch bekommen die User der Templates nicht mehr alles auf einmal zu Gesicht, was sie im Prinzip gar nicht sehen sollen. Sie sollen sich ja nur f¨ ur die Deklarationen interessieren, nicht f¨ ur die Definitionen. • Der so in zwei Teile zerlegte Header wird wie gewohnt von den benutzenden Teilen des Templates eingebunden. Am Beispiel betrachtet sieht dies also folgendermaßen aus: Wir schreiben eine Template Deklaration (generic_storage.h). 1 2
// g e n e r i c s t o r a g e . h − j u s t the d e c l a r a t i o n o f the g e n e r i c // s t o r a g e template
3 4 5
#i f n d e f g e n e r i c s t o r a g e h #define g e n e r i c s t o r a g e h
6 7 8 9 10 11 12 13 14
template < c l a s s DataType> class GenericStorage { protected : DataType d a t a ; public : GenericStorage ( ) throw ( ) ;
15
G e n e r i c S t o r a g e ( const DataType &data ) throw ( ) ;
16 17 18
G e n e r i c S t o r a g e ( const G e n e r i c S t o r a g e &s r c ) throw ( ) ;
19 20 21
virtual G e n e r i c S t o r a g e &operator = ( const G e n e r i c S t o r a g e &s r c ) throw ( ) ;
22 23 24 25
virtual operator DataType ( ) throw ( ) ;
26 27 28 29
};
30 31 32
// the f o l l o w i n g i n c l u s i o n i s u n f o r t u n a t e l y not a v o i d a b l e #include ” g e n e r i c s t o r a g e . cpp”
33 34
#endif // g e n e r i c s t o r a g e h
Zu dieser Deklaration schreiben wir die entsprechenden Definitionen in das File generic_storage.cpp: 1
// g e n e r i c s t o r a g e . cpp − d e f i n i t i o n s o f the template methods
2 3
#include ” g e n e r i c s t o r a g e . h”
4 5 6 7 8 9 10 11
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ export template < c l a s s DataType> G e n e r i c S t o r a g e : : G e n e r i c S t o r a g e ( ) throw ( ) {
450
12
13. Templates
}
13 14 15 16 17 18 19 20 21 22
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ export template < c l a s s DataType> G e n e r i c S t o r a g e : : G e n e r i c S t o r a g e ( const DataType &data ) throw ( ) { d a t a = data ; }
23 24 25 26 27 28 29 30 31 32
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ export template < c l a s s DataType> G e n e r i c S t o r a g e : : G e n e r i c S t o r a g e ( const G e n e r i c S t o r a g e &s r c ) throw ( ) { data = s r c . data ; }
33 34 35 36 37 38 39 40 41 42 43 44
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ export template < c l a s s DataType> G e n e r i c S t o r a g e &G e n e r i c S t o r a g e : : operator = ( const G e n e r i c S t o r a g e &s r c ) throw ( ) { data = s r c . data ; return (∗ th is ) ; }
45 46 47 48 49 50 51 52 53 54
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ export template < c l a s s DataType> G e n e r i c S t o r a g e : : operator DataType ( ) throw ( ) { return ( d a t a ) ; }
Das Programm, das mit diesem Template arbeiten will, inkludiert wie gewohnt einfach unseren Header (generic_storage_test.cpp): 1 2 3
// g e n e r i c s t o r a g e t e s t . cpp − t e s t program f o r the g e n e r i c s t o r a g e // template t h a t has been s e p a r a t e d i n t o d e c l a r a t i o n and // d e f i n i t i o n f i l e s
4 5 6
#include < i o s t r e a m> #include < s t d e x c e p t>
7 8 9
#include ” u s e r t y p e s . h” #include ” g e n e r i c s t o r a g e . h”
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16 17
int main ( int a r g c , char ∗ argv [ ] ) { G e n e r i c S t o r a g e i n t 3 2 s t o r a g e ; int32 storage = 0;
13.7 Source Code Organisation
451
G e n e r i c S t o r a g e<double> d o u b l e s t o r a g e ( 1 2 . 0 ) ;
18 19
G e n e r i c S t o r a g e i n t 3 2 s t o r a g e 2 = i n t 3 2 s t o r a g e ; G e n e r i c S t o r a g e<double> d o u b l e s t o r a g e 2 ; double storage 2 = double storage ;
20 21 22 23
cout << ” i n t 3 2 s t o r a g e : ” << s t a t i c c a s t( i n t 3 2 s t o r a g e ) << e n d l << ” d o u b l e s t o r a g e : ” << s t a t i c c a s t<double>( d o u b l e s t o r a g e ) << e n d l << ” i n t 3 2 s t o r a g e 2 : ” << s t a t i c c a s t( i n t 3 2 s t o r a g e 2 ) << e n d l << ” d o u b l e s t o r a g e 2 : ” << s t a t i c c a s t( d o u b l e s t o r a g e ) << e nd l ; return ( 0 ) ;
24 25 26 27 28 29 30 31 32 33
}
In Kapitel 14 werden wir noch eine Methode kennen lernen, wie wir die Definitionen eines Templates durch einen entsprechenden Namespace vor den Benutzern gut genug verstecken k¨ onnen, sodass die Abh¨angigkeiten so gering wie m¨oglich ausfallen. Dadurch kann man dann wirklich sehr gut verhindern, dass Implementationsdetails pl¨ otzlich zum Problem f¨ ur die Benutzer werden, obwohl sie mit der Implementation des Templates selbst nichts am Hut haben. Vorsicht Falle: Zu guter Letzt m¨ ochte ich noch von einer Vorgehensweise abraten, die mancherorts praktiziert wird, um die Definition von Templates wirklich vollst¨ andig in eine eigene Compilation Unit zu verlagern und nirgends mehr zu inkludieren: Es wird ein eigenes cpp File geschrieben, in dem die Deklaration enthalten ist. Allerdings wird dieses nicht in den Header eingebunden, sondern es inkludiert seinerseits einen ganz besonderen Header, in dem nichts anders gemacht wird, als f¨ ur jede im Programm ben¨otigte spezielle Auspr¨ agung des Templates eine Dummy-Variable zu definieren. Nat¨ urlich zwingt man dadurch den Compiler, den entsprechenden Code zu erzeugen. Nat¨ urlich erreicht man damit auch, dass der Code nur einmal erzeugt wird. Allerdings hat man damit einen ganz b¨osen Seiteneffekt eingebaut: Man muss die Benutzung einer speziellen Auspr¨agung eines Templates explizit an einer f¨ ur das Programm vordefinierten Stelle registrieren. Außerdem hat man, je nach Compiler, noch ein Problem: Der generierte Code ist oft nur in einer Compilation Unit sichtbar. Damit hat man also erst wieder nichts erreicht, denn der Linker wird die Auspr¨agung nicht finden.
14. Namespaces
Als letztes Feature im C++ Sprachstandard kommen wir zu den sogenannten Namespaces, die man sehr sinnvoll zur Modularisierung von Software einsetzen kann. Mit Hilfe von Namespaces kann man logische Gruppierungen definieren und gegeneinander abgrenzen. Nehmen wir einfach an, wir h¨atten eine Sammlung von Klassen, Templates und Funktionen, die sich alle um das Thema Datenstrukturen drehen. Dann w¨are es nur logisch, diese Sammlung nach außen hin als Gruppierung z.B. unter dem Namen Datastructures_ anzubieten. Grunds¨ atzlich geht das sehr einfach, wie man am folgenden Beispiel sehen kann. Als Demonstration, wie man Funktionen in einen Namespace verpacken kann, schreiben wir einfach eine beliebige inline Funktion und stecken sie in einen Header (something.h): 1
// something . h − j u s t a dummy f u n c t i o n
2 3 4
#i f n d e f s o m e t h i n g h #define s o m e t h i n g h
5 6
#include < i o s t r e a m>
7 8 9
using s t d : : cout ; using s t d : : e nd l ;
10 11 12
namespace D a t a s t r u c t u r e s {
13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ i n l i n e void doSomething ( ) { cout << ” Function doSomething c a l l e d ” << e n d l ; }
21 22
} // end namespace D a t a s t r u c t u r e s
23 24
#endif // s o m e t h i n g h
In den Zeilen 11–12 bzw. in Zeile 22 sieht man, wie man etwas zum Member eines Namespaces macht: Man schreibt das Keyword namespace, gefolgt vom gew¨ unschten Namen eines Namespaces und klammert alles, was zu diesem Namespace geh¨ oren soll, zu einem Block zusammen. Wenn wir uns jetzt u ur eine große Menge an Code einen ¨berlegen, dass wir gar nicht so selten f¨
454
14. Namespaces
gemeinsamen Namespace haben wollen, dann f¨allt auf, dass diese einfache Klammerung eigentlich noch nicht der Weisheit letzter Schluss sein kann. Denn damit m¨ usste ja alles, was zu einem Namespace geh¨ort auch gemeinsam in einem File stehen, was sicher nicht im Sinne des Erfinders ist. Zum Gl¨ uck ist dem auch nicht so, denn Namespaces sind offen. Das bedeutet, dass an beliebig vielen verschiedenen Orten Teile eines Namespaces existieren k¨onnen. Solange der Bezeichner des Namespaces derselbe ist, werden sie auch vom Compiler demselben Namespace zugerechnet. Mit jeder neuen Definition, die zu einem Namespace geh¨ ort, wird dieser also erweitert. Genau diese Eigenschaft n¨ utzen wir in einem kurzen Beispiel aus. Werfen wir einen Blick auf ein Template eines primitiven Stacks (simple_stack.h): 1
// s i m p l e s t a c k . h − a s i m p l e s t a c k template
2 3 4
#i f n d e f s i m p l e s t a c k h #define s i m p l e s t a c k h
5 6 7
#include < s t d e x c e p t> #include ” u s e r t y p e s . h”
8 9 10 11 12
namespace D a t a s t r u c t u r e s { using s t d : : r a n g e e r r o r ; using s t d : : b a d a l l o c ;
13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ Stack ∗ ∗ j u s t a s i m p l e s t a c k template ∗ ∗/
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
template < c l a s s ElementType> c l a s s Stack { public : s t a t i c const u i n t 3 2 MAX NUM ELEMENTS = 8 ; private : Stack ( const Stack<ElementType>&) {} const Stack<ElementType>& operator = ( const Stack<ElementType>&) { return (∗ t his ) ; } protected : ElementType ∗ e l e m e n t s ; u i n t 3 2 num elements ; public : Stack ( ) throw( b a d a l l o c ) : num elements ( 0 ) { e l e m e n t s = new ElementType [MAX NUM ELEMENTS] ; }
40 41 42 43 44 45
virtual ˜ Stack ( ) throw ( ) { delete [ ] e l e m e n t s ; }
46 47 48
virtual void push ( ElementType const &element ) throw( r a n g e e r r o r )
14. Namespaces
455
{
49
i f ( num elements >= MAX NUM ELEMENTS) throw r a n g e e r r o r ( ” s t a c k o v e r f l o w ” ) ; e l e m e n t s [ num elements ++] = element ;
50 51 52
}
53 54
virtual ElementType pop ( ) throw( r a n g e e r r o r ) { i f ( num elements <= 0) throw r a n g e e r r o r ( ” s t a c k underflow ” ) ; return ( ElementType ( e l e m e n t s [−−num elements ] ) ) ; }
55 56 57 58 59 60 61 62 63
};
64 65
} // end namespace D a t a s t r u c t u r e s
66 67
#endif // s i m p l e s t a c k h
Man sieht, dass hier ein Namespace mit demselben Bezeichner wie zuvor in something.h in den Zeilen 9–10 ge¨offnet wird. Damit wird auch das Stack Template in den Namespace Datastructures_ dazu aufgenommen. Wie verh¨ alt sich das Ganze nun, wenn wir eine “normale” Klasse schreiben und diese nach gewohnter Manier auf zwei Files, einen Header und eine Definition aufteilen, diese Klasse aber im selben Namespace haben wollen? Sehen wir uns also einfach eine einfache Klasse CharBuffer an. Die Deklaration sieht folgendermaßen aus (char_buffer.h): 1
// c h a r b u f f e r . h − a s i m p l e c h a r a c t e r b u f f e r
2 3 4
#i f n d e f c h a r b u f f e r h #define c h a r b u f f e r h
5 6 7
#include < s t d e x c e p t> #include ” u s e r t y p e s . h”
8 9 10 11 12
namespace D a t a s t r u c t u r e s { using s t d : : r a n g e e r r o r ; using s t d : : b a d a l l o c ;
13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ CharBuffer ∗ ∗ j u s t a s i m p l e char B u f f e r ∗ ∗/
21 22 23 24 25 26 27 28 29 30 31 32
c l a s s CharBuffer { public : s t a t i c const u i n t 3 2 MAX NUM ELEMENTS = 8 ; private : CharBuffer ( const CharBuffer & s r c ) throw( b a d a l l o c ) { } const CharBuffer& operator = ( const CharBuffer&) { return (∗ th is ) ; } protected : u i n t 3 2 num elements ;
456
33 34 35 36 37 38 39
14. Namespaces
uint32 num elements allocated ; uint32 read index ; uint32 w r i t e i n de x ; char ∗ e l e m e n t s ; public : CharBuffer ( ) throw( b a d a l l o c ) ;
40 41
virtual ˜ CharBuffer ( ) throw ( ) { delete [ ] e l e m e n t s ; }
42 43 44 45 46 47
virtual void put ( char element ) throw( r a n g e e r r o r ) ;
48 49 50
virtual char getNext ( ) throw( r a n g e e r r o r ) ;
51 52 53
};
54 55
} // end namespace D a t a s t r u c t u r e s
56 57
#endif // c h a r b u f f e r h
Die Definition der Methoden ist erwartungsgem¨aß in char_buffer.cpp zu finden, was sich dann so liest: 1
#include ” c h a r b u f f e r . h”
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
namespace D a t a s t r u c t u r e s { //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ CharBuffer : : CharBuffer ( ) throw( b a d a l l o c ) { num elements = 0 ; read index = 0; write index = 0; n u m e l e m e n t s a l l o c a t e d = 0 ; // j u s t i n c a s e new f a i l s . . . e l e m e n t s = new char [MAX NUM ELEMENTS] ; n u m e l e m e n t s a l l o c a t e d = MAX NUM ELEMENTS; }
18 19 20 21 22 23 24 25 26 27 28 29 30 31
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/ void CharBuffer : : put ( char element ) throw( r a n g e e r r o r ) { i f ( num elements >= n u m e l e m e n t s a l l o c a t e d ) throw r a n g e e r r o r ( ” b u f f e r o v e r f l o w ” ) ; num elements ++; e l e m e n t s [ w r i t e i n d e x ++] = element ; i f ( w r i t e i n d e x >= n u m e l e m e n t s a l l o c a t e d ) write index = 0; }
32 33 34 35
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗/
14. Namespaces
36 37 38 39 40 41 42 43 44 45 46
457
char CharBuffer : : getNext ( ) throw( r a n g e e r r o r ) { i f ( num elements <= 0) throw r a n g e e r r o r ( ” b u f f e r underflow ” ) ; num elements −−; char element = e l e m e n t s [ r e a d i n d e x ++]; i f ( r e a d i n d e x >= n u m e l e m e n t s a l l o c a t e d ) read index = 0; return ( element ) ; }
47 48
} // end namespace D a t a s t r u c t u r e s
Wie zu erwarten war, muss man auch die Definitionen in denselben Namespace verpacken, in dem schon die Deklarationen zu finden waren. Verabs¨aumt man das, so ist der Compiler recht ungl¨ ucklich damit, weil er nicht erkennen kann, zu welcher Deklaration die Definition geh¨ort. Wir haben also nun einen Namespace Datastructures_ definiert, in dem ein Buffer, ein Stack Template und eine (zugegeben recht sinnlose) Funktion enthalten sind. Wie man nun prinzipiell mit diesem Namespace umgeht, zeigt unser Demoprogramm (first_namespace_demo.cpp): 1
// f i r s t n a m e s p a c e d e m o . cpp − demo how to use namespaces
2 3
#include < i o s t r e a m>
4 5 6 7 8
#include #include #include #include
” s i m p l e s t a c k . h” ” c h a r b u f f e r . h” ” something . h” ” u s e r t y p e s . h”
9 10 11
using s t d : : cout ; using s t d : : e nd l ;
12 13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { D a t a s t r u c t u r e s : : Stack a s t a c k ; D a t a s t r u c t u r e s : : CharBuffer a b u f f e r ;
17 18
D a t a s t r u c t u r e s : : doSomething ( ) ;
19 20 21 22 23
a a a a
s t a c k . push ( 1 ) ; s t a c k . push ( 2 ) ; s t a c k . push ( 3 ) ; s t a c k . push ( 4 ) ;
24 25 26 27
cout << ” s t a c k co n t e n t : ” << a s t a c k . pop() << ” , ” << a s t a c k . pop() << ” , ” << a s t a c k . pop() << ” , ” << a s t a c k . pop() << e n d l ;
28 29 30 31 32
a a a a
buffer buffer buffer buffer
. put ( ’ a ’ . put ( ’ b ’ . put ( ’ c ’ . put ( ’ d ’
); ); ); );
33 34 35 36
cout << ” b u f f e r co n t e n t : ” << a b u f f e r . getNext () << ” , ” << a b u f f e r . getNext () << ” , ” << a b u f f e r . getNext () << ” , ” << a b u f f e r . getNext () << e nd l ;
37 38
cout << ”max number o f elements i n the b u f f e r : ” <<
458
14. Namespaces
D a t a s t r u c t u r e s : : CharBuffer : :MAX NUM ELEMENTS << e n d l ;
39 40
return ( 0 ) ;
41 42
}
In den Zeilen 15–16 erkennt man, dass unser altbekannter Scope Operator auch bei Namespaces Anwendung findet. W¨ urde man z.B. in Zeile 15 den expliziten Scope auf Datastructures_ nicht angeben, dann w¨ urde dies zu einem Compilerfehler f¨ uhren, denn das Stack Template w¨are f¨ ur den Compiler nicht auffindbar. Dass es auch manchmal notwendig sein kann, mehrere Scope Operatoren hintereinander zu verwenden, zeigt Zeile 39: Dort wird auf den static Member MAX_NUM_ELEMENTS der Klasse CharBuffer zugegriffen, die im Namespace Datastructures_ deklariert ist. Ich glaube, zu diesem simplen Beispiel brauche ich keine weiteren Worte mehr zu verlieren. Der Output des Programms zeigt auch, dass alles so funktioniert, wie erwartet: Function doSomething c a l l e d s t a c k co n t e n t : 4 , 3 , 2 , 1 b u f f e r content : a , b , c , d max number o f elements i n the b u f f e r : 8
Das Makefile, mit dem dieses Programm u ¨bersetzt wurde, findet sich auf der beiliegenden CD-ROM unter dem Namen FirstNamespaceDemoMakefile. So nett es auch ist, dass man verschiedene Dinge zu einem logischen Ganzen zusammenfassen kann, so l¨ astig kann es auch werden, immer wieder explizit den Scope anzugeben. Stellen wir uns vor, wir verwenden die Funktion doSomething sehr oft in einem Teil unseres Codes. Dann w¨are es doch nett, wenn man das auch einfach so hinschreiben k¨onnte, ohne jedes Mal den Namespace extra anzugeben. Wie das funktioniert, wissen wir bereits, denn wir haben genau dieses Feature bereits im gesamten Buch z.B. f¨ ur cout verwendet: Es gibt daf¨ ur die using Direktive. So, wie wir bisher immer using std::cout; geschrieben haben, um danach einfach nur noch cout ohne Scope auf den Namespace std zu verwenden, k¨ onnen wir es auch mit unserer Funktion doSomething machen. Wir schreiben einfach using Datastructures_::doSomething; und schon ersparen wir uns das oftmalige, l¨astige Schreiben des Scopes. Nat¨ urlich k¨ onnen wir dasselbe auch mit dem Buffer und dem Stack machen. Eine Besonderheit muss noch zu using erw¨ahnt werden: Das sichtbar Machen von Teilen eines Namespaces gilt immer nur innerhalb des Blocks in dem die using Direktive steht. Werfen wir dazu einen kurzen Blick auf das Beispielprogramm: in Zeile 10 steht using std::cout; Das bedeutet, dass im gesamten File cout ganz einfach ohne besonderen Scope verwendet werden kann. W¨ urde diese Zeile aber innerhalb der main Funk-
14. Namespaces
459
tion stehen, also in unserem Fall z.B. in Zeile 15, dann w¨ urde sich die Verwendung von cout ohne Scope Operator auf die main Funktion beschr¨anken. Spinnt man diesen Gedanken mit der Sichtbarkeit weiter, so kann man sich leicht vorstellen, dass eine using Direktive, die innerhalb eines Namespaces steht, f¨ ur den gesamten Namespace gilt. Dieses Feature wird gerne verwendet, wenn ein großes Modul mit mehreren kleinen Submodulen arbeitet. Alle Teile des Namespaces k¨onnen hierbei auf die Submodule ohne besonderen Scope zugreifen, die Programmteile, die außerhalb des Namespaces stehen, aber nicht. Durch diesen Mechanismus kann man sehr gut gewisse Implementationsdetails vor den Benutzern einer Library verstecken. Neben der Verwendung von using f¨ ur einzelne Teile eines Namespaces gibt es auch noch die M¨ oglichkeit mit einer einzigen Anweisung gleich den gesamten Namespace sichtbar zu machen. Wollten wir unseren gesamten Namespace Datastructures_ sichtbar machen, w¨ urden wir Folgendes schreiben: using namespace Datastructures_; Damit w¨ aren alle Elemente aus unserem Namespace ohne expliziten Scope ansprechbar. Ich m¨ ochte jedoch darauf hinweisen, dass diese Vorgehensweise wirklich nur in besonderen F¨ allen sinnvoll ist. Im Normalfall ist es besser, ein¨ zelne Elemente aus einzelnen Namespaces einzubinden um Uberraschungen zu verhindern. Es k¨ onnte ja sein, dass in einem Namespace gewisse Elemente enthalten sind, die eigentlich nur f¨ ur den privaten Gebrauch innerhalb der Implementation dieses Namespaces gedacht sind. Mir ist schon klar, dass das eigentlich nur aufgrund von eher mangelhaftem Design von Namespaces passieren kann, aber man soll niemanden in Versuchung f¨ uhren :-). Es gibt auch noch einen anderen Grund, warum man nicht gleich ganze Namespaces automatisch sichtbar machen soll: Was passiert, wenn in zwei Namespaces zuf¨ allig Elemente unter demselben Namen existieren? Dann kann sich der Compiler beim Aufruf ohne Scope nicht wirklich entscheiden, welches Element denn nun gemeint ist. In einem solchen Fall wird man also dann erst recht wieder gezwungen, mit einem expliziten Scope zu arbeiten, um den Compiler zu beruhigen. Einen Fall allerdings gibt es, in dem der Compiler einen Name-Clash selbst aufl¨ost: Nehmen wir an, wir h¨ atten in unserem Namespace Datastructures_ die Direktive using namespace SomeStacks_; und im Namespace SomeStacks_ h¨ atten wir, so wie in Datastructures_, eine Funktion doSomething mit denselben Parametern. Ruft man in einem Codest¨ uck innerhalb des Namespaces Datastructures_ nun doSomething auf, so wird automatisch die Implementation aus dem eigenen Namespace genommen und nicht die sichtbar gemachte Implementation aus SomeStacks_. Aus gutem Grund m¨ ochte ich allen Lesern raten, die Bezeichner von Namespaces immer sprechend und nicht zu kurz zu machen. Namespaces mit Bezeichnern wie z.B. DS (f¨ ur Datastructures_) sind um jeden Preis
460
14. Namespaces
zu vermeiden. Durch solche Bezeichner besteht die große Gefahr, dass man irrt¨ umlich Namespaces, die miteinander nichts zu tun haben, zu einem gemeinsamen gr¨ oßeren Namespace vereint, bloß weil die Namen zuf¨allig dieselben sind. Das ist sicher nicht im Sinne des Erfinders und vor allem sind dadurch Name-Clashes praktisch vorprogrammiert. Nun gibt es aber F¨alle, in denen man kein explizites using f¨ ur viele verschiedene Elemente aus einem Namespace schreiben will, weil man jedes einzelne f¨ ur sich eigentlich nicht oft genug braucht, um das zu rechtfertigen. Andererseits aber braucht man trotzdem den Scope auf diesen Namespace ziemlich oft, weil man es eben mit vielen verschiedenen Namespaces zu tun hat. F¨ ur solche F¨alle gibt es die M¨oglichkeit, einem Namespace ein Alias zu geben. Wollte man z.B. das Alias DS f¨ ur Datastructures_ im Code verwenden, so kann man folgendes Statement schreiben: namespace DS = Datastructures_; Ich w¨ urde jedoch wirklich zur Vorsicht mahnen, denn man kann Code dadurch ziemlich verwirrend und damit unlesbar machen. Zu guter Letzt m¨ ochte ich noch auf eine M¨oglichkeit hinweisen, die man mit Namespaces hat und die eigentlich wie ein Paradoxon klingt: Es gibt auch sogenannte unnamed Namespaces. Man kann z.B. die folgenden Zeilen schreiben: 1 2 3 4 5 6 7 8 9
namespace { void f ( ) { // whatever code } // some code f ( ) ; // c a l l s the f u n c t i o n f d e f i n e d above }
Hierdurch hat man einen unnamed Namespace verwendet, der einzig und allein die Aufgabe hat, die Funktion mit dem glorreichen Namen f davor zu sch¨ utzen, einer anderen Funktion f in die Quere zu kommen, die an einem anderen Ort definiert sein k¨ onnte. Der Aufruf von f() in Zeile 8 findet aufgrund der zuvor genannten Aufl¨ osungsstrategie sicher zuerst die im Namespace definierte Funktion f. Allerdings kann ich zu dieser Vorgehensweise wirklich nur noch sagen, dass es wohl keinen schlimmeren Hack gibt als solche sinnlosen Funktionsnamen. Sollte man jemals in die Situation kommen, einen unnamed Namespace zu brauchen, dann sollte man lieber einmal stark dar¨ uber nachdenken, ob nicht ein Umschreiben des Codes dringend vonn¨oten w¨are. Bei sauber geschriebenem Code ist die Wahrscheinlichkeit praktisch Null, dass man jemals unnamed Namespaces brauchen w¨ urde. Nat¨ urlich kann man jetzt auch das Argument bringen, dass es ja z.B. alten Code gibt, den man wiederverwenden m¨ochte und der eben leider einmal nicht so toll ist. Aber auch dann w¨ urde ¨ ich sehr dringend (!) zu einer Uberarbeitung raten. Durch das Kaschieren von schlimmen Hacks hat man das Problem ja doch nur verschoben und nicht aus der Welt geschafft!
14. Namespaces
461
Ein gutes Haar muss ich allerdings trotzdem an den unnamed Namespaces lassen: In C++ wird zwar die Verwendung des Keywords static bei globalen Variablen zum Einschr¨ anken der Sichtbarkeit auf die Compilation Unit weiterhin aus Kompatibilit¨ atsgr¨ unden unterst¨ utzt. Jedoch wird angeraten, dieses Feature nicht mehr zu verwenden und stattdessen unnamed Namespaces zum Einsatz zu bringen. Wenn also kein schlimmer Hack vorliegt, wie im Beispiel zuvor, man sich aber trotzdem vor eventuellen Name-Clashes aus Vorsichtsgr¨ unden sch¨ utzen will, dann sind unnamed Namespaces ein sauberes Mittel zum Zweck.
15. Verschiedenes
In diesem Kapitel werden Aspekte von C++ behandelt, die aus verschiedenen Gr¨ unden keinen guten Platz im bisherigen Text bekommen konnten. Sie h¨atten den Aufbau entweder durch starke Vorgriffe oder durch starke Nebenl¨aufigkeit zu sehr verletzt.
15.1 mutable Member Variablen Es wurde in Abschnitt 6.1 als auch in Abschnitt 9.4.1 bereits erw¨ahnt, dass es eine saubere Alternative zum const_cast gibt. Es ist n¨amlich in C++ m¨oglich, bestimmte Member-Variablen als mutable zu deklarieren. Damit d¨ urfen sie auch im Fall eines mit const vor Ver¨anderung gesch¨ utzten Objekts modifiziert werden, ohne dass sich der Compiler beschwert. Durch mutable steht also ein sauberer Mechanismus zur Verf¨ ugung, logische von physikalischer Konstanz zu trennen. Am Beispiel sieht das folgendermaßen aus (mutable_demo.cpp): 1 2
// mutable demo . cpp − demo how mutable o b j e c t s h e l p to avoid // the nasty c o n s t c a s t
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ ClassWithCache ∗ ∗ j u s t a dummy c l a s s with a cache ∗ ∗/
17 18 19 20 21 22 23 24 25 26
c l a s s ClassWithCache { protected : mutable u i n t 3 2 c a c h e d r e s u l t ; mutable bool d i r t y ; uint32 o r i g i n a l ; public : ClassWithCache ( ) throw ( ) : c a c h e d r e s u l t ( 0 ) ,
464
15. Verschiedenes
d i r t y ( true ) , o r i g i n a l (0) {}
27 28 29 30 31 32
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual ˜ ClassWithCache ( ) throw ( ) { }
33 34 35 36 37 38 39
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual void s e t O r i g i n a l ( u i n t 3 2 o r i g i n a l ) { original = original ; d i r t y = true ; }
40 41 42 43 44 45
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual u i n t 3 2 g e t O r i g i n a l ( ) const { return ( o r i g i n a l ) ; }
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual u i n t 3 2 g e t R e s u l t ( ) const { if ( dirty ) { cout << ” c a l c u l a t i n g r e s u l t . . . ” << e nd l ; c a c h e d r e s u l t = 2 ∗ o r i g i n a l ; // very computing i n t e n s e // c a l c u l a t i o n . . . r e s u l t has to be cached : − ) dirty = false ; } else { cout << ” j u s t t a k i n g cached r e s u l t . . . ” << e nd l ; } return ( c a c h e d r e s u l t ) ; } };
64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { ClassWithCache a n o b j e c t ;
69
an object . setOriginal (17);
70 71
cout << ” a n o b j e c t s t o r e s the o r i g i n a l ” << a n o b j e c t . g e t O r i g i n a l () << e n d l << ” a f t e r the c a l c u l a t i o n the r e s u l t i s : ” << a n o b j e c t . g e t R e s u l t () << ” a l l f u r t h e r r e q u e s t s come from the cache : ” << e n d l << a n o b j e c t . g e t R e s u l t () << e n d l << a n o b j e c t . g e t R e s u l t () << e n d l ;
72 73 74 75 76 77 78 79
}
In den Zeilen 21–22 sieht man, dass die beiden Variablen, die mit dem Caching des Resultats zu tun haben als mutable deklariert wurden. Aus diesem Grund d¨ urfen sie auch v¨ ollig ungestraft innerhalb der const Methode getResult ver¨andert werden. Ich gebe ja schon zu, dass die Berechnung in Zeile 53 nicht ganz so rechenintensiv ist, dass man das Ergebnis zwischenspeichern m¨ usste, aber sie k¨ onnte es sein :-). Der Output des Programms zeigt, dass der Cache auch wirklich funktioniert:
15.2 Unions im OO Kontext
465
a n o b j e c t s t o r e s the o r i g i n a l 1 7 a f t e r the c a l c u l a t i o n the r e s u l t i s : c a l c u l a t i n g r e s u l t . . . 34 a l l f u r t h e r r e q u e s t s come from the cache : j u s t t a k i n g cached r e s u l t . . . 34 j u s t t a k i n g cached r e s u l t . . . 34
Diese Art des Cachings ist in vielen verschiedenen Situationen sehr brauchbar, vor allem auch, wenn es um Netzwerk- und Plattenzugriffe geht. Dort liegt dann auch einer der Haupteinsatzbereiche von mutable Member Variablen in der Praxis. Vorsicht Falle: Man kann gar nicht oft genug davor warnen: Sowohl ein const_cast als auch mutable Members sind ausschließlich dann einzusetzen, wenn auch wirklich die logische Konstanz eines Objekts gewahrt bleibt! Fehler, die dadurch entstehen, dass sich ein Objekt nicht mehr logisch konstant verh¨alt, sind unglaublich schwer zu lokalisieren und ziehen zumeist lange N¨achte mit hohem Kaffeeverbrauch im Zuge einer verzweifelten Debugging Session nach sich.
15.2 Unions im OO Kontext In Abschnitt 2.4.3 wurden bereits die Grundeigenschaften von Unions und auch die Gefahren bei ihrer Nutzung erkl¨art. Da zu diesem Zeitpunkt aber die OO Konzepte von C++ noch nicht bekannt waren, musste ich einige Aspekte schuldig bleiben, die ich in der Folge nachholen werde. Wenden wir uns zu diesem Zweck also am besten einem einfachen Beispiel zu (union_demo.cpp): 1
// union demo . cpp − demo f o r unions i n the OO c o n t e x t
2 3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8 9 10
using using using using
std std std std
: : cout ; : : cerr ; : : e nd l ; : : bad cast ;
11 12 13 14 15 16 17 18
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ DummyUnion ∗ ∗ j u s t a dummy union f o r demo purposes ∗ ∗/
19 20 21 22 23 24 25
union DummyUnion { private : DummyUnion( const DummyUnion&) {} const DummyUnion& operator = ( const DummyUnion&) { return (∗ t h is ) ; } public :
466
15. Verschiedenes
uint32 uint32 value ; double d o u b l e v a l u e ;
26 27 28
DummyUnion( ) throw ( ) { cout << ”DummyUnion c o n s t r u c t o r ” << e nd l ; }
29 30 31 32 33 34
˜DummyUnion( ) throw ( ) { cout << ”DummyUnion d e s t r u c t o r ” << e n d l ; }
35 36 37 38 39 40 41
};
42 43 44 45 46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ UnionEncapsulation ∗ ∗ j u s t an e n c a p s u l a t i o n f o r a union ∗ ∗/
50 51 52 53 54 55 56 57 58 59
c l a s s UnionEncapsulation { protected : uint32 current type ; DummyUnion t h e u n i o n ; public : const s t a t i c u i n t 3 2 NO TYPE = 0 ; const s t a t i c u i n t 3 2 UINT32 TYPE = 1 ; const s t a t i c u i n t 3 2 DOUBLE TYPE = 2 ;
60 61 62
UnionEncapsulation ( ) throw ( ) : c u r r e n t t y p e (NO TYPE) { }
63 64 65
UnionEncapsulation ( const UnionEncapsulation & s r c ) throw ( ) ;
66 67 68
virtual ˜ UnionEncapsulation ( ) throw ( ) { }
69 70 71 72
virtual UnionEncapsulation& operator = ( const UnionEncapsulation & s r c ) throw ( ) ;
73 74 75 76 77 78
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual u i n t 3 2 getType ( ) const { return ( c u r r e n t t y p e ) ; }
79 80 81 82 83 84 85
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual void s e t V a l u e ( u i n t 3 2 v a l u e ) { c u r r e n t t y p e = UINT32 TYPE ; the union . uint32 value = value ; }
86 87 88 89 90 91
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual void s e t V a l u e ( double v a l u e ) { c u r r e n t t y p e = DOUBLE TYPE; the union . double value = value ;
15.2 Unions im OO Kontext
}
92 93 94 95 96 97 98 99 100 101
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual operator u i n t 3 2 ( ) throw( bad cast ) { i f ( c u r r e n t t y p e ! = UINT32 TYPE) throw bad cast ( ) ; return ( t h e u n i o n . u i n t 3 2 v a l u e ) ; }
102 103 104 105 106 107 108 109 110
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual operator double ( ) throw( bad cast ) { i f ( c u r r e n t t y p e ! = DOUBLE TYPE) throw bad cast ( ) ; return ( t h e u n i o n . d o u b l e v a l u e ) ; }
111 112
};
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− UnionEncapsulation : : UnionEncapsulation ( const UnionEncapsulation & s r c ) throw ( ) : c u r r e n t t y p e ( s r c . c u r r e n t t y p e ) { switch ( c u r r e n t t y p e ) { case UINT32 TYPE : the union . uint32 value = src . the union . uint32 value ; break ; case DOUBLE TYPE: the union . double value = src . the union . double value ; break ; default : c u r r e n t t y p e = NO TYPE; // a l s o an e x c e p t i o n would be ok h e r e case NO TYPE: break ; } }
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− UnionEncapsulation& UnionEncapsulation : : operator = ( const UnionEncapsulation & s r c ) throw ( ) { current type = src . current type ; switch ( c u r r e n t t y p e ) { case UINT32 TYPE : the union . uint32 value = src . the union . uint32 value ; break ; case DOUBLE TYPE: the union . double value = src . the union . double value ; break ; default : c u r r e n t t y p e = NO TYPE; // a l s o an e x c e p t i o n would be ok h e r e case NO TYPE: break ; } return (∗ th is ) ; }
155 156 157
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] )
467
468
158
15. Verschiedenes
{ UnionEncapsulation encap ;
159 160
encap . s e t V a l u e ( ( u i n t 3 2 ) 1 0 ) ;
161 162
try { cout << ” t r y i n g u i n t 3 2 v a l u e : ” << s t a t i c c a s t(encap ) << e nd l ; cout << ” t r y i n g double v a l u e : ” << s t a t i c c a s t<double>(encap ) << e nd l ; } catch ( bad cast &exc ) { // should take c e r r , but to p r e s e r v e the o r d e r o f output // cout i s taken h e r e f o r demo purposes cout << ” oops − t r i e d to a c c e s s wrong type . . . ” << e n d l ; }
163 164 165 166 167 168 169 170 171 172 173 174 175 176
encap . s e t V a l u e ( 2 0 . 5 ) ; try { cout << ” t r y i n g double v a l u e : ” << s t a t i c c a s t<double>(encap ) << e nd l ; cout << ” t r y i n g u i n t 3 2 v a l u e : ” << s t a t i c c a s t(encap ) << e nd l ; } catch ( bad cast &exc ) { // should take c e r r , but to p r e s e r v e the o r d e r o f output // cout i s taken h e r e f o r demo purposes cout << ” oops − t r i e d to a c c e s s wrong type . . . ” << e n d l ; }
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
return ( 0 ) ;
192 193
}
In den Zeilen 23–24 und 29–39 sieht man, dass auch Unions in C++ Konstruktoren und Destruktoren besitzen k¨ onnen. Es l¨asst sich unschwer erraten, dass jegliche Member Methoden und Operatoren nat¨ urlich auch im Kontext von Unions funktionieren, deshalb gehe ich im Beispiel nicht explizit darauf ein. Eine Eigenschaft der Methoden in Unions allerdings verdient Beachtung: Sie k¨onnen nicht virtual sein. Der Grund ist ein einfacher: Man kann von Unions nicht ableiten und dementsprechend existiert auch kein Polymorphismus, der dynamic Binding notwendig machen w¨ urde. Die Klasse UnionEncapsulation in den Zeilen 51–112 demonstriert eine M¨oglichkeit, wie man Unions zumindest durch Kapselung ein wenig besser vor Fehlverwendung sch¨ utzen kann. In dieser Klasse wird der aktuelle Typ vermerkt, der in der Union gerade G¨ ultigkeit besitzt, und nat¨ urlich die Union selbst. Dass hier nicht eine anonymous Union (siehe Abschnitt 2.4.3) Anwendung findet, hat nur den Grund, dass ich zeigen wollte, dass auch Unions Methoden besitzen k¨ onnen. In der Praxis w¨ urde man hier in den meisten F¨allen auf eine anonymous Union zur¨ uckgreifen. In den Zeilen 81–92 zeigt sich, wie man ein Overloading von Methoden im Dienste der typensicheren Verwendung einer Union einsetzen kann. Weiters wird der direkte lesende Zugriff auf Members der Union nicht erlaubt, sondern
15.2 Unions im OO Kontext
469
durch die entsprechenden Typumwandlungs-Operatoren in den Zeilen 95–110 ersetzt. Die Implementationen des Copy Constructors und des Zuweisungsoperators bergen keine Neuigkeiten, also sehen wir uns nur noch kurz die Verwendung unserer gekapselten Union an: • In Zeile 161 wird setValue mit einem uint32 Parameter aufgerufen. Der explizite Cast ist notwendig, da ja 10 einfach als int interpretiert wird und somit der Compiler vor einem Ambiguit¨atsproblem stehen w¨ urde. • In den Zeilen 165–166 wird korrekt auf die Union zugegriffen und dementsprechend funktioniert alles wie geplant. • In den Zeilen 167–168 jedoch wird versucht einen double Wert zu lesen, der hier ung¨ ultig ist. Zum Gl¨ uck gibt’s das try ... catch Konstrukt :-). Anzumerken w¨ are hier noch, dass Fehlermeldungen wie in den Zeilen 174 und 189 im Normalfall immer auf cerr und nicht auf cout geschrieben werden m¨ ussen. Im Demoprogramm wurde nur deshalb auf cout geschrieben, um die Reihenfolge der einzelnen Ausgaben nicht durcheinander zu bringen. • In den Zeilen 177–190 findet dasselbe Spielchen noch einmal statt, nur eben hier mit einem double Wert. Dass meine Beschreibung der Vorg¨ ange nicht frei erfunden ist, sieht man am folgenden Output, den das Programm liefert: DummyUnion c o n s t r u c t o r trying uint32 value : 1 0 t r y i n g double v a l u e : oops − t r i e d to a c c e s s wrong type . . . t r y i n g double v a l u e : 2 0 . 5 t r y i n g u i n t 3 2 v a l u e : oops − t r i e d to a c c e s s wrong type . . . DummyUnion d e s t r u c t o r
Obwohl wir hier also offensichtlich einen Weg gefunden haben, Unions einigermaßen sicher zu machen, sollte man sich einige Gedanken um deren Verwendung bzw. um die Vermeidung derselben machen: Wenn wir hier einmal ganz bestimmte und besondere Vorgaben durch die Hardware oder durch Fremdsysteme außen vor lassen, ist der einzige Grund f¨ ur die Verwendung von Unions die damit erreichbare Speicherersparnis. ¨ Uberlegt man allerdings, dass der von einer Union ben¨otigte Speicher immer ihrem gr¨ oßten Member entspricht, dann kommt man zu folgendem Schluss: Sollten alle Members einigermaßen gleich groß sein, dann erreicht man eine sinnvolle Ersparnis. Sollte es jedoch zumindest einen Member geben, der im Vergleich zu den anderen sehr groß ist, dann ist die Ersparnis nicht mehr wirklich gegeben. Ganz im Gegenteil! Man kann hierbei n¨amlich je nach Anwendung sogar eine große Speicherverschwendung bewirken, denn auch f¨ ur die kleinen Members wird immer der Platz des bzw. der großen belegt! ¨ Aus dieser Uberlegung heraus kommt man schnell zu dem Schluss, dass es in solchen F¨ allen besser w¨ are, mit einer vern¨ unftigen Klassenhierarchie und unter sinnvoller Ausn¨ utzung der Polymorphismus-Eigenschaften zu arbeiten.
470
15. Verschiedenes
15.3 Funktionspointer Wie bereits in Abschnitt 6.2.4 erw¨ ahnt, gibt es auch in C++ die bereits von C her bekannten Funktionspointer. Man kann einfach eine entsprechende Pointervariable definieren und ihr als Adresse die Einsprungadresse einer Funktion zuweisen. In C++ kann man Funktionspointer allerdings nicht nur auf Funktionen zeigen lassen, sondern unter anderem auch auf Member-Methoden. Sehen wir uns hierzu einfach ein kleines Beispiel an (function_pointer_demo1.cpp): 1
// f u n c t i o n p o i n t e r d e m o 1 . cpp − f i r s t demo f o r f u n c t i o n p o i n t e r s
2 3 4
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
5 6 7
using s t d : : cout ; using s t d : : e nd l ;
8 9 10 11 12 13
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− uint32 aCalculationFunction ( uint32 val ) { return ( v a l ∗ 5 ) ; }
14 15 16 17 18 19
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− uint32 anotherCalculationFunction ( uint32 val ) { return ( v a l + 1 7 ) ; }
20 21 22 23 24 25 26 27
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ CalcMethodCollection ∗ ∗ j u s t a dummy c o l l e c t i o n o f c a l c u l a t i o n methods ∗ ∗/
28 29 30 31 32 33 34 35 36 37 38 39 40 41
c l a s s CalcMethodCollection { private : CalcMethodCollection ( ) ; CalcMethodCollection ( const CalcMethodCollection &) {} const CalcMethodCollection & operator = ( const CalcMethodCollection&) { return (∗ th is ) ; } public : //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− static uint32 aSpecialCalculationFunction ( uint32 val ) { return ( v a l ∗ 2 ) ; }
42 43 44 45 46 47 48
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− static uint32 anotherSpecialCalculationFunction ( uint32 val ) { return ( v a l + 1 2 ) ; } };
49 50 51 52
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] )
15.3 Funktionspointer
53
471
{ uint32 (∗ calcFunction ) ( uint32 ) = aCalculationFunction ;
54 55
cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << c a l c F u n c t i o n (3) << e n d l ;
56 57
calcFunction = anotherCalculationFunction ; cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << c a l c F u n c t i o n (3) << e n d l ;
58 59 60
c a l c F u n c t i o n = CalcMethodCollection : : a S p e c i a l C a l c u l a t i o n F u n c t i o n ; cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << c a l c F u n c t i o n (3) << e n d l ;
61 62 63
calcFunction = CalcMethodCollection : : a n o t h e r S p e c i a l C a l c u l a t i o n F u n c t i o n ; cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << c a l c F u n c t i o n (3) << e n d l ;
64 65 66 67
return ( 0 ) ;
68 69
}
Wir haben es in unserem Beispiel mit vier verschiedenen Berechnungsroutinen zu tun. Zwei davon sind Funktionen und in den Zeilen 10–19 definiert, zwei weitere davon sind static Methoden der Klasse CalcMethodCollection und in den Zeilen 38–48 definiert. In Zeile 54 sieht man, wie man eine Variable definiert, die als Typ einen Pointer auf eine Funktion h¨ alt: Man schreibt sie einfach mit Parameterliste und return-Value hin. Wichtig hierbei ist, dass man die Klammerung, die den * und den Variablennamen einschließt, nicht vergisst. Ansonsten w¨ urde sich der * n¨ amlich auf die Deklaration der return-Values beziehen, wodurch der Compiler den Rest der Definition nicht mehr interpretieren kann und mit einem Fehler aussteigt. Allen Lesern, denen die eben beschriebene Syntax oder auch Funktionspointer in C im Allgemeinen nicht wirklich allzu gel¨aufig sind, m¨ochte ich sehr eindringlich die Lekt¨ ure von Abschnitt 10.7 aus Softwareentwicklung in C ans Herz legen. In den Zeilen 54 und 58 sieht man, wie man der Variable die Adresse einer Funktion zuweist: Man schreibt einfach ihren Namen auf die rechte Seite der Zuweisung (bzw. Initialisierung in Zeile 54). Erwartungsgem¨aß ist dieser Mechanismus derselbe bei static Members von Klassen. Hier ist es nur notwendig, dass man den entsprechenden Scope auch angibt, sonst ist der Compiler gar nicht so gl¨ ucklich. Dass tats¨achlich die immer gleichen Aufrufe von calcFunction jeweils eine andere Routine aufrufen, zeigt sich im Output des Programms: calling calling calling calling
calcFunction ( 3 ) : calcFunction ( 3 ) : calcFunction ( 3 ) : calcFunction ( 3 ) :
15 20 6 15
Ganz bewusst habe ich in das Programm noch etwas eingebaut, was man bei der Verwendung von Funktionspointern bedenken sollte: Die static Members sind beide inline. Was passiert allerdings, wenn man einen Funktionspointer auf eine inline Methode zeigen l¨asst? Nun ja, das l¨asst sich nicht
472
15. Verschiedenes
so ganz genau sagen und ist vom Compiler abh¨angig. Entweder er l¨asst das Inlining der Methode u ¨berhaupt bleiben oder er generiert zwei verschiedene Auspr¨agungen derselben Methode, eine als inline Methode und eine andere, die u ¨ber den Funktionspointer aufrufbar ist. Auch andere Alternativen sind m¨oglich, aber eine Diskussion dar¨ uber u ¨berlasse ich am besten den Compilerbauern :-). Im obigen Beispiel habe ich bewusst nur static Members demonstriert. Wie verh¨ alt es sich aber nun mit Funktionspointern auf Members, die nicht static sind? Wenn man sich u ¨berlegt, dass bei solchen Methoden immer auch ein Scope auf die aktuelle Instanz mit im Spiel ist und dass entsprechend der this Pointer quasi als versteckter Parameter beim Aufruf mitgegeben wird, dann kann man sich leicht vorstellen, dass die Situation hierbei nicht mehr ganz so einfach ist. Wenn man einen Pointer auf eine Methode hat, muss man also beim Aufruf daf¨ ur sorgen, dass irgendwie der Kontext zu einer Instanz der Klasse, aus der die Methode kommt, erhalten bleibt. Wie das funktioniert, sieht man am folgenden Beispiel (function_pointer_demo2.cpp): 1 2
// f u n c t i o n p o i n t e r d e m o 2 . cpp − demo o f ( something l i k e ) f u n c t i o n // p o i n t e r s to non−s t a t i c methods
3 4 5
#include < i o s t r e a m> #include ” u s e r t y p e s . h”
6 7 8
using s t d : : cout ; using s t d : : e nd l ;
9 10 11 12 13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ CalcMethodCollection ∗ ∗ j u s t a dummy c o l l e c t i o n o f c a l c u l a t i o n methods ∗ ∗/
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
c l a s s CalcMethodCollection { private : CalcMethodCollection ( const CalcMethodCollection &) {} const CalcMethodCollection & operator = ( const CalcMethodCollection&) { return (∗ th is ) ; } protected : uint32 m u l t i p l i c a t o r ; uint32 o f f s e t ; public : //−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− CalcMethodCollection ( u i n t 3 2 m u l t i p l i c a t o r , u i n t 3 2 o f f s e t ) throw ( ) : m u l t i p l i c a t o r ( m u l t i p l i c a t o r ) , o f f s e t ( o f f s e t ) {}
33 34 35
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual ˜ CalcMethodCollection ( ) { }
36 37 38 39 40 41
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− u i n t 3 2 aCalculatorMethod ( u i n t 3 2 v a l ) { return ( v a l ∗ m u l t i p l i c a t o r ) ; }
15.3 Funktionspointer
473
42 43 44 45 46 47 48
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual u i n t 3 2 anotherCalculatorMethod ( u i n t 3 2 v a l ) { return ( v a l + o f f s e t ) ; } };
49 50 51 52 53 54 55 56
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ AnotherCalcMethodCollection ∗ ∗ j u s t a demo f o r polymorphism ∗ ∗/
57 58 59 60 61 62
c l a s s AnotherCalcMethodCollection : public CalcMethodCollection { public : AnotherCalcMethodCollection ( u i n t 3 2 m u l t i p l i c a t o r , u i n t 3 2 o f f s e t ) throw ( ) : CalcMethodCollection ( m u l t i p l i c a t o r , o f f s e t ) { }
63
virtual ˜ AnotherCalcMethodCollection ( ) throw ( ) { }
64 65 66 67 68 69 70 71 72
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− virtual u i n t 3 2 anotherCalculatorMethod ( u i n t 3 2 v a l ) { return ( v a l + ( 2 ∗ o f f s e t ) ) ; } };
73 74 75 76 77 78
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { u i n t 3 2 ( CalcMethodCollection : : ∗ c a l c F u n c t i o n ) ( u i n t 3 2 ) ;
79
CalcMethodCollection m e t h o d c o l l e c t i o n 1 ( 2 , 7 ) ; AnotherCalcMethodCollection m e t h o d c o l l e c t i o n 2 ( 2 , 7 ) ;
80 81 82
c a l c F u n c t i o n = & CalcMethodCollection : : aCalculatorMethod ; cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << ( m e t h o d c o l l e c t i o n 1 . ∗ c a l c F u n c t i o n )(3) << e n d l ;
83 84 85 86
c a l c F u n c t i o n = & CalcMethodCollection : : anotherCalculatorMethod ; cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << ( m e t h o d c o l l e c t i o n 1 . ∗ c a l c F u n c t i o n )(3) << e n d l ;
87 88 89 90
// a l s o polymorphism works with f u n c t i o n p o i n t e r s cout << ” c a l l i n g c a l c F u n c t i o n ( 3 ) : ” << ( m e t h o d c o l l e c t i o n 2 . ∗ c a l c F u n c t i o n )(3) << e n d l ;
91 92 93 94
return ( 0 ) ;
95 96
}
In den Zeilen 38–41 ist nun eine der Berechnungsmethoden als statisch gebundener Member implementiert, in den Zeilen 44–48 findet sich die zweite Berechnungsmethode als virtual Methode. Ein Overriding dieser Methode findet sich in der abgeleiteten Klasse in den Zeilen 58–72. Richtig interessant wird es allerdings erst weiter unten. In Zeile 78 erkennt man, wie man einen Pointer auf eine Methode als Variable definiert: Im
474
15. Verschiedenes
Prinzip gleich, wie wir es schon gewohnt sind, nur dass auch der Scope auf die Klasse mit angegeben werden muss, in der die Methode zu finden ist. Zeile 78 ist also folgendermaßen zu lesen: Die Variable calcFunction ist ein Pointer auf eine beliebige Methode der Klasse CalcMethodCollection, die einen uint32 als Parameter nimmt und einen uint32 als return-Value liefert. Das bedeutet also, dass man dieser Variable alle Methoden zuweisen kann, die diese Bedingung erf¨ ullen. In Zeile 80 erzeugen wir eine Instanz unserer CalcMethodCollection, die den Kontext f¨ ur einen Aufruf bietet. In Zeile 83 sieht man, wie man die Zuweisung an unsere Variable bewerkstelligt: Man weist einen Pointer auf die Methode zu. Hierbei braucht man, im Gegensatz zu Funktionen und static Methoden, tats¨achlich den & Operator um den Compiler davon zu u ¨berzeugen, dass man wirklich die Adresse der Methode zuweisen will. Jetzt habe ich allerdings zuvor noch groß davon gesprochen, dass man ja eine Methode nur im Kontext der Instanz eines Objekts aufrufen kann. Bisher ist aber von einer solchen Instanz noch keine Rede, obwohl man sie bei der Zuweisung eigentlich erwartet h¨atte. Des R¨atsels L¨osung zeigt sich in den Zeilen 84–85: Der Kontext wird beim Aufruf der Methode hergestellt! Die Zeilen 87–89 zeigen, dass man auch virtual Methoden an einen solchen Funktionspointer zuweisen kann. Dass dadurch auch im Fall des Aufrufs u ¨ber einen Funktionspointer die Eigenschaft des Polymorphismus erhalten bleibt, zeigen die Zeilen 92–93: Hier wird nur die zuvor zugewiesene Methode in einem anderen Kontext aufgerufen, n¨amlich im Kontext einer Instanz eines von CalcMethodCollection abgeleiteten Objekts. Dass hierbei erwartungsgem¨ aß die entsprechende Methode in der abgeleiteten Klasse aufgerufen wird, zeigt der Output des Programms: c a l l i n g calcFunction ( 3 ) : 6 c a l l i n g calcFunction ( 3 ) : 1 0 c a l l i n g calcFunction ( 3 ) : 1 7
Dadurch bedingt, dass man bei Pointern auf nicht static Members immer beim Aufruf die Verbindung zu einem Objekt hergestellt werden muss, hat man nicht mehr ganz die Flexibilit¨ at, die man bei Pointern auf Funktionen und static Members hatte. Man kann nicht einfach “irgendeine” Methode aufrufen, die jemand einmal zugewiesen hat, ohne die Klasse und sogar das Objekt dazu zu kennen. Aus diesem Grund stehe ich auf dem Standpunkt, dass man Pointer auf Methoden eigentlich auch wirklich nicht Funktionspointer nennen sollte, sondern Methodenpointer. Dadurch ist n¨amlich auch bereits im Namen ausgedr¨ uckt, dass sie eine andere Natur haben und anders zu behandeln sind. Vorsicht Falle: Neben allen Stolpersteinen, die Funktionspointer von Natur aus zu bieten haben (siehe Abschnitt 10.7 aus Softwareentwicklung in C ), m¨ochte ich auf eine Falle hinweisen, in die gerade begeisterte Neulinge sehr gerne tappen: Funktions- und Methodenpointer k¨onnen vorsichtig, sel-
15.4 Besondere Keywords, Diagraphs und Trigraphs
475
ten und sauber angewandt Code flexibler und auch u ¨bersichtlicher machen. ¨ Ein Ubermaß an solchen Pointern allerdings macht Code absolut undurch¨ schaubar und unwartbar. Vor allem ist dieses Ubermaß sehr schnell erreicht! Deshalb m¨ ochte ich folgenden Tipp geben: Man sollte immer zuerst sehr eingehend dar¨ uber nachdenken, ob das Ziel, das man erreichen will, nicht auch mit sauberen OO Mitteln erreichbar ist. Nur, wenn wirklich Klassen, Objekte und Ableitungshierarchien mit allen ihren Eigenschaften, nicht zum gew¨ unschten Erfolg f¨ uhren, sollte man u ¨ber den Einsatz von Funktions- und Methodenpointern nachdenken!
15.4 Besondere Keywords, Diagraphs und Trigraphs Aus Gr¨ unden der Vollst¨ andigkeit m¨ ochte ich auch auf das Thema der sogenannten Diagraphs und Trigraphs eingehen, sowie auf besondere Keywords, die in C++ f¨ ur den Fall existieren, dass gewisse Zeichen auf den Entwicklungsmaschinen der Entwickler nicht zur Verf¨ ugung stehen. Das Problem, u ¨ber das wir hier sprechen, hat seinen Ursprung darin, dass die Characters [, ], {, }, \ und | in manchen europ¨ aischen Zeichens¨atzen schlicht und ergreifend nicht vorhanden sind. Stattdessen sind ihre Positionen an besondere, sprachabh¨ angige Zeichen vergeben, wie es in einigen der ISO-646 Character Sets der Fall ist. Hat man aber diese Zeichen nicht zur Verf¨ ugung, wie soll man dann ein C++ Programm schreiben? Um hier Abhilfe zu schaffen, gibt es zwei M¨ oglichkeiten, von denen in jedem Fall die erste zu bevorzugen ist: 1. Man installiert zum Entwickeln zumindest einen Zeichensatz, in dem diese Characters zur Verf¨ ugung stehen und konfiguriert sowohl den Editor als auch das Keyboard entsprechend. 2. Wenn das aus irgendwelchen Gr¨ unden wirklich u ¨berhaupt nicht geht, bietet C++ eine Alternative an, die in der Folge besprochen wird. Kommen wir zuerst zum Teil der in C++ lebensnotwendigen CharacterSequenzen, die u ugung stehen. Verwendung dieser ¨ber Keywords zur Verf¨ Keywords resultiert immerhin noch in lesbarem Code und deshalb sehe ich die Verwendung derselben noch als akzeptabel an. Die Rede ist hier von den logischen und den Bitoperatoren, die man auch wie folgt schreiben kann:
476
15. Verschiedenes
Operator && || ! != & &= | |= ~ ^ ^=
alternatives Keyword and or not not_eq bitand and_eq bitor or_eq compl xor xor_eq
Das bedeutet also, dass z.B. der Ausdruck my_var && your_var auch als my_var and your_var geschrieben werden kann. Wenn es vermeidbar ist, sollte man es nicht tun, aber immerhin ist es lesbar. Es gibt Entwickler, denen die Keywords im laufenden Code lieber sind als die Originaloperatoren und die sie deswegen auch ohne besondere Notwendigkeit verwenden. Ob man dies zul¨asst ist Geschmackssache und eventuell im Coding Standard festzuschreiben. Vorsicht Falle: Manche Entwickler sind schon in die Falle getappt, Variablennamen wie z.B. and zu vergeben. Abgesehen davon, dass ein solcher Name mit an Sicherheit grenzender Wahrscheinlichkeit schwachsinnig ist, ist auch der Compiler gar nicht so gl¨ ucklich dar¨ uber, denn es handelt sich ja hierbei um ein reserved Keyword mit besonderer Bedeutung. Weniger lesbar sind da schon die sogenannten Diagraphs, also Kombinationen aus zwei Characters, die f¨ ur ein besonderes Zeichen stehen und die im laufenden Programmcode Verwendung finden: Zeichen { } [ ] #
alternativer Diagraph <% %> <: :> %:
Als ob Diagraphs nicht schon schlimm genug w¨aren, stellt sich auch ein anderes Problem: Was tut man, wenn man in Strings bzw. in Character Konstanten diese Zeichen ben¨ otigt? Hierzu kann man die Diagraphs nicht verwenden, sondern dazu werden die sogenannten Trigraphs, also Kombinationen aus drei Characters, herangezogen:
15.6 RTTI und dynamic cast im OO Kontext
Zeichen { } [ ] \ ~ | ^ #
477
alternativer Trigraph ??< ??> ??( ??) ??/ ????! ??’ ??=
15.5 volatile Objekte Es grenzt zwar schon an einen Overkill, f¨ ur das Keyword volatile einen eigenen Abschnitt zu schreiben, aber f¨ ur manche, sehr seltene (!) Anwendungen ist volatile einfach notwendig und eine Erkl¨arung fand im laufenden Text einfach beim besten Willen keinen vern¨ unftigen Platz. Manchmal muss man den Compiler daran hindern, besondere Optimierungen vorzunehmen, die auf Annahmen des Optimizers u ¨ber die Datenhaltung hinter einer Variable beruhen. Das Standardbeispiel f¨ ur einen solchen Fall ist das Auslesen eines Zeitgebers u ¨ber eine Pseudo-Konstante. Nehmen wir einfach an, wir h¨ atten folgende Deklaration im Programm: extern const long timer_clock_; Beim Optimieren kann der Compiler im Normalfall zu Recht annehmen, dass sich am Inhalt von timer_clock_ nichts ¨andert, denn es handelt sich ja hier um eine Konstante. Dies w¨ are in unserem Fall aber fatal, denn hier war etwas Anderes beabsichtigt: Es sollte nur verhindert werden, dass jemand im Programm irrt¨ umlich versucht, an timer_clock_ einen Wert zuzuweisen. Hinter dieser Konstanten verbirgt sich jedoch der Zeitgeber und die Variable timer_clock_ ver¨ andert ihren Wert sehr wohl im Lauf der Zeit, denn sie repr¨asentiert die laufende Uhrzeit. Schreibt man die Deklaration um in extern const volatile long timer_clock_; so hat man damit dem Compiler mitgeteilt, dass eine Optimierung, die auf die Eigenschaft, dass der Wert von timer_clock_ konstant bleibt, nicht zul¨assig ist. Damit wird der Compiler von solchen Schritten Abstand nehmen und das Auslesen des Zeitgebers funktioniert wie erwartet.
15.6 RTTI und dynamic cast im OO Kontext In Abschnitt 3.6.1 wurden bereits die Grundprinzipien von RTTI besprochen. Da zu diesem Zeitpunkt die OO-Mechanismen von C++ noch nicht bekannt
478
15. Verschiedenes
waren, musste ich in dieser Besprechung allen Lesern Teile der Information vorenthalten. Diese fehlenden Teile m¨ochte ich nun hier nachliefern, gemeinsam mit interessanten Eigenheiten von dynamic_cast. Bisher bekannt ist, dass man mittels typeid den Typ einer Variable erfahren kann. Nicht wirklich erkl¨ art wurde, was typeid nun genau liefert und was man alles damit tun kann. Hier wenden wir uns am besten einem Beispiel zu (rtti_demo.cpp): 1
// r t t i d e m o . cpp − demo , what can be done with r t t i
2 3 4 5
#include < i o s t r e a m> #include < t y p e i n f o> #include ” u s e r t y p e s . h”
6 7 8 9 10 11
using using using using using
std std std std std
: : cout ; : : e nd l ; : : type info ; : : bad typeid ; : : bad cast ;
12 13 14 15 16 17 18 19 20 21 22 23
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DummyBase { public : virtual void doSomething ( ) { cout << ”DummyBase : : doSomething” << e nd l ; } };
24 25 26 27 28 29 30 31 32 33
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DummyClassOne : public DummyBase { public : virtual void doSomething ( ) { cout << ”DummyClassOne : : doSomething” << e n d l ; } };
34 35 36 37 38 39 40 41 42 43
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s DummyClassTwo : public DummyBase { public : virtual void doSomething ( ) { cout << ”DummyClassTwo : : doSomething” << e nd l ; } };
44 45 46 47 48 49 50
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { DummyBase base ; DummyClassOne dummy1 ; DummyClassTwo dummy2 ;
51 52 53 54
try { const type info & i n f o b a s e = typeid ( base ) ;
15.6 RTTI und dynamic cast im OO Kontext
479
const type info &info dummy1 = typeid (dummy1 ) ; const type info &info dummy2 = typeid (dummy2 ) ;
55 56 57
cout << ” i s base o f type DummyBase? −> ” << ( i n f o b a s e == typeid (DummyBase)) << e n d l ;
58 59 60
// t h i s kind o f comparison i s ok cout << ” i s dummy1 o f type DummyBase? −> ” << ( info dummy1 == typeid (DummyBase)) << e nd l ;
61 62 63 64
// p o i n t e r comparison o f t y p e i n f o o b j e c t s i s bad ! ! ! ! cout << ” i s dummy1 o f type DummyClassOne ( bad v e r s i o n )? −> ” << (&info dummy1 == &typeid (DummyClassOne)) << e n d l ;
65 66 67 68
cout << ” i s dummy1 compatible with DummyBase? −> ” << ( dynamic cast(&dummy1) != 0) << e n d l ;
69 70 71
cout << ” i s dummy1 o f type DummyClassTwo? −> ” << ( info dummy1 == typeid (DummyClassTwo)) << e n d l ;
72 73 74
cout << ” i s dummy1 o f the same type as dummy2? −> ” << ( info dummy1 == info dummy2) << e n d l ;
75 76 77
// i n c a s e o f a 0 p o i n t e r the two a r e not compatible cout << ” i s dummy1 compatible with dummy2? −> ” << ( dynamic cast(&dummy1) != 0) << e nd l ;
78 79 80 81
// the f o l l o w i n g l i n e r e s u l t s i n a b a d c a s t e x c e p t i o n ( dynamic cast(dummy1 ) ) . doSomething ( ) ;
82 83
} catch ( bad { cout << } catch ( bad { cout << }
84 85 86 87 88 89 90 91 92
typeid &exc ) ” oops − t y p e i d f a i l e d . . . ” << e n d l ; cast &exc ) ” oops − dynamic cast f a i l e d . . . ” << e n d l ;
93
try { cout << ” t y p e i d o f expr : ” << typeid ( 3 + 5 ) . name() << e n d l ; cout << ” t y p e i d o f main : ” << typeid ( main ) . name() << e n d l ;
94 95 96 97 98
DummyBase ∗ b a s e p t r = 0 ; // the r e s u l t o f the f o l l o w i n g t y p e i d c a l l w i l l // throw a b a d t y p e i d e x c e p t i o n cout << ” t y p e i d o f 0 p o i n t e r : ” << typeid (∗ b a s e p t r ) . name() << e nd l ;
99 100 101 102 103
} catch ( bad typeid &exc ) { cout << ” oops − t y p e i d f a i l e d . . . ” << e n d l ; }
104 105 106 107 108 109
return ( 0 ) ;
110 111
}
Gleich vorweg erw¨ ahnt: Beim Output, den die Exceptions erzeugen, habe ich auch hier wieder aus Gr¨ unden der Reihenfolge der Ausgaben auf cout geschrieben. Im Normalfall m¨ usste man nat¨ urlich immer ausschließlich auf cerr schreiben.
480
15. Verschiedenes
Wir haben es hier mit den Deklarationen von drei verschiedenen Klassen zu tun, die sicherlich keine n¨ ahere Erkl¨arung brauchen. Interessanter sind hier schon die Zeilen 54–56, in denen man sieht, dass typeid als returnValue eine const Reference auf ein type_info Objekt liefert. Was daran so besonders interessant ist, wird gleich noch besprochen, wenden wir uns aber zuerst einmal den Zeilen 58–59 zu. In diesen wird u uft, ob info_base ¨berpr¨ vom Typ DummyBase ist. Dies geschieht durch Vergleich der beiden entsprechenden type_info Objekte mittels ==. Ein entsprechendes Overloading von == ist Teil von type_info. Auf dieselbe Art stellen wir auch in den Zeilen 62–63 fest, wie es sich mit dem Typ von dummy1 verh¨ alt. In den Zeilen 66-67 jedoch passiert im Programm etwas ganz B¨ oses: Es werden hier die Pointer auf zwei type_info Objekte verglichen um die Gleichheit der Typen festzustellen. Genau das ist auch der Punkt, warum zuvor erw¨ ahnt wurde, dass es interessant ist, dass typeid eine Referenz liefert: Es ist nicht garantiert, dass zu ein und demselben Typ immer nur ein einziges type_info Objekt existiert. Das bedeutet, dass die Referenzen, die man erh¨ alt nicht unbedingt auf dasselbe Objekt zeigen m¨ ussen. Im Fall von dynamic Libraries w¨are eine solche Garantie auch unm¨oglich abzugeben. In unserem Programm aber wird auf diesen Umstand keine R¨ ucksicht genommen und es werden einfach die Pointer miteinander verglichen. Der Output weiter unten zeigt, dass das genau hier funktioniert, es kann allerdings auch fehlschlagen! Mittels typeinfo kann man ganz einfach feststellen, ob zwei Objekte genau denselben Typ haben, wie man in den Zeilen 75–76 sehen kann. Man kann typeid nat¨ urlich auf verschiedenste Dinge anwenden, z.B. kann man auch den Typ des Resultats einer Operation u ufen, wie sich in Zeile 96 ¨berpr¨ erkennen l¨ asst. Auch Funktionspointer besitzen einen Typ. Dies zeigt sich an Zeile 97. Dass typeinfo auch eine Exception werfen kann, zeigt sich allerdings in den Zeilen 102–103: Hier wird versucht, den Typ eines Objekts herauszufinden, indem der Pointer auf das Objekt dereferenziert wird. Das w¨are auch prinzipiell noch kein Problem und in der Praxis vollkommen legitim und u ¨blich. Leider aber handelt es sich genau in diesem speziellen Fall um einen 0 Pointer. Wenn dieser Pointer also auf “nichts” zeigt, dann kann dieses “Nichts” auch keinen Typ haben. Dementsprechend wirft typeid in diesem Fall eine Exception. W¨ urden wir nicht den Typ des Objekts erfahren wollen, das sich hinter base_ptr verbirgt, wof¨ ur wir den Pointer dereferenzieren m¨ ussen, sondern den Typ von base_ptr selbst, dann w¨ urde es hier kein Problem geben. Ein Aufruf von typeid(base_ptr) w¨ urde ganz einfach das entsprechende type_info Objekt liefern, dass einem Pointer auf ein DummyBase Objekt entspricht. Was kann man also aus diesem Programm bisher lernen?
15.6 RTTI und dynamic cast im OO Kontext
481
• Die type_info Objekte unterst¨ utzen sowohl den == als auch den != Operator. Aussagen u onnen nur gesichert getroffen werden, wenn ¨ber Typen k¨ diese beiden verwendet werden. • Keinesfalls d¨ urfen Pointer auf type_info Objekte miteinander verglichen werden, denn es ist nicht garantiert, dass es zu einen bestimmten Typ nur ein einziges type_info Objekt gibt. • Bisher noch nicht erw¨ ahnt, aber trotzdem interessant ist, dass type_info Objekte die Methode bool before(const type_info&) unterst¨ utzen, die eine Sortierung zwischen solchen Objekten m¨oglich macht. Vorsicht Falle: Was sich auch immer bei verschiedenen Implementationen als Reihenfolge durch Verwendung von before ergibt sagt u ¨berhaupt nichts u ¨ber Ableitungshierarchien aus! Manche Entwickler treffen leider Fehlannahmen wie z.B. “ein type_info Objekt der Basisklasse wird genau vor dem der davon abgeleiteten Klassen gereiht”. Dies ist falsch, auch wenn es manchmal tats¨ achlich so aussehen k¨onnte, als ob es zutreffen w¨ urde. • Die Methode name dient dem Herausfinden des (internen!) Typnamens eines Objekts. • Ein Aufruf von type_info kann eine bad_typeid Exception werfen, wenn keine Chance besteht, den gefragten Typ herauszufinden. Dies ist bei einem dereferenzierten 0 Pointer der Fall. So nett die Anwendungsm¨ oglichkeiten von typeid auch sind, eines kann man damit nicht tun, n¨ amlich Typkompatibilit¨aten feststellen, die sich durch die Ableitungshierarchie ergeben. Dazu m¨ ussen wir unseren bekannten dynamic_cast Operator heranziehen und eine Eigenheit desselben ausn¨ utzen, die bisher noch nicht bekannt ist: In Abschnitt 9.4.4 wurde bereits erw¨ahnt, dass dynamic_cast eine bad_cast Exception wirft, wenn ein Cast nicht zul¨assig ist. Das ist allerdings nur die halbe Wahrheit. In Wirklichkeit ist dynamic_cast vollst¨ andig so definiert: • Mittels dynamic_cast k¨ onnen (ausschließlich) Casts zwischen Pointern auf Objekte oder zwischen Referenzen auf Objekte durchgef¨ uhrt werden. • Wendet man dynamic_cast auf Pointer an, dann k¨onnen folgende zwei F¨alle passieren: 1. Wenn der Cast zul¨ assig ist, so wird ein Pointer auf das gewandelte Objekt geliefert. 2. Wenn der Cast nicht zul¨ assig ist, so wird ein 0 Pointer geliefert. Es wird in diesem Fall keine Exception geworfen! Dieses Verhalten ist sehr sinnvoll, denn damit kann man dynamic_cast dazu verwenden, Kompatibilit¨ aten festzustellen, ohne eine Exception bef¨ urchten zu m¨ ussen, wie z.B. in Zeile 70 zu sehen ist. F¨ ur alle Leser, die mit
482
15. Verschiedenes
Java zu tun haben: Dieses Verhalten entspricht im Prinzip dem, was man dort mit instanceof erreicht. • Wendet man einen dynamic_cast auf Referenzen an, dann sieht die Welt ein wenig anders aus: 1. Wenn der Cast zul¨ assig ist, so wird eine Referenz auf das gewandelte Objekt geliefert. 2. Wenn der Cast nicht zul¨ assig ist, so wird eine bad_cast Exception geworfen, wie man z.B. in Zeile 83 sieht. Der Output des Programms best¨ atigt, dass es sich bei den soeben besprochenen Dingen nicht nur um Theorien handelt: i s base o f type DummyBase? −> 1 i s dummy1 o f type DummyBase? −> 0 i s dummy1 o f type DummyClassOne ( bad v e r s i o n )? −> 1 i s dummy1 compatible with DummyBase? −> 1 i s dummy1 o f type DummyClassTwo? −> 0 i s dummy1 o f the same type as dummy2? −> 0 i s dummy1 compatible with dummy2? −> 0 oops − dynamic cast f a i l e d . . . t y p e i d o f expr : i t y p e i d o f main : FiiPPcE t y p e i d o f 0 p o i n t e r : oops − t y p e i d f a i l e d . . .
15.7 Weiterfu ¨ hrendes zu Exceptions In Kapitel 11 wurde alles besprochen, was man im normalen Programmieralltag wissen muss, um vern¨ unftig mit Exceptions umgehen zu k¨onnen. Hier m¨ochte ich noch ein wenig weiterf¨ uhrende Information liefern, die manchmal sehr n¨ utzlich sein kann. Vor allem geht es um Situationen, bei denen man in der Entwicklungsphase von Software beim Debugging verzweifelt, denn ein Programm wird partout immer zwangsweise vom System beendet, obwohl man sich doch eigentlich keines Fehlers bewusst ist. Trotzdem man alle nur vorstellbaren try ... catch Konstrukte geschrieben hat, werden gewisse Exceptions beim besten Willen nicht ihrer angedachten Behandlung zugef¨ uhrt, sondern stattdessen geht das Programm ansatzlos in den Tiefflug u ¨ber. Betrachten wir zuerst das einfachste Problem, das uns u ¨ber den Weg laufen kann: Wir wissen aus verschiedenen Gr¨ unden nicht ganz genau, welche Exceptions aus einem Funktions- oder Methodenaufruf u ¨berhaupt geworfen werden k¨ onnen. Dies kann mannigfaltige Gr¨ unde haben: Wir k¨onnen es mit Code zu tun haben, der keinen Gebrauch von throw Deklarationen macht und zu dem auch keine ausreichende Dokumentation vorhanden ist. Wir k¨onnen es aber auch mit Template- oder Funktionspointer Implementationen zu tun haben, bei denen im Vorhinein gar nicht bekannt sein kann, welche Exceptions geworfen werden k¨ onnen, denn das h¨angt immer vom speziellen Fall ab. Wie dem auch immer sei, wir suchen nach einer M¨oglichkeit, alle
15.7 Weiterf¨ uhrendes zu Exceptions
483
Exceptions zu fangen und aufgrund dessen, was wir gefangen haben, u ¨ber die Behandlung zu entscheiden. Im Prinzip geht das in C++ sehr leicht, wie man in der Folge sehen kann (catch_all_demo.cpp): 1
// c a t c h a l l d e m o . cpp − demo , how to catch a r b i t r a r y e x c e p t i o n s
2 3 4 5
#include < i o s t r e a m> #include < t y p e i n f o> #include ” u s e r t y p e s . h”
6 7 8 9 10 11
using using using using using
std std std std std
: : cout ; : : e nd l ; : : type info ; : : bad typeid ; : : bad cast ;
12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s OneException { };
17 18 19 20 21
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s AnotherException { };
22 23 24 25 26 27 28 29 30 31 32 33 34
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void doSomething ( ) { static uint32 c a l l c o u n t e r = 0; i f ( c a l l c o u n t e r ++ % 2) { cout << ” throwing AnotherException ” << e n d l ; throw AnotherException ( ) ; } cout << ” throwing OneException” << e nd l ; throw OneException ( ) ; }
35 36 37 38 39 40 41 42 43 44 45 46 47 48
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void handleKnownStandardExceptions ( ) { try { throw ; } catch ( OneException &exc ) { cout << ” i t ’ s a OneException , can handle i t ” << e n d l ; // perform handling h e r e . . . . } }
49 50 51 52 53 54 55 56 57 58 59 60
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { for ( u i n t 3 2 count = 0 ; count < 2 ; count++) { try { doSomething ( ) ; } catch ( . . . ) {
484
15. Verschiedenes
cout << ” oops − caught an e x c e p t i o n . . . ” << e nd l ; // perform whatever a c t i o n h e r e . . . // e . g . c a l l a ha n dl e r f o r some standard e x c e p t i o n s // where a non s p e c i a l i z e d handling i s p o s s i b l e . . . handleKnownStandardExceptions ( ) ;
61 62 63 64 65
}
66
}
67 68
return ( 0 ) ;
69 70
}
In diesem Beispiel haben wir es mit einer Funktion doSomething zu tun, die wild mit Exceptions um sich wirft, von denen wir allerdings nichts wissen (ok, wir tun einfach so, als w¨ ussten wir nichts :-)). Das entsprechende catch, das f¨ ur alle Exceptions, die geworfen werden k¨ onnen steht, sieht man in Zeile 59: man schreibt einfach catch(...) und es landet garantiert jede Exception, die nicht vorher gefangen wurde, in diesem Zweig. Man sieht, dass hier kein Parameter mehr im Spiel ist, u ¨ber den man den Typ der Exception erfahren k¨ onnte. Im Normalfall w¨ urde man hier eine b¨ose Meldung ausgeben und zum Debugging schreiten, wenn das Programm einmal hier in diesem Zweig landet. Es gibt allerdings auch eine M¨oglichkeit, den Typ der Exception, die geworfen wurde, wieder (teilweise) zu rekonstruieren. Der Trick dazu findet sich in der Methode handleKnownStandardExceptions in den Zeilen 37–48: Man macht ein einfaches Re-throw der Exception und f¨angt diejenigen, mit denen man etwas anfangen kann. In unserem Fall ist das die OneException. Auf diese Art spart man sich das Hinschreiben einer catch Orgie an allen Stellen im Programm, an denen man unerwartete Exceptions behandeln will. Ich m¨ ochte allerdings auch gleich dazusagen, dass dies wirklich nur eine Methode f¨ ur besondere F¨alle ist. Keinesfalls darf diese Methode aus Schreibfaulheit zum Standard f¨ ur jegliches Exception Handling erhoben werden! Der Output des Programms sieht dann folgendermaßen aus: throwing OneException oops − caught an e x c e p t i o n . . . i t ’ s a OneException , can handle i t throwing AnotherException oops − caught an e x c e p t i o n . . . Abort
Soweit also zu Exceptions, die nicht explizit angef¨ uhrt wurden. Was aber passiert, wenn eine Funktion in einer throw Angabe sehr wohl Exceptions deklariert, aber aus irgendwelchen Gr¨ unden eine dort nicht deklarierte Exception geworfen wird? Einerseits versucht nat¨ urlich der Compiler, solche ¨ Probleme bereits beim Ubersetzen zu entdecken. Andererseits hat er aber keine Chance, alle F¨ alle zu finden. Es muss also auch zur Laufzeit garantiert werden, dass hier nicht gegen die Spezifikation verstoßen wird. Genau dieser Code, der das zur Laufzeit garantieren kann, wird auch tats¨achlich generiert. Im Normalfall wird bei einem Verstoß einfach das Programm beendet. Das allerdings f¨ uhrt genau zur Situation, dass wir beim Debugging lange suchen m¨ ussen, was nun eigentlich passiert ist. Man kann auf dieses Verhalten jedoch
15.7 Weiterf¨ uhrendes zu Exceptions
485
Einfluss nehmen. Die erste M¨ oglichkeit ist, bei einer Methode bzw. Funktion die Exception bad_exception durch eine throw Angabe als erlaubte Exception zu deklarieren. Darauf reagiert der Compiler und setzt nicht mehr Code ein, der das Programm beendet. Stattdessen setzt er Code ein, der im Fall einer nicht deklarierten Exception eine bad_exception wirft. Diese wiederum kann man nach altbekannter Manier fangen. Vorsicht Falle: Nicht alle Compiler folgen dieser Spezifikation, was nat¨ urlich ¨ wieder zu netten Uberraschungen f¨ uhren kann. Deshalb ist es sicherer, gleich zur in der Folge vorgestellten Methodik zu greifen, mit der man genau dieses Verhalten explizit implementieren kann. Die andere M¨ oglichkeit, Einfluss zu nehmen, ist der unexpected_handler, der im Prinzip sehr ¨ ahnlich funktioniert, wie der new_handler, den wir bereits kennen gelernt haben. Sehen wir uns diese beiden M¨oglichkeiten am besten gleich am Beispiel an (unexpected_exc_demo.cpp): 1 2
// unexpected exc demo . cpp − demo , how to d e a l with unexpected // exceptions
3 4 5 6 7
#include #include #include #include
< i o s t r e a m> < t y p e i n f o> < e x c e p t i o n> ” u s e r t y p e s . h”
8 9 10 11 12
using using using using
std std std std
:: :: :: ::
cout ; e nd l ; bad exception ; set unexpected ;
13 14 15 16 17
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s OneException { };
18 19 20 21 22
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s AnotherException { };
23 24 25 26 27 28 29 30 31 32 33 34 35 36
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void doSomething ( ) throw( OneException , b a d e x c e p t i o n ) { static uint32 c a l l c o u n t e r = 0; i f ( c a l l c o u n t e r ++ % 2) { cout << ” throwing AnotherException ” << e n d l ; throw AnotherException ( ) ; // w i l l become b a d e x c e p t i o n } cout << ” throwing OneException ” << e nd l ; throw OneException ( ) ; }
37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void makeBadFromUnexpectedException ( ) throw( b a d e x c e p t i o n ) { cout << ”makeBadFromUnexpectedException c a l l e d ” << e nd l ;
486
15. Verschiedenes
throw b a d e x c e p t i o n ( ) ;
43 44
}
45 46 47 48 49
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { u i n t 3 2 count = 0 ;
50
void ( ∗ o l d u n e x p e c t e d h a n d l e r ) ( ) = s e t u n e x p e c t e d ( makeBadFromUnexpectedException ) ;
51 52 53
for ( count = 0 ; count < 2 ; count++) { try { doSomething ( ) ; } catch ( OneException &exc ) { cout << ” oops − caught OneException . . . ” << e n d l ; } catch ( b a d e x c e p t i o n &exc ) { cout << ” oops − caught b a d e x c e p t i o n . . . ” << e nd l ; } } set unexpected ( old unexpected handler ) ;
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
return ( 0 ) ;
71 72
}
In diesem Beispiel wurde der Handler gleich so definiert, dass er eine entsprechende bad_exception generiert, wie es bereits zuvor als Automatismus des Compilers beschrieben wurde. Im Handler kann man nat¨ urlich beliebigste Aktionen ergreifen, so z.B. auch wieder den Versuch einer Standardbehandlung u ¨ber ein Re-throw und erneutes catch implementieren, wie wir es im letzten Beispiel kennen gelernt haben. Leser, die ausprobieren wollen, ob ihr Compiler das automatische Mapping auf eine bad_exception durchf¨ uhrt, m¨ ussen nur die Zeilen 51–52, sowie die Zeile 69 auskommentieren. Sollte dann immer noch die Behandlung der bad_exception in den Zeilen 64–67 angesprungen werden, dann funktioniert alles erwartungsgem¨aß. Der Output des Beispiels liest sich also folgendermaßen: throwing OneException oops − caught OneException . . . throwing AnotherException makeBadFromUnexpectedException c a l l e d oops − caught b a d e x c e p t i o n . . .
Neben den unexpected Exceptions, also solchen, die im Widerspruch zu einer throw Angabe stehen, gibt es noch eine weitere Art von Exceptions, die zu Problemen f¨ uhren k¨ onnen. Es sind dies die uncaught Exceptions, also solche, die zwar ordnungsgem¨ aß und spezifikationsgem¨aß geworfen, aber nirgends im Programm gefangen werden. Im Normalfall f¨ uhren diese, wie zu erwarten, auch zu einem Programmabbruch. Wie allerdings ebenfalls zu erwarten, kann man sich in die Behandlung zumindest rudiment¨ar einklinken. Rudiment¨ar deshalb, weil man den Programmabbruch selbst nicht verhindern kann. Aber
15.7 Weiterf¨ uhrendes zu Exceptions
487
zumindest kann man noch Handlungen setzen, bevor das Programm u ¨ber den Jordan geschickt wird. Warum man den Abbruch nicht verhindern kann, ist einleuchtend: Wird eine Exception geworfen, dann wird das Stack Unwinding so lange durchgef¨ uhrt, bis man bei einem entsprechenden catch angelangt ist. Sollte keines gefunden werden, so ist dadurch auch main dem Unwinding zum Opfer gefallen. Wie soll man also dann noch im Programm weitermachen? Es gibt einfach keinen Punkt mehr, an dem man neu aufsetzen k¨onnte. An einem kurzen Beispiel betrachtet, sieht das dann so aus (uncaught_exc_demo.cpp): 1
// uncaught exc demo . cpp − demo f o r uncaught e x c e p t i o n s
2 3 4 5 6
#include #include #include #include
< i o s t r e a m> < t y p e i n f o> < e x c e p t i o n> ” u s e r t y p e s . h”
7 8 9 10
using s t d : : cout ; using s t d : : e nd l ; using s t d : : s e t t e r m i n a t e ;
11 12 13 14 15
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s OneException { };
16 17 18 19 20 21 22 23
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void doSomething ( ) throw( OneException ) { cout << ” throwing OneException” << e n d l ; throw OneException ( ) ; }
24 25 26 27 28 29
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void preMortemExceptionProblemFunction ( ) { cout << ” preMortemExceptionProblemFunction c a l l e d ” << e n d l ; // do whatever has to be done and then say good bye
30
cout << ” time to say good bye . . . ” << e n d l ; e x i t (−1);
31 32 33
}
34 35 36 37 38 39
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { void ( ∗ o l d t e r m i n a t e h a n d l e r ) ( ) = s e t t e r m i n a t e ( preMortemExceptionProblemFunction ) ;
40
doSomething ( ) ;
41 42
set terminate ( old terminate handler ) ;
43 44
return ( 0 ) ;
45 46
}
Der Name der entsprechenden Funktion in den Zeilen 26–33 verr¨at es bereits: Wenn das Programm einmal hier landet, dann gibt es keine Rettung mehr. Es wird nicht erwartet, dass das Programm aus dieser Funktion jemals
488
15. Verschiedenes
zur¨ uckkehrt. Man hat nur noch die Chance, selbst so definiert wie m¨oglich auszusteigen, bevor das System das von sich aus u ¨bernimmt. In Zeile 32 passiert genau das. W¨ urde man hier nicht mittels exit aussteigen, und wider Erwarten aus der Funktion wieder herauskommen, dann w¨ urde automatisch abort aufgerufen werden. Den Handler setzt man durch Aufruf der Methode set_terminate in Kraft. Der weltbewegende Output dieses sterbenden Meisterwerks liest sich dann so: throwing OneException preMortemExceptionProblemFunction c a l l e d time to say good bye . . .
In Kapitel 11 wurde bereits eindringlichst gesagt, dass man unbedingt daf¨ ur sorgen muss, dass aus einem Destruktor keine Exceptions geworfen werden. Durch das catch(...) Konstrukt haben wir auch jetzt eine sch¨one M¨oglichkeit, innerhalb eines Destruktors auf alle Eventualit¨aten zu reagieren und heimt¨ uckische Exceptions, die aus aufgerufenen Teilen stammen, daran zu hindern, aus dem Destruktor hinaus zu fallen. Wie bereits erkl¨art kann ein Destruktor eben im Zuge eines Stack Unwindings aufgerufen werden und dann w¨ urden b¨ osartigerweise pl¨ otzlich zwei Exceptions gleichzeitig auftreten. Wenn man dar¨ uber genau nachdenkt, dann kommt man allerdings zu der Erkenntnis, dass man Exceptions aus einem Destruktor theoretisch schon werfen k¨ onnte, wenn man nur sicherstellt, dass man nicht gerade im Zuge eines Stack Unwindings destruiert wird. Genau das ist auch wirklich der Fall, wie im folgenden Beispiel zu sehen ist (destructor_exc_demo.cpp): 1
// d e s t r u c t o r e x c d e m o . cpp − demo f o r e x c e p t i o n s i n d e s t r u c t o r s
2 3 4 5 6
#include #include #include #include
< i o s t r e a m> < t y p e i n f o> < e x c e p t i o n> ” u s e r t y p e s . h”
7 8 9 10 11
using using using using
std std std std
:: :: :: ::
cout ; e nd l ; set terminate ; uncaught exception ;
12 13 14 15 16
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− c l a s s OneException { };
17 18 19 20 21 22 23 24
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ VeryBadClass ∗ ∗ throws an e x c e p t i o n i n the d e s t r u c t o r . . . ∗ ∗/
25 26 27 28 29 30 31
c l a s s VeryBadClass { public : virtual ˜ VeryBadClass ( ) { cout << ”VeryBadClass d e s t r u c t o r ” << e nd l ;
15.7 Weiterf¨ uhrendes zu Exceptions
throw OneException ( ) ;
32
}
33 34
};
35 36 37 38 39 40 41 42
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ BadClass ∗ ∗ throws an e x c e p t i o n i n the d e s t r u c t o r . . . ∗ ∗/
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
c l a s s BadClass { public : virtual ˜ BadClass ( ) { cout << ” BadClass d e s t r u c t o r ” << e nd l ; i f ( u n c a u g h t e x c e p t i o n ( ) ) // s t a c k unwinding i n p r o g r e s s { cout << ” s t a c k unwinding i n p r o g r e s s . . . ” << ” not throwing an e x c e p t i o n ” << e n d l ; return ; } cout << ” s t a c k unwinding not i n p r o g r e s s . . . ” << ” throwing OneException ” << e nd l ; throw OneException ( ) ; }
60 61
};
62 63 64 65 66 67 68
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− void preMortemExceptionProblemFunction ( ) { cout << ” preMortemExceptionProblemFunction c a l l e d ” << e n d l ; // do whatever has to be done and then say good bye
69
cout << ” time to say good bye . . . ” << e nd l ; e x i t (−1);
70 71 72
}
73 74 75 76 77 78
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− int main ( int a r g c , char ∗ argv [ ] ) { void ( ∗ o l d t e r m i n a t e h a n d l e r ) ( ) = s e t t e r m i n a t e ( preMortemExceptionProblemFunction ) ;
79 80 81 82 83 84 85 86 87 88
try { BadClass b a d c l a s s 1 ; BadClass b a d c l a s s 2 ; } catch ( OneException &exc ) { cout << ” caught OneException” << e nd l ; }
89 90 91 92 93 94 95 96 97
try { VeryBadClass v e r y b a d c l a s s 1 ; VeryBadClass v e r y b a d c l a s s 2 ; } catch ( OneException &exc ) { cout << ” caught OneException” << e nd l ;
489
490
15. Verschiedenes
}
98 99
set terminate ( old terminate handler ) ;
100 101
return ( 0 ) ;
102 103
}
Wir haben es hier mit einer BadClass und mit einer VeryBadClass zu tun. Die VeryBadClass zeichnet sich dadurch aus, dass sie ohne R¨ ucksicht auf Verluste aus dem Destruktor eine Exception wirft. Die BadClass ist ein wenig r¨ ucksichtsvoller und wirft nur dann eine Exception, wenn nicht gerade ein Stack Unwinding im Gange ist. Ob das der Fall ist, findet man durch Aufruf der Funktion uncaught_exception in Zeile 50 heraus. Wenn diese Funktion true liefert, dann ist gerade ein Stack Unwinding im Gang, ansonsten nicht. In unserem main legen wir in den Zeilen 82–83 zwei Variablen vom Typ BadClass an. Sobald die erste der beiden im Zuge des Verlassens des umschließenden Blocks ihr Leben verwirkt hat, wirft sie aus dem Destruktor eine Exception. Dadurch wird das Stack Unwinding ausgel¨ost und im Zuge dessen wird auch die zweite Variable destruiert. Diese erkennt im Destruktor, dass sie sich mit dem Werfen von Exceptions gef¨alligst zur¨ uckhalten soll und verh¨alt sich entsprechend brav. Dadurch gelingt es dem Programm, sich bis zum entsprechenden catch in den Zeilen 85–88 durchzuk¨ampfen. Anders ist die Situation schon bei unserer VeryBadClass. Auch hier wird beim ersten Destruktoraufruf eine Exception geworfen, das ein Stack Unwinding zur Folge hat. Im Zuge dessen wirft aber auch noch die zweite Instanz dieser Klasse schwachsinnigerweise aus dem Destruktor eine Exception. Dadurch bleibt gar nichts Anderes mehr u ¨brig, als das Programm zu beenden. Wie man am folgenden Output sieht, wird auch daf¨ ur wieder der entsprechende terminate Handler herangezogen, den wir schon von den uncaught Exceptions her kennen. BadClass d e s t r u c t o r s t a c k unwinding not i n p r o g r e s s . . . throwing OneException BadClass d e s t r u c t o r s t a c k unwinding i n p r o g r e s s . . . not throwing an e x c e p t i o n caught OneException VeryBadClass d e s t r u c t o r VeryBadClass d e s t r u c t o r preMortemExceptionProblemFunction c a l l e d time to say good bye . . .
Vorsicht Falle: Auch wenn man im Destruktor nach entsprechender Absicherung Exceptions werfen kann, muss ich zu a¨ußerster Vorsicht mahnen! Destruktoren m¨ ussen so entworfen werden, dass diese Maßnahme nicht n¨otig ist, sonst landet man irgendwann in Teufels K¨ uche. Die Problematik ist, dass man in gewissen Situationen Exceptions nicht werfen kann, weil gerade ein Stack Unwinding passiert. Was tut man aber in so einer Situation mit Exceptions, die einem im Destruktor von darin aufgerufenen Methoden um die Ohren geworfen werden? Ein silent Catch f¨ uhrt garantiert zur Katastrophe!
Teil III
Ausgesuchte Teile aus der C++ Standard Library
16. Die C++ Standard Library
Dieser dritte Abschnitt des Buchs ist einer u ¨berblicksm¨aßigen Besprechung der C++ Standard Library gewidmet. Wie zu Beginn des Buchs erw¨ahnt wurde, w¨ urde eine detaillierte Beschreibung der gesamten Funktionsweise und der Designentscheidungen, die zur derzeitigen Form der Library gef¨ uhrt haben, bei weitem den Rahmen sprengen. Deshalb beschr¨anke ich mich hier darauf, kurz zu erkl¨ aren, welchen Umfang die Standard Library hat und was man prinzipiell damit tun kann. Das Ziel dieses Kapitels ist es also, den Lesern ein Gef¨ uhl daf¨ ur zu vermitteln, bei welchen Problemstellungen sie selbst Hand anlegen m¨ ussen und wof¨ ur bereits wiederverwendbare L¨osungen existieren. Die C++ Standard Library ist u ¨blicherweise bei Entwicklern unter dem Namen STL (=Standard Template Library) bekannt und dementsprechend wird auch hier im Buch in der Folge die Bezeichnung STL daf¨ ur verwendet. Diese Bezeichnung verr¨ at auch bereits sehr viel u ¨ber die Natur der Library: Sie enth¨ alt eine gr¨ oßere Menge an Templates zum L¨osen von Standardproblemen, die sich Entwicklern im Alltag immer wieder stellen.
¨ 16.1 Ubersicht Es war gerade zuvor die Rede von Standardproblemen, die zum Entwicklungsalltag geh¨ oren, nun stellt sich also die Frage, welche dieser Probleme es wert sind, in eine Standard Library aufgenommen zu werden. Die Entwickler der STL scheinen die folgende, sehr logische und lobenswerte Sicht vertreten zu haben, als sie entschieden, welchen Umfang die STL haben soll: Was sind die prinzipiellen Low-Level Datenstrukturen und Algorithmen, die man bei beinahe jeder Probleml¨ osung ben¨ otigt? Diese geh¨ oren ein f¨ ur alle Mal befriedigend und m¨ oglichst allgemeing¨ ultig implementiert, sodass sie nicht noch unz¨ ahlige Male von neuem implementiert werden m¨ ussen! Welche Entwickler kennen nicht die Situation, immer wieder bestimmte Datenstrukturen, wie z.B. Listen, Stacks, etc., von neuem implementieren zu m¨ ussen, da alle ihre anderen Implementationen so spezialisiert f¨ ur eine bestimmte Anwendung sind, dass sie außerhalb dieser Anwendung wieder nicht vern¨ unftig verwendet werden k¨ onnen. Nach der x-ten Implementation
494
16. Die C++ Standard Library
beschließen die Entwickler, dass es nun wirklich an der Zeit w¨are, eine allgemeing¨ ultige Version zu schreiben, die auch den anderen Leuten im Team oder in der ganzen Firma zur Verf¨ ugung stehen soll. Auf diese Art kam und kommt es st¨ andig dazu, dass Dinge, die zum Teil bereits seit der tiefsten Steinzeit der Softwareentwicklung bekannt und gel¨ost sind, wieder und wieder von neuem geschrieben werden. Das bedeutet auch, dass gleichermaßen immer und immer wieder dieselben Fehler gemacht werden und unter großem Arbeits- und Zeitaufwand korrigiert werden m¨ ussen. Teilweise Schuld an diesem Ph¨ anomen hat auch das sogenannte not invented here Syndrom: “Diese Implementation kann bei meinem Problem nicht gut funktionieren, weil die Entwickler dies und das nicht bedacht haben / anders gemacht haben / etc.” Dass solche Kritik nicht so selten auch ihre Berechtigung hat, soll hier gar nicht in Frage gestellt werden. Die Entwickler der STL hatten also folgende Anforderungen zu erf¨ ullen, um die Library auch wirklich f¨ ur einen m¨oglichst breiten Anwenderkreis einsetzbar zu machen: • Die STL soll allgemein, verst¨ andlich und sinnvoll genug gehalten sein, dass sie f¨ ur Einsteiger und erfahrene Entwickler gleichermaßen einsetzbar ist. • Die STL soll nicht nur leicht und sinnvoll bei der Entwicklung von Applikationen einzusetzen sein, sondern sie soll auch einfach und sinnvoll als Basis-Library f¨ ur andere Library-Entwicklungen dienen k¨onnen. • Die allgemeinen Implementationen in der STL m¨ ussen effizient genug sein, dass sie gute Alternativen zu speziellen, sehr problembezogenen Implementationen darstellen. • Alle Algorithmen, die in der STL Anwendung finden, sollen entweder Policy-frei sein, oder eine Policy als Argument nehmen. Das bedeutet, dass es z.B. keinen Sortieralgorithmus in der STL geben darf, der auf Gedeih und Verderb aufsteigend sortiert und auch zwangsweise die Implementation der > < und = Operatoren von den einzelnen Elementen erwartet. Es kann ja auch sein, dass man z.B. absteigend sortieren will und dass man eine eigene Komparatorfunktion zum Vergleich von Elementen bereitstellen will. Dies muss dann u ¨ber entsprechende selbst implementierbare Policies einstellbar sein. • Alle Teile der STL m¨ ussen so designed sein, dass sie immer nur genau eine einzige Aufgabe erf¨ ullen, diese aber so gut wie m¨oglich. Niemals darf eine Komponente zwei miteinander gekoppelte Rollen erf¨ ullen, denn dies geht erstens zu Lasten der Allgemeinheit und zweitens erlangt man durch explizites Verkn¨ upfen zweier spezialisierter Komponenten u ¨blicherweise eine bessere L¨ osung. • Komponenten der STL m¨ ussen sicher in ihrer Verwendung sein. Das bedeutet, dass es nicht vorkommen darf, dass man besondere Interna einer Komponente kennen muss, um mit ihr sinnvoll arbeiten zu k¨onnen und z.B. kein Speicherloch zu verursachen. • Komponenten der STL m¨ ussen typsicher sein.
¨ 16.1 Ubersicht
495
• Komponenten der STL m¨ ussen sowohl mit Standard-Datentypen als auch mit benutzerdefinierten Datentypen gleichermaßen verwendbar sein. • Komponenten der STL m¨ ussen Dinge so vollst¨andig wie m¨oglich implementieren. Das bedeutet, dass sie eine Aufgabe, die sie u ¨bernehmen, so vollst¨andig implementieren m¨ ussen, dass es nicht notwendig ist, f¨ ur eine Standardanwendung eine Erweiterung selbst schreiben zu m¨ ussen. Es h¨atte z.B. keinen Sinn, ein Template f¨ ur eine Liste zu schreiben, in die man zwar Elemente einf¨ ugen kann, aber aus der man keine Elemente mehr entfernen kann. Wenn schon Liste, dann ordentlich :-). In kurzen Schlagworten umrissen bedeutet das also, dass das Ziel der STL ist, so allgemein wie m¨ oglich, so vollst¨andig wie m¨oglich und so robust wie m¨oglich zu sein bei gleichzeitiger Wahrung der Intuitivit¨at bei der Verwendung und der Effizienz der einzelnen L¨osungen. Dass man die STL nicht vor fahrl¨ assigem Missbrauch sch¨ utzen kann, ist sonnenklar. Wie sollte man auch verhindern, dass jemand eine falsche Datenstruktur zur L¨osung eines Problems in einer Applikation verwendet? In jedem Fall muss man der STL attestieren, dass sie bei korrekter und sinnvoller Verwendung auch wirklich sehr effizient und fehlerfrei arbeitet und Entwicklern sehr viel Zeit erspart! So einige Entwickler, die der Meinung waren, dass man die eine oder andere Datenstruktur auch besser implementieren kann, wurden sehr schnell eines Besseren belehrt. Deshalb kann ich an dieser Stelle allen Lesern nur den Rat geben, die STL auch wirklich zu benutzen anstatt selbst das Rad neu zu erfinden! Sehen wir uns also ganz kurz an, f¨ ur welche Einsatzgebiete die STL bereits vorgefertigte L¨ osungen anbietet: Container: Hierzu werden Templates zum Arbeiten mit Vektoren, Listen, Mengen, Queues und Stacks angeboten. Im Prinzip also alles, was man im Programmieralltag an verschiedenen Arten von Containern braucht, sofern man es nicht gleich mit Datenmengen zu tun hat, die nach einer gr¨oßeren Datenbank verlangen. Iterators: Zu den verschiedenen Containern werden, wo sinnvoll, Iterators angeboten, mit denen man den Inhalt eines Containers Element f¨ ur Element in wohldefinierter Ordnung (also z.B. vorw¨arts und r¨ uckw¨arts) durchgehen kann. Allocators: Aus Gr¨ unden der Portierbarkeit will man Memory-Management Details, wie z.B. das Wissen u ¨ber bestimmte Pointer-Typen, Objektgr¨oßen und Speicher-Anlege- und Freigabe-Details, sauber kapseln. Alle Container der STL sind in Bezug auf die Allocators parametrisierbar, das bedeutet, dass sie z.B. nicht selbstt¨atig nach Gutd¨ unken Objekte anlegen, sondern dass sie das Anlegen von einem entsprechenden Allocator anfordern. Auf diese Art werden Container unabh¨angig von speziellen Memory Modellen der verwendenden Applikationen. Strings: Zwei große Probleme treten bei der Verwendung von simplen char * als Strings in Programmen auf:
496
16. Die C++ Standard Library
1. Die Gefahr von Memory Leaks und wild gewordenen Pointern ist extrem groß. 2. Zeichentabellen, wie z.B. Unicode, die mehr als 8 Bits pro Zeichen verlangen, sind nachtr¨ aglich nur extrem schwer bis u ¨berhaupt nicht in ein existentes Programm einzubauen. Die einzig sinnvolle L¨ osung zum sauberen Umgang mit Strings in Programmen ist daher ihre Kapselung in eine eigene Klasse bzw., aus Gr¨ unden der verschiedenartigen Zeichensatztabellen, in ein Template. Streams: Ob es sich nun um Input vom Keyboard, von einem File oder auch von einem String handelt und ob der Output auf den Bildschirm oder sonstwohin gehen soll, es handelt sich immer um einen Datenstrom. Dieser ist in seiner L¨ ange prinzipiell nicht im Vorhinein definierbar und auch das Zeitverhalten (z.B. wann wird etwas geschrieben und wie viel) ist nicht limitiert. Genau um dieses Verhalten zu modellieren, gibt es die sogenannten Streams. Numerik: Verschiedene aus dem Bereich der Numerik kommende Datenstrukturen (z.B. verschiedene Array-Typen, komplexe Zahlen) und Algorithmen zum Rechnen mit diesen werden oft genug in der Praxis verwendet, dass sie in die STL Eingang fanden. In diesem Bereich der STL sind unter anderem die typischen Funktionen f¨ ur den Sinus, Cosinus, Logarithmus, etc. und nat¨ urlich auch die Zufallszahlen enthalten. Algorithmen und Funktionsobjekte: In diesem Bereich der Library finden sich die typischen Algorithmen, die immer wieder auf Datenstrukturen angewandt werden. Vertreter hierf¨ ur sind z.B. Transformationen, das Vertauschen von Inhalten, etc. Wie es auch in den Anforderungen definiert ist, kann man nat¨ urlich auf Basis der Teile, die in der STL enthalten sind, eigene Komponenten schreiben, wie z.B. sehr spezielle Container etc. Um ein Gef¨ uhl f¨ ur die mit der STL ausgelieferten Komponenten zu bekommen, ist den verschiedenen oben erw¨ahnten Gruppen in der Folge jeweils ein kurzer Abschnitt gewidmet. Eine tiefer gehende Beschreibung vieler Design- und Anwendungsaspekte k¨onnen interessierte Leser in [Stroustrup 1997] nachlesen. Ein sehr gutes und ausf¨ uhrliches Buch, das sich einzig und allein mit der STL besch¨aftigt, stellt [Musser et al. 2001] dar. Außerdem gibt es im Internet einige onlineManuals und Tutorials zur STL zum Download. Zu [Stroustrup 1997] m¨ochte ich noch erw¨ ahnen, dass ich ganz absichtlich auf die Special Edition seines Buchs verweise und nicht auf die Standard Edition, da die Special Edition gerade durch die sehr tief gehende Diskussion der STL so “special” wird :-). Im Prinzip muss man zur STL sagen, dass ihr Inhalt sehr intuitiv verwendbar ist, sofern man sich einmal an die doch nicht ganz standardgem¨aße Namensgebung gewisser Methoden gew¨ ohnt hat (z.B. implementiert eine Queue Methoden namens push und pop anstatt die sonst u ¨blichen Bezeichnungen put und get zu verwenden). Ist man einmal u urde hinweg, ¨ber diese kleine H¨ kann man den Umgang mit den Komponenten der STL auch einfach durch
16.2 Container
497
Lesen der entsprechenden Header-Files und durch Ausprobieren sehr leicht in den Grundz¨ ugen erlernen. Lesern, die den Umgang mit fremdem Code noch nicht gewohnt sind, m¨ ochte ich diese Vorgangsweise sogar sehr ans Herz ¨ legen, um ein wenig Ubung zu bekommen.
16.2 Container Als Container werden in der STL einfache und assoziative Typen angeboten. Der Unterschied zwischen diesen beiden ist, dass die assoziativen Container eine Assoziation zwischen einem Key und einem Value quasi zum Nachschlagen implementieren. Die einfachen Container erlauben “nur” einer Speicherung und das Durchgehen bzw. auch, je nach Container, den indizierten Zugriff auf einzelne Elemente. 16.2.1 Vektoren Beginnen wir mit den einfachen Containern, die auch oftmalig als Sequences bezeichnet werden. Ein sehr brauchbares Template ist der Vektor (Template vector), den wir uns einmal ganz kurz am Beispiel ansehen wollen (simple_vector_demo.cpp): 1 2
// s i m p l e v e c t o r d e m o . cpp − j u s t a s i m p l e demo , how to // use the v e c t o r template
3 4 5
#include < v e c t o r> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : v e c t o r ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16 17
int main ( int a r g c , char ∗ argv [ ] ) { v e c t o r a v e c t o r ; v e c t o r a n o t h e r v e c t o r ( 4 ) ; // v e c t o r with 4 elements
18 19 20
// never f o r g e t to make s u r e t h a t t h e r e i s enough space ! ! ! a vector . r e s i z e (4);
21 22 23 24 25
a a a a
vector [0] vector [1] vector [2] vector [3]
= = = =
10; 20; 30; 40;
26 27 28 29 30
another another another another
vector [0] vector [1] vector [2] vector [3]
= = = =
100; 200; 300; 400;
31 32 33
for ( int count = 0 ; count < 4 ; count ++) {
498
16. Die C++ Standard Library
cout << ” a v e c t o r [ ” << count << ” ] : ” << a v e c t o r [ count] << ” , a n o t h e r v e c t o r [ ” << count << ” ] : ” << a n o t h e r v e c t o r [ count ] << e n d l ;
34 35 36
}
37 38
// r e s i z i n g can be done any time , but be c a r e f u l to use // t h i s f e a t u r e i n t e l l i g e n t l y ! ! ! ! ! another vector . r e s i z e ( 5 ) ; another vector [4] = 500;
39 40 41 42 43
cout << ” a f t e r r e s i z e . . . . a n o t h e r v e c t o r [ 4 ] : ” << a n o t h e r v e c t o r [4] << e nd l ;
44 45 46
cout << ” a n o t h e r v e c t o r has the c a p a c i t y f o r ” << a n o t h e r v e c t o r . s i z e () << ” elements ” << e n d l ;
47 48 49
return ( 0 ) ;
50 51
}
In guter alter Template-Manier erzeugt man eine gew¨ unschte Auspr¨agung von vector durch Angabe des Elementtyps, der in ihm gespeichert werden soll, wie in den Zeilen 15–16 zu sehen ist. Es stehen mehrere Konstruktoren zur Verf¨ ugung, von denen hier beispielhaft zwei gezeigt werden: Der default Konstruktor in Zeile 15 erzeugt einen Vektor, der noch keinen Speicherplatz f¨ ur Elemente anlegt. Der Konstruktor mit numerischem Argument in Zeile 16 erzeugt einen Vektor, der Speicherplatz f¨ ur 4 Elemente reserviert hat. In Zeile 19 sieht man, dass man unbedingt sicherstellen muss, dass ein Vektor auch gen¨ ugend Fassungsverm¨ ogen hat, bevor man in ihm Elemente speichert. Dass man auch zur Laufzeit einen Vektor vergr¨oßern und verkleinern kann, zeigt sich in Zeile 40. Es versteht sich von selbst, dass man hierbei m¨oglichst intelligent agieren muss und nicht z.B. in einer oft durchlaufenen Schleife einen Vektor immer um ein Element nach dem anderen vergr¨oßern soll. So eine Vorgangsweise w¨ urde zur Laufzeitkatastrophe f¨ uhren. Dass f¨ ur Vektoren nat¨ urlich der direkte Zugriff auf Elemente mittels Index-Operator definiert ist, ist einleuchtend, denn das vector Template wurde mit der Zielvorstellung geschrieben, eine saubere Kapselung f¨ ur LowLevel Arrays zur Verf¨ ugung zu stellen. Ich w¨ urde allen Entwicklern w¨armstens ans Herz legen, in ihren Programmen nur in Notf¨allen dynamische Arrays von Hand zu verwalten. Stattdessen sollte vector als vollwertiger und sicherer Ersatz dienen. Wie sich z.B. in Zeile 47 zeigt, wissen Vektoren nat¨ urlich auch u oße Bescheid, was eine weitere Fehlerquelle ¨ber ihre augenblickliche Gr¨ eliminiert. Der Output, den unser Progr¨ ammchen erzeugt, liest sich also erwartungsgem¨aß so: a vector [ 0 ] : 1 0 , another vector [ 0 ] : a vector [ 1 ] : 2 0 , another vector [ 1 ] : a vector [ 2 ] : 3 0 , another vector [ 2 ] : a vector [ 3 ] : 4 0 , another vector [ 3 ] : after r e s i z e . . . . another vector [ 4 ] : a n o t h e r v e c t o r has the c a p a c i t y f o r
100 200 300 400 500 5 elements
16.2 Container
499
Zum Index-Operator m¨ ochte ich noch ein Wort verlieren: Dieser nimmt aus ¨ Gr¨ unden der Performance keine Uberpr¨ ufung vor, ob der Index im erlaubten ¨ Bereich liegt. Sollte eine solche Uberpr¨ ufung beim Zugriff aus Sicherheitsgr¨ unden gew¨ unscht sein, so steht auch die Methode at(...) zur Verf¨ ugung, die als Parameter erwartungsgem¨ aß eine Ganzzahl entgegennimmt. Die Elemente eines Vektors sind nicht nur direkt indiziert ansprechbar, sondern man kann auch entsprechende Iterators anfordern um die Elemente der Reihe nach durchzugehen (siehe auch Abschnitt 16.3). 16.2.2 Listen Oft brauchen wir in unseren Programmen aber nicht einfach nur einen sauber gekapselten Ersatz f¨ ur ein Array, sondern einen Container, der folgende besonderen Eigenschaften besitzt: • Wir wollen ohne Laufzeiteinbußen an beliebigen Stellen Elemente einf¨ ugen k¨onnen. • Wir wollen ohne Laufzeiteinbußen Elemente von beliebigen Stellen wieder entfernen k¨ onnen und die verbliebenen Elemente sollen “nachr¨ ucken”. Dass wir bei diesen Forderungen mit einem Vektor nicht mehr weit kommen, ist leicht einzusehen. Wir brauchen hier eine verkettete Liste, denn nur diese Datenstruktur kann die notwendigen Eigenschaften bieten. Erwartungsgem¨ aß findet sich eine solche in der STL als list Template. Diese Liste stellt die Methoden insert, erase und clear zur Verf¨ ugung um Elemente an einer bestimmten Stelle einzuf¨ ugen, zu entfernen und die gesamte Liste zu leeren. Erwartungsgem¨ aß weiß auch eine Liste wieder u ¨ber die Anzahl der in ihr gespeicherten Elemente Bescheid und gibt dieses Wissen bei Aufruf von size auch preis. Eines ist allerdings bei der Liste bewusst nicht implementiert: Der Index-Operator, der direkten Zugriff auf ein bestimmtes Element erlauben w¨ urde. Dies ist auch einsichtig, denn um einen indizierten Zugriff zu erreichen, m¨ usste jedes Mal die Liste von einem Ende her bis zum gew¨ unschten Element durchgegangen werden, was mit einer Komplexit¨at von O(n) einer ziemlichen Laufzeitkatastrophe gleichkommen w¨ urde. Deshalb wurde nach dem Motto “...und f¨ uhre uns nicht in Versuchung...” der Index Operator gleich gar nicht implementiert. In einem kurzen Beispiel sieht die Verwendung der Basisfeatures einer Liste dann folgendermaßen aus (simple_list_demo.cpp): 1 2
// s i m p l e l i s t d e m o . cpp − j u s t a s i m p l e demo , how to // use the l i s t template
3 4 5
#include < l i s t> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : l i s t ;
500
16. Die C++ Standard Library
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { l i s t<double> a l i s t ;
17
a l i s t . push front ( 1 . 0 ) ; a l i s t . push back ( 5 . 0 ) ;
18 19 20
l i s t<double > : : i t e r a t o r i n s e r t i o n i t e r a t o r = a l i s t . end ( ) ; i n s e r t i o n i t e r a t o r −−; a list . insert ( insertion iterator ,1,3.0); i n s e r t i o n i t e r a t o r −−; a list . insert ( insertion iterator ,1,2.0);
21 22 23 24 25 26
cout << ” i t e r a t i n g
27
l i s t from the b e g i n n i n g : ” << e nd l ;
28
l i s t<double > : : i t e r a t o r f o r w a r d i t e r a t o r = a l i s t . begin ( ) ; while ( f o r w a r d i t e r a t o r ! = a l i s t . end ( ) ) cout << ∗ f o r w a r d i t e r a t o r++ << ” ” ;
29 30 31 32
cout << e nd l ;
33 34
return ( 0 ) ;
35 36
}
Das Anlegen einer Liste erfolgt nat¨ urlich wieder unter Angabe des Typs, wie in Zeile 16 zu sehen ist. In den Zeilen 18–19 sieht man, dass f¨ ur eine Liste zwei ganz spezielle Methoden existieren, mit denen man ganz vorne bzw. ganz hinten Elemente anf¨ ugen kann. Zur Erkl¨arung des Einf¨ ugens eines oder mehrerer Elemente in eine Liste muss ich hier einen kurzen Vorgriff auf Iterators (siehe Abschnitt 16.3) machen. Kurz und salopp erkl¨art, sind Iterators Datenstrukturen, die sich merken, bei welchem Element in der Liste man gerade steht und mit denen man sich außerdem entsprechend vor- und r¨ uckw¨arts in der Liste bewegen kann. Außerdem kann man u ¨ber Iterators nat¨ urlich auch direkt auf das Element zugreifen, bei dem man gerade steht. Will man also mittels insert ein oder mehrere Element einf¨ ugen, so sind folgende Schritte zu unternehmen: • Man fordert einen Iterator an. In unserem Fall geschieht dies in Zeile 21. Da wir nicht bei jedem einzelnen Container raten wollen, welchen Typ dieser Iterator hat (es gibt im Prinzip sogar verschiedenste Iterators f¨ ur einen bestimmten Listentyp), ist der Typ zum Gl¨ uck u ¨ber das Template selbst zug¨ anglich. In unserem Fall enth¨ alt list<double>::iterator die entsprechende Typdefinition f¨ ur den Iterator, der f¨ ur unsere Liste funktioniert. Die Methode end liefert uns einen Iterator, der hinter (!) das letzte Element der Liste zeigt (siehe auch Abschnitt 16.3). • Wenn wir nun direkt vor dem letzten Element der Liste ein Element einf¨ ugen wollen, dann m¨ ussen wir daf¨ ur sorgen, dass der Iterator nicht hinter das letzte Element der Liste zeigt, sondern direkt auf das letzte Element. Der Grund daf¨ ur ist, dass die Methode insert das entsprechende
16.2 Container
501
Element immer vor dem Element einf¨ ugt auf das der Iterator verweist. ¨ Das Andern der Position des Iterators um eins in Richtung Listenanfang bewerkstelligen wir durch Aufruf des -- Operators, der f¨ ur den Iterator speziell definiert ist, wie man in Zeile 22 sieht. • Nun, da der Iterator auf das Element zeigt, vor dem wir ein Element einf¨ ugen wollen, rufen wir insert auf, wobei die Aufrufparameter folgendermaßen zu interpretieren sind: Der erste Parameter gibt an, vor welchem Element eingef¨ ugt werden soll. Der zweite Parameter gibt an, wie viele Elemente eingef¨ ugt werden sollen. Das bedeutet, dass man ein- und dasselbe Element auch 20 Mal einf¨ ugen kann, wenn man hier einfach 20 angibt. Der dritte Parameter ist das Element selbst. In den Zeilen 24–25 wiederholt sich dasselbe Spiel. Will man die Liste nun von Anfang bis Ende durchgehen, so holt man sich einen Iterator, der auf das erste Element zeigt, wie in Zeile 29 zu sehen ist. Die Schleife in den Zeilen 30–31 zeigt, dass der Iterator erstens kompatibel zu einem Pointer auf ein Element ist und dass man ihn mit dem abschließenden Element der Liste vergleichen kann, um zu wissen, wann es kein weiteres Element mehr gibt. Dass das ganze Spielchen auch so funktioniert, wie hier beschrieben, sieht man am Output, den das Programm liefert: iterating 1 2 3 5
l i s t from the b e g i n n i n g :
Die M¨oglichkeiten, die eine Liste bietet, gehen weit u ugen, Ent¨ber das Einf¨ fernen und Durchgehen von Elementen hinaus: Man kann Listen auch teilen, sortieren und mehrere Listen zu einer zusammenf¨ ugen. Dies geschieht u ¨ber die Methoden splice, sort und merge. 16.2.3 Double-Ended Queues Bisher kennen wir einerseits Vektoren, die effizienten indizierten Zugriff gestatten und Listen, die effizientes Einf¨ ugen und L¨oschen von Elementen erlauben, jedoch keinen indizierten Zugriff unterst¨ utzen. Relativ oft stoßen wir in Anwendungen auf einen recht speziellen Fall, der so h¨aufig ist, dass ihm eine eigene Datenstruktur gewidmet ist: Wir ben¨otigen einen Container, der es erlaubt am Anfang und am Ende einer Sequenz Elemente effizient einzuf¨ ugen (nicht in der Mitte), der jedoch auch den schnellen indizierten Zugriff auf die einzelnen Elemente gestattet. Diese Datenstruktur nennt sich Deque (ausgesprochen: dek ), was f¨ ur double-ended-Queue steht. Das entsprechende Template nennt sich bezeichnenderweise deque, wie wir am kurzen Beispiel sehen k¨ onnen (simple_deque_demo.cpp): 1 2
// simple deque demo . cpp − j u s t a s i m p l e demo , how to // use the deque template
3 4 5
#include <deque> #include < i o s t r e a m>
502
16. Die C++ Standard Library
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : deque ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { deque a deque ;
17 18
a a a a
19 20 21 22
// push elements at the f r o n t and the back deque . p u s h f r o n t ( 2 0 ) ; deque . push back ( 3 0 ) ; deque . p u s h f r o n t ( 1 0 ) ; deque . push back ( 4 0 ) ;
23
// u t i l i z e indexed a c c e s s for ( int count = 0 ; count < 4 ; count ++) cout << ” a deque [ ” << count << ” ] : ” << a deque [ count ] << e nd l ;
24 25 26 27
// pop elements from the f r o n t and the back a deque . p o p f r o n t ( ) ; a deque . pop back ( ) ;
28 29 30 31
cout << ” remaining a f t e r pop : ” << a deque [0] << ” , ” << a deque [1] << e nd l ;
32 33 34
return ( 0 ) ;
35 36
}
Mittels push_front und push_back kann man vorne und hinten an die gespeicherte Sequence Elemente anf¨ ugen (siehe Zeilen 19–22). Der indizierte Zugriff ist gleich wie beim Vektor m¨ oglich (siehe Zeilen 25–26) und nat¨ urlich kann man Elemente nicht nur vorne und hinten anf¨ ugen, sondern diese auch wieder entfernen. Dies macht man mit den entsprechenden pop_front und pop_back Methoden. Der Output dieses grandiosen Meisterwerks sieht dann so aus: a deque [ 0 ] : 1 0 a deque [ 1 ] : 2 0 a deque [ 2 ] : 3 0 a deque [ 3 ] : 4 0 remaining a f t e r pop : 2 0 , 3 0
16.2.4 Standard Queues Wo eine Deque existiert, kann nat¨ urlich eine “normale” Queue nicht weit sein. Das diesbez¨ ugliche Template aus der STL nennt sich queue und wird so verwendet (simple_queue_demo.cpp): 1 2
// simple queue demo . cpp − j u s t a s i m p l e demo , how to // use the queue template
3 4 5
#include #include < i o s t r e a m>
16.2 Container
503
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : queue ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { queue a queue ;
17 18
a a a a
19 20 21 22
// push elements ( they a r e appended at the back ) queue . push ( 1 0 ) ; queue . push ( 2 0 ) ; queue . push ( 3 0 ) ; queue . push ( 4 0 ) ;
23
cout << ” elements i n the queue : ” ;
24 25
for ( int count = 0 ; count < 4 ; count++) { cout << a queue . f r o n t () << ” ” ; // popping elements i s done from the f r o n t a queue . pop ( ) ; }
26 27 28 29 30 31 32
cout << e nd l ;
33 34
return ( 0 ) ;
35 36
}
Mittels push stellt man Elemente in die Queue, wie in den Zeilen 19–22 zu sehen ist. Diese Elemente werden nat¨ urlich hinten an die Sequence angeh¨angt. Der Zugriff auf das vorderste Element in der Queue erfolgt durch Aufruf von front, wie man in Zeile 28 sieht. Das vorderste Element der Queue entfernt man dann mittels pop, wie es auch in Zeile 30 geschieht. Der Vollst¨andigkeit halber ist hier noch der Output des Programms angef¨ uhrt: elements i n the queue : 1 0 2 0 3 0 4 0
16.2.5 Priority Queues In verschiedenen Situationen ben¨ otigt man nicht nur das Buffer-Verhalten einer Queue, die man von einer Seite auff¨ ullt und von der anderen Seite ausliest, sondern man will einzelnen Elementen eine gewisse Priorit¨at geben und sie damit beim Hineinstellen automatisch vor anderen Elementen mit niedriger Priorit¨ at eingereiht haben. Auf diese Art werden beim Auslesen immer die Elemente mit hoher Priorit¨at vor Elementen mit niedrigerer Priorit¨ at geliefert. Auch diese Datenstruktur, die sich Priority Queue nennt, ist als Template unter dem Namen priority_queue verf¨ ugbar (simple_priority_queue_demo.cpp):
504
1 2
16. Die C++ Standard Library
// s i m p l e p r i o r i t y q u e u e d e m o . cpp − j u s t a s i m p l e demo , how to // use the p r i o r i t y q u e u e template
3 4 5
#include #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : p r i o r i t y q u e u e ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16 17 18 19 20
//−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− /∗ ∗ ElementWithPriority ∗ ∗ j u s t a dummy Element with a p r i o r i t y f i e l d ∗ ∗/
21 22 23 24 25 26 27 28 29
c l a s s ElementWithPriority { protected : uint32 p r i o r i t y ; uint32 data ; public : ElementWithPriority ( ) throw ( ) : p r i o r i t y ( 0 ) , d a t a ( 0 ) { }
30
ElementWithPriority ( u i n t 3 2 p r i o r i t y , u i n t 3 2 data ) throw ( ) : p r i o r i t y ( p r i o r i t y ) , d a t a ( data ) { }
31 32 33
ElementWithPriority ( const ElementWithPriority & s r c ) throw ( ) { priority = src . priority ; data = s r c . data ; }
34 35 36 37 38 39 40
virtual ElementWithPriority& operator = ( const ElementWithPriority & s r c ) { priority = src . priority ; data = s r c . data ; return (∗ t h is ) ; }
41 42 43 44 45 46 47
virtual bool operator < ( const ElementWithPriority &cmp with ) const { return ( p r i o r i t y < cmp with . p r i o r i t y ) ; }
48 49 50 51 52
virtual operator u i n t 3 2 ( ) const { return ( d a t a ) ; }
53 54 55 56 57
virtual ˜ ElementWithPriority ( ) throw ( ) { }
58 59 60
};
61 62 63 64 65
int main ( int a r g c , char ∗ argv [ ] ) { p r i o r i t y q u e u e<ElementWithPriority> a p r i o r i t y q u e u e ;
16.2 Container
505
66 67
a a a a
68 69 70 71
// push elements ( they a r e appended at the back ) p r i o r i t y q u e u e . push ( ElementWithPriority ( 1 0 , 1 0 0 ) ) ; p r i o r i t y q u e u e . push ( ElementWithPriority ( 4 0 , 2 0 0 ) ) ; p r i o r i t y q u e u e . push ( ElementWithPriority ( 2 0 , 3 0 0 ) ) ; p r i o r i t y q u e u e . push ( ElementWithPriority ( 2 0 , 4 0 0 ) ) ;
72
cout << ” elements i n the p r i o r i t y queue : ” ;
73 74
for ( int count = 0 ; count < 4 ; count++) { cout << a p r i o r i t y q u e u e . top () << ” ” ; // popping elements i s done from the f r o n t a p r i o r i t y q u e u e . pop ( ) ; }
75 76 77 78 79 80 81
cout << e nd l ;
82 83
return ( 0 ) ;
84 85
}
Die Implementation der Priority Queue, wie wir sie in unserem Beispiel verwendet haben, beruht auf folgender Voraussetzung: Elemente, die in ihr gespeichert werden, m¨ ussen mittels des < Operators miteinander vergleichbar sein. Der Operator muss die Elemente gem¨aß ihrer Priorit¨at reihen. Sieht man sich den entsprechenden Operator in den Zeilen 48–51 an, so tut dieser in unserem Fall genau das. Jetzt wurde aber zu Beginn dieses Kapitels erw¨ahnt, dass die Templates aus der STL Policy-frei sein m¨ ussen und pl¨otzlich wird hier eine Policy gefordert. Was ist hier los? Die Antwort ist einfach: Die hier verwendete Variation des Templates ist nur eine der M¨oglichkeiten. Es gibt auch eine andere M¨ oglichkeit, die diese Forderung nicht stellt und stattdessen eine Komparatorfunktion als Parameter nimmt. Wie funktioniert nun unsere Priority Queue? Werfen wir dazu einen Blick auf die Zeilen 68–71: Dort werden die speziellen priorisierten Objekte mittels push in die Queue gestellt. Man sieht auch, dass die beiden Elemente in den Zeilen 70–71 dieselbe Priorit¨at besitzen. Von der Priority Queue wird dies so gehandhabt: Elemente mit unterschiedlicher Priorit¨ at werden in der Reihenfolge ihrer Priorit¨aten geordnet, Elemente mit gleicher Priorit¨at gelten als gleichwertig und ihre Ordnung untereinander ist nicht definiert. Durch Aufruf der Methode top bekommt man immer das Element mit der h¨ochsten Priorit¨at geliefert, wie in Zeile 77 zu sehen ist. Will man dieses Element aus der Priority Queue entfernen, so muss man explizit pop aufrufen (siehe Zeile 79). Dass die Elemente auch wirklich nach Priorit¨ aten sortiert geliefert werden, sieht man am Output des Programms: elements i n the p r i o r i t y queue : 2 0 0 3 0 0 4 0 0 1 0 0
16.2.6 Stacks Eine weitere Datenstruktur, die immer wieder in verschiedensten Zusammenh¨angen gebraucht wird, ist der Stack. Ein entsprechendes Template steht
506
16. Die C++ Standard Library
als stack in der STL zur Verf¨ ugung. Dieses wird verwendet, wie im folgenden Beispiel gezeigt (simple_stack_demo.cpp): 1 2
// s i m p l e st a c k d e m o . cpp − j u s t a s i m p l e demo , how to // use the queue template
3 4 5
#include < s t a c k> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : s t a c k ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { s t a c k a s t a c k ;
17 18
a a a a
19 20 21 22
// push elements ( they a r e appended at the back ) s t a c k . push ( 1 0 ) ; s t a c k . push ( 2 0 ) ; s t a c k . push ( 3 0 ) ; s t a c k . push ( 4 0 ) ;
23
cout << ” elements on the s t a c k : ” ;
24 25
for ( int count = 0 ; count < 4 ; count++) { cout << a s t a c k . top () << ” ” ; // popping elements has to be done e x p l i c i t l y a s t a c k . pop ( ) ; }
26 27 28 29 30 31 32
cout << e nd l ;
33 34
return ( 0 ) ;
35 36
}
Die einzige Auff¨ alligkeit, die anfangs vielleicht etwas komisch anmutet, ist die Notwendigkeit top und pop immer explizit aufrufen zu m¨ ussen, um erst das oberste Element zu erhalten (Zeile 28) und es dann aus dem Stack zu entfernen (Zeile 30). Laut Theorie g¨abe es eine Methode peek zum Nachsehen ohne Entfernen und eine Methode pop, die ein Element liefert und es gleichzeitig entfernt. Jedoch ist dies nach einer kurzen Eingew¨ohnungszeit auch kein besonderes Problem. Der Output des Programms zeigt uns, dass alles so funktioniert, wie erwartet: elements on the s t a c k : 4 0 3 0 2 0 1 0
16.2.7 Maps Maps sind eine der meist verwendeten Datenstrukturen u ¨berhaupt, denn man steht im Prinzip in jedem Programm mehr als nur einmal vor der Anforderung, gewisse Daten in einem Container zusammenzufassen und einzelne
16.2 Container
507
Elemente u ussel, sogenannte Keys, ansprechen zu wollen. ¨ber bestimmte Schl¨ Ein solcher Container hat also keine sequentielle Ordnung mehr, sondern speichert Key-Value Pairs. Auch das ist am einfachsten wieder an einem Beispiel zu zeigen (simple_map_demo.cpp): 1 2
// simple map demo . cpp − j u s t a s i m p l e demo , how to // use a map
3 4 5
#include <map> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : map ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { map a map ;
17
a a a a
18 19 20 21
map [ map [ map [ map [
’a’ ’x’ ’u’ ’b’
] ] ] ]
= = = =
11.0; 1.3; 17.8; 0.7;
22
cout << ”what ’ s the element f o r key u? −> ” << a map [ ’ u ’ ] << e nd l ;
23 24 25
return ( 0 ) ;
26 27
}
Im Beispiel wurden Elemente vom Typ char als Key verwendet und die dazu gespeicherten Values sind vom Typ double. Der Index-Operator ist f¨ ur Maps so definiert, dass er Elemente des Typs entgegennimmt, der f¨ ur den Key definiert wurde und dadurch Zugriff auf den Value gew¨ahrleistet. Vom Gef¨ uhl her verwendet man also eine Map sehr ¨ahnlich wie ein Array. Der Unterschied ist nur, dass man keine fortlaufenden Index-Zahlen mehr hat, sondern beliebige Keys verwenden kann (z.B. auch Strings). Daher kommt auch der sehr gebr¨ auchliche Name associative Array f¨ ur eine Map. Der Output des Programms sieht dann folgendermaßen aus: what ’ s the element f o r key u? −> 17.8
Eines muss noch zum map Template gesagt werden: Keys m¨ ussen innerhalb einer map eindeutig sein, Duplikate sind nicht gestattet. Sollte dies gew¨ unscht sein, so steht daf¨ ur das multimap Template zur Verf¨ ugung. 16.2.8 Sets Oftmals ist man nur an den Keys interessiert und gar nicht an den Values, die u ¨ber einen Key ansprechbar sind. Man will z.B. nur wissen, ob man einen bestimmten Key bereits verwendet hat, ohne aber darunter einen expliziten Wert speichern zu wollen. Zu diesem Zweck steht
508
16. Die C++ Standard Library
das set Template zur Verf¨ ugung. In einem Set speichert man Elemente, die selbst Key-Funktionalit¨ at besitzen. Am Beispiel sieht dies so aus (simple_set_demo.cpp): 1 2
// s i m p l e s e t d e m o . cpp − j u s t a s i m p l e demo , how to // use a s e t
3 4 5
#include < s e t> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : s e t ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16 17
int main ( int a r g c , char ∗ argv [ ] ) { s e t a s e t ; s e t a n o t h e r s e t ;
18
a a a a
19 20 21 22
set set set set
. . . .
insert insert insert insert
(10); (20); (30); (40);
23
another another another another
24 25 26 27
set . set . set . set .
insert insert insert insert
(10); (40); (30); (20);
28
cout << ” i s 2 0 element o f a s e t ? −> ” << ( a s e t . f i n d ( 2 0 ) ! = a s e t . end ()) << e nd l ;
29 30 31
cout << ” i s 2 5 element o f a s e t ? −> ” << ( a s e t . f i n d ( 2 5 ) ! = a s e t . end ()) << e nd l ;
32 33 34
cout << ” a r e the two s e t s i d e n t i c a l ? −> ” << ( a s e t == a n o t h e r s e t ) << e nd l ;
35 36 37
return ( 0 ) ;
38 39
}
In diesem Beispiel wird eine Eigenschaft von Sets demonstriert, die sehr brauchbar ist: Man kann sie vergleichen. Sollten dieselben Elemente darin gespeichert sein, ergibt der Vergleich true, andernfalls false (siehe auch Zeile 36). Das Speichern von Elementen erfolgt erwartungsgem¨aß mittels insert. Um herauszufinden, ob ein Element in einem Set enthalten ist, ruft man find mit dem entsprechenden Element auf, woraufhin man einen entsprechenden Iterator geliefert bekommt. Ist dieser Iterator identisch zum Iterator, der von der Methode end geliefert wird, so ist das Element nicht enthalten, ansonsten schon. Dieses Verhalten wird in den Zeilen 29–33 gezeigt. Der Output des Programms liest sich dann folgendermaßen: i s 2 0 element o f a s e t ? −> 1 i s 2 5 element o f a s e t ? −> 0 a r e the two s e t s i d e n t i c a l ? −> 1
16.2 Container
509
Wie bei map ist auch bei set ein duplicate Key nicht zul¨assig und analog zur multimap gibt es auch f¨ ur F¨ alle, in denen dieses Verhalten ben¨otigt wird ein multiset. Neben diesem gibt es noch eine weitere besondere Art eines Sets, das sich in der Praxis als sehr brauchbar erweist: Das Bit-Set, das als bitset Template zur Verf¨ ugung steht. Oft hat man, aus welchen Gr¨ unden auch immer, eine gewisse Anzahl von boolschen Variablen, die verschiedene Teilzust¨ ande repr¨ asentieren. Jede dieser Variablen speichert im Prinzip nur ein einziges Bit an Information, jedoch wird ein x-faches an Speicherplatz daf¨ ur verbraucht (je nach Compiler und Architektur zwischen 8 und 32 Bits). Bei Objekten, die vielfach im Programm vorkommen, kann das schon zur groben Speicherverschwendung entarten. Arbeitet man jedoch mit einem bitset, so kann man dies leicht in den Griff bekommen, ohne auf den Gebrauch von Bitmasken per Hand zur¨ uckgreifen zu m¨ ussen. Wie dies in der Praxis aussehen k¨ onnte, sieht man an folgendem Beispiel: 1 2
// s i m p l e b i t s e t d e m o . cpp − j u s t a s i m p l e demo , how to // use a b i t s e t
3 4 5
#include < b i t s e t> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : b i t s e t ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16 17 18 19 20
static static static static static static static
const const const const const const const
uint32 uint32 uint32 uint32 uint32 uint32 uint32
INITIALIZED BIT BROKEN BIT OPEN HANDSHAKE BIT ALIVE BIT CLOSE HANDSHAKE BIT CLOSED BIT NUM BITS
= = = = = = =
0; 1; 2; 3; 4; 5; 6;
21 22 23 24
int main ( int a r g c , char ∗ argv [ ] ) { b i t s e t c o n n e c t i o n s t a t u s = 0 x0 ;
25
c o n n e c t i o n s t a t u s [ INITIALIZED BIT ] = 1 ; c o n n e c t i o n s t a t u s [ ALIVE BIT ] = 1 ;
26 27 28
cout << ” i s the c o n n e c t i o n i n i t i a l i z e d ? −> ” << c o n n e c t i o n s t a t u s [ INITIALIZED BIT] << e nd l ;
29 30 31
cout << ” i s the c o n n e c t i o n a l i v e ? −> ” << c o n n e c t i o n s t a t u s [ ALIVE BIT] << e nd l ;
32 33 34
cout << ” i s c l o s e handshake i n p r o g r e s s ? −> ” << c o n n e c t i o n s t a t u s [CLOSE HANDSHAKE BIT] << e nd l ;
35 36 37
return ( 0 ) ;
38 39
}
Eine konkrete Auspr¨ agung eines Bit-Sets wird also durch Angabe der ben¨otigten Bits erzeugt, wie man in Zeile 24 sieht. Angesprochen werden die ein-
510
16. Die C++ Standard Library
zelnen Bits intuitiverweise u urlich ist der Index ¨ber den Index Operator. Nat¨ des ersten verf¨ ugbaren Bits 0, wie wir es bei Indizes generell gewohnt sind. Der Output des Programms sieht dann so aus: i s the c o n n e c t i o n i n i t i a l i z e d ? −> 1 i s the c o n n e c t i o n a l i v e ? −> 1 i s c l o s e handshake i n p r o g r e s s ? −> 0
Noch zu erw¨ ahnen w¨ are, dass sinnigerweise f¨ ur Bit-Sets alle BitmanipulationsOperatoren entsprechend definiert sind, so dass man mit einem Bit-Set ganz gleich arbeiten kann wie mit Ganzzahlen. 16.2.9 Zusammenfassung der Container-Operationen Da ich bei den verschiedenen Containern immer nur einen geringen Teil ihrer M¨oglichkeiten gezeigt und besprochen habe, m¨ochte ich an dieser Stelle eine kurze tabellarische Zusammenfassung geben, welche Operationen prinzipiell f¨ ur die verschiedenen Container definiert sind. Die Tabellen enthalten die Operationen (Methoden und Operatoren), die im Prinzip f¨ ur alle verschiedenen Standard-Container G¨ ultigkeit haben. Je nach Spezialisierung bestimmter Container sind noch weitere Operationen definiert, die hier nicht angef¨ uhrt sind (z.B. Bitmanipulationen beim bitset). Die Absicht hinter den folgenden Tabellen ist es, Lesern einen zus¨atzlichen Startpunkt zum Spielen mit den Standard Containern zu geben. F¨ ur eine detailliertere Diskussion m¨ochte ich auf die entsprechende Spezialliteratur (z.B. [Musser et al. 2001] und [Stroustrup 1997]) verweisen. front back [] at
Elementzugriff Erstes Element Letztes Element Ungepr¨ ufter Zugriff mit Index Gepr¨ ufter Zugriff mit Index (nur vector und deque)
16.2 Container
size empty max_size capacity reserve resize swap get_allocator == != <
511
Generelle Operationen Anzahl der Elemente Ist der Container leer? Gr¨ oße des gr¨ oßtm¨oglichen Containers F¨ ur wie viele Elemente ist Platz reserviert? (nur vector) Reserviere Platz f¨ ur x Elemente (nur vector) Containergr¨ oße ¨andern (vector, list, deque) Elemente zweier Container austauschen Kopie des Allocators anfordern Ist der Content zweier Container identisch? Ist der Content zweier Container verschieden? Ist ein Container lexikographisch kleiner?
Stack und Queue Operationen push Element anf¨ ugen pop Element entfernen push_back Element am Ende anf¨ ugen pop_back Element vom Ende entfernen push_front Element als erstes anf¨ ugen (nur deque) pop_front Erstes Element entfernen (nur deque)
Anm.: Die Operationen push_front und pop_front sind bewusst in der folgenden Tabelle noch einmal angef¨ uhrt, da sie auch f¨ ur Listen gelten, eine Liste aber weder ein Stack, noch eine Queue ist. Listenoperationen insert Element einf¨ ugen erase Element entfernen clear Container entleeren push_front Element als erstes anf¨ ugen pop_front Erstes Element entfernen
Die folgende Tabelle wurde mit Assoziative Operationen betitelt, da die darin angef¨ uhrten Operationen sowohl f¨ ur Maps als auch f¨ ur Sets g¨ ultig sind.
512
16. Die C++ Standard Library
[] find lower_bound upper_bound equal_range key_comp value_comp
Assoziative Operationen Quasi-indizierter Zugriff mittels Key Suche nach Key Suche nach erstem Element mit Key Suche nach erstem Element mit Key gr¨ oßer als gegebener Key Suche nach allen Elementen mit Key Liefert eine Kopie des Key-Komparator Objekts Liefert eine Kopie des Value-Komparator Objekts
In der folgenden Tabelle schl¨ agt leider zum wiederholten Mal das “Henne-EiProblem” zu: Einerseits wurden Iteratoren noch nicht erkl¨art, andererseits sind die entsprechenden Operationen Teil der Container. Deshalb wurden auch bereits Vorgriffe gemacht. W¨ urde man die Reihenfolge der Behandlung der beiden Themen umstellen, so steht man vor demselben Problem: Es werden die Iterators erkl¨ art, allerdings fehlt das Wissen um die Container, ¨ auf die sie sich beziehen. Nach kurzem Uberfliegen von Abschnitt 16.3 sollte sich diese Diskrepanz aber in Wohlgefallen aufgel¨ost haben. begin end rbegin rend
Anfordern von Iteratoren Iterator, der auf das erste Element zeigt Iterator, der um ein Element hinter das letzte Element zeigt Iterator, der auf das erste Element der reverse Sequence Iterator, der um ein Element vor das letzte Element der reverse Sequence zeigt
In den Beispielen, die zur kurzen Demonstration verwendet wurden, wurde bereits von der M¨ oglichkeit Gebrauch gemacht, Datentypen vom entsprechenden Container-Template zu erfragen. Die folgende Tabelle enth¨alt die Zusammenfassung aller verf¨ ugbaren Datentypen, die man von einem Container erfahren kann.
16.3 Iterators
513
Abfragbare Datentypen Typ des Key-Objekts Typ des Value-Objekts Typ des Allocators Typ f¨ ur indizierten Zugriff Typ f¨ ur Unterschied zwischen Iteratoren Typ des Iterators, der sich wie value_type* verh¨alt const_iterator Typ des Iterators, der sich wie const value_type* verh¨alt reverse_iterator analog zu iterator (umgekehrte Reihenfolge) const_reverse_iterator analog zu reverse_iterator (umgekehrte Reihenfolge) reference Typ des Iterators, der sich wie value_type& verh¨alt const_reference analog zu reference, nur const mapped_type Typ von mapped_value (nur assoziative Container) key_compare Typ des Key Komparators (nur assoziative Container) key_type value_type allocator_type size_type difference_type iterator
16.3 Iterators Bisher wurden Iterators zwar bereits im Zuge von kurzen Vorgriffen erw¨ahnt und auch verwendet, aber eine genaue Erkl¨arung dazu bin ich noch schuldig geblieben. Die sogenannten Iterators l¨osen ein immer wieder auftretendes Problem, u ¨ber das man bei der Verwendung von Containern stolpern kann: Man stelle sich vor, es g¨ abe eine Implementation einer Liste, die man ganz einfach durch Aufruf der Methoden first...next...next...etc. Element f¨ ur Element durchlaufen kann. Die Information, welches Element nun das aktuelle ist, w¨ urde im Container selbst stecken. Nun stelle man sich weiters vor, was passiert, wenn aus mehreren Programmteilen heraus zugleich ein und dieselbe Liste durchlaufen w¨ urde. Das funktioniert schlicht und ergreifend nicht, denn sobald aus einem Teil heraus next aufgerufen w¨ urde, n¨ahme man dem anderen Teil ja damit ein Element weg. Diese Programmteile w¨ urden sich also gegenseitig durcheinander bringen und das ist sicher nicht im Sinne des Erfinders. Ganz anders verh¨ alt es sich da schon, wenn die Information u ¨ber das aktuelle Element außerhalb des eigentlichen Containers gespeichert wird und genau das ist die Natur des Iterators: Ein Iterator bezieht sich immer auf ein aktuelles Element, das sich aufgrund irgendeiner Art einen Container zu durchwandern ergeben hat. Egal also, ob ein Programmteil mit einem Iterator vor, zur¨ uck, hin oder her wandert, es wird dadurch niemals ein anderer
514
16. Die C++ Standard Library
Programmteil in seiner Arbeit des Durchwanderns beeinflusst, solange dieser einen eigenen Iterator besitzt. Ein Iterator bezieht sich also auf das aktuelle Element und u ¨ber ihn findet auch die “Navigation” durch den Container statt. Navigation u ¨ber Iterators bedeutet, dass man die Elemente eines Containers immer in einer bestimmten Ordnung zu Gesicht bekommt, die man vorw¨arts oder r¨ uckw¨arts durchlaufen kann. Man bewegt sich dabei immer um ein Element in eine Richtung. Salopp gesagt bedeutet dies, dass sich die Elemente eines Containers durch den Iterator so pr¨ asentieren, als w¨ aren sie in einer verketteten Liste gespeichert. Jede Navigationsoperation zum n¨ achsten, vorherigen oder welchem anderen Element auch immer, ver¨ andert das aktuelle Element, auf das der Iterator zeigt. Je nachdem, was man nun tun will, gibt es eine Reihe verschiedener Typen von Iterators, die neben der Navigation die eine oder andere besondere Zusatzaufgabe zu erledigen haben: • Es gibt solche, die nur zum lesenden Zugriff auf Elemente verwendet werden. Wenn einfach nur von Iterator die Rede ist, sind in der Regel genau diese gemeint. • Es gibt solche, die dazu verwendet werden, in eine Datenstruktur an einer bestimmten Stelle, die man vorher heraussucht, Elemente einzuf¨ ugen. Solche werden im allgemeinen Sprachgebrauch von Softwareentwicklern als Inserters bezeichnet. Man kann sich leicht vorstellen, dass beim Erlauben von schreibenden Zugriffen ein erheblicher Synchronisationsaufwand vonn¨oten ist, um nicht Inkonsistenzen in anderen Programmteilen zu verursachen, die gleichzeitig einen Container lesender- oder ebenfalls schreibenderweise iterieren. • Es gibt solche, die man ganz bewusst zum verkehrten Durchlaufen einer Datenstruktur verwendet. Diese werden als reverse Iterators bezeichnet, wenn sie nur f¨ ur den lesenden Zugriff gedacht sind, als reverse Inserters, wenn sie auch zum Einf¨ ugen von Elementen gedacht sind. Warum die Bezeichnung reverse so besonders herausgestrichen wird, wird in Abbildung 16.1 skizziert. • Es gibt dann auch noch den Typ der Checked Iterators, die sich dadurch auszeichnen, dass bei Navigationsoperationen u uft wird, ob diese ¨berpr¨ Operation beim aktuellen Inhalt des Containers u ¨berhaupt noch m¨oglich ist (z.B. nach dem Ende einer Sequence kann man nicht noch einmal um ein Element weiter r¨ ucken). In der STL sind f¨ ur alle verschiedenen Container entsprechende Iterators definiert. Wenn man einen Iterator von einem Container anfordert, so kann man dies prinzipiell auf eine der folgenden vier Arten tun, die den Startpunkt der folgenden Navigationsoperationen definieren: begin fordert einen Iterator an, der auf das erste Element im Container zeigt. Ein Anfordern des Elements vom Iterator liefert also das erste Element.
16.3 Iterators
515
end fordert einen Iterator an, der um ein Element hinter das letzte Element zeigt. Ein Anfordern des Elements vom Iterator liefert also gar kein Element, sondern end! Diese Vereinbarung wurde deshalb getroffen, da alle Einf¨ ugeoperationen, die u ¨ber Iterators angeboten werden, so definiert sind, dass sie das neu dazugekommene Element immer vor dem aktuellen Element einf¨ ugen. W¨ urde der Iterator also direkt auf das letzte Element zeigen, so k¨ onnte niemals ein Element hinter dem letzten Element der Liste eingef¨ ugt werden. rbegin fordert einen Iterator an, der auf die Elemente des Containers in verkehrter Reihenfolge zeigt. Ein Anfordern des Elements vom Iterator liefert also nun das letzte Element. rend fordert einen Iterator an, der um ein Element hinter das letzte Element in verkehrter Reihenfolge zeigt. Iterators in der STL gehen also von folgender logischer Sicht aus: Wenn man “verkehrt” im Container navigiert, dann ist der Beginn trotzdem “vorne”, nur die Elemente pr¨ asentieren sich in umgekehrter Reihenfolge. Das ist auch der Grund, warum es f¨ ur einen reverse_iterator einen eigenen Datentyp gibt. Verwirrend? Keine Sorge, mit einem kleinen Beispielchen wird das Ganze klarer (simple_iterator_demo.cpp): 1 2
// s i m p l e i t e r a t o r d e m o . cpp − j u s t a s i m p l e demo , how to // use the i t e r a t o r s
3 4 5
#include < v e c t o r> #include < i o s t r e a m>
6 7
#include ” u s e r t y p e s . h”
8 9
using s t d : : v e c t o r ;
10 11 12
using s t d : : cout ; using s t d : : e nd l ;
13 14 15 16
int main ( int a r g c , char ∗ argv [ ] ) { v e c t o r a v e c t o r ( 1 0 ) ;
17 18 19
for ( int count = 0 ; count < 1 0 ; count++) a v e c t o r [ count ] = count ;
20 21 22 23
v e c t o r : : i t e r a t o r f o r w a r d i t e r a t o r = a v e c t o r . begin ( ) ; v e c t o r