This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
C++ für Naturwissenschaftler Beispielorientierte Einführung
Bitte beachten Sie: Der originalen Printversion liegt eine CD-ROM bei. In der vorliegenden elektronischen Version ist die Lieferung einer CD-ROM nicht enthalten. Alle Hinweise und alle Verweise auf die CD-ROM sind ungültig.
An imprint of Pearson Education Deutschland GmbH München • Boston • San Francisco • Harlow, England Don Mills, Ontario • Sydney • Mexico City Madrid • Amsterdam
Ein Titeldatensatz für diese Publikation ist bei Der Deutschen Bibliothek erhältlich.
Die Informationen in diesem Produkt werden ohne Rücksicht auf einen eventuellen Patentschutz veröffentlicht. Warennamen werden ohne Gewährleistung der freien Verwendbarkeit benutzt. Bei der Zusammenstellung von Texten und Abbildungen wurde mit größter Sorgfalt vorgegangen. Trotzdem können Fehler nicht vollständig ausgeschlossen werden. Verlag, Herausgeber und Autoren können für fehlerhafte Angaben und deren Folgen weder eine juristische Verantwortung noch irgendeine Haftung übernehmen. Für Verbesserungsvorschläge und Hinweise auf Fehler sind Verlag und Herausgeber dankbar. Alle Rechte vorbehalten, auch die der fotomechanischen Wiedergabe und der Speicherung in elektronischen Medien. Die gewerbliche Nutzung der in diesem Produkt gezeigten Modelle und Arbeiten ist nicht zulässig. Fast alle Hardware- und Softwarebezeichnungen, die in diesem Buch erwähnt werden, sind gleichzeitig auch eingetragene Warenzeichen oder sollten als solche betrachtet werden. Umwelthinweis: Dieses Buch wurde auf chlorfrei gebleichtem Papier gedruckt. Die Einschrumpffolie – zum Schutz vor Verschmutzung – ist aus umweltverträglichem und recyclingfähigem PE-Material.
Deklaration und Definition Mathematische Standardfunktionen Selbst definierte Funktionen String-Funktionen Char-Funktionen Rekursive Funktionen Die Funktion main() Funktion mit variabler Parameterliste Call-by-value Call-by-reference Zeiger auf Funktionen Array von Funktionszeigern Funktionen, die einen Zeiger liefern Funktionen, die eine Referenz liefern Default-Argumente Inline-Funktionen Überladen von Funktionen Template-Funktionen Übungen
Klassen als ADT Definition und Deklaration Elementfunktionen Klasse als Verallgemeinerung der Funktion Eine Klasse Bruch Konstruktoren Destruktoren Beispiel zur Datenkapselung Klasse mit Array Klasse mit dynamischem Array Statische Datenelemente Überladen von Operatoren Überladen von Operatoren 2 Friend-Funktionen Der this-Zeiger Klasse mit Funktionszeiger Klasse mit Invarianten Friend-Klassen Übungen
9 Elemente des Software-Engineerings 9.1 9.2 9.3 9.4 9.5
Die Verifikation von Algorithmen Software-Metrik Stichpunkte der UML Kriterien für Programmiersprachen Forderungen des Software-Engineerings
10 Ausnahmebehandlung 10.1 10.2 10.3 10.4
Ablauf eines Ausnahmefalls Ein Beispiel mit selbst definierten Fehlerklassen Vordefinierte Fehlerfunktionen Die Standardausnahmen
11 Vererbung 11.1 11.2 11.3 11.4 11.5 11.6
Zugriffsmöglichkeiten Konstruktoren und Destruktoren Polymorphismus Virtuelle Funktionen RTTI Abstrakte Basisklassen
183 183 188 192 199 202
205 205 207 208 210
211 212 214 217 218 220 225
8
Inhaltsverzeichnis
11.7 11.8 11.9 11.10 11.11
Beispiel aus der Biologie Beispiel aus der Mathematik Mehrfache Vererbung Virtuelle Basisklassen Übung
Iteratoren Funktionsobjekte Hilfsmittel Der Container vector Der Container deque Der Container list Der Container map Der Container multimap Der Container set Der Container multiset
228 230 232 235 237
243 243 244 249 252 255 257 257 259
265 266 268 269 271 273 274
277 278 280 282 285 287
291 292 294 301 302 305 307 309 311 313 318
Inhaltsverzeichnis
15.11 15.12 15.13 15.14 15.15 15.16 15.17
Der Adapter stack Der Adapter queue Der Adapter priority_queue Die Klasse bitset Die Klasse string Die Klasse valarray Übungen
Nichtmodifizierende Algorithmen Modifizierende Algorithmen Suchen in geordneten Bereichen Sortierähnliche Verfahren Misch-Verfahren Die Mengen-Funktionen Minumum und Maximum Permutationen Heap-Algorithmen Komplexe Arithmetik Numerische Algorithmen Demoprogramme zu den STL Algorithmen
17 Grafik 17.1 17.2 17.3 17.4 17.5 17.6
Ein Beispiel zu X Windows Grafik mit dem C++Builder Hofstadter-Schmetterling Der Ikeda-Attraktor Julia-Menge Orthogonale Trajektorien
Die Binomialverteilung Die Poisson-Verteilung Hypergeometrische Verteilung Normalverteilung Die t-Verteilung Die c2-Verteilung F-Verteilung
Die Richmond-Iteration Numerische Differenziation Adaptive Simpson-Formel Integration nach Gauss Integration von Tabellenfunktionen Romberg-Integration Runge-Kutta-Fehlberg Verfahren von Dormand-Prince Adams-Moulton-Verfahren Tschebyschew-Approximation
Gleichgewichtstemperatur der Erde Energieanteil eines schwarzen Strahlers Abkühlvorgang eines Festkörpers Die Lorenz-Gleichungen Massenformel von Weizsäcker Das Drei-Körper-Problem Dampfdruckkurve Biegeschwingung eines einseitig eingespannten Stabs Zustandsgrößen eines Sterns
21 Literaturverzeichnis C++ Programmiersprachen UML Informatik Astronomie Physik Chaostheorie Mathematik
Index
419 421 423 425 427 430 433 435 437 440
445 445 449 452 453 455 459 463 467 469
475 475 475 475 476 476 476 476 476
477
Vorwort
C++ ist ohne Zweifel die Programmiersprache der neunziger Jahre. Die Gründe für den Erfolg von C++ sind unter anderem: 왘 C++ weist die wesentlichen Merkmale einer objektorientierten Programmierspra-
che auf, erlaubt aber dem Anwender einen Übergang von der prozeduralen zur objektorientierten Programmierweise. 왘 Compiler für C++ sind praktisch an allen Maschinen verfügbar; der Sprachstan-
dard garantiert eine sichere Investition in C++. 왘 C++-Programme sind mit einer Vielzahl von existierenden C-Quellcodes kombi-
nierbar. Außerdem gibt es zahlreiche C++-Bibliotheken für nahezu alle Anwendungen. 왘 C++ ist Ausgangspunkt für die Entwicklung weiterer objektorientierter Program-
miersprachen wie Java. Das Erlernen von C++ liefert eine fundierte Kenntnis der erfolgreichsten Familie von Programmiersprachen, die zur Zeit durch die neue Programmiersprache C# von Microsoft erweitert wird. Viele Bücher über C++ handeln von irgendwelchen abstrakten Klassen oder widmen sich allgemein der objektorientierten Softwareerstellung. Was fehlt, ist eine Darstellung, die das objektorientierte Programmmieren (OOP) aus der Sicht des Naturwissenschaftlers aufzeigt. Hier setzt das vorliegende Buch ein. Es zeigt für Leser mit Grundkenntnissen im Programmieren: 왘 wie die strukturierte und prozedurale Programmierung objektorientiert wird, 왘 wie vielfältig Klassen im naturwissenschaftlichen Bereich eingesetzt werden kön-
nen. Da C++ eine der mächtigsten Programmiersprachen ist, ist sie nicht, wie andere Programmiersprachen, in wenigen Stunden erlernbar. Um den Lernvorgang zu erleichtern, wird die Leserin bzw. der Leser durch konkrete, nachvollziehbare Beispiele und zahlreiche Abbildungen, aber nicht durch abstrakt-formale Beschreibungen in die komplexe Welt der C++-Programmierung eingeführt.
12
Vorwort
Die Grundlage dieses Buchs bildet die Beschreibung von C++ gemäß der ANSI/ISO C++-Norm, die im Juli 1998 von der internationalen Behörde ISO/IEC akzeptiert worden ist. Es ist zu erwarten, dass künftige Compilerversionen weitgehend mit der Norm konform gehen. Um die Bandbreite der C++-Anwendungen zu demonstrieren, behandelt das Buch eine Vielzahl von Themen aus Mathematik, Informatik, Physik, Astronomie und Computergrafik. Es ist natürlich im Rahmen eine solchen Buchs nicht möglich, alle benötigten Grundlagen aus diesen Wissenschaften bereitzustellen. Der Leser wird hier auf die Fachliteratur (im Anhang) verwiesen. Kapitel 1 enthält als Einführung die Geschichte und Entwicklung der Programmiersprache C++ und das Basiswissen über OOP. Kapitel 2 bietet grundlegende Informationen über das Arbeiten mit C++-Compilern, über auftretende Fehlermeldungen und erläutert die Eigenschaften von Algorithmen. Kapitel 3 stellt die Syntax von C++ und die einfachen Datentypen so ausführlich dar, dass das Buch auch ohne C-Kenntnisse gelesen werden kann. Kapitel 4 erläutert alle abgeleiteten Datentypen wie Pointer, Referenzen, dynamische Variablen und Reihungen (Array). Kapitel 5 erklärt die vielfältigen Operatoren, Ausdrücke und Anweisungen sowie den Begriff des Namensraums. Kapitel 6 diskutiert die Kontrollstrukturen; d.h. bedingte Anweisungen, Wiederholungsanweisungen und Transferfunktionen. Kapitel 7 zeigt alles über Funktionen, Rekursivität, Wertübergabe, Default-Argumente, Inline- und Template-Funktionen. Kapitel 8 liefert die Grundlagen für das Programmieren von Klassen, Konstruktoren, Destruktoren, statischen Elementen, Überladen von Operatoren, Funktionszeigern und Friend-Klassen. Kapitel 9 vermittelt einen Einblick in Methoden des Software-Engineerings. Hier findet sich auch eine Einführung in die Unified Modeling Language (UML), die in Zukunft noch größere Bedeutung erlangen wird. Exemplarisch werden die KlassenDiagramme behandelt, die mit Hilfe der UML-Software »Together 4.0« erstellt wurden. Kapitel 10 zeigt die Grundzüge der in C++ eingebauten Ausnahmebehandlung. Kapitel 11 ist der einfachen und mehrfachen Vererbung gewidmet. Es werden die Zugriffsmöglichkeiten der abgeleiteten Klassen, virtuelle Funktionen und abstrakte Basisklassen diskutiert. Kapitel 12 vermittelt die Grundlagen der objektorientierten Ein- und Ausgabe. Kapitel 13 erörtert die vielfältigen Beziehungen von Klassen untereinander, insbesondere das Aggregat und die Komposition. Kapitel 14 erklärt die Programmierung von Template-Klassen an Hand der Datenstrukturen Stack, Warteschlange (Queue) und Polynom. Die Beschäftigung mit diesen Template-Klassen dient als Vorbereitung zum Arbeiten mit der Standard Template Library (STL).
Vorwort
13
Kapitel 15 beschäftigt sich ausführlich mit den generischen Datentypen (Containern) vector, deque, list, map, set und den Adaptern stack, priority_queue und queue. Kapitel 16 stellt umfassend dar, wie die Algorithmen der STL auf den generischen Datentypen der STL operieren können. Durch viele Beispiele wird das Arbeiten mit der Template-Klasse der komplexen Zahlen erläutert. Kapitel 17 liefert einige interessante grafische Anwendungen aus dem Bereich der Chaostheorie, fraktalen Geometrie und der komplexen Abbildungen. Gezeigt wird u. a. eine Julia-Menge und der bekannte Schmetterling von Hofstadter, der die fraktalen Eigenwerte von Bloch-Zellen im homogenen Magnetfeld mit Cantor-Eigenschaften grafisch demonstriert. Kapitel 18 bietet grundlegende Klassen zur Berechnung von Wahrscheinlichkeitsverteilungen und Prüfstatistiken. Kapitel 19 enthält wichtige Klassen zur Lösung von nichtlinearen Gleichungen, numerischer Integration von Integralen und gewöhnlichen Differentialgleichungen. Dabei werden auch neuere und weniger bekannte Algorithmen wie die Integration nach Dormand-Prince, die Richmond-Iteration und die adaptive Simpson-Integration behandelt. Das abschließende Kapitel 20 löst interessante physikalische Problemstellungen, u. a. die Gleichgewichtstemperatur der Erde, Lorenz-Gleichungen, Wärmestrahlung, Dampfdruckkurve, Eigenwertproblem eines eingespannten Stabs und das eingeschränkte Drei-Körper-Problem. Beiliegende CD-ROM enthält alle Musterlösungen der Übungsaufgaben und den Quellcode der im Buch enthaltenen nichttrivialen Programme. Damit Sie die CD-ROM nützen können, müssen Sie über ein entsprechendes Laufwerk verfügen. Der Quellcode der Programme kann mit allen Betriebssystemen wie Windows 95/98/NT, Linux, Solaris oder Apple gelesen werden. Zum Compilieren der C++-Programme wird ein neuerer C++-Compiler benötigt; ausführliche Hinweise zum Arbeiten mit Compilern finden sich zweiten Kapitel. Leser, die über eine Internet-Zugang verfügen, können sich den Borland-Compiler (Version 5.5) in der Kommandozeilenversion downloaden. Ebenfalls kostenlos im Internet verfügbar ist der GNU-C++-Compiler; er wird für alle Rechner mit dem Linux-Betriebsystem empfohlen. Es wird ausdrücklich darauf hingewiesen, dass es derzeit keinen Compiler gibt, der alle im Buch enthaltenen Programme compilieren kann; viele Compiler haben noch nicht alle Neuerungen der C++-Norm implementiert. Dem Verlag Addison-Wesley, nunmehr Teil des Verlags Pearson Education, danke ich für die Herausgabe der zweiten, wesentlich erweiterten Auflage dieses Buch im Rahmen der Serie Scientific Computing. Dadurch war es möglich, die gesamte Darstellung der neuen ISO C++-Norm anzupassen und den Umfang durch ca. 150 Seiten zur Standard Template Library erheblich zu steigern. Besonderen Dank schulde ich Frau Irmgard Wagner, die als Lektorin einen großen Verdienst an dem Zustandekommen des Buchprojekts hat und Frau Petra Kienle für das Korrekturlesen des Buches.
14
Vorwort
Danken möchte ich auch allen, die per E-Mail und Brief geholfen haben, Druckfehler publik zu machen, insbesondere Herrn Tiebel (Uni Potsdam), Herrn Lüer (Uni Hannover), Herrn Braeske (Uni Erlangen) und Herrn Mende (Bochum). Für Hinweise und (humorvolle) Kommentare ist der Autor dankbar. Er ist erreichbar unter der E-Mail-Adresse [email protected]. Dietmar Herrmann, Oktober 2000
1
Einleitung No turning back. Object-orientation is the future, and the future is here and now. E. Yourdon
Object-oriented programming is an exceptionally bad idea which could only have originated in California. E.W. Dijkstra
1.1
Was ist OOP?
Das objektorientierte Programmieren (abgekürzt OOP) ist nur eine von mehreren Programmiermethoden, die in der Informatik seit der Einführung von FORTRAN entwickelt worden sind. 왘 Der prozedurale Programmierstil ist durch die Programmiersprachen FORTRAN
und ALGOL eingeführt worden und dient hauptsächlich dem Rechnen im technisch-naturwissenschaftlichen Bereich. Ein Programm besteht hier also aus einer bestimmten Zahl von Funktionen und Prozeduren. Ein Algorithmus sorgt dafür, dass diese Funktionen in geeigneter Weise abgearbeitet werden. Bekannte prozedurale Programmiersprachen sind Pascal und C. 왘 Der deklarative Programmierstil wird insbesondere durch die Programmiersprache
PROLOG vertreten. Ein deklaratives Programm besteht aus Fakten und logischen Regeln. Es wird nicht durch einen vorgegebenen Algorithmus gesteuert. Vielmehr versucht der Interpreter bzw. Compiler mit Hilfe des eingebauten Backtracking, eine oder mehrere Lösungen zu finden, die die Wissensbasis und Regeln erfüllen. Dieser Programmierstil ist sehr gut geeignet für die Implementierung von Expertensystemen. 왘 In der funktionalen Programmierung werden, meist mit Hilfe von Rekursion, benut-
zerdefinierte Funktionen auf einfache Listenoperationen zurückgeführt. Da rekursiv definierte Strukturen stets mit Hilfe von mathematischer Induktion charakterisiert werden können, eignen sich besonders mathematische Strukturen und Symbolverarbeitung zur funktionalen Programmierung. Ein Vertreter dieser Richtung ist das bereits Ende der fünfziger Jahre entstandene LISP. Die meisten der Computer-Algebra-Systeme von MUMATH bis MATHEMATICA besitzen einen eingebauten LISP-Interpreter, der die eingegebenen Terme symbolisch abarbeitet. 왘 Die objektorientierte Programmierung trennt nicht mehr Algorithmus und Daten-
struktur, sondern modelliert die Problemstellung durch interagierende Objekte, die bestimmter eingebauter Methoden fähig sind. Alle Aktivitäten gehen dabei von diesen Objekten aus, die wiederum andere Objekte durch Aussenden von geeigne-
16
1 Einleitung
ten Botschaften (messages) zu Aktionen bzw. Interaktionen auffordern. Objekte mit gleichem Verhalten werden in sog. Klassen definiert. Mit Hilfe solcher Objekte können komplexe Modelle der realen Welt nachgebildet werden; die eingesetzten Objekte lassen sich bausteinartig für ähnliche Probleme wiederverwenden (Modularität). Das Konzept der Klassen wurde von der Programmiersprache SIMULA 76 übernommen. Als erste rein objektorientierte Programmiersprache gilt SMALLTALK (1980). Die Programmiersprache C++ ist eine hybride Sprache, da sie neben der Objektorientierung aus Effizienzgründen auch prozedurales Programmieren erlaubt. Programmiermethode
Anwendung
typische Problemklassen
prozedural
Numerik
Berechnungen
funktional
Lambda-Kalkül
Formelmanipulation
deklarativ
automatisches Beweisen
Prädikatenlogik 1.Stufe
objektorientiert
Klassifikation
Objekthierarchien
Tab. 1.1: Hierarchie der Programmiermethoden
Seit der NATO Software-Engineering Konferenz 1968 in Garmisch war die SoftwareKrise in aller Munde. David Gries schrieb darüber in The Science of Programming: Die Informatiker sprachen offen über das Versagen ihrer Software und nicht nur über ihre Erfolge, um auf den Grund der Probleme zu kommen. Zum ersten Mal kam es zur generellen Übereinkunft, dass es wirklich eine Software-Krise gab und dass das Programmieren noch nicht gut verstanden war. Man hatte erkannt, dass die herkömmlichen Programmiersprachen und -methoden nicht mehr ausreichten, um große Software-Projekte in den Griff zu bekommen. Folgende Qualitätsnormen wurden im Software-Engineering angestrebt: 왘 Korrektheit (Software soll exakt die Anforderungspezifikation erfüllen) 왘 Erweiterbarkeit (Maß für die Fähigkeit, Software an erweiterte Spezifikationen anzu-
passen) 왘 Wiederverwendbarkeit (Maß für die Möglichkeit, Software teilweise oder vollständig
für neue Anwendungen wiederzuwenden) 왘 Kompatibilität (Maß für die Fähigkeit, verschiedene Software-Produkte miteinander
zu kombinieren) Es entstanden eine Vielzahl von Entwicklungsumgebungen, die in der Regel objektorientiert konzipiert sind und nun als CASE-Tools (Computer Aided Software Engineering) auf dem Markt erscheinen. Welchen Umfang Software-Projekte haben können, sieht man am Quellcode von Windows 95, der ca. 750.000 Zeilen umfasst. Die Software zur Steuerung der Mondlandung von APOLLO 11 umfasste dagegen nur ca. 300.000 Programmzeilen. Was ist nun ein Objekt genau? Nach Shlaer/Mellor (1988) lassen sich alle Objekte in folgende fünf Kategorien einteilen: 왘 Erfassbare Dinge (z.B. Auto, Bankkonto) 왘 Rollen (z.B. Chauffeur, Kontoinhaber)
Das OOP wird in der Literatur durch folgende Schlagworte gekennzeichnet: 왘 Geheimnisprinzip/ Datenkapselung (information hiding/ data encapsulation) 왘 Unterstützung abstrakter Datentypen (data abstraction) 왘 Vererbung (inheritance) 왘 Polymorphismus (polymorphism) 왘 Modularität (modularity) 왘 Wiederverwendbarkeit (reusability)
Abbildung 1.1: Prinzipien (Paradigma) der OOP
Dabei wurden die Konzepte Datenkapselung, Vererbung und Polymorphismus bereits 1965 von O.J. Dahl und K. Nygard für die Programmiersprache SIMULA erdacht, die später dann SIMULA 67 genannt wurde. Zu dieser Zeit war das Schlagwort Objektorientierung aber noch gar nicht geprägt! Unter Datenkapselung versteht man das Abschirmen eines Objekts und seiner Methoden nach außen. Die Kapselung ermöglicht es, Elemente und Funktionen einer Klasse gegen einen Zugriff von außen, z.B. von einem anderen Objekt, zu schützen. Bei der Vererbung können bestimmte Methoden des Objekts ausgeschlossen werden. Die Kapselung ist eine Anwendung des allgemeineren Geheimnisprinzips (information hiding), das 1972 von D. Parnas postuliert wurde: Jedes Programmmodul soll nach diesem Prinzip so wenig wie möglich über seine interne Arbeitsweise aussagen. Unter Vererbung versteht man die Fähigkeit eines Objekts, Eigenschaften und Methoden automatisch von einem anderen Objekt zu erben. Kann ein Objekt nur von einem anderen erben, so spricht man von einfacher Vererbung (single inheritance). Können jedoch Eigenschaften von mindestens zwei Objekten geerbt werden, so handelt es sich
18
1 Einleitung
um mehrfache Vererbung (multiple inheritance). Die vererbende Klasse heißt auch Basisklasse, die erbende Klasse abgeleitet. Unter Polymorphismus versteht man die Fähigkeit, dass verschiedene Objekte auf dieselbe Methode mit unterschiedlichen Aktionen reagieren können. In der Sprache von C++ heißt das, Objekte, die über einen Zeiger auf die Basisklasse angesprochen werden können, agieren so, als wären sie vom Typ der abgeleiteten Klasse (sofern sie vom Typ der abgeleiteten Klasse sind). Diese Zeiger können während eines Programmlaufs mehrere Werte annehmen. Der Compiler kann erst zur Laufzeit entscheiden, zu welchem Objekt die jeweils aufgerufene Methode gehört. Dieser Prozess heißt dynamisches Binden (dynamic binding) oder spätes Binden (late binding). Modularität bedeutet im Allgemeinen Wiederverwendbarkeit (reusability) und Erweiterbarkeit (extensibility). Wiederverwendbarkeit bedeutet, dass Programme aus bereits existierenden Software-Komponenten zusammengesetzt werden können. Mit Erweiterbarkeit ist gemeint, neue Software-Komponenten so zu schreiben, dass sie mit existierenden Programmen ohne Änderung kombiniert werden können. Nach B. Meyer umfasst die Modularität folgende Eigenschaften: 왘 Zerlegbarkeit (Systeme können in einzelne funktionierende Einheiten zerlegt wer-
den) 왘 Zusammenfügbarkeit (Einzelmodule können beliebig kombiniert werden) 왘 Verständlichkeit (Verstehen der Einzelmodule ermöglicht Verstehen des Ganzen) 왘 Kontinuität (kleine Änderungen des Systems bewirken nur eine kleine Änderung
im Verhalten der Software) 왘 Schutz (Ausnahmebehandlungen und Fehler wirken nur im verursachenden
Modul oder in seiner Nähe) Wiederverwendbarkeit ist aber in der Praxis nur schwer zu realisieren. E. Yourdon sagt darüber (1990): Wiederverwendbarkeit ist natürlich eine Pfadfindertugend, wie Treue, Tapferkeit und Mut; aber niemand weiß so richtig, wie man diese in die Tat umsetzen kann. Die prozedurale und objektorientierte Programmierung haben gewisse Ähnlichkeiten. Nach N. Wirth (1990) lassen sich folgende Analogien finden prozedural
objektorientiert
Datentyp
Klasse/Abstrakter Datentyp
Variable
Objekt/Instanz
Funktion/Prozedur
Methode
Funktionsaufruf
Senden einer Botschaft Tab. 1.2: Analogie prozedurales/objektorientiertes Programmieren
Alle Aspekte des prozeduralen Programmierens sind zu einem gewissen Grad auch im OOP vertreten. Anstatt weitgehend vom Datentyp unabhängige Funktionen und Prozeduren zu entwickeln, definiert man geeignete Methoden, die nun direkter Bestandteil der speziellen Objektklassen sind. Während man früher überlegt hat, durch welche Datenstrukturen ein Algorithmus optimal implementiert werden kann, werden Klassen bereits bei der Definition so konzipiert, dass sie der Struktur des Objekts und seines Datenaustausches entsprechen.
1.2 Warum C++?
19
Den Vorteilen des OOP stehen nur wenige Nachteile gegenüber, wie verstärkter Ressourcenbedarf, größere Planungskomplexität und komplizierte Entwurfstechniken. Neuere OOP-Programmierumgebungen mit grafischer Benutzeroberfläche (GUI, englisch Graphical User Interface) erfordern mehr Maschinenressourcen als textorientierte Editoren. Das erste GUI wurde im Xerox PARC (Palo Alto Research Center) entwickelt, von wo auch das SMALLTALK-Interface WIMP (Windows, Icons, Menus, Pointers) stammt. Als Vorstufen des OOP betrachtet man die objektorientierte Analyse (OOA) und das objektorientierte Design (OOD). Hier wurde eine Vielzahl von Entwurfstechniken entwickelt, die nun in der Unified Modelling Language (UML) zusammengefasst worden sind (mehr darüber in 9.3). Compiler und Codierung des OOP sind komplexer und anspruchsvoller geworden. Der Parser von C++ ist doppelt so groß wie der von ADA. C++ kann daher als die komplexeste Programmiersprache angesehen werden. Das Zusammenwirken von Objektklassen muss sorgfältig geplant werden, damit die Vererbung von Methoden und die Wiederverwendbarkeit von Klassen in gewünschter Weise genützt werden können. M. Terrible schreibt (1994): Objektorientierte Methoden versprechen mehr Erfolg für Programmierer und Software-Ingenieure. Lernen und Anwenden von C++ ist nicht dasselbe wie der Entwurf von objektorientierter Software. Ohne grundlegendes Wissen über Planung und Design von objektorientierten Systemen kann C++ nur begrenzten Erfolg haben. Das Programmieren in C++ steigert die Anstrengungen, die Systemspezialisten und Designer erbringen müssen, damit die Analyse stichhaltig, die Architektur überschaubar und das Design begründet ist und nicht zufällig geschieht.
1.2
Warum C++?
Die Grundlagen von C++ (damals noch unter dem Namen C mit Klassen) wurden 1979 bis 1984 von dem aus Dänemark stammenden Informatiker Bjarne Stroustrup bei den Bell Laboratories von AT&T gelegt. Damals, zu Beginn der achtziger Jahre, gab es eine Vielzahl von Bestrebungen, neue rein objektorientierte Sprachen – unabhängig von den bisherigen Programmiersprachen – zu entwickeln. Eine dieser Sprachen ist EIFFEL, das Mitte der achtziger Jahre von Bertrand Meyer konzipiert wurde. Eine Frühform von EIFFEL wurde in Object-Oriented Software Construction (1988) vorgestellt. Der endgültige Entwurf von EIFFEL wurde erst 1992 von Meyer publiziert in EIFFEL: The Language. Dieser Entwurf verfügt über mehr OOP-Konzepte als C++. Vom Standpunkt des reinen OOP gesehen, wäre eine ausschließlich objektorientierte Programmiersprache wie SMALLTALK oder EIFFEL vorzuziehen. Als Stroustrup sah, dass für SMALLTALK (Entwurf Alan Kay) nur ein Interpreter, für EIFFEL nur ein Entwurf existierte, wurde ihm klar, dass diese Sprachen nicht als Basis seines Projekts dienen konnten. Auch von der Vielzahl weiterer Programmiersprachen – darunter MODULA 2, ADA, MESA und CLU –, die Stroustrup in dieser Zeit studierte, konnte keine seinen Anforderungen genügen. So fügte er Teile von SIMULA 76, ALGOL 68 und C zu einem neuen Entwurf zusammen. Eine solche hybride Sprache war neben der Objektorientierung ebenfalls zum prozeduralen Programmieren geeignet.
20
1 Einleitung
Der Schwachpunkte der Programmiersprache C war sich Stroustrup sehr wohl bewusst, er erntete in diesem Zusammenhang viel Kritik, der Erfolg gab ihm aber Recht. Für C sprachen insbesondere folgende Punkte: 왘 Flexibilität 왘 Effizienz 왘 Verfügbarkeit 왘 Portabilität
C war sowohl zur Systemprogrammierung – wie UNIX zeigt – als auch zum technischnaturwissenschaftlichen Rechnen geeignet. Ferner unterstützte es – im Gegensatz etwa zu PASCAL – die Software-Entwicklung durch separates Compilieren. C erlaubte es, durch Bitoperationen teilweise auf Maschinenebene zu programmieren und sich damit jeder Rechnerarchitektur anzupassen. C war wie keine andere Sprache auf allen Betriebssystemebenen vertreten, insbesondere auf Mehrbenutzeranlagen wie UNIX. Außerdem galt C als portabel; durch die Einführung von Bibliotheksfunktionen konnten z.B. alle Ein- und Ausgaben maschinenunabhängig beschrieben werden. Stroustrup schreibt: A programming language serves two related purposes: it provides a vehicle for the programmer to specify the action to be executed and a set of concepts for the programmer to use when thinking about what can be done. Diese beiden Aspekte können auch charakterisiert werden als die Grundphilosophie von C: 왘 Nahe-an-der-Maschine 왘 Nahe-am-Problem
Der Schwachpunkt von C (vor der ANSI C-Norm), die fehlende Typenprüfung, sollte in C++ durch striktes Type-Checking überwunden werden. Da C++ weitgehend abwärtskompatibel ist, erfordert das saubere objektorientierte Programmieren in C++ erhöhte Programmierdisziplin. Für C++ spricht ferner die Möglichkeit, ein reines C-Programm mit einem C++-Compiler zu übersetzen. Davon wird von Programmierern häufig Gebrauch gemacht (nach dem Motto C++ as better C).
1.3
Die Entwicklung von C++
Bjarne Stroustrup kannte durch sein Studium in Cambridge die Programmiersprachen SIMULA 76 und ALGOL 68 gut. Nach seinem Eintritt in die Bell Laboratories in London hatte er sich intensiv mit C zur Systemprogrammierung befasst. Als er im Frühjahr 1979 zu AT&T (New Jersey) wechselte, wurde er mit der Aufgabe betraut, den Kern von UNIX so zu modifizieren, dass das System auch in einem lokalen Netzwerk lief. Als Folge seiner Arbeit erschien 1980 die erste Publikation über C mit Klassen in einem internen Bericht von Bell Labs. Dieser Report sah folgende Eigenschaften vor: 왘 Klassen (mit privatem/öffentlichem Zugriff) 왘 Einfache Vererbung (aber noch keine virtuelle Funktionen)
Das Klassenkonzept übernahm Stroustrup aus SIMULA 76, das Operator-Überladen entlehnte er aus ALGOL 68. Der 1982 erschienene SIGPLAN-Report Adding Classes to the C Language: An Exercise in Language Evolution umfasste zusätzlich noch 왘 Inline-Funktionen 왘 Default-Argumente 왘 Überladen des Zuweisungsoperators
1984 erkannte Stroustrup, dass der Erfolg seiner Bemühungen nur eine eigenständige Sprachentwicklung sein konnte und nicht eine Ergänzung einer existierenden Sprache. Diese Entwicklung wurde zunächst C84, später dann, auf Vorschlag seines Kollegen R. Mascitti, C++ genannt. Zur weiteren Verbreitung musste natürlich ein geeigneter (Prä-)Compiler bereitgestellt werden. Die Version 1.0 von Cfront erschien 1985 und umfasste ungefähr 12.000 Programmzeilen C++. Die weiteren Cfront-Versionen bis 3.0 erschienen bis September 1991. Die Version 1.0 war in der 1986 erschienenen Sprachendefinition The C++ Programming Language zugrunde gelegt. Mit der Version 2.0 wurde C++ u.a. um folgende Funktionen erweitert 왘 Mehrfache Vererbung 왘 Abstrakte Klassen 왘 Statische und konstante Elementfunktionen
Die Version 2.1 wurde durch das Referenzwerk: The Annotated C++ Reference Manual (ARM genannt) im April 1990 festgelegt. Hierbei wurde noch folgenden Eigenschaften hinzugefügt 왘 Templates 왘 Virtuelle Funktionen 왘 Ausnahmen (Exceptions) 왘 Verbesserte Ein-/Ausgabe (mittels iostream)
Eine modifizierte Version des Cfront 3.0-Compilers, die im September 1992 von Hewlett-Packard verschickt wurde, unterstützte diese Neuerungen, einschließlich den Ausnahme-Behandlungen (exceptions handling). Diese Version ist praktisch von allen Compilerherstellern übernommen worden, u.a. von Apple, Centerline, Glockenspiel, PARC Place und SUN. Das im Dezember 1989 ins Leben gerufene ANSI C++-Komitee sorgte für eine gute Abstimmung mit der gleichzeitig entstehenden ANSI C-Norm. Für die Zusammenarbeit galt die Devise As Close to C as possible – but not closer. Die Liste der 1990 im ANSI C++-Komitee vertretenen Firmen liest sich wie das Who is Who der Computerindustrie:
22
1 Einleitung
Amdahl, Apple, AT&T, Borland, British Aerospace, CDC, DEC, Fujitsu, Hewlett-Packard, IBM, Los Alamos National Labs, Lucid, Microsoft, NEC, Prime Computer, SAS, Siemens, Silicon Graphics, Sun, Texas Instruments, UniSys, Wang, Zortech u.a. Bereits im Mai 1990 hatte Borland einen C++-Compiler in die MS-DOS-Welt gebracht, von dem bis zur COMDEX 1993 mehr als eine Million Exemplare verkauft wurden (Zortech hatte seit 1988 einen C++-Compiler für MS-DOS angeboten). Microsoft zog erst im März 1992 durch die Übernahme des Glockenspiel-Compilers nach. Auf der UNIX-Seite sind die Compiler der USENIX-Gruppe, GNU C++ (seit Dezember 1987) zu nennen. Für X-Windows Systeme gibt es die komfortablen Versionen von SUN und Centerline. 1993 wurden folgende Erweiterungen vom ANSI/ISO C++-Komitee beschlossen: 왘 Run-Time Type Identification (RTTI) 왘 Namensraum (namespace)
Ein wesentlicher Schritt war auch die Akzeptanz der von Alexander Stepanow und Meng Lee bei Hewlett Packard entwickelten Standard Template Library (STL), die 1994 weitgehend, aber nicht ganz, vom ANSI/ISO-Komitee verfügt wurde. In die kommende Norm wurden schließlich noch neue Operatoren zur Typumwandlung aufgenommen wie 왘 static_cast, dynamic_cast 왘 const_cast, reinterpret_cast
Am 26. September 1995 wurde dann endlich der lang erwartete Normenentwurf Draft ANSI/ISO C++ Standard beschlossen. Nach langen Verhandlungen dauerte es dann noch fast drei Jahre, bis am 27. Juli 1998 die ANSI C++-Norm, international als ISONorm 14882:1998, endgültig beschlossen war. Die Publikation umfasst 774 Seiten und ist elektronisch als *.pdf-File verfügbar. Momentan sammelt das ISO-Komitee über die nationalen Komitees und User-Gruppen Fehler-Reports (defect reports), die über Unklarheiten, Widersprüche oder Auslassungen der Norm Auskunft geben. In etwa zwei Jahren werden dann alle, vom Komitee akzeptierten Reports, als Technical Corrigendum (TC) publiziert. Die Publikation zweier solcher TC ist möglich; erst nach 2003 kann es zu einer letzten Bewertung (final review) der ISO-Norm kommen. Damit war nach fast zehnjähriger Arbeit die umfangreichste internationale Normierung zu einem Schlusspunkt gekommen. Alle Programmierer und Software-Firmen haben nun die Sicherheit, dass ein der Norm entsprechendes C++-Programm nie mehr geändert werden muss! Für viele Informatiker ist der Umfang von C++ bereits zu groß. Stroustrup war stets darauf bedacht, den Umfang von C++ in Grenzen zu halten. Er wollte damit vermeiden, dass C++ nur noch von einer Handvoll Spezialisten verstanden wird und damit das Schicksal von ALGOL 68 teilt. Wie das OOP-Konzept in C++ realisiert wurde, zeigt die Tabelle im Überblick.
1.4 Die Weiterentwicklung von C
OOP-Konzept
Realisation in C++
Klasse
class
Instanz
Variable oder Objekt vom Typ class
Instanzvariable
class-Komponente
Klassenvariable
statiche class-Komponente
Methode
class-Komponentenfunktion
Klassenmethode
statische class-Komponentenfunktion
Protokoll
Deklaration aller Methoden
Vererbung
einfache/mehrfache Vererbung
23
Abstrakte Klasse
Klasse mit rein virtuellen Methoden
Parametrisierte Typen
Templates
Polymorphismus
Überladen von Funktionen/Operatoren, virtuelle Funktionen
Dynamisches Binden
virtuelle Funktionen Tab. 1.3: Realisierung der OOP-Konzepte in C++
1.4
Die Weiterentwicklung von C
Die ANSI/ISO C-Norm war Ende 1989 verabschiedet worden und wurde als ISO/IEC 9899:1900 publiziert. Damit war die Arbeit der ANSI C++-Gruppe keineswegs beendet. Die Bemühungen um eine Internationalisierung führten 1995 zu einer Ergänzung der C-Norm, dem Amendment 1, das zum ersten Mal auch internationale Zeichensätze in C einführte. Dazu erschien eine eigene Norm ISO/IEC 10646, die den Universal Multiple Octet Coded Character Set (UCS) definiert. Auf diese Norm geht die HeaderDatei zurück, die dann auch in C++ übernommen wurde. Java war also keineswegs die erste Programmiersprache, die internationale Zeichensätze unterstützte. Auch ein weiteres internationales Komitee NCEG (Numerical C Extensions Group) beschäftigte sich mit der Weiterentwicklung von C; insbesondere strebte man eine Standardisierung des Parallelrechnens und der Fließpunkt-Arithmetik an. Für das numerische Rechnen wurde ein eigener doppelt-genauer komplexer Zahlentyp long double complex entwickelt. Auch die Diskussionen und Arbeitspapiere des ANSI C++-Komitees wurden von der C-Gruppe zur Kenntnis genommen. Über ein Jahr diskutierte eine Untergruppe, ob und wie man objektorientierte Eigenschaften von C++, wie einfache Vererbung, Zugriffskontrolle und virtuelle Funktionen, in C zulassen sollte. Aus verschiedenen Gründen wurde dies aber später verworfen. Viele (nicht objektorientierte) Sprachelemente von C++ wurden aber als nützlich erkannt, unter anderem die Form der Kommentierung (mittels //), der bool-Typ (Schlüsselwort _BOOL), der sizeof-Operator und anderes mehr. Alle diese Bemühungen mündeten in der Verabschiedung einer neuen Norm ANSI/ ISO 9899:1999 für die nunmehr C99 genannte Programmiersprache. Wichtige Neuerungen von C99 sind:
24
1 Einleitung
왘 Neue Datentypen wie long long, long double complex und long double imaginary. 왘 Erweiterte int-Typen wie int32_t. 왘 Erweiterte Möglichkeiten bei der Fließpunkt-Arithmetik (Rundungsregeln, Aus-
nahmefälle). 왘 Übernahme von C++-Elementen wie const, sizeof und der Blockstruktur der Kon-
trollstrukturen; Letzteres erlaubt nun Definitionen innerhalb der FOR-Schleife. 왘 Arrays mit variabler Länge. 왘 Verkürzte Initialisierung von Strukturen.
1.5
Java als C++-Derivat Java is a new object-oriented programming language developed at Sun Microsystems to solve a number of problems in modern programming practice and to provide a programming language for the Internet. Java Homepage der Fa. SUN
1990 hatte der Software-Ingenieur J. Gosling begonnen, im Auftrag der Fa. SUN unter dem Projektnamen OAK eine neue objektorientierte Sprache zu entwickeln. Die Vorgaben waren 왘 einfache Entwicklungsmöglichkeiten von Applikationen 왘 Stabilität und Zuverlässigkeit von Programmen 왘 Plattformunabhängigkeit
Als Syntaxbasis wurde ein abgespecktes C++ verwendet. Das OAK-Projekt wurde 1994 auf Eis gelegt, da die Medienkonzerne wie Time Warner kein Interesse zeigten. Aus urheberrechtlichen Gründen wurde OAK in Java umbenannt. Als jedoch im Herbst gleichen Jahres P. Naughton und J. Payne einen WWW (World Wide Web)-Browser für SUN entwickelten, konnten sie auf die Grundlagen von OAK zurückgreifen. Im Januar erhielt dieser Browser den Namen HotJava. Die Möglichkeiten, die der Browser bot, waren bahnbrechend, sodass SUN das Java-Projekt als ClientServer-Technologie wieder aufnahm. Um Java populär zu machen, stellte SUN die Programmiersprache im Mai 1995 ins Internet. Dort gewann diese Sprache bei Internet-Surfern und Computerzeitschriften eine ungeheure Popularität, erlaubt Java doch neben separaten Programmen (Applikationen) auch die Ausführung eines so genannten Applets innerhalb einer WWW-Seite. Dieses Applet kann sogar an irgendeiner Internet-Adresse (URL, englisch Uniform Resource Locator) vorliegen. Hinzu kommt, dass nicht einmal ein Compiler notwendig ist; die Interpretation übernimmt ein passender Internet-Browser. Die damit eröffneten Möglichkeiten der Interaktivität und Animation gehen weit über das hinaus, was die Seitenbeschreibungssprache HTML (HyperText Markup Language) vermag. HTML ist ein Derivat der von IBM entwickelten General Markup Language (GML), die später als SGML (Standard General Markup Language) zur ISO-Norm erhoben wurde.
1.5 Java als C++-Derivat
25
Java erhielt von C++ die Syntax, die Kontrollstrukturen (bis auf goto) und die einfachen Datentypen (bis auf char, enum, struct, union). Für den Zeichensatz wurde der 16Bit-Uni(-versal)code gewählt, der aus HTML bekannt ist. Damit wurde das leidige Problem der westeuropäischen Umlaute und der asiatischen Sprachen gelöst. Alle einfachen Datentypen wurden einheitlich als vorzeichenbehaftet (signed) festgelegt, die Bytezahl von int-Typen wurde auf vier fixiert. Ebenfalls übernommen wurden die einfache Vererbung und die Ausnahmefälle (exceptions). C-Funktionen wie malloc(), die einen direkten Speicherzugriff gestatten, wurden aus Sicherheitsgründen außer Kraft gesetzt. Nicht mehr enthalten sind die mehrfache Vererbung, die Template-Klassen und die Möglichkeit, nicht objektorientiert zu programmieren. Das folgende Beispiel eines Applet zeigt die formale Ähnlichkeit mit C++: import java.awt.*; import java.applet.*; public class KurveApp extends Applet // einfache Vererbung von Applet auf KurveApp { int f(double x) // Schwebungsfunktion {return (int)((Math.cos(x/5)+Math.sin(x/7)+2)*50); } public void paint(Graphics g) { for (int x=0; x<400; x++) g.drawLine(x,f(x),x+1,f(x+1)); } }
Die Bibliothek java.awt.* (Abstract Windows Toolkit) enthält die Klassen zur Grafik, Farbgebung, Schriftdeklaration wie: 왘 java.awt.Color; java.awt.Font; java.awt.Graphics; // usw.
In der Version 1.2 kommt eine sehr viel komfortablere Grafikbibliothek der JFC-SwingKlassen hinzu. Damit lassen sich in einfacher Weise Grafikprogramme auf den Bildschirm bringen. Will man über die vordefinierten Klassen des AWT bzw. des JFC-Swings hinausgehen, so muss ein rein objektorientiertes Package geschrieben werden, was dem Programmieraufwand von C++ nahe kommt. Ob Java einmal C++ ablösen wird, ist noch völlig offen. Jedoch zeigt die Lizenzübernahme von Java durch Firmen wie IBM und früher Microsoft, dass hier handfeste kommerzielle Interessen im Spiel sind. Die strikte Objektorientierung von Java erfordert es, dass alle Funktionen von Applikationen, wie auch main(), im Rahmen einer Klasse auftreten. Das berühmte »Hello World«-Programm lautet in Java:
26
1 Einleitung
Abbildung 1.2: Das Java-Applet KurveApp
// hello.java public class hello { static public void main(String args[]) { System.out.println("Hello World!"); } }
Die Klasse System zur Ausgabe wird hier automatisch geladen und braucht daher nicht importiert zu werden. Die Java-Interpreter erzeugen einen für alle Rechner einheitlichen Zwischencode auf einer sogenannten virtuellen Maschine, der dann vom jeweiligen Betriebssystem in Maschinencode umgesetzt wird. Java hatte in den ersten vier Jahren bis Mai 1999, dem Zeitpunkt der Freigabe von Java 1.2 – nunmehr Java 2 genannt –, einen ungewöhnlichen Erfolg, der zu viel Konkurrenz und Monopolbestrebungen unter den Software-Firmen führte. Wie bekannt, konnte die Fa. SUN durch einen Musterprozess die alleinige Kontrolle und damit das Monopol für Java einklagen. SUN verweigerte damit dem geplanten ISO-Komitee das Mitspracherecht, weil es fürchtete, dass die Vertreter der Firmen Microsoft, Intel und Hewlett Packard in diesem Gremium dominieren würden. Die Tragweite dieses Entschlusses lässt sich noch nicht abschätzen. Auch die Zusammenarbeit mit dem Verband ECMA der europäischen OEM-Hersteller, der ebenfalls das Recht hat, eine internationale Norm einzubringen, ist gescheitert. Die von SUN der ECMA vorgelegte Java-Spezifikation (Umfang 8400 Seiten) wurde zurückgezogen, als klar wurde, dass sich auch das europäische Gremium die Bedingungen nicht diktieren lässt. Eine internationale ISO-Norm für Java wird es also nicht geben. Einige Informatiker sehen daher die Gefahr, dass die Weiterentwicklung von Java in hohem Masse durch firmeninterne Interessen von SUN bestimmt sein könnte. Augenfällig ist die Steigerung des Sprachumfangs. Gegenüber der Version 1.0 hat sich der Umfang der offiziellen, gedruckten Dokumentation der Java 1.1 Class Libraries mit
verdoppelt. Mit der Einführung der umfangreichen JFC-Swing-Klassen, der 2D-Grafik-Bibliotheken und des Java Database Connectivity Package (JDBC) nimmt der Sprachumfang von Java 2 noch deutlich zu. Inzwischen existiert bereits der Entwurf von Java 1.3. Die weitere Entwicklung in den nächsten Java-Versionen ist noch nicht abzusehen. Microsoft hat inzwischen eine konkurrierende Programmiersprache C# (gesprochen C sharp) entwickelt, die integraler Bestandteil der nächsten Visual Studio Version 7.0 werden soll. Wie weit sich C# als Konkurrenzprodukt auf dem Markt etablieren wird, ist noch ungewiss. Das »Hello World«-Programm in C# lautet: using System; class Hello { public static void Main() { Console.writeLine("Hello World!"); } }
Informationen zu C# finden sich im Internet unter: http://msdn.microsoft.com/ vstudio/ nextgen.
2
Algorithmen und Compiler
In diesem Kapitel wird besprochen, wie man ein C++-Programm compiliert und welche Fehlermeldungen auftreten. Als Vorbereitung zum Programmieren werden einige einfache Algorithmen vorgestellt.
2.1
Wie man ein C++-Programm compiliert
Der Compiler übersetzt das Quellprogramm als Ganzes in Maschinensprache, im Gegensatz zum einem Interpreter, der den Quellcode Zeile für Zeile, oft in einem Zwischencode, interpretiert. Die meisten der gängigen Compiler enthalten in ihrer Entwicklungsumgebung (IDE – Integrated Development Environment) einen integrierten Editor, in dem der Quelltext eingegeben, bearbeitet und compiliert werden kann. In der Regel ist auch ein Debugger enthalten, mit dem das Abarbeiten des Programms zeilenweise verfolgt und die Variablenwerte überwacht werden können.
Abbildung 2.1: Übersetzung eines Quellcodes in ein ausführbares Programm
Anhand mehrerer Compiler soll das Erstellen eines »Hello World«-Programms gezeigt werden, das traditionell erste C++-Programm eines Programmierneulings.
2.1.1 Der Borland C++-Compiler (Version 5.02) Abb. 2.2 zeigt die Windows-Entwicklungsumgebung des Borland-Compilers, dessen letzte Versionsnummer 5.02 ist. Weitere Versionen wird es nicht geben, da der Compiler durch den C++Builder abgelöst wurde (vgl. 2.1.3). Es gibt jedoch seit Herbst 1999 die Compiler-Version 5.05, die im Kommandozeilenmodus, d.h. ohne grafische Benutzeroberfläche arbeitet. Diese Version wurde von Borland ins Internet gestellt und kann kostenlos heruntergeladen werden von der Adresse www.borland.com/bcppbuilder/freecompiler.
30
2 Algorithmen und Compiler
Für eine Einzeldatei wird im Menü File zunächst der Menüpunkt new und dann neue Textdatei ausgewählt. Im Editorfenster erscheint eine leere Datei noname.cpp. In dieses Textfenster tippt man das berühmte »Hello World«-Programm ein. Es lautet: // hello.cpp #include int main() { cout << "Hello World!" << endl; return 0; }
Zum Compilieren wählt man im Menüpunkt Projekt den Punkt Compile. Es erscheint kurzzeitig ein Konsolenfenster mit der Ausgabe »Hello World!«, das sofort wieder verschwindet. Ist dies nicht der Fall, so haben Sie sich vielleicht vertippt und der Compiler zeigt im Message-Fenster eine Fehlermeldung.
Abbildung 2.2: Benutzeroberfläche des Borland C++-Compilers 5.02
Damit das Fenster sich nicht gleich wieder schließt, gibt man vor der return-Anweisung noch getch(); ein, damit das Betriebssystem auf eine Eingabe wartet. Nach dem Drücken einer Taste schließt sich das Konsolenfenster wieder. Damit der Compiler diesen Befehl erkennt, muss noch die Header-Datei eingebunden werden. Damit ist der Quellcode unseres Programms:
2.1 Wie man ein C++-Programm compiliert
31
// hello.cpp #include #include // nicht Standard //using namespace std; int main() { cout << "Hello World!" << endl; getch();// nicht Standard return 0; }
Die Anweisung using namespace std; ist hier als Kommentar eingefügt. Sie ist aber notwendig für neuere Compiler, damit die Schlüsselwörter cout und endl erkannt werden. Die Borland-Compilerversion 5.02 benötigt hier diese Anweisung nicht.
2.1.2 Der Microsoft Visual C++-Compiler Die Entwicklungsumgebung (Version 6.0) des Visual C++-Compilers zeigt Abb. 2.3. Zum Erstellen eines Einzelprogramms klicken Sie im Menü Datei den Punkt Neu.. an. Es erscheint ein weiteres Fenster, in dem Sie erst das Menü Dateien und dann C++Quellcodedatei wählen. Der Editor zeigt Ihnen ein leeres Fenster namens Cpp1. Dort tippen Sie das Programm ein wie folgt: // hello.cpp #include using namespace std; int main() { cout << "Hello World!" << endl; return 0; }
Nach erfolgter Eingabe klicken Sie im Menü Erstellen den gleichnamigen Menüpunkt Erstellen an. Das System fragt, ob die neue Datei gespeichert und ob ein Standard-Projekt-Arbeitsbereich angelegt werden soll. Durch Anklicken von ja, beginnt der Compilerlauf. Die Meldungen des Compiler sind im Erstellen-Fenster zu sehen. Bei erfolgreicher Übersetzung erscheint ein Konsolenfenster mit der Bildschirmausgabe, das mit einem Tastendruck geschlossen werden kann. Falls Sie den Quellcode bereits erstellt haben, können Sie den Menüpunkt Datei öffnen anklicken. Es erscheint ein Fenster mit dem Dateisystem Ihres Rechners. Dort klicken Sie den Namen der vorbereiteten Datei an; diese wird dann in den Editor geladen. Das Compilieren verläuft wie eben beschrieben. Durch Anklicken des Menüpunkts Arbeitsbereich schließen wird das Programm beendet.
32
2 Algorithmen und Compiler
Abbildung 2.3: Benutzeroberfläche des Microsoft Visual C++ Compilers 6.0
2.1.3 Der Inprise C++Builder Nach dem Starten des C++Builders (hier Version 5.0) erscheint ein leeres Projekt mit zwei Fenstern, dem leeren Formular Form1 und der teilweise vorgegebenen Datei Unit1.cpp. Um ein Programm im Konsolenfenster zu erstellen, beenden Sie das Projekt durch Anklicken von All close im Menü File. Durch Wählen des Menüpunkts New im Menü File erscheint das Fenster New Items, in dem Sie das Icon Console expert anklicken. Es erscheint ein weiteres Fenster mit der Voreinstellung Console und exe-File. Nach der Bestätigung öffnet sich das Editorfenster mit einer teilweise vorgegebenen Datei Projekt1.cpp. In der main()-Funktion tippen Sie das »Hello World«-Programm ein und ergänzen oben noch die include und die using-Anweisung, Letztere ohne Kommentarzeichen. Das Programm zeigt sich wie folgt: #pragma hdrstop #include #include using namespace std; //-------------------------------#pragma argsused int main(int argc, char* argv[]) {
2.1 Wie man ein C++-Programm compiliert
33
cout << "Hello World! " << endl; return 0; }
Abbildung 2.4: Benutzeroberfläche des Inprise C++Builders 5.0
Abschließend speichern Sie das Projekt unter einem Namen, z.B. hello. Das Editorfenster zeigt dann entsprechend den Programmnamen hello.cpp. Durch Anklicken des grünen Dreiecks wird das Projekt compiliert. Das Konsolenfenster erscheint kurzzeitig mit der Bildschirmausgabe und verschwindet wieder. Durch Eingabe von getch(), wie in 2.1.1 beschrieben, kann das Konsolenfenster angehalten werden. Durch den Menüpunkt Alle schließen wird das Programm beendet. Die Hauptaufgabe des C++Builders ist natürlich die Erstellung eines Programms mit einer grafischen Benutzeroberfläche (siehe Abb. 2.4). Dazu erstellen Sie ein neues Projekt. Mit der Maus ziehen Sie das Icon mit dem großen »A« (Label) auf das leere Formular und erzeugen damit einen rechteckigen Rahmen. Der Rahmen trägt den Defaultnamen Label1. Hineinklicken in den Rahmen lässt im Objektinspektor das Feld Caption erscheinen. In dem nebenstehenden Textfeld mit Vorgabe Label1 geben Sie »Hello World!« ein. Die Schriftgröße können Sie im Feld Font des Objektmanagers ändern, z.B. auf 30 Punkte. Gleichzeitig können Sie im Font-Menü auch die gewünschte Schriftfarbe auswählen, z.B. Blau. Schieben Sie nun das Formular mit der Maus auf die gewünschte Größe, fertig ist Ihr Windows-Programm! Durch Klicken auf das grüne Dreieck wird das Projekt compiliert und gleichzeitig ein lauffähiges Programm erzeugt.
34
2 Algorithmen und Compiler
2.1.4 Der GNU C++-Compiler Der GNU Compiler existiert in einer UNIX-, Linux-, WIN 95/98/NT- und DOS-Version und hat keinen eigenen Editor. Die jeweils neueste Version kann im Internet heruntergeladen werden, unter www.gnu.org/software/gcc . Sehr wichtig ist hier die richtige Pfadeinstellung. Falls der Compiler im Verzeichnis C:\cygnus liegt, müssen folgende Umgebungsvariablen in der autoexec.bat gesetzt
werden: set gcc_exec_prefix=C:\cygnus\H-i386-cygwin32\lib\gcc-lib\ set path=%path%;C:\cygnus\H-i386-cygwin32\bin
Zunächst gibt man in einem Texteditor des jeweiligen Betriebssystems das »Hello World«-Programm ein und speichert es unter dem Namen hello.cpp oder hello.cc. Komfortabler ist der Einsatz eines Editors, der direkt den Compiler aufrufen kann. Durch den Compileraufruf g++ hello.cpp gcc hello.cc // für C-Programme
wird das Programm compiliert und eine ausführbare Datei erzeugt: a.out (UNIX) bzw. a.exe (DOS). Da diese Dateien beim nächsten Compilergang überschrieben werden, müssen diese – falls noch benötigt – umbenannt werden; entweder mittels ren unter DOS oder mv unter UNIX. Der gewünschte Dateiname kann auch gleich beim Compilieren vergeben werden durch: g++ hello.cpp -o hello
Soll die Datei nur compiliert, aber nicht gelinkt werden, so ist einzugeben g++ hello.cpp -c g++ hello.cpp -ansi -c // strikte ANSI-Kompatibilität
Falls mehrere Programme zu einem Projekt zusammen compiliert werden sollen, ist die Kommandozeile g++ programm1.cpp programm2.cpp ... -o projekt
Bei Aufruf von hello.out oder hello.exe erscheint die gewünschte Ausgabe in einem Konsolenfenster (siehe Abb. 2.5). Für die Erstellung von Windowsprogrammen sind noch weitere Bibliotheken einzubinden, beispielsweise: gcc winprog.cpp -lkernel32 -luser32 -lgdi32
2.2 Programmfehler
35
Abbildung 2.5: Aufruf des GNU-Compilers im DOS-Fenster
2.2
Programmfehler
Beim Eingeben und Ausführen von Programmen kommt es oft zu Fehlern und Fehlermeldungen. Die wichtigsten Fehler, die beim Compilieren und zur Laufzeit auftreten, werden hier besprochen.
2.2.1 Syntax-Fehler Vergisst man im »Hello World«-Programm ein Anführungszeichen oder vertippt man sich bei einem Schlüsselwort // hello.cpp #include using namespace std; int main() { count << "Hello World! << endl; // Syntax-Fehler return 0; }
36
2 Algorithmen und Compiler
so erhält man eine Fehlermeldung des Compilers. Hier wird count statt cout als nicht definierte Variable, die Zeichenkette "Hello World! als nicht beendet (unterminated string) angesehen.
2.2.2 Link-Fehler Ein Link-Fehler tritt auf, wenn der Binder (Linker) nach dem Compilerlauf nicht alle benötigten Programmteile oder Bibliotheksfunktionen einbinden kann. Im Programm // hello.cpp int main() { cout << "Hello World!" << endl; return 0; }
erhält man eine Fehlermeldung, da die Ausgabe mit cout ohne Headerdatei nicht erkannt wird.
2.2.3 Laufzeitfehler Ein Laufzeitfehler (run time error) tritt auf, wenn ein erfolgreich compiliertes Programm ein Ergebnis außerhalb des vom Rechner darstellbaren Zahlbereichs hat oder eine nicht definierte mathematische Operation durchführen oder auf eine nicht existierende Datei bzw. auf ein nicht vorhandenes Laufwerk zugreifen soll. int main() { int x = 200; // 2 Byte cout << (x*x) << endl; // Overflow bei INT_MAX=32565 return 0; }
2.2.4 Rundungsfehler Rundungsfehler treten bei allen numerischen Rechenoperationen auf, da der Rechner nur mit einer beschränkten Stellenzahl rechnet. Bei Rundungsfehlern gelten die in der Schule gelernten Rechenregeln nicht mehr. Nach den binomischen Formeln gilt:
x2 − y2 = x+y x−y Das Programm liefert aber rundungsbedingt nicht den exakten Wert 2,000001. int main() { float x=1.000001,y=1.0;
2.3 Einfache Algorithmen
37
cout << (x*x-y*y)/(x-y) << endl; return 0; }
2.2.5 Verfahrensfehler Verfahrensfehler treten in einem Programm auf, wenn ein Algorithmus prinzipiell nur einen Näherungswert liefert. Dies ist stets der Fall bei der Diskretisierung, wenn ein infinitesimaler Prozess, z.B. eine Grenzwertbildung, durch endlich viele Rechenschritte ersetzt werden muss.
2.2.6 Programmierfehler Programmier- oder logische Fehler sind Resultate falscher Programmierung, die vom Compiler nicht entdeckt werden. Ein Beispiel ist die Division durch 2a. int main() { float x=3,a=1.2; cout << (x/2*a) << endl; // Fehler statt x/(2*a) return 0; }
Hier liefert das Programm infolge des Programmierfehlers 1,8 statt des beabsichtigten 1,25.
2.3
Einfache Algorithmen
Ein Algorithmus ist ein Verfahren zur Lösung eines Problems, das von einer Maschine ausgeführt werden kann. Jedes Computerprogramm stellt somit (mindestens) einen in einer Programmiersprache formulierten Algorithmus dar. Algorithmen haben im Allgemeinen folgende Eigenschaften: 왘 Allgemeinheit: Ein Algorithmus löst im Allgemeinen eine ganze Klasse von Proble-
men, z.B. alle quadratischen Gleichungen mit reeller Lösung. 왘 Determiniertheit: Ein Algorithmus ist in der Regel determiniert. Das bedeutet, bei
gleichen Anfangs- und Startbedingungen liefert er stets dieselbe Lösung. Ausnahmen sind hier z.B. Monte Carlo-Algorithmen, die Zufallszahlen verwenden. 왘 Determinismus: Ein Algorithmus heißt deterministisch, wenn die Wirkung und die
Reihenfolge der Einzelschritte eindeutig festgelegt ist. 왘 Terminierung: In der Regel sind nur solche Algorithmen von Interesse, die für jede
Eingabe nach endlich vielen Schritten terminieren, d.h. anhalten. Einige bekannte Algorithmen werden im Folgenden vorgestellt. Das Nachvollziehen eines Algorithmus mit Bleistift und Papier ist eine gute Übung zum »algorithmischen Denken« als Vorstufe des Programmierens.
38
2 Algorithmen und Compiler
2.3.1 Heron-Algorithmus Das Heronsche Verfahren war bereits den Babyloniern bekannt, es berechnet die Wurzel einer positiven Zahl a. Das Verfahren beruht auf folgendem Algorithmus: (1) (2) (3) (4) (5) (6)
Eingabe a>0 (reelle Zahl) Setze x = 1 Setze y = (x+a/x)/2 Wenn |x-y| < 10-7, gehe nach (6) Setze x = y, gehe nach (3) Rückgabe von x als Wurzel(a)
Mit Hilfe eines Taschenrechners erhält man für a=2 folgende Wertetabelle x
a/x
y
1
2
1.5
1.5
1.3333333
1.4166665
1.4166665
1.4117649
1.4142157
1.4142157
1.4142114
1.4142134
1.4142134
1.4142137
1.4142136
1.4142136
1.4142135
1.4142135
Damit ist
2 = 1,4142135 mit der Genauigkeit 10−7 .
2.3.2 Umwandlung einer Dezimalzahl ins Binärsystem Die Umwandlung einer Dezimalzahl ins Binärsystem wird beschrieben durch: (1) (2) (3) (4) (5) (6) (7) (8)
Eingabe ganze Dezimalzahl x >0 Setze k = 0 Wenn x ungerade, setze bk = 1, gehe nach (5) Setze bk = 0 Setze x = x/2 (ganzzahlige Division) Setze k = k+1 Wenn x>0, gehe nach (3) Rückgabe Binärzahl (bk ... b2b1b0)
Für x = 176 erhält man die Wertetabelle k
x
bk
0
176
0
1
88
0
2
44
0
3
22
0
4
11
1
5
5
1
2.3 Einfache Algorithmen
39
k
x
bk
6
2
0
7
1
1
8
0
-
Dies liefert die Umwandlung: Binärzahl (176)10 = (10110000)2.
2.3.3 Umwandlung einer Binärzahl ins Dezimalsystem Die Umkehrung des Algorithmus 2.3.2 kann folgendermaßen beschrieben werden: (1) (2) (3) (4) (5) (6) (7)
Eingabe Binärzahl (bk ... b2b1b0) Setze x = 0 Setze i = k+1 Setze i = i-1 Setze x = 2x + bi Wenn i>0, gehe nach (4) Rückgabe Dezimalwert x
Für die Binärzahl (11010001)2 ergibt sich die Wertetabelle: i
bi
x
8
-
0
7
1
1
6
1
3
5
0
6
4
1
13
3
0
26
2
0
52
1
0
104
0
1
209
Es gilt somit (11010001)2 =(209)10.
2.3.4 Euklidscher Algorithmus Die Berechnung des größten gemeinsamen Teilers (ggT) zweier natürlicher Zahlen ist der älteste bekannte Algorithmus. Er findet sich im 10. Buch der Elemente von Euklid. (1) (2) (3) (4) (5) (6) (7)
Eingabe a,b > 0 (natürliche Zahlen) Wenn a < b, vertausche (a,b) Setze r = a – b Setze a = b Setze b = r Wenn r > 0, gehe nach (3) Rückgabe a als ggT
40
2 Algorithmen und Compiler
Für die Eingabe a = 121, b = 88 erhält man folgende Tabelle: a
b
r
121
88
55
88
55
33
55
33
22
33
22
11
22
11
11
11
11
0
Der größte gemeinsame Teiler von 121 und 88 ist somit 11.
2.3.5 Die ägyptische Multiplikation Die ägyptische oder Bauern-Multiplikation zweier natürlicher Zahlen funktioniert gemäß folgendem Algorithmus: (1) (2) (3) (4) (5) (6) (7)
Eingabe a,b > 0 (natürliche Zahlen) Setze s = 0 wenn a ungerade,setze s = s + b Setze b = 2b Setze a = a/2 (ganzzahlige Division) Wenn a > 0, gehe zu (3) Rückgabe Produkt s
Ein Zahlenbeispiel für a = 37, b = 24 ergibt: a
b
s
37
24
0
37*
24
24
18
48
24
9*
96
120
4
192
120
2
384
120
1*
768
888
0
1536
888
Die Fälle mit ungeradem a sind durch ein Sternchen (*) gekennzeichnet. Das gesuchte Produkt ist also 37 ⋅ 24 = 888 .
2.3.6 Schnelles Potenzieren Der Algorithmus des schnellen Potenzierens stammt von Legendre und hat Ähnlichkeit mit der Ägyptischen Multiplikation:
2.4 Übungen
(1) (2) (3) (4) (5) (6) (7)
41
Eingabe a,b > 0 (natürliche Zahlen) Setze s = 1 Wenn b ungerade, setze s = sa Setze a = a2 Setze b = b/2 (ganzzahlige Division) Wenn b > 0, gehe zu (3) Rückgabe Potenz s
Zur Berechnung der Potenz 313, d.h. für a = 3, b = 13, folgt: a
b
s
3
13
1
3
13*
3
9
6
3
81
3*
243
6561
1*
1594323
43046721
0
1594323
Die Fälle mit ungeradem b sind wieder mit (*) markiert. Die Tabelle zeigt 313 = 1594323.
2.4
Übungen
Bestimmen Sie mit Papier und Bleistift, was folgende Algorithmen ausführen! Falls Sie Lust haben, können Sie versuchen, diese Algorithmen zu programmieren! Übung (2.1): Erstellen Sie dazu eine Wertetabelle für p = 8, q = 6 (1) (2) (3) (4) (5) (6) (7) (8)
Eingabe p,q>0 Wenn p < q, vertausche (p,q) Setze r = q2/p2 Setze s = r/(r+4) Setze p = p(2s+1) Setze q = q.s Wenn q>10-12, gehe nach (3) Rückgabe p
Übung (2.2): Erstellen Sie dazu eine Wertetabelle für x = 20, y = 3 (1) (2) (3) (4) (5) (6) (7) (8)
Eingabe x,y>0 (natürliche Zahlen) Wenn x
42
2 Algorithmen und Compiler
Übung (2.3): Erstellen Sie dazu eine Wertetabelle für a = 16 (1) Eingabe a>0 (natürliche Zahl) (2) Setze x = 4 (3) Setze y = 5 (4) Setze z = 1 (5) Wenn x > a, gehe nach (10) (6) Setze x = x + y (7) Setze y = y + 2 (8) Setze z = z + 1 (9) Gehe nach (5) (10) Rückgabe z
Übung (2.4): Erstellen Sie dazu eine Wertetabelle für a = 64 (1) (2) (3) (4) (5) (6) (7) (8)
Eingabe a>0 (natürliche Zahl) Setze x = 0 Setze y = a Wenn y > 1, gehe nach (8) Setze x = x + y Setze y = y / 2 (ganzzahlige Division) Gehe nach (4) Rückgabe x
3
Lexikalische Elemente und Datentypen The C vocabulary was supplementary to others and consisted entirely of scientific and technical terms. Any word could be strengthened by the affix plus –, or, for still greater emphasis, doubleplus. George Orwell (1948) im Roman »1984«
In diesem Kapitel werden die wichtigsten Syntaxregeln und die fundamentalen Datentypen besprochen. Die Sprachbeschreibung von C++ kennt folgende Art von lexikalischen Elementen, Literale genannt: 왘 Bezeichner 왘 Schlüsselwörter 왘 Literale 왘 Operatoren 왘 Separatoren (Trennzeichen) 왘 Kommentare
3.1
Zeichensatz
C++ kennt zwei Arten von Zeichen: 왘 char // ASCII-Zeichen (character) 왘 wchar_t // Multibyte-Zeichen (wide character)
Multibyte-Zeichen sind insbesondere die Zeichen des Uni(-versal)Codes, der in Java und bei Internet-Browsern große Bedeutung gewonnen hat. Unter UNIX wird in der Regel der bekannte ASCII-Zeichensatz (American Standard Code for Information Interchange) verwendet. Da in einem 8-Bit-Code 28=256 Zeichen möglich sind, belegt der ASCII-Code mit den Zeichen Nr. 0 bis Nr. 127 genau die erste Hälfte. Die Verwendung der Zeichen Nr. 128 bis Nr. 255 ist bei Verwendung des ASCII-Codes maschinenabhängig. Die Darstellung hängt vom Betriebssystem und von dem jeweils geladenen Tastaturtreiber ab. Ein typisches Beispiel für ein Durcheinander von Zeichensätzen erhält man, wenn man einen Windows-Text mit Umlauten in einem DOS-Fenster liest. Die ersten 256 Zeichen des Uni(-versal)Code von \u0000' bis '\u00FF', Latin-1 genannt, sind durch die Norm ISO 8859-1 fest gelegt und in den ISO C++-Standard (Anhang E) übernommen worden. Wie Abb. 3.1 zeigt, stimmt die erste Hälfte von Latin-1, Basic Latin genannt, mit dem ASCII-Code überein.
44
3 Lexikalische Elemente und Datentypen
Insgesamt umfasst der UniCode 216 = 2562 = 65.536 Zeichen, von denen bisher 38.885 Zeichen festgelegt wurden, darunter die meisten asiatischen Zeichensätze wie Katakana, Hiragana, Thai, Devanagari und Bengali. Die kyrillischen Buchstaben sind z.B. festgelegt durch ISO 8859-1, die hebräischen Zeichen durch ISO 8859-8. Besonders kompliziert erwies sich das Einordnen aller Han-Zeichen, die von den vier Ländern China, Japan, Vietnam und Korea verwendet werden. Sie werden fixiert auf den Bereich '\u4e00' bis '\u9fff', das sind über 20,000 Zeichen! Ein Problem ist, dass der Zugriff auf diese Zeichensätze unter C++ noch nicht realisiert ist, da die meisten Rechner die asiatischen Zeichensätze nicht darstellen können; dies ist bis jetzt nur bei den asiatischen Windows-Versionen implementiert. Auch die meisten Internet-Browser sind bereits imstande, einige außereuropäische Zeichensätze darzustellen.
Abbildung 3.1: Der UniCode-Zeichensatz Latin-1
3.2 Schlüsselwörter
45
Der ASCII-Code und Latin-1 erfüllen die Bedingung, dass die Kleinbuchstaben 'a' ... 'z' und Großbuchstaben 'A' ... 'Z' in zwei Blöcken jeweils lückenlos aufeinander folgen. Damit lässt sich jedes Zeichen durch Hochzählen von 'a' oder 'A' finden: 'e' = char('a'+4) 'H' = char('A'+7)
Ein Test auf Klein- oder Großbuchstaben ergibt sich damit zu: if ((ch >= 'a') && (ch<='z')) if ((ch >= 'A') && (ch<='Z'))
UniCode-Zeichen werden durch ein vorangestelltes 'L' von den ASCII-Zeichen unterschieden, z.B.: 'A','a' // char L'A',L'a '// wchar_t
3.2
Schlüsselwörter
Schlüsselwörter sind reservierte Wörter, die nicht als Bezeichner verwendet werden dürfen. Im ISO C++-Standard gibt es die 63 Schlüsselwörter: asm
auto
bool
break
case
catch
char
class
const
const_cast
continue
default
delete
do
double
dynamic_cast
else
enum
explicit
export
extern
false
float
for
friend
goto
if
inline
int
long
mutable
namespace
new
operator
private
protected
public
register
reinterpret_ca st
return
short
signed
sizeof
static
static_cast
struct
switch
template
this
throw
true
try
typedef
typeid
typename
union
unsigned
virtual
void
volatile
wchar_t
while
Ebenfalls reserviert in der Header-Datei (früher ) sind folgende Bezeichner für logische und Bit-Operatoren, die zu einem späteren Zeitpunkt die entsprechenden Sonderzeichen ersetzen sollen. and
and_eq
bitand
or
or_eq
xor
bitor
compl
xor_eq
not_eq
not
46
3.3
3 Lexikalische Elemente und Datentypen
Kommentare und Separatoren
Ein Kommentar ist eine Zeichenfolge in einem Programm, die nur zur Information des Lesenden gedacht ist und vom Compiler nicht übersetzt wird. Formen des Kommentars sind // dies ist ein Kommentar (C++-Stil) /* dies ist auch ein Kommentar (C-Stil) */
Die beiden Kommentarformen dürfen nicht verschachtelt werden. Der Unterschied beider Formen ist, dass im ersten Kommentar »//« die ganze Restzeile als Kommentar gilt. Der Kommentar mittels /*...*/ kann dagegen über mehrere Zeilen gehen /* Dies ist ein langer Kommentar, der sich ueber mehrere Zeilen erstreckt */
Separatoren oder Trennzeichen sind die Zeichen, die Literale einer Programmiersprache voneinander trennen. Es sind dies der Strichpunkt »;«, das Komma »,«, runde Klammern »()«; eckige Klammern »[]«, geschweifte Klammern »{}«, Doppelpunkt »:«, Ellipsen »...«, Sternchen »*«, Referenzzeichen »&«, Gleichheitszeichen »=« und Kommentare. Die genannten Zeichen heißen auch Sonderzeichen. Separatoren, die nicht gleichzeitig Kommentare sind, heißen auch whitespace, da sie auf dem Bildschirm oder im Listing nicht sichtbar sind. Dies sind das Leerzeichen (blank), der Tabulator, der Zeilen- und der Seitenvorschub.
3.4
Operatoren
Folgende Zeichen bzw. Zeichengruppen haben als Operatoren eine spezielle Bedeutung: !
%
~
&
*
()
-
+
=
|
[]
<
>
?:
/
,
.
->
++
--
*
<<
>>
<=
>=
==
!=
&&
||
*=
/=
%=
+=
-=
<<=
>>=
&=
^=
|=
::
Es gibt jedoch auch Operatoren, die durch Schlüsselwörter definiert sind. Dies sind: new, delete, sizeof
und die Operatoren zur Typumwandlung: const_cast, static_cast, dynamic_cast,reinterpret_cast
Treffen in einem Ausdruck mehrere Operatoren aufeinander, so muss die Priorität (Auswertungsreihenfolge) und die Assoziativität (Zusammenfassen von links oder rechts) festgelegt werden. Siehe hierzu Tabelle 3.1:
Literale Konstanten umfassen in C/C++: 왘 Integer-Konstanten 왘 Fließzahlen-Konstanten 왘 Zeichen- und String-Konstanten 왘 Wahrheitswert-Konstanten
Integer-Konstanten können im Dezimal-, Oktal- oder Hexadezimalssystem geschrieben werden. Oktalziffern beginnen mit der Null, Hexziffern mit 0x oder 0X. Eine Dezimalzahl darf daher nicht mit einer Null beginnen! Folgende Konstanten sind gleichwertig: 143 // dezimal 0217 // oktal 0x8F // hex
Integer-Konstanten in Textform sind entweder die Hexziffern A bis E oder stellen Suffixe (Endungen) für Datentypen dar: 3U // unsigned int 5L // long int 11UL //unsigned long int 2.0f //float 2.34L // long double Zahl
Zeichenkettenkonstanten bestehen aus einer (möglicherweise leeren) Folge von Zeichen, die durch Anführungszeichen eingeschlossen werden. "47.11" "Karl der Grosse" L"to be or not to be" // wchar_t "C++ is not meant to be understood in two hours (B.Stroustrup)"
3.6
Definition und Deklaration
Variables serve as carriers for values. H. Rutishauser The basic elements ... are objects und variables. Objects are the data entities that are created and manipulated by … programs. Variables are just names used in a program to refer to objects. B.H. Liskov Bezeichner sind Namen, die von der programmierenden Person für Variablen, Konstanten, Typen, Klassen und Funktionen gewählt werden. Bezeichner können in C++ beliebig lang sein und dürfen aus beliebigen alphanumerischen Zeichen (Buchstaben und Ziffern) bestehen. Sie müssen jedoch mit einem Buchstaben anfangen. Der tiefgesetzte Strich »_« (underscore) ist zugelassen. Er sollte jedoch als erstes Zeichen eines Bezeichners vermieden werden, da diese Schreibung von vielen Compilern als Kennung interner Variablen benützt wird. C++ ist case sensitive, d.h. Klein- und Großschreibung wird unterschieden. Gültige Bezeichner sind: abc Abc aBc abC alpha
50
3 Lexikalische Elemente und Datentypen
hallo1 karl_der_grosse
Ungültige Bezeichner sind: ludwig-2 // Kein "-" 3dim // keine führende Ziffer c++ // kein "+" _f // nicht empfohlen!
Variablen werden in C++ durch Angabe ihres Datentyps deklariert (Typvereinbarung): int a,b; char ch; float x,y;
Variablen können in C++, im Gegensatz etwa zu PASCAL, mit der Deklaration auch gleichzeitig initialisiert und damit definiert werden: int a=1; int b(2); // b=2 char ch = 'a'; float x=2.,y=3.;
Wird eine Variable durch eine andere definiert, so muss deren Wert bereits bekannt sein: int a=7,b=4,rest=a % b; // ok int a=1,c=(b=2),b+1; // ok, b=2,c=3 int a=1,c=b,b=a+1; // falsch
3.7
Einfache Datentypen
Einen Überblick über alle Datentypen gibt die Abb. 3.2. C++ übernimmt von C die einfachen Datentypen: char/ wchar_t// Zeichen int/ unsigned int // ganzzahlig long/ unsigned long // ganzzahlig float/ double // Fliesskommazahlen void // kein Datentyp
Neu in C++ ist der Datentyp bool // Wahrheitswert
der genau die vordefinierten Werte false (falsch) und true (wahr) annehmen kann. Boolesche Variablen können durch einen Wahrheitswert oder einen Booleschen Term definiert werden: bool konvergent = false; bool sortiert = true; int x=13,alter=17;
3.7 Einfache Datentypen
51
bool volljaehrig = alter >= 18; bool ungerade = x % 2 == 1;
Der Typ char (vgl. Abschnitt 3.1) wird am Rechner durch ein Byte codiert und kann damit 256 Zeichen darstellen. Aus Kompatibilitätsgründen zum Typ signed/ unsigned int definiert man ebenfalls Typmodifizierer für char: unsigned char (Wert 0..255) (signed) char (Wert -128..127)
Abbildung 3.2: Datentypen in C++
Der Typ wchar_t stellt einen Multibyte-Typ für Zeichen dar, wie er z.B. durch den UniCode (vgl. Abb. 3.1) realisiert wird. Prinzipiell können in C++ auch Multibyte-Zeichen verwendet werden; die ISO Norm stellt auch für solche Zeichenketten entsprechende Stringfunktionen bereit, die aber momentan noch wenig gebräuchlich sind. Beim Ganzzahltyp int findet man auf 16-Bit-Maschinen (z.B. PC) folgende Modifizierer: short int/int (Wert -32.768..32767) unsigned int (Wert 0..65535) long (Wert -2147483648..2147483647) unsigned long (Wert 0..4294967295)
Diese Werte sind vorgeschriebene Mindestwerte. Auf 32-Bit-Maschinen (z.B. SUN) ergeben sich folgende Wertebereiche: short int/int (Wert -32.768..32767) unsigned int (Wert 0.. 4294967295)
52
3 Lexikalische Elemente und Datentypen
long (Wert -2147483648..2147483647) unsigned long (Wert 0..4294967295)
Bei Fließkommazahlen zeigen sich die Formate: float (4 Byte, Wertebereich ±3.4·10–38..3.4·1038) double (8 Byte, Wertebereich ±1.7·10–308..1.7·10308)
Achtung: Bei Wertzuweisungen und Vergleichen von int mit unsigned-Typen kommt es zur impliziten Typumwandlung, bei der int-Werte in unsigned umgewandelt werden: int i; unsigned c=60000; i = c;// Nebeneffekt i= -5536 (2 Byte)
Auch bei Vergleichen kommt es zu einer automatischen Konversion, die bei einer Fehlersuche schwer zu durchschauen ist: unsigned x = int y = -1; bool kleiner if (kleiner) else cout <<
5; = (x < y ); // Nebeneffekt cout << "x < y " << endl; "x >= y " << endl;
Achtung: Hier ist scheinbar 5 < -1, da y = -1 umgewandelt wird in 4294967295(!) bei 4-Byte-Arithmetik. Dieser Nebeneffekt tritt insbesondere bei dem Aufruf einer Bibliotheksfunktion auf, die den Typ unsigned erwartet. Hier sind Compiler-Warnungen besonders zu beachten! Typumwandlungen werden nach folgenden Regeln durchgeführt: 왘 char, short und enum-Typen können immer an Stelle von int treten. Können alle
Werte nach int umgewandelt werden, so erfolgt diese Umwandlung, ansonsten nach unsigned int. Diese Konversionen werden Integralausweitung (englisch integral promotion) genannt. 왘 Treffen in einem arithmetischen Ausdruck zwei verschiedene Datentypen aufeinan-
der, so geschieht die Umwandlung nach der Faustregel »Ausweiten auf den größeren Datentyp«: Ist einer der beiden Operanden vom Typ long double, so wird der andere ebenfalls in long double umgewandelt. Entsprechendes gilt für double und float. 왘 Ist Letzteres nicht der Fall, so wird eine Integralausweitung versucht. Andernfalls
wird eine der folgenden Umwandlungen durchgeführt: Ist einer der beiden Operanden vom Typ unsigned long, so wird auch der andere in unsigned long konvertiert. Entsprechendes gilt für long int, long bzw. int. Nach der ISO Norm ist nur das Überlaufverhalten von unsigned-Typen definiert. Dies wird im folgenden Programmabschnitt gezeigt. Der Nachfolger der größten unsignedZahl ULONG_MAX ist sicher 0, der Nachfolger der größten long-Zahl LONG_MAX ist möglicherweise -2147483648.
3.7 Einfache Datentypen
53
#include unsigned x = ULONG_MAX; int y = LONG_MAX; for (int i=0; i<5; i++) cout << (x+i) << endl; for (int i=0; i<5; i++) cout << (y+i) << endl;
Die Ausgabe zeigt, nach Überlauf von unsigned-Typen wird von 0 beginnend weitergezählt, dagegen nicht notwendigerweise bei int-Werten. 4294967295 0 1 2 3 2147483647 -2147483648 -2147483647 -2147483646 -2147483645
Der Typ void ist ein Datentyp, der das Fehlen eines Datentyps anzeigt. Er findet Anwendung bei Prozeduren, die keinen Wert zurückgeben. Im Sinne von C++ sind alle Prozeduren Funktionen vom Typ void. Es wird auch verwendet bei Pointern, die auf irgendeinen Datentyp zeigen. Ferner existiert der Datentyp: long double (12 bzw. 16 Byte)
Bei INTEL-Prozessoren haben diese Typen 10 Byte, entsprechend dem Wertebereich von:
±3.4 ⋅ 10−4932.. 1.1 ⋅ 10 4932 Bei Borland-Compilern haben die mathematischen Funktionen des long double-Formats spezielle Namen, was nicht der C++-Norm entspricht. Diese sind bei Borland erkennbar durch die Endung l, z.B. expl(), sinl(), cosl()
Folgender Programmabschnitt liefert eine Wertetabelle der Funktion sin() auf dem Definitionsbereich [0;1]: for (long double x=0.; x<1.01; x+=0.1) cout << x << "\t" << sinl(x) << endl; // Borland
Es ergibt sich bei 15-stelliger Ausgabe: 0 0.1 0.2 0.3 0.4
Mit Hilfe des sizeof-Operators können die Speicherformate der jeweils benutzten Maschine ermittelt werden. Dies zeigt das Programm: #include typedef unsigned char uchar; typedef unsigned int uint; typedef unsigned long ulong; typedef long double ldouble; int main(), {, cout << "char cout << "uchar cout << "short int cout << "int cout << "uint cout << "long int cout << "ulong cout << "float cout << "double cout << "ldouble return 0; }
An einem PC erhält man bei einem 16-Bit-Compiler die Ausgabe: char 1 uchar 1 short int 2 int 2 uint 2 long int 4 ulong 4 float 4 double 8 ldouble 10
Byte Byte Byte Byte Byte Byte Byte Byte Byte Byte
Bei 32-Bit-Systemen, wie UNIX, OS/2 und Windows NT, gilt abweichend: int uint
4 Byte 4 Byte
Zu beachten ist, dass die typedef-Anweisung keinen neuen Datentyp, sondern nur ein Synonym für einen bereits existierenden Typ erzeugt! Zahlbereiche für int-Typen können ebenfalls der Header-Datei entnommen werden. Unter anderem finden sich dort folgende Grenzen:
3.8 Der Aufzählungstyp enum
55
INT_MAX // Maximum signed int 32767 UINT_MAX // Maximum unsigned int 65535U LONG_MAX // Maximum signed long 2147483647L ULONG_MAX // Maximum unsigned long 4294967295UL
Alle einfachen Datentypen können durch die Standard-Ein-/-Ausgabe cout/cin
eingelesen und ausgegeben werden. Dazu muss die Header-Datei , früher , eingebunden werden. Als Beispiel für eine Ein-/Ausgabe sollen Umfang und Fläche eines Kreises berechnet werden. // kreis.cpp #include #include using namespace std; int main() { double r; cout << "Geben Sie den Radius des Kreises ein! "; cin >> r; double flaeche = M_PI*r*r; double umfang = 2.*M_PI*r; cout << "Umfang = " << umfang << endl; cout << "Flaeche = " << flaeche << endl; return 0; }
Die Kreiskonstante π ist traditionell, aber nicht verpflichtend nach ISO, in der HeaderDatei (früher <math.h>) vordefiniert. Dies ist von allen Compiler-Herstellern (außer Microsoft) übernommen worden.
3.8
Der Aufzählungstyp enum
Der Aufzählungstyp enum (enumerated type) ist ebenfalls ein ganzzahliger Typ, der bei Bedarf in einen int-Typ umgewandelt werden kann, jedoch nicht umgekehrt. Beispiele sind: enum enum enum enum
Die erste Deklaration könnte auch lauten: typedef enum{fruehling,sommer,herbst,winter } jahreszeit;
Zu beachten ist, dass die Konstanten der Aufzählungstypen eindeutig sein müssen. Es ist also nicht möglich, im gleichen Geltungsbereich eine weiteren Aufzählungstyp mit winter zu definieren, wie:
56
3 Lexikalische Elemente und Datentypen
enum semester {sommer,winter}; // Fehler!
Die Nummerierung der enum-Werte beginnt, wie in C/C++ üblich, aufsteigend bei Null, wenn keine andere Bewertung gegeben ist (Default-Wert). Obige Deklarationen sind somit gleichbedeutend mit: enum jahreszeit{fruehling=0,sommer=1,herbst=2,winter=3}; enum ampel{rot=0,gelb=1,gruen=2}; enum wochentag{sonntag=0,montag=1,dienstag=2,mittwoch=3, donnerstag=4,freitag=5,samstag=6};
Die monoton aufsteigende Nummerierung kann durchaus geändert werden, z.B. in: enum ampel{rot=0,gelb=2,gruen};
Der Wert von gruen ist dann automatisch 3. Die Nummerierung muss nicht bei Null beginnen. enum farbe {karo=9,herz,pik,kreuz} enum RGBColor {blue=0xff,green=0xff00,red=0xff0000}; enum roemisch {I=1,V=5,X=10,L=50,C=100,D=500,M=1000};
In C++ gibt es auch so genannte anonyme enum-Typen: enum {konst=12};
Diese Definition ist im Wesentlichen gleichbedeutend mit: const int konst = 12;
Für enum-Typen gibt es keine Standard-Ein-/-Ausgabe. Die Ausgabe muss mit einer gesonderten switch()-Anweisung (vgl. Abschnitt 6.3) erfolgen. switch(a) // a vom Typ ampel { case rot : cout << "Rot"; break; case gelb : cout << "Gelb"; break; case gruen : cout << "Gruen"; }
Der Inkrementoperator ++ darf nicht direkt auf enum-Typen angewandt werden. Einige Autoren halten den enum-Typ für entbehrlich; auch in Java wurde er nicht übernommen.
3.9
Speicherklassen
C++ kennt für namentlich deklarierte Objekte fünf Speicherklassen-Attribute (englisch storage class specifier): auto(matic), static, register, extern, mutable
3.9 Speicherklassen
57
Diese Speicherklassen-Attribute bestimmen die Speicherklasse der Objekte und damit die Lebensdauer und die Bindung (linkage). Die Attribute dürfen nicht im Zusammenhang mit einer typedef-Anweisung verwendet werden. 왘 auto(matic)
Die Speicherklasse auto ist der Defaulttyp; d.h. ein Bezeichner ohne Speicherklassenattribut kennzeichnet eine automatische Variable. Automatische Variablen werden erst erzeugt, wenn der Programmfluss den Block ihrer Deklaration bzw. Definition erreicht. Ihre Lebensdauer reicht dann bis zum Blockende. Dieser Block beginnt mit der öffnenden Klammer vor der Definition und reicht bis zur zugehörigen schließenden Klammer. { int x=2; // auto x += 2; , } // Block= Lebensdauer von x,
Alle lokalen Variablen eines Geltungsbereichs sind automatisch, sofern sie nicht explizit als statisch erklärt werden. Sie werden nicht implizit initialisiert wie statische Typen. Wird der Block, in dem sie definiert werden, vom Programmfluss verlassen, so werden sie zerstört. Existiert ein Variable gleichen Namens in einem äußeren Block, so überdeckt die lokale Variable im Blockinneren deren Wert; der Wert geht aber nicht verloren. { // aeusserer Block int i=11; , { // innerer Block int i=13; // überdeckt i=11, cout << i << endl; // Ausgabe 13, } cout << i << endl; // Ausgabe 11, }
Die Speicherklasse auto ist ausschließlich für lokale Bezeichner reserviert. 왘 register
Variablen der register-Speicherklasse sind auto(matische) Variablen, die zum schnellen Zugriff möglichst im Register des Akkumulators zu halten sind. Diese Option hat im Zeitalter des optimierenden Compiler ihre Bedeutung verloren, brachte aber bei älteren Übersetzern einen Geschwindigkeitsgewinn. registerVariablen sind zum schnellen Zugriff gedacht, sie können nur vom Registertyp der Maschine sein, d.h. 2- oder 4-Byte-Typen. 왘 static
Statische Variablen dagegen existieren während der gesamten Laufzeit. Sie werden entweder genau einmal implizit mit Null oder explizit mit dem gegebenen Anfangswert initialisiert. Mit Hilfe des Geltungsbereichsoperators (scope resolution operator) kann gegebenenfalls auf den Wert der globalen und damit statischen Variablen zugegriffen werden. Dies zeigt folgendes Programm: int x=0; // global int main()
58
3 Lexikalische Elemente und Datentypen
{ int x=2; // lokal cout << x << endl; // Ausgabe 2 cout << ::x << endl; // Ausgabe 0 return 0; }
Eine innerhalb eines Blocks als statisch definierte Variable ist nur lokal gültig, existiert aber während der gesamten Programmlaufzeit. Dies gilt insbesondere für statische Variablen innerhalb eines Funktionsblocks. Diese behalten ihren Wert, auch wenn der Funktionsstack gelöscht wird und somit alle lokalen auto-Variablen verschwinden. Ein Beispiel zeigt dies: int f(int x) {, static long summe; // implizit genau einmal mit 0 initialisiert summe += x; , return summe; } int main() { int summe; for (int i=1; i<11; i++) summe = f(i); cout << "Summe 1 bis 10 = " << summe; return 0; }
Hier wird eine Summe von ganzen Zahlen erzeugt, indem eine statische Variable mit allen Summanden inkrementiert wird. In C werden Variablen als statisch erklärt, um ihnen dateiweite Gültigkeit zu geben, in C++ kann dies eleganter durch die Deklaration eines Namensraums (vgl. Abschnitt 6.2) geschehen. Zu erwähnen ist noch, dass die Speicherklasse static im Zusammenhang mit Klassen eine weitere Bedeutung gewinnt. Näheres findet man in Abschnitt 8.8. 왘 extern
Die extern-Deklaration sagt dem Linker, dass sich die zugehörige Definition in einem anderen Modul befindet. Besteht ein Projekt aus zwei Dateien, so benötigt die Datei, in der eine bestimmte Variable nicht deklariert wird, eine extern-Vereinbarung. //programm1 extern int x; { ....... }
//programm2 int x = 3; { ......... }
Wichtige Anwendung findet diese Spezifikation, wenn eine C-Funktion von einem C++-Compiler gelinkt werden soll. Wegen der Differenzen auf der internen Objektcode-Ebene kann hier eine Funktion als extern deklariert werden wie z.B.:
3.9 Speicherklassen
59
extern "C" char* strcpy (char *t,const char *s)
Mehrere C-Funktionen werden durch die Verbundanweisung zusammengefasst. extern "C" { char* strcpy (char *t,const char *s) unsigned strlen (const char* ) } 왘 mutable
Im Zusammenhang mit Klassen-Elementen (vgl. Kap. 8) gibt es noch das Attribut mutable, das einzelne Elemente als veränderbar erklärt, auch wenn die Klassenmethode als const erklärt ist. Das Attribut darf aber nicht auf Elemente angewandt werden, die explizit als konstant oder statisch deklariert sind. Zusammenfassend zeigt die folgende Tabelle für alle Speicherklassen die Lebensdauer und den Gültigkeitsbereich an: Speicherklasse
Lebensdauer
Gültigkeit
auto
Block
lokal
register
Block
lokal
static
Datei
lokal/global
extern
Datei
global
4
Abgeleitete Datentypen
Aus den einfachen Datentypen lassen sich weitere Typen ableiten wie: 왘 Konstanten 왘 Zeiger 왘 Referenzen 왘 Reihungen (arrays) 왘 Strukturen 왘 Strings
4.1
Konstanten
Konstanten werden definiert, indem man einer Deklaration das Schlüsselwort const voranstellt. Damit verhindert der Compiler zur Programmlaufzeit, dass dem (konstanten) Objekt ein neuer Wert zugewiesen wird. Aus diesem Grund muss eine Konstante bei ihrer Definition gleichzeitig initialisiert werden. const const const const
char newline = '\n'; // char-Konstante int dim = 3; // int-Konstante double g = 9.8065; // Erdbeschleunigung (Fliesskomma) double c = 3.0e5; // Lichtgeschwindigkeit (Fliesskomma)
Das Konzept der Konstanten wurde in C++ auch auf Zeiger, formale Parameter von Funktionen und private Datenelemente von Klassen ausgeweitet. Dies wird später einen entscheidenden Schutzmechanismus für Elementfunktionen einer Klasse liefern. Bei Klassenfunktionen lassen sich übergebene Parameter als konstant deklarieren: function(const int& x) { ... };
Gleiches gilt für Element-Funktionen einer Klasse, wobei hier const am Ende steht. function() const { ... };
4.2
Zeiger Pointer arithmetic is a popular pastime for system programmers. C.M. Geschke
Zeiger sind ein umstrittener Teil einer Programmiersprache und werden von vielen akademischen Informatikern als extrem fehleranfällig abgelehnt. Wie das Zitat zeigt,
62
4 Abgeleitete Datentypen
werden sie gerade noch als Freizeit-Hobby für Systemprogrammierer akzeptiert. Der Programmieralltag zeigt aber, dass in C++ auf Zeiger kaum verzichtet werden kann. Um eine Variable verwalten zu können, muss das System ihren Wert, den Speicherbedarf und ihre Adresse kennen. Um solche Adressen verwalten zu können, benötigt man einen Datentyp, der keinen Wert angibt, sondern auf eine Adresse zeigt. Ein Zeiger (pointer type) ist also ein Datentyp, der zur Manipulation von Objektadressen dient. Folgende Operationen stehen im Zusammenhang mit Zeigern: 왘 Deklaration von Zeiger-Objekten 왘 Feststellen von Objekt-Adressen (mittels Adressoperator) 왘 Ansprechen der Objekte, auf die Zeiger verweisen (Indirektion) 왘 Allozieren von Objekten, auf die Zeiger verweisen (new) 왘 Löschen des Speicherbereichs von dynamisch angelegten Objekten (delete)
Prinzipiell können folgende Zeiger unterschieden werden: 왘 Zeiger auf Speicherobjekte 왘 void-Zeiger 왘 Zeiger auf Funktionen 왘 Zeiger auf Klassenelemente/Methoden
Zeigervariablen werden deklariert, indem man einem Bezeichner einen Stern voransetzt. Mit int i=11,*p; p = &i; // p ist Zeiger auf i
wird ein Zeiger auf ein int-Objekt definiert. Durch die zweite Anweisung wird dem Zeiger die Adresse des Objekts zugewiesen. Das Zeichen »&« dient hier als Adressoperator. int * pi; // Zeiger auf int char* pc; // Zeiger auf char double* pd; // Zeiger auf double
Durch die Angabe des Typs werden Zeigervariablen »gebunden«; d.h., ein Zeiger auf int wird ausschließlich auf int-Typen zeigen, wenn er nicht durch Typumwandlung auf ein anderes Objekt verweist. Eine Zuweisung der Art int i=11; double* p; p = &i; // falsch
ist nicht korrekt. Die Korrektheit einer solchen Typumwandlung liegt in der Verantwortung der programmierenden Person. Die Stellung des Sterns spielt keine Rolle: int* p; // C++-Stil int *p; // C-Stil
4.2 Zeiger
63
Abbildung 4.1: Veranschaulichung von Pointern
Bei der Deklaration von zwei Zeigern sind zwei Sternchen zu setzen: int *p,*q; // richtig int* p,q; // falsch
Besser ist eine getrennte Deklaration: int* p; int* q;
Um den Inhalt der Variablen anzusprechen, muss der Pointer dereferenziert werden. Dies geschieht in C/C++ ebenfalls durch Voranstellen eines Sterns als Dereferenzierungsoperator: int i=11,*p; p = &i; // p ist Zeiger auf i
Es gilt hier also: *p = 11
Die Anweisung *p = *p+1; // äquivalent i = i+1;
liefert den Wert: *p = 12
Gleichzeitig wurde damit der Wert von i auf 12 erhöht. *p ist somit ein anderer Name (Alias) für i. int x=10,y; int* p; // Zeiger auf int
64
4 Abgeleitete Datentypen
p = &x; y = *p; // y Alias für x mit Wert 10
Konstante Objekte werden vor Manipulationen durch nicht konstante Zeiger geschützt. const int i=11; int* p,j = 12; p = &i; // verboten p = &j; // ok
Wird der Zeiger ebenfalls als konstant deklariert, so ist die Adresszuweisung an ein nicht konstantes Objekt verboten. const int i=11; const int* p; int j = 12; p = &i; // ok p = &j; // verboten
Ein Zeiger auf eine Konstante darf jedoch verändert werden: const int i=11,j=12; const int* p; p = &i; // ok p = &j; // erlaubt (*p) liefert 12
Zeigervariablen selbst können ebenfalls als const erklärt werden. const int i=11; const int* const p = &i;
Hier sorgt der Compiler dafür, dass weder p noch *p verändert werden. Zeiger auf den leeren Datentyp void können als Zeiger auf Objekte mit unbekanntem oder noch nicht spezifiziertem Datentyp angesehen werden. Numerische Zeigerkonstanten, außer dem Nullzeiger, werden nur in der Systemprogrammierung benötigt. Dies ist z.B. der Fall, wenn man direkt in den Bildschirmspeicher schreiben will, hier verweist der Zeiger auf die untere Bildschirmadresse. Der Nullzeiger NULL, definiert in der Header-Datei <stdio.h>, definiert als (void*)0, ist ein Zeiger, der nirgendwohin zeigt. Der Nullzeiger besitzt aber einen definierten Zustand, im Gegensatz etwa zu einem nicht initialisierten Zeiger. In C++ initialisiert man Zeiger auf 0. int *p = 0; if (p == 0) // pruefen auf Null
4.3
Erzeugen dynamischer Variablen
Zeigervariablen können auch dynamisch, d.h. erst zur Programmlaufzeit, erzeugt und später auch gelöscht werden. Dies geschieht in C++ mit den Schlüsselwörtern new / delete.
4.3 Erzeugen dynamischer Variablen
65
int i=11,j=12; int *p = new int[i]; // erzeugen u.initialisieren cout << *p << endl; // Ausgabe 11 delete p; // vernichten int *p = new int[j]; // erzeugen u.initialisieren cout << *p << endl; // Ausgabe 12 delete p; // vernichten
Ein mit new erzeugtes Objekt darf nur mit delete gelöscht werden. Auf keinen Fall darf die C-Funktion free() verwendet werden, die für die C-Funktion malloc() gedacht ist. Der Ausdruck new int wird vom System als new(sizeof(int)) interpretiert. Nach dem ISO C++-Standard gibt es drei Versionen des new-Operators, definiert in der HeaderDatei . void* operator new(size_t ) throw(bad_alloc); void* operator new(size_t,const nothrow_t& ) throw(); void* operator new(size_t,void* ) throw();
Die Funktion throw() zeigt an, dass ein Ausnahmefall auftreten kann. Genaueres zu Ausnahmenfällen folgt in Kapitel 10. Die erste Version von new() wurde bereits angewendet. Die zweite Version kann verwendet werden, wenn kein Ausnahmefall erzeugt werden soll, z.B. bei: #include int* p = new(nothrow) int; if (p).. // ohne Exception
Die mit new allozierten Variablen haben im Gegensatz zu gewöhnlichen Variablen keinen Bezeichner und können nur über ihren Zeiger angesprochen werden. Daher muss darauf geachtet werden, dass der Zeiger »nicht verbogen« wird. Ein Beispiel: int x=12,y=14; int* p = &x; // p int* q = &y; // q cout << (*p) << " q = p; // q zeigt cout << (*p) << "
zeigt auf zeigt auf " << (*q) nun auf y " << (*q)
x y << endl; // Ausgabe 12,14 (Zeiger verbogen!) << endl; // Ausgabe 12,12
Da der Zeiger q auch auf x zeigt, gibt es hier keinen Zeiger mehr, der auf y weist. y ist daher nicht mehr durch q adressierbar (int y existiert aber weiter). Zur Vermeidung des Effekts, dass bei der Zuweisung zweier Pointer beide Pointer auf dasselbe Objekt zeigen, also beiden Zeigern das Objekt »gehört«, wurde der auto(matische) Pointer auto_ptr geschaffen. Der Auto-Pointer hat folgende Eigenschaften: 왘 Bei Zuweisung an einen zweiten Pointer wird der erste Pointer auf Null gesetzt. 왘 Der belegte Speicherbereich wird automatisch freigegeben. 왘 Er kann nicht mit dem new[]-Operator (array-Version) zusammenarbeiten. 왘 Er sollte nicht bei Containertypen der STL verwendet werden.
66
4 Abgeleitete Datentypen
Der Auto-Pointer ist als Instanz einer Template-Klasse (vgl. Kapitel 14) zu initialisieren: auto_ptr a(new int[5]); // a zeigt auf 5 auto_ptr b = a; // b zeigt auf 5, a auf Null
Ein Programmbeispiel ist: #include <memory> using namespace std; int main() { auto_ptr a(new int[5]); cout << "Adresse von a = " << a.get() << endl; cout << "a zeigt auf Wert " << *a.get() << endl; auto_ptr b=a; cout << "Adresse von b = " << b.get() << endl; cout << "b zeigt auf Wert " << *b.get() << endl; // kein delete a,b noetig! return 0; }
Die Ausgabe Adresse a zeigt Adresse b zeigt
von auf von auf
a = 0x00672d4c Wert 5 b = 0x00672d4c Wert 5
zeigt, dass b tatsächlich den Speicherplatz und Wert von a übernimmt, die Adresse ist hier natürlich zufällig
4.4
Referenzen The introduction of references into a high-level language is a serious retrograde step. C.W. Hoare
Pointer sind ebenso wie Referenzen unter einigen akademischen Informatikern umstritten und werden – wie das Zitat zeigt – als Rückschritt in die Assemblerzeit empfunden. Tatsache aber ist, dass keine moderne Programmiersprache auf Referenzen verzichtet. Besonders in Java sind alle Objekte Referenzen. Referenztypen sind neu in C++ gegenüber C. Referenzen (reference types) sind eng mit Zeigern verwandt. Man kann sie anschaulich als konstante Zeiger interpretieren, die bei der Anwendung automatisch, d.h. ohne Angabe des Sterns, dereferenziert werden. Referenzen werden analog zu Zeigern definiert, wobei an Stelle des Sterns der Adressoperator tritt.
4.5 Typumwandlungen
67
int i=11; int& r = i;
Referenzen müssen initialisiert werden. Die Variable r stellt hier eine Referenz auf ein int-Objekt und daher ein Alias der Variablen i dar. Achtung: Die Anweisung r = r*2; // d.h. i = r = 22;
verdoppelt r und ändert damit gleichzeitig auch i (ohne explizite Wertzuweisung!). Es ist klar, dass solche Nebeneffekte eine Fehlersuche sehr stark erschweren. Diese Darstellung mit Referenzen ist besser lesbar als die gleichwertige Zeigerdarstellung: int i=11; int* r = &i; *r = *r*2;
Für Referenzen gibt es zwei Schreibweisen: int i=11; int& r = i; // besser int &r = i; // alternativ
Mit Hilfe von Referenzen kann in C++ die Parameterübergabe Call-by-reference ausgeführt werden, die in C nicht möglich ist (siehe Abschnitt 7.6). Referenzen stellen selbst keine Datenobjekte mehr dar. Daher sind Referenzen auf Referenzen nicht erlaubt. Ferner gibt es keine Zeiger auf Referenzen, keine Referenzen auf void und auf Bitfelder. In der Programmiersprache Java sind alle Objekte Referenzen, außer den primitiven Typen wie z.B. int, char, double, boolean.
4.5
Typumwandlungen
Achtung: Folgendes Programmstück zeigt einen schlimmen Nebeneffekt: int i; unsigned u = 50000; i = u; // Wert -15536 an 16-Bit-Maschinen
Ist hier i ein 2-Byte Typ, so wird i durch die Typumwandlung mit –15536 bewertet(!). Für die Portabilität von Programmen ist es daher wichtig zu wissen, dass solche impliziten Typkonversionen stattfinden, u.a. bei Wertzuweisungen, Vergleichen, Funktionsaufrufen. 왘 Verknüpfungen von char, short, enum mit int werden stets in int umgewandelt
(analog unsigned). 왘 Verknüpfungen mit long werden stets in long int umgewandelt (analog unsigned
long int).
68
4 Abgeleitete Datentypen
왘 Verknüpfungen mit float werden stets in float umgewandelt. 왘 Verknüpfungen mit double werden stets in double umgewandelt.
In C++ kann für explizite Typumwandlungen (cast) die Funktionsschreibweise angewandet werden. long i; int j = 100; i = (long) j; // C-Stil i = long(j); // alter C++-Stil
In Abschnitt 5.5 finden sich weitere Operatoren zur Typumwandlung. In C++ erübrigt sich beim Aufruf von mathematischen Funktionen die explizite Umwandlung int->double, wenn ein passender Funktionsprototyp vorliegt. Manche Programmierer behalten in C++ die explizite Konversion aus Gründen der C-Kompatibilität bei. x x x x
Auch bei Funktionsaufrufen kommt es beim Vergleich mit den formalen Parametern zu Konversionen des Typs T, unter anderem:. von
zu
T
T& (Referenz)
T&
T
T
const T
T*
const T*
T&
const T&
4.6
Reihungen
Unter einer Reihung (array) versteht man die Zusammenfassung von Objekten des gleichen Typs zu einem Ganzen. Die Kennzeichnung der Objekte geschieht durch Indizierung; d.h., auf jedes Element der Reihung kann mit Hilfe des Index zugegriffen werden. In C/C++ beginnt die Nummerierung stets bei Null. Die Deklaration einer Reihung beginnt mit dem Datentyp, gefolgt von dem Objektnamen und der Anzahl der Objekte. int a[10]; // 10 Elemente von a[0] bis a[9] double b[50]; // b[0] bis b[49]
Die Anzahl der Objekte muss hier stets eine Konstante (oder ein konstanter Ausdruck) sein, damit der Compiler im Voraus den benötigten Speicherplatz ermitteln kann.
4.6 Reihungen
69
const int size = 100; int sieb[size+1];
Bei reinen Deklarationen kann die Elementezahl entfallen. extern double mat[];
Reihungen können gleichzeitig initialisiert werden. int a[3] = {0,1,2}; float b[4] = {0}; // unvollständig double c[] = {0.,1.,2.,3.,4.}; // Elementezahl kann entfallen
Wird die Reihung vollständig initialisiert, d.h. entspricht die Länge der Initialisierungsliste der Elementzahl, so kann die Angabe der Grenze entfallen. Im Fall einer unvollständigen Initialisierung werden fehlende Werte durch Null ergänzt. Die Ausgabe einer Reihung erfolgt typischerweise mit einer FOR-Schleife (vgl. Abschnitt 6.4). const int n=12; int a[n]; for (int i=0; i
Reihungen können nur elementweise kopiert werden. const int n=12; int a[n],b[n]; for (int i=0; i
Wie alle anderen einfachen Datentypen können Reihungen auch als konstant erklärt werden und müssen initialisiert werden. const tageimmonat[12]={31,28,31,30,31,30,31,31,30,31,30,31}; // retardierte Nummerierung der Monate
C-Strings sind Reihungen von Buchstaben und werden üblicherweise durch ein Nullbyte ('\0') terminiert. Damit wird die Speicherlänge um Eins größer als die physikalische Länge. Näheres über Zeichenketten folgt in Abschnitt 4.7. Reihungen stehen in C/C++ in enger Beziehung zu den Zeigertypen. Der Name einer Reihung stellt nämlich einen konstanten Zeiger auf das erste, in der Zählweise von C nullte, Feldelement dar. Für eine Reihung a[] sind a // oder &a[0]
gleichbedeutend. Der Zugriff auf ein Element geschieht durch Dereferenzierung: a[i] // oder *(a+i)
70
4 Abgeleitete Datentypen
Leider vermisst man in C eine weitere Unterstützung des Reihungskonzepts, wie sie andere Programmiersprachen bieten: 왘 Laufzeitkontrolle von Indizes (wie in Java) 왘 Möglichkeit zur Vereinbarung beliebiger Indizes (wie in Pascal) 왘 Kopiermöglichkeit von gleichartigen Reihungen
Der sizeof-Operator kann auch auf Reihungen angewandt werden. Er liefert das Produkt aus der Anzahl der Elemente und der Bytezahl des Elementtyps. int a[5] = {1,2,3,4,5}; //2 oder 4 Byte cout << sizeof(a) << endl; // Ausgabe 10 oder 20
Umgekehrt kann die Elementezahl einer Reihung ermittelt werden durch int n = sizeof(a)/sizeof(int); // Klammern optional
Mehrdimensionale Felder werden deklariert, indem man für jede Dimension eine separate eckige Klammer schreibt. double mat[3][3] = {{1,2,3},{3,4,5},{5,6,7}};
ist somit eine Definition einer dreireihigen quadratischen Matrix. Gleichbedeutend sind wieder die Zugriffe: mat[i][j] // oder *(*mat+i)+j)
Eine weitere Schwierigkeit stellt die Übergabe einer mehrdimensionalen Reihung an eine Prozedur dar. C/C++ übergibt nämlich nur einen Zeiger, der auf den Anfang eines eindimensionalen Speicherbereichs weist, jedoch nicht die Array-Länge. So muss beispielsweise beim Aufruf einer Sortierprozedur die Arraylänge mit übergeben werden: sort(int a[],int n);
4.7
Der struct-Typ
Der zweite höhere Datentyp neben dem Aufzählungstyp ist der Verbund struct, der in Pascal record genannt wird. Der Verbund ist eine Zusammenfassung von nicht notwendig gleichartigen Objekten zu einem Ganzen wie: struct bruch // spaeter als Klasse definieren { int zaehl,nenn; }; struct datum // spaeter als Klasse definieren { int tag,monat,jahr; };
Der Typ struct ist deshalb von besonderer Bedeutung, da der Verbundstyp im Zuge des OOP zur Klasse (class) verallgemeinert wurde.
4.7 Der struct-Typ
71
Ein Verbund kann als Ganzes initialisiert und kopiert werden. bruch a,b={2,3}; a =b; datum xmas = {24,12,2000};
Die Komponenten (englisch members oder fields) eines Verbunds können mit dem Punkt-Operator angesprochen werden: int t = xmas.tag; int m = xmas.monat; int z = a.zaehl; int n = a.nenn;
Erfolgt der Zugriff über einen Zeiger, so kann das Operatorenpaar (*.) ersetzt werden durch den Pfeil-Operator ->. (*x.mas).tag // oder xmas->tag (*a).nenn // oder a->nenn
Verbunde können sich wieder aus Verbunden oder Aufzählungstypen zusammensetzen: struct datum {int tag,monat,jahr;}; struct adresse {long PLZ;char* wohnort;char* strasse;}; enum famstand {ledig,verheiratet,geschieden,verwitwet}; enum geschlecht {maennlich,weiblich}; struct person { char name[80]; datum geburtstag; famstand f; geschlecht g; adresse a; };
Ein Beispiel einer Personendefinition ist: person p; strcpy(p.name,"Gabi Meier"); p.geburtstag.tag = 10; p.geburtstag.monat = 9; p.geburtstag.jahr = 1970; p.g = weiblich; p.f = ledig; p.adr.PLZ = 81330; strcpy(p.adr.wohnort;"Muenchen"); strcpy(p.adr.strasse;"Ringstr.23");
Die Ausgabe erfolgt im Format: Name..............Gabi Meier Geburtstag........10.9.1970 Geschlecht........weiblich Familienstand.....ledig PLZ...............81330 Wohnort...........Muenchen Strasse...........Ringstr.23
Der obige Verbund kann auch verschachtelt werden: struct person { struct datum {int tag,monat,jahr;} struct adresse {long PLZ;char wohnort[];char strasse[]}; char name[80]; datum geburtstag; famstand f; geschlecht g; adresse a; };
Alle Komponenten eines Verbunds sind im Gegensatz zu den Elementen einer Klasse öffentlich (public), d.h., sie können außerhalb ihres Blocks überall angesprochen werden. Folgende Typen kartesisch/Kartesisch sind somit gleichwertig: struct kartesisch {double x,y;}; class Kartesisch { public:
4.7 Der struct-Typ
73
double x,y; };
Die Typen werden deklariert bzw. definiert durch: kartesisch k; kartesisch k(1,2);
Auch Verbunde können wie die einfachen Datentypen an Funktionen (Kapitel 7) übergeben werden. Betrachtet wird der Verbund Koerper, der den Typ eines Rotationskörpers repräsentiert: enum Rotationskoerper { kugel,zylinder,kegel}; struct Koerper { double radius,hoehe,volumen; };
Zur Berechnung der Volumina wird die Funktion rauminhalt() definiert, die mit Hilfe einer switch-Anweisung (Vorgriff auf Abschnitt 6.2) die entsprechende Volumenformel zuordnet (Näheres über Funktionen erfahren Sie in Kapitel 7). double rauminhalt(Rotationskoerper R,double r,double h=1) { double vol; switch(R) { case kugel: vol = 4./3*M_PI*pow(r,3.); break; case zylinder: vol = M_PI*r*r*h; break; case kegel: vol = 1./3*M_PI*r*r*h; } return vol; }
Das Hauptprogramm liest die Parameter ein und ruft die Volumenfunktion auf: int main() { ......... cout << "Radius Hoehe des Zylinders? "; cin >> zyl.radius >> zyl.hoehe; zyl.volumen = rauminhalt(zylinder,zyl.radius,zyl.hoehe); cout << "Volumen = " << zyl.volumen << endl; return 0; }
74
4 Abgeleitete Datentypen
4.8
Strings
C++ kennt zwei Arten von Zeichenketten (strings): 왘 char[],wchar_t[] // C-Strings 왘 string // Klasse der Standard-Bibliothek
4.8.1 C-Strings C-Strings sind Reihungen des Typs char bzw wchar_t. Sie können wie gewöhnliche Datentypen im Eingabe-/Ausgabestrom cin/cout auftreten. char str[20] = "ABCDEFG"; cout << str << endl; // Ausgabe ABCDEFG str[5] = '*'; // Array-Zugriff cout << str << endl; // Ausgabe ABCDE*G cout << "Gib einen String ein! "; cin >> str; cout << "Eingegebener String = " << str << endl;
Der Eingabestrom ignoriert führende Leerstellen und stoppt bei beliebigen Separatoren (Whitespaces). Wird » Hallo, World!« eingegeben, kommt nur »Hallo« in den Eingabestrom, der Rest verbleibt im Tastaturpuffer. Die Länge eines C-Strings im Speicher wird durch das Nullzeichen '\0' begrenzt. Das System speichert nur einen (konstanten) Zeiger auf das erste Zeichen des Strings; ein C-String ist somit ein Zeiger auf ein Speichersegment, dessen Elemente vom Typ char sind. C-Strings können mit leeren Klammerpaar [] initialisiert werden, der Computer ermittelt dann die Stringlänge. Gleichwertig sind char name[] = "Bjarne Stroustrup"; char name[18] = "Bjarne Stroustrup"; char* const name = "Bjarne Stroustrup";
Die Deklaration char *str;
ist kein gültiger String, auch kein leerer. Notwendig ist eine Zuweisung an eine bestehende char-Adresse. char char str2 str2
str1[20] = "Hello, World!"; *str2; = "C++ is great"; // Literal ok = str1; // C-String ok
Wertzuweisungen wie char str1[20] = "Hello, World!"; char str2[20] = "C++ is great"; str2 = str1; // falsch
sind bei C-Strings nicht möglich, dafür gibt es eine spezielle Funktion strcpy(). Zum Arbeiten mit C-Strings stellt die Header-Datei wichtige Funktionen bereit.
4.8 Strings
75
Vor der Umbenennung der Header-Datei durch die ISO C++-Norm hieß die Datei <string.h>, sodass es bei älteren Compilern zu einer Namenskollision mit der HeaderDatei der STL-Klasse kommen kann. C-String-Funktionen sind: strcat() strchr() strcmp() strcpy() strlen()
// // // // //
Verkettung Zeichensuche im String lexikografischer Vergleich neuer Inhalt String-Länge ohne '\0'
Für beide Strings liefert strlen() dann die Länge 4. Der Funktionswert von strlen() stimmt nicht mit dem Wert des sizeof-Operators überein, der stets die Länge des belegten Speicherplatzes angibt. char str[20] = "Beethoven"; cout << strlen(str) << endl; // Ausgabe 9 cout << sizeof(str) << endl; // Ausgabe 20
Sollen Leerstellen und Zeichen innerhalb von Datensätzen eingelesen werden, so ist die C++-Funktion cin.getline() zu verwenden, wie im Beispiel: #include const int LAENGE = 80; // Laenge Daten const int ANZAHL = 10; // Anzahl Daten typedef char Datensatz[LAENGE]; int main() { Datensatz Buch[ANZAHL]; int n=0; while(cin.getline(Buch[n++],LAENGE) && (n
76
4 Abgeleitete Datentypen
--n; // letztes Erhoehen rueckgaengig for (int i=0; i
Es lassen sich dann ganze Datensätze einlesen wie Bjarne Stroustrup, The Design and Evolution of C++ (1994)
4.8.2 C++-Strings Der C++-Standard definiert die Klasse string in der Header-Datei <string> im Standard-Namensraum std. Genau genommen ist dies ein Alias für das Template: typedef basic_string string;
Eine Deklaration erfolgt in der Form: #include <string> using namespace std; string str1; // leerer String
Die Verkettung kann mit dem (überladenen) Operator »+« oder »+=« geschehen: string str6 = str2 + "is great"; // NY is great str4 += "*****"; // 15 Sternchen
Auch die Vergleichsoperatoren sind zur Anwendung auf Strings überladen: (str1 (str1 (str1 (str1
< str2) // str1 alphabetisch vor str2 <= str2) // str1 alphabetisch vor str2 oder gleich != str2) // str1 ungleich str2 == str2) // str1 gleich str2
Auch der Array-Zugriff ist definiert: str2[4] = 'C'; cout << str2 << endl; // liefert New Cork
Einige viel verwendete String-Funktionen sind: substring() // liefert Teilstring erase() // löscht Teilstring replace() // ersetzt Teilstring find() // sucht char length() // liefert Länge
Dateinamen auf Betriebssystemebene müssen C-Strings sein. Daher gibt es eine Umwandlungsfunktion in einen C-String c_str(). string dateiname; cout << "Welche Datei? "; cin >> dateiname; ifstream datei(dateiname.c_str()); // Datei öffnen
4.9
Übungen
Übung (4.1): Erklären Sie, warum das folgende Programm nicht »Hello World« ausdruckt (vgl. Sie Abschnitt 4.2)! // ueb4_1.cpp #include using namespace std; char *p = "Hello World"; char a[12]; void init() { int i; char* p; for(p = a,i = 0; i<5; i++) *p++ = 'a'+i; } int main() { init(); cout << p <<endl; return 0; }
Übung (4.2): Erklären Sie die Ausgabe des folgenden Programms! // ueb4_2.cpp #include using namespace std;
78
int main() { int x=11,y=13; int* p = &x,*q cout << x << ' *p += 7; *q += cout << x << ' return 0; }
4 Abgeleitete Datentypen
= &y; ' << y << ' ' << endl; 7; ' << y << ' ' << endl;
Übung (4.3): Schreiben Sie einen Verbund (struct) zum Typ Buch. Verwenden Sie z.B. die Komponenten Autor, Titel, Verlag, Erscheinungsjahr, ISBN. Formulieren Sie auch eine geeignete Eingabe von Tastatur und Ausgabe am Bildschirm. Übung (4.4): Schreiben Sie einen Verbund zum Typ PKW.
5
Ausdrücke und Anweisungen
In diesem Kapitel werden die in C++ möglichen Ausdrücken und die Anweisungen besprochen.
5.1
Arithmetische Ausdrücke
Ausdrücke (expressions) entsprechen den aus der Mathematik bekannten Termen. Sie bestehen aus einer Verknüpfung von Objekten (Variablen, Konstanten und Funktionswerte) mit Operatoren. Arithmetische Ausdrücke enthalten die Operatoren +,-,*,/ ,%. Beispiele sind: 17+12 17%12 // Modulo-Rechnen 3*(a-b) 7*sin(x) x = 3 // kein Semikolon x == 3
Bei der Auswertung von Ausdrücken ist die Präzedenz (Rang) und Assoziativität der Operatoren zu berücksichtigen. Für arithmetische Operatoren stimmt die Präzedenz mit den mathematischen Rechenregeln (Punkt vor Strich) überein, sofern nicht Klammern entgegenstehen. Die Assoziativität gibt an, in welcher Richtung ein Ausdruck a b c ausgewertet wird ( stellt hier irgendeinen Operator dar).
a b c = ( a b) c // linksassoziativ a b c = a (b c) // rechtsassoziativ Es folgen zwei Beispiele zur Auswertung von Operatoren: Was ergibt x *= y += z = 3; wenn x = 2, y= 3 vorgegeben sind? Die Operatorentabelle 3.1 ergibt für die auftretenden Zuweisungsoperatoren »=,*=,+=« dieselbe Priorität 2 und rechte Assoziativität. Fasst man von rechts zusammen, erhält man (x *= (y += (z=3))). Dies ergibt (z=3), also 3. Damit folgt (y += 3) oder (y = 6), also 6. Schließlich folgt (x *= 6) oder (x=12), also die Bewertung 12. Was ergibt z += z < y ? x++:y++; wenn x = 3, y = 2, z =1 vorgegeben sind? Die Operatorentabelle 3.1 zeigt für den auftretenden Zuweisungsoperator »+=« die Priorität 2 und rechte Assoziativität, abgekürzt geschrieben als (2,r). Für den Bedingungsoperator findet sich (3,r). Der Inkrementoperator hat die höchste Priorität (15,r). Fasst man von rechts zugehörige Terme zusammen, erhält man (z += ((z < y)?(x++):(y++)). Die
80
5 Ausdrücke und Anweisungen
Bewertung zeigt (z += ((1<2)?(3++):(2++)). Ausführen des Bedingungsoperators ergibt, wegen (1<2) gleich wahr, 3++. Dies liefert (z += 3++), also (z=4) und (x=4). Insgesamt folgt die Bewertung 4. Der False-Teil des Bedingungsoperators wird nicht ausgeführt, y bleibt daher 2. Ausdrücke sind u.a.: 왘 arithmetische Ausdrücke 왘 logische Ausdrücke 왘 Zuweisungen 왘 Primärausdrücke 왘 Postfix-Ausdrücke
Ein Primärausdruck ist einer der folgenden Formen: 왘 Literal (Zahlkonstante, Stringkonstante) 왘 this 왘 ::Bezeichner (:: ist der Scope-Operator) 왘 ::Operator 왘 { Ausdruck } 왘 Name
Eine detailliertere Aufzählung aller möglichen Ausdrücke ist Gegenstand einer Sprachreferenz; interessierte Leser(innen) werden auf die ISO-Norm oder das Handbuch von Stroustrup[08] verwiesen.
5.2
Logische Operatoren
Logische Ausdrücke enthalten die Vergleichs- und logischen Operatoren: =,!=,<,>,<=,>= (gleich,ungleich,größer,kleiner usw.) ! (logisches Nicht) && (logisches Und) || (logisches Oder)
Beispiele mit logischen Operatoren sind:
5.2 Logische Operatoren
81
if (a==0 && b==0) // beide gleich Null if (x<-1 || x>1) // Betrag größer 1 if (x >= -1 && x <= 1) // Betrag kleiner oder gleich 1 gerade = x % 2 == 0 positiv = x>0 volljaehrig = alter >= 18 weiblich = !maennlich
Das logische Oder a ∨ b ist wahr, wenn a oder b wahr ist. Dies ist nicht zu verwechseln mit dem ausschließenden Oder (englisch eXclusive OR), das auch xor genannt wird. a xor b ist also genau dann wahr, wenn a und b verschiedene Wahrheitswerte haben. Das logische Und wird in der Mathematik als a ∧ b geschrieben. Die Bedingung, dass die positiven Zahlen a, b, c die Seiten eines Dreiecks bilden, ergibt sich aus der Dreiecksungleichung: ist_dreieck = (abs(a-b) < c) && (c < a+b)
Aus den logischen Und, Oder und Nicht lassen sich weitere Verknüpfungen bilden, wie die Implikation und die Äquivalenz. Letztere ist die Verneinung von xor.
a imp b ⇔ a ∨ b b imp a ⇔ b ∨ a a eqv b ⇔ a == b a xor b ⇔ ( a ∧ b) ∨ ( a ∧ b) Eine wesentliche Eigenart von C/C++ ist, dass prinzipiell alle Ausdrücke bewertet werden. Bei booleschen Termen kann es allerdings zu einem so genannten Kurzschluss (englisch short cut evaluation) kommen, bei dem der Wahrheitswert bereits festliegt, ohne dass der Gesamtterm vollständig ausgewertet wurde. Alle Werte ungleich Null werden als wahr, alle Werte gleich Null als falsch interpretiert. Das bedeutet, dass es keinen vollständig implementierten booleschen Datentyp wie etwa in Pascal oder Java gibt. Das Ermitteln von Wahrheitswerten wird in C++ mit Hilfe der Werten 0 bzw. !=0 simuliert; true und false sind nur als Konstanten definiert. Dies hat u.a. die Folge, dass auch folgender Ausdruck: if (x=1) x=2; else x=3; // statt x==1
syntaktisch korrekt ist. Da die Anweisung x=1; mit 1 – also als wahr – bewertet wird, erhält hier stets x den Wert 2 statt des beabsichtigten x=3. Neuere Compiler geben aber hier stets eine Warnung aus. Achtung: Eine weitere Folge ist, dass auch sinnlose Ausdrücke bewertet und damit vom Compiler akzeptiert werden, wie: if (0 < x < 5) // falsch statt (0<x && x<5)
Mit einem kleinen Programm kann die Wahrheitstafel für die logischen Operatoren && bzw. || mit zwei FOR-Schleifen erzeugt werden. Die Wahrheitswerte werden dabei
82
5 Ausdrücke und Anweisungen
durch Prüfen auf 1 erzeugt. Dieser Trick wird hier verwendet, da gemäß der ISO-Norm kein Inkrementoperator für boolesche Werte definiert ist. Boolesche Werte können daher nicht als Schleifenvariablen verwendet werden. for (int a=0; a<=1; a++) for (int b=0; b<=1; b++) { bool p = (a==1); bool q = (b==1); cout << p << " " << q << " " << (p&&q) << " " << (p||q) << endl; }
Die Ausgabe ist hier in Tabellenform zusammengefasst. p
q
p && q
p || q
0
0
0
0
0
1
0
1
1
0
0
1
1
1
1
1
Die Defaultwerte für die Ausgabe von Booleschen Werten sind 0/1. Zur Ausgabe der Konstanten false/true dient der Manipulator boolalpha aus der Header-Datei . #include bool p = (a ==1); cout << boolalpha << p; // Ausgabe false/true
Die Aussagenlogik kennt (22 )
2
= 16
logische Verknüpfungen zweier booleschen Variablen. Es sind dies die folgenden acht Formen und deren Verneinungen. p
stets p
q
stets q
true
stets wahr
p && q
p und q
p || q
p oder q
p imp q
p impliziert q
q imp p
q impliziert p
p eqv q
p äquivalent q
5.2 Logische Operatoren
83
Bei logischen Ausdrücken findet man oft fehlende Operatoren wie: if (x) // (x!=0) while(rest) // (rest!=0) if (!found) // (found==0)
Fehlende Vergleichsoperatoren werden dabei als (!=0), bei vorangestellter Verneinung als (==0) gewertet. Alle Vergleichsoperatoren sind linksassoziativ. Hat x den Wert Null, so erhält der nicht korrekte Ausdruck -1 < x < 1 // falsch statt (-1<x && x<1)
den Wert 0. Die Bewertung gemäß der Assoziativität ergibt (-1<0)<1 und somit true<1, also 1<1 und somit falsch! Von den logischen Operatoren gibt es auch Wortformen, die ursprünglich für nichtamerikanische Tastaturen gedacht waren. Sie finden sich in der Header-Datei , früher genannt. Es sind: and
für &&
or
für ||
not_eq
für !=
not
für !
Das obige Programm für logische Operatoren lässt sich lesbarer schreiben als: #include for (int a=0; a<=1; a++) for (int b=0; b<=1; b++) { bool p = (a==1); bool q = (b==1); cout << p << " " << q << " " << (p and q) << " " << (p or q) << endl; }
Andere Anwendungen sind der Boolesche Test auf ungerades x: ist_ungerade = x and 1;// niederwertigstes Bit=1
oder die Prüfung, ob das Zeichen c einen Buchstaben darstellt: is_letter = ((c >= 'a') and (c <= 'z') or (c >= 'A') and (c <= 'Z'));
und die Schaltjahrbedingung für das Jahr j schaltjahr = (j % 4 ==0) and (j % 100 != 0) or (j % 400 == 0);
Bei den beiden letzten Ausdrücken wird von der Priorität von and gegenüber or Gebrauch gemacht. Logische Ausdrücke werden nach der ISO-Norm in KurzschlussAuswertung (short circuit) behandelt. Dies bedeutet, dass ein Und-Term wie a && b
bereits falsch ist, wenn nur a falsch ist. Entsprechend ist ein Oder-Term wie: a || b
84
5 Ausdrücke und Anweisungen
wahr, wenn nur a wahr ist. Manche Terme können nur im Kurzschluss ausgewertet werden: if ((x!=0) && (a/x ==1)) ...
Ist nämlich hier x == 0, so wird die Auswertung bereits abgebrochen, ohne dass es im zweiten Term a/x zur Division durch Null kommt. Man sollte sich jedoch auf solche Konstruktionen nicht immer verlassen, da solche Kurzschluss-Auswertungen bei manchen Compilern abgeschaltet werden können. Zu vermeiden sind logische Terme, die einen Nebeneffekt enthalten, wie: if ((x < 3) && (++y >= z)) Achtung
Dies führt zu einem schwer vorhersagbaren Verhalten, da der Nebeneffekt (++y) nur eintritt, wenn keine Kurzschluss-Auswertung ausgeführt wird!
5.3
Bitoperatoren
Die Bitoperationen in C++ sind ein Erbe von C aus der Systemprogrammierung. Sie werden in C++ hauptsächlich zur Steuerung von Flags zur Ein-/Ausgabe in der Klasse ios (vgl. Kapitel 12) verwendet. Beim Schreiben von Systemprogrammen, die direkt auf die Hardware zugreifen, ist zu beachten, dass bereits die Anordnung von Highbzw. Low-Byte maschinenabhängig ist. Bitoperatoren sind die Shift-Operatoren, Bit-Oder, Bit-Und, Bit-Xor (ausschließendes Oder): <<,>>
Left-/Right-Shift
~
Bit-Not
|
Bit-Oder
&
Bit-And
^
Bit-Xor
Betrachtet wird der folgende Programmausschnitt: unsigned unsigned unsigned unsigned unsigned
a b c d e
= = = = =
237L; 199L; a & b; // 197 a | b; // 239 a ^ b; // 42
Die auftretenden vorzeichenlosen 32-Bit-Zahlen haben folgendes Bitmuster: a b c d e
= = = = =
00000000 00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000 00000000
00000000 00000000 00000000 00000000 00000000
11101101 11000111 11000101 11101111 00101010
5.3 Bitoperatoren
85
f = 11111111 11111111 11111111 00010010 g = 11111111 11111111 11111111 00111000
In c sind alle Bits gesetzt (=1), die auch in a und b gesetzt sind. In d sind alle Bits gesetzt, die auch in a oder b gesetzt sind. Entsprechend sind in e alle Bits gesetzt, die sich in a und b unterscheiden. Die Shift-Operatoren: x << n, x >> n
(in ihrer nicht überladenen Bedeutung) verschieben das Bitmuster der Zahl x um n Stellen nach links bzw. rechts. Ein Beispiel für 16 Bitzahlen ist: unsigned unsigned unsigned unsigned unsigned unsigned unsigned
Mit Hilfe eines kleinen Programms kann die Binärdarstellung einer vorzeichenlosen 16-Bit-Zahl gefunden werden. Um das aktuelle Bit zu erhalten, wird die Eins mittels Shift-Operator auf die Position gebracht: bit = 1 << n
Ist dieses Bit gesetzt, so liefert das Bit-Und bit & x == 1
und wird eine Eins ausgegeben, andernfalls eine Null. Vergleiche dazu den folgenden Programmausschnitt: unsigned int x; cout << "Eingabe x>0? "; cin >> x; for (int i=15; i>=0; i--) { unsigned bit = 1 << i; (bit & x)? cout << "1" : cout << "0"; } cout << endl;
Der Bit-xor-Operator liefert, zweimal angewandt, wieder das ursprüngliche Ergebnis. unsigned a=237,b=199; a ^= b; // 42 a ^= b; // wieder 237
Jede Operation, die bestimmte Bits ändert, kann generell durch eine andere Operation rückgängig gemacht werden. Ein Beispiel mit 2 Byte-Werten ist:
86
5 Ausdrücke und Anweisungen
unsigned h = 255; // binaer 00000000 11111111 h &= 0xf0; // 240,binaer 00000000 11110000 h |= 0xf; // wieder 255
Wie aus den beiden letzten Zuweisungen ersichtlich wird, können mit den Operatoren &= und |= bestimmte Bitstellen gesetzt oder gelöscht werden. Man spricht hier auch von Bit-Masken. Wie Sie sicher bemerkt haben, dienen die Shift-Operatoren in C++ auch zur Ein- /Ausgabe im Zusammenhang mit cout/cin. Beim Einbinden der Header-Datei werden die Shift-Operatoren zu Ein- und Ausgabeoperatoren. Diese Möglichkeit in C++, die Bedeutung eines Operators zu ändern, nennt man Überladen. Auch für Bitoperatoren finden sich in die besser lesbaren Wortformen: bitand
statt &
bitor
statt |
xor
statt ^
and_eq
statt &=
or_eq
statt |=
Ein Beispiel zeigt das Programm: #include #include using namespace std; int main() { for (int p=0; p<=1; p++) for (int q=0; q<=1; q++) { cout << p << " " << q << " " << (p bitand q) << " " << (p bitor q) << " " << (p xor q) << endl; } return 0; }
Mit Hilfe der Bitoperationen werden systemintern sämtliche arithmetische Operationen abgewickelt. So kann z.B. die Subtraktion zweier Zahlen auf die Addition des Minuenden nach einer Bitumkehr zurückgeführt werden. Wichtig sind die Bitoperatoren, die Ausgabe ostream mittels so genannter Flags steuern. Eine Ausgabe mit Fließkomma und Exponentialdarstellung wird erzeugt durch: cout.setf(ios_base::scientific|ios_base::floatfield);
5.4 Zuweisungen
5.4
87
Zuweisungen
Jeder Ausdruck ist entweder ein so genannter Lvalue (Kurzform von Left value) oder ein Rvalue (Kurzform für Right value). Der Begriff Lvalue bedeutete ursprünglich einen Ausdruck, der auf der linken Seite einer Wertzuweisung stehen konnte: lvalue = Ausdruck;
Nach neuerer Interpretation ist alles, was einen Speicherbereich hat, ein Objekt. Ein LWert (englisch lvalue) ist damit ein Ausdruck, der ein Objekt oder eine Funktion bezeichnet. Es kann aber nicht jeder L-Wert auf der linken Seite einer Zuweisung stehen, z.B. eine Konstante. Man unterscheidet daher noch: Ein L-Wert heißt modifizierbar, wenn er keine Konstante und keinen Funktionsnamen und Array-Namen kennzeichnet. Eine Rvalue ist damit ein Ausdruck, der kein Lvalue ist. Steht ein Lvalue auf der rechten Seite einer Zuweisung, so wird er automatisch in einen Rvalue umgewandelt. Zuweisungsoperatoren sind neben »=« alle Operatoren der Form
= wobei » « für einen binären (d.h. zweistelligen) Operator steht. Diese Operatoren stellen Kurzschreibweisen dar: a += b // bedeutet a = a+b a ^= b // bedeutet a = a^b
Für int-Typen sind die Zuweisungen a += 1 /* oder */ ++a b -= 1 /* oder */ --b
gleichbedeutend mit der Anwendung des Inkrement- bzw. Dekrement-Operators. Diesen gibt es in zwei Formen, der so genannten Präfix-Form und der Postfix-Form. ++a,--b // Präfix a++,b-- // Postfix
Der Unterschied zwischen den beiden Formen äußert sich in der Zuweisungsreihenfolge. Bei der Präfix-Form wird vor der Zuweisung inkrementiert bzw. dekrementiert, bei der Postfix-Form geschieht dies nach der Zuweisung. a = 3; x = ++a; // liefert x=4,a=4 x = a++; // liefert x=3,a=4
Zuweisungsoperatoren sind rechtsassoziativ. Dies hat zur Folge, dass Anweisungen wie a = b = c = 0
geschrieben werden können. Die Bewertung von rechts liefert: a = (b = (c = (d = 0)))
88
5 Ausdrücke und Anweisungen
d erhält den Wert Null, die Zuweisung wird ebenfalls mit Null gewertet und dies ist auch der Wert, den c erhält. Diese Bewertung setzt sich fort, bis schließlich a=0 gesetzt
wird. Zuweisungen können auch verschachtelt werden. Der Ausdruck: c =(a += 5)/(b*=2);
ist gleichbedeutend mit a += 5; b *= 2; c = a/b;
Hat a anfangs den Wert 7, b den Wert 2, so erhält c damit den Wert 3. Es ist klar, dass solche Ausdrücke schwer lesbar sind. Zu beachten ist, dass die Auswertungsreihenfolge innerhalb von Ausgaben prinzipiell nicht definiert und damit compilerabhängig ist. Das selbe Programm int main() { int a=7,b=2,c=5; cout << ++a << " " << b-- << " " << (c += b) << endl; return 0; }
liefert dann auf einem PC bzw. einer SUN verschiedene Ausgaben, was sicher unerwünscht ist: 8 8
2 2
7 // PC mit Borland C++ 5.0 6 // SUN SPARKstation mit SUNWpro C++ 3.0
Bei Operatoren gleicher Priorität ist keine Reihenfolge definiert. So lässt sich beim nächsten Programmausschnitt durch keine Klammersetzung die Reihenfolge der Funktionsauswertungen beeinflussen. int f(int x) { cout << x << " "; return x; } int main() { int a=(f(1)+f(2))-(f(3)+f(4)); return 0; }
Die Ausgabe, die in der Funktion f() erzeugt wird, kann hier jede Permutation von 1 bis 4 sein!
5.5 Weitere Operatoren
89
Auch die Reihenfolge von Auswertungen, bei der dieselbe Variable auf beiden Seiten einer Zuweisung erscheint wie bei x[i] = ++i; // Auswertung unklar
sind nicht definiert und im Interesse der Vorausberechenbarkeit zu vermeiden. Solche Effekte werden in Anzeigen gern als Werbung für spezielle Software-Produkte verwendet. So publiziert die Firma Gimpel folgenden angeblichen Bug von C/C++ class X // C/C++ Bug #564 { int a[100]; public: X(); int get(int i); }; X::X() { for (int i=0; i<100; i++) a[i] = i++; }
der eine nicht definierte Auswertungsreihenfolge a[i] = i++ enthält.
5.5
Weitere Operatoren
Zu erwähnen ist noch der dreistellige Bedingungsoperator ? : (auch FragezeichenOperator genannt). Er dient zum Verkürzen von bedingten Anweisungen. Statt if (a>=b) max = a; else max = b;
lässt sich verkürzt schreiben: max = (a>=b)? a : b;
Auch der Bedingungsoperator kann verkettet werden. Dies kann aber zu schwer lesbaren Ausdrücken führen wie: sgn = (x>0)? 1:(x==0)? 0:-1;// Vorzeichen
Der Fragezeichen-Operator ist der einzige dreistellige Operator. Der (binäre) Kommaoperator verbindet beliebig viele Zuweisungen miteinander, die strikt von links zusammengefasst werden. Typische Beispiele sind: h = a,a = b,b = h; // Dreieckstausch int a,b=1,c=2; // Deklaration u. Definition
Ebenfalls möglich sind auch Zuweisungen wie: int a = (cout << "a =",b+=2);// nicht empfohlen
Die Bewertung eines Ausdrucks mit Kommaoperator wird stets vom letzten Ausdruck geliefert, die links stehenden Ausdrücke werden als Seiteneffekt ausgeführt, hier die
90
5 Ausdrücke und Anweisungen
Ausgabe von »a = » (mittels cout). Bei dem oben gezeigten Dreieckstausch (unter Verwendung des Kommaoperators) kann die Verbund-anweisung entfallen. Ausdrücke mit new- und delete-Operatoren dienen zur dynamischen Speicher-belegung zur Laufzeit. int x = 13; int* p= new int; *p = x; cout << "x = " << (*p) << endl; delete p;
Diese Operatoren gibt es auch in einer Array-Version new [] und delete []. Der folgende Programmabschnitt erfragt eine ganze Zahl, reserviert zur Laufzeit Speicherplatz für das Array, füllt es mit den natürlichen Zahlen und löscht es nach der Ausgabe. #include #include <exception> int i,n; int* a; cout << "Wie viele Zahlen? "; cin >> n; try{ int* a = new int[n]; for (i=0; i
Wie Reihungen können auch zweidimensionale Matrizen zur Laufzeit alloziert werden: int **mat; // ganzzahlige (m,n)-Matrix try{ mat = new int*[m]; // erst Zeilen for (int j=0; j<m; j++) mat[j] = new int[n]; // dann Spalten } catch(bad_alloc a) ..
Das Löschen der Matrix muss in umgekehrter Reihenfolge erfolgen: for (int i=0; i<m; i++) delete[] mat[i]; // erst Spalten delete [] mat; // dann Zeilen
Hier ist zu bemerken, dass die Fehlermeldungsübergabe der Klasse bad_alloc nach der ISO-Norm (Abschnitt [lib.bad.alloc]) implementierungsabhängig ist.
5.6 Grenzen der Zahlbereiche
91
Schließlich sind noch Ausdrücke mit dem sizeof-Operator zu erwähnen. Dieser liefert Byte-Größen vom Typ size_t (entspricht meist unsigned). Als Beispiel sei der Verbund person folgendermaßen definiert: enum familienstand {ledig,verheiratet,geschieden,verwitwet}; enum geschlecht {maennlich,weiblich}; struct person { char famname[30]; familienstand famstand; geschlecht g; int alter; };
Der Operator liefert hier (bei 4 Byte-Integer) für sizeof person den Wert 42, der sich zusammensetzt aus den 30 Byte für den Namen und drei ganzzahligen Typen von je 4 Byte.
5.6
Grenzen der Zahlbereiche
Die im Abschnitt 3.7 genannten Grenzen der Zahlbereiche können aus der HeaderDatei , früher , mit Hilfe der neuen Template-Klasse numeric_limits in einem Programm abgefragt werden. Template-Klasse bedeutet hier, dass der jeweilige Datentyp in spitzen Klammern geschrieben werden muss: numeric_limits<…>
Die Header-Datei gestattet unter anderem folgende Abfragen: T min() // Minimum des Datentyps T T max() // Minimum des Datentyps T is_integer() // wahr für int T epsilon() // kleinste darstellbare Zahl beim Typ T T round_error() // max. Rundungsfehler beim Typ T int min_exponent10 // kleinster Zehnerexponent (Fließpunkt) int max_exponent10 // größter Zehnerexponent(Fließpunkt) bool has_infinity() // Darstellung von Unendlich bool is_iec559 // Typ entspricht IEC Norm 559(=IEEE 754) bool is_bounded // wahr, wenn Typ beschränkt
Das folgende Programm zeigt, welche Arten von Ausdrücken hier auftreten können: // limit.cpp #include #include #include using namespace std; int main() {
Die folgende Ausgabe ergibt sich an einem PC bei 4-Byte-Arithmetik: -2147483648 2147483647 0 4294967295 1.19209e-007 2.22045e-016 -307 308 1.#INF true
Die Header-Datei kann damit den Header weitgehend ersetzen.
5.7
Typumwandlungen
Wie in Abschnitt 4.5 gezeigt, stellt das Klammerpaar als Operator eine Typumwandlung (engl. cast) dar. Hier wird ein Zeichen 'A' (ASCII Nr. 65) in eine int-Zahl vom Wert 65 umgewandelt. char c = 'A'; int i = (int) c; // C-Stil int i = int(c); // alter C++-Stil
Um Umwandlungen typensicherer zu machen, wurde der alte Cast-Operator in der C++-Norm durch vier neue Umwandlungsoperatoren mit verschiedenen Anwendungsbereichen ersetzt. Diese Umwandlungsoperatoren haben die Form eines Templates, d.h. bei ihnen muss der jeweilige Datentyp in spitzen Klammern angegeben werden. static_cast<>
Definierte Umwandlung von Datentypen
dynamic_cast<>
Umwandlung von polymorphen Objekten
const_cast<>
vorübergehende Entfernung von const
reinterpret_cast<>
Nichtdefinierte Umwandlungen
5.7 Typumwandlungen
93
Der static_cast-Operator ist für alle Typumwandlungen gedacht, die der Compiler als gültig betrachtet. int i = static_cast('A'); // Verlust an Information int j = static_cast(1.2345); // Verlust an Stellen
Eingeschlossen ist die Umwandlung in Typen mit geringerer Bytezahl, wobei dem Compiler das »Wissen« des möglichen Genauigkeitsverlust signalisiert wird. Der Compiler verzichtet dann auf die übliche Warnung Conversion may lose digits. Der const_cast-Operator dient hauptsächlich dazu, vorübergehend einen als const deklarierten Parameter für einen bestimmten Zweck als nicht konstant zu erklären. int x= 7; const int* p; int* q = &x; p = q; // ok cout << *p << endl; // 7 q = p; // falsch! const int* ->int* q = const_cast(p); // ok cout << *q << endl; // 7
Eine weitere Anwendung des const_cast-Operators ist, dass Methoden mit constParametern nach Anwendung des Operators auch mit nicht-konstanten Parametern aufgerufen werden können. Der Einsatz des const_cast-Operators sollte nicht missbraucht werden. Es sollte auf keinen Fall dazu führen, das Konzept der Konstanten in C++ zu unterlaufen und womöglich alle Konstanten veränderbar zu machen. Der dynamic_cast-Operator ist gedacht für polymorphe Objekte, wie sie bei der Vererbung auftreten und wird im Abschnitt 11.6. behandelt. Alle anderen nicht erwähnten Fälle sind Sache des reinterpret-Operators. Hier kann ein Zeiger auf ein Objekt in einen Zeiger ganz anderer Art umgewandelt werden. char* p = new char[20]; int* q = reinterpret_cast p;
Neben den erwähnten cast-Operatoren gibt es noch den typeid-Operator, der hauptsächlich bei der Typermittlung zur Laufzeit (RTTI = Run Time Type Information) verwendet wird. Er kann aber auch für einfache Typen verwendet werden. Ein Programmausschnitt dazu ist: #include { char a = 'A'; int b = 3; double c = 5; if (typeid(a) == typeid(char)) cout << "Typ char\n"; if (typeid(b) == typeid(int)) cout << "Typ int\n"; if (typeid(c) == typeid(double)) cout << "Typ double\n"; }
94
5 Ausdrücke und Anweisungen
Die Header-Datei enthält eine Methode name() des Operators, mit deren Hilfe sogar der Name der Klasse oder des Datentyps abgefragt werden kann. Mit der Anwendung von name() kann das oben gegebene Programm vereinfacht werden zu: #include int i=2; bool b = true; long k = 1L; double d = 1.23; cout << "b ist vom Typ " cout << "i ist vom Typ " cout << "k ist vom Typ " cout << "d ist vom Typ "
Nach der ISO-Norm ist eine Anweisung (statement) gegeben durch eine: 왘 Ausdrucksanweisung (expression statement) »;« 왘 Verbundanweisung (compound statement) 왘 Deklarationsanweisung (declaration statement) 왘 Auswahlanweisung (if, if else, switch) 왘 Iterationsanweisung (while, do while, for) 왘 Sprunganweisung (break, continue, return, goto) 왘 Leeranweisung »;« (null statement) 왘 Anweisung nach Label (case, default)
Die Verbundanweisung und die weiteren Anweisungen – außer der bereits besprochenen Ausdrucksanweisung – werden im folgenden Kapitel 6 Kontrollstrukturen besprochen.
5.9
Der Namensraum
Namensräume (englisch namespaces) sind Gültigkeitsbereiche, in denen beliebige Objekte wie Variablen, Funktionen, Klassen, andere Namensräume deklariert werden können. Namensräume sind relativ spät in die C++-Norm aufgenommen worden. Sie lösen folgendes Problem: Verwendet man umfangreiche eigene und fremde Bibliotheken, so ist die Wahrscheinlichkeit groß, dass irgendwann einmal zwei Funktionen gleichen Namens auftreten. Deklariert man nun jede Bibliothek als eigenen Namensraum, so kann der Compiler die Funktionen gleichen Namens unterscheiden. Alle Bibliotheken der Standard Template Library (STL) sind zum Namensraum std zusammengefasst; er wird erklärt durch der using-Anweisung: using namespace std;
5.9 Der Namensraum
95
Ein Namensraum hat in C++ folgende Eigenschaften: 왘 Er kann deklariert werden. Alle in ihm enthaltenen Objekte können mittels des
Scope-Operators :: angesprochen werden. 왘 Für einen Namensraum darf ein Alias-Name gewählt werden. Ein Namensraum
ohne Namen (anonymer Namensraum) erhält vom Compiler einen internen Namen. 왘 Wird die Verwendung eines bestimmten Namensraums mittels der using-Anwei-
sung erklärt, so kann auf alle Objekte auch ohne Scope-Operator zugegriffen werden. Ein Programmbeispiel zeigt die Deklaration von Namensräumen: namespace A { int i=10; int f(int x) { return 2*x+1; } } namespace B { int i=20; int f(int x) { return 2*x; } } int main() { cout << "i in Namespace A: " cout << "i in Namespace B: " cout << "f() in Namespace A: for (int x=0; x<5; x++) cout cout << endl; cout << "f() in Namespace B: for (int x=0; x<5; x++) cout cout << endl; return 0; }
Man erhält die Ausgabe: i in Namespace A: 10 i in Namespace B: 20 f() in Namespace A: 1 3 5 7 9 f() in Namespace B: 0 2 4 6 8
Namensräume können auch verschachtelt werden: namespace A { int a = 11; }
96
5 Ausdrücke und Anweisungen
namespace B { int b = 15; namespace C { int c = 17;} } int main() { int a = 13; cout << a << endl; cout << A::a << endl; cout << B::b << endl; cout << B::C::c << endl; // doppelter Scope using namespace A; // Achtung! cout << a << endl; // Ausgabe 13 return 0; }
Die letzte Ausgabe zeigt den lokalen Gültigkeitsbereich von a innerhalb von main(). Es wird das lokale a=13 ausgegeben, obwohl eine using-Anweisung vorliegt! Zur Zuordnung eines Bezeichners führt der Compiler drei Schritte durch: 왘 Zuerst prüft er, ob der Bezeichner im lokalen Block definiert ist. 왘 Dann sucht er die Definition des Bezeichners in einem übergeordneten Block. 왘 Letztlich werden alle importierten Namesräume nach der Definition durchsucht,
einschließlich des lokalen, namenslosen (falls vorhanden). Für den verschachtelten Namensraum namespace B { int b = 15; namespace C { int c = 17;} }
kann ein Alias eingeführt werden: namespace AB = A::B;
Anonyme Namensräume namespace { }
erhalten vom Compiler einen internen Namen. Sie können verwendet werden, um Variablen mit dem Gültigkeitsbereich Datei zu definieren. namespace { ........ int i; }
5.10 Übungen
97
In C verwendet man statische Vereinbarungen wie static int i, um Variablen mit Datei-Gültigkeitsbereich zu definieren. Die Verwendung eines Namensraums ist hier die bessere Lösung, der Einsatz einer statischen Variablen verschleiert die Absicht der programmierenden Person.
5.10 Übungen Übung (5.1): Schreiben Sie ein Programm, das alle 16 Verknüpfungen zweier booleschen Variablen in Form einer Tabelle ausgibt (vgl. Abschnitt 5.2). Verwenden Sie die neuen Bezeichner nach ! Übung (5.2): Schreiben Sie ein Programm, das mit Booleschen Variablen folgendes Party-Problem löst: Fünf Leute kommen nur unter folgenden Bedingungen zu einer Party: Wenn A nicht kommt, dann kommt D. B kommt nur mit D oder gar nicht. Wenn A kommt, dann erscheinen auch C und D. Wenn C kommt, dann auch E. B kommt, wenn E nicht kommt und umkehrt. Wer kommt nun? Übung (5.3): Schreiben Sie ein Programm zum Beweis des folgenden Distributivgesetzes der Mengenalgebra:
( A ∩ B) ∪ C == ( A ∩ C ) ∪ (B ∩ C ) Hinweis: Das Gesetz ist gültig, wenn jede Belegung der booleschen Terme A, B, C den Wert true liefert. Übung (5.4): Die Schaltfunktionen
c = a ∧ b // carry s = a xor b realisieren einen Halbaddierer. Schreiben Sie ein Programm mit Bitoperatoren, das die Schalttabelle des Halbaddierers ermittelt.
6
Kontrollstrukturen
In diesem Kapitel werden die schon mehrfach verwendeten Kontrollstrukturen 왘 Verbundanweisung (compound statement) 왘 Bedingte Anweisung (selection statement) 왘 Wiederholungsanweisung (iteration statement) 왘 Sprunganweisung (jump statement)
besprochen.
6.1
Verbundanweisung
Die Verbundanweisung fasst eine Folge von Anweisungen mittels des geschweiften Klammerpaars {} zu einem Block zusammen. { int a = b = }//
h=a; b; h; Block
Jeder Block bildet einen neuen Gültigkeitsbereich (englisch scope). C++ kennt vier solcher Gültigkeitsbereiche: 왘 Lokaler Gültigkeitsbereich (Block) 왘 Gültigkeitsbereich einer Funktion 왘 Gültigkeitsbereich eines Namensraums (vgl. Abschnitt 5.8) 왘 Gültigkeitsbereich einer Klasse (mehr dazu in Kapitel 8)
Solche Blöcke können auch geschachtelt werden: { // aeusserer Block int a=3,b=5; { // innerer Block int h=a; a = b; b = h; } cout << a << " " << b << endl; }
Der innere Block stellt hier den Gültigkeitsbereich oder die Lebensdauer der auto(matischen) Variablen h dar; d.h., h wird zu Beginn des inneren Blocks erzeugt und
100
6 Kontrollstrukturen
gelöscht, wenn der Programmfluss den inneren Block verlassen hat. Entsprechend liefert der äußere Block die Lebensdauer der Variablen a, b. Es fördert die Verständlichkeit eines Programms, wenn alle Variablen nur eine kurze Lebensdauer haben. Die Blockstruktur bestimmt auch die Sichtbarkeit einer Variablen. Wenn nämlich in einem inneren Block eine Variable mit gleichem Bezeichner auftritt wie eine Variable des äußeren Blocks, so kann diese im inneren Block nicht mehr angesprochen werden, sie ist dann unsichtbar. Ein Programmbeispiel zeigt dies: #include using namespace std; int x = 12; // global void f(); int main() {// aeusserer Block float x = 1.2f; // lokales x { // innerer Block double x = 3.47; // lokales x, verdeckt x=1.2 cout << x << endl; // Ausgabe 3.47 cout << ::x << endl; // Scope global, Ausgabe 12 } f(); // Ausgabe 12 cout << x << endl; // Ausgabe 1.2 return 0; } void f() { cout << x << endl; } // Scope global
6.2
Bedingte Anweisungen
In C/C++ gibt es zwei bedingte Anweisungen: 왘 IF-Anweisung (Zweifach-Verzweigung) 왘 Switch-Anweisung (Mehrfach-Verzweigung)
Die IF-Anweisung hat die Form: if (expression) statement_1 else statement_2
Dabei kann der else-Teil ganz entfallen. Beispiele für bedingte Anweisungen sind if (x>=0) sgn = 1; else sgn = -1; // Signum x if (x>=0) abs = x; else abs = -x; // Absolutbetrag x
Werden zwei if-Anweisungen verkettet, so gilt das (von ALGOL übernommene) Prinzip, dass ein else-Teil immer zum letzten if gehört. if (a) if (b) s1; else s2;
6.2 Bedingte Anweisungen
101
Statement s2 wird ausgeführt, wenn b als falsch gewertet wird. Soll s2 zum ersten if gehören, muss entsprechend geklammert werden: if (a) { if (b) s1; } else s2;
Die Switch-Anweisung hat die Syntax: switch(expression) { case const_1: statement_1; case const_2: statement_2; case const_3: statement_3; .......................... case const_n: statement_n; default: statement; }
Hier kann der default-Teil entfallen. Im Gegensatz zur CASE-Anweisung von Pascal ist die Switch-Anweisung nicht abweisend. Dies bedeutet, dass nach Ausführen eines Statements der Kontrollfluss zum Statement des nächsten case-Labels übergeht. Um das zu verhindern, muss eine Break-Anweisung gesetzt werden. Das folgende Beispiel gibt zu jeder Nummer eines Wochentags (beginnend mit Sonntag=0) den zugehörigen Namen aus. switch(wochtag) { case 0 : cout << case 1 : cout << case 2 : cout << case 3 : cout << case 4 : cout << case 5 : cout << case 6 : cout << }
Die folgende Mehrfachverzweigung ordnet jeder Monatsnummer mon von 1 bis 12 die Anzahl der Tage im Monat (ohne Schaltjahr) zu. switch(mon) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: tageimMonat = 31; break; case 4: case 6: case 9:
102
6 Kontrollstrukturen
case 11: tageimMonat = 30; break; case 2: tageimMonat = 28; }
6.3
Wiederholungsanweisungen
In C/C++ umfassen die Kontrollstrukturen folgende Wiederholungsanweisungen 왘 FOR-Anweisung (Zählwiederholung) 왘 WHILE-Anweisung (Wiederholung mit Anfangsbedingung) 왘 DO-Anweisung (Wiederholung mit Endbedingung)
6.3.1 Die FOR-Schleife Die FOR-Anweisung in C++ hat das Format: for (restricted_statement; expression_1; expression_2) statement
Die FOR-Anweisung endet, wenn expression_1 als falsch bewertet wird. Diese Anweisung ist gegenüber C geändert worden, in C gilt allgemeiner: for (expression_1; expression_2; expression_3) statement
Eine eingeschränkte Anweisung (restricted_statement) ist eine 왘 Ausdrucksanweisung 왘 Deklarationsanweisung 왘 Leeranweisung
Typische FOR-Anweisungen sind: for for for for
(i=0; i<100; i++); // Weiterzaehlen um Eins (i=1; i<100; i+=2); // Weiterzaehlen um Zwei (i=100; i>0; i--); // Rückwärtszaehlen (i=1; i<=1024; i*=2); // geometrische Folge
FOR-Schleifen erfüllen die ALGOL-Konvention; d.h., sie werden nicht ausgeführt, wenn beim Aufwärtszählen der Endwert b kleiner ist als der Startwert a. Analoges gilt beim Abwärtszählen, wenn der Endwert b größer ist als der Startwert a. for (i=a; ib; i--) // abweisend für b>a
Ist die Zählvariable noch nicht deklariert, so kann dies in der FOR-Anweisung geschehen: for (int i=0; i<100; i++) a[i]=i;
Damit liefert die FOR-Schleife den Gültigkeitsbereich und die Lebensdauer der Schleifenvariablen i. Außerhalb der FOR-Schleife ist die Schleifenvariable nicht mehr definiert. Im Falle einer weiteren FOR-Schleife muss die Schleifenvariable neu definiert werden!
6.3 Wiederholungsanweisungen
103
Die etwas trickreiche FOR-Schleife berechnet den größten gemeinsamen Teiler (ggT) zweier natürlicher Zahlen nach dem Algorithmus von Euklid: int a,b,r; cin >> a >> b; // Eingabe a,b>0 for ( ; r = a % b; a=b, b=r); cout << "ggT = " << b << endl;
Die FOR-Schleife ist – nicht wie in Pascal – an den ganzzahligen Datentyp gebunden. Möglich ist somit die Verwendung von Fließkommazahlen: for (double x=0.; x<1.1; x+=0.1) { .. }
Die Variable x durchläuft hier das Intervall [0;1] mit Schrittweite 0.1. Dabei liegt es in der Verantwortung der programmierenden Person, dass die FOR-Anweisung tatsächlich terminiert. Insbesondere muss hier die beschränkte Genauigkeit der FließkommaArithmetik berücksichtigt werden. Beachtet werden muss die Eigenschaft der FOR-Anweisung, die Schleife nicht zu terminieren, wenn expression_1 die Leeranweisung ist. for ( ; ; ); // Endlos!
stellt somit eine Endlosschleife dar. Mit Hilfe des Kommaoperators können die Ausdrücke einer FOR-Anweisung ergänzt werden. Die Summe aller Zahlen von 1 bis 100 wird ermittelt durch: for (sum=0,i=1; i<101; sum +=i,i++);
Achtung: Das Initialisieren der Summationsvariablen und das eigentliche Aufsummieren geschieht hier als Nebeneffekt. Diese Art der Programmierung kann zu schwer lesbaren Programmen führen.
6.3.2 Die WHILE-Schleife Die WHILE-Anweisung hat die Syntax: while(expression) statement
Beispiele sind: while (x>0) { x--; } while (i<100) { i++; } while (fabs(x-y)<eps) {...}
Wird der in runden Klammern stehende Ausdruck zu Beginn der WHILE-Schleife als falsch bewertet, wird die Schleife abweisend, d.h. sie wird nicht ausgeführt. Ist der Ausdruck stets wahr, führt dies zu einer Schleife while(1) { .....break;}
die erst über die Break-Anweisung verlassen wird. Der Euklidsche Algorithmus mit WHILE-Schleife lautet:
104
6 Kontrollstrukturen
r = b; while (r>0) {r = a % b, a=b, b=r; } ggt = a;
Verschwindet b, so ist der Rest r = a % b nicht definiert. In diesem Fall wird die WHILE-Schleife abweisend und der ggT erhält den Wert von a, wegen der Gültigkeit von ggT(0,a) = a. Im andern Fall werden a und b wechselseitig dividiert, bis der Rest Null wird. Der gesuchte ggT ist dann der letzte Wert von a.
6.3.3 Die DO-Schleife Die DO-Anweisung hat folgende Syntax: do {statement} while (expression); int i=9; Achtung: Sie terminiert genau dann, wenn der Ausdruck in runden Klammern als
falsch bewertet wird. Da die Schleife mindestens einmal durchlaufen wird, heißt sie nichtabweisend. Zu beachten ist, dass es dabei zu Nebeneffekten, wie Inkrementierung eines Zählers, kommen kann. do { i++; .... } while(i<10); // i=10
Beispiele für DO-Schleifen sind: do {i++;} while(i<100); do {x--;} while(x>0); do {}while(!kbhit()); // Warten auf Tastendruck (DOS)
Eine Endlosschleife erhält man durch: do{} while(1);
Folgendes Programm liefert mit Hilfe der DO-Anweisung die Quadratwurzel der positiven Zahl gemäß dem Algorithmus von Heron. double x,y,a; cout << "Gib eine positive Zahl ein! "; cin >> a; a = fabs(a); // a>0 wichtig! y = a; do { x = y; y = (x + a/x)/2; cout << y << endl; }while (fabs(x-y)>1e-6*fabs(x));
Für die Eingabe 2 erhält man Näherungswerte für 1.5 1.41667 1.41422 1.41421
2:
6.4 Die Transfer-Funktionen
6.4
105
Die Transfer-Funktionen
Die dritte Gruppe der Kontrollstrukturen sind die Transfer-Funktionen oder Sprunganweisungen: 왘 Break-Anweisung 왘 Goto-Anweisung 왘 Continue-Anweisung 왘 Return-Anweisung
Die lange Zeit diskutierte Goto-Anweisung ist in C++ in der Regel entbehrlich und sollte vermieden werden. Die Break-Anweisung springt stets aus dem Block der Wiederholungs- oder SwitchAnweisung heraus. Sie ist besonders dann nützlich, wenn das Abbruchkriterium in der Mitte der Wiederholungsanweisungen steht. Die Continue-Anweisung springt an das Ende des aktuellen Blocks und setzt die Wiederholungsanweisung fort. Dieses Verhalten wird nur selten benötigt. Der folgende Programmausschnitt gibt alle natürliche Zahlen bis 100 aus, die nicht durch 7 teilbar sind for (int i=0; i<101; i++) { if (i % 7 == 0) continue; cout << i << " "; }
Die Return-Anweisung liefert den Rückgabewert einer Funktion. Die Anweisung kann mehrfach vorhanden sein; dies spricht aber nicht für eine strukturierte Programmierung, da die Korrektheit einer solchen Funktion nur schwer zu beweisen ist. Ein Beispiel einer Primzahltest-Funktion mit mehreren Return-Werten ist: bool ist_primzahl(long n) // liefert genau dann wahr, wenn n Primzahl { if (n < 2) return false; // 0,1 keine Primzahl if (n == 2|| n == 3|| n == 5) return true; // Primzahl 2,3,5 if (n % 2 == 0 || n % 3 == 0) return false; // Teiler 2,3 long w = static_cast(sqrt(n)); long teiler = 5L; // erster Probeteiler int inkr = 2; while(teiler <= w) { if (n % teiler == 0) return false; // Teiler gefunden teiler += inkr; inkr = 6-inkr; } return true; // keine Teiler >=5 gefunden }
106
6 Kontrollstrukturen
Der obige Algorithmus verwendet die Tatsache, dass alle Primzahlen p ≥ 5 die Form haben:
p = 6 ⋅ k ±1 Zu erwähnen ist hier noch die exit()-Anweisung. Sie bewirkt das Verlassen des Programms mit Übergabe des Exit-Werts an das Betriebssystem.
6.5
Das assert()-Makro
Mit Hilfe des assert()-Makros kann die Gültigkeit eines Ausdrucks zur Laufzeit überwacht werden. Dazu muss die Header-Datei eingebunden werden. Das Makro wird von einigen C++-Puristen wenig geschätzt, da hier mächtigere Werkzeuge wie die Ausnahmefälle (exceptions) zur Verfügung stehen. Die Syntax ist prozedurähnlich: void assert(int test)
Das Makro wird zur Laufzeit zu einer IF-Anweisung erweitert. Wird dabei test mit Null bewertet, so erfolgt eine Fehlermeldung im Format Assertion test failed, file Dateiname, line Zeilennummer
Ist die assert()-Bedingung nicht erfüllt, erhält man eine Fehlermeldung (hier unter Windows 95).
Abbildung 6.1: Fehlermeldung eines assert()-Makros unter WIN95
Auch zum Debuggen kann das assert()-Makro genutzt werden. Durch Hinzufügen des Makros #define NDEBUG
vor dem ersten Aufruf von assert() entfernt der Präprozessor sämtliche assert()Anweisungen.
6.6 Übungen
6.6
107
Übungen
Übung (6.1): Das folgende Programm erzeugt keine Ausgabe. Es enthält einen Tippfehler, den der Compiler nicht findet! Können Sie den Fehler entdecken? //ueb6_1.cpp #include using namespace std; int main() { double x, y; for(x = 0; x < 4.0; x++) for(y = 0; y < 5,0; y++) if (x * y < 1000) cout << (x*y) << endl; return 0; }
Übung (6.2): (»Verflixte Sieben«). Schreiben Sie ein C++-Programm, das alle Zahlen zwischen 1 und 250 am Bildschirm ausgibt, die weder den Teiler 7 noch die Ziffer '7' aufweisen. Übung (6.3): Schreiben Sie ein C++-Programm, das drei beliebige ganze Zahlen sortiert, ohne dabei ein Sortierverfahren wie Bubble-Sort zu verwenden. Übung (6.4): Geben Sie an, welchen Ausdruck das folgende Programm liefert? // ueb6_4.cpp #include using namespace std; int main() { for (int i=1; i<5; i++) { int j=i; switch(j) { case 1: j+=3; break; case 2: j=3; case 3: j++; break; default: ; } cout << j << endl; } return 0; }
Übung (6.5): Schreiben Sie ein C++-Programm zodiac.cpp, das zu jedem Geburtstag (Tag, Monat) das zugehörige Tierkreiszeichen am Bildschirm ausgibt.
Übung (6.6): Schreiben Sie ein C++-Programm gauss.cpp, das zu jedem Jahr j des Gregorianischen Kalenders den Ostersonntag nach dem Algorithmus von C. F. Gauß bestimmt. Eingabe j>1582 s = j/100-j/400-2 m = (j-100*(j/4200))/300-2 M = (15+s-m) mod 30 N = (6+s) mod 7 a = j mod 19 b = j mod 4 c = j mod 7 d = (19*a+M) mod 30 e = (2*b+4*c+6*d+N) mod 7 f = 22+d+e g = f-31;
Ostersonntag ist dann der f.te März, wenn f<32; ansonsten der g.te April mit folgenden Ausnahmen: wenn d = 29 und e = 6, dann g= 19 wenn e = 6 und d=28 und a>10, dann g=18
Übung (6.7): Schreiben Sie ein C++-Programm muenze.cpp, das 500 Münzwürfe simuliert (Ergebnis 0/1). Verwenden Sie folgenden Algorithmus Eingabe ganzzahliges a,b >0 wenn (a>b), vertausche (a,b) für i=1 bis 500 tue a = a*a; wenn (a
Übung (6.8): Das Problem von Collatz ist eines der bekanntesten ungelösten Probleme. Es erscheint in den meisten Büchern unter dem Namen Ulam, da sich dieser Name in der amerikanischen Literatur findet. Neuerdings wird die Erfindung des Problems dem Mathematiker L. Collatz zugeschrieben. Das Verfahren verläuft wie folgt:
6.6 Übungen
109
Man startet mit einer beliebigen natürlichen Zahl x. Ist x gerade, so wird x halbiert, andernfalls wird x verdreifacht und um Eins vermehrt.
3 ⋅ xn + 1, falls xn ungerade xn+1 = xn /2, falls xn gerade Dieses Verfahren setzt sich fort, bis die Zahl 1 erreicht ist. Bisher konnten die Mathematiker noch nicht beweisen, dass die Folge von Collatz stets bei 1 endet. Bisher weiß man nur durch numerische Berechnungen, dass alle Folgen irgendwann die Zahlen 5, 21, 85 und 341 erreichen. Daraus ergibt sich im nächsten Schritt eine Zweierpotenz, deren fortgesetzte Halbierung bei 1 endet.
7
Funktionen
Eine Funktion ist in der Mathematik eine Vorschrift, die zu jedem Satz von Definitionswerten genau einen Funktionswert liefert. Entsprechend ist in der Informatik eine Funktion ein Unterprogramm, das in Abhängigkeit von einem Parametersatz einen bestimmten Wert liefert. Eine Funktion erfüllt eine bestimmte Aufgabe; sie stellt so etwas wie einen Makrobefehl dar und dient zur Modularisierung eines Programms. Wenn wir eine definierte Funktion f() benützen wollen, dann interessieren uns die Details eigentlich nicht. Mit anderen Worten: Wir wollen f() als Black Box behandeln, für die nur die Art der Benutzung relevant ist (Pepper 1995).
7.1
Deklaration und Definition
Mit einer Funktionsdeklaration werden Name und Typ einer Funktion vereinbart. Der Typ einer Funktion umfasst: 왘 Typ der Funktion (Return-Wert) 왘 Typ der formalen Argumente 왘 Spezifizierer wie const (nur für Elementfunktionen) 왘 throw()-Liste der Ausnahmefälle (kann entfallen)
Beispiele für Funktionsdeklarationen sind: int Max(int,int); // max vordefiniert float fahrenheit(float); void swap(double &,double &); void swap(double *,double *);
Diese Funktionsdeklarationen werden auch als Funktionsprototypen bezeichnet. Den Funktionstyp und die Typen der formalen Parameter bezeichnet man als die Signatur der Funktion. Sie ermöglicht es dem C++-Compiler, bei einem Funktionsaufruf Anzahl und Typ der Argumente zu prüfen. Prozeduren in C/C++ sind spezielle Funktionen mit dem Rückgabewert void. Funktionsdefinitionen umfassen neben der Deklaration auch den Funktions- bzw. Prozedurrumpf. Funktionsbeispiele sind: int Max(int a,int b) // Maximum { return (a>=b)? a:b; } float fahrenheit(float celsius) // Fahrenheit-Skala
112
7 Funktionen
{ return 1.8*celsius+32.0; } int ggt(int a,int b) // ggT { a = abs(a); b=abs(b); int r = b; while(r>0) { r = a % b; a = b; b = r; } return a; }
Ein Funktionsdeklaration kann, wie erwähnt, auch eine throw()-Liste enthalten: double f(double) throw (a,b,c);
Diese zeigt, dass die Funktion einen Ausnahmefall (exception) ausrufen kann und zwar die in runden Klammern angegebenen Ausnahmen. Ein Beispiel einer Probe-Division ist: #include <stdexception> // enthaelt runtime_error double division(int a,int b) throw (runtime_error) { if (b == 0) throw runtime_error("Nenner Null"); return double(a)/b; }
Eine leere Exception-Liste zeigt, dass die Funktion keinen Ausnahmefall hat. double f(double) throw ();
Eine Funktion ohne throw() kann jeden möglichen Ausnahmefall ausrufen: double f(double);
Für die Ausnahmebehandlung sei auf Kapitel 10 verwiesen. Ein Funktionsaufruf ist durch den Klammeroperator () gekennzeichnet: m = Max(17,-11); cout << fahrenheit(20) << endl;
Ein Funktionsaufruf hat folgende Wirkungen: 왘 Es wird ein Funktionsstack errichtet. Dort werden die formalen Argumente erzeugt
und mit den aktuellen Werten initialisiert. Diese Werteübergabe (Call-by-value) findet nicht bei Reihungen und Referenzen statt. 왘 Die Anweisungen im Funktionsrumpf werden ausgeführt und der Funktionswert wird ermittelt. 왘 Dieser (eventuell leere) Return-Wert wird an das aufrufende Programm zurückgegeben und der Funktionsstack wird gelöscht. Den Aufwand beim Auf- und Abbau des Funktionsstacks nennt man den Funktionsoverhead. Dieser ist hardwareabhängig und kann in einem Programm mit einer Vielzahl von rekursiven Funktionsaufrufen unter Umständen ganz erheblich sein.
7.2 Mathematische Standardfunktionen
113
Ein Beispiel einer Ausgabe-Prozedur ist void datumsausgabe(int t,int m,int j) { cout << "Datum ist der " << t <<"." << m << "." << j << endl; return; // Kein Rueckgabewert }
7.2
Mathematische Standardfunktionen
C/C++ enthält wie kaum eine andere Sprache eine Vielzahl von mathematischen Funktionen. Die Prototypen finden sich in der Header-Datei , früher <math.h>. Hier finden sich alle wichtigen Standardfunktionen, eingeschlossen die Tangens-, Potenz- und Arcus-Funktionen, die man in Pascal so vermisst hat. double double double double double double double double double double double double double double double double double double double
Manche UNIX-Compiler und Microsoft C++ bieten noch zahlreiche weitere Funktionen an, wie die Bessel-Funktionen, die jedoch nicht ISO-Standard sind. double j0(double); double y0(double); double j1(double); double y1(double); double jn(int,double); double yn(int,double);
Als Beispiel für das Arbeiten mit mathematischen Funktionen sollen die Werte
eπ
163
, e3π 1
163
berechnet werden. Beide Werte zeigen die Besonderheit, dass sie sich um weniger als 10-9 von einer ganzen Zahl unterscheiden.
114
7 Funktionen
#include #include #include <string> #include using namespace std; int main() { string str1 = "262537412640768743.9999999999992500725972"; string str2 = "640320.00000000060486373504"; long double x = exp(M_PI*sqrt(163)); // Borland sqrtl() long double y = exp(M_PI*sqrt(163)/3); cout.setf(ios::fixed|ios::showpoint); cout << x << endl; cout << "exakt auf 20 Dezimalen " << str1 << endl; cout << y << endl; cout << "exakt auf 20 Dezimalen " << str2 << endl; return 0; }
Die Ausgabe zeigt die Ungenauigkeit der doppelt genauen Fließpunkt-Arithmetik: 262537412640768333.500000 exakt auf 20 Dezimalen 262537412640768743.9999999999992500725972 640320.000000 exakt auf 20 Dezimalen 640320.00000000060486373504
Die Ausgabe zeigt hier eine etwa 18-stellige Genauigkeit; der Default-Wert für die Anzahl der ausgegebenen Dezimalen beträgt 6. Mit Hilfe dieser vordefinierten mathematischen Funktionen können weitere eigene Funktionen definiert werden, wie die Logarithmen zu einer beliebigen Basis und die Area-Funktionen (Umkehrfunktionen der hyperbolischen Funktionen): #include double log2(double x) // Logarithmus zur Basis 2 { return log(x)/log(2.); } double arsinh(double x) // Umkehrfkt. zu sinh() { return log(x+sqrt(x*x+1)); } double artanh(double x) // fuer fabs(x)<1 { return 0.5*log((x+1)/(x-1)); }
7.2 Mathematische Standardfunktionen
115
Die trigonometrischen Funktionen sin() usw. erwarten ein Argument im Bogenmaß; Winkelmaße müssen daher erst umgewandelt werden. double bogenmass(double alpha) { return alpha*M_PI/180.; // Bogen } cout << "Eingabe Winkel in Grad "; cin >> alpha; alpha = bogenmass(alpha); double x = sin(alpha);
Entsprechend liefern die Umkehrfunktionen der trigonometrischen Funktionen Werte im Bogenmaß, die bei Bedarf ins Winkelmaß umgerechnet werden müssen. double winkelmass(double x) { return x*180./M_PI; // Winkel } double x = asin(alpha); alpha = winkelmass(alpha); cout << "Winkel = " << alpha << endl;
Eine weitere wichtige Funktion ist der Zufallszahlengenerator: int rand();
Der Prototyp befindet sich in der Datei , früher <stdlib.h>. Der Generator muss mit Hilfe eines Anfangswertes gestartet werden, dafür ist die Startprozedur srand() vorgesehen. Durch Eingabe desselben Startwerts erhält man stets dieselbe Folge von Zufallszahlen, was für manche Zwecke nützlich sein kann. Stets verschiedene Zufallszahlen erhält man, wenn man srand() mit der Systemzeit aufruft, die sich ja ständig ändert. #include #include time_t now; srand((unsigned) time(&now));
Für Borland-Compiler gibt es einen speziellen Aufruf des Zufallszahlengenerators: randomize(); // nicht ISO Norm
Die größte ganzzahlige Zufallszahl ist durch die Konstante RAND_MAX gegeben, die sich in der Bibliothek findet (sowohl für 2 als auch für 4 Byte-Systeme). Ganzzahlige Zufallszahlen z mit 0 ≤ z < n (mit n < RAND_MAX) können mit der folgenden selbst definierten Funktion random(int n) durch Modulo-Rechnen erzeugt werden: int random(int n) // Zufallszahl mod n { return rand() % n; }
116
7 Funktionen
Typische Beispiele für Zufallszahlen erhält man durch Modulo-Rechnen: rand() % 1+rand() rand() % 1+rand()
Reelle Zufallszahlen im Intervall [0; 1 [ liefert die selbst definierte Funktion random() mit leerer Parameterliste: double random() { return rand()/(1.+RAND_MAX); }
Da in C++ Funktionsnamen überladen werden können, kommt es hier nicht zu einer Namenskollision (siehe Abschnitt 4.9). Als Anwendung von Zufallszahlen soll die Wahrscheinlichkeit, dass zwei zufällige natürliche Zahlen teilerfremd sind, mit einer Monte Carlo-(MC-)Simulation angenähert werden. // simggt.cpp #include #include #include using namespace std; int random(int n) {return rand() % n;} int ggt(int a,int b) // Euklid { int r=b; while(r>0) { r = a % b; a = b; b = r; } return a; } void simulation(int n) // MC-Simulation { int x,y,zaehl=0; for (int i=0; i
118
Die Ausgabe ist hier: JD(1,1,2000) = 2451545 Energie bei Richterskala(6.5) = 1.06782e+20 J Luftdruck(4000) = 614.6 mbar
7.4
String-Funktionen
Dieser Abschnitt ist zum Nachschlagen gedacht; er kann beim ersten Lesen übersprungen werden. In Folge werden die wichtigsten String-Funktionen vorgestellt. Dabei ist zwischen den C-Strings vom Typ char*, wchar_t* und dem C++-String aus der Standard-Bibliothek zu unterscheiden.
7.4.1 C-String-Funktionen String-Funktionen für C-Strings vom Typ char* finden sich in der Header-Datei . char* strcat(char* dest,char* src) // anhängen von src an dest char* strchr(const char* s,int c) // liefert Zeiger auf erste Stelle mit c int strcmp(const char* s2, const char* s2) // liefert <0,0,>0, wenn s1 vor s2,s1==s2,s1 nach s2 char* strcpy(char* dest,char* src) // kopiert src auf dest char* strncpy(char* dest,char* src,int n) // kopiert n Zeichen von src auf dest size_t strlen(const char* s) // liefert Stringlänge double strtod(char* src, chr** end) // wandelt String src in double um long strtol(char* src, chr** end, int basis) // umwandelt String src in long um char* strtok(char* src,const char* sep) // zerlegt src in Token, die vom Separator getrennt werden char* strstr(const char* s,const char* substr) // liefert Zeiger auf erste Stelle mit Teilstring substr
Der wesentliche Unterschied zwischen den beiden Anweisungen char* p = str; strcpy(p,str)
ist, dass im ersten Fall p nicht nur den Inhalt, sondern auch den Speicherplatz von str übernimmt (p und str zeigen auf denselben Speicherplatz). Dagegen behält im zweiten Fall p seinen Speicherplatz und übernimmt nur den Inhalt von str.
7.4 String-Funktionen
119
Ein Beispiel zur strstr()-Funktion ist: char str[] = "programmiersprache c++"; char substr[] = "sprache"; char* p = strstr(str,substr); cout << p << endl; // liefert "sprache c++"
Die Funktion strtok() wirkt als Parser und zerlegt einen String in einzelne Token, hier getrennt durch Blanks. char* text = "Bjarne Stroustrup ist der Entwickler von C++"; char* token = strtok(text," "); cout << token << endl; while(token = strtok(0," ")) cout << token << endl;
Die Funktion strtol() aus löst long-Zahlen aus einem String: char str[] = "123456DM"; char* rest; long x = strtol(str,&rest,10); // Dezimal cout << "Gegebener String = " << str <<endl; cout << "Umgewandelte Zahl = " << x << endl; cout << "Reststring = " << rest << endl;
Weitere Umwandlungsfunktionen aus sind: double atof(const char* ) // Umwandlung in double int atoi(const char* ) // Umwandlung in int long atol(const char* ) // Umwandlung in long enthält nun auch die Memory-Funktionen, die urprünglich <mem.h> zugeordnet waren. Dies sind: void *memchr(const void *s1,int c,size_t n) // Suchen in Blöcken nach Zeichen int memcpy(const void *s1,const void *s2,size_t n) // Kopieren von Speicherblöcken void *memmove(const void *s1,const void *s2,size_t n) // Verschieben von Speicherblöcken void *memset(const void *s1,int c,size_t n) // Belegen eines Blocks mit Zeichen int memcmp(const void *s1,const void *s2,size_t n) // Vergleich zweier Speicherblöcke
Zur Illustration folgen einige Programmausschnitte zu memchr(), memcpy() und memmove(). char str[17]; char *ptr; strcpy(str, "This is a string"); ptr = (char *) memchr(str, 'r', strlen(str)); if (ptr) cout << "The character 'r' is at position << (ptr-str) << endl; // 12 else cout << "The character was not found\n"; char src[] = "**********"; char dest[] = "abcdefghijlkmnopqrst"; char *ptr; cout << dest << endl; //abcdefghijlkmnopqrst; ptr = (char*) memcpy(dest, src, strlen(src));
120
7 Funktionen
if (ptr) cout << dest << endl; //**********lkmnopqrst else cout << "memcpy failed\n"; char *dest = "abcdefghijklmnopqrst"; char *src = "**********"; cout << dest << endl; // abcdefghijklmnopqrst; memmove(dest,src,6); cout << dest << endl; //******ghijklmnopqrst;
7.4.2 String-Funktionen für wchar_t-Typen Alle oben erwähnten String-Funktionen gibt es auch für Zeichenketten aus wchar_tTypen. In der Header-Datei finden sich unter anderem: wcsstr() // wie strstr() wcstok() // wie strtok() wcstol() // wie strtol() wcstod() // wie strtod() ( string to double) wcscpy() // wie strcpy() wcscmp() // wie strcmp() wcvslen() // wie strlen()
Dort sind auch die Prototypen der Funktionen, die Strings aus Multibyte-Typen unterstützen. Diese Funktionen erkennt man an den beiden ersten Buchstaben mb..(), z.B. mbrlen() // wie strlen() mbrtowc() // Umwandlung in wchar_t
7.4.3 C++-String-Funktionen Einige String-Funktionen der STL wie c_str() sind bereits im Abschnitt 4.7.2 erwähnt worden. Weitere Funktionen sind string& append(const string& s,size_t i,size_t, k) // hängt die ersten k Zeichen von String s ab Position i an
Diese Funktion gibt es auch im Format: string& append(size_t k,char c) // hängt k mal das Zeichen c an string betrag("*123.45DM"); betrag.append(6,'*'); // "123.45DM******"
Wie der []-Operator wirkt: char &at(size_t i) // wie Indexzugriff [i]
Dabei wird eine Indexprüfung durchgeführt und gegebenenfalls ein Ausnahmefall geworfen.
7.5 Char-Funktionen
121
Das Einfügen von Zeichen und Teilstrings in einen String kann erfolgen mit: string& insert(size_t i,const char &c) // fügt vor der Stelle i Zeichen c ein string& insert(size_t i,const string &s) // fügt vor der Stelle i String s ein
Ähnlich funktioniert das Ersetzen von Zeichen und Teilstrings in einen String: string& replace(size_t i,const char &c) // ersetzt vor der Stelle i Zeichen c string& replace(size_t i,const string &s) // ersetzt vor der Stelle i String s
Weitere Funktionen ohne Iteratoren aus der Standard Template Librray (STL) sind bool empty() const// gibt an, ob string leer size_t capacity() const // Gibt die max. Anzahl der Zeichen an, die ein C++-String haben kann size_t length() const // liefert Länge des Strings size_t size() // wie length()
Weitere String-Funktionen, die Iteratoren verwenden, werden im Zusammenhang mit der STL besprochen.
7.5
Char-Funktionen
Die Header-Datei enthält Funktionen für Zeichen. Zur Typbestimmung der Zeichen gibt es neben anderen die Funktionen: int int int int int
isalnum(int c) // prüft auf alphanumerisches Zeichen (Buchstabe oder Zeichen) isalpha(int c) // prüft auf Buchstabe isdigit(int c) // prüft auf Ziffer isxdigit(int c) // prüft auf Hexziffer isupper(int c)// prüft auf Großbuchstabe
Umwandlungen in Klein-/Großschreibung können ausgeführt werden mit: int tolower(int c) // Umwandlung in Kleinbuchstaben int toupper(int c) // Umwandlung in Großbuchstaben
Ein Beispiel für toupper(): char *str = "dies ist ein string"; for (int i=0; i<strlen(str); i++) string[i] = toupper (string[i]); cout << str << endl;
Für die Zeichen vom Typ wchar_t finden sich die entsprechenden Funktionen in der Header-Datei . Sie heißen dort: wint_t wint_t wint_t wint_t wint_t wint_t wint_t
iswalnum(int c) iswalpha(int c) iswdigit(int c) iswxdigit(int c) iswupper(int c) towupper(int c) towlower(int c)
122
7 Funktionen
7.6
Rekursive Funktionen The transformation from recursion to iteration is one of the most fundamental concepts of computer science. D. Knuth
Wie das Zitat von Donald Knuth zeigt, mussten früher – zu Zeiten von FORTRAN – alle Funktionen in eine iterative Form überführt werden, da FORTRAN-Compiler keine Rekursion ermöglichten. Heutzutage unterstützen fast alle Compiler von höheren Programmiersprachen die rekursive Programmierung. In C++ dürfen alle Funktionen – außer main() – rekursiv sein. Dies ist der Fall, wenn sich die Funktion direkt oder indirekt (über eine andere Funktion) aufruft. Viele mathematische Funktionen sind rekursiv definiert wie:
n
n − 1 n − 1
+ für k > 1, sonst 1 Binomialkoeffizienten: = k k − 1 k ggt(b, a mod b) für b > 0 ggt( a, b) = a für b = 0 Gammafunktion:
Γ ( z + 1) = zΓ ( z) ; Γ (1) = 1; Tschebyschew-Polynome : Tn+1 ( x) = 2 xTn ( x ) − Tn−1 ( x) ; T0 = 1; T1 = x Der Binomialkoeffizient
n k »k aus n« genannt, gibt die Anzahl der Möglichkeiten an, k Dinge aus n auszuwählen. Obiges Rekursionsschema der Binomialkoeffizienten kann in C++ direkt realisiert werden: long binkoeff(int k,int n) { assert(n>=k && k>=0); if (k==0 || k==n) return 1L; else return binkoeff(n-1,k-1)+binkoeff(n-1,k); }
7.6 Rekursive Funktionen
123
Es liegt in der Verantwortung des Programmierers sicherzustellen, dass das Rekursionsschema sicher endet, da andernfalls eine Endlosschleife erzeugt wird und es somit zum Stack-Overflow kommt. Wesentlich einfacher ist die iterative Formulierung der Binomialkoeffizienten: long binkoeff(int k,int n) // k aus n { long bin = 1L; assert(n>=k && k>=0); if (k > n/2) k = n-k; for (int i=0; i
Für die Anzahl von sechs Richtigen aus 49 Lottozahlen ergibt sich hier:
49 = 13 983 816 6 Ein häufig zitiertes Beispiel ist die rekursive Definition der Fakultätsfunktion n! (Produkt aller natürlichen Zahlen von 1 bis n):
n ! = n ⋅ ( n − 1)! mit 0! = 1 Sie gibt die Anzahl der Möglichkeiten an, n unterscheidbare Dinge in einer Reihe anzuordnen. Die rekursive Version könnte man realisieren als long fak(int n) // n>=0 { if (n ==0) return 1L; else return n*fak(n-1); }
Die Fakultätsfunktion kann sehr viel effizienter iterativ implementiert werden durch: long fak(int n) { long f = 1L; for (int i=1; i<=n; i++) f *= i; return f; }
Im Allgemeinen liefert eine rekursive Funktion eine elegantere Lösung. Jedoch sollte man – falls eine iterative Lösung bekannt ist – eine iterative Funktion vorziehen. Der Funktionsoverhead bei nicht rest-rekursiven Lösungen kann ganz erheblich sein, da bei vollrekursiven Programmen bei jedem Funktionsaufruf der vollständige Funktionsstack mit allen Parameterwerten abgespeichert werden muss! Das folgende Programm zeigt, wie einfach durch Rekursion alle 01-Tupel erzeugt werden können.
124
7 Funktionen
// 01tupel.cpp #include using namespace std; void ausgabe(int x[],int n) { for (int i=0; i
Man erhält hier alle 01-Tupel der Länge 5 und damit die Binärdarstellung aller Zahlen 0 bis 31 = 25-1. 00000 01000 10000 11000
7.7
00001 01001 10001 11001
00010 01010 10010 11010
00011 01011 10011 11011
00100 01100 10100 11100
00101 01101 10101 11101
00110 01110 10110 11110
00111 01111 10111 11111
Die Funktion main()
Jedes C/C++-Programm muss und kann nur genau ein Hauptprogramm enthalten, das durch die Funktion main() gegeben ist. Die Funktion darf aber nicht überladen werden (vgl. Abschnitt 7.12) und darf keinen Spezifizierer wie const oder static tragen, main() hat keine Adresse. main() ist eine externe »C«-Funktion und darf nicht rekursiv sein. Der Rückgabe- oder Return-Wert von main() wird jeweils an das Betriebssystem übergeben. In der Regel wird der Wert Null geliefert, wenn kein Fehler vorliegt. int main() { .......
7.7 Die Funktion main()
125
return 0; // optional }
Der Return-Wert von main() ist nicht mehr von der ISO Norm gefordert! Unter MSDOS kann dieser Return-Wert über die Abfrage des errorlevel, z.B. in einer BatchDatei if errorlevel 1
ermittelt werden. Unter UNIX findet sich die Statusvariable $status in der C-Shell oder $? in der Bourne-Shell. Wird das Hauptprogramm über die Prozedur exit(x); // Prototyp void exit(int)
aus verlassen, so ist x der Returnwert von main(). Für die Rückgabewerte des Hauptprogramms gibt es in zwei vordefinierte Konstanten: EXIT_SUCCESS // Erfolg EXIT_FAILURE // Misserfolg
Beim Verlassen mittels exit() werden alle offenen Dateien geschlossen und die von der Funktion atexit() – sofern vorhanden – registrierten Funktionen (max. 32) ausgeführt. #include #include void f1() { cout << "Exit function #1 called\n");} void f2() { cout << "Exit function #2 called\n"); int main() { atexit(f1); // Registrierung von f1 atexit(f2); // Registrierung von f2 return 0; }
Die Parameterliste von main() kann benützt werden, um Parameter an das Hauptprogramm zu übergeben. Der Prototyp von main() ist dann: int main(int argc,char *argv[]);
Dabei ist argc eine Variable, die die Anzahl der eingegebenen Argumente zählt, *argv[] ist ein Array von Strings, die Eingabeparameter enthalten, die in der Kommandozeile dem Aufruf der Funktion folgen. Man spricht hier vom KommandozeilenModus. Dies soll mit einem kleinen Programm demonstriert werden. Das folgende Programm wird zu demo.exe bzw. demo.out compiliert. // demo.cpp // Beispiel fuer Kommandozeilenmodus #include using namespace std; int main(int argc,char *argv[])
126
7 Funktionen
{ int i=0; if (argc>1) cout << "argc = " << argc << endl; for (i=1; i<argc; i++) cout << "argv[" << i << "] = " << argv[i] << endl; return 0; }
Ruft man das compilierte Programm mit folgender Kommandozeile auf: demo Dies ist ein Test 10/12/00
so erfolgt die Ausgabe: argc = 6 argv[1] = argv[2] = argv[3] = argv[4] = argv[5] =
Dies ist ein Test 10/12/00
Dies zeigt, dass die Zählung, wie in C++ üblich, mit Null beginnt; argv[0] ist jeweils der Programmname. Eine einfache Möglichkeit, unter MS-DOS den Return-Wert zu ermitteln, ist das Einschalten des DEBUG-Modus. Man erhält dann eine Meldung in der Form: Process 0xFFFE4E01 terminated, exit code 0 (0x0) // Microsoft
7.8
Funktion mit variabler Parameterliste
Es gibt Funktionen, wie die Ausgabefunktion printf() von C, die mit einer wechselnden Zahl von Argument aufgerufen werden kann. Dieser Mechanismus ist als Makro va_arg() in definiert und wurde auch in C++ übernommen. Zunächst definiert man einen Array-Typ va_list ap, dessen Abarbeitung durch das Makro va_start(ap,T) gestartet und mittels va_end(ap) beendet wird. Dazwischen wird mit dem Makro va_arg(ap,T) das nächste Argument vom Typ T aus der Parameterliste eingelesen. Die variable Parameterliste wird durch (genau) drei Punkte, Ellipse genannt, gekennzeichnet. Die Anzahl der beim Funktionsaufruf jeweils übergebenen Parameter kann entweder als eigener Parameter übergeben werden oder durch eine Abbruchbedingung (z.B. letztes Element ==0) signalisiert werden. Das folgende Programm zeigt beide Möglichkeiten bei der Berechnung des arithmetischen und geometrischen Mittels: #include #include #include using namespace std; double arithmittel(int n,...)
7.9 Call-by-value
127
{ long sum = 0; va_list ap; va_start(ap,n); for (int i=0; i0 { int zaehl = 0; double produkt = 1.; va_list ap; va_start(ap,str); int x; while ((x = va_arg(ap,int)) != 0) { produkt *= x; zaehl++; } va_end(ap); cout << str << pow(produkt,1./zaehl) << endl; } int main() { cout << "Arithmetisches Mittel aus 1 bis 6 = " << arithmittel(6,1,2,3,4,5,6) << endl; geomittel("Geometrisches Mittel aus 1 bis 6 = ",1,2,3,4,5,6,0); return 0; }
Es liefert hier die folgende Ausgabe: Arithmetisches Mittel aus 1 bis 6 = 3.5 Geometrisches Mittel aus 1 bis 6 = 2.9938
7.9
Call-by-value
Die gewöhnliche Variablenübergabe an Funktionen geschieht, wie in Abschnitt 7.1 beschrieben, dadurch, dass die formalen Parameter der Funktion den Wert der aktuellen Parameter übernehmen. Dieser Mechanismus wird Call-by-value genannt und ist die einzig mögliche Wertübergabe in C. Im folgenden Beispiel erhalten die formalen Parameter a, b der ggT()-Funktion die Werte x, y des aufrufenden Programms und geben den größten gemeinsamen Teiler als Funktionswert an das Hauptprogramm zurück. Es ist nicht notwendig, dass die Bezeichner der formalen Funktionsparameter (hier a, b) mit denen der Funktionsaufrufparameter (hier x, y) übereinstimmen.
128
7 Funktionen
int ggt(int a,int b) // Call by Value { a = abs(a); b= abs(b); // a,b =>0 int r=b; while(r>0) { r = a % b,a = b,b = r; } return a; // ggt = a } int main() { int x,y; cout << "Gib 2 natürliche Zahlen > 0 ein! "; cin >> x >> y; cout << "ggT = " << ggt(x,y) << endl; return 0; }
7.10 Call-by-reference Gesucht ist eine Prozedur zum Vertauschen (englisch swap) zweier ganzzahliger Werte. Folgender Programmausschnitt führt nicht zum Ziel, da zwar die Werte in der Prozedur vertauscht werden, aber diese Änderung nicht im Hauptprogramm bemerkbar wird: void swap(int a,int b) // Call by value { int h = a, a = b, b = h; // Dreieckstausch return; } int x=11,y=13; swap(x,y); cout << x << " " << y << endl; // Ausgabe 11,13
Damit eine Änderung der Parameter im Hauptprogramm sichtbar wird, kann in C eine Zeigervariable übergeben werden, deren Änderung dann zum gewünschten Ergebnis führt. Dies soll an Hand der oben erwähnten Vertauschungsprozedur gezeigt werden. void swap(int *a,int *b) //Zeiger simuliert Call by reference { int h = *a; *a = *b; *b = h; return; }
Der zugehörige Prozeduraufruf muss die Adressen der Variablen bereitstellen. int x=11,y=13; swap(&x,&y); cout << x << " " << y << endl; // Ausgabe 13,11
7.10 Call-by-reference
129
Eine sehr viel elegantere Lösung stellt C++ mit Hilfe von Referenzen bereit. Damit kann die Übergabe von Variablenparametern – d.h., Parameter mit Rückgabe wie in Pascal – durchgeführt werden: void swap(int &a,int &b) // Call by reference { int h = a; a = b; b = h; return; }
Der zugehörige Prozeduraufruf erzeugt hier die Referenzen &a, &b als Alias von x, y, deren Änderung im Hauptprogramm wirksam wird: int x=11,y=13; swap(x,y); cout << x << " " << y << endl; // Ausgabe 13,11
Bei der Übergabe von großen Strukturen versucht man, ein Kopieren mittels Call-byvalue zu vermeiden, und führt daher eine Referenzübergabe durch. Um ein (unabsichtliches) Ändern des Objekts zu verhindern, kann eine Referenz als const erklärt werden. Es ergibt sich damit ein Prozeduraufruf der Form: void f(const type &ref);
Es ist guter Programmierstil, wenn die Parameterübergabe ausschließlich über Callby-value bzw. Call-by-reference erfolgt. Achtung: Die Übergabe von Werten durch globale Variablen kann zu undurchschaubaren Nebeneffekten führen, die eine Fehlersuche sehr erschweren. Ein Beispiel eines Nebeneffekts ist: // sideff.cpp #include using namespace std; int z; int f(int x) { z -= 10; return x*x+1; } int main() { z = 10; cout << (f(10)*f(z)) << endl; // moegl. 101 z = 10; cout << (f(z)*f(10)) << endl; // moegl. 10201 return 0; }
Hier wird mit sehr großer Wahrscheinlichkeit das Kommutativgesetz der Multiplikation ausgehebelt, da die Compiler 101 und 10201 (abhängig von der Auswertungsreihenfolge) ausgeben. In Java sind globale Variablen daher prinzipiell nicht zulässig.
130
7 Funktionen
7.11 Zeiger auf Funktionen In C/C++ haben Funktionen auch eine Adresse. Daher kann ein Zeiger auf diese Adresse auch als Zeiger auf die Funktion betrachtet werden. Die Deklaration eines Zeigers auf eine Funktion geschieht wegen der Priorität von () nicht wie: double *fkt(double); // Funktion mit Wert double*
sondern mittels double (*fkt)(double); // Zeiger auf double-Funktion
Ein Funktionswert kann damit ausgewertet werden durch: f = (*fkt)(x);
Obige Zeigerdarstellung kann mittels einer typedef-Anweisung realisiert werden. typedef double (*function)(double);
Mit Zeigern auf Ordnungsfunktionen können Sortier-Routinen gesteuert werden. Mit Hilfe des Zeigers *compare() wird im folgenden Programm gesteuert, ob die Zahlen auf- oder absteigend sortiert werden sollen. // bubble.cpp #include using namespace std; void bubble(int anz,int a[],bool (*compare)(int,int)) // Bubble-Sort mit Funktionszeiger { bool sorted; do { sorted=true; for (int i=0; ib; } bool absteigend(int a,int b) { return a
7.11 Zeiger auf Funktionen
Zeiger auf Prozeduren haben auch wichtige Anwendungen in der Numerik. Enthält eine numerische Prozedur einen Funktionszeiger, so können durch Zuweisung des Zeigers an verschiedene Funktionen diese Prozeduren für mehrere Funktionen aufgerufen werden. Als numerisches Beispiel sollen hier mit Hilfe der Simpson-Regel zwei bekannte Integrale berechnet werden. 3
Si(3) = ∫ 0
sin x dx = 1.84865 (Integralsinus) x π
F(0.8) = ∫ 0
dx 1 − 0.64 sin 2 x
= 3.99061 (vollst. Elliptisches Integral 1.Art)
Die Simpson-Regel wird als Funktion vom Typ double geschrieben, die als formale Parameter die Integrationsgrenzen a, b und den Funktionszeiger aufnimmt. //integral.cpp #include #include #include using namespace std; typedef double (*function)(double); double Si(double x) // Integralsinus { if (fabs(x) < DBL_EPSILON.) return 1.; else return sin(x)/x; } double ellipt(double x) // Ellipt.Integral F(0.8)1.Art { double s=sin(x);
132
7 Funktionen
return 1./sqrt(1.-0.64*s*s); } double integral(double a,double b,function f) // Integration mit Simpson-Formel { const int n=32; // assert(n % 2 == 0) double c=2.; double h=(b-a)/n; double simp = f(a)+f(b); for (int i=1; i
Die Ausgabe des Programms (Default sechsstellig) ist hier: 1.84865 3.99061
Der Vergleich mit den exakten Integralwerten zeigt, dass alle Stellen korrekt sind. Da hier die Funktionen vor dem Hauptprogramm stehen, sind keine Funktionsprototypen notwendig.
7.12 Array von Funktionszeigern Von den im letzten Abschnitt gezeigten Funktionszeigern können auch Reihungen gebildet werden. Das Durchlaufen eines solchen Array ruft dann alle Funktionen auf. Das folgende Programmbeispiel definiert ein Array von vier Standardfunktionen, die in der Prozedur tabelle() in einem bestimmten Intervall mit gegebener Schrittweite tabelliert werden: // fktpoint.cpp #include #include using namespace std; typedef double (*fkt)(double); double double double double
fkt fktarr[] = { f1,f2,f3,f4}; // Array v.Funktionspointern void tabelle(fkt f,double a,double b,double h) { for (double x=a; x
Die Wertetabelle der ersten Funktion f1 = ex im Intervall [0;1] ergibt sich zu 0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1
Entsprechend erhält man auch die Tabellenwerte der übrigen Funktionen, die hier aus Platzgründen nicht abgedruckt sind.
7.13 Funktionen, die einen Zeiger liefern Eine Funktion kann auch einen Pointer liefern. Ein einfaches Beispiel zeigt das folgende Programm. Dabei erzeugt die Funktion dreifach() einen Pointer, der auf das Dreifache des eingegebenen Werts zeigt. Durch die Zuweisung des Funktionswerts an den im Hauptprogramm erzeugten Zeiger p wird der Funktionswert übergeben und anschließend p wieder gelöscht. //dreifach.cpp #include #include using namespace std;
134
7 Funktionen
double* dreifach(double x) { double* result = new double(0); if (!result){ cerr << "Kein Speicher frei!"; exit(1); } *result = 3.*x; return result; } int main() { double x = 5.; cout << "Gegebenes x = " << x << endl; double* p = new double(0); if (!p) { cout << "Speicher belegt!"; exit(1); } p = dreifach(x); // Pointer auf Ergebnis cout << "Dreifaches = " << (*p) << endl; delete p; return 0; }
7.14 Funktionen, die eine Referenz liefern Eine Funktion kann auch eine Referenz liefern. Ihr Return-Wert ist dann ein L-Wert; d.h., der Funktionsaufruf wird dann auf der linken Seite einer Wertzuweisung stehen! Folgendes Programm verdeutlicht die etwas ungewohnte Schreibweise: #include using namespace std; int a[5] = {0,1,2,3,4}; int &setzen(int); // Prototyp int main() { int i; for (i=0; i<5; i++) cout << a[i] << " "; cout << endl; setzen(0) = 11; setzen(4) = 13; for (i=0; i<5; i++) cout << a[i] << " "; cout << endl; return 0; } int &setzen(int index) { return a[index]; }
Die Programmausgabe zeigt, dass a[0] und a[4] tatsächlich verändert wurden: 0 11
1 1
2 2
3 3
4 13
7.15 Default-Argumente
135
Die Möglichkeit, dass eine Funktion eine Referenz liefert, wird bei Elementfunktionen und Operatoren verwendet, um Funktionswerte der Operatoren = und << verketten zu können: x = y = z = 0; cout << a << b << endl;
Zu beachten ist, dass eine solche Funktion keine Referenz auf eine lokale Variable zurückgeben darf, da solche nichtstatischen Variablen nach Verlassen des Funktionsstacks nicht mehr definiert sind! Ein Beispiel für diese nicht erlaubte Zuweisung ist: int &select(int n) { int x = 1; int y = 2; if (n == 1) return x; // Fehler! else return y; // Fehler! } int main() { int &z = select(1); cout << "value = " << z; return 0; }
Der Compiler liefert hier eine Fehlermeldung der Art Versuch, eine Referenz auf eine lokale Variable zurückzugeben.
7.15 Default-Argumente In der Liste der formalen Parameter einer Funktion kann in C++ ein Default-Argument vorgegeben werden. Fehlt dieser Wert bei der Übergabe, so wird der formale Parameter mit dem Default-Wert belegt. Als Beispiel dient die Volumenformel eines Quaders, wobei die Default-Werte die Kantenlängen des Einheitswürfels sind. // default.cpp #include using namespace std; double inhalt(double a=1,double b=1,double c=1) {return a*b*c; } int main() { cout << inhalt(2,3,4) << endl; cout << inhalt(2,3) << endl; cout << inhalt() << endl; return 0; }
136
7 Funktionen
Die Funktionswerte liefern den Rauminhalt der Quader 2*3*4 und 2*3*1 und des Einheitswürfels. Zu beachten ist, dass die Default-Werte immer links in der Parameterliste stehen. Typische Beispiele für Default-Werte sind unter anderem 왘 Brüche mit Nenner 1 (sodass auch ganze Zahlen als Eingabe möglich sind) 왘 komplexe Funktionen mit Imaginärteil 0 (damit werden auch rein reelle Werte
akzeptiert) 왘 Standard-Normalverteilung mit Mittel und Standardabweichung
µ = 0, σ = 1
7.16 Inline-Funktionen Wie bereits erwähnt, kann der Funktionsoverhead bei der Abarbeitung zahlreicher Funktionen sehr erheblich sein. Daher wurde in C++ der Funktionsspezifizierer inline geschaffen, der dem Compiler nahelegt, die Anweisungen des Funktionsrumpfs direkt in den Quellcode zu schreiben, sodass keine Funktionsaufrufe mehr notwendig sind. Mögliche Inline-Funktionen sind: inline int Max(int a,int b) // max vordefiniert { return (a>=b)? a : b; } inline double Abs(double x) // abs vordefiniert { if (x>=0) return x; else return -x; }
Diese Funktionen sollen die in C verwendeten, stark fehleranfälligen, Makros ersetzen der Art: #define max(a,b) ((a)>=(b)?(a):(b)) #define abs(x) (((x)>=0)?(x):(-x))
Die meisten Compiler führen den inline-Befehl nicht aus, wenn die Funktion eine Kontrollstruktur, z.B. eine FOR- oder Switch-Anweisung, enthält. Ein Nachteil der Inline-Funktionen ist, dass durch zahlreiche Codeeinfügungen der Umfang des Maschinencode vergrößert wird. Jedoch bedeutet die Vermeidung des Funktionsoverhead meist einen erheblichen Zeitgewinn.
7.17 Überladen von Funktionen Hat man für mehrere Datentypen gleichartige Funktionen zu schreiben, so müssen diese in C verschiedene Namen tragen, damit der Compiler sie unterscheiden kann. In C++ besteht die Möglichkeit, für gleichartige Funktionen innerhalb eines Namensbereichs den gleichen Namen zu vergeben. Dies nennt man Überladen von Funktionen (function overloading). Ein Beispiel ist:
7.18 Template-Funktionen
137
char Max(char a,char b) { return (a>=b)? a:b; } int Max(int a,int b) { return (a>=b)? a:b; } double Max(double a,double b) { return (a>=b)? a:b; }
Anhand der verschiedenen Signaturen kann der Compiler die zum Datentyp passende Funktion auswählen. Max('1','2') // ok Max(1,2) // ok Max(1.,2.) // ok Max(1,2.) // ok wegen internem Cast
7.18 Template-Funktionen Vergleicht man noch einmal das Beispiel vom letzten Abschnitt zur selbst definierten Maximumfunktion char Max(char a,char b) { return (a>=b)? a:b; } int Max(int a,int b) { return (a>=b)? a:b; } double Max(double a,double b) { return (a>=b)? a:b; }
so erkennt man sofort, dass diese Funktionen nur die Typennamen char, int bzw. double unterscheiden. Genau hier setzt die Möglichkeit ein, in C++ eine TemplateFunktion zu definieren. Diese Funktionen werden in der Literatur auch generisch genannt, auch die Bezeichnung Funktionsschablonen findet sich in der Literatur. Ersetzt man bei den obigen Funktionen den Datentyp durch den formalen Parameter T, so erhält man: T max(T a,T b) { return (a>=b)? a:b; }
Damit der Compiler die Template-Funktion erkennt, muss noch template
vorangestellt werden. Ein vollständiges Programm zur Template-Maximumsfunktion lautet somit: // templat.cpp #include using namespace std; template T Max(T a,T b) { return (a>b)? a:b; } int main() { int m=13; double x=14.2; char ch = 'P'; cout << Max(m,-17) << endl; cout << Max(x,15.8) << endl;
Wie kompliziert eine Programmiersprache ohne überladene Funktionen bzw. Templates sein kann, zeigt MODULA 2. Hier muss für jeden Datentyp eine eigene Ausgabeprozedur definiert werden, u.a. WriteString // String WriteLn // endl WriteCard // unsigned WriteWord WriteChar //char
Ein weiteres Beispiel zeigt eine Template-Funktion zur Ausgabe einer Reihung. // templat2.cpp // Template-Funktion fuer Array-Ausgabe #include using namespace std; template void print(T *array,const int n) { for (int i=0; i
7.19 Übungen
139
Die Ausgabe lautet: 1 2 3 4 5 6 7 1.1 2.2 3.3 4.4 5.5 6.6 7.7 A l b e r t E i n s t e i n
Für Template-Funktionen gelten folgende Regeln: 왘 Jedes Argument einer Template-Parameterliste muss auch formaler Parameter der
Funktion sein. 왘 Alle Argumente müssen Typ-Parameter sein. 왘 Template-Funktionen müssen eindeutig sein; d.h., es darf nur eine Definition geben. 왘 Spezifizierer müssen nachgestellt werden:
7.19 Übungen Übung (7.1): Erklären Sie die Wirkung des folgenden Programmabschnitts: int& maxi(int& x,int& y){ return (x>y ? x : y); } int a=7,b=9; maxi(a,b) = 15; maxi(a,b) -= 10; maxi(a,b)++;
Übung (7.2): Die Differenz von scheinbarer m und absoluter Helligkeit M eines Sterns heißt Entfernungsmodul. Schreiben Sie eine Funktion entfernung(), die aus dem Entfernungsmodul die Entfernung d eines Sterns in Lichtjahren (Lj) berechnet (vgl. Abschnitt 20.9). Es gilt
d = 32.6 Lj ⋅ 10( m− M )/5 Bestimmen Sie damit die Entfernung des Sterns Rigel (ß Orionis) mit m = 0.15, M=-7.1. Übung (7.3): Die Anzahl der radioaktiven Zerfälle je Zeiteinheit nennt man Aktivität eines Präparats. Misst man die Zeit in Sekunden, so heißt die Einheit der Aktivität Becquerel. Für die Aktivität A(t) zur Zeit t gilt: t A(t) = ( 12 )th A0
Dabei ist A0 die Aktivität am Anfang (t=0) und th die Halbwertszeit. Schreiben Sie eine Funktion c14alter(), die nach Eingabe der Aktivität von 1 Gramm Kohlenstoff das Alter der Probe bestimmt. Es gelten die Werte A0 = 14,0 Zerfälle/min und
140
7 Funktionen
th = 5730 Jahre. Berechnen Sie damit das C14-Alter der Jesaja-Schriftenrollen aus den Qumran-Höhlen, für die eine C14-Aktivität von A(t) = 11,1 Zerfälle/min gemessen wurde (bezogen auf 1 Gramm). Übung (7.4): Der Luftwiderstand F eines Fahrzeugs ist gegeben durch
F = 12 cw Aρv 2 Dabei ist v die Geschwindigkeit (in m/s), A die Querschnittsfläche, cw der Luftwiderstandsbeiwert und ρ die Luftdichte. Schreiben Sie eine Funktion luftwiderstand(), die unter Verwendung der konstanten Werte
A = 2, 2m2 , cw = 0, 3, ρ = 1, 29
kg m3
den Luftwiderstand in Abhängigkeit von der Geschwindigkeit v (in km/h) berechnet. Übung (7.5): Schreiben Sie eine Funktion zur Berechnung der Einkommenssteuer für das Jahr 2001: Einkommen in DM
Formel
0 – 14.093
0
14.094-18.089
(387,89*y+1.990)*y
18.090 – 107567
(142,49*z+2.300)*z+857
>107.568
0,485*x-19.299
Dabei ist E das zu versteuernde Einkommen, das auf das nächst gelegene Vielfache von 54 DM abgerundet und um 27 DM erhöht wird. Ferner ist x = E, y = (E-14.000)/ 10.000 und z=(E-18.036)/10.000. Übung (7.6): Finden Sie eine nichtrekursive Version der Funktion von McCarthy für eine natürliche Zahl n:
n − 10 für n > 100 f (n) = f ( f (n + 11) für n ≤ 100 Übung (7.7): Folgende Funktion gibt an, in wie viele Gebiete die Zeichenebene durch n Geraden maximal geteilt wird. Bestimmen Sie den Funktionsterm!
2 für n = 1 f (n) = f (n − 1) + n für n > 1 Übung (7.8): Schreiben Sie eine Funktion zufall(), die mit folgender Formel
x = e x +π − e x +π
7.19 Übungen
141
reelle Zufallszahlen im Bereich [0;1[ erzeugt. Möglicher Startwert ist x = π4 . Die eckigen Klammern stellen die Gaußklammer-Funktion dar, die hier durch die Funktion floor() ersetzt werden kann.
8
Klassen A class is a set of objects that share a common structure and a common behavior. G.Booch
Eine Klasse ist ein benutzerdefinierter Datentyp; er basiert auf einer Weiterentwicklung des strukturierten Datentyps struct. Die Erweiterung ermöglicht neben der Vereinbarung von Datenelementen und Elementfunktionen u.a. auch die Definition von Operatoren, mit deren Hilfe die Datenelemente der Klasse manipuliert werden.
8.1
Klassen als ADT
Klassen sind im Sinne der Informatik ein abstrakter Datentyp (ADT). Abstrakt deswegen, weil eine Klasse – ähnlich einer Black Box – zur Lösung eines beliebigen komplexen Problems eingesetzt wird, ohne dass interne Details vorgegeben sind. Im Sinne der Unified Modeling Language (UML) sind die Objekte die Abstraktion der Dinge der Realwelt. Die Struktur der Dinge wird durch die Attribute der Objekte (in C++ Datenelemente) repräsentiert. Das Verhalten der Dinge wird simuliert durch Operationen (in C++ Elementfunktionen genannt).
Abbildung 8.1: Objekte als Abstraktionen der Realwelt
Klassen sind im Sinne der UML Beschreibungen oder Mengen von Objekten mit gemeinsamen Eigenschaften, u.a.: 왘 gemeinsame Struktur und gleichartiges Verhalten 왘 Kapseln der internen Struktur und des Verhaltens 왘 genaues Definieren der Zugriffsmöglichkeiten von Außen
144
8 Klassen
왘 Vorgabe des Mechanismus, mit dem Attribute hinzugefügt, gelöscht oder modifi-
ziert werden 왘 Bilden von Typen mit einer gemeinsamen Spezifikation und Schnittstelle 왘 Vollziehen eines Lebenszyklus (erzeugen, initialisieren, modifizieren, selektieren,
löschen) Beispiele von ADT des täglichen Lebens sind etwa: ADT Person Repräsentation Name(String), Alter(Ganzzahl), Geschlecht (enum-Typ), Familienstand (enum-Typ) erzeuge() // Erzeugen einer Person setzeAlter(),gibAlter() // Zugriff auf Alter setzeNamen(), gibNamen() // Zugriff auf Namen setzeFamstand(), gibFamstand() // Zugriff auf Familienstand setzeGeschlecht(), gibGeschlecht() // entsprechend ADT Münzbetrag Repräsentation 5DM, 2DM, 1DM, 50Pfg, 10Pfg, 5Pfg, 2Pfg, 1Pfg (jeweils Ganzzahl) erzeuge() // Erzeugen eines Münzbetrags aus vorgeg. Münzen betrag() // Münzbetrags aus vorgegebenen Münzen einzahlen() // Hinzufügen eines Münzbetrags auszahlen() // Entnehmen eines Münzbetrags istLeer() // Angabe, ob Münzbetrag gleich Null
Abstrakte Datentypen lassen sich auch axiomatisch definieren. Erfüllt eine Klasse alle Axiome, d.h. alle Bedingungen, so stellt sie die Lösung des Problems dar, unerheblich, wie die Klasse intern realisiert ist und ob es eine Alternativlösung gibt. Ein Beispiel eines axiomatisch definierten ADT ist der Stack: ADT Stack new() // erzeugen e. leeren Stacks top() // liefert oberstes Element push(item) // Item auf Stack legen pop() // oberstes Element entnehmen is_empty() // prüft ob leer top(push(stack,item)) = item top(new) = Item-Error pop(push(stack,item) = stack pop(new) = Stack-Error
Das bekannteste Axiomensystem bilden wohl die Gesetze der Mengenlehre ADT Menge
8.2 Definition und Deklaration
145
A∪ B = B∪ A A∩ B = B∩ A
( A ∪ B) ∪ C = A ∪ (B ∪ C) ( A ∩ B) ∩ C = A ∩ (B ∩ C) A ∪ (B ∩ C) = ( A ∪ B) ∩ ( A ∪ C) A ∩ (B ∪ C) = ( A ∩ B) ∪ ( A ∩ C) A∪ B = A∩ B A∩ B = A∪ B A∪ A = A A∩ A = A Solche formale Beschreibungen können in manchen Programmiersprachen direkt implementiert werden.
8.2
Definition und Deklaration
Eine Klasse wird deklariert durch Angabe des Klassennamens: class A;
Diese Deklaration entspricht der Deklaration eines Verbundes struct, es darf kein Typspezifizierer wie in Java vorangehen: public class X; // Fehler, kein Typspezifizierer!
Die Deklaration einer Klasse wird als Vorwärtsdeklaration benötigt, wenn zwei Klassen wechselseitig Objekte der anderen enthalten; wie es im folgenden Beispiel bei den Klassen X und Y der Fall ist: class X; // Vorwärtsdeklaration class Y { X* x}; // Y enthält ein Objekt vom Typ X class X { Y* y}; // X enthält ein Objekt vom Typ Y
Die Klasse wird definiert durch ihren (eindeutigen) Namen und Angabe ihrer Komponenten (englisch members): 왘 Name 왘 Datenelemente (structural characteristic im Sinne der UML) 왘 Elementfunktionen (behavioral characteristic im Sinne der UML)
Eine Klassendefinition muss stets mit einem Semikolon abgeschlossen werden! Eine Definition kann gemäß der UML grafisch dargestellt werden. Wegen der Datenkapselung in C++ muss für alle Klassenkomponenten die Zugriffsmöglichkeit spezifiziert werden.
146
8 Klassen
Abbildung 8.2: Symbole für Zugriffspezifierer in einer Klasse
Private (private) Komponenten einer Klasse sind nur für die Elementfunktionen dieser Klasse zugänglich. Auf öffentliche (public) Komponenten kann von allen Funktionen des Programms zugegriffen werden, sie stellen also die Schnittstelle der Klasse nach außen dar. Geschützte (protected) Komponenten treten bei der Vererbung auf (siehe Kapitel 11). Wird in einer Klasse kein Zugriffspezifizierer gegeben, ist gilt der DefaultWert private. Jede Klassendeklaration führt einen neuen, eigenen Typ ein; Objekte zweier Klassen sind daher nicht kompatibel. class A { int x; } a1,a2; class B { int x; } b; a1 = a2; // ok const A a3 = a1; // ok int y = a1; // Fehler, falscher Typ a2 = b; // Fehler, falscher Typ
Eine Klassendeklaration ist vollständig in dem Sinn, dass an keiner anderen Stelle des Programms ein weiteres Datenelement oder eine Elementfunktion an eine Klasse angefügt werden kann. Ein Beispiel einer einfachen Klasse ist: // include fuer M_PI class Kreis { private: double radius; public: Kreis(double R){ radius = R; } double flaeche() const { return M_PI*radius*radius; } double umfang() const { return 2*M_PI*radius; } };
8.3 Elementfunktionen
147
Die Klasse Kreis besteht hier aus dem privaten Datenelement r (Radius) und den öffentlichen Elementfunktionen flaeche() und umfang(). Ein Quader ist bestimmt durch Länge, Breite und Höhe und besitzt ein Volumen und eine Oberfläche. Daraus lässt sich folgende Klasse bilden: class Quader { private: double a,b,c; // Laenge, Breite, Hoehe public: Quader(double A,double B,double C) {a =A; b = B; c=C;} double volumen() const { return a*b*c; } double oberflaeche() const { return 2(a*b + a*c +b*c); } };
Da Klassenobjekte im Gegensatz zu den eingebauten Datentypen char/int/float noch nicht existieren, müssen die Objekte oder Instanzen zuerst deklariert bzw. definiert werden. Beispiele sind: Kreis K1; Kreis K2(5); K1 = K2; Quader Q1,Q2; Quader Q3(3,4,5);
Falls Sie diese Beispiele compilieren wollen, sei erwähnt, dass die oben angegebenen Versionen von Kreis und Quader noch keine Deklarationen erlauben (hier fehlt noch der sog. Default-Konstruktor, vergleiche dazu Abschnitt 8.4). Ein Objekt besteht aus 왘 Objektnamen 왘 Zustand (Wertemenge der Zustandsvariablen) 왘 Verhalten (Methoden der Klasse)
Das heißt, die Objekte einer bestimmten Klasse stimmen nicht notwendig in ihrem Zustand überein. Die Menge aller möglichen Zustände, d.h. der Zustandsbereich, ist durch die Klasse gegeben. Ein Objekt ist realisiert durch den für das Objekt reservierten Speicherplatz. Während eine Klasse während der gesamten Laufzeit existiert, kann ein Objekt zur Laufzeit erzeugt und vernichtet werden.
8.3
Elementfunktionen
Die öffentlichen Elementfunktionen sind die Funktionen, die die Schnittstelle einer Klasse nach außen spezifizieren. Sie haben folgende Eigenschaften: 왘 Sie besitzen vollen Zugriff auf alle Elemente der Klasse, auch auf die privaten. 왘 Sie können nur in Bezugnahme auf ein Klassenobjekt verwendet werden; für
fremde Objekte sind sie nicht zugänglich.
148
8 Klassen
왘 Ihre Definition kann innerhalb oder außerhalb der Klasse erfolgen. Im ersten Fall
sind sie dann – sofern möglich – eine inline-Funktion. Im zweiten Fall müssen sie mittels Scope-Operators definiert werden. Die UML unterscheidet folgende Elementfunktionen Konstruktoren/Destruktoren
Mögliche Zugriffsfunktionen (in oben genannter Klasse Kreis noch zu definieren) sind: void setzeRadius(double R) { radius = R; } double gibRadius() { return radius; }
Im Englischen beginnt der Bezeichner einer Zugriffsfunktion meist mit get...// lesender Zugriff set...// schreibender Zugriff
Eine Klasse ist eine Erweiterung des Datentyps struct. Entfernt man nämlich alle Elementfunktionen aus einer Klasse und erklärt alle Datenelemente als public, so ist der Typ class gleichwertig mit struct. Ein Beispiel einer solchen Klasse mit ausschließlich öffentlichen Datenelementen ist: class Rechner { public: enum MaschinenTyp{PC,Workstation,Server}; enum Monitor{Farbe,SW}; MaschinenTyp Typ; Monitor Bildschirm; int Festplatte; // MB int RAM; // Speicher(MB) int Frequenz; // Prozessor(MHz) bool CDROM; // vorhanden(j/n) };
8.4 Klasse als Verallgemeinerung der Funktion
8.4
149
Klasse als Verallgemeinerung der Funktion
Eine Klasse kann aber auch als Erweiterung einer Funktion betrachtet werden. Dies ist z.B. der Fall, wenn die Klasse nur eine Elementfunktion enthält, die den gewünschten Wert liefert. Allerdings wird für jeden Funktionsaufruf ein Objekt der Klasse erzeugt. Dies zeigt das folgende Beispiel, das eine Umrechnungstabelle von Celsius-/Fahrenheit-Graden erzeugt. class Fahrenheit { double celsius; // Grad Celsius public: Fahrenheit(double C){ celsius = C;} double temperatur() {return 1.8*celsius+32;} }; int main() { for (double x=0; x<=100; x +=10) { Fahrenheit f(x); cout << x << " " << f.temperatur() << endl; } return 0; }
8.5
Eine Klasse Bruch
Als Beispiel einer einfachen Klasse sei die Klasse Bruch definiert. Als Datenelemente werden zaehl bzw. nenn gewählt. Als Elementfunktionen dienen wert() bzw. nenner(), die als öffentlich erklärt werden. Zum Erzeugen eines Bruchs wird ein entsprechender Konstruktor geschrieben. Damit erhält man folgende Klasse: class Bruch { private: int zaehl; // Zaehler int nenn; // Nenner public: Bruch(int z=0,int n=1) // Konstruktor mit Default-Werten { zaehl = z; nenn = n; } double wert() const { return zaehl/double(nenn); } int nenner() const {return nenn; } };
Durch Konstruktor-Aufruf werden zwei Brüche a = 3/5 und b = 3 definiert: Bruch a(3,5); // Bruch (3/5) Bruch b(3); // Bruch (3/1)
150
8 Klassen
Ein direkter Zugriff auf die privat erklärten Zähler und Nenner ist nicht möglich: a.zaehl = -2; // Fehler, privat b.nenn = 7; // Fehler, privat cout << b.zaehl << endl; // Fehler, privat
Eine Ausgabe des Nenners und des Bruchwert dagegen ist möglich, da diese Funktion öffentlich sind. cout << "Wert von a = " << a.wert() << endl;// ok, public cout << "Nenner von b = " << b.nenner() << endl; // ok
Elementfunktionen, die innerhalb der Klasse definiert sind, sind implizit inline-Funktionen. Viele Compiler (darunter der von Borland) behandeln eine Funktion, die eine bedingte oder Wiederholungsanweisung enthält, nicht als inline. Elementfunktionen, die außerhalb der Klasse definiert werden, müssen explizit mittels des Bereichsoperators :: als zur Klasse gehörig ausgewiesen werden. Diese außerhalb definierten Elementfunktionen müssen jedoch innerhalb der Klasse in Form eines Prototypen deklariert werden. class Bruch { .... void print() const; // Deklaration }; void Bruch::print() const // Definition { cout << zaehl << "/" << nenn << end;}
Das Schlüsselwort const gibt an, dass hier die Elementfunktion die privaten Datenelemente nicht ändern darf.
8.6
Konstruktoren
Um ein Objekt zu erzeugen, verwendet man eine spezielle Elementfunktion, Konstruktor (constructor) genannt. Beim letzten Beispiel ist dies die Elementfunktion: Bruch(int z=0,int n=1){ zaehl = z; nenn = n; }
die einen Bruch nz erzeugt. Damit auch eine ganze Zahl als Eingabe möglich ist, erhält der Nenner den Default-Wert Eins; damit wird auch gleichzeitig die Division durch Null bei Default-Werten verhindert. Ein Konstruktor hat folgende Eigenschaften: 왘 Er trägt den Namen der Klasse. 왘 Er liefert keinen Return-Wert (auch nicht void). 왘 Seine Parameterliste (falls vorhanden) initialisiert ein Objekt.
8.6 Konstruktoren
151
Konstruktoren treten in folgenden Formen auf: 왘 Default-Konstruktor 왘 Benutzerdefinierter Konstruktor 왘 Copy-Konstruktor
Ein Konstruktor darf weder als const noch als static oder virtual erklärt werden. Der Default-Konstruktor (default constructor) ist ein Konstruktor, der keine Parameter oder ausschließlich Default-Werte enthält. In der ersten Form wird dieser Konstruktor vom Compiler (implizit) erzeugt, falls das Programm keinen enthält. Kreis; // Default Quader(double A=1,double B=1,double C=1):a(A),b(B),c(C){} // Defaultwerte fuer Einheitswuerfel
Eine Klasse darf aber auch nicht zwei Default-Konstruktoren enthalten, wie bei: Kreis(){} Kreis(double R = 1) { radius = R; } // Fehler, nicht eindeutig
Der Default-Konstruktor einer Klasse wird nur formal wie ein Funktionsprototyp geschrieben. Eine Deklaration in der Form () Quader Q(); // falsch
ist daher nicht möglich. Dies wurde auch in Hinblick auf die Kompatibilität zu C festgelegt. Die Deklaration eines Objekts folgt der Schreibweise von einfachen Datentypen, also in der Form Quader Q; // ok
Der benutzerdefinierte Konstruktor (user defined constructor) enthält (falls benötigt) die Parameterliste für die Initialisierung der (nicht notwendig aller) Datenelemente. Dieser Konstruktor kann beliebig überladen werden. Jedoch müssen sich alle benutzerdefinierten Konstruktoren in ihrer Signatur unterscheiden. Dies wird an der folgenden Klasse Dreieck gezeigt: class Dreieck { double a,b,c; // Dreiecksseiten public: Dreieck (double A,double B,double C) {a = A; b = B; c = C;} // Konstruktor mit Seiten Dreieck (Punkt A,Punkt B,Punkt C) {} // Konstruktor mit Punkten };
Dagegen wäre der Konstruktor mit den drei Höhen ha , hb , hc : Dreieck (double ha,double hb,double hc) { .. }
nicht unterscheidbar, da seine Signatur Dreieck(double,double,double)
mit der des »Seiten«-Konstruktors übereinstimmt. Der Konstruktor-Aufruf in der Form: Dreieck (double A,double B,double C){a = A;b = B;c = C;}
152
8 Klassen
ist zu unterscheiden von der Initialisierung mittels Initialisierungsliste: Dreieck (double A,double B,double C):a(A),b(B),c(C){ }
da Letztere keine Wertzuweisung im Sinne von C++ ist. Die Initialisierung benötigt man für alle const-Typen. Ein weiteres Beispiel eines Objekts mit mehreren möglichen Konstruktoren ist die Gerade, deren Gleichung in vielfältiger Form auftritt:
ax + by + c = 0
(implizit)
y = ax + b (exp lizit) y − y1 y = y1 + 2 ( x − x1 ) x2 − x1 x x0 cos α = + t y y0 sin α ax + by − c a2 + b 2
(2 Punkteform)
(Parameterform)
= 0 ( Hesse − Normalform)
Die ersten beiden Formen enthalten zwei bzw. drei reelle Parameter; die zugehörigen Konstruktoren können dadurch unterschieden werden. Die dritte Geradenform kann durch Einführung von kartesischen Punkten konstruiert werden. Die vierte Form könnte durch Einführung von zweidimensionalen Vektoren erklärt werden. Das folgende Beispiel beschränkt sich auf die ersten drei Formen. class Punkt { public: double x,y; // kartesisch Punkt(double X=0,double Y=0) {x = X; y = Y; } }; class Gerade { double A,B,C; // Ax+By+C=0 implizite Form public: Gerade(){} Gerade(double a,double b,double c); // implizite Form Gerade(double M,double P); // explizite Form y=mx+p Gerade(Punkt a,Punkt b) // 2-Punkte-Form };
Ein fehlender Copy-Konstruktor wird (implizit) vom Compiler geliefert. Ein solcher Default-Copy-Konstruktor (copy constructor) erzeugt ein neues Objekt durch Wertzuweisung an ein bereits existierendes als (elementweise) exakte Kopie.
8.6 Konstruktoren
153
Bruch a(3,5); // Initialisierung Bruch b = a; // Copy-Konstruktor
Die Aufrufe der verschiedenen Konstruktoren zeigt das Programm: class Objekt { private: int i; public: Objekt() { i = 0; call();} Objekt(int I) { i = I; call(); } ~Objekt() {} void call(){cout << "Constructor called " << "i = " << i << endl;} }; int main() { Objekt obj1; // Default-Konstruktor Objekt obj2(100); // benutzerdef. Konstruktor Objekt obj3 = obj2; // Copy Konstruktor return 0; }
Die Ausgabe Constructor called; i = 0 Constructor called; i = 100
zeigt, beide expliziten Konstruktoren wurden aufgerufen. Da aber drei Objekte definiert werden, wurde das dritte durch compilergenerierten Copy-Konstruktor angelegt. Der Copy-Konstruktor kann durch Objekt(Objekt &) //bzw. Objekt(const Objekt &)
auch explizit definiert werden. Es ist zu vermeiden, dass ein Konstruktor eine Ausgabefunktion enthält, da dies ein unerwünschter Nebeneffekt ist. Dies wurde im obenstehenden Programm nur aus Demonstrationsgründen getan. Ein Konstruktor hat wie eine Funktion keine Adresse. Mittels cout << (void *)this << endl;
lässt sich aber die aktuelle Speicheradresse des laufenden Programms ausdrucken. Man erhält eine Ausgabe (in Hexadezimalzahlen) in der Art: 0x549f23e8 0x549f23e6 0x549f23e4
154
8 Klassen
Dies zeigt hier maschinenabhängig, dass mit jedem Konstruktor-Aufruf zwei Byte (entsprechend dem Bedarf des hier verwendeten Rechners für den Typ int) im Speicher belegt werden. Der Copy-Konstruktor wird in den folgenden drei Fällen aktiviert: 왘 Explizite Initialisierung eines Objekts durch ein anderes 왘 Initialisierung eines formalen Parameters beim Call-by-Value 왘 Übergabe eines Funktionsergebnisses
Konstanten einer Klasse müssen in Form von Initialisierungslisten definiert werden; sie können nicht, wie statische Daten, direkt initialisiert werden. class X { int a; const int b; public: X(int A):a(A),b(17) {cout << a << " " << b << endl;} X(int A,int B): a(A),b(B) {cout << a << " " << b << endl;} }; int main() { X x(12); // x.a=12; const x.b=17 X y(12,17); // y.a=12; const y.b=17 X z(12,21); // z.a=12; const z.b=21 return 0; }
Auch hier wurde aus Demonstrationsgründen eine Ausgabe im Konstruktor-Aufruf bewirkt, was sonst nicht empfohlen wird. In den Internet-Seiten der Newsgroup language.c++ wird ctor als Kurzform von constructor verwendet. Wie schon erwähnt, erzeugt der Compiler gewisse fehlende Elementfunktionen implizit. Einen Überblick liefert die folgende Tabelle: Generierte Elementfkt.
Prototyp
Aktion
Default-Konstruktor
T();
-
Copy-Konstruktor
T(const T&)
flache Kopie (Initialisierung)
Zuweisungsoperator
T& operator=(const T&)
flache Kopie (Zuweisung)
Adressoperator
T* operator&()
liefert Adresse
Für Klassen mit dynamisch erzeugten Datenelementen müssen diese Elementfunktionen explizit, d.h. vom Programm, erzeugt werden (vgl. Abschnitt 8.11).
8.7 Destruktoren
8.7
155
Destruktoren
Destruktoren sind wie Konstruktoren Elementfunktionen, die keinen Return-Typ liefern. Sie können weder const noch static sein, jedoch aber virtual. Ein Destruktor hat keine formalen Parameter. Er wird gekennzeichnet durch die Tilde, wobei der BitNegationsoperator überladen wird. Ein Destruktor wird aufgerufen: 왘 implizit, wenn eine auto(matische) Variable ihren Geltungsbereich verlässt. 왘 implizit, wenn ein statisches Objekt beim Programmende zerstört wird. 왘 explizit durch die Anwendung des delete-Operators zum Löschen eines mit new
dynamisch erzeugten Objekts. Das implizite Löschen von automatischen Variablen wird mit einem kleinen Programm demonstriert: int count=0; // globaler Zaehler class Objekt { public: Objekt() // Konstruktor { count++; cout << "Anzahl der Objekte " << count << endl; } ~Objekt() // Destruktor { count--; cout << "Anzahl der Objekte " << count << endl; } }; int main() { Objekt A,B,C; { Objekt D; } { Objekt E; } return 0; }
Auch hier ist zu beachten, dass eine Ausgabe in einem Konstruktor bzw. Destruktor ein unerwünschter Nebeneffekt ist! Die Ausgabe erfolgt hier nur aus Demonstrationsgründen; sie zeigt, wie bei jedem (Default-)Konstruktor-Aufruf die Anzahl der vorhandenen Objekte um Eins erhöht wird und bei Verlassen des Blocks um Eins verringert wird. Anzahl der Objekte 1 // A erzeugt Anzahl der Objekte 2 // B erzeugt
Die Reihenfolge kann compilerabhängig sein. Bei dynamischen Variablen ist auf das Zusammenspiel von (impliziten) Copy-Konstruktor und Destruktor zu achten. Betrachtet wird folgendes (unschuldig aussehendes) Programm: class String { private: char *str; int size; // Laenge public: String() {} String(int len) { str = new char[size = len]; cout << "new called" << endl; } ~String() {cout << "delete called" << endl; delete str;} }; // in main() String s1(10),s2; s2 = s1; // Vorsicht!!
Die Ausgabe zeigt eine böse Überraschung: new called delete called delete called
Es wurde ein Objekt erzeugt, dessen Speicherplatz aber zweimal gelöscht! Dies kann fatale Folgen für die Stabilität des Rechners haben. Was ist geschehen? Für String s1 wurde der benutzerdefinierte, für s2 der Default-Konstruktor aufgerufen, dabei werden für s1.str 10 Byte Speicher belegt. Der Copy-Konstruktor bewirkt, dass s1.str und s2.str nun auch denselben Speicherplatz zeigen. Dieser Speicherplatz wird nun vom Destruktor zweimal gelöscht. Abhilfe schafft hier ein selbst definierter CopyKonstruktor, der dafür sorgt, dass nicht der Zeiger, sondern das ganze Array kopiert wird. Ein Beispiel folgt im Abschnitt 8.13. In den Internet-Seiten der Newsgroup language.c++ wird dtor als Kurzform von destructor benützt.
8.8 Beispiel zur Datenkapselung
157
Folgende Deklarationen sollte eine Klasse aufweisen, damit Objekte mit anderen Datentypen in üblicher Weise verknüpft werden können: 왘 Korrekter Default-Konstruktor 왘 Enthält eine Klasse dynamische Datenelemente, so müssen eine benutzerdefinierter
Zuweisungsoperator und ein Copy-Konstruktor vorhanden sein. 왘 Ein (meist) virtueller Destruktor garantiert das Löschen der Datenelemente (das
Schlüsselwort virtual wird in Kapitel 11 erklärt). Dies wird die kanonische Form von Klassen genannt.
8.8
Beispiel zur Datenkapselung
An einem Beispiel soll der Vorteil der Datenkapselung dargestellt werden. Gegeben sei die Klasse Punkt, realisiert mit Hilfe von kartesischen Koordinaten. class Punkt { private: double x,y; // kartesische Koordinaten public: Punkt(double X=0,double Y=0) {x = X; y = Y; } void setx(double X) {x = X;} // Setzen d. Koordinaten void sety(double Y) {y = Y;} double getx() const {return x;} // Einlesen d.Koordinaten double gety() const {return y;} void moveto(double X,double Y) {x = X; y = Y; } };
Die Klasse wird durch ein Hauptprogramm aufgerufen. int main() { Punkt A(3,1),B(6,-3); cout << "Gegebene Punkte " << endl; cout << "A = (" << A.getx() << "," << A.gety() << ")" << endl; cout << "B = (" << B.getx() << "," << B.gety() << ")" << endl; B.moveto(9,9); cout << "B verschoben auf (" << B.getx() << "," << B.gety() << ")" << endl; return 0; }
Soll nun aus irgendeinem Grund die Klasse Punkt mittels Polarkoordinaten realisiert werden, so kann dies geschehen, ohne irgendeine öffentliche Elementfunktion zu ändern! Eine neue Programmversion behält dadurch, unabhängig von einer Änderung der privaten Funktionen, seine Schnittstelle nach außen bei. Eine mögliche Realisierung hier bietet die modifizierte Klasse Punkt.
Dies lässt sich mit demselben Hauptprogramm testen, die Ausgabe ist in beiden Fällen: Gegeben A = (3,1) Gegeben B = (6,-3) B verschoben auf (9,9)
Die Kapselung einer Klasse nach außen zeigt sich auch in der Dokumentation. Man zerlegt den Quellcode der Klasse in zwei Dateien. Die Header-Datei zeigt nur die Deklaration der Klasse, eine Quellcode-Datei enthält die Implementation der Klasse und liegt nach dem separaten Compilieren als Objekt-Code *.obj (bzw. unter Unix als *.o) vor. Beide Dateien können dann in einem beliebigen Programmprojekt eingebunden werden. Der Anwender kann die Header-Datei im Textformat lesen und sich über die enthaltenen Deklarationen der Klasse informieren. Die Programmierung der Klassenmethoden ist im Objektcode verborgen. Eine Header-Datei zur Klasse ist // punkt.h #ifndef _punkt_h #define _punkt_h class Punkt { double x,y; public: Punkt(){} Punkt(double,double); void setx(double); void sety(double); double getx() const; double gety() const; void moveto(double,double); }; #endif /* _punkt_h */
8.9 Klasse mit Array
159
Manche Compiler, z.B. der Borland-Compiler, führen zum schnelleren Zugriff Buch über vorcompilierte Header-Dateien. Enthält ein solcher Header Quellcode, so erfolgt eine Warnung in der Form Warning: Cannot create pre-compiled header: code in header
da bei Änderung der Implementation dieser Quellcode neu compiliert werden muss. Dagegen benötigt eine reine Deklaration keine Neucompilierung.
8.9
Klasse mit Array
Zunächst ist es nicht klar, wie die konstante Obergrenze zur Deklaration eines Array in einer Klasse eingebracht werden soll. Die Lösung einer externen Konstante ist möglich, aber unbefriedigend. const int size = 100; class X { int a[size]; ... }
Eine praktikable Lösung ist die Einführung der Konstanten mit einem enum-Typ gemäß: enum {maxDeg = 26}; int deg; // Polynomgrad<=25 double pol[maxDeg]; // Koeffizienten
Damit lässt sich eine Klasse für Polynomfunktionen schreiben. class Polynom { private: enum {maxDeg = 26}; // max.Polynomgrad int deg; // Polynomgrad <=25 double pol[maxDeg]; // Koeffizienten public: Polynom(double c=0) // Defaultkonstruktor fuer Konstante { deg = 0; pol[0] = c; } Polynom(int n,double koeff[]); double horner(double x); // Hornerschema };
Der benutzerdefinierte Konstruktor setzt den Polynomgrad und kopiert die Koeffizienten: Polynom::Polynom(int n,double koeff[]) // Konstruktor { deg = n-1; for (int i=0; i<=n; i++) pol[i] = koeff[i]; }
Gemäß der ISO C++-Norm lassen sich neben den Enum-Typen nun auch statische Konstanten innerhalb der Klasse initialisieren:
160
8 Klassen
class Polynom { private; static const int maxDeg = 26; int deg; // Polynomgrad <=25 double pol[maxDeg]; // Koeffizienten // ....... };
Als Elementfunktion wird zur Funktionsauswertung das Horner-Schema verwendet. Das Horner-Schema wertet Polynome durch fortgesetztes Ausklammern ohne Potenzieren aus, z.B.
((( x − 2) ⋅ x − 3) ⋅ x + 5) ⋅ x + 1 = x 4 − 2x 3 − 3x 2 + 5x + 1 double Polynom::horner(double x) // Horner-Schema { double f = pol[deg+1]; for (int i=deg; i>=0; i--) f = f*x+pol[i]; return f; }
Den Aufruf von horner() werden wir im Abschnitt 8.13.2 durch Überladen des Operators () ersetzen. Ein mögliches Hauptprogramm ist: int main() { double a[] = {4,-2,-5,0,1}; // Polynom x^4-5*x^2-2*x+4 int n = sizeof(a)/sizeof(double); Polynom p(n,a); for (double x=0.; x<=1.01; x+=0.1) cout << x << " " << p.horner(x) << endl; return 0; }
Damit erhält man die Wertetabelle, die eine Nullstelle in [0.7;0.8] anzeigt. 0 4 0.1 3.7501 0.2 3.4016 0.3 2.9581 0.4 2.4256 0.5 1.8125 0.6 1.1296 0.7 0.3901 0.8 -0.3904 0.9 -1.1939 1 -2
8.9 Klasse mit Array
161
Das folgende Beispiel definiert mehrere Objekte der Klasse Box und vergleicht zwei Boxen mit Hilfe von Zeigern mittels Volumen. Sodann wird ein Array der Länge vier namens schachtel[] von Box-Objekten erzeugt; ein Zeiger durchläuft das Array und ermittelt alle Volumina. Dabei wird der static_cast-Operator verwendet. class Box { double laenge,breite,hoehe; public: Box(double l=1,double b=1,double h=1) { laenge = l; breite = b; hoehe = h;} double volumen() { return laenge*breite*hoehe; } bool compare(Box* pBox) { return this->volumen() > pBox->volumen(); } }; int main() { Box schachtel[4]; Box zuendholz; Box zigarre(12,15,4); Box umzug(120,80,60); Box spielzeug(50,40,30); Box* pA = &zigarre; Box* pB = 0; cout << "Addresse von Box1 = " << pA << endl; pB = static_cast(&zuendholz); if (pB->compare(pA)) cout << "Box 1 ist groesser als Box 2\n"; else cout << "Box 1 ist kleiner als Box 2\n"; schachtel[0] = zuendholz; schachtel[1] = zigarre; schachtel[2] = umzug; schachtel[3] = spielzeug; pA = static_cast(schachtel); for (int i=0; i<4; i++) cout << "Volumen von Schachtel " << i << " = " << (pA+i)->volumen() << endl; return 0; }
Eine Ausgabe ist hier: Adresse von Box1 = 0x0064fd6c Box 1 ist kleiner als Box 2 Volumen von Schachtel 0 = 1 Volumen von Schachtel 1 = 720 Volumen von Schachtel 2 = 576000 Volumen von Schachtel 3 = 60000
162
8 Klassen
8.10 Klasse mit dynamischem Array Die im Abschnitt 8.5 besprochene Schwierigkeit mit der Festlegung der Obergrenze einer Reihung kann mit Hilfe eines dynamischen Arrays überwunden werden. Dabei wird die Anzahl der Reihungselemente erst zur Laufzeit festgelegt. Dies sei am Beispiel eines Primzahlsiebs demonstriert. class Sieb { private: unsigned maxPrim; // max.Element char *p; // Sieb void fuellen(); void aussieben(); public: Sieb(unsigned); // Konstruktor ~Sieb() {delete [] p;} // Destruktor void ausgeben(); void zaehlen(); };
Im Konstruktor wird mit Hilfe des Operators new der benötigte Speicherplatz für das Sieb belegt, das Sieb gefüllt (durch Setzen der Elemente auf true) und ausgesiebt. Sieb::Sieb(unsigned M) { p = new char[maxPrim = M]; if (p==0){ cerr << "Kein Speicher frei\n"; exit(1);} fuellen(); aussieben(); }
Wichtig ist es, den mit new allozierten Speicherplatz zur Laufzeit wieder freizugeben. Dies geschieht durch den delete-Operator durch Destruktor-Aufruf. Bei Reihungen muss eine eckige Klammer eingefügt werden in der Form: ~array(){ delete [] a; }
8.11 Statische Datenelemente Alle bisher besprochenen Klassen haben die Eigenschaft, für jedes Objekt der Klasse einen vollständigen Satz von Elementfunktionen bereitzustellen; z.B. bei der Klasse Bruch: Bruch a,b; cout << a.wert() << endl; // Wert von a cout << b.nenner() << endl; // Nenner von b
8.11 Statische Datenelemente
163
In vielen Fällen möchte man eine Funktion verwenden, die der ganzen Klasse gehört und nicht zu einem einzelnen Objekt. Ein Beispiel wäre die Prozedur kuerzen(), die einen beliebigen Bruch kürzen soll: void kuerzen(int x, int y)....
und nicht ein spezielles Objekt: a.kuerzen() // Elementfunktion von a
Ein anderer Grund zur Einführung einer solchen Funktion ist die Unsymmetrie in der Schreibweise. Man möchte in der Klasse Punkt schreiben abstand(Punkt A,Punkt B) // Abstand zweier beliebiger Punkte
statt A.abstand(Punkt B) // Elementfunktion von Punkt A
Ein weiterer Grund ist die Speicherplatz-Ökonomie. Enthält eine Klasse eine umfangreiche Tabelle; z.B. eine Währungs- oder Umrechnungstabelle, so wäre es äußerst speicheraufwendig, für jedes Objekt der Klasse eine eigene Kopie der Tabelle bereitzustellen. Daher besteht in C++ die Möglichkeit, ein statisches Klassenelement oder eine statische Klassenfunktion zu definieren, das bzw. die allen Objekten der Klasse gehört! Nach der ISO Norm können nicht-konstante statische Datenelemente zwar innerhalb der Klasse initialisiert werden, sie müssen aber außerhalb der Klasse deklariert werden. class Objekt { static int anzahl = 0; // z.B. Zaehlen von Objekten ........... }; int Objekt::anzahl; // Deklaration ausserhalb
Der Zugriff erfolgt über den Scope-Operator oder über ein Objekt der Klasse. Jedoch enthält das statische Klassenelement keinen *this-Zeiger auf dieses Objekt, wie es bei Datenelementen der Fall ist (vgl. Abschnitt 8.15). Statische Klassenfunktionen dürfen nur auf statische Elemente, jedoch nicht auf Datenelemente zugreifen. Umgekehrt dürfen jedoch Elementfunktionen auch statische Objekte verwenden. Statische Klassenfunktionen dürfen inline definiert werden, wie in der folgenden Klasse Punkt gezeigt wird: class Punkt { double x,y; // Koordinaten public: Punkt(double X=0,double Y=0):x(X),y(Y){} static double abstand(Punkt A,Punkt B) { return sqrt((A.x-B.x)*(A.x-B.x)+(A.y-B.y)*(A.y-B.y));}
164
8 Klassen
}; Punkt A(3,5); Punkt B(6,1); cout << Punkt::abstand(A,B) << endl;
Der Aufruf einer Klassenfunktion erfolgt über den Klassennamen mittels Scope-Operator. Möglich ist auch der Aufruf über ein anderes Objekt der Klasse, wie in: Punkt A(3,5); Punkt B(6,1); Punkt C; cout << C.abstand(A,B) << endl;
Letzteres wird nicht empfohlen. Auf ähnliche Art wird in der Klasse Vektor (des Ñ3) das Skalarprodukt als Klassenfunktion definiert: class Vektor { double x,y,z; public: Vektor(double X=0,double Y=0,double Z=0):x(X),y(Y),z(Z){} static double skalarprodukt(Vektor a,Vektor b) { return a.x*b.x+a.y*b.y+a.z*b.z; } };
Der Aufruf erfolgt auch hier über den Scope-Operator. Vektor a(4,1,-3); Vektor b(3,0,2); cout << Vektor::skalarprodukt(a,b) << endl;
8.12 Überladen von Operatoren In C++ können alle Operatoren überladen werden mit Ausnahme von .
*
::
?:
sizeof
da sie bereits für Operanden von Klassen vorbelegt sind. Die Operatoren *, +, –, &
können sowohl in ihrer unären (einstelligen) als auch ihrer binären Bedeutung überladen werden.
8.12.1 Überladen der arithmetischen Operatoren Beim Überladen ist zu beachten, dass sich die Arität (Stelligkeit) und Assoziativität des Operators nicht ändert. Würde man z.B. den Operator »^« als Potenzoperator überladen, so hätte ein Ausdruck wie x = a^b+c
8.12 Überladen von Operatoren
165
nicht den gewünschten Effekt, da hier der Plus-Operator stärker bindet. Außerdem sollte der Operator das tun, was der Leser eines Programms von ihm erwartet. Es ist nicht sinnvoll, z.B. den +-Operator so zu überladen, dass er eine Multiplikation ausführt. Das Erscheinen desselben Operators für verschiedene Aufgaben ist ein Teil des Polymorphismus von C++. Als Beispiel für das Überladen von Operatoren soll die Klasse Bruch von Abschnitt 8.5 erweitert werden. class Bruch { // wie vorher Bruch operator+(const Bruch &b) {return Bruch(zaehl*b.nenn+nenn*b.zaehl,nenn*b.nenn);} Bruch operator-(const Bruch &b) {return Bruch(zaehl*b.nenn-nenn*b.zaehl,nenn*b.nenn);} Bruch operator*(const Bruch &b) {return Bruch(zaehl*b.zaehl,nenn*b.nenn);} Bruch operator/(const Bruch &b) {return Bruch(zaehl*b.nenn,nenn*b.zaehl);} };
Neu daran ist hier die Anwendung der mathematischen Operatoren auf Brüche. Da die arithmetischen Operatoren nur für einfache Datentypen definiert sind, müssen diese Operatoren zur Bruchrechnung überladen werden. Das Überladen eines Operators erfolgt in Form einer Funktion; d.h., mit Hilfe einer Return-Anweisung wird der gesuchte Operatorwert zurückgegeben. Bruch operator+(const Bruch& b); // Deklaration innerhalb d.Klasse Bruch Bruch::operator+(const Bruch &b) // Definition aussserhalb { return Bruch(zaehl*b.nenn+nenn*b.zaehl,nenn*b.nenn); }
Die Schreibweise a + b
wird vom Compiler interpretiert als a.operator+(b)
Dabei wird der rechte Bruch der Summe als konstante Referenz übergeben, damit er nicht kopiert werden muss. Der linke Bruch ist ja hier implizit gegeben. Analog können die übrigen arithmetischen Operatoren für die Bruchrechnung überladen werden. Die Überladung von Operatoren wird nur von wenigen, mächtigen Programmiersprachen unterstützt (außer C++ auch Ada) und realisiert damit einen Teil des Polymorphismus-Prinzips.
166
8 Klassen
8.12.2 Überladen des Inkrement-Operators Beim Überladen des Inkrement-Operators ++ würde sich hier eine Zweideutigkeit ergeben, da die Stellung (Präfix/Postfix) des Operators nicht erkennbar ist. operator++(object a) // a++ ? oder operator++(object a)// ++a ?
Gemäß der Norm wird daher definiert: operator++() // Präfix-Form operator++(int) // Postfix-Form
Die Postfix-Form wird hier durch ein formales int-Argument unterschieden. Als Beispiel des Inkrement-Operators soll das Weiterzählen von Jahreszeiten mittels Inkrement-Operator realisiert werden. Wir definieren die Inkrement-Operatoren als Elementfunktionen der Klasse Jahreszeit // season.cpp #include using namespace std; class Jahreszeit { public: enum season {fruehling,sommer,herbst,winter}; private: season s; public: Jahreszeit(season j) {s = j;} Jahreszeit operator++() // Praefix { season tmp = s; s = static_cast<season>((s+1) % 4); return tmp; } Jahreszeit operator++(int) // Postfix { s = static_cast<season>((s+1) % 4); return s; } void ausgabe(); }; void Jahreszeit::ausgabe() { switch(s) { case fruehling: cout << "Fruehling" << endl; break; case sommer: cout << "Sommer" << endl; break; case herbst: cout << "Herbst" << endl; break;
8.12 Überladen von Operatoren
167
case winter: cout << "Winter" << endl; } return; }
Zum Testen schreibt man ein kleines Hauptprogramm int main() { Jahreszeit s1(Jahreszeit::fruehling); Jahreszeit s2 = ++s1; // Praefixform s2.ausgabe(); s1.ausgabe(); Jahreszeit s3 = s1++; // Postfixform s3.ausgabe(); s1 = Jahreszeit::winter; for (int i=0; i<4; i++) { s1++; s1.ausgabe(); } return 0; } s1 ist als fruehling vordefiniert. Die Wertzuweisung s2 = ++s1; liefert den alten Wert von s1 und gibt s1 nach der Zuweisung den neuen Wert sommer. Die Anweisung s2 = s1++; setzt zunächst den Wert von s1 auf herbst und übergibt dann diesen Wert an s3. Die Ausgabe bestätigt dies. Fruehling Sommer Herbst
Mit Hilfe des Inkrementoperators können alle Jahreszeiten durchlaufen werden. Die FOR-Schleife ergibt hier Fruehling Sommer Herbst Winter
8.12.3 Benutzerdefinierte Typumwandlung In vielen Fällen ist es sinnvoll, die Typumwandlung nicht dem Compiler zu überlassen, sondern fertige Umwandlungsmethoden entweder in einem Konstruktor oder einer expliziten Umwandlungsfunktion vorzugeben. Als Beispiel soll unsere Bruch-Klasse gewählt werden. Die bisher verwendete Elementfunktion wert() ist ein guter Kandidat für eine solche Umwandlungsfunktion. Da der Wert vom Typ double ist, liegt folgende Definition nahe: operator double() const {return double(zaehl)/nenn;}
Damit kann eine Wertzuweisung lesbarer geschrieben werden: double a; Vektor b(22,7);
168
8 Klassen
a = double(b); // Absicht klar! a = b.wert(); // Typ von wert()?
8.13 Überladen von Operatoren 2 In diesem Abschnitt wird das Überladen der Operatoren =, (), [] dargestellt. Das Überladen als Friend-Funktionen erfolgt im Abschnitt 8.14, von += und anderen im Abschnitt 8.15.
8.13.1 Überladen des Operators [] Betrachtet wird folgende Klasse Array vom Typ int. class Array { public: Array(int Size = 15); Array(const Array& A); // nicht implementiert ~Array() { delete [] a; } Array& operator=(const Array& A); // nicht impl. int& operator[](int); int getSize() const { return newSize; } private: int *a; int newSize; };
Der Konstruktor erzeugt ein dynamisches Array und initialisiert es auf Null. Array::Array(int Size):newSize(Size) // Konstruktor { a = new int[Size]; for (int i=0; i<Size; i++) a[i] = 0; }
Das eckige Klammerpaar [] wird als Index-Operator überladen. Damit der Wert auch im Hauptprogramm sichtbar wird, wird üblicherweise eine Referenz zurückgegeben. int& Array::operator[](int index) { assert(index >=0 && index < getSize()); return a[index]; }
Zum Testen schreibt man ein kleines Hauptprogramm: int main() { Array a(10); for (int i=0; i<10; i++) a[i] = i; // Operator []
Die Ausgabe zeigt die Belegung der ersten zehn Werte und die Änderung von a[5] und a[7]. 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 9 6 3 8 9
Dieses Beispiel wird in Kapitel 10 (Ausnahmebehandlung) weitergeführt; da hier der Zugriff auf einen nicht existierenden Index einen Ausnahmefall darstellt.
8.13.2 Überladen des Operators () Wir betrachten noch ein Mal die Klasse Polynom aus Abschnitt 8.9. class Polynom { enum {maxDeg = 26}; // max.Polynomgrad int deg; // Polynomgrad <=25 double pol[maxDeg]; // Koeffizienten public: Polynom(double c=0) //Defaultkonstruktor { deg = 0; pol[0] = c; } Polynom(int n,double koeff[]); double horner(double x) const; // Hornerschema };
Zur Polynomwertberechnung wurde hier das Horner-Schema als separate Methode eingesetzt. Viel schöner wäre es, wenn man statt des Aufrufs horner() den Funktionswert mittels Operator () aufrufen könnte. double a[] = {4,-2,-5,0,1}; // Polynom x^4-5*x^2-2*x+4 int n = sizeof(a)/sizeof(double); Polynom p(n,a); // Abschnitt 8.9 double y = p.horner(3); double y = p(3); // besser lesbar
Dazu wird der Operator () überladen. Der Prototyp in der Klasse lautet: double operator()(double x) const;
Die Definition (hier außerhalb der Klasse) vollzieht den Algorithmus des HornerSchemas: double Polynom::operator()(double x) const { double f = pol[deg+1]; for (int i=deg; i>=0; i--) f = f*x+pol[i];
170
8 Klassen
return f; }
8.13.3 Explizites Überladen des Operators = Mit Hilfe des Operator-Überladens kann nun auch das im Abschnitt 8.6 angesprochene Problem durch explizite Wertzuweisung mittels des =-Operators gelöst werden. Betrachtet wird die Klasse TString: class TString { char* str; int length; // Laenge public: TString() { length=0; str = 0; cout << "Default called\n"; } TString(int len) { length = len; str = new char[length+1]; cout << "constructor called" << endl; } ~TString() {cout << "delete called" << endl; delete [] str;} TString(TString&); TString& operator=(const TString&); // Zuweisung };
Der Copy-Konstruktor muss explizit definiert werden: TString::TString(TString& s) // Copy-Konstruktor { length = s.length; str = new char[length+1]; memcpy(str,s.str,length+1); cout << "copy constructor called" << endl; }
Hier ist der selbst definierte Zuweisungsoperator: TString& TString::operator=(const TString& s) { if (this == &s) return *this; // Vermeiden von s=s delete [] str; str = 0; length = s.length; if (s.str!=0) { str = new char[length+1]; memcpy(str,s.str,length+1); } return *this; }
8.14 Friend-Funktionen
171
Das Hauptprogramm int main () { TString S1; TString S2(10); TString S3=S2; return 0; }
zeigt die gleiche Anzahl von Aufrufen der Konstruktoren bzw. Destruktoren. Default called constructor called copy constructor called delete called delete called delete called
8.14 Friend-Funktionen Wie im Abschnitt über statische Funktionen bemerkt, ist die Operatordarstellung unsymmetrisch, da der linke Operand für Elementfunktionen implizit vorgegeben ist, wie man am folgenden Beispiel der Bruchaddition sieht: Bruch Bruch::operator+(const Bruch &b) { return Bruch(zaehl*b.nenn+nenn*b.zaehl,nenn*b.nenn); }
Folgende Schreibweise würde mehr der mathematischen Auffassung entsprechen: Bruch Bruch::operator+(const Bruch &a, const Bruch &b) // nicht moeglich
Diese Darstellung ist für eine Elementfunktion nicht möglich, da sie einen (impliziten) Zeiger auf das aufrufende Objekt hat (siehe Abschnitt 8.15). Somit erklärt man die oben gegebene Form als befreundete Funktion (Friend-Funktion) der Klasse. Dieser Mechanismus ermöglicht den Zugriff auf die internen, privaten Daten der Klasse (unter Umgehung des Prinzips der Datenkapselung). Die obige Deklaration des überladenen +-Operators erhält nun die gewünschte Form: friend Bruch operator+(Bruch a,Bruch b); // Deklaration Bruch operator+(Bruch a,Bruch b) // Definition { return Bruch(a.zaehl*b.nenn+a.nenn*b.zaehl,a.nenn*b.nenn); }
Auch in der Punkt-Klasse kann bei Definition einer Friend-Funktion die symmetrische Schreibweise angewandt werden: double abstand(Punkt A,Punkt B) {..}
Das bereits bekannte Programmbeispiel dazu ist:
172
8 Klassen
class Punkt { double x,y; public: Punkt(double X=0,double Y=0):x(X),y(Y) {} friend double abstand(Punkt A,Punkt B) { return sqrt((A.x-B.x)*(A.x-B.x)+(A.y-B.y)*(A.y-B.y)); } }; int main() { Punkt A(3,5); Punkt B(6,1); cout << abstand(A,B) << endl; return 0; }
Der Aufruf der Friend-Funktion darf also weder über ein Objekt, noch über den ScopeOperator – wie bei statischen Funktionen – erfolgen: Punkt C; cout << C.abstand(A,B); // Fehler, keine Elementfunktion cout << Punkt::abstand(A,B); // Fehler, nicht statisch
Eine wichtige Anwendung von Friend-Funktionen findet man beim Überladen des Ausgabe-Operators »<<«. Der Operator ist ein Element der Klasse und kann daher nicht Elementfunktion der Klasse Bruch sein. Um den Ausgabe-Operator trotzdem innerhalb der Klasse zu schreiben und auf private Daten zugreifen zu können, erklärt man den Operator zum friend. class Bruch { // wie frueher friend ostream& operator<<(ostream &,Bruch&); }; ostream& operator<<(ostream& s,Bruch& b) { s << b.zaehl << "/" << b.nenn; return s; }
Prinzipiell könnte man auf einen friend-Operator verzichten, wenn für alle auszugebenden Datenelemente öffentliche Zugriffsfunktionen definiert sind: public: int Nenner() { return nenn; } int Zaehler() { return zaehl; }
Dann könnte die Ausgabe ohne friend als globale Funktion erfolgen mittels
8.14 Friend-Funktionen
173
ostream& operator<<(ostream& s,Bruch& b) { s << b.Zaehler() << "/" << b.Nenner(); return s; }
Damit die Ausgabe mittels »<<« verkettet werden kann, muss der Operator eine Referenz auf ostream zurückgeben. Friend-Funktionen können inline sein, sind aber dennoch keine Element-Funktionen! Analog zur obigen Definition lassen sich Friend-Funktionen für alle Operatoren definieren. Als Beispiel sei die skalare Multiplikation eines Vektors mit einer reellen Zahl aufgeführt. class Vektor // R^3 { double x,y,z; public: Vektor(double X=0,double Y=0,double Z=0):x(X),y(Y),z(Z){} friend Vektor operator*(double lambda,Vektor& a) { return Vektor(lambda*a.x,lambda*a.y,lambda*a.z); } };
Sind nun ein Vektor und ein Skalar gegeben in der Form: Vektor v,u(1,2,3); double lambda = 2;
so wird die Zuweisung v = lambda* u; // ergibt v=(2,4,6)
korrekt über den Friend-Operator * ausgeführt. Hier kann der Operator keine Elementfunktion sein, da der links stehende Skalar kein Vektorobjekt ist! Das abschließende (nicht mathematische) Beispiel demonstriert, wie eine befreundete Funktion freund() den Wert eines privaten Datenelements der Klasse A ändern kann: // friend.cpp #include class A { int a; public: A(int a1) { a = a1;} void getA() { cout << a << endl; } friend void freund(A *a); }; void freund(A *b) // Friend { ++(*b).a; return;
174
8 Klassen
} int main() { A a2(4711); cout << "alter Wert von a = ";a2.getA(); freund(&a2); cout << "neuer Wert von a = ";a2.getA(); return 0; }
8.15 Der this-Zeiger Beim Zugriff einer Elementfunktion auf Datenelemente wird beim Aufruf implizit ein Zeiger auf dieses Objekt als zusätzliches aktuelles Argument übergeben. Mit Hilfe dieses this-Zeigers kann z.B. ein Datenelement einer Elementfunktion angesprochen werden, wenn es in einem Block durch eine lokale Variable gleichen Namens überdeckt wird. class X { int i; public: X(int I) { i = I;} void f(); void ausgabe() {cout << i << endl; } }; void X::f() { int i=7; // lokale Variable this->i = 10; // aendert Element i von X ausgabe(); }
Eine wichtige Anwendung findet der this-Zeiger beim Überladen von Operatoren der Art +=, wo man den Zeiger this zurückgibt und die Erzeugung eines lokalen Objekts erspart. In der schon mehrfach behandelten Klasse Vektor soll der Operator += für Vektoren überladen werden. class Vektor { double x,y,z; public: Vektor(double X,double Y,double Z):x(X),y(Y),z(Z){}
8.16 Klasse mit Funktionszeiger
175
Vektor operator+=(Vektor&); };
Die Definition des Operators += lautet damit: Vektor Vektor::operator+=(Vektor& b) { x += b.x; y += b.y; z += b.z; return *this; }
Ein Zahlenbeispiel ist: Vektor v(1,-2,0); Vektor u(2,3,4); u += v; // u=(3,1,4)
Analog lässt sich dieser Operator auch für komplexe Zahlen überladen. Zuerst eine Version, die den Wert einer lokalen Variable benützt: complex complex::operator+=(complex& b) { complex a; // lokales Objekt a.re = re + b.re; a.im = im + b.im; return a; // *this unverändert }
Ohne lokales Objekt lässt sich dies kürzer schreiben als: complex operator+=(complex& b) { re += b.re; im += b.im; return *this; // Seiteneffekt für *this }
Auch die Addition mit einer reellen Zahl b lässt sich überladen mittels: Complex operator+(double b) { re += b; return *this; }
8.16 Klasse mit Funktionszeiger Als Beispiel einer Klasse behandeln wir eine Klasse zum Intervallhalbierungsverfahren oder Bisektionsverfahren. Diese Klasse enthält einen Zeiger auf die stetige – im Gegensatz zum Newton-Verfahren nicht notwendig differenzierbare – Funktion, deren Nullstelle mittels fortgesetzter Intervallhalbierung berechnet werden soll.
Die Elementfunktion bisect() liefert die gewünschte Nullstelle durch wiederholte Intervallhalbierung. double Bisektion::bisect(double k,double r,int n) // Im Fehlerfall wird Null zurückgegeben { double m=0.; int iterat=0; do { if (fkt(a,k,r,n)*fkt(b,k,r,n)>0.) break; // Kein Vorz.wechsel m = (a+b)/2.; // Intervallmitte if (fkt(a,k,r,n)*fkt(m,k,r,n)<=0.) b = m; else a= m; }while (++iterat<=30 && fabs(a-b)>fabs(a)*FLT_EPSILON); return (iterat>=30)? 0:m; }
Mit Hilfe des Bisektionsverfahrens soll der Effektivzins eines Kredits berechnet werden. Wird ein Kredit K monatlich nachschüssig in n Monatsraten zu je R DM bezahlt, so ergibt sich der (mathematische) Monats-Effektivzins p aus der Ratengleichung (endliche geometrische Reihe):
Kqn = R
qn − 1 p ; q = 1 + 100 q −1
Der effektive Jahreszinssatz ist dann gegeben durch:
peff = 100(q12 − 1) Die Ratengleichung, nach p aufgelöst, wird definiert als: double f(double q,double K,double R, int n) // Ratengleichung { return R*(1.-1./pow(q,double(n)))-K*(q-1.); }
Für den Kredit bildet man eine eigene Klasse.
8.17 Klasse mit Invarianten
177
class Kredit { double kredit; // Kredit in DM double rate; // Monatliche Rate (nachschuessig) int n; // Anzahl der Monatsraten public: Kredit(double K,double R,int N){ kredit=K; rate = R; n = N; } double effzins() const; }; double Kredit::effzins() const { Bisektion R(1.001,1.999,f);// Zins zwischen 0.1% und 99.9% double p = R.bisect(kredit,rate,n); // Monatszinsfuss return pow(p,12.)-1.; }
Im Hauptprogramm wird eine Instanz der Klasse Kredit erzeugt. Der Aufruf der Elementfunktion effzins() generiert eine Instanz der Klasse Bisektion, die die zugehörige Ratengleichung löst. Wendet eine Elementfunktion einer Klasse eine solche einer anderen Klasse an, so spricht man hier von einer Using-Relation (vgl. Abschnitt 13.4). int main() { Kredit darlehen(10000,480,24); double peff = 100*darlehen.effzins(); // Umrechnen in % cout << "Effektivzins = " << setprecision(4) << peff << " %" << endl; return 0; }
Für die angegebenen Werte Kredit DM 10000 Monatsrate DM 480 Anzahl der Raten 24
liefert das Programm den Effektivzins 14,89 %. Hinweis: Die Banken rechnen hier mit einer anderen Formel, bei der noch Agio, Anzahlungen usw. berücksichtigt werden.
8.17 Klasse mit Invarianten Wie in Abschnitt 9.1 erläutert wird, stellt die Verifikation von Algorithmen mittels Invarianten ein wesentliches Hilfsmittel zur Sicherung der Robustheit von Software dar. Dieses Prinzip lässt sich ebenfalls auf Klassen übertragen, um die Korrektheit der verwendeten Parameter sicherzustellen. Beispiel einer Datumsklasse des Gregorianischen Kalenders mit Invariantenprüfung: class Datum // Gregorianisches Datum {
178
8 Klassen
protected: int tag; int mon; int jhr; public: Datum(int T,int M,int J){ tag = T; mon = M; jhr = J; } void datumtest() const; }; void Datum::datumtest() const { assert(tag >= 1 && tag <= 31); // Invarianten assert(mon >= 1 && mon <= 12); assert(jhr >= 1582); }
Durch eine Fallunterscheidung für den Monat bzw. eine Prüfung auf Schaltjahr könnte man den Datumstest noch verbessern. Weitere Beispiele zur Invarianten-Prüfung finden sich im Buch im Kapitel 9 (Software-Engineering) und 18 (Statistik).
8.18 Friend-Klassen Die Freundschaft (friendship) ist eine Relation zwischen Klassen; sie kann von einer Klasse an eine andere oder an deren Funktionen verliehen werden und ist nicht symmetrisch. Freundschaft erlaubt eine gezielte Verteilung der Zugriffsrechte an eine spezielle Klasse. Ansonsten müssten alle Datenelemente, auf die zugegriffen wird, public und damit für alle Klasse zugänglich sein. Friend-Klassen besitzen also die Möglichkeit, auf private Datenelemente einer Klasse zuzugreifen. Dies ist im Sinne der Datenkapselung eigentlich unerwünscht. Bei mathematischen Datentypen, die eng miteinander zusammenhängen, wie: 왘 Vektoren-Matrizen 왘 Punkte-Vektoren 왘 Knoten-Kanten in einem Graphen 왘 Geraden-Ebenen
ist es manchmal wünschenswert, dass eine Klasse direkt auf die Datenelemente der anderen zugreifen kann, ohne dass eine Elementfunktion für den Export oder eine Vererbung vorliegt. Die Relationseigenschaften der Friend-Klassen sind: 왘 nicht transitiv 왘 nicht symmetrisch 왘 nicht vererbbar
Andernfalls könnte die Zugriffsberechtigung auf andere Klassen ausgedehnt werden.
8.18 Friend-Klassen
179
8.18.1 Beispiel aus der Analytischen Geometrie Das Programm wird aus Umfangsgründen hier nur teilweise wiedergegeben; es befindet sich vollständig auf der beiliegenden CD-ROM. Die drei grundlegenden Objekte der Analytischen Geometrie (hier im IR3 ) sind Vektoren, Geraden und Ebenen, die jeweils durch eine eigene Klasse repräsentiert werden. Die Klasse Vektor wird implementiert als class Vektor // (Orts-)Vektor im R^3 { private: double x,y,z; public: Vektor() {} Vektor(double a,double b,double c){ x=a; y=b; z=c;} Vektor operator+(Vektor); Vektor operator-(Vektor); friend Vektor operator*(double,Vektor); // skal.Mult. double operator*(Vektor); // Skalarprodukt Vektor vektorprodukt(Vektor); double betrag() {return sqrt(x*x+y*y+z*z); } double skalarprod(Vektor a) { return x*a.x+y*a.y+z*a.z; } bool senkrecht(Vektor); double winkel(Vektor); double determinante(Vektor,Vektor); friend class Gerade; friend class Ebene; };
Zur Vektoraddition und Subtraktion werden die entsprechenden Operatoren überladen. Die verschiedenen Produkte wie Skalarprodukt und Vektorprodukt können nicht alle durch Überladen des Multiplikationsoperators definiert werden. Sie sind teilweise als Elementfunktion, aus Gründen verschiedener Signaturen auch als Friend-Funktion implementiert. Die skalare Multiplikation muss als Friend-Funktion erklärt werden, da hier der erste Parameter keine Datenelement ist. Damit die Klassen Gerade bzw. Ebene auf die (privaten) Vektorkomponenten zugreifen können, werden diese Klassen als befreundet erklärt. Die Klasse Gerade verwendet die Punkt-Richtungsform
x = a +λ r
Dabei ist a der Ortsvektor des Aufpunkts und r der Richtungsvektor. Damit die Klasse Ebene auf die Komponenten einer Geraden zugreifen kann, wird sie ebenfalls als befreundet erklärt. class Gerade // Gerade des R^3 in Punkt-Richtungsform { private:
1 Hier wurde der Aufpunkt a = 1 gewählt; die Eingabe ist daher: 1 Vektor p(3,2,1); // Ortsvektor Ebene E(Vektor(2,-1,3),Vektor(1,1,1)); cout << "Abstand d(P,g) = " << E.abstand(p) << endl;
Das Programm liefert den korrekten Abstand 0.801784 =
3 . 14
8.19 Übungen Übung (8.1): Das folgende Programm liefert nicht bei jedem Compiler die Werte -2 und 2. Erklären Sie dies! #include using namespace std; class X { public: int n,m; X(int N,int M) { n = (N > M) ? N : M; m = (n < m) ? N : M; } X(int N = 0) { n = (N > 0) ? N : 0; m = 0; } }; int main() { X x(2,-2); cout << x.n << ' ' << x.m; return 0; }
Übung (8.2): Das folgende Programm druckt nicht bei jedem Compiler die Werte 1, 2 aus! Erklären Sie dies! #include using namespace std; class X { int u,v; public: X(int w):u(w),v(u+1){}
182
8 Klassen
void output() { cout << u << " " << v << endl; } }; int main() { X x(1); x.output(); return 0; }
Übung (8.3): Erstellen Sie eine Klasse Datum (vgl. Übung (4.6)). Übung (8.4): Erstellen Sie eine Klasse Wuerfel. Die Methode wurf() soll jeweils den nächsten Würfelwurf liefern. Übung (8.5): Erstellen Sie eine Klasse Spielkarte. Überladen sie den Ausgabe-Operator geeignet.
9
Elemente des SoftwareEngineerings
In diesem Kapitel werden einige interessante Aspekte des Software-Engineerings angesprochen, die auch für den C++-Programmierenden von Interesse sind. Für eine vertiefte Darstellung wird auf die Literatur verwiesen.
9.1
Die Verifikation von Algorithmen
Die Theorie der Programmverifikation wurde 1961/62 von J. McCarthy und E. W. Dijkstra angeregt. McCarthy schrieb: Anstatt Computerprogramme durch Testläufe fehlerfrei zu machen, sollte man beweisen, dass sie die gewünschten Eigenschaften haben. Die ersten Arbeiten erschienen 1966/67 von P. Naur und R. Floyd. Naur war es auch, der zusammen mit John Backus eine formale Sprache zur Definition von Programmiersprachen entwickelte. Durch Floyd angeregt, entwickelte dann 1969 C. A. R. Hoare die wichtigsten Semantikregeln. Die Verifikation von Algorithmen kann hier nur verkürzt dargestellt werden. Es wird insbesondere auf die Diskussion über schwächere und stärkere Vorbedingungen verzichtet, ebenso werden keine Seiteneffekte und Quantoren in Betracht gezogen.
9.1.1 Semantik von Zuweisungen Was macht folgender Programmabschnitt? int a,b; // a,b irgendwie belegt a + = b; b = a-b; a -= b;
Durch Einsetzen einiger Zahlenwerte findet man die Vermutung, dass a und b vertauscht werden. Der formale Beweis, dass beliebige Werte a und b vertauscht werden, verläuft wie folgt. Schreibt man zu jeder Anweisung die Vor- und Nachbedingung (als Kommentar) hinzu, so sieht man /*x = a; y = b;*/ a += b; /*x = a+b; y = b;*/ /*x = a+b; y = b;*/ b = a-b; /*x = a+b; y = a;*/ /*x = a+b; y = a;*/ a -= b; /*x = b; y = a;*/
Damit ist – unabhängig von speziellen Werten von a und b – gezeigt, dass die Variablen ihren Wert vertauschen, ohne dass hier eine Hilfsvariable verwendet wird.
184
9 Elemente des Software-Engineerings
Noch ein Beispiel. Was bewirkt folgender Programmausschnitt? int a,b,x,y; // a,b irgendwie belegt x = (a+b+abs(a-b))/2; y = (a+b-abs(a-b))/2;
Wir führen eine Fallunterscheidung durch. 1. Fall: a ≥ b ⇒ ( a + b + a − b )/2 = ( a + b + a − b)/2 = (2a)/2 = a 2. Fall: a < b ⇒ ( a + b + a − b )/2 = ( a + b + b − a)/2 = (2b)/2 = b Wie man sieht, erhält x in jedem Fall den Wert des Maximums max(a,b). Entsprechend lässt sich die Gültigkeit zeigen von y = min(a,b).
9.1.2 Semantik von IF-Anweisungen Die Semantik der IF-Anweisung ist /*P*/ if (B) S1 else S2 /*Q*/
oder das gleichzeitige Gelten von: /*P and B*/ S1 /*Q*/ /*P and not(B)*/ S2 /*Q*/
Wir zeigen, dass die Anweisung: if (x>=0) abs = x; else abs = -x;
stets den Betrag abs = |x| liefert. Die Fallunterscheidung zeigt: 1. Fall: x ≥ 0 ⇒ abs = x ⇒ ( abs > 0) ∧ ( abs = x ) 2. Fall: x < 0 ⇒ abs = −x ⇒ ( abs > 0) ∧ ( abs = x ) In beiden Fällen erhält abs den Absolutbetrag |x| unabhängig davon, welchen Wert x tatsächlich hat. Ein zweites Beispiel: Die Anweisung: if (a >= b) m = a; else m = b;
liefert stets das Maximum von a und b. Die Fallunterscheidung ist hier: 1. Fall: a ≥ b ⇒ m = a ⇒ m = max( a, b) 2. Fall: a < b ⇒ m = b ⇒ m = max( a, b)
9.1 Die Verifikation von Algorithmen
185
In beiden Fällen erhält m den Maximumswert max(a,b) unabhängig davon, welchen Werte a, b haben. Ein drittes Beispiel: Was macht das folgende Programmkonstrukt? if (a>b) { h=a; a=b; b=h; } if (b>c) { h=b; b=c; c=h; } if (a>b) { h=a; a=b; b=h; }
Wir zeigen, dass a ,b, c aufsteigend sortiert werden. Es folgt im Einzelnen: if (a>b) { h=a; a=b; b=h; }; // b = max(a,b) if (b>c) { h=b; b=c; c=h; }; // c = max(b,c) if (a>b) { h=a; a=b; b=h; }; // a = min(a,b)
Die beiden ersten Nachbedingungen liefern: (b = max(a,b)) ∧ (c = max(b,c)) ⇒ c=max(a,b,c) Zusammen mit der dritten Bedingung folgt die Behauptung: ((a = min(a,b) ∧ c=max(a,b,c)) ⇒ a < b < c
9.1.3 Semantik von WHILE-Anweisungen Die Semantik der WHILE-Anweisung ist: /*P*/ while(B) {S} /*P and not B*/
Dies bedeutet, dass bei Vorliegen der Vorbedingung P nach der Ausführung von S die Nachbedingung Q=P and not B gültig ist. (P,Q) bilden die Spezifikation der Schleife. Was macht z.B. folgender Programmabschnitt? // x,y>0 vorbelegt mit natuerlichen Zahlen int r=x,q=0; while(y<=r) {r -= y; q++;} // Ausgabe q,r
Durch Eingabe einiger Werte kommt man zur Vermutung, dass hier eine Division durchgeführt wird mit dem Ergebnis q = x/y; r = x % y.
Aber wie beweist man dies allgemeingültig? Die Theorie der Programmverifikation macht dies mit Hilfe von sog. Zusicherungen und Invarianten. Zu beweisen ist hier die Zusicherung x = q*y +r
Zunächst ist zu zeigen, dass durch die Anfangswahl von q und r diese Bedingung erfüllt ist. Die Gültigkeit folgt hier wegen (r==x) && (q==0)
186
9 Elemente des Software-Engineerings
Sodann ist zu zeigen, dass diese Zusicherung auch innerhalb der WHILE-Anweisung gilt. Eine solche Bedingung, die während der Dauer einer Wiederholungsanweisung gilt, heißt auch Schleifeninvariante. Wegen der Schleifenanweisungen r -= y; q++; ist diese Bedingung tatsächlich invariant. x = (++q)*y + (r-=y) = (q+1)*y+r-y = q*y +y+r-y = q*y+r
Die Schleifeninvariante schließt auch die WHILE-Bedingung ein: (x = q*y+r) && (r<=y).
Nach Termination der WHILE-Anweisung gilt die Nachbedingung (y
Daraus folgt durch ganzzahlige Division mit y bzw. Rechnung mod y: (q = x/y ) && (r = x % y)
Damit ist die formale Begründung erfolgt, dass dieses Programmstück eine Division ausführt. Die Programmverifikation ist ein Schreibtischtest und daher programmiersprachenunabhängig. Die Besonderheit von C++ ist aber, dass die Zusicherungen und Invarianten mit Hilfe des assert()-Makros direkt in das Programm eingebettet werden können! Das ausführlich kommentierte Listing lautet: // assert1.cpp #include #include using namespace std; int main() { int x,y; cout << "Gib 2 natuerliche Zahlen x>y>0 ein! "; cin >> x >> y; // Vorbedingung (x>0) && (y>0) && (x>y) int r=x,q=0; assert(x == q*y+r); // Zusicherung while(y<=r) { r -= y; q++; assert(x == q*y+r); // Invariante (x=q*y+r)&&(y<=r) } assert(x == q*y+r); // Nachbedingung //=> (x = q*y+r) && (0<=ry) && (x>0) && (y>0) //=> (q = x/y) && (r = x % y) cout << "x/y = " << q << endl << "x % y = " << r << endl; return 0; }
9.1 Die Verifikation von Algorithmen
187
Sowohl Zusicherung, Schleifeninvariante und Nachbedingung werden hier vom Programm selbst überwacht! Als zweites Beispiel soll der bekannte Algorithmus der ägyptischen Multiplikation (auch russische Bauern-Multiplikation genannt) verifiziert werden. // Eingabe zweier natuerlicher Zahlen a,b>=0 int x=a,y=b,z=0; while(x>0) { if (x % 2==1) z += y; y *= 2; x /= 2; } // Ausgabe z
Man findet hier die Zusicherung z +x*y = a*b
Die WHILE-Schleife terminiert wieder für x=0. Damit folgt die Behauptung: z = a*b
Das ausführlich kommentierte Programm lautet: // assert2.cpp // Verifikation der Aegyptischen Multiplikation #include #include using namespace std; int main() { int a,b; cout << "Gib 2 natuerliche Zahlen x y ein! "; cin >> a >> b; // Vorbedingung (a>=0) && (b>=0) int x=a,y=b,z=0; assert(z + x*y == a*b); // Zusicherung while(x>0) { if (x % 2==1) z += y; y *= 2; x /= 2; assert(z + x*y == a*b);// Invariante(z+x*y=a*b)&&(x>0) } assert(z + x*y == a*b); // Nachbedingung //=> (z + x*y = a*b) && (x=0) //=> (z = a*b) cout << "Produkt = " << z << endl; return 0; }
188
9 Elemente des Software-Engineerings
9.1.4 Übungen Übung (9.1): Verifizieren Sie, dass das folgende Programmkonstrukt die Differenz der Eingabewerte liefert: int a,b; cout << "Gib 2 ganze Zahlen a>b ein "; cin >> a >> b; while(b>0) { a--; b--; } cout << a << endl;
Übung (9.2): Verifizieren Sie, dass der folgende Programmausschnitt das Produkt der Eingabewerte liefert: int a,b; cout << "Gib 2 ganze Zahlen a b ein "; cin >> a >> b; int p=0; while(a>0) { int q=0; while(q
9.2
Software-Metrik
Ein wichtiges formales Kriterium zur Beurteilung von Programmen stellt die SoftwareMetrik dar. Sie berechnet Maßzahlen für die Komplexität von Software und erlaubt Rückschlüsse auf die Wartbarkeit (englisch maintainability).
9.2.1 Die Metrik von McCabe Die Metrik von Thomas McCabe wurde 1976 in den IEEE Transactions publiziert. Stellt man einen Algorithmus durch ein Kontrollflussdiagramm dar, so kann man dieses Diagramm in der Regel als einen (meist zusammenhängenden) Graphen interpretieren, dessen zyklometrische Zahl definiert ist als C = e -n +2p
9.2 Software-Metrik
189
Dabei ist e bzw. n die Anzahl der Knoten bzw. der Kanten und p die Anzahl der Teile (Komponenten), in die der Graph zerfällt. Am Beispiel des Programmablaufplans (PAP) von Abb. 9.1 erhält man e = 9 Knoten, n = 9 Kanten und p = 1 Komponenten. Die zyklometrische Zahl C ist damit 2. Nach Empfehlungen des IBM-Labors in LaGaude (Frankreich) sollte das McCabe-Maß 15 bei Funktionen nicht überschritten werden. Prozeduren mit dem Maß kleiner 9 werden als überschaubar angesehen. Bei einer wesentlich größeren Maßzahl wird empfohlen, die Funktionen in zwei Teile zu teilen. Hat man keinen Programmablaufplan vorliegen, so kann das McCabe-Maß näherungsweise berechnet werden als die Summe der Anzahl von bedingten und Schleifenanweisungen und der Zahl von booleschen Operatoren, vermehrt um Eins. Für diese Näherung erhält man C = 3 für eine WHILE-Anweisung und einen Vergleich.
Abbildung 9.1: Programmablaufplan (PAP) zur Ermittlung des ggT
9.2.2 Die Metrik von Halstead Einen anderen Weg geht die Metrik von Maurice Halstead aus dem Jahre 1977. Sie misst die Komplexität eines Programms anhand der Anzahl von Operanden und Operatoren. Unter Operanden wird hier die Anzahl der Konstanten (auch Literalkonstanten) und Variablen verstanden, alles andere wird als Operator gezählt, z.B. Anweisungen, Boolesche Vergleiche, Sprungmarken und auch das Trennen von Anweisungen mittels »;«. Nicht gezählt werden dagegen Deklarationen.
190
9 Elemente des Software-Engineerings
Als Basisgrößen wählt man: 왘 n1 die Anzahl der verschiedenen Operatoren 왘 n2 die Anzahl der verschiedenen Operanden: 왘 N1 die Anzahl aller Operatoren 왘 N2 die Anzahl aller Operanden
Nach Halstead definiert man folgende Maßzahlen: 왘 Halstead-Länge H = N = N1 + N2 왘 Vokabular n = n1+ n2 (vocabulary) 왘 Umfang V = Nlog 2(n) (volume) 왘 Schwierigkeit D = (n/2)(N2/n2) (difficulty) 왘 Verständnisaufwand E = DV (effort)
Betrachtet wird das Beispiel der ggT()-Funktion (aus Abschnitt 6.3): int ggT(int a,int b) { int r; r = b; while(r>0) { r = a % b; a = b; b = r; // Invariante ggT(a,b) = ggT(b,a % b) } return a; }
Zum Abzählen der Operationen bildet man die folgende Tabelle: Anweisung
und somit die Halstead-Metrik: 왘 N = N1 + N2 = 27 왘 n = n1 + n2 = 12 왘 V = Nlog2(n) = 96.8 왘 D =(n/2)(N2/n2) = 6*3 = 18 왘 E = 1733.4
Manche Autoren schätzen die Halstead-Länge ab durch die Gleichung N = n1log2(n1)+n2log2(n2) = 8*3+4*2 = 32 Hier erhält man den Näherungswert 32 für den exakten Wert 27.
9.2.3 Der Wartbarkeitsindex Das renommierte Software Engineering Institut (SEI) der Carnegie Mellon-University hat aus den Maßzahlen von McCabe und Halstead einen Wartbarkeitsindex MI (maintainability index) erstellt. Er berücksichtigt noch die Parameter: 왘 LOC = Anzahl der Programmzeilen (lines of code) 왘 CM = Anteil von Kommentarzeilen am Quellcode (comment)
Kennzeichnet man Mittelwerte durch spitze Klammern (<>), so gilt für den Wartbarkeitsindex des SEI MI = 171 – 5.2log2 -0.23-16.2log2-50sin( 2.4 < CM > ) Diese etwas merkwürdig aussehende Formel ist entstanden durch statistische und empirische Bewertung von Standard-Software, ausgeführt von der Fa. Hewlett Packard. Wählt man die Maßzahlen der ggT()-Funktion als Mittelwert, so erhält man mit 왘 = 27 왘 = 2 왘 = 13 왘 = 0.07
den Wartbarkeitsindex MI = 171-5.2*log2(27)-0.23*2-16.2*log2(13)-50*sin(0.59) = 65 was nur ein mittelmäßiger Wert ist (empfohlen MI > 80).
192
9.3
9 Elemente des Software-Engineerings
Stichpunkte der UML UNIFIED MODELING LANGUAGE
Abbildung 9.2: Das Logo der UML
Die Unified Modeling Language (UML) ist ein von dem internationalen Gremium OMG (Object Management Group) standardisiertes System, das die besten Analyse- und Entwurfsmethoden der Software-Entwicklung zusammenfasst. Die UML ist natürlich zu komplex, um in einem Abschnitt eines Buchs abgehandelt zu werden. Deswegen wird die Darstellung auf einige wesentliche Punkte beschränkt; mit Hilfe der Literatur kann sich der Leser oder die Leserin in das Thema vertiefen.
9.3.1 Was ist die UML? Die UML ist die Standardsprache mit überwiegend grafischer Notation zur Visualisierung, Spezifikation, Konstruktion und Dokumentation von Software-Modellierung. Sie bietet: 왘 Vereinheitlichung von Informationssystemen und der Methodik des Software-
Engineerings 왘 Möglichkeit zur Spezifikation, Visualisierung und Dokumentation beliebiger Soft-
ware-Systeme 왘 Anwendung des objektorientierten Paradigmas 왘 fortgeschrittene Konzepte wie Frameworks und Patterns
Sie kann bei allen Stationen des Software-Entwicklungszyklus eingesetzt werden und ist unabhängig von speziellen Programmiersprachen. Die wichtigsten Ziele der UML sind: 왘 den Anwendern der UML einsatzbereite und wirkungsvolle Hilfsmittel an die
Hand zu geben, damit neue Modelle entwickelt und bestehende verbessert werden können 왘 Mechanismen zu erkennen, die es erlauben, bestehende Konzepte der Software-
Entwicklung zu erweitern 왘 Unabhängigkeit von Entwicklungsumgebungen und Programmiersprachen zu
erlangen 왘 Schaffung einer formalen Basis zum Verständnis einer Modellierungssprache 왘 Entwicklung neuer Werkzeuge für den wachsenden Markt von objektorientierten
Entwicklungs-Tools
9.3 Stichpunkte der UML
193
Die UML enthält auch einige neue Konzepte, wie: 왘 Mechanismen für Erweiterbarkeit (Stereotypen usw.) 왘 Threads und Prozesse 왘 Distributive und parallele Systeme 왘 Muster und Kollaborationsdiagramme 왘 Aktivitätsdiagramme 왘 Interfaces und Komponenten
9.3.2 Entwicklung der UML Viele der Ideen, die in die UML eingegangen sind, stammen aus ganz unterschiedlichen Quellen und aus einer Zeit, als die Objektorientierung noch kein Schlagwort war. Diese Ideen verbinden sich nun zu einem zusammenhängenden Ganzen. Man schätzt, dass es zwischen 1989 und 1994 etwa 50 verschiedene Ansätze zur objektorientierten Modellierung (OOM) gab. Diese Methoden der ersten Generation waren nicht standardisiert und führten zu einem »Krieg der Methoden«. 1994 schloss sich Jim Rumbaugh der Firma Rational Software Corp. an. Gemeinsam mit G. Booch arbeitete Rumbaugh im Oktober 1995 am Entwurf der UML-Version 0.8. Im Herbst 1995 stieg auch Ivar Jacobson bei Rational ein. Die Ansätze der drei Amigos: 왘 Arbeiten von Grady Booch 왘 OMT (Object Modelling Technique) von Jim Rumbaugh 왘 OOSE ( Object Orientated Software Engineering) von Ivar Jacobson.
wurden schließlich vereinheitlicht und führten 1997 zur UML-Version 1.0, die im Zuge der Weiterentwicklung 1999 bis zur Version 1.3 gelangte. Dieser einheitlicher Entwurf zur UML erlaubt nun eine weitgehende objektorientierte Modellierung von Software – ohne dass auch nur eine Zeile Quellcode geschrieben wird.
9.3.3 Was ist die OMG? Die 1989 gegründete Object Management Group (OMG) ist eine internationale Organisation von über 800 Mitgliedern, die die wichtigsten Software- und Hardware-Firmen vertreten. Mitgliedsfirmen sind u.a. Digital Equipment., Hewlett Packard, i-Logix, IntelliCorp, IBM, MCI Systemhouse, Microsoft, Oracle, Rational Software, Texas Instruments und UniSys. Hauptziel der OMG ist die Förderung der Wiederverwendbarkeit, Portabilität und Austauschbarkeit von Software auf verteilten, heterogenen Plattformen und Betriebssystemen.
9.3.4 Das objektorientierte Paradigma Alle Dinge sind entweder materiell oder geistig; erstere existieren auch, ohne dass sie von jemandem wahrgenommen werden. Materielle und geistige Dinge haben folgende gemeinsame Eigenschaften (vgl. Abbildung 9.3):
194
9 Elemente des Software-Engineerings
왘 Strukturelle Eigenschaften bestimmen alle möglichen Zustände, die die Dinge wäh-
rend ihrer Existenz durchlaufen. 왘 Verhaltensmuster bestimmen alle Aktivitäten der Dinge – die von der Umwelt aus-
gelösten Aktionen und Reaktionen. Die Struktur und das Verhalten werden durch die Attribute und Operationen von Objekten dargestellt. Neben den Objekten gibt es noch Links und Szenarios. Links stellen die Verknüpfungen dar, die alle in einer bestimmten Relation stehenden Dinge verbinden. Sie sind jedoch nicht selbst Teil der Dinge. Links sind Instanzen von Assoziationen (allgemeine Relationen zwischen Objekten). Alle Relationen zwischen Objekten sind entweder Assoziationen, Aggregate oder Kompositionen. Die Assoziationen werden in Kapitel 13 noch ausführlicher behandelt. Szenarios stehen stellvertretend für alle Interaktionen mittels Botschaften oder Kommunikation.
Abbildung 9.3: Das objektorientierte Paradigma der UML
9.3.5 Wichtige Diagramme Die wichtigste Aufgabe der UML ist die grafische Darstellung aller möglichen Modellierungskonzepte. Zum gegenwärtigen Zeitpunkt gibt es sieben verschiedene Diagrammtypen, die jeweils eine spezielle Modellierung ermöglichen: 왘 Klassen-/Objektdiagramme (nach Booch) 왘 Interaktionsdiagramme (darunter Sequenz-, Kollaborationsdiagramme) 왘 Paketdiagramme (packet)
9.3 Stichpunkte der UML
195
왘 Zustandsdiagramme (statechart nach David Harel) 왘 Aktivitätsdiagramme (activity) 왘 Verteilungsdiagramme (deployment) 왘 Anwendungsdiagramme (use case nach Rumbaugh)
Zur Erstellung dieser UML-Diagramme kann eine spezielle Software verwendet werden. Abbildung 9.4 zeigt die Benutzeroberfläche der Software »Together Control Center 4.0« der Firma TogetherSoft, die es unter anderem erlaubt, aus einem C++/JavaQuellcode automatisch ein Klassendiagramm zu erzeugen.
Abbildung 9.4: Benutzeroberfläche der Software »Together Control Center 4.0«
Es ist nicht möglich, alle der oben genannten Diagramme in diesem Rahmen darzustellen. Wir beschränken uns auf die drei am häufigsten gebrauchten Diagramme, alle weiteren finden sich in der angegebenen Literatur.
9.3.6 Das Klassendiagramm Für Klassendiagramme gibt es drei grundlegende Sichtweisen: 왘 konzeptionell 왘 spezifizierend 왘 implementierend
196
9 Elemente des Software-Engineerings
Abbildung 9.5: Das Klassendiagramm (aus »UML konzentriert«[14])
Das Klassendiagramm beschreibt alle Typen von Objekten und ihre statischen Beziehungen. Abbildung 9.5 zeigt das Zusammenspiel der Klassen Privatkunde, Firmenkunde, Auftrag und Auftragsposition. Die statischen Beziehungen sind: 왘 Assoziationen werden durch beschriftete Linien dargestellt; hier: Kunde erteilt einen
Auftrag.
9.3 Stichpunkte der UML
197
왘 Abgeleitete Objekte werden durch Vererbung, d.h. durch eine nicht gefüllte Pfeil-
spitze, gekennzeichnet. Hier ist ein Privatkunde ein Ableitungsobjekt von Kunde. 왘 Spezielle Assoziationen sind die Kardinalitäten; sie geben an, wie viele Objekte
beteiligt sind. Die Bezeichnungsweise von Kardinalitäten ist 왘 1 (genau ein Objekt) 왘 * (viele; d.h. Null oder mehr) 왘 0..1 (höchstens eins) 왘 m..n (mindestens m, höchstens n)
Zu jedem Auftrag gehört genau ein Kunde (Kardinalität 1); umgekehrt kann ein Kunde keinen oder mindestens einen Auftrag erteilen (Kardinalität *) (vgl. Abbildung 9.5).
9.3.7 Das Aktivitätsdiagramm Das Aktivitätsdiagramm ist insbesondere nützlich zur Beschreibung von Arbeitsvorgängen, die teilweise auch parallel verlaufen können. Es basiert auf Vorarbeiten von Jim Odell (zustandsbasierte Modelltechnik (SDL)); die Symbolik orientiert sich etwas an den Petri-Netzen. Ein Aktivitätsdiagramm kann die folgenden geometrischen Symbole enthalten: 왘 Ein gefüllter Kreis bedeutet den Eingang. 왘 Zwei konzentrische Kreise, davon der innere gefüllt, bedeuten den Ausgang. 왘 Eine Raute stellt einen Entscheidungsvorgang dar. Der waagrechte Ausgang wird
bei wahrer Bedingung durchlaufen, sonst der senkrechte Ausgang. 왘 Ein dicker waagrechter Balken bedeutet Synchronisation; d.h., alle Aktivitäten, die
den Balken treffen, starten wieder synchron. Wird eine Aktivität in mehrere Teilaktivitäten zerlegt, so dienen die Ein- und Ausgangspunkte als Schnittstelle; d.h., sie können an diesen Punkten aneinander gereiht werden. Werden andauernde (z.B. rund um die Uhr laufende) Aktivitäten beschrieben, so muss das Aktivitätsdiagramm kein Ausgangsymbol haben. Beim obigen Diagramm ist nicht ersichtlich, welches Objekt für eine bestimmte Aktivität zuständig ist. Deswegen hat man die Aktivitätsdiagramme durch Einbeziehung von Verantwortlichkeitsgrenzen (in UML swimlanes genannt) erweitert. Alle Aktivitäten werden durch senkrechte Striche in Zonen eingeteilt, die die Verantwortlichkeitsgrenzen der beteiligten Objekte angeben.
198
9 Elemente des Software-Engineerings
Abbildung 9.6: Das Aktivitätsdiagramm (aus »UML konzentriert"[14])
9.3.8 Das Anwendungsfalldiagramm Das Anwendungsfalldiagramm geht auf die use case-Diagramme von Jacobson zurück, die nun Bestandteil der UML geworden sind. Zentraler Teil eines Anwendungsfalldiagramms sind die Akteure (englisch actors). Abbildung 9.7 stellt einen Finanzhandelsplatz mit den Akteuren Händler, Verkäufer, Buchhalter und Manager dar. Akteure müssen aber nicht unbedingt Menschen sein; es kann auch ein Buchhaltungssystem sein, das jede Nacht die Konten aktualisiert. Da kein System nach außen abgeschlossen ist, ist es nicht immer einfach zu entscheiden, welche externen Akteure in das Diagramm aufgenommen werden sollen. Die Anzahl der aufgenommenen Akteure ist entscheidend für den Programmieraufwand. Jacobson gibt als Faustregel vor, dass 20 Akteure etwa ein 10 Mannjahr-Projekt darstellen.
9.4 Kriterien für Programmiersprachen
199
Abbildung 9.7: Das Anwendungsfalldiagramm (aus »UML konzentriert«[14])
9.4
Kriterien für Programmiersprachen
Language design is compiler construction. (N. Wirth) In diesem Abschnitt werden stichwortartig wichtige Kriterien für Programmiersprachen zusammengestellt und kurz kommentiert. (1) Effizienz (efficiency) 왘 Effizienz beim Übersetzen: Das in der Programmiersprache geschriebene Pro-
gramm soll möglichst wirkungsvoll (effizient) in Maschinensprache übersetzt werden können. Effizienz kann gemessen werden mittels Speicherbedarf oder Laufzeit. 왘 Effizienz beim Programmieren: Der Programmierer soll die Gewissheit haben, dass
er korrekte, präzise und schnelle Programmabläufe (nach dem Übersetzen) erhält. Es sollte möglich sein, komplexe Daten und Abläufe effizient zu formulieren. 왘 Möglichkeit der Code-Optimierung
(2) Allgemeinheit (generality) 왘 Eine Programmiersprache sollte möglichst viele Programmierfälle und Datentypen
unterstützen (das Gegenteil ist Einfachheit). 왘 Gleichartige Operatoren und Konstrukte sollten bei verschiedenen Datentypen
gleichartige Wirkungen hervorrufen.
200
9 Elemente des Software-Engineerings
왘 Umgekehrt dürfen auch verschiedenartige Programmanweisungen nicht gleichar-
tig aussehen. (3) Orthogonalität (orthogonality) 왘 Orthogonalität bedeutet, dass die Kombinationen aller in der Programmiersprache
zugelassenen Konstrukte möglich und sinnvoll ist. 왘 Keine unerwarteten Einschränkungen oder unerwartetes Verhalten (Gegenbeispiel:
Parameterübergabe in C/C++ von Reihungen) (4) Uniformität (uniformity) 왘 Gleichartige Operationen und Konstrukte werden gleichartig implementiert. 왘 Gleichartige Wertzuweisungen für alle Datentypen, nicht nur für einfache 왘 Funktionen liefern alle möglichen Datentypen. 왘 Kompatibilität zwischen Zeichen und Zeichenketten
(5) Einfachheit (simplicity) Everything should be made simple as possible but not simpler. (A. Einstein) 왘 einfach zu lernen 왘 einfach zu schreiben 왘 einfach zu verstehen (es muss möglich sein, selbst erklärende Programme zu
schreiben und durch Kommentare zu dokumentieren) 왘 einfach zu warten 왘 Unterstützung der Modularität 왘 wenige Programmkonstrukte und Schlüsselwörter
Die Anzahl der Schlüsselwörter verschiedener Programmiersprachen zeigt die folgende Tabelle: C
32
PASCAL
35
MODULA-2
40
C++
63
ADA
83
(6) Maschinenunabhängigkeit (machine independence) 왘 Eine Programmiersprache sollte auf mehreren Plattformen verfügbar sein. 왘 Eine Programmiersprache soll plattformunabhängig sein; d.h. die Übertragbarkeit
eines Quellcodes auf ein anderes Betriebssystem bzw. auf einen anderen Rechner sollte gewährleistet sein. 왘 Deklaration geeigneter Konstanten (z.B. größte Ganzzahl, kleinste Maschinenzahl) 왘 Anpassungsmöglichkeit an andere Rechnerarchitekturen (Speicher usw.) 왘 Unterstützung gemeinsamer Ein-/Ausgabeströme und Handling von Dateien
9.4 Kriterien für Programmiersprachen
201
Beispiel: Die Entwickler von ALGOL 60 konnten sich nicht einigen, welche Ein- und Ausgabefunktionen notwendig waren; jedes Institut bzw. jeder Hersteller musste dazu eigene Maschinenroutinen schreiben. (7) Sicherheit, Zuverlässigkeit (security, reliability) Maximize the number of errors that could not be made. C. A. R. Hoare 왘 weitgehende Fehlererkennung beim Erstellen des Quellcodes 왘 Hilfestellung beim Debuggen von Programmen
Möglichkeiten dazu sind: 왘 Explizite Deklaration aller Variablen und Sprungmarken 왘 rigorose Typenprüfung bei Wertzuweisung 왘 striktes Funktionsprototyping 왘 Schutz von Konstanten 왘 Prüfung auf nicht initialisierten Variablen 왘 Prüfung von Array-Indizes 왘 Verbot von Nebeneffekten 왘 keine Unterstützung von Default-Werten 왘 kurze Lebensdauer von Variablen 왘 Eindeutigkeit und Lesbarkeit von Operatoren 왘 Blockstruktur von Funktionen und Klassen 왘 Unterstützung von Selbstdokumentation 왘 Möglichkeit von langen, selbst erklärenden Bezeichnern
Beispiel: Verlust der Mariner 1-Marssonde wegen eines Programmierfehlers in FORTRAN DO 99 I = 1.10
C
statt DO 99 I = 1,10
Hier wurde durch ein Versehen ein Komma durch einen Punkt ersetzt. Wegen der automatischen Variablendeklaration in FORTRAN interpretierte der Compiler die DOAnweisung als Wertzuweisung für die reelle Variable D099I; die Schleife mit Zähler I wurde daher nicht ausgeführt. 왘 Als Zuverlässigkeit kann man die Wahrscheinlichkeit definieren, mit der ein Pro-
gramm seine Aufgabe für eine vorgegebene Zahl von Eingabefällen erreicht. 왘 Bei allem Streben nach Sicherheit und Zuverlässigkeit darf die Arbeit des Program-
mierers nicht behindert werden. (8) Präzise Beschreibbarkeit (preciseness) 왘 Genau definierte Syntax, Beschreibung durch BNF (Backus-Naur-Form) 왘 Genau definierter Standard durch internationale Norm
202
9 Elemente des Software-Engineerings
Beispiel: Die Programmiersprache FORTRAN war nur umgangssprachlich definiert. C. A. R. Hoare konnte seinen Algorithmus quicksort erst formulieren, nachdem er ALGOL-W gelernt hatte. (9) Modularität (modularity) 왘 Eine Programmiersprache muss die Möglichkeit bieten, Programme in separaten
Modulen zu schreiben, diese einzeln zu compilieren und später zu einem Projekt zusammenzubinden. (10) Unterstützung von modernen Software-Techniken 왘 Optimierende Compiler 왘 GUI (Grafisches User Interface) 왘 Unterstützung abstrakter Datentypen 왘 Datenkapselung 왘 Wiederverwendbarkeit 왘 Polymorphismus 왘 generische Algorithmen 왘 Vererbung 왘 Schnittstellen 왘 Unterstützung von Ausnahmefällen
Zusammenfassung: Alle diese Kriterien können nicht in gleicher Weise erfüllt werden. Einfachheit und Allgemeinheit widersprechen sich.
9.5
Forderungen des Software-Engineerings
Das Software-Engineering beinhaltet das Ziel, den vollständigen Entwicklungszyklus von Software-Projekten vom anfänglichen Entwurf bis zum Testen und zur Wartung auf eine methodische und handhabbare Weise zu organisieren. Qualität, Leistung und Kosten der Software müssen vorhersehbar sein. Ein angemessener Kompromiss zwischen Kosten und Zuverlässigkeit muss erreicht werden. Weitere Anforderungen an Software sind: (11) Adaptierbarkeit 왘 Eine Software ist adaptierbar, wenn sie auf verschiedene Aufgabenstellungen und
variierende Benutzeranforderungen hin angepasst werden kann. (12) Robustheit 왘 Eine Software ist robust, wenn sie unempfindlich gegen falsche Eingaben ist.
(13) Funktionale Korrektheit 왘 Eine Software heißt funktional korrekt (auch effektiv), wenn sie genau die gege-
bene Aufgabenstellung erfüllt.
9.5 Forderungen des Software-Engineerings
203
(14) Ausfallsicherheit 왘 Eine Software arbeitet ausfallsicher, wenn ein Fehler, eine Störung des Servers oder
der Ausfall von Peripherie-Geräten (in Netzwerken Clients) oder ein Übertragungsfehler bei Ein- und Ausgabegeräten das korrekte Arbeiten der Software nicht unmöglich machen, sondern höchstens behindern. (15) Verfügbarkeit 왘 Eine Software heißt in hohem Maße verfügbar, wenn die Zeitdauer des fehlerfreien
Verlaufs sehr groß ist im Vergleich zu der Zeitraum, in dem das Programm ausgefallen ist oder wieder installiert werden muss. Der Grad der Verfügbarkeit V ist definiert als:
V=
MTBF MTBF + MTR
Dabei bedeutet: 왘 MTBF = Mean Time Between Failures (mittlere Zeit zwischen zwei Ausfällen) 왘 MTR = Mean Time to Repair (mittlere Zeit zur Wiederherstellung)
(16) Benutzerfreundlichkeit 왘 Ein Programm heißt benutzerfreundlich, wenn es auch von einem nicht geschulten
Anwender leicht zu bedienen ist und sich erwartungsgemäß verhält.
10
Ausnahmebehandlung
Die Steuerung von strukturierten Ausnahmebehandlungen (englisch exception handling) ist ein erklärtes Ziel von modernen Programmiersprachen. Die Wahl des Pentagons 1982 fiel deshalb auf die Programmiersprache ADA, da diese (neben anderen Eigenschaften wie Synchronisation) auch eine differenzierte Ausnahmebehandlung erlaubte. Wichtige Ausnahmetypen sind in ADA bereits vordefiniert, wie 왘 constraint_error (Indizes bei Arrays usw.) 왘 numeric_error (Overflow, Division durch Null) 왘 program_error (Fehler bei dynamischer Deklaration) 왘 storage_error (Fehler bei dynamischer Speicherbelegung) 왘 tasking_error (Fehler bei konkurrierenden Prozessen)
Die Ausnahmebehandlung wurde in C++ relativ spät und erst nach langer Diskussion (1984–1989) implementiert. Der Entwurf von Andrew Koenig und B. Stroustrup wurde dann 1990 der USENIX C++-Konferenz vorgelegt und ist nun Bestandteil der ISONorm. Ein üblicher Weg bei der Fehlerbehandlung ist zunächst die Rückgabe eines Wertes, der nicht als Funktionswert auftreten kann und somit einen Fehler anzeigt. Bei der Quadratwurzel kann, z.B. im Fehlerfall, ein negativer Wert wie -1 zurückgegeben werden. Eine andere Möglichkeit ist das Setzen einer globalen Konstanten wie errno in C. Hier kann durch Abfragen des Fehlerwerts errno geprüft werden, ob ein Fehler vorliegt, wie 왘 EDOM (Domain Error – außerhalb des Definitionsbereichs) 왘 ERANGE (Out of range – Overflow)
Die Werte von errno finden sich in der C-Headerdatei <errno.h>. Beide oben erwähnten Fehlermethoden entsprechen nicht dem Prinzip der Objektorientierung.
10.1 Ablauf eines Ausnahmefalls Für den Fall, dass auftretende Fehler vom aufrufenden Programmteil behandelt werden können, stellt C++ folgenden Ausnahmemechanismus auf: 왘 Ein Funktionsaufruf wird versucht (englisch try). 왘 Wird ein Fehler entdeckt, den die Funktion nicht beheben kann, wird eine Aus-
nahme geworfen (englisch throw).
206
10 Ausnahmebehandlung
왘 Die Ausnahme wird von einem anderen Programmteil zur Fehlerbehandlung auf-
gefangen (englisch catch) und kann wiederum einem anderen Programmteil den Fehler »zuwerfen«. Damit ergibt sich folgendes Schema für den Ablauf eines Ausnahmefalls: try { // Funktionsaufruf catch(Typ 1) throw Fehler } catch(Typ 1) { // Fehlerbehandlung vom Typ 1 } catch(Typ 2) { // Fehlerbehandlung vom Typ 2 }
Es gibt auch eine catch()-Anweisung, die jeden Datentyp aufnimmt. Dies ist: catch(...) // genau 3 Punkte (Ellipse) { cerr << "Unerwartete Ausnahme!\n "; unexspected(); }
Wie im Abschnitt 7.1 erläutert wurde, gehört zu jeder Funktion eine Liste von Ausnahmen, die von dieser Funktion ausgelöst werden können. Eine Probedivision mit einer Funktion division() kann realisiert werden als #include <stdexception> #include using namespace std; double division(int a,int b) throw (runtime_error) { if (b == 0) throw runtime_error("Nenner Null"); return double(a)/b; } int main() { int x,y; cout << "Gib 2 ganze Zahlen ein "; cin >> x >> y; try{ cout << division(x,y) << endl; } catch (const exception& e) {
10.2 Ein Beispiel mit selbst definierten Fehlerklassen
Der Konstruktor der Fehlerklasse, hier runtime_error, wird mit einem String aufgerufen, der die Art des Fehlers angibt. Die Basisklasse exception ist im Header <exception> definiert: class exception { public: exception() throw(); exception(const exception& ) throw(); exception& operator=(const exception& ) throw(); virtual ~exception() throw(); virtual const char* what() const throw(); //.… }
10.2 Ein Beispiel mit selbst definierten Fehlerklassen An dem Rechenbeispiel des allgemeinen Logarithmus von x zur Basis y
log y ( x) =
log10 ( x) ln( x) = log10 ( y) ln( y)
wird das Auswerfen von verschiedenen selbst definierten Fehlerklassen demonstriert: #include #include <math> #include using namespace std; class class class class
Matherr{}; Singularity : public Matherr{}; Base : public Matherr{}; Domain : public Matherr{};
if (x < 0) throw Domain(); if (fabs(x) < eps) throw Singularity(); if (y<0||fabs(y)<eps||fabs(y-1)<eps) throw Base(); double f = log(x)/log(y); cout << "log = " << f << endl; } catch(Domain) { cout << "Nicht im Definitionsbereich!\n"; } catch(Singularity) { cout << "Singularitaet!\n"; } catch(Base) { cout << "Nicht erlaubte Basis!\n"; } return 0; }
10.3 Vordefinierte Fehlerfunktionen Für eine Reihe von Fällen stellt C++ spezielle Fehlerfunktionen zur Verfügung, die im Header <exception> definiert sind.
10.3.1 Die Funktion terminate() Die Funktion void terminate() beendet das Programm; sie wird aufgerufen, wenn 왘 die Funktion unexpected_handler() aufgerufen wird (siehe folgenden Abschnitt), 왘 der Ablauf der Fehlerbehandlung keine passende Möglichkeit mit catch() findet, 왘 ein Konstruktor eines Objekts oder ein Destruktor beim Aufräumen des Speichers
(englisch stack unwinding) einen Fehler aufwirft.
10.3.2 Die Funktion unexpected() Die Funktion unexpected() wird benützt, wenn eine Funktion einen Ausnahmefall wirft, der nicht in der Exception-Spezifikation aufgeführt ist. unexpected() kann nun selbst einen Ausnahmefall werfen, der dem nicht erklärten Fehler entspricht, und so eine Weiterbehandlung ermöglichen. Falls die ausgelöste Exception nicht der Spezifikation entspricht, gibt es zwei Möglichkeiten: 왘 Die Exception-Spezifikation enthält u.a. die bad_exception. Dann wird der Typ der
ausgelösten Exception zum Typ bad_exception. 왘 Die Exception-Spezifikation enthält keine bad_exception. Dann wird die Funktion
terminate() aufgerufen (vgl. 10.3.1).
10.3.3 Die Funktion uncaught_exception() Die Funktion bool uncaught_exception() liefert true zurück, wenn der ExceptionHandler abgeschlossen wurde oder die Funktionen unexpected() bzw. terminate() aufgerufen wurden.
10.3 Vordefinierte Fehlerfunktionen
209
10.3.4 Selbst definierte Handler Die oben aufgeführten Funktionen können nach Bedarf auch selbst definiert werden. Dazu sind zwei Funktionszeiger vordefiniert: typedef void (*unexpected_handler)(); typedef void (*terminate_handler)();
Es existieren es zwei Funktion set_unexpected() bzw. set_terminate(), an die ein Zeiger auf die selbst definierten Funktionen übergeben wird: unexpected_handler set_unexpected(unexpected_handler f) throw(); terminate_handler set_terminate(terminate_handler f) throw();
Wird kein Handler für set_unexpected() definiert, so wird set_terminate() aufgerufen. Existiert auch hier kein selbst definierter Handler, so wird das Programm über den Aufruf von abort() vom System beendet. Der Vorgang wird erläutert durch das folgende Programmbeispiel: #include #include <exception> #include using namespace std; class Nenner_Null{}; void Unerwartet() { cerr << "Unerwarteter Fehler\n"; exit(1); }; int main() { set_unexpected(Unerwartet); int x,y; cout << "Eingabe Zaehler Nenner "; cin >> x >> y; try{ if (y==0) throw Nenner_Null(); cout << "Quotient = " << double(x)/y << endl; } catch(Nenner_Null) { cerr << "Fehler: Nenner Null!\n "; } return 0; }
210
10 Ausnahmebehandlung
10.4 Die Standardausnahmen Die von C++ aufgeworfenen Standardausnahmen sind: Name
Geworfen von
Header
bad_alloc
new
bad_cast
dynamic_cast
bad_typeid
typeid
bad_exception
Except.Spezifikation
<exception>
Tab. 10.1: Standardausnahmen von C++
Die Standardbibliothek wirft die Ausnahmen: Name
Geworfen von
Header
out_of_range
at()
<stdexception>
out_of_range
bitset<>::operator[]
<stdexception>
invalid_argument
bitset-Konstruktor
<stdexception>
overflow_error
bitset<>::to_ulong()
<stdexception>
ios_base::failure
ios_base::clear()
Tab. 10.2: Ausnahmen der Standardbibliothek
Die in der Tabelle gezeigten Ausnahmen der Standardbibliothek sind Teil einer Vererbungshierarchie, die von Abb. 10.1 veranschaulicht wird.
Abbildung 10.1: Vererbung der Ausnahmenklassen
11
Vererbung
Die Vererbung (englisch inheritance) ist ein aus der Biologie übernommener Begriff, der besagt, dass ein Nachkomme die genetischen Eigenschaften seiner Vorfahren erhält.
Abbildung 11.1: Klasse der Säugetiere und Unterordnungen
Dieser Begriff wurde von der OOP übernommen für den Vorgang, dass eine abgeleitete Klasse Datenelemente und Elementfunktionen von einer Basisklasse übernehmen kann. Die Vererbung ist von prinzipieller Bedeutung für die Wiederverwendbarkeit von Programmcode, da wichtige Eigenschaften von existierenden Klassen auf einfache Weise abgeleitet werden können. Diese Vererbung kann fortgesetzt werden, sodass eine ganze Hierarchie von abgeleiteten Klassen entsteht. Diese können spezialisierte und zusätzliche Funktionen gegenüber ihren vererbenden Klassen haben. Die gemeinsame Basisklasse enthält dann den Durchschnitt aller Klassen, d.h. die Eigenschaften, die allen abgeleiteten Klassen gemeinsam sind.
212
11 Vererbung
Leitet sich jede Klasse direkt von genau einer vererbenden Klasse ab, so spricht man von einfacher Vererbung, sonst von mehrfacher Vererbung. Die mehrfache Vererbung ist relativ spät von C++ übernommen worden. Sie wird zwar von einigen Informatikern nicht befürwortet, stellt aber ein mächtiges Werkzeug der Programmierung dar. Die Vererbung erfüllt zwei Zwecke in einer objektorientierten Anwendung: 왘 Spezialisierung: Ein Teil der bestehenden Funktionalität einer Klasse wird übernom-
men in die abgeleitete Klasse. 왘 Generalisierung: Bestehende Methoden einer Basisklasse werden in der abgeleiteten
Klasse mit einer weiteren Eigenschaft überladen.
Abbildung 11.2: Generalisierung und Spezialisierung am Beispiel von Fahrzeugklassen
Die Vererbung erfüllt gemäß der UML folgende Eigenschaften: 왘 Die Vererbung ist transitiv; d.h., ist B abgeleitet von A und C abgeleitet von B, so ist
auch C abgeleitet von A. 왘 Die Vererbung ist antisymmetrisch; d.h., ist B abgeleitet von A, so kann A nicht von
B abgeleitet werden. 왘 Vererbung ermöglicht Substitution; d.h., ein Objekt einer abgeleiteten Klasse kann
unter Umständen als Objekt der Oberklasse auftreten.
11.1 Zugriffsmöglichkeiten Im Sinne der Datenkapselung hat eine abgeleitete Klasse prinzipiell keinen Zugriff auf private Datenelemente der Basisklasse. Zugang besteht nur für alle public und protected (geschützten) Elemente. Insbesondere können alle public-Elementfunktionen wie eigene verwendet werden.
11.1 Zugriffsmöglichkeiten
213
class A { private: int a; void f1(); public: int b; void f2(); }; class B : public A // B erbt von A { .... a = 1; // Fehler, a privat b = 2; // ok, b public f1(); // Fehler, f1 privat f2(); // ok, f2 public };
Auf öffentliche Datenelemente können sowohl Element- als auch Nicht-Elementfunktionen beider Klassen zugreifen. Geschützte Elemente sind nur sichtbar für Elementfunktionen der Basis- und abgeleiteten Klasse. Die Art der Ableitung wird durch einen Zugriffsspezifizierer gekennzeichnet. Fehlt dieser, so ist die Ableitung privat (Default-Wert). Class B : private A // private Ableitung Class B : protected A // protected Ableitung Class B : public A // public Ableitung
Bei öffentlicher Ableitung bleibt der Status der Datenelemente erhalten; bei privater Ableitung werden die public- und protected-Elemente ebenfalls privat. Spezifizierer der Basisklasse
Art der Ableitung
public
protected
private
public
public
protected
private
protected
protected
protected
private
private
private
private
private
Tab. 11.1: Zugriffsspezifizierer bei Vererbung
Bei geschützter Ableitung werden die public- und protected-Elemente ebenfalls geschützt. Der Vorgang wird durch die Tabelle 11.1 veranschaulicht.
214
11 Vererbung
11.2 Konstruktoren und Destruktoren Benötigt man in einer abgeleiteten Klasse einen Konstruktor und besitzt die Basisklasse ebenfalls einen, so wird derjenige der Basisklasse verwendet. Der Konstruktor der Basisklasse wird zuerst aufgerufen, derjenige der Ableitung anschließend (bei Destruktoren verläuft der Vorgang genau umgekehrt). Besitzt die Basisklasse mehrere Konstruktoren, so wählt der Compiler einen, gemäß der Signatur, aus. Als Beispiel wird die Klasse Punkt betrachtet: class Punkt { protected: double x,y; public: Punkt(double X=0,double Y=0){x = X; y = Y; } virtual ~Punkt(){} // Destruktor void moveto(double X,double Y) { x = X; y = Y; } double abstand(Punkt B) const; friend ostream& operator<<(ostream&,Punkt&); }; double Punkt::abstand(Punkt B) const { return sqrt((x-B.x)*(x-B.x)+(y-B.y)*(y-B.y)); } ostream& operator<<(ostream& o,Punkt& P) { o << "(" << P.x << "," << P.y << ")"; return o; }
Die Klasse Kreis erbt den Mittelpunkt und alle Methoden der Klasse Punkt. Daher muss der Mittelpunkt nicht mehr innerhalb der Kreisklasse deklariert werden! class Kreis : public Punkt { double radius; public: Kreis(double X=0,double Y=0,double R=1):Punkt(X,Y),radius(R){} bool ist_innen(Punkt P) const { Punkt M(x,y); return (M.abstand(P) < radius); } friend ostream& operator<<(ostream&,Kreis&); }; ostream& operator<<(ostream& o,Kreis& K) { o << "Mittelpunkt (" << K.x << "," << K.y << "); Radius = " << K.radius; return o; }
11.2 Konstruktoren und Destruktoren
215
Der Konstruktor für den Kreis ruft zur Erzeugung des Mittelpunkts den Konstruktor der Basisklasse auf. Die Syntax erfordert hier den Operator : gefolgt von einer Initialisierer-Liste. Die Methode ist_innen() des Kreises erbt automatisch die Methode abstand() der Punkt-Klasse. Zum Testen schreiben wir ein Hauptprogramm: int main() { Kreis K(3,4,5); Punkt P(6,3); cout << K << endl; if (K.ist_innen(P)) cout << "P liegt innerhalb " << endl; else cout << "P liegt ausserhalb " << endl; K.moveto(6,6); cout << "nach Verschiebung:\n" << K << endl; return 0; }
Die Programmausgabe ist hier Mittelpunkt (3,4); Radius = 5 P liegt innerhalb nach Verschiebung: Mittelpunkt (6,6); Radius = 5
Sie zeigt nicht nur die Vererbung der abstand()-Methode, sondern auch die der moveto()-Methode. Durch moveto(6,6) wird also der Kreismittelpunkt auf den Punkt (6|6) verschoben (unter Beibehaltung des Radius), ohne das dies für den Kreis programmiert wurde! Dies zeigt deutlich die Ökonomie des Vererbens. Aber die Vererbung bietet mehr als das Einsparen von Quellcode. Der Mittelpunkt ist ein spezieller Punkt, er erfüllt also eine ist ein-(englisch IS-A)Relation. Liefert die Vererbung eine Spezialisierung, so sollte stets die IS-A-Relation erfüllt sein. Durch Einfügen einer Ausgabeanweisung in den Konstruktor und Destruktor kann man zeigen, dass immer zuerst der Konstruktor der Basisklasse, dann der der abgeleiteten Klasse aufgerufen wird. Bei Programmende erfolgt der Aufruf der Destruktoren in der umgekehrten Reihenfolge. Aus Gründen, die später erläutert werden, empfiehlt es sich stets, den Destruktor einer Basisklasse als virtuell zu deklarieren. Als weitere Anwendung der Vererbung werden die Eigenschaften einer Person auf die einer(s) Angestellten vererbt. Hier ist wieder die Relation IS-A erfüllt. Betrachtet wird die Klasse Person: class Person { public: enum familienstand {ledig,verheiratet,geschieden,verwitwet}; enum geschlecht {maennlich,weiblich}; protected: string name; int alter; familienstand famstand; geschlecht g;
Eine formatierte Ausgabe wird erzeugt durch: ostream& Person::ausgabe(ostream& o) const { o << "Name.............."; o << name << endl; o << "Alter............."; o << alter << endl; o << "Geschlecht........"; if (g == weiblich) o << "weiblich\n"; else o << "maennlich\n"; o << "Familienstand....."; switch(famstand) { case ledig : o << "ledig"; break; case verheiratet : o << "verheiratet"; break; case geschieden : o << "geschieden"; break; case verwitwet : o << "verwitwet"; } return o; }
Die Klasse Angesteller erbt alle Eigenschaften der Person und verfügt zusätzlich über das Datenelement einkommen. Die Vererbung zeigt die folgende Klasse: class Angestellter : public Person { private: double einkommen; public: Angestellter(string N,int A,familienstand F,geschlecht G,double E): Person(N,A,F,G){ einkommen = E; } ostream& ausgabe(ostream&) const; }; ostream& Angestellter::ausgabe(ostream& o) const { return Person::ausgabe(o) << endl << "Einkommen.........DM " << einkommen << endl; }
Der Konstruktor Angestellter ruft für Namen, Alter, Familienstand, Geschlecht den Personen-Konstruktor auf und initialisiert das Einkommen. Daneben wird bei der Methode ausgabe() die geerbte Methode Person::ausgabe() verwendet. Ein mögliches Hauptprogramm ist:
11.3 Polymorphismus Der Begriff des Polymorphismus bedeutet soviel wie »Vielgestaltigkeit«. Man unterscheidet folgende Formen des objektorientierten Polymorphismus: 왘 Polymorphismus mittels Änderung der Parameter (Templates und Überladen von
Element-Funktionen) 왘 Polymorphismus durch Änderung der Methoden (virtuelle Methoden beim Verer-
ben, Überladen von Operatoren, spätes Binden).
Abbildung 11.3: Formen des Polymorphismus
Werden von einer Klasse A die Klassen B und C abgeleitet, so können Zeiger vom Typ Pointer auf A auch auf Objekte von B und C verweisen. Enthalten diese Klassen eine Elementfunktion gleichen Namens, so kann es hier zu gleich lautenden Funktionsaufrufen kommen, die aber verschiedene Wirkungen besitzen. Dies ist eine Form des Polymorphismus. Man unterscheidet hier zwei Formen des Linkens oder Bindens: 왘 Kann bereits beim Compilieren festlegt werden, welche Funktion aufgerufen wird,
so spricht man vom statischen oder frühen Binden (early binding). 왘 Kann erst zur Laufzeit bestimmt werden, welcher Funktionsaufruf erfolgt, so
spricht man vom dynamischen oder späten Binden (late binding). Die Möglichkeit des späten Bindens gibt es nur bei echt objektorientierten Programmiersprachen.
218
11 Vererbung
11.4 Virtuelle Funktionen Virtuelle Funktionen sind spezielle Elementfunktionen, die nicht zur Übersetzungszeit, sondern erst zur Laufzeit gebunden werden. Das heißt, erst beim Aufruf einer virtuellen Methode wird entschieden, welche Funktion tatsächlich ausgeführt wird. Betrachtet wird ein Programmbeispiel, bei dem die abgeleiteten Klassen eine Elementfunktion gleicher Signatur wie die Basisklasse aufweisen. // virt.cpp #include class A { public: void zeige() { cout << "A" << endl; } // ohne virtual }; class B : public A { void zeige() { cout << "B" << endl; } }; class C : public A { void zeige() { cout << "C" << endl; } }; class D : public A { void zeige() { cout << "D" << endl; } }; int main() { A* arr[4]; arr[0] = new A; arr[1] = new B; arr[2] = new C; arr[3] = new D; for (int i=0; i<4; i++) arr[i]->zeige(); return 0; }
Die etwas überraschende Ausgabe des Programms zeigt, dass in allen Fällen der Compiler die Funktion zeige() der Basisklasse anwendet. Da diese Funktion nicht als virtuell deklariert ist, wird sie statisch gebunden und daher die Funktion der Basisklasse ausgeführt. Fügt man in der Definition von Klasse A das Schlüsselwort virtual ein void virtual zeige() { cout << "A" << endl; }
so ergibt sich die erwartete Ausgabe.
11.4 Virtuelle Funktionen
219
Zum Vergleich von virtuellen und überladenen Funktionen wird das Programm virt2.cpp betrachtet. // virt2.cpp // Demo fuer virtuelle u.ueberladene Funktionen #include class A { public: A(){} virtual void zeige(char ch) { cout << "virtual A::zeige " << ch << endl; } }; class B : public A { public: B(){} void zeige(const char *ch) // ueberladen { cout << "B::zeige " << ch << endl; } void zeige(int i) // ueberladen { cout << "B::zeige " << i << endl; } void zeige(char ch) // virtual { cout << "virtual B::zeige " << ch << endl; } }; class C : public B { public: C(){} void zeige(const char *ch) // ueberladen { cout << "C::zeige " << ch << endl; } void zeige(double x) // ueberladen { cout << "C::zeige " << x << endl; } void zeige(char ch) // virtual { cout << "virtual C::zeige " << ch << endl; } }; int main() { A a; B b; C c; a.zeige('A'); b.zeige('B'); b.zeige(12); b.zeige("B-Obj"); c.zeige('C'); c.zeige(3.1415); c.zeige("C-Obj"); return 0; }
220
11 Vererbung
Bei diesem Beispiel ist die Funktion zeige(char) von Klasse A als virtuell erklärt. Dies führt dazu, dass alle abgeleiteten Funktionen gleicher Signatur ebenfalls virtuell sind (nach dem Motto Einmal virtuell, immer virtuell). Dagegen sind die Funktionen mit verschiedener Signatur in den Basisklassen überladen. Dies zeigt die Programmausgabe: virtual A::zeige A virtual B::zeige B B::zeige 12 B::zeige B-Obj virtual C::zeige C C::zeige 3.1415 C::zeige C-Obj
Zu beachten ist, dass eine solche virtuelle Funktion, die nicht in der abgeleiteten Klasse überschrieben wird, dort unsichtbar ist.
11.5 RTTI C++ stellt für polymorphe Objekte sog. Laufzeit-Typinformationen, englisch Run- Time Type Information (RTTI) , zur Verfügung. Damit wird es möglich, den Typ eines polymorphen Objekts zur Laufzeit festzustellen. Zur RTTI gehören zwei Operatoren und eine Klasse dynamic_cast
erlaubt sichere Typumwandlung bei polymorphen Objekten
typeid
liefert Typ eines polymorphen Objekts
type_info
liefert u.a. den Namen der Klasse
Als erstes Beispiel wird eine Klasse Figur als Prototyp einer geometrischen Figur definiert: class Figur // geometrische Figur { protected: static int zaehl= 0; public: Figur() { zaehl++; } virtual ~Figur()=0 { zaehl--; } virtual void ausgabe() const= 0; static int anzahl() { return zaehl; } }; int Figur::zaehl;
Davon werden mehrere andere Klassen wie Kreis, Quadrat und Dreieck usw. abgeleitet: class Kreis : public Figur { private: void operator=(Kreis&); // Kein Ueberladen moeglich protected:
11.5 RTTI
221
static int zaehl; public: Kreis() { zaehl++; } Kreis(const Kreis&) { zaehl++; } virtual ~Kreis() { zaehl--; } virtual void ausgabe() const { cout << "Kreis\n"; } static int anzahl() { return zaehl; } }; int Kreis::zaehl; class Quadrat : public Figur // analog class Dreieck : public Figur // analog
Mit Hilfe des Zufallszahlengenerators werden zwölf zufällige Figuren erzeugt und auf einen Stack, implementiert mit Hilfe der STL (siehe Abschnitt 14), gelegt. typedef stack > Stack; int main() { static int NKreise,NQuadrate,NDreiecke; time_t now; srand((unsigned)time(&now)); Stack S; for (int i=0; i<12; i++) { int x = rand() % 3; switch(x) { case 0: S.push(new Kreis); break; case 1: S.push(new Quadrat); break; case 2: S.push(new Dreieck); } }
Sodann wird der Stack geleert. Das jeweils entnommene Objekt ist ein polymorphes Gebilde der Klasse Figur. Mit Hilfe des typeid-Operators wird die abgeleitete Klasse ermittelt und durch dynamic_cast in das entsprechendes Objekt umgewandelt, das mittels eines Zählers registriert wird. while(!S.empty()) { Figur* f = S.top(); cout << typeid(*f).name() << " erzeugt\n"; if (dynamic_cast(f)) NKreise++; if (dynamic_cast(f)) NQuadrate++; if (dynamic_cast(f)) NDreiecke++; S.pop(); } cout << "Es wurden folgende Figuren erzeugt:\n"; cout << NKreise << " Kreise\n" << NQuadrate << " Quadrate\n"
222
11 Vererbung
<< NDreiecke << " Dreiecke" << endl; return 0; }
Als zweites Beispiel werden von der Basisklasse Wertpapier die Klassen Aktie und Anleihe abgeleitet. Die Basisklasse ist hier: class Wertpapier { protected: string name; public: Wertpapier(const string N):name(N){} virtual ~Wertpapier(){} friend ostream& operator<<(ostream& o,const Wertpapier& w) { return w.ausgabe(o); } virtual ostream& ausgabe(ostream& o) const= 0; private: Wertpapier(const Wertpapier&); Wertpapier& operator=(const Wertpapier&); }; ostream& Wertpapier::ausgabe(ostream& o) const { return o << name; }
Davon erbt die Klasse Aktie und Wertpapier. class Aktie : public Wertpapier { private: double nennwert; double kurswert; double dividende; public: Aktie(string N1,double N2,double K,double D):Wertpapier(N1), nennwert(N2), kurswert(K),dividende(D){} protected: ostream& ausgabe(ostream& o) const { return Wertpapier::ausgabe(o) << "\n" << "Nennwert = DM " << nennwert << "\n" << "Kurswert = DM " << kurswert << "\n" << "Dividende = DM " << dividende << endl; } }; class Anleihe : public Wertpapier // analog
11.5 RTTI
223
Ein Array enthält polymorphe Objekte vom Typ Aktie oder Anleihe: Wertpapier* papier[4] = { new Aktie("Deutsche Telekom",5.00,38.70,1.20), new Aktie("BASF",5.00,81.90,2.10), new Anleihe("Bundesrepublik Deutschland, Staatsanleihe 86", "20.09.2016",6.125), new Anleihe("Bundesrepublik Deutschland, Bundesobligation", "21.05.2001",5.), };
Mit Hilfe eines Zeigers wird das Array durchlaufen, die abgeleitete Klasse ermittelt und das entsprechende Wertpapier ausgedruckt. int main() { Wertpapier* w; for (int i=0; i<4; i++) { w = papier[i]; cout << typeid(*w).name() << endl; cout << (*w) << endl; } return 0; }
Der RTTI-Mechanismus ist nur als Hilfsmittel in C++ gedacht. Eine Anhäufung von typeid-Abfragen und dynamic_cast-Umwandlungen ist kein guter Programmierstil im Sinne der objektorientierten Programmierung. Besser ist der Programmierstil, der es dem Compiler erlaubt, selbstständig polymorphe Objekte zu klassifizieren. Die Zuweisung von Objekten einer abgeleiteten Klasse auf Objekte der Basisklasse ist im Allgemeinen mit Informationsverlust verbunden, da eventuell zusätzlich vorhandene Datenelemente der abgeleiteten Klasse ignoriert werden. Auch im umgekehrten Fall, bei der Zuweisung von im Allgemeinen kleineren Objekten der Oberklassen auf abgeleitete Klassenobjekte, gibt es ein Informationsdefizit. In diesem Fall muss durch Bereitstellung eines Zuweisungsoperators oder einer Umwandlungsfunktion die Konversion explizit vorgegeben werden. Bei Zeigern wird die oben genannte Regelung nicht so strikt gehandhabt. Zeiger auf abgeleitete Objekte können auf Zeiger der Basisklasse umgewandelt werden (sog. Upcast), wenn die Basisklasse zugänglich ist. Dies ist z.B. nicht möglich, wenn die Vererbung privat ist. Die Umkehrung, das sog. Downcast, muss explizit definiert werden. class A {}; class B : public A {}; A* a; B* b = dynamic_cast(a);
Die Konversion ist nur sinnvoll, wenn a tatsächlich auf ein Objekt von B zeigt; andernfalls wäre der Zugriff auf ein zusätzliches Element von B gar nicht definiert. Ein Programmbeispiel zum Upcast ist:
224
class class class C* pc B* pb A* pa
11 Vererbung
A B C = = =
{}; : public A {}; : public B {}; new C; dynamic_cast(pc); // upcast dynamic_cast(pc); // upcast