next up previous contents index
Next: Programmieren mit C++ Up: C++-Entwicklung mit Linux Previous: Programmieren unter Linux   Contents   Index

Subsections


Grundlagen der objektorientierten Programmierung in C++

In diesem Kapitel will ich Ihnen die Grundlagen der objektorientierten Programmierung in C++ vorstellen. Dabei haben wir ein umfangreiches Pensum vor uns:

Da Sie hier erst Schritt für Schritt die elementaren Begriffe der C++-Programmierung kennen lernen, sind die Beispiele leider vorwiegend Codeausschnitte und keine richtigen Programme. Denn dazu sind immer Sprachelemente notwendig, die zu dem Zeitpunkt eben noch nicht besprochen sind. Aus diesem Grund verlangt dieses Kapitel von Ihnen einiges an Durchhaltevermögen, führt Sie aber in sehr knapper Form durch alle Tiefen der objektorientierten Programmierung mit C++. Im nächsten Kapitel werden wir dann die erlernten Konzepte praktisch anwenden.


Grundideen

Das Erlernen der objektorientierten Programmierung ist für zwei Gruppen von Menschen schwierig:

  1. Solche, die noch nie programmiert haben;
  2. solche, die schon einmal programmiert haben.
Dabei wage ich zu behaupten, dass es die erste Gruppe noch etwas leichter hat als die zweite. Sie hat nämlich keine eingefahrenen Denk- und Vorgehensmuster, die sie erst überwinden muss. Allerdings sind die ersten Schritte in jeglicher Programmierung stets schwierig, so dass von einem echten Vorsprung nicht die Rede sein kann.

Wenn Sie den Stellenmarkt für Programmierer und Informatiker durchblättern, werden Sie feststellen, dass heute fast überall Kenntnisse in objektorientierter Programmierung gefragt sind - egal ob C++ oder Java. In der Tat werden immer mehr Entwicklungsprojekte nach diesem Muster (vornehm ausgedrückt: Paradigma) durchgeführt. Der Erfolg ließ aber lange auf sich warten. Die erste objektorientierte Programmiersprache gab es nämlich bereits 1967. Zum einen war es sicher das Image als Sprache für wissenschaftliche Spielereien , die eine weite Verbreitung verhinderten. Zum anderen war es erst der Durchbruch der grafischen Benutzeroberflächen wie Windows Anfang der neunziger Jahre, der zur Popularität der Objektorientierung führte. Denn ein Fenster, eine Schaltfläche oder ein Menü sind auf ganz natürliche Weise Objekte, wodurch sich schnell zeigte, dass Fensteranwendungen am einfachsten und schnellsten objektorientiert zu programmieren sind. Darüber hinaus führte der wachsende Zeit- und Kostendruck bei den Softwarehäusern dazu, dass Aspekte wie Wartbarkeit und Wiederverwendbarkeit eine immer größere Bedeutung erhielten. Und der objektorientierte Ansatz verspricht mehr als alle anderen, diese Ansprüche zu erfüllen.


Beherrschung der Komplexität

Wenn Sie gerade mit dem Programmieren begonnen haben, kommen Ihnen die meisten Programme vielleicht simpel und schnell überschaubar vor. Alle, die schon etwas mehr Erfahrung haben, werden mir aber zustimmen, dass moderne Softwaresysteme alles andere als einfach sind. Ein Informationssystem von heute ist aus vielerlei Gründen komplex, und das leider allzu oft in sehr hohem Maße. Häufig ist bereits die Problemstellung sehr komplex, denn der Anwender möchte oft alle seine Probleme mit einer Software in den Griff bekommen. Da ein solches System schon aufgrund seiner Größe und der Terminvorgaben nicht von einer Person allein erstellt werden kann, kommt noch die Schwierigkeit hinzu, den Entwicklungsprozess vernünftig zu steuern. Außerdem soll immer auch eine hohe Flexibilität gewährleistet werden, um mit den Investitionen von heute den Anforderungen von morgen noch gewachsen zu sein. Und schließlich darf man auch nicht vergessen, dass es sich in einem Computer immer nur um ein Modell der Wirklichkeit handelt, das alle Schwächen, die mit dieser Modellierung einhergehen, permanent in sich trägt; schon dieses Problem allein kann zu mancherlei unliebsamen Überraschungen führen, die man natürlich so weit wie möglich begrenzen will.

Warum erzähle ich Ihnen das alles? Sie sollen einen ersten Eindruck davon bekommen, dass Problemlösung mit Hilfe einer Software nicht bedeutet, sofort an den Rechner zu stürzen und mit den ersten Codezeilen zu beginnen. Es wird vielmehr immer wichtiger, sich zunächst viele Gedanken über den Aufbau des Programms (und die Organisation des Projekts) zu machen. Denn letztlich können Sie die Komplexität nur durch die Aufteilung des Systems in immer kleinere Einheiten beherrschen (Abbildung 2.1).

Figure: Die Beherrschung der Komplexität gelingt nur durch sukzessives Aufteilen in immer kleinere Einheiten.

\resizebox*{10.5cm}{2.33cm}{\includegraphics{images/komplex.eps}}

Die Herausforderung für den Entwickler besteht nun darin, die geeignetste Art der Aufteilung zu finden. Denn im schlimmsten Fall kann eine ungeschickte Aufteilung das Chaos erst richtig herbeiführen. Die verschiedenen Programmierparadigmen lehren dabei auch verschiedene Arten der Aufteilung. Also werfen wir zunächst einen Blick darauf, um zu verstehen, wie sich schließlich die objektorientierte Vorgehensweise von ihren Vorgängern unterscheidet.

Rückblick auf strukturiertes Programmieren

Trotz meiner Bemerkung von vorhin nehme ich an, dass Sie schon Programme geschrieben haben, z.B. in Basic, Pascal, Java oder C. Ihre allerersten Programme werden vermutlich sehr unstrukturiert gewesen sein, also etwa alle Anweisungen und alle Daten in einem Hauptprogramm. Damit sind Sie sicher auf Dauer nicht zufrieden gewesen, sei es, dass Sie das Programm vergrößern wollten und sich alles zu einem noch größeren Chaos entwickelte, oder sei es, dass Sie das Programm weglegten, nach einiger Zeit wieder anschauten und überhaupt nicht mehr nachvollziehen konnten, was Sie sich dabei eigentlich gedacht hatten. Fazit: Größere und professionelle Programme brauchen eine Struktur, um das Chaos zu ordnen.

Prozeduren und Module

Das prozedurale Programmieren macht sich die simple Tatsache zunutze, dass es in fast jedem Programm Abläufe gibt, die mehrmals in ähnlicher Form abgearbeitet werden müssen. Diese fasst man dann zu einer Prozedur zusammen, die einen Satz von Parametern übergeben bekommt, mit diesen eine Aufgabe erledigt und anschließend einen Wert zurückliefern kann. Das Programm fährt direkt hinter der Stelle fort, an der die Prozedur aufgerufen wurde.

Der modulare Ansatz geht noch einen Schritt weiter und fasst zusätzlich Prozeduren von verwandter Funktionalität zu Modulen zusammen, die gröbere Untereinheiten des Programms darstellen. Das kann sowohl die Organisation des Entwicklungsprojekts erleichtern, da man jeweils einen Programmierer mit einem Modul betrauen kann, als auch das Testen, da hierbei die Module bereits separat einmal getestet werden können und später nur noch das korrekte Zusammenspiel untersucht werden muss. (Beispiele für Module sind die Units in Turbo/Borland Pascal oder die Module in Fortran90. Bereits recht früh gab es zudem die Programmiersprache Modula-2, die ebenfalls als ein Pascal mit Modulen konzipiert wurde.)

Jedes Modul sollte dabei über eine klar definierte Schnittstelle verfügen. (Darunter versteht man die Gesamtheit aller Prozeduren, die von einem anderen Modul aus aufgerufen werden können.) Dann kann man auch das Gesamtsystem in hierarchische Schichten von Modulen aufteilen, wobei die Module der oberen Schichten stärker abstrakte und komplexe Funktionalität bieten, die sie durch Aufruf darunter liegender Module erreichen, die dann die konkreteren Anweisungen enthalten (die so genannte untere Ebene).

Die Daten, mit denen das Programm umgeht, fasst man zu Strukturen zusammen, also zu Gruppen mit eigenem Namen, die einen Datentyp festlegen. Jedes Modul kann dabei eine eigene Datenmenge haben; diese gibt es nach Bedarf ganz oder teilweise an nachgeordnete Module zur Bearbeitung weiter.


Probleme der strukturierten Programmierung

Diese Vorgehensweise war viele Jahre gang und gäbe. Mit der Zeit haben sich aber einige Probleme herauskristallisiert, die immer wieder auftauchten und daher typisch für diesen Ansatz sind.

Zunächst ist es äußerst schwierig, die Robustheit des Codes selbst zu gewährleisten. (Damit meint man Aspekte wie Sicherheit vor Abstürzen, Ausfallsicherheit, Fähigkeit mit unvorhergesehenen Situationen umgehen zu können.) Speicher für Datenstrukturen muss dynamisch, das heißt zur Laufzeit des Programms, reserviert werden, um flexibel zu bleiben. Dieser Speicher muss jedoch auch explizit, also durch eine Programmanweisung, wieder freigegeben werden; zudem darf ein Programm nicht in Bereiche schreiben, die es nicht reserviert hat. Die Probleme, die allein aus diesem Zusammenhang herrühren, sind legendär und treten am gravierendsten bei Verwendung der Programmiersprache C auf.

Zudem ist der Compiler nicht in der Lage, bei Parameterübergaben und Zuweisungen zu überprüfen, ob die Datentypen überhaupt zueinander passen. Gerade beim Austausch inkompatibler Informationen kommt es immer wieder zu Verfälschungen und schweren Rundungsfehlern, zum Beispiel wegfallende Vorzeichen.

Schwerwiegender noch sind indessen die Probleme, die die strukturierte Programmierung hinsichtlich der Projektorganisation mit sich bringt.

Was ist also das Fazit? Mit einem strukturierten Ansatz ist die Wartung häufig sehr aufwendig und kostenintensiv und die Wiederverwendbarkeit stark eingeschränkt. Damit sind neue Projekte mit größerer Komplexität kaum möglich. Natürlich gilt dieses Resümee nicht zwangsläufig für alle auf diese Art entwickelte Software. In der Praxis hat sich aber gezeigt, dass die beschriebenen Probleme leider recht oft auftraten.

Sie dürfen daraus jedoch auch nicht den Umkehrschluss ziehen, dass mit einem objektorientierten Ansatz alle Schwierigkeiten vorbei sind. Ein Allheilmittel ist auch dieser nicht, denn auch ein objektorientiert durchgeführtes Projekt kann schief gehen. Allerdings unterstützen die Konzepte Sie sehr stark bei der Bewältigung dieser (und anderer) Probleme. Und gerade das scheint einer der wesentlichen Gründe für die Beliebheit der objektorientierten Softwareentwicklung zu sein.


Objekte

Meistens schreibt man Programme, um damit Vorgänge in der realen Welt zu modellieren, zu unterstützen oder zu automatisieren. (Und man schreibt Programme, um mit anderen Programmen weniger Ärger zu haben, aber das ist nur eine um eins höhere Abstraktionsebene ...) In dieser Welt sind wir nicht von Datenstrukturen umgeben, sondern von Objekten, also von Tieren, Ampeln, Musikinstrumenten oder PCs (Abbildung 2.2). Wenn unsere Programme also einen Bezug zur physischen Welt haben sollen, liegt es nahe, auch in den Programmen mit Objekten umzugehen.

Figure: Die reale Welt, deren Vorgänge unsere Programme im Allgemeinen beschreiben, besteht aus einer Vielzahl von Objekten.

\resizebox*{10.5cm}{!}{\includegraphics{images/objekte.eps}}

Der erste Schritt bei der Entwicklung eines objektorientiert aufgebauten Programms ist somit immer, den Ausschnitt der Realität zu betrachten, der für dieses Programm relevant ist, und die darin vorkommenden Objekte sowie deren Beziehungen untereinander zu identifizieren. Dieser Satz gehört zu der Klasse von Ratschlägen, die so allgemein sind, dass sie sicher richtig und jedem plausibel sind, die aber in der Praxis unheimlich schwer zu beherzigen sind. Es hängt nämlich immer von der Problemstellung und dessen Kontext ab, mit welchen Objekten ein Programm arbeiten soll. Ein einfaches Beispiel sind Artikel, die ein Geschäft zum Verkauf vorrätig hat. Diese haben eine Bezeichnung und verschiedene weitere Eigenschaften:


\fbox{\begin{tabular}{l} \textbf{Kaffeemaschine}\\
\par
Hersteller: Grips Eingang: 20.11.1999 Preis: 32.55 EUR
\par
\end{tabular} }

\fbox{ \begin{tabular}{l} \textbf{Haartrockner} Hersteller: Schwarz\\
\par
Eingang: 15.01.2000 Preis: 27.90 EUR \end{tabular} }



Für ein weiteres Beispiel denken Sie an die Simulationsspiele, die zurzeit sehr beliebt sind. Meistens muss der Spieler dabei eine Siedlung gründen und aufbauen und an die verschiedensten Einflüsse denken, die ihm dabei in die Quere kommen können. Die ganze Szenerie besteht dabei üblicherweise aus einer Vielzahl unterschiedlicher Objekte, die mehr oder weniger ein Eigenleben führen. Wenn wir also beispielsweise eine Kolonialisierung des Planeten Antares-3 programmieren wollen, brauchen wir unter anderem ein paar Raumfahrzeuge:


\fbox{\begin{tabular}{l} \textbf{Ariane-5}\\
\par
Herkunft: Erde Höhe: 110 m Höchstgeschw.: 400 $\frac{\rm km}{\rm s}$\par
\end{tabular} }

\fbox{\begin{tabular}{l} \textbf{Shuttle Enterprise}\\
\par
Herkunft: Erde Höhe: 45 m Höchstgeschw.: 25,000 $\frac{\rm km}{\rm s}$\par
\end{tabular} }

 


\fbox{\begin{tabular}{l} \textbf{Antarianer Raumschiff}\\
\par
Herkunft: Antares-3 Höhe: 64 m Höchstgeschw.: 3fache Lichtg.
\par
\end{tabular} }



Aus beiden Beispielen wird deutlich, wodurch sich ein Objekt charakterisiert: nämlich durch seine Eigenschaften (auch Attribute genannt). Die Arten von Eigenschaften sind bei jeder Gruppe von Objekten dieselben, ihre Werte indessen verschieden. Auch im realen Leben halten wir ja Gegenstände anhand ihrer Eigenschaften auseinander.

Ein weiteres wichtiges Merkmal ist das Verhalten eines Objekts. Dieses hängt ab vom Zustand, in dem das Objekt sich augenblicklich befindet. Ein Raumfahrzeug soll auch in unserer Simulation stehen, starten, fliegen und landen. Diese Zustände gehen durch Befehle des Kommandanten (oder unseres Programms) ineinander über. Ändert sich der Zustand eines Objekts, so ändern sich oft auch seine Verhaltensweisen.

Auch kann es verschiedene Gruppen von Zuständen geben, die gegenseitig aufeinander Einfluss haben. Ist das Raumfahrzeug beispielsweise schwer angeschossen, kann es überhaupt nicht mehr fliegen. Die Änderung von einsatzbereit in abgeschossen bewirkt, dass nur noch bestimmte Bewegungszustände möglich sind.


Klassen

Grob gesprochen haben unsere Objekte jeweils dieselben Verhaltensweisen. Worin sie sich wirklich unterscheiden, sind die Werte ihrer Eigenschaften und Zustände. Wenn wir also eine Simulation programmieren wollen, müssen wir nicht für jedes einzelne Raumfahrzeug alle Verhaltensmuster implementieren (dann hätte uns unser objektorientierter Zugang eine Menge zusätzlicher Arbeit bereitet!), sondern wir versuchen, eine allgemein gültige Beschreibung für alle Objekte vom Typ Raumfahrzeug zu finden.

Fassen wir also die bisher verwendeten Attribute zu einer Schablone zusammen: Raumfahrzeug verfügt über die Eigenschaften Bezeichnung, Herkunft, Höhe, Höchstgeschwindigkeit, BewegungsZustand sowie GradDerFunktionstüchtigkeit. Damit haben wir unser Wissen über die Elemente abstrahiert. Eine solche Abstraktion nennt man eine Klasse. Sie ist eine allgemein gültige Beschreibung von Arten von Objekten.

Figure: Klassen sind allgemein gültige Schablonen für Objekte.

\resizebox*{10.5cm}{!}{\includegraphics{images/klassen.eps}}

Hintergrund

Was hat das Ganze mit Programmierung zu tun? In etwas theoretischer aufgebauten Büchern zur objektorientierten Programmierung können Sie lesen, dass es sich bei einer Klasse um einen abstrakten Datentyp handelt. Jede Programmiersprache bringt eine Reihe von Standarddatentypen mit, etwa für ganze Zahlen oder für Zeichenketten. Objektorientierte Sprachen zeichnen sich unter anderem dadurch aus, dass sie es dem Programmierer ermöglichen, neue Datentypen zu definieren. Abstrahiert man die Daten des zu behandelnden Problemkontextes zu Datenstrukturen, so kann man daraus einen abstrakten Datentyp (ADT) bilden. Dies ist ein neuer Typ, der über eine exakt festgelegte Schnittstelle verfügt, das heißt über eine Menge von Funktionen, die er nach außen anbietet. Konsequenterweise können die enthaltenen Daten ausschließlich über diese Schnittstelle gelesen und verändert werden. Der Datentyp kann selbst Einschränkungen und Bedingungen bei der Verwendung seiner Funktionen bestimmen.

Wenn Sie genau hinsehen, werden Sie merken, dass bei der Definition eines ADT vieles an ein Modul erinnert. Der wesentliche Unterschied ist jedoch, dass Sie aufgrund seiner Typeigenschaft nicht nur ein Exemplar davon bilden können, sondern beliebig viele. Und diese Exemplare sind genau die Objekte, von denen ich oben sprach.

Die wesentlichen Kennzeichen einer Klasse

Damit kennen Sie auch schon die wesentlichen Kennzeichen einer Klasse:


Methoden und Prozessabstraktion

Wenn wir uns allein mit Eigenschaften und Zustände bei unseren Objekten zufrieden geben würden, wären unsere Programme ziemlich langweilig. Sie würden nämlich nur eine sehr statische Sichtweise auf die Objekte wiedergeben. Sie sagen aber noch nichts über das Verhalten der Objekte aus, also über die Dynamik. Dazu brauchen wir noch Funktionen und Prozeduren.

Wir haben Sec:Problem_struktur gesehen, dass bei der strukturierten Programmierung Daten und Funktionen getrennt sind. Einige der Probleme, die wir für diesen Ansatz identifiziert haben, lassen sich darauf zurückführen. In der objektorientierten Programmierung hingegen bilden Daten und Funktionen eine Einheit. Denn das Verhalten der Objekte hängt ja oft unmittelbar von den Werten ihrer Attribute ab. Das Shuttle kann nicht schneller fliegen, als seine Höchstgeschwindigkeit es erlaubt.

Wie bringen wir nun ein Objekt dazu, sich auf die eine oder andere Art zu verhalten? Wir sagen es ihm einfach. Da wir nicht direkt mit ihm sprechen können, senden wir ihm eine Nachricht. Das ist nämlich genau die Art, wie Objekte untereinander kommunizieren: Sie schicken sich gegenseitig Nachrichten, die dann zu Änderungen des Zustands oder eines Attributs führen können.

Die Funktionen, die zu einer Klasse gehören, nennt man Methoden (oder Operationen). Sie dienen dazu, Nachrichten zu versenden beziehungsweise zu behandeln. Methoden arbeiten meist innerhalb einer konkreten Instanz einer Klasse und greifen dabei auf die Attribute und Zustände dieses Objekts zu. Die Änderungen, die die Methode durchführt, sind dann nur in dieser Instanz gültig und haben auf andere Instanzen, die eventuell im Augenblick daneben noch existieren, keinen Einfluss.

Von besonderer Bedeutung ist das Prinzip der so genannten Prozessabstraktion: Für den Absender einer Nachricht ist es normalerweise unwichtig und daher auch unbekannt,

Figure: Das Absenderobjekt merkt nicht, welche Operationen der Empfänger durchführt und welche Nachrichten er verschickt, bevor er die Antwort zurückgibt. (Hier eine Darstellung des Ablaufs in der Beschreibungssprache UML.)

\includegraphics{images/processabstr.eps}

In Abbildung 2.4 sind einige mögliche Vorgänge bei der Kommunikation zwischen Objekten dargestellt. Zunächst schickt das erste Objekt eine Nachricht an das zweite. Das kann diese Anfrage nicht alleine beantworten und fragt daher bei einem dritten Objekt nach. Dieses gibt auch eine Antwort zurück. Anschließend führt das zweite Objekt noch eine Änderung seines Zustands durch. Danach kann es die Antwort auf die ursprüngliche Anfrage erstellen und an den Absender schicken.

In wirklichen Programmen werden Sie nur selten etwas finden, das wie eine tatsächliche Nachrichtensendung aussieht. Normalerweise sind es Funktionen, die Sie an einem Objekt aufrufen. Im Gegensatz zur strukturierten Programmierung müssen Sie aber in einem objektorientierten Programm stets angeben, zu welchem Objekt die Methode gehört, die Sie rufen wollen.


Datenabstraktion

Die Sichtweise der Nachrichten bringt uns noch zu einem anderen Aspekt. Wenn man ein Objekt nur dann modifizieren kann, wenn man ihm eine Nachricht schickt, heißt das praktisch, dass von außen kein direkter Zugriff auf die Daten, also die Eigenschaften und Zustände, möglich ist. Man sagt dazu, die Daten sind gekapselt. Da folglich die Daten nach außen, also außerhalb des jeweiligen Objekts, nicht sichtbar sind, können andere Objekte diese weder direkt lesen noch direkt verändern. Sie müssen dazu immer das besitzende Objekt benachrichtigen (Abbildung 2.5). Dieses kann dann selbst bestimmen, wie es auf eine solche Nachricht reagieren will. Es kann dem Wunsch nachkommen - oder ihn zurückweisen. Das Objekt behält also jederzeit die volle Kontrolle über seine Daten.

Figure: Andere Objekte können nur über Nachrichten auf die Attribute eines Objekts zugreifen.



\resizebox*{10.5cm}{2.8cm}{\includegraphics{images/datenabstraktion.eps}}

Zusammen mit dem Prinzip der Prozessabstraktion bedeutet das außerdem, dass der Absender der Nachricht auch meist nichts über die Verarbeitung innerhalb des Objekts erfährt. Man kann also beispielsweise den Verarbeitungsalgorithmus ändern oder gleich ganz austauschen, ohne dass das restliche Programm davon überhaupt etwas merkt.

Die Abstraktion von Daten und Prozessen hat auch für die Organisation des Entwicklungsprojektes Folgen. Normalerweise ist nämlich jede Klasse genau einem Entwickler zugeordnet. Er ist dann allein für die inneren Abläufe in dieser Klasse verantwortlich. Alle anderen nutzen nur die Schnittstelle, die er nach außen anbietet, und brauchen auch nicht zu wissen, wie die Klasse intern arbeitet. Wenn etwa ein Fehler mit einem Objekt dieser Klasse auftritt, kann der Entwickler ihn meist allein beheben. Bei der strukturierten Programmierung, die solche Abstraktionsebenen nicht kennt, sind von der Fehlerbereinigung meistens gleich mehrere Module, Datenstrukturen oder Funktionen betroffen, so dass vielfach auch mehrere Entwickler eingebunden werden müssen. Die strikte Trennung von Zuständigkeiten kann - wenn sie vernünftig praktiziert wird - für alle das Leben erheblich erleichtern.

Zusammenfassung

Sie sollten sich zu diesem Thema folgende Punkte einprägen:


Übungsaufgaben

Ein beliebtes Beispiel in der Literatur ist die Bankanwendung.

  1. Entwerfen Sie zwei Klassen Konto und Bankkunde mit einigen Eigenschaften, die Sie für relevant halten, und definieren Sie ein paar mögliche Methoden.
  2. Erklären Sie anhand der von Ihnen entworfenen Klassen die Begriffe Datenabstraktion und Prozessabstraktion. Welche Nachrichten werden zwischen den Objekten ausgetauscht?


Die C++-Programmiersprache

Bisher haben wir zwar viel über den Aufbau von Programmen sowie die Organisation der Daten und Prozesse in Objekten gesprochen, aber nicht über die Programmierung. Das liegt daran, dass zum Schreiben guter Software eine noch bessere Vorarbeit nötig ist. Dazu gehört ein sehr genaues Verständnis des Problems, das gelöst werden soll, ebenso wie ein durchdachtes Design, also eine sinnvolle Aufteilung der Daten in Klassen und Objekte und eine detaillierte Definition der Nachrichten, die die Objekte austauschen sollen. Letztlich rechnet man heute, dass weniger als ein Drittel der Zeit und Kosten eines Entwicklungsprojekts für die eigentliche Programmierung aufgewendet wird.

Aber zu einem guten Design gehört auch die Kenntnis der Programmiersprache, in der das Projekt später realisiert werden soll. Und außerdem haben Sie sich dieses Buch ja nicht gekauft, um Softwaredesign, sondern um C++-Programmierung zu lernen. Daher werden wir in diesem Abschnitt ganz konkret und sehen uns an, was C++ ist und warum man es für die objektorientierte Programmierung gut verwenden kann.

Historisches

C++ hat nicht ohne Grund in seinem Namen das C. Es wurde nämlich ursprünglich (ca. 1980) entworfen, um die unter Unix verbreitete Programmiersprache C um das Klassenkonzept zu erweitern. Daher taufte der Entwickler, Bjarne Stroustrup, seine Sprache zunächst C with Classes. Da es sich um eine Weiterentwicklung von C handelt, wurde 1983 der Begriff C++ geprägt. In C bedeutet der Operator ++ nämlich eine Erhöhung der daneben stehenden Variable um 1. (So gesehen ist es übrigens syntaktisch falsch, das zweite Plus wegzulassen ...)

Bis in die frühen neunziger Jahre hinein gab es auch kaum eigene Compiler für C++, also Programme, die aus dem Quelltext ein maschinenlesbares und -ausführbares Programm machen. Meist verwendete man einen Compiler, der aus C++-Code dann C-Code erstellte, den man anschließend mit einem normalen C-Compiler weiterbehandeln konnte. Sie können sich sicherlich denken, dass dieses Verfahren einige Probleme mit sich brachte und nicht optimal sein konnte. Heute verwendet man daher nur noch eigene C++-Compiler, die direkt Maschinencode erzeugen.

Da C++ schnell eine große Anhängerschaft fand, wurde es nötig, die Syntax, also die Regeln für die Sprache, formal zu standardisieren. Seit 1991 arbeitet eine gemeinsame Kommission der amerikanischen Standardisierungsbehörde ANSI und der internationalen ISO an einem Standard der Sprache C++. Eine Reihe von Entwürfen hat die Kommission in dieser Zeit veröffentlicht, die alle von den Compiler-Herstellern als De-facto-Standards aufgegriffen und bei der Entwicklung ihrer Werkzeuge berücksichtigt wurden. (Eine genaue Schilderung der Entwicklungsgeschichte findet sich in [STROUSTRUP 1994].) Im Herbst 1998 wurde dann tatsächlich ein Standard verabschiedet, der die Sprache äußerst detailliert und in ihrem maximalen Umfang beschreibt. Doch auch damit hört die Arbeit nicht auf. Es liegen schon wieder neue Entwürfe für Erweiterungen vor.

Die grundlegenden Sprachelemente werden selbstverständlich von allen aktuellen Compilern unterstützt. Die einzelnen Werkzeuge unterscheiden sich nur darin, wie umfangreich und wie gut sie auch die neueren Erweiterungen abdecken. Der GNU-Compiler etwa, der in diesem Buch verwendet wird, kennt ab Version 2.95 zum Glück so gut wie alle Sprachkonstrukte des ANSI/ISO-Standards. Gleichzeitig bedeutet ein gutes Programm zu schreiben jedoch nicht, möglichst alle neuesten Features aneinander zu hängen. Gerade wenn Sie noch wenig Erfahrung mit C++ haben, sollten Sie für Ihre Programme auf die allzu trickreichen Konstrukte verzichten.

C++ und C

Die Nähe zu C ist ein entscheidendes Merkmal von C++. Viele Probleme, die die Programmierer mit C++ haben, gehen darauf zurück. Die C-Erblast hat auch zur Folge, dass die Sprache C++ nicht ganz wie aus einem Guss wirkt, sondern noch so manches Konstrukt aus Gründen der Kompatibilität unterstützt, welches eigentlich einer modernen objektorientierten Programmiersprache nicht angemessen ist. Das führt zu der Situation, dass die Sprache eine Vielzahl von Programmierstilen ermöglicht, von rein prozeduraler Form bis zu streng objektorientierter. So schreiben einige Leute Programme in C++, die sie für objektorientiert halten, die aber nur modular sind.

Figure 2.6: C++ ist eine Obermenge von C.
\resizebox*{6.5cm}{2.33cm}{\includegraphics{images/ccpp.eps}}

Festzuhalten bleibt: C++ ist eine Obermenge von C (Abbildung 2.6), das heißt, es hat alle Syntaxeigenschaften von C, aber noch einige darüber hinaus. Der Vorteil ist, dass bereits bestehender C-Code problemlos in jedes C++-Programm eingebunden werden kann - sofern man das möchte und für sinnvoll hält.

C++ und Linux

Unix und die Programmiersprache C sind sehr eng miteinander verbunden. C wurde für Unix konzipiert und so gut wie alle Unix-Implementierungen wurden in C programmiert. Auch der Linux-Kernel ist in C geschrieben.

Mit der Zeit haben allerdings auch die Entwickler unter Unix die Vorteile des objektorientierten Ansatzes erkannt. Er mag vielleicht bei systemnahen Programmen nicht so offensichtlich sein, so dass hier auch heute noch C vorherrscht. Bei Anwendungsprogrammen, insbesondere solchen mit grafischer Benutzeroberfläche, ist hingegen der Gewinn bei Konzeption, arbeitsteiliger Programmierung und Wartung/Verbesserung deutlich spürbar. Für die Entwicklung von Benutzeroberflächen stehen heute eine Reihe sehr guter C++-Bibliotheken (zum Beispiel wxWindows, V oder Qt) zur Verfügung. Auf der Bibliothek Qt basiert beispielsweise die gesamte Arbeitsumgebung KDE (siehe www.kde.org und Seite [*]) und die dafür geschriebenen Anwendungen. Sie sehen also, wenn Sie sich für C++ unter Linux entscheiden, liegen Sie nicht völlig falsch.

Der Standard-Compiler unter Linux war lange Zeit der GCC, also der GNU-C-Compiler, der in bewährter Weise als freie Software entstanden war. Obwohl er auch um einen C++-Compiler (namens g++) erweitert wurde, konnten die Entwickler nicht mit der Geschwindigkeit mithalten, mit der sich die Sprache C++ weiterentwickelte. Eine kleine Gruppe machte sich 1997 daran, auf der Basis des GCC einen Schritt weiter zu gehen und zusätzliche Features zu unterstützen. Das Projekt nannte sich experimental GNU compiler suite, abgekürzt egcs, gesprochen eggs. Im April 1999 machten die Verantwortlichen dann der Parallelität der Entwicklungen ein Ende und erklärten den egcs zum offiziellen GNU-Compiler. Seit der Version 2.95 vom Sommer 1999 gibt es wieder nur eine GCC, was nun GNU compiler collection heißen soll; da sich noch niemand an den neuen Namen und das damit verbundene neue Geschlecht im Deutschen gewöhnt hat, werde ich weiterhin von dem GCC sprechen. Der nächste große Schritt wurde im Sommer 2001 vollzogen: die Freigabe der Version 3.0. Diese war zwar anfangs noch etwas fehlerhaft, wurde aber rasch stabilisiert. Damit liegt jetzt ein GCC vor, der fast vollständig konform zum ANSI/ISO-Standard ist.

Figure: Bei egcs schlüpft das GNU aus dem Ei.
\resizebox*{3cm}{3.56cm}{\includegraphics{images/egcs_logo.eps}}

Mit diesem werden wir im Folgenden arbeiten. Fast alle Beispiele sind aber noch mit der Vorgängerversion 2.9x übersetzbar. Im nächsten Abschnitt finden Sie zudem eine Gegenüberstellung der Unterschiede. Außerdem werde ich Sie an den entsprechenden Stellen auf Abweichungen hinweisen. (Es sind zum Glück nicht allzu viele.)

Der genaue Umgang mit diesem Compiler wird Ihnen im Abschnitt 2.3 auf Seite [*] erläutert.

Aber der GCC ist nicht der einzige C++-Compiler, den es unter Linux gibt. Kommerziell erhältlich sind beispielsweise die Compiler der Portland Group (zu finden unter www.pgroup.com) oder von Kuck & Associates (siehe www.kai.com/C_plus_plus).

Auch den Intel-Compiler, der besonders gut für die Pentium- und Itanium-Architektur optimieren kann, gibt es für Linux. Er ist bei privater Nutzung kostenfrei, bei kommerziellem Einsatz muss eine Lizenz erworben werden. (Downloads gibt es unter http://www.intel.com/software/products/compilers/c60l/clinux.htm.) Seit der Version 6.0 ist er voll kompatibel zum GCC. Das geht so weit, dass sogar Module, die mit ihm übersetzt wurden, problemlos mit anderen GCC-kompilierten verlinkt werden können. Daraus ergibt sich eigentlich bereits, dass er auch mit dem gdb-Debugger ohne Schwierigkeiten zusammenarbeitet. Durch seine ausgefeilten Optimierungsfähigkeiten kann er für das eine oder andere Projekt durchaus interessant sein (siehe auch []). Für den Kontext dieses Buches müssen wir ihn jedoch aufgrund seiner GCC-Kompatibilität nicht gesondert behandeln.

Außerdem ist die Entwicklungsumgebung Kylix von Borland ab Version 3 auch C++-fähig. Eine freie Version (die Open Edition) kann von der Website www.borland.com/kylix heruntergeladen werden.


Neuerungen im GCC 3.0

Auch wenn Sie vielleicht an dieser Stelle noch nicht alle Details richtig einschätzen können, möchte ich Ihnen doch hier einen Überblick geben, welche Änderungen der GCC mit dem Übergang zur Version 3.0 erfahren hat. Auf den in Klammern angebenen Seiten finden Sie Erklärungen, was diese Beschränkungen beziehungsweise Neuerungen zu bedeuten haben.

Die Versionen vor 3.0, also bis 2.96, unterstützen den ANSI/ISO-Standard von C++ noch nicht vollständig. Insbesondere gab es die folgenden Einschränkungen:

Diese Probleme sind nun alle behoben. Das bedeutet allerdings auch, dass Programme, die diese Features nutzen, sich nur noch mit der neuen Version und nicht mehr mit älteren Versionen kompilieren lassen. Wollen Sie in Ihrem Code eine Abwärtskompatibilität hinsichtlich des Compilers erreichen, bleibt Ihnen also nichts anderes übrig, als bedingte Kompilierung in Abhängigkeit von der Compiler-Version einzusetzen.

Nicht nur der Standard wurde erreicht - es gibt auch noch ein paar weitere Neuerungen, die der GCC 3 zu bieten hat, zum Beispiel:

Auch wenn Sie von dieser Diskussion über Neues und Altes im GCC noch nicht viel verstanden haben - bei den Beispielen im Rahmen dieses Buches sind diese Aspekte ohnehin nicht so wichtig. Fast alles können Sie ohne große Änderungen mit beiden Compiler-Generationen übersetzen. Denn zunächst möchten Sie ja auch mehr über die Sprache lernen und weniger über den Compiler!

Das erste C++-Programm

Wenn Sie das Buch bis hierher gelesen haben, werden Sie vermutlich schon ungeduldig darauf warten, wann es denn endlich mit dem richtigen Programmieren losgeht. Daher nun gleich das erste Programm.

In den Lehrbüchern zur Programmierung hat es sich eingebürgert, jede Einführung in eine neue Sprache mit einem Programm zu beginnen, das nichts tut, außer die Worte Hello World! auszugeben. Sie nehmen es mir bestimmt nicht übel, wenn ich uns diesen Schritt erspare, und werfen mit mir einen Blick auf das folgende Programm:


1:  /* Das erste Programm: 

2: Summe der Zahlen von 1 bis 10
3: */
4:
5: #include <iostream.h>
6:
7: int main(void)
8: {
9: // Variable deklarieren und initialisieren
10: int zahl;
11: zahl = 0;
12:
13: // Schleife durchlaufen
14: for (int i = 1; i <= 10; i++)
15: {
16: zahl += i;
17: cout << "Summe bis " << i << ": ";
18: cout << zahl << "\n";
19: }
20: }
21:
Haben Sie schon eine Idee, welchen Zweck es haben könnte? Zunächst natürlich soll es Ihnen ein paar lexikalische Elemente der Sprache C++ vor Augen führen. Zur Ausgabe kommen wir später ...

Das Programm ist so einfach, dass es nur einer Datei mit diesem Stück Code bedarf. Auf eine Header-Datei, wie auf Seite [*] beschrieben, können wir also verzichten.

Sie finden übrigens alle in diesem Buch vorgestellen Beispielprogramme auch auf beiliegender CD-ROM, im Unterverzeichnis des jeweiligen Kapitels. Das soll Sie aber nicht davon abhalten, auch mal das eine oder andere Beispiel selbst einzutippen. Denn meistens macht man dabei einen Tippfehler und lernt damit gleich, wie der Compiler auf solche Fehler reagiert.

Kommentare

Gleich die ersten drei Zeilen sind ein Kommentar. Darunter versteht man einen Text, eine Anmerkung, die vom Compiler ignoriert wird. Sie dient lediglich dazu, den Programmtext für einen menschlichen Leser verständlicher zu machen. Bei einem so kleinen Programm mag Ihnen das nicht besonders wichtig vorkommen. Bei größeren Programmen ist eine gute Kommentierung aber oft entscheidend. Wenn Sie etwa Ihr Programm ein paar Monate beiseite legen und dann noch genau wissen wollen, was es anstellt, sind gute Kommentare eine große Hilfe. Dasselbe gilt, wenn Sie Programme verstehen wollen, die andere geschrieben haben.

Viele Programmierer empfinden das Kommentieren als lästige Pflicht - und genauso sehen ihre Kommentare dann auch aus. Dabei kann ich Ihnen nur raten, möglichst viel zu kommentieren, auch Dinge, die Ihnen momentan selbstverständlich erscheinen; vielleicht sehen andere das nicht so. Als Faustregel kann man sagen: Ein gut kommentiertes Programm verfügt über mindestens ebenso viele Programm- wie Kommentarzeilen.

In C++ gibt es zwei Möglichkeiten, um Kommentare zu deklarieren:

  1. Mit zwei Schrägstrichen //, dann gehören alle Zeichen bis zum Zeilenende zum Kommentar. In der nächsten Zeile geht es dann wieder mit Programmcode weiter. Diese Möglichkeit eignet sich eher für einzeilige Kommentare sowie Anmerkungen hinter Befehlen.
  2. Mit Schrägstrich und Stern, dann sind alle Zeichen von /* bis */ Teil des Kommentars, egal ob sie in einer oder in mehreren Zeilen liegen. Auf diese Weise können Sie längere Bemerkungen in den Quelltext einbauen.
Eine weitere nützliche Anwendung von Kommentaren ist, einzelne Anweisungen auszukommentieren. Wenn Sie etwa testen wollen, was Ihr Programm ohne eine bestimmte Anweisung macht, setzen Sie einfach die beiden Schrägstriche davor und schon ignoriert der Compiler diesen Befehl.


Variablen

In fast jedem Programm müssen Sie mit irgendwelchen Daten umgehen. Dazu brauchen Sie Speicherstellen im Rechner, in denen Sie diese Daten ablegen. Eine solche Speicherstelle bezeichnet man als Variable, eben weil sie ihren Wert (das heißt ihren Inhalt) im Verlauf des Programms ändern kann. In C++ unterscheidet man die Variablen nach ihrem Typ, also ob sie Zahlen oder Wörter oder etwas anderes aufnehmen können. Zahlen sind dabei sehr wichtige Typen; es gibt für ganze Zahlen und für Dezimalbrüche mehrere Typen - je nach Zahlenbereich, der sich mit ihnen darstellen lassen soll. Doch dazu später mehr (auf Seite [*]).

Jede Variable hat einen Namen. In unserem Beispiel gibt es zwei Variablen, nämlich zahl und i. Bevor Sie eine Variable verwenden können, müssen Sie sie zunächst deklarieren. Das heißt, Sie schreiben etwa (wie in Zeile 10)

int zahl;

und geben damit an, dass Sie eine Variable mit dem Namen zahl vom Typ int (wie Integer, also Ganzzahl) im folgenden Programm verwenden wollen. Durch diese Anweisung reserviert der Compiler den nötigen Speicherplatz und merkt sich, dass die Variable dieses Namens mit diesem Speicherplatz verbunden ist. Sie können auch mehrere Variablen desselben Typs zusammen deklarieren, indem Sie sie durch Kommas getrennt aneinander setzen. Zudem ist es erlaubt, der Variable gleich bei der Deklaration einen Wert zuzuweisen, sie also zu initialisieren.

Bei der Namensgebung für Variablen sind Sie ziemlich frei, wenngleich Sie ein paar Regeln beachten müssen: Der Name muss mit einem Buchstaben oder einem Unterstrich (_) beginnen und darf dann aus Groß- und Kleinbuchstaben sowie Ziffern und Unterstrichen bestehen. Nicht erlaubt sind Leerzeichen und Sonderzeichen. Die maximale Länge ist 250 Zeichen, aber mehr als fünfzehn bis zwanzig dürfte bereits ziemlich unpraktisch sein. Bedenken Sie auch, dass C++ zwischen Groß- und Kleinschreibung unterscheidet; Sie müssen also alle Namen genau so verwenden, wie Sie sie deklariert haben.

Eine Unart hat C++ von C geerbt: Variablen werden bei der Deklaration nicht initialisiert. Das bedeutet, sie haben nach der Deklaration keinen definierten Wert, sondern können alle möglichen Werte aufweisen. Das ist besonders dann wichtig, wenn Sie mit dieser Variable gleich weiterarbeiten und sie etwa erhöhen, wie in Zeile 16. Dort wird nämlich zum aktuellen Wert eine Zahl addiert. Ist bereits der aktuelle Wert undefiniert (was er in diesem Beispiel aber dank der Initialisierung in Zeile 11 nicht ist), ist es das Ergebnis genauso. Daher sollten Sie jede Variable gleich nach der Deklaration mit einem sinnvollen Wert initialisieren, um solchen unangenehmen Effekten vorzubeugen. Wenn Sie allerdings Glück haben, ist bereits der Compiler so intelligent und belegt die Werte der Variablen vor. Der GCC setzt in diesem Fall die Variable auf 0. Ein solches Wohlverhalten dürfen Sie aber nicht überall erwarten.

Operatoren

Eine Zuweisung an eine Variable geschieht einfach mit dem =-Operator, wie Sie in Zeile 11 sehen. Sie können natürlich auch einer Variablen den Wert einer anderen zuweisen oder ein Rechenergebnis, wie in Zeile 16. Dort wird sogar für das Rechnen und das Zuweisen ein einziger Operator verwendet, das +=. Dieser Befehl bedeutet dasselbe wie

zahl = zahl + i;

Von dieser Sorte Operatoren gibt es in C++ noch einige weitere; daher komme ich gleich (auf Seite [*]) darauf zurück.

Eine besondere Art von Operatoren wird für die Ein- und Ausgabe verwendet. In obigem Beispiel konnten Sie in Zeile 17 etwa lesen:

cout << ``Summe bis `` << i << ``: ``;

Der «-Operator bedeutet dabei nichts weiter, als dass alles, was rechts von ihm steht, nach links weitergereicht wird. Dieses Weiterreichen endet am cout, das die Ausgabe auf dem Bildschirm (zum Beispiel im Shell-Fenster) repräsentiert. Mit dieser Zeile geben wir also zwei Texte mit einer Zahl in der Mitte aus.

Strukturierungselemente

Und noch etwas sehen Sie an diesem Beispiel: Jede Anweisung in C++ endet mit einem Semikolon. Eine Ausnahme bilden dabei Kontrollstrukturen wie die for-Schleife. Nach diesen folgt entweder eine einzelne Anweisung oder ein Block. Solche Blöcke kennzeichnet man in C++ mit geschweiften Klammern { und }. Jede Funktion besteht beispielsweise aus einem Block. Selbstverständlich kann man auch mehrere Blöcke ineinander verschachteln.

Um dabei den Überblick zu bewahren, empfehle ich Ihnen, jede geschweifte Klammer in eine eigene Zeile zu setzen. Das ist nämlich noch eine Eigenart von C++: So gut wie alle Leerzeilen und Zeilenumbrüche werden vom Compiler ignoriert. Ob Sie also zum Beispiel bei einer Variablendeklaration zwischen dem Typ und dem Variablennamen ein oder zehn Leerzeichen einfügen, spielt überhaupt keine Rolle. Die Freiheiten, die Sie folglich bei der Gestaltung des Quelltextes haben, sollten Sie dazu nutzen, den Programmtext möglichst lesbar (für Sie und andere!) zu schreiben. Ein paar Grundregeln dabei sind:

Da aber die Gestaltung des Programmtextes letztlich Geschmackssache ist, gehen die Meinungen naturgemäß auseinander, was nun die beste Art des Schreibens sei. In einem späteren Abschnitt (ab Seite [*]) werde ich Ihnen noch ein paar Konventionen vorschlagen, wie Sie Ihre Programme schreiben können. Wie es dann tatsächlich tun, überlasse ich aber Ihnen. Die Hauptsache ist, Sie (und eventuell Ihr Team) schreiben alles nach einheitlichen Regeln. Dann ist im Allgemeinen schon ein wichtiger Schritt getan, dass das Programm lesbar und damit wartbar bleibt.


Präprozessor-Anweisungen

Eine weitere typische Eigenart der C-Kompilierung ist die Verwendung eines Präprozessors. Meistens merken Sie seine Arbeit gar nicht. Vor dem eigentlichen Compilerlauf wird aber immer noch eine Vorverarbeitung gemacht. Der Präprozessor bereitet den Code für den Compiler auf, indem er etwa die angegebenen Header-Dateien einfügt oder Makros expandiert. (Makros sind so etwas wie Abkürzungen von ein paar Anweisungen.) Er kann sogar über Bedingungen gesteuert werden, so dass der Compiler dann nur bestimmte Teile des Quelltextes übersetzt.

Anweisungen für den Präprozessor erkennen Sie immer am Doppelkreuzzeichen #. Für den Anfang müssen Sie lediglich den Befehl #include kennen (verwendet in Zeile 5). Damit geben Sie an, dass Sie die Header-Datei verwenden möchten, deren Namen Sie in spitzen Klammern (< und >) oder in Anführungzeichen ``...`` dahinter gesetzt haben. Der Unterschied dabei ist: Steht der Dateiname in normalen Anführungzeichen, so wird die Header-Datei auch im aktuellen Verzeichnis gesucht. Verwendet man die spitzen Klammern, so beschränkt sich die Suche auf die Verzeichnisse für die System-Header und eigens angegebene Verzeichnisse (über den Aufrufparameter -I für den Compiler, siehe Seite [*]).

Die Hauptfunktion

Jedes Programm muss irgendwo beginnen. In C++ liegt dieser Anfang immer in einer Funktion namens main(). Auch wenn davor noch ganz andere Funktionen stehen, fängt die Ausführung immer in dieser Funktion an. Folglich erwartet der Compiler auch, dass jedes ausführbare Programm über eine Funktion dieses Namens verfügt.


Datentypen und Typumwandlung

Mit der Festlegung des Datentyps bestimmen Sie die Art, wie der Compiler die Informationen interpretiert und wie er sie im Speicher ablegt. Auch die Art der Operationen, die mit dieser Variablen erlaubt sind, hängen von ihrem Datentyp ab. Wie oben (Seite [*]) erklärt, erfolgt die Festlegung bei der Deklaration der Variablen, also zum Beispiel:



int zahl; //Variable als Integer deklariert
char x, y, z; //Drei Variablen als einfache Zeichen
float d = 3.14; //Fließkommazahl gleich initialisiert



C++ hat zum einen eine Reihe von eingebauten Typen (so genannten Standard- oder elementaren Datentypen), bietet aber zum anderen auch die Möglichkeit, eigene Datentypen darauf aufbauend zu definieren, unter anderem Klassen.

Die elementaren Datentypen

Diese unterscheidet man nach ihrer Verwendung:

Aus den Beispielen erkennen Sie auch, wie man literale Konstanten angibt (also solche, deren Wert unmittelbar im Programmtext steht). Merken Sie sich dabei folgende Regeln:

  1. Ganze Zahlen (also ohne Dezimalpunkt dahinter) werden als int interpretiert. Wenn Sie eine lange Ganzzahl angeben wollen, müssen Sie das L dahinter setzen.
  2. Ebenso wird jede Gleitkommazahl, also solche, die einen Dezimalpunkt enthalten, automatisch als double angesehen. Wenn Sie nur die einfache Genauigkeit ausdrücken möchten, bedarf es eines f dahinter.
  3. Wenn Sie wollen, können Sie ganze Zahlen auch im Hexadezimalformat angeben. Dazu müssen Sie dem Wert ein 0x voranstellen, also beispielsweise int p = 0x1AF7.
  4. Für einzelne Zeichen verwendet man das einfache Hochkomma, also z.B. ´a´. Diese Konstante kann man dann einer Variablen vom Typ char zuweisen.


Implizite Typumwandlung

Meistens kann man Variablen und Konstanten verschiedener Datentypen einander zuweisen. C++ ist in der Lage, die dabei nötigen Umwandlungen automatisch vorzunehmen. Als Programmierer sollten Sie dennoch die dabei angewandten Prinzipien kennen, um beurteilen zu können, ob die automatische Umwandlung auch die ist, die Sie wollen.

Völlig unproblematisch ist die Umwandlung von einem Datentyp mit einem kleineren auf einen mit einem größeren Wertebereich, also beispielsweise von short auf int. Vorsicht ist beim umgekehrten Fall geboten. Hierbei können Informationen verloren gehen und sogar völlig andere Ergebnisse herauskommen, als Sie vielleicht erwarten. Ein Beispiel:

short k = 222 * 222;

cout << k << endl;

Welche Ausgabe erwarten Sie? Das richtige Ergebnis wäre 49284, auf dem Bildschirm aber erscheint -16252. Das liegt daran, dass die Anzahl der Bits der Variablen zum Speichern dieser Zahl nicht ausreichen. Das Ergebnis wird zwar richtig gebildet und in die Speicherstelle geschrieben; aber die vorderen Bits fehlen. Zudem wird das höchste Bit von Ganzzahlen (sofern sie nicht als unsigned deklariert sind) stets als Vorzeichen angesehen. Wenn Sie also zum höchsten Wert 32767 noch 1 addieren, enthält Ihre Variable anschließend den Wert -32768.

Wenn noch Konstanten im Quelltext stehen, kann man von Hand nachrechnen und aufmerksam werden. Wenn aber andere Variablen beteiligt sind, wird das schon schwieriger, zum Beispiel:

short k; 

long l = 222L;

k= l*l;

cout << k << endl;

Sie sollten also immer darauf achten, dass Sie bei Zuweisungen (oder Übergaben an Unterprogramme und so weiter) keinen Informationsverlust und damit eine eventuell unerwünschte Typumwandlung riskieren.

Bei der gegenseitigen Zuweisung von ganzen und Dezimalzahlen sind nicht so viele Fallstricke ausgelegt. Sie sollten lediglich daran denken, dass der gesamte Teil nach dem Komma abgetrennt wird, wenn Sie als Ergebnis eine Ganzzahl angeben. Beispielsweise liefert

int i = 10.0/8;

als Resultat 1.

C++-Compiler haben zwar eine gewisse Flexibilität bei der impliziten Typumwandlung, sind aber meist viel genauer als C-Compiler. Das liegt daran, dass C++ eine streng typisierte Sprache ist, wie man sagt. Jede Variable muss über einen eindeutigen Typ verfügen, und bei Zuweisungen und Vergleichen müssen die Variablen beziehungsweise Konstanten als kompatibel bekannt sein, sonst wird eine Fehlermeldung ausgegeben. Überhaupt weist Sie der Compiler mit Hilfe von Warnungen oft auf mögliche Fehler oder Mehrdeutigkeiten hin, etwa wenn er annimmt, dass Sie eine Anweisung anders gemeint haben, als sie nun im Quelltext steht. Lesen Sie diese Meldungen aufmerksam und versuchen Sie stets die Ursachen dafür zu verstehen. Auf diese Weise werden Sie auch viel über die richtige Programmierung lernen.


Explizite Typumwandlung

Anstatt sich auf die automatische Typkonvertierung zu verlassen, kann es auch manchmal sinnvoll sein, dem Compiler eine Regel zur Umwandlung vorzuschreiben. So können Sie Missverständnissen vorbeugen, die sonst in Form von Warnungen zu Tage treten würden.

Für die explizite Typumwandlung verwenden die C-Programmierer einen sehr bildlichen Ausdruck, nämlich cast, das englische Wort für Gipsverband. Um einer Variablen einen solchen anzulegen, haben Sie in C++ verschiedene Möglichkeiten. Die C-Variante ist, den Typ in runden Klammern vor die Variable zu schreiben, und zwar dort, wo an ihrer Stelle der umgewandelte Wert stehen soll, zum Beispiel

float f = 1.5;

int i = (int)f;

Bei der Umwandlung von float nach int werden alle Nachkommastellen abgeschnitten; eine Rundung findet nicht statt.

Die andere Möglichkeit ist, die Variable in Klammern zu setzen und den Typ davor:

float f = 1.5;

int i = int(f);

(Wer ganz sicher gehen will, kann sogar beide Varianten gleichzeitig verwenden ...)

Im neuen C++-Standard gibt es noch ein paar ausgefeiltere Methoden zur expliziten Typkonvertierung. Da Sie aber erst noch einige weitere Begriffe kennen müssen, um diese zu verstehen, verschieben wir die Diskussion auf später (Seite [*]).


Aufzählungstypen

Nicht immer sind numerische Werte für eine Variable sinnvoll. Manchmal will man für sie nur einen begrenzten Wertebereich zulassen. Das kann beispielsweise die Unterscheidung zwischen Werktag, Sonntag und Feiertag sein, aber auch ein Fehlerstatus wie Erfolg, Information, Warnung, Fehler und Abbruch. Natürlich können Sie diese Werte durch einen ganzzahligen Datentyp im Programm repräsentieren, etwa

unsigned short status; 

wobei den Erfolgsfall, 1 die Information, 2 die Warnung, 3 den Fehler und 4 den Abbruch darstellen. Dann können Sie bei Zuweisungen oder Vergleichen aber nicht sofort erkennen, welche Art von Status gemeint ist. Außerdem wäre es zulässig, dass Sie der Variablen zum Beispiel den Wert 10 zuweisen, was außerhalb des Wertebereichs 0 bis 4 liegt und sich nicht mehr sinnvoll interpretieren lässt.

Hier sollten Sie einen Aufzählungstyp verwenden. Sie deklarieren ihn mit dem Schlüsselwort enum, einem Typnamen und einer Liste von Werten, eingeschlossen in geschweiften Klammern und getrennt durch Kommas. Für unseren Status können wir schreiben:

enum Status { SUCCESS, INFO, WARNING, 

              ERROR, FATAL};

(Die Schreibweise für die Werte ist zwar beliebig; ich empfehle Ihnen aber die Konvention, Konstanten mit Großbuchstaben zu schreiben, um sie leichter von Variablen unterscheiden zu können.)

Damit können Sie nun Variablen definieren

Status error_code;

und gegebenfalls auch gleich initialisieren:

Status rueckgabe = SUCCESS;

Es ist auch zulässig, gleich bei der Deklaration eines Aufzählungstyps Variablen davon zu definieren, etwa:

enum Abrechnungstag 

  { WERKTAG, SONNTAG, FEIERTAG} tariftag;

Wenn Sie den Aufzählungstyp nur bei einer Variablendefinition brauchen und sonst nicht mehr, können Sie sogar den Typnamen weglassen und ihn anonym deklarieren, zum Beispiel:

enum { EINSATZFAEHIG, DEFEKT, 

       AUSGEMUSTERT} zustand;

Obwohl Sie damit neue Datentypen deklarieren, werden Aufzählungstypen intern als natürliche Zahlen verwaltet. Dabei erhält die erste Konstante immer den Wert , die folgenden jeweils einen um 1 erhöhten Wert. Dies zeigt, dass Sie Werte eines Aufzählungstyps auch nach int umwandeln können - allerdings nicht umgekehrt.


Operatoren

Variablen alleine nützen noch nicht viel. Man muss sie auch miteinander verknüpfen, sie zuweisen, verändern und ausgeben können. Dazu dienen die Operatoren. Fast alle Sonderzeichen, die die Tastatur hergibt, haben in C++ eine Bedeutung als Operatoren. Neben den einfachen mathematischen wie + und * gibt es auch sehr ungewöhnliche wie «=, mit denen viele Programmierer nie in Berührung kommen. Einige erinnern noch daran, dass C einmal als besonders hardwarenahe Sprache entworfen wurde.

Überblick

Auf jeden einzeln einzugehen, würde an dieser Stelle zu weit führen. Ich will Ihnen daher in Tabelle 2.1Tab:Operatoren eine Liste mit den wichtigsten Operatoren geben. Wenn Ihnen Verschiedene nicht gleich klar sind, müssen Sie sich deshalb keine Sorgen machen: Wenn Sie etwas Erfahrung in der C++-Programmierung gewonnen haben, werden Sie auch alle Operatoren verstehen; auf einige werde ich im Folgenden auch noch genauer eingehen.


0pt



Table: Die wichtigsten Operatoren in C++ nach absteigender Priorität




Operatoren werden zu so vielen Zwecken eingesetzt, dass teilweise schon die Zeichen auf der Tastatur nicht auszureichen scheinen. Tatsächlich gibt es ein paar Operatoren, deren Bedeutung vom Kontext abhängt, in dem sie verwendet werden. Die größte Bandbreite hat dabei das Kaufmanns-Und &. Es dient als Referenzierungsoperator, wenn kein Argument davor kommt - sonst ist es eine bitweise AND-Verknüpfung. Außerdem macht man dabei aus einer Variablendeklaration eine Referenzdeklaration. Mit der Zeit werden Sie deutlich sicherer im Lesen von C++-Programmen werden, so dass Ihnen diese Unterschiede irgendwann offensichtlich erscheinen. Für den Augenblick ist es hilfreich, wenn Sie die Quelltexte sehr konzentriert durchsehen und sich bei jeder Anweisung nach deren Bedeutung fragen.


Prioritäten

Schon bei den Grundrechenarten kann es ein wesentlicher Unterschied sein, welche Rechnung man bei größeren Termen zuerst ausführt. Dort regelt beispielsweise der Grundsatz Punkt vor Strich, dass zunächst multipliziert und dann erst addiert wird. Eine solche Reihenfolge der Abarbeitung der Operatoren braucht auch jede Programmiersprache, sonst müssten endlos viele Klammern gesetzt werden, die den Programmtext äußerst unübersichtlich werden ließen.

In Tabelle 2.1 sind die Operatoren bereits nach ihren Prioriäten sortiert, das heißt, dass zunächst die primären Operatoren ausgewertet werden, dann die unären, anschließend die binären und am Ende die Zuweisungsoperatoren. Auch innerhalb jeder Gruppe kann es noch Reihenfolgen geben. So hat die Multiplikation erwartungsgemäß Vorrang vor der Addition, aber auch die Subtraktion vor dem Vergleich.


Der Inkrementoperator

Eine Sonderstellung nehmen die Operatoren für Inkrementierung ++ und Dekrementierung - ein. Sie können nämlich sowohl vor als auch hinter der Variablen stehen, auf die sie wirken. Man nennt diese beiden Stellungen auch Präfix und Postfix.

Die Operatoren werden meist nicht als einziger Bestandteil einer Anweisung verwendet, sondern in kompliziertere Ausdrücke eingebaut. Die Regel ist: Steht der Operator vor der Variablen, wird er zuerst ausgeführt und dann erst der Ausdruck ausgewertet. Steht er dagegen dahinter, wird erst der gesamte Ausdruck ausgewertet und dann der Operator angewandt. Zum Beispiel:

k = (i+2) * j++;

Hier wird k noch mit dem aktuellen Wert von j berechnet und erst nach der Zuweisung wird j um 1 erhöht.

Anfänger empfinden diese Schreibweise stets als verwirrend; die erfahrenen Programmierer sehen sie dagegen als besonders elegant und kompakt an. Überlegen (oder probieren) Sie mal selbst, welche Ausgabe der folgende Code erzeugt:

unsigned int i = 2;

cout << i++ << `` `` << i << `` `` 

     << ++i << `` `` << i << endl;

Unbedingt vermeiden sollten Sie, eine Variable, die in einem Ausdruck mehrfach vorkommt, auch mit mehreren Inkrementoperatoren zu versehen. Das Ergebnis kann dann schnell unvorhersagbar werden. (Um kein irreführendes Vorbild zu geben, spare ich mir das Beispiel zu diesem Fall. Der Code im letzten Abschnitt ist bereits schlimm genug!)


Der Bedingungsoperator

Einer der Operatoren, die in Tabelle 2.1 fehlen, ist ?: für die Bedingung. Er hat folgende allgemeine Form:

Bedingung ? Ausdruck1 : Ausdruck2
Die Verwendung ist ganz einfach: Ist die Bedingung wahr, wird Ausdruck1 ausgewertet, ist sie falsch, dann Ausdruck2. Zum Beispiel:

c = (a!=5) ? 25 : 50;

Falls der Wert der Variablen a ein anderer als 5 ist, bekommt c den Wert 25, ansonsten wird c auf 50 gesetzt.


Die Maximum- und Minimumoperatoren

Zwei weitere Operatoren sind aus gutem Grund nicht in Tabelle 2.1 aufgelistet: die Maximum- und Minimumoperatoren. Sie gibt es nämlich nur beim GCC. Wenn Sie diese also einsetzen, müssen Sie sich bewusst sein, dass andere Compiler mit diesen Operatoren nichts anfangen können und eine Fehlermeldung ausgeben. Ihr Code wird sich dann nur noch mit dem GCC übersetzen lassen!

Wenn Sie also in kompakter Schreibweise ein Maximum oder Minimum ermitteln wollen, geht das ganz leicht:

int c = a >? b;

weist c das Maximum von a und b zu. Ebenso ergibt

int d = a <? b;

das Minimum von a und b. Die Operatoren arbeiten nur auf numerischen Datentypen.

Ausdrücke

Gerade war schon von Ausdrücken die Rede. Was ist eigentlich ein Ausdruck? Dem Begriff des Ausdrucks (engl. expression) entspricht etwa der des Terms in der Mathematik, also eine Zusammensetzung von Konstanten, Variablen und Operatoren. Im Programm wird ein Ausdruck ausgewertet, das heißt, die durch die Operatoren bestimmten Verknüpfungen und Modifikationen werden berechnet; das Ergebnis dieser Auswertung heißt dann Wert des Ausdrucks.

In C++ hat dieser Wert immer auch einen wohlbestimmten Typ. Dieser hängt von den beteiligten Variablen und Operatoren ab. Einfache Beispiele sind:

 

 


Ein Ausdruck besteht oft aus mehreren verschachtelten Unterausdrücken, die mit Operatoren verknüpft sind, etwa

y = (-s1 * a - (b1 / 2.0)) / (b2 * (l - l0));

Die Prioritäten, nach denen ein Ausdruck ausgewertet wird, richten sich nach denen der beteiligten Operatoren (siehe Seite [*]). So wird etwa bei

a + b * c

zunächst b * c berechnet und das Resultat dann zu a addiert.

Der Typ, den der Wert eines arithmetischen Ausdrucks erhält, wird durch den höchstwertigen Teilausdruck bestimmt, also durch den Typ mit dem größten Informationsumfang. Betrachten wir beispielsweise folgende Variablen:

int    i;

short  s;

char   c;

double d;

Dann erhalten die Werte der Verknüpfungen dieser Variablen folgende Typen:

i * s    // Typ int

c + i    // Typ int

s * d    // Typ double

Sie können dieser automatischen Typfestlegung durch explizite Typumwandlung zuvorkommen (siehe Seite [*]). Das kann unter anderem dann nützlich sein, wenn das Resultat sonst durch Rundungen verfälscht würde, zum Beispiel

i / (double) s  // Typ double

Zusammenfassung

Aus diesem Abschnitt sollten Sie sich Folgendes merken:

Übungsaufgaben

  1. Was passiert, wenn man einer Variablen vom Typ int eine Zahl mit Dezimalstellen zuweist?

  2. Welchen Wert hat eine Variable, nachdem sie deklariert wurde?

  3. Erklären Sie den Unterschied zwischen expliziter und impliziter Typkonvertierung.

  4. Die Funktion sizeof() hat den Zweck, den Speicherverbrauch eines Datentyps oder einer Variablen zu bestimmen. Welche Ausgabe liefert folgendes Programm?

    #include <iostream.h>

     

    int main() {

      cout << "short int: "

           << sizeof(short int) << endl;

      cout << "int: " << sizeof(int) << endl;

      cout << "long int: "

           << sizeof(long int) << endl;

      cout << "float: " << sizeof(float) << endl;

      cout << "double: "

           << sizeof(double) << endl;

      return 0;

    }

    Geben Sie das Programm ein und speichern Sie es in der Datei typesize.cc. Geben Sie dann in der Shell ein:

    g++ typesize.cc -o typesize

    Wenn Sie keinen Fehler gemacht haben, ist nun die ausführbare Datei typesize neu in Ihrem Verzeichnis. Starten Sie diese und vergleichen Sie deren Ausgaben mit Ihren Vorhersagen.

  5. Für eine Variable x soll ausgegeben werden, ob sie 0 ist oder nicht. Was ist hier falsch?

    cout << x = (0 ? ``null'' : ``ungleich 0'');


Umgang mit dem GNU-C++-Compiler

Der C++-Compiler des GNU-Projekts (GCC) erfreut sich aufgrund seiner weiten Verbreitung, seiner Verfügbarkeit auf vielen Plattformen und seiner Effizienz großer Beliebtheit. Dabei spielt natürlich auch eine Rolle, dass es sich dabei um freie Software handelt, der Compiler also kostenlos erhältlich ist. Unter Linux ist er der Standard-Compiler für C- und C++-Programme.

Installation

Sie können sich stets mit der neuesten Version des Compilers aus dem Internet versorgen, am besten von gcc.gnu.org.

Allerdings werden Sie dazu kaum je Anlass verspüren. In jeder Linux-Distribution ist nämlich dieser Compiler enthalten - schon allein deshalb, weil er zum Kompilieren des Linux-Kernels gebraucht wird. Erscheint eine neue Version, bringen die Hersteller der Distributionen sie meist relativ schnell in die Form von Installationspaketen, die Sie dann nur noch von den Sites der Hersteller herunterladen müssen. Die eigentliche Installation auf Ihrem Rechner ist dann mit den jeweiligen grafischen Werkzeugen oder mit rpm leicht zu erledigen.

(Wenn Sie doch einmal den GCC selbst übersetzen wollen, finden Sie in der den Quellen beiliegenden Datei INSTALL alle nötigen Informationen und Anleitungen.)

Aufruf und Optionen

Die ausführbare Datei des C++-Compilers hat den Namen g++. In der einfachsten Form des Aufrufs ist der Name der Quelltextdatei als einziges Argument anzugeben, also etwa

g++ mein_programm.cc

Wenn keine Fehler auftreten, gibt er Compiler auch keinerlei Text auf den Bildschirm aus. Nach kurzer Zeit meldet sich lediglich die Eingabeaufforderung der Shell wieder. Im aktuellen Verzeichnis gibt es dann eine neue ausführbare Datei namens a.out. (Das ist seit alten C-Zeiten der Standardname für alle Programme.)

Wenn Ihr Programm aus mehreren Quelltextdateien besteht, können Sie auch mehrere hintereinander angeben. Dabei dürfen die Dateien auch in mehreren Verzeichnissen liegen, Sie müssen dazu nur jeweils den vollständigen Pfad angeben, zum Beispiel:

g++ mein_programm.cc datei2.cc ../gen/liste.cc

Der GNU-Compiler kennt eine Fülle von Optionen, mit denen sich der Übersetzungsvorgang darüber hinaus beeinflussen lässt.


Table 2.2: Die wichtigsten Optionen des GNU-C++-Compilers
   
Option Bedeutung
 

-c

-g Hinzufügen von Informationen für den Debugger
-IDIR Zusätzlicher Suchpfad DIR für Header-Dateien
-lBIBL Hinzulinken der Bibliothek libBIBL.a
-LDIR Zusätzlicher Suchpfad DIR für Bibliotheken
-o NAME Erzeugung der Ausgabedatei NAME
-O Optimierung des ausführbaren Programms auf Geschwindigkeit
-Wall Ausgabe von Warnungen bei unschönem Code

Die wichtigsten sind in Tabelle 2.2 zusammengefasst; ich werde sie Ihnen im Folgenden kurz erläutern. Die Reihenfolge, in der Sie die Optionen angeben, ist völlig egal. Sie müssen lediglich sicherstellen, dass jeweils eine Option und ihr zugehöriger Parameter zusammen stehen.


Name für die ausführbare Datei

Vermutlich sind Sie mit dem Namen a.out nicht so zufrieden. Um dies zu ändern, kennt der Compiler die Option -o NAME, mit der Sie einen alternativen Namen für die ausführbare Datei angeben können. Zum Beispiel erzeugt folgender Aufruf eine ausführbare Datei namens mein_programm:

g++ -o mein_programm mein_programm.cc

Sie \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} müssen immer auch einen Dateinamen hinter die Option -o schreiben; sonst kann es passieren, dass der Compiler Ihre Programmdatei für den gewünschten Namen hält und diese einfach überschreibt!


Debug-Informationen

Jeder Programmierer macht Fehler. Um diese aufzudecken, gibt es Debugger (vom englischen Wort bug, das eigentlich Käfer, umgangssprachlich auch Programmierfehler bedeutet; ob man in den ersten riesigen Elektronenhirnen mal tatsächlich Käfer fand, ist nicht zweifelsfrei überliefert). Debugger sind Hilfsprogramme, mit denen Sie das Programm quasi beim Ablauf beobachten können. Sie können es an bestimmten Stellen stoppen, Werte von Variablen ausgeben lassen oder es schrittweise ablaufen lassen.

Damit der Debugger die Anweisungen im ausführbaren Programm den Quelltextzeilen zuordnen kann, braucht er entsprechende Informationen. Diese werden durch die Option -g in das Programm eingefügt.

g++ -g mein_programm.cc

Im normalen Ablauf verhält sich das Programm kaum anders als ohne diese Option; es ist allerdings teilweise erheblich größer. (Die Objektdateien können bis zum Zehnfachen größer werden, die ausführbaren Dateien dagegen meist um zehn bis zwanzig Prozent, bei größeren Projekten aber auch mehr.) Erst ein Debugger kann die enthaltenen Informationen nutzen. Mehr dazu in Abschnitt [*]Sec:Debugger.


Fehler und Warnungen

Bei Syntaxfehlern (also Verletzung der Sprachregeln) stoppt der Compiler meist sofort und gibt die Datei sowie die Nummer der Codezeile aus, in der er den Fehler festgestellt hat. Wenn Sie wie in folgendem Beispiel ein Semikolon vergessen:

short s

long  l= 222L;

erhalten Sie die Meldungen (und noch ein paar mehr):

test.cc: In function `int main()':

test.cc:9: parse error before `long'

(Unter parsen versteht man das Durchgehen und Interpretieren des Quelltextes.)

Manchmal wird ein Syntaxfehler aber gar nicht als solcher erkannt, da die Anweisung in einem anderen Kontext durchaus korrekt sein könnte. Eine solche Fehlerinterpretation verwirrt den Compiler dann bei der Übersetzung der nachfolgenden Anwendungen so sehr, dass es meist Fehlermeldungen hagelt. Wenn Sie an der ersten Stelle, an der ein Problem auftrat, nichts finden können, sollten Sie daher auch die Zeilen darüber betrachten, ob sich in diesen nicht die Ursache finden lässt.

Anders verhält es sich mit Warnungen. Diese sind nicht so schwer wiegend, dass dadurch der Übersetzungsvorgang gleich eingestellt werden müsste. Andererseits handelt es sich dabei um potenzielle Probleme, die im einfachsten Fall ignoriert werden, im schwersten Fall aber zur Laufzeit einige unerwünschte Effekte hervorrufen können. So weist Sie der Compiler darauf hin, dass Sie etwas unschön geschrieben haben oder mehr als nötig, dass er etwas anders auffasst, als Sie es vermutlich gemeint haben, und so weiter.

Warnungen werden nicht standardmäßig ausgegeben, sondern Sie müssen sie mit einem Compiler-Schalter anfordern. Für fast jede Warnung gibt es eine eigene Option, jeweils von der Form -Wwarnungs-typ. Zum Glück gibt es aber auch Schalter, die ganze Gruppen von Warnungen aktivieren. Einer ist dabei besonders wichtig: Mit -Wall schalten Sie alle Warnungen ein, die auf unschönen Code hindeuten. Dabei handelt es sich beispielsweise um nicht oder unzureichend initialisierte Variablen, Typverwechslungen, missverständliche Klammerungen oder unbenutzte Variablen. Sie sollten die Warnungen ernst nehmen und solchen Code vermeiden. Wenn wir etwa schreiben:

int main()

{

  short s, p;

  int i=10.0/8;

  cout << i << endl;

  return 0;

}

so führt das zu den Warnungen:

test.cc:6: warning: initialization to `int' 

from `double'

test.cc:5: warning: unused variable `short int s'

Sie sehen daran auch, dass Sie der Compiler bei einem Durchlauf nicht auf alles gleichzeitig hinweist. Hier ist beispielsweise die Variable p genauso unbenutzt wie s, wird jedoch nicht angemahnt. Erst wenn Sie s entfernen würden, käme die Warnung für p.

Die Schreibweise -Wall steht zwar für warnings, all, ist aber nicht zufällig auch als wall, also Wand, zu lesen. Sie können sich dabei eine Schutzmauer um Ihren Code vorstellen; wenn er sie überwindet, ist er schon mal in ganz guter Verfassung. Daher empfehle ich Ihnen, diesen Schalter immer zu verwenden und dabei zu versuchen, alle daraus resultierenden Warnungen zu vermeiden.


Kompilierung zur Objektdatei

Wenn Sie Programme schreiben, die aus mehreren Dateien bestehen, so geben Sie diese üblicherweise nicht alle auf einmal beim Aufruf des Compilers an. Wenn Sie nämlich in einer davon einen Fehler gefunden haben, diesen korrigieren und die Übersetzung neu starten, werden auch alle davor stehenden nochmals kompiliert. Der GGC, genauer das Kommando g++, kann mit zwei verschiedenen Typen von Eingabedateien aufgerufen werden: mit C++-Dateien, das heißt Quelltext, und mit Objektdateien. Bei Programmen mit mehr als einer Quelldatei darf man die einzelnen Quellen nur bis zum Objektcode übersetzen, da sonst die Abhängigkeiten von den anderen Dateien nicht aufgelöst werden könnten. Dazu bedarf es für jede Datei eines eigenen Compiler-Aufrufs. Sind alle Dateien übersetzt, können sie mit einem weiteren Aufruf zusammengelinkt werden.

Um den Compiler anzuweisen, beim Objektcode aufzuhören und das Linken zu einem ausführbaren Programm gar nicht erst zu versuchen, verwenden Sie die Option -c. Besteht der Quelltext Ihres Programms beispielsweise aus den Dateien datei1.cc und datei2.cc, so können Sie den Compiler folgendermaßen aufrufen:

g++ -c datei1.cc

g++ -c datei2.cc

g++ -o prog datei1.o datei2.o

Auf diese Weise haben Sie die eigentliche Kompilierung der Quelltexte entkoppelt und trotzdem aus diesen die ausführbare Datei prog erzeugt.


Pfade zu Header-Dateien

Wir haben oben festgestellt (Seite [*]), dass Header-Dateien normalerweise in den bekannten Verzeichnissen für die System-Header gesucht werden. (Diese hatte ich Ihnen auf Seite [*] bereits vorgestellt.) Steht der Dateiname in normalen Anführungzeichen, so wird nach der Header-Datei zusätzlich im aktuellen Verzeichnis gesucht.

Gelegentlich werden Sie für die Übersetzung aber auch Header-Dateien benötigen, die weder in den Systemverzeichnissen noch im aktuellen Verzeichnis stehen. Dann können Sie zusätzlich Verzeichnisse angeben, in denen der Compiler nach Headern suchen soll. Dazu verwenden Sie die Option -I, hinter der Sie ohne ein Leerzeichen dazwischen das Verzeichnis angeben. Dabei können Sie relative Pfade (etwa -I../../inc) genauso verwenden wie absolute (zum Beispiel -I/opt/kde/include) oder solche mit Umgebungsvariablen (beispielsweise -I$HOME/include).


Bibliotheken

Wenn Sie Funktionen aus Bibliotheken verwenden, benötigen Sie zum Übersetzen deren Header-Datei, damit der Compiler den Namen und die Typen der Funktionsargumente sowie des Rückgabewertes und damit die Gültigkeit Ihres Aufrufs überprüfen kann. Die eigentliche Implementierung dieser Funktion haben Sie dabei noch völlig außer Acht gelassen. Der Linker, der die einzelnen Objektdateien zu einem ausführbaren Programm zusammenfügen soll, verbindet die Aufrufe mit den eigentlichen Funktionen auf Ebene der Maschinensprache. Damit er die Bibliotheksfunktion dabei findet, müssen Sie beim Linken noch die entsprechende Bibliothek dazubinden. Dazu haben Sie zwei Möglichkeiten:

  1. Sie geben die Bibliotheksdatei in gleicher Weise wie die anderen Objektdateien an, also etwa

    g++ -o prog datei1.o datei2.o meine_bib.a

  2. Sie verwenden die Option -l. Ebenso wie bei den Header-Dateien durchsucht der Linker nämlich eine Liste von Systemverzeichnissen nach der so angegebenen Bibliothek. Wenn Sie die Datei libBibl.a einbinden möchten, verwenden Sie die Option -lBibl. Das bedeutet, Sie lassen bei dieser Option den Namensbeginn lib ebenso weg wie die Endung .a.

    Wenn Sie noch andere Verzeichnisse in die Suche einschließen wollen, können Sie dies mit der Option -L tun. Diese arbeitet ganz analog zu der für Include-Dateien. Die Option -L wirkt dabei indes nur auf die -l-Befehle, die Sie danach angeben. Somit sollten Sie stets zunächst die Suchpfade und dann die Binde-Kommandos schreiben.

Bibliotheken \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} sind eigentlich nur Archive, die aus einzelnen Objektdateien zusammengesetzt sind. Der Linker sucht sich dabei alle Symbole (Konstanten, globale Variablen, Klassen, Funktionen usw.) heraus, die im restlichen Programm zwar verwendet, aber nicht definiert werden. Auch dabei ist die Reihenfolge entscheidend. Wenn Sie eingeben:

g++ datei1.o -lbasis datei2.o

so wird die Bibliothek libbasis.a noch vor dem Objekt-File datei2.o durchsucht. Der Linker ist dabei so intelligent, dass er nur die Teile der Bibliothek zur ausführbaren Datei hinzufügt, die auch tatsächlich verwendet wurden. Mit verwendet ist dabei gemeint, dass eine der Objektdateien, die vor der Bibliothek vom Linker verarbeitet wurde, einen Aufruf einer Bibliotheksfunktion enthält. Befindet sich jedoch in datei2.o auch ein Aufruf einer anderen Funktion aus der Bibliothek, so wird dieser nicht mehr berücksichtigt. Sie erhalten folglich vom Linker die Fehlermeldung: Unreferenced symbol [...] in datei2.o.

Wie Sie dynamische Bibliotheken einbinden und eigene Bibliotheken erstellen, erfahren Sie in Abschnitt [*] ab Seite [*].


Optimierung

Normalerweise versucht der Compiler, die ihm gestellte Aufgabe, also das Übersetzen eines Programms, möglichst schnell zu erledigen. Das ist während des Entwicklungszyklus auch für den Programmierer hilfreich. Ist das Programm aber einmal fertig, soll es so schnell und effizient wie möglich seine Arbeit verrichten. Dafür schalten Sie die Optimierung ein.

Compiler-Optimierung ist ein eigenes wissenschaftliches Fachgebiet. Der GCC war lange Zeit dafür bekannt, dass er besser optimiert als viele seiner kommerziellen Konkurrenten. Unter Linux liegt er immer noch in der Spitzengruppe. In älteren Programmierbüchern können Sie oft lesen, wie man Code schreiben sollte, damit er vom Compiler optimal umgesetzt werden kann. Aufgrund der Fähigkeiten des automatischen Optimierers können Sie sich diese Feinheiten heute sparen. Natürlich sollten Sie immer noch Ihre Algorithmen möglichst ökonomisch organisieren - Sie müssen aber nicht mehr bestimmte Reihenfolgen von Anweisungen oder Befehlen beachten, um Variablen in die Prozessorregister unterzubringen. Sie müssen nicht einmal wissen, was der Optimierer eigentlich macht, denn es interessiert Sie ja nur, dass Ihr Programm möglichst schnell läuft.

Die Standardoption zum Einschalten der Optimierung ist -O. Damit fordern Sie den Compiler auf, die Größe und die Laufzeit des Programms so gering wie möglich zu halten. Zum Beispiel:

g++ -O -o mein_programm mein_programm.cc

So viel Arbeit hat allerdings seinen Preis: Mit eingeschalteter Optimierung dauert die Kompilierung länger und erfordert auch mehr Speicher.

Es gibt dazu sogar noch Steigerungen: Mit -O2 und -O3 schalten Sie noch zusätzliche Optimierungsverfahren ein. Zuweilen kann das aber des Guten zu viel werden, denn manche Programme zeigen bei zu hohem Optimierungsniveau ein unvorhersagbares Verhalten bis hin zu Abstürzen. Daher ist das einfache -O meist die Methode der Wahl.

Wenn Sie noch höhere Geschwindigkeit erreichen wollen, können Sie den Compiler anweisen, die Eigenheiten der CPU Ihres Computers bestmöglich zu nutzen. Dazu dient die Familie der Compiler-Schalter, die mit -m beginnt, also -m386 (auf Intel-Plattformen der Vorgabewert), -m486, -mpentium und -mpentiumpro (neben PentiumPro auch für Pentium II und III). Seit Version 3.0 gibt es auch eine Anpassung für AMD-Prozessoren mit -mcpu=athlon. Ein so optimiertes Programm läuft natürlich auf Rechnern mit Prozessoren der vorhergehenden Generationen nicht mehr in allen Fällen.

Hintergrund

Ein besonderes Feature, das ebenfalls neu ab der Version 3.0 ist, ist die Sprungoptimierung auf Basis von Referenzläufen. Um dies zu nutzen, gehen Sie folgendermaßen vor: Diese Technik hat jedoch enge Grenzen. Sie funktioniert dann am besten, wenn sich die Art, wie ein Programm benutzt wird, meist gleicht. Das kann zum Beispiel bei umfangreichen mathematischen Berechnungen der Fall sein. Je mehr Unterschiede es in der Ablauflogik und -reihenfolge gibt, desto geringer wird der Effekt dieser Optimierung sein.


Info-Seiten

Eine etwas längere Liste mit Optionen finden Sie auf der man-Seite zu g++. Dazu geben Sie einfach ein:

man g++

Die vollständige Aufstellung aller Compiler-Schalter steht auf den Info-Seiten zu gcc/g++. Damit ist eine Dokumentationsform aus der Frühzeit der Unix-Programmierung gemeint. Es gibt heute noch auf allen Unix-Plattformen das Werkzeug info, um sich diese Seiten anzusehen. Allerdings ist die Bedienung so gewöhnungsbedürftig, dass ich allen Anfängern nur davon abraten kann.

Eine wesentliche Vereinfachung der Bedienung können Sie schon mit xinfo erreichen. Dann erhalten Sie die Informationen wenigstens in einem X-Window und können etwas mit der Maus klicken.

Sehr viel einfacher und zeitgemäßer ist allerdings die Betrachtung mit einem Web-Browser wie Netscape. Zur Konvertierung dient ein kleines Programm namens inf2html, mit dem jede angeforderte Info-Seite sofort nach HTML umgewandelt wird. Es ist Bestandteil der SuSE-Distribution; Sie können es über deren FTP-Server beziehen (unter der Adresse ftp://ftp.suse.com/pub/suse/i386/current/suse/doc1/inf2htm.rpm) beziehungsweise über Spiegel-Server davon. Es ist jedoch nicht nur auf diese Distribution beschränkt, sondern ist ein einfaches CGI-Skript, das die Dateien in /usr/info aufbereiten kann. Auf diese Weise können Sie bequem mit der Maus zwischen den einzelnen Seiten hin- und herspringen (Abbildung 2.8Fig:Info2HTML).

Figure 2.8: Die Info-Seiten zum GCC, dargestellt im Browserfenster



\includegraphics{images/inf2html.eps}

Ebenso leicht ist das aber auch mit dem Help-Browser gnome-help-browser der Arbeitsoberfläche GNOME möglich. Dieser hat ebenfalls die Konvertierung nach HTML eingebaut. Dasselbe gilt für die Hilfe-Anwendung des K-Desktop, kurz KDE. Wenn Sie etwa die Seiten zum GCC mit kdehelp betrachten wollen, genügt der Befehl

kdehelp 'info:(gcc)' 

Ab KDE2 übernimmt der Dateimanager und Browser Konqueror diese Aufgabe. Dort geben Sie den Namen der gewünschten Info-Seite wie eine URL ein, also beispielsweise info:/gcc.

Eine weitere komfortable Möglichkeit zum Betrachten der Infoseiten bietet der XEmacs-Editor; lesen Sie dazu Seite [*].

Die Eingangsseite zeigt Ihnen eine Liste aller Programmpakete, zu denen Dokumentation im Info-Format vorhanden ist. Dort müssen Sie nur nach dem Stichwort gcc suchen und dieses anklicken. Dann sollten Sie die in Abbildung 2.8 dargestellte Seite vor sich sehen. Hinter dem Unterpunkt INVOKING GCC finden Sie beispielsweise die Compiler-Schalter.


Editieren mit NEdit und anderen

Vielleicht hatten Sie während des Lesens ab und zu schon den Wunsch, auch ein kleines Programm zu schreiben und zu übersetzen. Ich habe schon viel über Programmierung erzählt, aber noch nichts darüber, wie man den Quelltext eigentlich eintippt. Der Grund dafür ist, dass es unter Linux zwar den C++-Compiler gibt, aber nicht einen vorherrschenden Editor (auch wenn manche den Emacs dafür halten). Es gibt eine große Bandbreite an Programmen, mit denen Sie einen Text, aber auch speziell einen Programmtext bearbeiten können. Entsprechend vielseitig ist die Palette der Bedienphilosophien. Viele Editoren sind meiner Erfahrung nach für Anfänger ungeeignet, da dabei sehr eigenwillige Tastenkombinationen nötig sind. Und da ich davon ausgehe, dass Sie vor Ihrer Beschäftigung mit Linux schon einige Erfahrungen mit Windows oder MacOS gemacht haben, möchte ich Sie lieber auf Werkzeuge hinweisen, deren Handhabung Ihnen etwas vertrauter vorkommen müsste. Es macht doch keinen Sinn, wenn Sie sich nicht nur in eine neue Programmiersprache, sondern gleichzeitig auch in einen ungewohnten Texteditor einarbeiten müssten, oder?

Später (ab Seite [*]) will ich Ihnen noch einige Editoren im Detail vorstellen sowie verschiedene integrierte Entwicklungsumgebungen besprechen. Damit nicht alles aus dem Zusammenhang gerissen wird, Sie aber jetzt schon Ihre ersten Programme schreiben können, möchte ich an dieser Stelle einen Editor herausgreifen und Ihnen ein paar Tipps zur Arbeit mit NEdit geben.

Bei NEdit (dem Nirvana-Editor, ein Begriff, vom dem Sie sich aber nicht abschrecken lassen sollten) handelt es sich um einen Texteditor mit Fensteroberfläche für Textdateien und Programmtexte (Abbildung 2.9 auf Seite [*]). Er lehnt sich insbesondere an die unter Windows und MacOS verwendeten Konventionen an und bietet zum Beispiel Menüs, Dialogfenster, Bearbeitungsmöglichkeiten mit der Maus sowie die gewohnten Shortcuts.

Viele Distributionen beinhalten bereits NEdit. Natürlich können Sie sich auch mit der neuesten Version aus dem Internet versorgen; dort gibt es sogar schon vorkompilierte Versionen. Sehen Sie dazu am besten auf der Homepage www.nedit.org nach. Auch auf beiliegender CD-ROM finden Sie dieses Programm.

Die Bedienung erfolgt in gewohnter Weise. Ein sehr hilfreiches Feature ist das Syntax-Highlighting, das die verschiedenen Sprachelemente wie Schlüsselwörter, Präprozessordirektiven oder Kommentare in Farbe und Schrifttyp voneinander abhebt. Um es einzuschalten, wählen Sie den Menüpunkt PREFERENCES $\vert$ HIGHLIGHT SYNTAX. Damit es bei jedem Start des Programms auch wieder aktiviert ist, müssen Sie es mittels PREFERENCES $\vert$ DEFAULT SETTINGS $\vert$ SYNTAX HIGHLIGHTING $\vert$ ON einschalten und dann die Voreinstellungen abspeichern.

Figure 2.9: NEdit hebt die Syntaxelemente des Programms hervor.



\includegraphics{images/nedit.eps}

Das Programm erkennt an der Dateierweiterung .cc, dass es sich um einen C++-Quelltext handelt. Entsprechend sind Einrückungen und Ähnliches automatisch eingestellt, so dass Sie immer einen Block auch in derselben Spalte beginnen können. Wenn Sie an die Stelle hinter einer Klammer kommen, wird die zugehörige Klammer kurz durch ein rotes Quadrat markiert.

Ausschneiden, Kopieren und Einfügen können Sie sowohl über das EDIT-Menü als auch mit den Tastenkombinationen Strg+X, Strg+C beziehungsweise Strg+V erledigen. Mit dem Menü SEARCH können Sie nach Textstellen suchen. Und das Speichern geht mittels Strg+S mit links.

NEdit verfügt über eine Online-Hilfe mit allen wichtigen Benutzungsmerkmalen und eine ausführliche Dokumentation im HTML-Format. Wenn Sie also einmal einen Sachverhalt nicht als selbsterklärend erachten, finden Sie dort sicherlich Rat.

Und nun kann ich Ihnen nur raten, die gezeigten Beispiel immer wieder zu laden, zu verändern und zu erweitern - und natürlich auch eigene Programme zu schreiben. Sicher werden Sie dabei immer wieder mit den verschiedensten Fehlermeldungen des Compilers konfrontiert werden. Aber nur auf diese Weise werden Sie mit der Zeit seine Arbeitsweise verstehen und zu einem korrekten Programmierstil gelangen, mit dem Sie Ihre eigentlichen Aufgaben angehen können.

Vielleicht ist Ihnen aber auch der Wechsel zwischen Editor und Shell, die Handeingabe des Compiler-Aufrufs und des Programmstarts zu mühsam und Sie sehnen sich nach einer Umgebung, aus der heraus Sie alles mit ein paar Mausklicks erledigen können. Solche Umgebungen gibt es natürlich unter Linux auch; in Kapitel [*] werde ich Ihnen einige davon vorstellen. Eine der heute wichtigsten ist KDevelop. Sie können alle weiteren Beispiele bequem mit KDevelop entwickeln, ohne sich viel mit dem Compiler, seinem Aufruf und seinen Parametern auseinander setzen zu müssen (obschon Sie diese Techniken im Grunde auch beherrschen sollten). Ab Seite [*] finden Sie Kochrezepte, wie Sie Konsolenprogramme - und um solche handelt es sich bei unseren Beispielen - einfach mit KDevelop erzeugen und bearbeiten können.

Zusammenfassung

Die wichtigsten Aspekte dieses Abschnitts waren:

Übungsaufgaben

  1. Welchen Namen hat die erzeugte ausführbare Datei, wenn Sie beim Aufruf des GCC nur Ihre Quelltextdatei angeben?

    g++ meinprog.cc

  2. Was versteht man unter Optimierung eines Programms? In welchen Situationen sollte man diese aktivieren, in welchen weglassen?

  3. Was ist an folgendem Aufruf falsch?

    g++ -I../.. -c -o mydata.cc

    Welchen (unerwünschten) Effekt hätte er?

  4. Welchen Sinn hat die Option -g?
  5. Wie lautet der Compiler-Aufruf, wenn Sie Ihre Quelldatei mit dem Namen myprog.cc übersetzen und mit der Bibliothek /usr/lib/libm.a zu einer ausführbaren Datei namens myprog zusammenbinden wollen?
  6. Erklären Sie den Unterschied zwischen folgenden Aufrufen:

    g++ datei1.o datei2.o -lbasis

    sowie

    g++ datei1.o -lbasis datei2.o

  7. Auf welche Arten können Sie Info-Seiten betrachten? Rufen Sie zur Überprüfung Ihrer Aussage die Info-Seite zum GCC auf.


Klassen und Objekte

Auf den Seiten [*] ff. haben Sie den Klassenbegriff bereits in abstrakter Form kennen gelernt. Nun wollen wir uns ansehen, wie man Klassen und Objekte in C++ formuliert.


Klassendeklaration und -definition

Eine Klasse ist zunächst einmal ein von Ihnen definierter Datentyp. Sie müssen sie deklarieren, damit sie dem Compiler bekannt ist. Eine Klasse hat im Allgemeinen folgende Bestandteile:


Deklaration

Die Syntax zur Klassendeklaration besteht aus dem Schlüsselwort class, dann dem Namen der Klasse, gefolgt von den Klassenbestandteilen; diese sind von geschweiften Klammern eingeschlossen.

class MeineKlasse

{

  // ...

};

Nach der schließenden Klammer muss ein Semikolon stehen! Wenn Sie dieses vergessen (was ein sehr häufiger Anfängerfehler ist!), interpretiert der Compiler den nachfolgenden Code falsch und überschüttet Sie mit völlig unverständlichen Fehlermeldungen.

Kommen wir auf unser Beispiel von Seite [*] zurück. Als C++-Klasse könnten wir unser Raumfahrzeug folgendermaßen deklarieren:


1:  class Raumfahrzeug 

2: {
3: private:
4: string bezeichnung;
5: string herkunft;
6: float hoehe;
7: unsigned long hoechstgeschw;
8: unsigned long geschw;
9: Bewegung zustand;
10: Funktion grad;
11:
12: public:
13: Raumfahrzeug();
14: ~Raumfahrzeug();
15: void starten();
16: void landen();
17: bool setGeschwindigkeit(unsigned long _tempo);
18: unsigned long getGeschwindigkeit();
19: void andocken(Raumfahrzeug _anderes);
20: };
Die meisten Attribute der Klasse haben Standarddatentypen, die Sie ja bereits kennen. Neu ist der Typ string; dies ist eine Klasse für Zeichenketten, die wir später noch öfter verwenden werden (ganz genau ab Seite [*]).

Für Bewegung und Grad der Funktionstüchtigkeit gibt es nur eine beschränkte Zahl möglicher Werte. Wir verwenden daher Aufzählungstypen, die wir folgendermaßen definieren können:

enum Bewegung { STEHT, STARTET, FLIEGT, 

                LANDET, HYPERRAUM};

enum Funktion { VOLL, ANGESCHOSSEN, 

                DEFEKT, ZERSTOERT};

Diese Deklarationen können sowohl innerhalb als auch außerhalb der Klasse stehen. Sie müssen aber stets bekannt sein, wenn sie das erste Mal verwendet werden. Daher setzen wir sie in diesem Beispielprogramm unmittelbar über die Klasse.

In den Zeilen 13 und 14 stehen die Deklarationen des Konstruktors und des Destruktors. Dies sind Funktionen, die immer dann automatisch aufgerufen werden, wenn ein Objekt von dieser Klasse erzeugt beziehungsweise vernichtet wird. Aber dazu in einem der nächsten Abschnitte mehr.


Definition und Bereichsoperator

Wahrscheinlich ist Ihnen nach dieser Überschrift nicht ganz klar, was jetzt schon wieder auf Sie zukommt. Was ist denn der Unterschied zwischen Deklaration und Definition einer Klasse? Man macht ihn hauptsächlich an den Methoden fest:

Kurz gefasst bedeutet dies: Deklaration = Schnittstelle, Definition = Implementation.

Wenn Sie Ihr Programm aus mehreren Dateien zusammensetzen, hinterlegen Sie die Deklaration der Klasse am besten in einer Header-Datei und die Definition in einer zugehörigen Quelltextdatei. Auf diese Weise können Sie ein Stück Prozessabstraktion verwirklichen: Andere, die Ihre Klasse verwenden wollen, müssen nur die Header-Datei, also die Deklaration kennen; die Definition kann im Verborgenen bleiben. (Wie wir später sehen werden, ist die Realität leider etwas komplizierter. Es ist nämlich auch erlaubt, die Definition einer Methode gleich inmitten der Klassendeklaration zu schreiben. Im Augenblick können Sie aber bei dieser Trennung bleiben.)

Wenn Sie eine Methode implementieren wollen, müssen Sie außer deren Namen dem Compiler auch angeben, zu welcher Klasse sie gehört. Dazu verwenden Sie den Bereichsoperator ::. Für die Methode setGeschwindigkeit() der Klasse Raumfahrzeug lautet die Implementierung etwa:

bool Raumfahrzeug::setGeschwindigkeit(

  unsigned long _tempo)

{

  if (_tempo >hoechstgeschw) 

    return false;

 

  geschw = _tempo;

  return true;

}

Die Methode hat alle Merkmale einer Funktion, das heißt Rückgabewert (hier vom Typ bool), Funktionsname (setGeschwindigkeit), Parameter (_tempo) und Funktionskörper (Genaueres ab Seite [*]). Die Zugehörigkeit zur Klasse Raumfahrzeug drückt sich einzig durch den Klassennamen mit dem Bereichsoperator aus.

Später (Seite [*]) werden wir die Möglichkeiten der Positionen, an denen Sie Deklaration und Definition unterbringen können, noch genauer angeben.

Objekte von Klassen

Eine Klasse ist nur eine Schablone, sozusagen ein abstrakter Bauplan für das konkrete Objekt. Ist sie erst einmal definiert, gilt sie als Datentyp. Wir können sie dann wie jeden anderen Datentyp verwenden und Variablen davon bilden. Man spricht bei einer Variablen, die als Typ eine Klasse hat, von einem Objekt dieser Klasse oder auch von einer Instanz der Klasse (auch wenn das eine nicht ganz korrekte Übersetzung des englischen Begriffs instance ist).

Wenn Sie auf Elemente eines Objekts zugreifen wollen, müssen Sie den Namen des Objekts zusammen mit dem Namen des Elements angeben, getrennt durch einen Punkt ´.´. Also etwa

class Raumfahrzeug

{

  // ...

};

 

int main(void) 

{

  Raumfahrzeug   ufo;

  Raumfahrzeug   shuttle;

 

  ufo.setGeschwindigkeit(10000);

  cout << ``Ufo fliegt `` 

       << ufo.getGeschwindigkeit() 

       << `` km/s'' << endl;

  shuttle.andocken(ufo);

  return 0;

}

 


Zugriffsbeschränkungen

Zugriffsbeschränkungen regeln, ob andere Klassen auf ein Klassenelement zugreifen dürfen oder nicht. Dabei gibt es folgende Möglichkeiten:

(Es gibt auch noch eine dritte Ebene, protected, die wir aber erst später - ab Seite [*] - behandeln wollen.)

Figure: Die Zugriffsbeschränkungen schließen die Klasse vor unbefugtem Zugriff ab.



\resizebox*{!}{3cm}{\includegraphics{images/schloss.eps}}

Wenn Sie eine Zugriffsbeschränkung angeben, gilt diese für alle nachfolgenden Elemente - so lange, bis eine andere Beschränkung kommt oder die Klassendeklaration endet. Fehlt in einer Klasse die Festlegung der Zugriffsbeschränkung ganz, so werden alle Elemente als private behandelt.

In obigem Beispiel haben wir die beiden Zugriffsebenen in folgender Form verwendet:

class Raumfahrzeug

{

private:

  unsigned long geschw;

  Bewegung zustand;

  // ... weitere 

public:

  Raumfahrzeug();

  ~Raumfahrzeug();

  bool setGeschwindigkeit(unsigned long _tempo);

  unsigned long getGeschwindigkeit();

  // ... weitere

};

(Dabei haben wir eine Regel angewendet, auf die ich auf Seite [*] noch ausführlicher eingehen werde: Deklarieren Sie Attribute Ihrer Klassen nach Möglichkeit als private und die Zugriffsmethoden als public. So haben Sie immer unter Kontrolle, wie Benutzer eines Objektes die Attribute lesen beziehungsweise verändern dürfen.)

Wenn Sie in Ihrem Programm etwa die Anweisung schreiben:

Raumfahrzeug  ufo;

ufo.geschw = 250000;

so wird der Compiler diesen Zugriff mit der Fehlermeldung verwehren: member `geschw' is a private member of class `Raumfahrzeug'. Sie dürfen als Benutzer der Klasse nur die Attribute lesen oder ändern und nur die Methoden aufrufen, die als public deklariert sind.

Anders sieht es in den Methoden der Klasse selbst aus. Hier dürfen Sie stets auf alle Elemente zugreifen, natürlich auch auf die privaten. Die Methode setGeschwindigkeit() am Ende von Abschnitt 2.4.1 ist dafür ein Beispiel.

Hintergrund

Wenn Sie erst einmal ein wenig mit Klassen und Objekten gearbeitet haben und über die einfachen Beispiele hinausgehen, werden Sie sicher schnell auf einen Grenzfall stoßen. Betrachten Sie folgende Methode:

void Raumfahrzeug::andocken(Raumfahrzeug _anderes)

{

  geschw = _anderes.geschw;

  // ...

}

Zum Andocken bringen wir zunächst beide Raumfahrzeuge auf dieselbe Geschwindigkeit. Wie selbstverständlich habe ich dabei auf das private Attribut geschw des Objekts _anderes zugegriffen. Ist Ihnen schon klar, wieso es sich dabei um einen Grenzfall handelt? Einerseits ist geschw Attribut der Klasse, deren Methode andocken() wir hier gerade implementieren; daher sollten wir auch die volle Zugriffsmöglichkeit haben. Andererseits geht es ja dabei um ein anderes Objekt als das, dessen Methode ausgeführt wird; darf dieses denn freien Zugang dazu haben?

Die Antwort ist: ja. Die Zugriffsbeschränkung gilt auf der Ebene der Klassen und nicht der Objekte. Wenn in einer Methode ein Objekt derselben Klasse auftaucht, so hat die Methode Zugang zu allen Elementen des Objekts, öffentliche wie private, zum Lesen wie zum Schreiben. Anders ließen sich viele Arten der komplexeren Zusammenarbeit zwischen Objekten kaum realisieren.


Freunde

Wenn Sie einmal in die Lage geraten, dass Sie ohne die Zugriffsmöglichkeit auf private Elemente einer anderen Klasse nicht weiterkommen, deutet das meistens auf einen Designfehler hin. Allerdings gibt es in der Praxis durchaus Fälle, bei denen ein Verzicht auf den Zugriff einen sehr viel umständlicheren und damit meist langsameren Code mit sich bringen würde.

Figure: Freunde dürfen auch ins Innere der Klasse schauen.



\resizebox*{!}{2.5cm}{\includegraphics{images/freunde.eps}}



Zum Glück gibt es da in C++ das Schlüsselwort friend, das es Ihnen erlaubt, die Zugriffsbeschränkungen zu umgehen. Damit teilen Sie dem Compiler innerhalb der Klassendeklaration mit, welche anderen Elemente vollen Zugriff auf die Klasse erhalten. Diese können sein:

Im Englischen packt man diese Eigenschaft in ein leicht zweideutiges Wortspiel: Only friends may touch your private things.

Zusätzlich zu den Klassenelementen deklarieren Sie also die Freunde, die dann so behandelt werden, als gehörten sie zur Familie, das heißt zur Klasse. Ein Beispiel:

class A;   // Vorwärts-Deklaration

 

class B

{

  void m(A _a);

};

 

class A

{

private:

  int i;

 

public:

  void init();

  friend void B::m(A _a);

  friend void f(A _a, int i);

  friend class C;

};

Um diesen Code zu verstehen, müssen Sie zunächst einmal wissen, was eine Vorwärts-Deklaration ist. Manchmal ist man in der unangenehmen Situation, dass Klasse A die Klasse B verwendet und umgekehrt! Welche man aber auch immer als Erstes deklariert, stets ist die andere dabei unbekannt. Aus diesem Grund ist es in C++ möglich, zunächst einmal nur class und den Klassennamen zu schreiben - ohne die Klassenelemente. Dabei geben Sie dem Compiler zu verstehen: Nachher gebe ich noch die ganze Klasse an, aber im Augenblick reicht es, wenn du weißt, dass es eine Klasse dieses Namens geben wird.

In unserem Beispiel wird zunächst A nur als Klassenname eingeführt, dann B deklariert; dadurch kann die Methode m() von B bereits mit einem Argument vom Typ A arbeiten. Anschließend folgt die vollständige Deklaration von A, in der wir wiederum Teile von B hernehmen und sie als friend deklarieren.

Die Methode B::m() erhält durch die Deklaration als friend von A das Privileg, das Attribut i von A lesen und gegebenenfalls verändern zu dürfen. Dieses Vorrecht räumen wir damit aber nur der Methode B::m() ein! Andere Methoden von B (sofern vorhanden) haben kein Zugriffsrecht auf A::i (und gegebenenfalls andere private Elemente von A).

Beachten Sie, dass in diesem Beispiel nur die Variable i sowie die Methode init() Elemente von A sind. Die Funktion f() ist global, das heißt überall sichtbar. Zudem haben wir in einer erneuten Vorwärts-Deklaration auf eine Klasse C hingewiesen, die an anderer Stelle deklariert werden kann, die aber hier das volle Zugriffsrecht auf A erhalten hat.

Zusammenfassung

Aus diesem Abschnitt sollten Sie sich merken:

Übungsaufgaben

  1. Erklären Sie den Unterschied zwischen Deklaration und Definition von Klassen und Methoden.
  2. Formulieren Sie Ihre Klassen Konto und Bankkunde aus dem Abschnitt 2.1.8 (Seite [*]) in C++. Beachten Sie dabei die Zugriffsbeschränkungen und die erforderlichen Methoden.
  3. Was versteht man unter einer Vorwärts-Deklaration? Wozu wird sie benötigt?


Funktionen und Methoden

Funktionen in C++

Ein wichtiges Sprachelement von C++ kam bisher noch überhaupt nicht vor: die Funktion. Die Möglichkeit, Funktionen zu bilden, ist ein herausragendes Merkmal einer Programmiersprache. Ganz allgemein versteht man unter einer Funktion einen in sich geschlossenen Programmteil, der eine bestimmte Aufgabe erfüllt. Eine Funktion, die zu einer Klasse gehört, nennt man Methode.

Betrachten wir folgendes Beispiel:

int add( int x, int y)

{

  int z = x+y;

  return z;

}

An diesem Codeausschnitt erkennen Sie alle Bestandteile einer Funktion:

Zu all diesen Teilen ist natürlich noch einiges zu sagen. Vorher noch ein typografischer Hinweis: Es hat sich eingebürgert, in Büchern zwei Klammern hinter Bezeichner zu setzen, die sich auf eine Funktion beziehen, zum Beispiel in Sätzen wie: Mit add() erreichen Sie die Addition zweier Ganzzahlen. Das sagt noch nichts über Art und Umfang der Argumentliste aus, sondern soll Sie lediglich daran erinnern, dass es dabei um eine Funktion und nicht um eine Variable geht. Ich will mich auch in diesem Buch daran halten.

Rückgabewert

In C++ muss jede Funktion einen Typ für den Wert angeben, den sie zurückliefert. Manchmal ist es aber auch gar nicht nötig oder sinnvoll, dass eine Funktion überhaupt einen Rückgabewert hat. In diesem Fall geben Sie als Typ void an.

Was macht man nun mit einem solchen Wert? Der Programmteil, der die Funktion aufruft, kann diesen an allen Stellen einsetzen, wo er sonst eine Variable oder Konstante angeben würde (in obiger Form allerdings nur dort, wo lediglich der Wert benötigt wird), also etwa:

int main()

{

  int a = 5;

  int b = 12;

  

  int c = add(a,b);

  cout << ``a = `` << a 

       << ``, c = `` << c 

       << ``, a+c = `` << add(a,c) << endl;

  

  return 0;

}  

Dieses Programm hat dann die Ausgabe:

a = 5, c = 17, a+c = 22

Übrigens: Selbst wenn eine Funktion einen Wert zurückgibt, müssen Sie ihn nicht beachten. Sie dürfen auch schreiben:

  int a = 5;

  int b = 12;

  add(a,b);

auch wenn das hier keinen Sinn machen würde. Bei Funktionen mit Rückgabetyp void ist das hingegen die übliche Form des Aufrufs. Allgemein kommt es aber häufiger vor, dass Rückgabewerte ignoriert werden. Beispielsweise geben viele Funktionen Statusinformationen darüber zurück, wie gut (oder schlecht) sie ihre Aufgabe erfüllen konnten. Viele Anwender solcher Funktionen interessieren sich nicht für den Status und übergehen ihn. Das kann manchmal aber auch gefährlich werden, wenn etwa aufgetretene Fehler aus diesem Grund zunächst unentdeckt bleiben.

Funktionsname

Wie alle anderen Bezeichner in C++ dürfen Sie auch Funktionsnamen nur aus Buchstaben, Ziffern sowie dem Unterstrich _ bilden. Außerdem ist es nicht erlaubt, Funktionsnamen zu verwenden, die Schlüsselwörten gleichen (etwa for).

Argumentliste

Eine Funktion kann immer nur auf den Daten arbeiten, die ihr lokal vorliegen. Außer global (das heißt außerhalb aller Funktionen) definierten Variablen sind das nur die Parameter, die das Hauptprogramm an die Funktion übergibt. Von diesen Parametern (auch Argumente genannt) können Sie keinen, einen oder mehrere angeben, die Sie dann durch Kommas trennen.

Wenn Sie eine Funktion ohne Argumente schreiben wollen, lassen Sie den Bereich zwischen den beiden runden Klammern einfach leer - denn die Klammern müssen Sie stets schreiben! - oder Sie setzen ein Argument vom Typ void ein.

Ansonsten geben Sie für jeden Parameter seinen Datentyp und einen Namen an, unter dem er in der Funktion bekannt sein soll. Dieser Name kann vollkommen anders sein als der im Hauptprogramm beim Aufruf verwendete. Auch in obigem Beispiel heißen die Summanden in der Funktion x und y, im Hauptprogramm aber a und b.

Funktionskörper

Hier stehen die Anweisungen, die bei einem Aufruf der Funktion ausgeführt werden. Man kann darüber streiten, wie lang Funktionen sein sollten. Es gibt Experten, die fordern, dass eine Funktion aus nicht mehr als 50 Zeilen bestehen dürfe, sonst werde sie unleserlich. Es gibt jedoch in der Praxis immer wieder Fälle, in denen längere Funktionen sinnvoll sind. Bei der objektorientierten Programmierung werden Sie allerdings ohnehin viel häufiger Funktionen (beziehungsweise Methoden) verwenden, die im Durchschnitt wesentlich kürzer sind als bei der prozeduralen Programmierung.

Innerhalb des Funktionskörpers können Sie die Funktionsparameter wie normale Variablen verwenden; zusätzlich können Sie natürlich auch noch lokale Variablen definieren. Außerdem ist es selbstverständlich erlaubt, aus einer Funktion wieder andere Funktionen aufzurufen. (Sie dürfen sogar die Funktion selbst wieder aufrufen; man spricht dann von einer rekursiven Funktion - aber das ist ein eigenes Thema.)

Return-Anweisung

Die Anweisungen im Funktionskörper werden so lange abgearbeitet, bis das Programm auf das Ende der Funktion oder eine return-Anweisung trifft. Diese erfüllt einen doppelten Zweck:

  1. Sie legen fest, welchen Wert die Funktion an das Hauptprogramm zurückliefern soll. Das kann eine Variable sein oder ein Ausdruck, eine Konstante oder der Rückgabewert einer anderen Funktion (wobei Letzteres als schlechter Stil gilt). Hat Ihre Funktion den Rückgabetyp void, geben Sie an dieser Stelle überhaupt nichts an. Der Typ des angegebenen Werts muss jedoch in jedem Fall mit dem deklarierten Rückgabetyp übereinstimmen.
  2. Sie beenden die Funktion und kehren zum Hauptprogramm zurück. Jede return-Anweisung - sei sie nun am Ende oder irgendwo inmitten des Funktionskörpers - markiert das Ende der Abarbeitung der Funktion und den Rücksprung an die Stelle, von der aus die Funktion aufgerufen wurde. Sie können also die Funktion schon beenden, bevor alle Anweisungen ausgeführt sind, zum Beispiel wenn eine bestimmte Bedingung erfüllt ist.

Ist der Rückgabetyp void, muss am Ende der Funktion keine return-Anweisung stehen (auch nicht das Schlüsselwort return), wie in folgendem Beispiel:

void ausgabe( int z)

{

  cout << ``Das Ergebnis ist: `` << z << endl;

}

Auch wenn es nicht zwingend erforderlich ist, ist es dennoch zulässig, return auch in void-Funktionen zu verwenden. Das bietet sich insbesondere dann an, wenn Sie zwischendrin die Funktion verlassen möchten - etwa wenn eine bestimmte Bedingung erfüllt ist:

void func( int status)

{

  if (status==0)

    return;

 

  // Tue etwas anderes

  // ...

}

Wenn Sie allerdings bei Funktionen mit irgendeinem anderen Rückgabetyp als void die return-Anweisung am Ende vergessen, meldet der Compiler einen Fehler.


Der Prototyp

Bevor Sie eine Funktion verwenden können, müssen Sie dem Compiler zunächst mitteilen, dass es eine Funktion dieses Namens gibt, wie viele und welche Parameter sie hat und welchen Typ sie zurückliefert. Dies geschieht mit einem so genannten Prototyp der Funktion. Der Prototyp sieht genauso aus wie die Funktion selbst bis auf den Funktionskörper; dieser fehlt und wird durch ein einfaches Semikolon ; ersetzt. (Es ist sogar erlaubt, die Namen der Argumente wegzulassen und nur ihre Typen anzugeben.) Analog zu den Klassen (siehe Seite [*]) ist also der Prototyp die Deklaration und die Funktion mit Körper die Definition.

Folgendes Beispiel zeigt den Umgang mit Prototypen:


1:  #include <iostream.h> 

2:
3: int add(int x, int y);
4: void ausgabe( int z);
5:
6: int main()
7: {
8: int a = 5;
9: int b = 12;
10: int c = add(a,b);
11: ausgabe(c);
12: ausgabe(add(a,c));
13: return 0;
14: }
15:
16: int add(int x, int y)
17: {
18: return (x+y);
19: }
20:
21: void ausgabe( int z)
22: {
23: cout << "Ergebnis: " << z << endl;
24: }
In den Zeilen 3 und 4 befinden sich die Prototypen der beiden Funktionen, die das Programm verwendet. In main() werden sie dann aufgerufen (Zeilen 10-12) und ab Zeile 17 definiert.

Da eine C++-Datei von oben nach unten kompiliert wird, sind im Prinzip Funktionen, die oben stehen, im weiteren Verlauf bekannt, so dass für diese keine Prototypen benötigt werden. Wenn das Programm aber weiter wächst, kann es jedoch schnell zu Problemen führen, wenn Sie sich ausschließlich auf die Reihenfolge verlassen. Ich rate Ihnen daher, für alle Ihre Funktionen Prototypen an den Beginn Ihrer Quelltextdatei zu setzen.

Wenn Ihr Programm aus mehreren Quelltextdateien besteht, müssen Sie die Prototypen in die Header-Dateien schreiben. Am besten verwenden Sie für jede Quelltextdatei eine eigene Header-Datei. Bei einem so kleinen Beispiel wie oben ist eine solche Trennung zwar nicht nötig; will man aber doch eine Aufteilung machen, könnte diese folgendermaßen aussehen:



 

Auf eine Header-Datei können Sie verzichten, wenn die Quelltextdatei keine Funktionen oder Klassen enthält, die Sie von anderen Dateien aus verwenden wollen. Hier ist dies haupt.cc; dort befindet sich ja nur die main()-Funktion, also der Hauptteil des Programms. Gegenüber dem obigen Code müssen Sie bei der Implementierung in getrennten Dateien dort noch einen #include-Befehl für die Header-Datei mit den Funktionsprototypen einfügen, also:

#include ``funktionen.hh''

 

int main()

{

  // weiter wie bisher

Beim Aufruf des Compilers müssen Sie dann auch beide Quelltextdateien übersetzen; wie erwähnt können Sie das entweder auf einmal machen:

g++ -Wall haupt.cc funktionen.cc -o funktionen

oder Sie übersetzen jede Datei einzeln und müssen bei Fehlern nur diese Übersetzung wiederholen:

g++ -Wall -c haupt.cc

g++ -Wall -c funktionen.cc

g++ haupt.o funktionen.o -o funktionen

Mit den ersten beiden Befehlen kompilieren Sie die jeweilige Quelltextdatei bis zur Objektdatei, mit dem letzten binden Sie beide zu einem ausführbaren Programm zusammen. (Näheres zu den Compiler-Optionen ab Seite [*].)


Überladen von Funktionen

Im Gegensatz zu anderen Programmiersprachen wird in C++ eine Funktion nicht nur anhand des Namens, sondern anhand des Namens und der Liste der Argumente identifiziert. (Beides zusammen bezeichnet man gelegentlich auch als Signatur der Funktion.) Damit erhalten Sie die Freiheit, zwei Funktionen gleichen Namens zu verwenden, die sich aber in ihren Argumenten unterscheiden. Man bezeichnet das auch als Überladung des Funktionsnamens.

Wir hatten oben (Seite [*]) eine Funktion add() definiert, um zwei Ganzzahlen zu addieren. Nun kann man natürlich auch Dezimalbrüche addieren; das deckt unsere Funktion aber nicht ab. Wir definieren also eine zweite Funktion mit demselben Namen (!), aber anderen Argumenten:

double add( double x, double y)

{

  return x+y;

}

Wenn Sie in Ihrem Programm die Funktion add() aufrufen, erkennt der Compiler an den Typen der Argumente, welche der ihm bekannten Funktionen er verwenden soll, beispielsweise:

  cout << add( 3, 5);     // int-Version

  cout << add( 1.5, 2.2); // double-Version

Beachten \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Sie aber, dass der Rückgabewert nicht zur Signatur gehört. Sie können also keine zwei Funktionen definieren, die zwar denselben Namen und dieselbe Argumentliste haben, jedoch über verschiedene Typen für den Rückgabewert verfügen, zum Beispiel:

int  calc( float a);

bool calc( float a);  // Führt zu Fehlermeldung!

Hintergrund

Gerade wenn zwei Funktionen wie unsere beiden add() dieselbe Anzahl von Argumenten haben und zudem nur Standardtypen verwenden, ist es für den Compiler machmal schwer, eindeutig die passende Funktion zu einem bestimmten Aufruf zu finden. Betrachten Sie etwa folgenden Code und überlegen Sie sich vor dem Weiterlesen, welche Funktion in den jeweiligen Zeilen aufgerufen wird:


 

1: int main()

2: {

3:    double a= 100.2;

4:    double b= 333.777;

5:    int c= 15; 

6:    int d= 2726;

7:

8:    cout << add(a,b);

9:    cout << add(c,d);

10:    cout << add(31,'a');

11:    cout << add(3.1, c);

12:    cout << add(3.1, float(c));

13:    cout << add(b,c);

14:    return 0;

15: }

(Zum besseren Verständnis können Sie auch nochmals die Regeln für die implizite Typumwandlung ab Seite [*] nachschlagen.) Sehen wir uns die Aufrufe also genauer an:

Genauso wie Funktionen können Sie auch Methoden in Klassen überladen. Insbesondere beim Konstruktor macht man regelmäßig davon Gebrauch; doch dazu später mehr (Seite [*] ff.).


Überladen von main()

Eine besondere Funktion in C++ ist main(). Auch sie gibt es in allen C++-Implementierungen in mindestens zwei überladenen Varianten:

int main( void);

int main( int argc, char* argv[]);

Bei der zweiten Version erhalten Sie als Parameter noch die Argumente, die beim Programmaufruf in der Shell hinter dem Programmnamen angegeben wurden. Dabei ist argc die Anzahl der Argumente in der Kommandozeile; diese ist immer mindestens 1, denn als erstes Argument wird der Programmname angesehen. In argv stehen dann die Argumente selbst, und zwar in Form eines zweidimensionalen Feldes vom Typ char. Der Umgang damit ist aus C++-Sicht etwas verzwickt, weshalb ich bisher verzichtet habe, darauf einzugehen. Um die Argumente nur auszugeben, benötigen Sie aber nicht viele Kenntnisse. Es genügt zu wissen, dass Sie mit argv[i] das i-te Argument in der Kommandozeile erhalten.

Folgendes Programm gibt die übergebenen Argumente auf den Bildschirm aus:


1:  // commandline.cc 

2:
3: #include<iostream.h>
4:
5: int main(int argc, char* argv[])
6: {
7: int i;
8:
9: for(i=0; i<argc; i++)
10: cout << i << ". Argument: "
11: << argv[i] << endl;
12:
13: return 0;
14: }

Die for-Schleife in Zeile 7, zu deren Syntax wir später noch genauer kommen (Seite [*]), durchläuft einfach alle Argumente vom ersten bis zum letzten, wie in argc angegeben. Der Aufruf dieses Programms mit

commandline erstes zweites drittes

führt zur Ausgabe:

0. Argument: commandline

1. Argument: erstes

2. Argument: zweites

3. Argument: drittes 

Auf diese Weise können Sie Ihre Programme durch beim Aufruf übergebene Werte steuern oder initialisieren. Sie kennen dieses Verhalten ja bereits von vielen Unix-Tools, unter anderen vom Compiler selbst.

Wenn Sie sehr umfangreiche Listen von Kommandozeilenargumenten verarbeiten wollen, empfehle ich Ihnen den Einsatz der Bibliotheksroutine getopt(), die ich Ihnen auf Seite [*] vorstellen werde.


Vorgabewerte für Parameter

In C++ können Sie für einzelne Argumente einer Funktion Vorgabewerte (engl. default values) festlegen, das heißt Werte, die verwendet werden, wenn der entsprechende Parameter im Aufruf der Funktion fehlt.

Schauen wir uns ein Beispiel an:

// Deklaration

void printNumber(float number, int fillsize=0, 

                 char fillchar=´ ´);

 

// Implementation

void printNumber(float number, int fillsize, 

                 char fillchar)

{

  int i;

  for(i=0; i<fillsize; i++)

    cout << fillchar;

  cout << number;

}

Wie Sie sehen, erscheinen die Vorgabewerte nur in der Deklaration und nicht in der Definition der Funktion. (Wenn es keinen Prototyp gibt, dürfen die Vorgabewerte aber auch in der Implementation stehen.)

Die Funktion printNumber() kann mit einem, mit zwei oder mit drei Parametern aufgerufen werden, jedoch nur in der angegebenen Reihenfolge. Somit sind folgende drei Aufrufe identisch:

  printNumber(3.5);

  printNumber(3.5, 0);

  printNumber(3.5, 0, ´ ´);

Wenn Sie aber vor der 3.5 noch 10 Nullen einfügen möchten, können Sie die Funktion auch so aufrufen:

  printNumber(3.5, 10, ´0´);

Zwei \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Aspekte müssen Sie beim Arbeiten mit Vorgabewerten aber unbedingt beachten:

Es ist Ihnen sicher jetzt schon klar, dass Vorgabewerte für Parameter zuweilen recht praktisch sein können. Hat etwa eine Funktion (oder eine Methode!) in den allermeisten Fällen dieselben Aufrufparameter, so machen Sie diese einfach zu den Vorgabewerten und sparen sich eine Menge Schreibarbeit. Aber auch aus Sicht der etwas abstrakteren Objektorientierung sind Vorgabewerte willkommen - denn sie sorgen für Erweiterbarkeit. Stellen Sie sich vor, Sie haben bereits ein größeres Programm geschrieben, das an vielen verschiedenen Stellen eine Funktion updateDisplay() aufruft. Nun kommen Sie in die Situation, ein weiteres Argument zu der Funktion hinzufügen zu müssen. In einer Programmiersprache ohne die Möglichkeit von Vorgabewerten müssten Sie nun an allen Stellen, an denen updateDisplay() aufgerufen wird, Ihr Programm ändern und einen Standardwert hinzufügen. In C++ kann dagegen sämtlicher Code beibehalten werden, wenn Sie diesen Standardwert als Vorgabe verwenden. Neu zu schreibende Aufrufe können indessen den zusätzlichen Parameter nutzen.

Referenzen und Parameterübergabe

Eine Referenz ist ein neuer Name, ein Alias für eine Variable. Sie können damit ein und denselben Speicherplatz unter zwei verschiedenen Namen im Programm ansprechen. Jede Modifikation der Referenz ist eine Modifikation der Variablen selbst - und umgekehrt. Der Typ der Referenz entspricht dem der Variablen; allerdings ist eine Referenz eine Abart davon. Sie wird gekennzeichnet durch das Zeichen ´&´ nach der Typbezeichnung.

Wenn Ihnen das jetzt etwas abstrakt vorkommt, befinden Sie sich in guter Gesellschaft. Referenzen sind für die meisten C++-Neulinge etwas schwer durchschaubar. Daher gleich ein Beispiel:

  int height = 800;  // Variable

  int& h = height;   // Referenz auf height

 

  h += 100;

  cout << ``Höhe: `` << height++;

  cout << ``Höhe: `` << h++;

  cout << ``Höhe: `` << height;

Wie lautet die Ausgabe, die dieser Codeausschnitt hervorruft? Überlegen Sie vor dem Weiterlesen erst selbst, was die Anweisungen bedeuten könnten. In diesem Beispiel sind height und h zwei Namen für einen Speicherplatz. Wenn man also h um 100 erhöht, ist auch height anschließend entsprechend größer. Die Ausgabe ist also:

Höhe: 900

Höhe: 901

Höhe: 902

Regeln für Referenzen

Für den Umgang mit Referenzen sollten Sie sich ein paar Grundregeln einprägen:

Innerhalb einer Funktion verwendet man nicht allzu häufig Referenzen. Manchmal kommt man aber zu ziemlich langen Ausdrücken, insbesondere bei Objekten, deren Attribute wieder Objekte sind und so weiter; dann kann der Einsatz von Referenzen für solche verschachtelten Attribute die Lesbarkeit deutlich erhöhen. Die häufigste Anwendung ist aber die Übergabe von Funktionsparametern als Referenz, wie wir sie im nächsten Abschnitt kennen lernen werden.


Parameterübergabe als Referenz

Bisher waren wir davon ausgegangen, dass eine Funktion die Parameter, die sie erhält, nur verwendet - also liest - und nicht verändert. In einigen Fällen aber will man gerade, dass die Funktion die Parameter verändert. Hat die Funktion beispielsweise mehrere Ergebniswerte, so reicht der eine return-Wert zum Austausch der Information nicht aus. Sehen wir uns etwa folgende Funktion an:

void swap_values(int x, int y)

{

  int temp = x;

  x = y;

  y = temp;

}

Sie soll dazu dienen, die Werte, die in den beiden Variablen x und y gespeichert sind, gegeneinander auszutauschen. Erfüllt sie diesen Zweck Ihrer Meinung nach?

Die Antwort ist leider nein. Es findet zwar ein Austausch statt, aber das Hauptprogramm erfährt davon nichts. Die Variablen, die dieser Funktion übergeben werden, haben hinterher immer noch dieselben Inhalte wie vorher.

Ganz anders sieht die Lage aus, wenn die Parameter als Referenzen übergeben werden (auf Englisch call by reference genannt). Dann sind diese nämlich nicht im Unterprogramm eigenständige Speicherstellen, die nach dem Rücksprung wieder freigegeben werden, sondern Aliasnamen für die Variablen im Hauptprogramm. Jede Änderung, die das Unterprogramm, also die Funktion, an den Parametern vornimmt, wirkt unmittelbar auf die im Hauptprogramm definierten und der Funktion übergebenen Variablen. Um das zu erreichen, müssen wir obige Funktion nur ein klein wenig abändern:

void swap_refs(int& x, int& y)

{

  int temp = x;

  x = y;

  y = temp;

}

Wie Sie sehen, haben die Parameter nun nicht mehr den Typ int, sondern int&. Ein kleines Hauptprogramm zeigt Ihnen, dass der Aufruf der beiden Funktionen swap_values() und swap_refs() zwar identisch, die Wirkung aber völlig unterschiedlich ist.

int main (void)

{

  int big = 10;

  int small = 20;

 

  cout << "big1: "<< big 

       << " small1: "<< small << endl;

 

  swap_values (big, small);

  cout << "big2: "<< big 

       << " small2: "<< small << endl;

 

  swap_refs (big, small);

  cout << "big3: "<< big 

       << " small3: "<< small << endl;

 

  return 0;

}      

Welche Ausgabe erwarten Sie von diesem Programm? Wenn Sie jetzt noch unsicher sind, probieren Sie es am besten gleich aus.

Konstanten und Referenzen

Wenn Sie einer Funktion ein Objekt per Wert übergeben (im Fachenglisch call by value genannt), wird eine Kopie des Objekts angelegt, mit dem die Funktion dann arbeitet. Bei größeren Objekten kann der Aufwand für das Erstellen einer solchen Kopie beträchtlich sein. Daher wäre es besser, diese als Referenz zu übergeben, denn dabei bekommt die Funktion nur den Verweis auf die Speicherstelle, was sehr schnell und einfach abgewickelt werden kann. Das Problem dabei ist jedoch, dass damit der Funktion das Recht eingeräumt wird, das Objekt in jeder beliebigen Form zu verändern, was ja meist nicht beabsichtigt ist. Gerade in größeren Programmen stellt dies eine Fehlerquelle dar, die Sie vermeiden sollten.

Um dieses Problem in den Griff zu bekommen, verwenden Sie das Schlüsselwort const. Damit können Sie jede Variable und jede Referenz als konstant deklarieren, so dass sie nicht mehr verändert werden kann. Wenn Sie also in Ihrem Programm einen bestimmten Wert mehrfach benötigen, sollten Sie ihn an zentraler Stelle (zum Beispiel global in der Hauptdatei des Projekts oder in einer entsprechenden Header-Datei) als Konstante definieren und später dann nur noch diesen Namen für den entsprechenden Wert verwenden. Ein Vorteil dabei ist, dass Sie später, wenn Sie das Programm mit einem anderen Wert übersetzen wollen, diesen nur einmal ändern müssen und ihn im Allgemeinen auch sofort finden. Zum Beispiel:

const unsigned long MAX_OBJ_SIZE = 1000000000;

Wie bei den Aufzählungstypen (Seite [*]) empfehle ich Ihnen auch bei allen anderen Konstanten die Schreibung in Großbuchstaben.

Doch zurück: Was hilft uns das Schlüsselwort const beim Problem der Parameterübergabe? Sie bauen die Funktion so, dass sie nicht eine einfache Referenz als Argument erhält, sondern eine konstante Referenz. Dann hat sie beispielsweise folgende Form:

void checkObject(const MyClass& a);

Auf diese Weise haben Sie

Am Aufruf sieht man nicht, ob da ein Objekt, eine Referenz oder eine konstante Referenz übergeben wird. Wenn Sie allerdings wissen, dass Ihr Objekt innerhalb der Funktion als Konstante gilt (und nur dort!), können Sie auch sicher sein, dass es sich nach dem Aufruf noch in genau demselben Zustand befindet wie vorher.

Vielleicht ist Ihnen aufgefallen, dass ich die ganze Zeit von Objekten gesprochen habe, während vorher immer allgemeiner von Variablen die Rede war. Der Grund dafür ist, dass konstante Referenzen im Allgemeinen nur für Objekte benutzt werden. Sie können natürlich auch konstante Referenzen auf Standardtypen verwenden; da diese stets kaum größer als die Referenz sind (zum Teil sogar kleiner), haben Sie dadurch jedoch keinen Gewinn. Ich empfehle Ihnen daher, für Standardtypen den Wert und für Instanzen von Klassen eine konstante Referenz als Übergabe an eine Funktion einzusetzen.


Konstante Methoden

Obwohl es eigentlich nichts mit Referenzen zu tun hat, wollen wir uns kurz noch mit dem Thema Konstante Methoden beschäftigen. Nachdem Sie jetzt wissen, wie Sie einer Funktion ein konstantes Objekt übergeben, ist Ihnen vermutlich noch nicht so ganz klar, was Sie in der Funktion mit dem Objekt machen dürfen und was nicht. Und wenn es Ihnen schon nicht klar ist, werden Sie es dem Compiler sicher ebenfalls kaum begreiflich machen können.

Anweisungen innerhalb des Funktionskörpers dürfen ja ohnehin nur auf die öffentlichen Attribute und Methoden des Objekts zugreifen (wenn wir den Sonderfall der Freunde außer Acht lassen). Die Attribute dürfen bei einem konstanten Objekt nur gelesen, aber nicht überschrieben werden. Was ist aber mit den Methoden? Wenn Sie eine Methode eines Objekts aufrufen, wissen Sie nicht, was darin genau passiert. Möglicherweise finden darin Modifikationen an den Attributen statt - genau das, was wir mit dem konstanten Objekt verhindern wollten.

Ich kann Sie beruhigen: Solche Methoden darf die Funktion nicht aufrufen, das verhindert bereits der Compiler. Welche aber dann? Hier kommt das Schlüsselwort const abermals zum Einsatz. Damit eine Methode für ein konstantes Objekt aufgerufen werden darf, müssen Sie diese in der Klasse als const deklarieren. Dieses const muss nach der Argumentliste und vor dem Semikolon beziehungsweise dem Methodenkörper stehen. (Wenn Deklaration und Definition einer Methode getrennt sind, muss const bei beiden stehen, sonst fasst der Compiler die Definition als eine andere Signatur auf.)

Sehen wir uns das an einem Beispiel an:

class Raumfahrzeug

{

public:  

  bool setGeschwindigkeit(unsigned long _tempo);

  unsigned long getGeschwindigkeit() const;

  // ...

};

Hier haben wir zwei Methoden deklariert; von diesen ist lediglich getGeschwindigkeit() auch bei konstanten Objekten erlaubt.

void zeigeStatus(const Raumfahrzeug& rf)

{

  cout << ``Geschwindigkeit: `` 

       << rf.getGeschwindigkeit();  // erlaubt!

 

  rf.setGeschwindigkeit(0);   // verboten!

  // ...

}

Auf diese Weise können Sie als Autor der Klasse festlegen, welche Methoden ein Benutzer aufrufen darf, der über eine konstante Referenz auf ein Objekt davon verfügt. Falls Sie in die Versuchung geraten, mit const einen Etikettenschwindel zu betreiben, also eine Methode so zu deklarieren, obwohl Sie in ihr Veränderungen an den Attributen vornehmen (etwa reine Lesezugriffe, die aber als Nebeneffekte andere Attribute aktualisieren), verweigert sich Ihnen der Compiler. Denn const heißt auch konstant.

Ebenso wenig sollten Sie mit const halbherzig umgehen, das heißt, es nach Belieben mal anfügen und mal weglassen. Fragen Sie sich besser bei jeder Methode, ob diese eine Veränderung des Objekts bewirken soll oder nicht; im letzteren Fall deklarieren Sie sie stets als const. Somit werden Sie übrigens auch daran gehindert, die stets fehlerträchtigen Nebeneffekte zu programmieren - schon mancher Benutzer einer Klasse hat sich gewundert, warum sein Objekt nach einem Lesezugriff plötzlich verändert war.


Zugriffsroutinen

Sie erinnern sich sicherlich, dass ich Ihnen die Datenabstraktion als wichtiges Prinzip der objektorientierten Programmierung vorgestellt habe (auf Seite [*]). Schlagen Sie am besten nochmals Abbildung 2.5 nach: Dort wird gezeigt, dass im Idealfall andere Objekte nur über Nachrichten auf die Daten eines Objekts zugreifen können. Nachrichten werden aber als Methoden des Empfängers implementiert.

Was heißt das nun konkret hinsichtlich der Implementierung? Für die Datenelemente Ihrer Klasse empfiehlt sich folgende Vorgehensweise:

  1. Deklarieren Sie alle Datenelemente als private und nur Methoden als public.
  2. Schreiben Sie für jedes Attribut, das Sie nach außen sichtbar machen wollen (manche dienen ja auch nur der internen Buchhaltung), je eine Methode für den Lese- und eine für den Schreibzugriff. Um den Bezug zum Attribut herzustellen sowie gleichzeitig die Bedeutung zu illustrieren, verwenden viele Programmierer (auch ich) für die Namen dieser Methoden immer die Vorsilben get beziehungsweise set, gefolgt vom Namen des Attributs.
  3. Deklarieren Sie alle Lesemethoden als const.
In unserer Beispielklasse haben Sie bereits solche Methoden gesehen:

class Raumfahrzeug

{

private:

  unsigned long geschw;

  // ...

public:  

  bool setGeschwindigkeit(unsigned long _tempo);

  unsigned long getGeschwindigkeit() const;

  // ...

};

Diese Art des Zugriffs über Methoden hat den Vorteil, dass Sie die volle Kontrolle über die Möglichkeiten der Modifikation von Objekten Ihrer Klasse behalten. In vielen Fällen wird die get-Methode nichts weiter tun, als den Wert des Datenelements zurückzuliefern, ebenso wie die set-Methode meist lediglich das Attribut auf den gewünschten Wert setzt.

unsigned long 

Raumfahrzeug::getGeschwindigkeit() const

{

  return geschw;

}

Aber manchmal (und dieser Fall kann erst im Laufe der Entwicklung des Programms erkennbar werden!), müssen diese Methoden noch zusätzliche Aufgaben erfüllen, etwa die Plausibilität der zu setzenden Werte überprüfen. Das haben wir ja auch in unserer Implementation von setGeschwindigkeit() gemacht: Wir setzen den Wert nicht, wenn die verlangte Geschwindigkeit über der Höchstgeschwindigkeit unseres Raumfahrzeugs liegt.

bool Raumfahrzeug::setGeschwindigkeit(

  unsigned long _tempo)

{

  if (_tempo > hoechstgeschw) 

    return false;

 

  geschw = _tempo;

  return true;

}

Der Rückgabetyp bool ist hier übrigens nur ein Vorschlag. Da viele Programmierer bei Schreibmethoden den Rückgabewert ohnehin zu ignorieren pflegen, verwendet man meist void. Um dann auf gravierende Fehler hinzuweisen, gibt es noch verschiedene Mittel und Wege, denen Sie im Laufe des Buches begegnen werden.


Inline-Funktionen

Was passiert eigentlich bei einem Funktions- oder Methodenaufruf? Grob gesprochen wird der Programmfluss angehalten, die Parameter in einen speziellen Bereich (genannt Stack) kopiert, der Ausgangspunkt vermerkt und an den Beginn der Funktion gesprungen. Nach deren Abarbeitung werden ihre lokalen Variablen wieder gelöscht, der Stack aufgeräumt und das Hauptprogramm fortgesetzt. Bei sehr kurzen Funktionen, wie sie bei der objektorientierten Programmierung häufiger als anderswo auftauchen (zum Beispiel als Zugriffsmethoden), dauert die Verwaltung des Aufrufs länger als die Abarbeitung selbst.

Abhilfe bieten da so genannte inline-Funktionen. Durch die Angabe dieses Schlüsselworts vor einer Funktion erreichen Sie, dass für die Funktion kein Aufruf im oben genannten Sinn erzeugt wird, sondern der Funktionskörper direkt an die Stelle eingefügt wird (eben in line, in der Zeile). Es ist dann also keine echte Funktion mehr, sondern nur ein Stück Code im Hauptprogramm.

Erfahrenen C-Programmierern kommt jetzt sicher der Gedanke an Makros. Auch diese bewirken eine Ersetzung (schon durch den Präprozessor); allerdings können bei diesen keine Datentypen für die Argumente definiert werden. Bei inline-Funktionen bleibt die Prüfung des Argumenttyps sowie der sonstigen Syntax genauso erhalten, als ob es sich um eine tatsächliche Funktion handeln würde.

Deklaration als inline

Um eine Funktion als inline zu deklarieren, schreiben Sie dieses Schlüsselwort zur Definition, und zwar als Allererstes, also noch vor den Rückgabetyp, zum Beispiel:

inline int add(int x, int y)

{

  return x+y;

}

Beim Aufruf sehen Sie nicht, ob Sie es mit einer echten oder einer inline-Funktion zu tun haben.

void calc()

{

  int p=3, q=5;

  // ...

  int r = add(p,q);

Die letzte Zeile wird so kompiliert, als ob da stünde:

  int r = p+q;

Einige weitere Aspekte sollten Sie in diesem Zusammenhang beachten:


Klassenmethoden als inline

Zugriffsmethoden von Klassen sind die idealen Kandidaten für die inline-Deklaration. Sie sind meist sehr kurz, werden aber ziemlich oft benötigt. Oben (Seite [*]) haben wir nur davon gesprochen, wie man Methoden außerhalb der Klassendeklaration definiert. Mit dem jetzigen Wissen können Sie sich auch für eine andere Stelle entscheiden, an der Sie Ihre Methode implementieren.

  1. Deklaration und Definition innerhalb der Klassendeklaration. Wenn Sie eine Methode direkt innerhalb der Klassendeklaration implementieren, also auch den Funktionskörper angeben, wird diese automatisch als inline behandelt, auch wenn das entsprechende Schlüsselwort fehlt. Das ist bei sehr kurzen Methoden (ein oder zwei Zeilen) durchaus üblich.
  2. Deklaration und Definition als inline-Funktion in der Header-Datei. Wenn Sie Ihre Methode als inline gelten lassen wollen, sollten Sie sie aus oben beschriebenen Gründen innerhalb der Header-Datei implementieren. Diese Möglichkeit wählt man oft bei etwas längeren Methoden (mit mehr als zwei Zeilen), die aber noch sehr einfach sind, also beispielsweise keine Schleifen enthalten, oder wenn man auf Klassenelemente zugreifen muss, die erst später deklariert werden.
  3. Deklaration in der Header-Datei und Definition in der Implementationsdatei. Diese Trennung ist der Standardfall für alle größeren und komplizierteren Methoden. Jede davon wird dann als echte Funktion behandelt, die einmal zu Objektcode übersetzt und später dann nur noch dazugelinkt werden muss.

Sehen wir uns die drei Varianten in der Praxis an. Die erste Möglichkeit führt etwa zu folgendem Code:

// Datei Raumfahrzeug.h

 

class Raumfahrzeug

{

private:

  unsigned long geschw;

  float hoehe;

  // ...

public:

  unsigned long getGeschwindigkeit() const

  {

    return geschw;

  }

 

  float getHoehe() const

  {

    return hoehe;

  }

  // ...

};

Nicht vergessen: Obgleich nicht ausdrücklich inline dabeisteht, sind es diese Methoden trotzdem.

Die zweite Variante hat beispielsweise folgende Form:

// Datei Raumfahrzeug.h

 

class Raumfahrzeug

{

public:

  unsigned long getGeschwindigkeit() const;

  float getHoehe() const;

  // ...

};

 

inline unsigned long 

Raumfahrzeug::getGeschwindigkeit() const

{

  return geschw;

}

 

inline float Raumfahrzeug::getHoehe() const

{

  return hoehe;

}

Dies alles steht noch in der Header-Datei, z.B. in Raumfahrzeug.h.

Anders sieht dies beim dritten Fall aus. Hier findet sich im Include-File nur noch:

// Datei Raumfahrzeug.h

 

class Raumfahrzeug

{

public:

  unsigned long getGeschwindigkeit() const;

  float getHoehe() const;

  // ...

};

Die Implementation steht in einer eigenen Datei:

// Datei Raumfahrzeug.cc

 

unsigned long 

Raumfahrzeug::getGeschwindigkeit() const

{

  return geschw;

}

 

float Raumfahrzeug::getHoehe() const

{

  return hoehe;

}

// ...

Bei Zugriffsmethoden wie diesen würde man sicher eine der beiden ersten Varianten bevorzugen, wobei die allererste für mich hier übersichtlicher ist - weiß man dabei doch gleich, wie und wo die Methode implementiert ist. Manche Programmierer bevorzugen generell die zweite Möglichkeit, denn dabei bleibt die Klassendeklaration in kompakter Form und wird nicht durch Funktionskörper zerrissen. Es ist zuweilen sogar nötig, diese Variante zu wählen, etwa wenn die Methode auf andere Klassenelemente zugreifen muss, die erst später deklariert werden.

Zusammenfassung

In diesem Abschnitt haben Sie eine Menge gelernt. Die wichtigsten Aspekte waren:


Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Geben Sie diese Programme in Ihren Rechner ein und versuchen Sie, sie zu übersetzen. Welche Fehlermeldung(en) gibt der Compiler aus? Korrigieren Sie die Fehler und starten Sie die Übersetzung erneut.

  3. Eine rationale Zahl wird dargestellt durch $r=\frac{a}{b}$ mit ganzzahligen $a$ und $b$, wobei $b\neq 0$. Addition und Multiplikation sind wie bekannt definiert. Schreiben Sie eine Klasse Rational, die eine rationale Zahl repräsentiert. Diese soll über die Rechenfunktionen add, sub, mult und div verfügen, wobei der Bruch stets vollständig gekürzt gespeichert werden soll. Die Klasse habe folgende Struktur:

    class Rational

    private:

      long zaehler, nenner;

      long ggT (long _a,long _b);

     

    public:

      Rational();

     

      void add(Rational& _a, Rational& _b);

      void sub(Rational& _a, Rational& _b);

      void mult(Rational& _a, Rational& _b);

      void div(Rational& _a, Rational& _b);

     

      void set(long _z, long _n);

      void kuerzen();

      void ausgabe();

    };

    Die Methode ggT(), die den größten gemeinsamen Teiler (ggT) nach dem euklidischen Algorithmus (siehe auch Seite [*]) ermittelt, ist dabei wie folgt definiert:

    long Rational::ggT(long _x, long _y)

    {

      while(_y)

      {

        long r = _x % _y;

        _x = _y;

        _y = r;

      }

      return (_x);

    }   

    Schreiben Sie die restlichen Methoden und testen Sie Ihre Klasse in einer Beispielanwendung.


Konstruktoren und Destruktoren

Wenn Sie bereits mit anderen Programmiersprachen gearbeitet haben, wird Ihnen das Konzept der Konstruktoren und Destruktoren zunächst etwas merkwürdig vorkommen - handelt es sich doch um Funktionen, die aufgerufen werden, ohne dass ihr Aufruf im Programmtext steht! Mit der Zeit werden Sie aber die Abläufe verstehen und merken, dass auch da kein Geheimnis dahinter steckt.

Überblick über Konstruktoren

Ein Konstruktor dient dazu, ein Objekt in einen definierten Anfangszustand zu versetzen, das heißt Speicherplatz für die Attribute bereitzustellen und gegebenenfalls die Attribute mit sinnvollen Anfangswerten zu initialisieren.

Figure: Konstruktoren werden beim Erzeugen eines Objekts aufgerufen.



\includegraphics{images/baustelle.eps}

Besondere Eigenschaften

Konstruktoren haben einige besondere Eigenschaften, die sie von allen anderen Methoden unterscheiden:

Ein einfaches, schon klassisches Beispiel für einen selbst definierten Datentyp ist ein Datum, das einen Tag im Kalender repräsentiert. Ich will in diesem Abschnitt eine solche Klasse mit Ihnen aufbauen, um die verschiedenen Arten von Konstruktoren deutlich zu machen. Die Ausgangsversion sei:

class Datum

{

private:

  unsigned int t, m, j;

 

public:

  void setze(unsigned int _t, 

    unsigned int _m, unsigned int _j);

  void setzeAufHeute();

  void ausgeben() const;

};

Hier haben wir wieder die Klassenstruktur vor uns, die wir im vorletzten Abschnitt kennen gelernt haben. Die Attribute t, m, j (für Tag, Monat und Jahr) sind private Datenelemente, können also nur in Methoden desselben Objekts gelesen und verändert werden. Um sie von außen zu setzen, gibt es die öffentliche Methode setze(), die das Datum auf die angegebenen Werte setzt, sowie setzeAufHeute(), die das Datumsobjekt mit dem aktuellen Systemdatum initialisiert. Die dritte öffentliche Methode ausgeben() ist als konstant deklariert (const hinter den runden Klammern); das deutet darauf hin, dass darin nur Attribute gelesen, jedoch nicht verändert werden - was ja auch von einer Ausgabefunktion erwartet werden darf. Konstante Methoden dürfen auch für konstante Referenzen des Objekts aufgerufen werden.


Arten von Konstruktoren

Je nach Form und Aufgabe unterscheidet man mehrere Arten von Konstruktoren:

Im Folgenden wollen wir die verschiedenen Konstruktoren genauer betrachten.


Standardkonstruktor

Der Standardkonstruktor, der leider viel zu oft auch im Deutschen mit dem englischen Ausdruck default constructor bezeichnet wird, hat keine Argumente. Er wird immer dann aufgerufen, wenn ein Objekt dieser Klasse ohne weitere Angaben erzeugt wird.

Bei unserem Beispiel lautet die Deklaration:

class Datum

{

private:

  unsigned int t, m, j;

 

public:

  Datum();   // Standardkonstruktor

  void setze(unsigned int _t, 

    unsigned int _m, unsigned int _j);

  void setzeAufHeute();

  void ausgeben() const;

};

Bei einem Datums-Objekt ist es sehr unschön, wenn sich die Attribute in einem undefinierten Zustand befinden, dies gilt auch für den Zeitpunkt unmittelbar nach seiner Erzeugung. Selbst ein 0.0.0 ist als Datum unbrauchbar. Ganz praktisch könnte es dagegen sein, wenn das Objekt gleich mit dem heutigen Datum vorbelegt wird. Genau das erledigt unser Konstruktor:

Datum::Datum()

{

  setzeAufHeute();

}

Konstruktoren werden oft als inline-Methoden implementiert (siehe Seite [*]), also direkt in die Klassendeklaration geschrieben. Dann ist auch dem Leser der Header-Datei sofort klar, mit was ein Objekt durch den Konstruktor eigentlich initialisiert wird. So wie der Konstruktor hier steht, würde man ihn am ehesten in die Header-Datei nach der Klassendeklaration schreiben.

Ich sprach oben davon, dass eine der Aufgaben des Konstruktors sei, Speicherplatz für die Attribute bereitzustellen. Vielleicht fragen Sie sich nun, warum Sie davon in dieser Methode nichts sehen. Ganz einfach: Es passiert automatisch. Ebenso wie bei lokalen Variablen - unabhängig davon, ob ihr Typ einfach oder eine Klasse ist - kann das System selbstständig ermitteln, wie viel Speicher die Attribute benötigen, und diesen entsprechend reservieren. Das ändert sich erst, wenn Sie selbst Felder oder Objekte dynamisch anlegen wollen, das heißt selbst die Anweisung zur Speicherreservierung geben. Auch das kann innerhalb eines Konstruktors geschehen. Doch dazu kommen wir später noch.

Jetzt sehen wir uns an, wo der Konstruktor aufgerufen wird. In einem Hauptprogramm steht beispielsweise:

int main()

{

  Datum heute;   // Impliziter Konstruktoraufruf

  heute.ausgeben();

  //...

An der Stelle, wo ein Objekt vom Typ Datum erzeugt wird, wird auch dessen Konstruktor aufgerufen.

Natürlich können Sie auch mit der Klasse arbeiten und Objekte davon erzeugen, wenn diese keinen Konstruktor hat. Allerdings sind dann die Attribute in einem unbestimmten Zustand - genauso wie Variablen, die Sie nur deklariert, aber nicht initialisiert haben. Ich möchte Ihnen daher empfehlen, bei allen Ihren Objekten zumindest einen Standardkonstruktor zu definieren.

Achten \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Sie aber darauf, im Konstruktor wirklich nur die nötigsten Initialisierungen vorzunehmen und ihn möglichst klein zu halten. Da er beispielsweise keinen Rückgabewert hat, werden mögliche Fehler nicht ohne weiteres vom Programm bemerkt. Richten Sie im Zweifelsfall lieber eine zusätzliche Methode ein, in der dann kritische Initialisierungen vorgenommen werden können.

Hintergrund

Die Einfügung des Konstruktoraufrufs klappt übrigens auch bei Verschachtelungen, das heißt bei Klassen, deren Attribute wieder Objekte anderer Klassen sind. Wenn Sie beispielsweise eine Klasse haben wie:

class Tabelleneintrag

{

private:

  Datum  datum;

  //...

 

public:

  Tabelleneintrag();

  // ...

};

so wird der Konstruktor von Datum automatisch bei der Abarbeitung des Konstruktors von Tabelleneintrag aufgerufen, und zwar noch vor der ersten Anweisung im Funktionskörper von Tabelleneintrag::Tabelleneintrag().


Allgemeine Konstruktoren

Allgemeine Konstruktoren haben Argumente und können wie normale Methoden überladen werden, das heißt, es darf mehrere Konstruktoren mit unterschiedlichen Parameterlisten geben. Auch Vorgabewerte für die Parameter sind erlaubt.

Wir wollen unsere Klasse um eine vollständige Initialisierung erweitern sowie um eine, die nur den Tag enthält.

class Datum

{

public:

  Datum();

  Datum(unsigned int _t, 

    unsigned int _m, unsigned int _j);

  Datum(unsigned int _t);

  // ...

};

Mit dem ersten neuen Konstruktor wollen wir einfach alle drei Datumsteile setzen; da wir für diese Aufgabe schon die Methode setze() vorgesehen haben, können wir sie auch gleich verwenden:

Datum::Datum(unsigned int _t, 

    unsigned int _m, unsigned int _j)

{

  setze( _t, _m, _j);

}

Die Unterstriche sind eine persönliche Konvention von mir; auf diese Weise kann ich Parameter der Methode besser von Attributen unterscheiden. Manche Programmierer machen es aber auch genau umgekehrt und beginnen die Namen aller Attribute mit einem Unterstrich oder einem m_ von member. Wie Sie es damit halten, überlasse ich ganz Ihnen; nur sollten Sie mit Ihrer Konvention konsequent sein und diese überall durchhalten. Denn der Zweck solcher Regeln ist ja letztlich, den Code für sich und andere möglichst lesbar zu halten.

Die gerade gezeigte Vorgehensweise empfiehlt sich übrigens auch allgemein: Wenn Sie zulassen möchten, dass Ihr Objekt entweder von einem allgemeinen Konstruktor oder einem expliziten Methodenaufruf initialisiert wird, verwenden Sie einfach im Konstruktor auch diese Methode. Auf diese Weise haben Sie den Code konsistent gehalten und die unnötige Verdopplung von Anweisungen vermieden.

Unser zweiter neuer Konstruktor soll nur den Tag als Parameter haben, wobei Monat und Jahr dieselben wie für den heutigen Tag sein sollen.

Datum::Datum(unsigned int _t)

{

  setzeAufHeute();

  t = _t;

}

Was hier noch fehlt, ist eine Plausibilitätsprüfung, denn nicht jede Zahlenkombination ist ja ein gültiges Datum. Vielleicht überlegen Sie sich einmal selbst, wie eine solche aussehen könnte.

Auch allgemeine Konstruktoren werden automatisch bei der Definition eines Objekts aufgerufen. Die Argumente geben Sie dabei in Klammern hinter dem Objektnamen an.

int main()

{

// Standardkonstruktor

  Datum heute;             

// Konstruktor mit 3 Argumenten

  Datum ostern(4,4,1999);  

// Konstruktor mit 1 Argument

  Datum gestern(20);       

  // ...

}

Der Compiler sucht dabei anhand der Anzahl und Typen der Parameter, welchen Konstruktor er aufrufen muss.

Hintergrund

Manchmal möchte man auch vorschreiben, dass Objekte nur über einen allgemeinen Konstruktor erzeugt werden dürfen, das heißt nur mit Angabe eines Parameters. Sie können dieses Ziel unter anderem dadurch erreichen, dass Sie den Standardkonstruktor weglassen. Fehlt dieser nämlich, wenn gleichzeitig ein anderer allgemeiner Konstruktor vorhanden ist, bricht der Compiler mit einem Fehler ab, sobald eine Instanz der Klasse erzeugt werden soll.

Besonders elegant ist diese Möglichkeit jedoch nicht, zumal der Anwender der Klasse die resultierende Fehlermeldung nicht sofort im Sinne des Entwicklers interpretieren würde. Ein anderer Weg ist die Deklaration des Standardkonstruktors als private. Auf diese Weise ist aus Sicht des Compilers ein solcher Konstruktor vorhanden, wird also nicht als fehlend gemeldet. Versucht ein Benutzer der Klasse jedoch, eine Instanz ohne Angabe eines Parameters zu erzeugen, meldet der Compiler, dass der dazu notwendige Standardkonstruktor nicht öffentlich ist und daher von außen nicht aufgerufen werden darf. Dem Benutzer bleibt somit nichts anderes übrig, als einen der öffentlichen allgemeinen Konstruktoren zu verwenden und einen Parameter anzugeben.

Diese Vorgehensweise bietet sich beispielsweise bei so genannten Wrapper-Klassen an, also Klassen, die keine eigene Funktionalität haben, sondern nur einer anderen Klasse eine neue Schnittstelle geben. Nehmen wir etwa an, Sie hätten obige Klasse Datum fertig implementiert und auch in einigen anderen Funktionen und Klassen eingesetzt; nun wollen Sie Ihren Code in einem anderen Projekt wieder verwenden. Dummerweise schreiben die Arbeitsrichtlinien für dieses Projekt aber vor, dass alle Bezeichner auf Englisch sein müssen. Anstatt nun die Methoden umzubenennen und dabei zu riskieren, etwas zu vergessen, schreiben Sie einfach einen Wrapper.

class Date

{

private:

  Datum& dat;

  Date();   // privater Konstruktor

 

public:

  Date(Datum& _dat) : 

    dat(_dat) {}

  void set(unsigned int _t,

    unsigned int _m, unsigned int _j)

    { dat.setze(_t, _m, _j); }

  void setToToday()

    { dat.setzeAufHeute(); }

  void print() const

    { dat.ausgeben(); }

};

Hier kann keine Instanz erzeugt werden, ohne dass die Elementvariable dat initialisiert wird. Da die anderen Methoden auch direkt auf sie zugreifen, ist diese Restriktion sinnvoll und notwendig. Will ein Benutzer der Klasse mittels der Zeile

  Date myDate;

ein Objekt vom Typ Date erzeugen, so erhält er vom Compiler die schlichte Meldung: Date::Date() is private within this context.

Beachten Sie außerdem, dass Referenzen als Attribute immer initialisiert werden müssen. Selbst wenn der Standardkonstruktor privat ist, müssen Sie in dessen Implementierung das Attribut dat initialisieren - und das bereits in der Initialisierungsliste, zu der wir jetzt kommen.

Initialisierung mit Listen

Beim Aufruf eines Konstruktors wird noch vor dem Betreten des Methodenkörpers Speicherplatz für die Datenelemente bereitgestellt. Anschließend wird erst der Code in diesem Block abgearbeitet; darin haben wir bisher die Datenelemente mit Werten versehen.

Auf diese Weise wird aber doppelt auf die Attribute zugegriffen: einmal bei der Erzeugung und einmal bei der Zuweisung. C++ bietet die Möglichkeit, beides in einem Schritt zusammen zu erledigen. Dazu verwendet man eine so genannte Initialisierungsliste. Dabei geben Sie hinter der schließenden runden Klammer der Argumentliste, getrennt duch einen Doppelpunkt, die Attribute an, wobei die zu verwendenden Werte in runden Klammern dahinter stehen. Bei unserem Beispiel hat das etwa folgende Form:

Datum::Datum(unsigned int _t, 

    unsigned int _m, unsigned int _j)

: t(_t), m(_m), j(_j)

{

}

Dabei sind die Größen t, m und j die Attribute (deklariert im private-Abschnitt der Klasse Datum), die wir durch diesen Konstruktor mit den übergebenen Werten belegen. Eine Prüfung, ob die angegebenen Werte in einem gültigen Bereich liegen, findet dabei allerdings nicht statt.

Wie Sie sehen, kann in diesem Fall der Methodenkörper sogar ganz leer sein. Beim Aufruf des Konstruktors wird zuerst die Initialisierungsliste abgearbeitet, und zwar in der Reihenfolge, wie die Attribute in der Klasse deklariert sind - und nicht, wie sie in der Liste stehen! Generell sollten Sie daher die Datenelemente in Ihren Initialisierungslisten stets in derselben Reihenfolge aufführen wie in der Klasse. (Besonders kritisch wird dies aber erst, wenn ein Datenelement auf ein anderes angewiesen ist.)

Hintergrund

Eine Klasse darf nicht nur Variablen und Funktionen enthalten, von denen bisher immer die Rede war, sondern auch Konstanten und Referenzen. Bei diesen stellt sich das Problem der Initialisierung noch wesentlich drängender. Denn normalerweise müssen Konstanten und Referenzen gleich bei ihrer Deklaration initialisiert werden, etwa (bei bekannter Klasse Kreis):

const unsigned int MAX_SIZE = 1000;

// ...

Kreis aktuellerKreis;

Kreis& k = aktuellerKreis;

Sind sie aber Bestandteil einer Klasse, so ist es nicht erlaubt, sie gleich bei ihrer Deklaration zu initialisieren, denn Zuweisungen innerhalb der Klassendeklaration sind in C++ nicht möglich. Folgender Code ist also nicht gültig:

class Kreis;

 

class Kreisliste

{

private:

  const unsigned int MAX_SIZE = 1000;  // nicht gültig!

  Kreis aktuellerKreis;

  Kreis& k = aktuellerKreis;  // nicht gültig!

// ...

};

Diese Klasse müssen Sie zunächst ohne die Initialisierung der Konstanten und Referenzen, die sie enthält, deklarieren. (Dann brauchen Sie eigentlich das Attribut aktuellerKreis gar nicht mehr, oder?)

class Kreisliste

{

private:

  const unsigned int MAX_SIZE;

  Kreis& k;

  // ...

public:

  Kreisliste(Kreis& _k);

  // ...

};

Hier kommt jetzt die Initialisierungsliste ins Spiel. Denn Konstanten und Referenzen müssen durch eine solche Liste initialisiert werden - ein anderer Weg ist nicht erlaubt! Beim Konstruktor schreiben Sie dann beispielsweise:

Kreisliste::Kreisliste(Kreis& _k)

  : MAX_SIZE(1000), k(_k)  // eventuell weitere

{

  // ...

}

Eine solche Initialisierung müssen Sie übrigens bei jedem Konstruktor dieser Klasse angeben.

Auf diese Weise erhalten Sie die Möglichkeit, zusätzliche Arten von Elementen in Ihre Klassen aufzunehmen.


Kopierkonstruktor

Ein Kopierkonstruktor hat die Aufgabe, ein Objekt mit einem anderen derselben Klasse zu initialisieren. Dazu hat er als Parameter eine konstante Referenz auf dieses Objekt. Für unsere Datumsklasse könnte das beispielsweise lauten:

Datum::Datum(const Datum& _datum)

: t(_datum.t), m(_datum.m), j(_datum.j)

{

  cout << ``Hier ist der Kopierkonstruktor!'' 

       << endl;

}

Sie können diesen Konstruktor aufrufen wie andere auch, etwa:

Datum d1;

Datum d2(d1);

Figure: Ein Kopierkonstruktor erzeugt ein Objekt nach der Vorlage eines anderen.
\resizebox*{!}{3.5cm}{\includegraphics{images/kopierer.eps}}

Es gibt aber noch eine - anfangs eventuell verwirrende - Möglichkeit, den Kopierkonstruktor aufzurufen, nämlich in Form einer Zuweisung:

Datum d1;

Datum d2 = d1;

Und obwohl hier das Gleichheitszeichen an eine Zuweisung denken lässt, ist es keine Zuweisung, sondern eine Initialisierung! Wenn Sie aber bedenken, dass auch Variablen von Standardtypen, die Sie gleich bei der Deklaration initialisieren, mit dem Gleichheitszeichen ihre Werte erhalten, ist diese Syntax nur konsequent. Trotzdem ist die hier vorgenommene strenge Unterscheidung zwischen Zuweisung und Initialisierung gerade für den Anfänger oft schwierig. Daher nochmals ein Beispiel:

Raumfahrzeug ufo;

Raumfahrzeug transporter;

 

// Zuweisung, kein Kopierkonstruktor:

transporter = ufo; 

 

// Initialisierung mit Kopierkonstruktor:

Raumfahrzeug jaeger = ufo; 

Sie können sich also merken: Der Kopierkonstruktor tritt nur dann in Aktion, wenn ein neues Objekt erzeugt wird, aber nicht, wenn ein bereits bestehendes einen neuen Wert erhält.

Hintergrund

Braucht eigentlich jede Klasse einen Kopierkonstruktor? Diese Frage ist durchaus berechtigt, ist doch die Initialisierung eines Objekts mit einem anderen derselben Klasse eine gängige Anweisung. Und da die Situation so oft vorkommt, erzeugt der Compiler selbst einen Kopierkonstruktor, wenn der Autor der Klasse keinen bereitstellt. In diesem wird das neue Objekt erzeugt, indem alle Datenelemente Bit für Bit kopiert werden. Das klappt bei Standarddatentypen immer, womit Sie sich notieren können: Bei Klassen, deren Attribute nur Standarddatentypen haben, ist ein selbst definierter Kopierkonstruktor nicht nötig. Ist ein Attribut wiederum selbst ein Objekt, so wird beim Kopieren dessen Kopierkonstruktor aufgerufen und so weiter.

Eine Klasse braucht jedoch mindestens immer dann einen Kopierkonstruktor, wenn sie dynamisch angelegten Speicherplatz verwaltet. Denn der automatisch erzeugte kopiert im Allgemeinen nur den Anfangspunkt dieses Speichers, so dass das kopierte Objekt einen Verweis auf denselben Speicherbereich erhält wie das ursprüngliche. Und das will man beim Kopieren ja vermeiden! Daher gilt auch die Faustregel: Immer wenn für eine Klasse ein Kopierkonstruktor erforderlich ist, braucht sie auch einen Zuweisungsoperator. Mehr Details dazu erkläre ich Ihnen aber später, wenn wir den Umgang mit dynamisch angelegtem Speicher genauer unter die Lupe nehmen (ab Seite [*]).

Objekte als Rückgabewerte

Manchmal ist es sinnvoll, wenn eine Funktion oder eine Methode ein Objekt zurückliefert. Dies kann einmal in Form einer Referenz geschehen, etwa:

Datum& Log::getLogDate();

Das Problem dabei ist jedoch, dass sich diese Referenz auf ein Objekt beziehen muss, das auch nach Ende der Funktion noch gültig ist. Wenn also eine Methode eine Referenz auf ein Attribut zurückliefert, ist das völlig in Ordnung (solange dadurch nicht der Zugriffsschutz zu sehr unterwandert wird ...). Wenn aber die Funktion ein Objekt zurückliefern will, das sie lokal erst erzeugt hat, ist die Referenz völlig untauglich.

Dabei ist es doch auch möglich, ein ganzes Objekt als Ergebnis einer Funktion oder Methode zurückzugeben. Sie müssen lediglich seine Klasse als Rückgabetyp der Funktion angeben. Sehen wir uns folgendes Beispiel an, um zu erkennen, was dabei so alles vor sich geht:


1:  Datum Log::getLogDate() 

2: {
3: Datum d(29,2,2000); // zu Testzwecken fix
4: return d; // Objekt wird zurückgegeben
5: }
6:
7: int main()
8: {
9: Log logObject;
10: Datum date = logObject.getLogDate();
11: // ...
12: }
In Zeile 1 finden Sie den Kopf der Methode getLogDate() (die für unsere Zwecke immer dasselbe Datum liefert). Dort ist - wie erwartet - als Typ des Rückgabewerts die Klasse Datum vermerkt. Ein Objekt davon wird in Zeile 3 angelegt; dabei wird natürlich der entsprechende Konstruktor aufgerufen. In der vierten Zeile startet der Rücksprung. Diesen müssen wir zusammen mit dem Aufruf in Zeile 10 betrachten, um die Vorgänge zu verstehen. Dort wird ein neues Objekt der Klasse Datum angelegt und gleichzeitig initialisiert - ein Fall für den Kopierkonstruktor. Dieser erhält als Argument gerade das lokale Objekt d aus getLogDate(). Anschließend, am Ende der Methode in Zeile 5, wird d erst gelöscht. Und am Ende von main(), in Zeile 12, wird natürlich auch das Objekt date wieder entfernt.

Das heißt also: Vor dem endgültigen Ende und dem Aufräumen der Funktion wird - im Kontext des Aufrufers! - erst der Kopierkonstruktor (oder ein Zuweisungsoperator) aufgerufen, der das Objekt, das die Funktion zurückgibt, an seinen Bestimmungsort bringt.


Typumwandlungskonstruktor

Ein Typumwandlungskonstruktor ist eine spezielle Form des allgemeinen Konstruktors. Er dient dazu, andere Datentypen in die jeweilige Klasse umzuwandeln. Damit ist dann eine Regel für die implizite Typkonvertierung erklärt und Sie können an allen Stellen, wo eigentlich ein Objekt der Klasse erwartet würde, einen Wert dieses Typs angeben.

Sehen wir uns dazu gleich ein Beispiel an. Bei unserer Datumsklasse ist es sehr viel einfacher, eine Zeichenkette zur Initialisierung anzugeben als drei int-Zahlen. Daher fügen wir folgenden Konstruktor hinzu:

class Datum

{

public:

  Datum(const string& _datumstring);

  // ... Rest wie auf Seite [*] f.

};

Dann können Sie die Typumwandlung in die gewünschten Bahnen lenken und einfach schreiben:

int main()

{

  Datum d1(``29.02.2000'');

Wenn Ihnen auf Anhieb klar ist, dass diese Syntax funktioniert, sehen wir uns gleich folgende Anweisung an:

  Datum d2;

  // ...

  d2 = ``03.03.2001'';

Das ist eigentlich eine Kurzschreibweise für zwei getrennte Schritte. Zunächst wird ein temporäres Objekt unter Zuhilfenahme des Typumwandlungskonstruktors erzeugt und anschließend wird dieses dem bestehenden, nämlich d2, zugewiesen.

Noch deutlicher wird das an folgendem Beispiel: Nehmen Sie an, Sie haben eine Funktion, um eine Log-Nachricht auszugeben. Diese erhält neben der Nachricht selbst noch das Datum als Argument:

log_message(const Datum& _date,

             const string& _message);

Dann dürfen Sie dank des Typumwandlungskonstruktors schreiben:

int main()

{

  log_message(``21.02.2001'', ``Jetzt geht´s los!'');

  // ...

}

Denn intern wird dieser Aufruf vom Compiler in folgenden Block umgewandelt:

int main()

{

  {

    Datum temp(``21.02.2001'');

    log_message(temp, ``Jetzt geht´s los!'');

  }

  // ...

}

Auf diese Weise können Sie leicht eine automatische Typumwandlung bei der Parameterübergabe erlauben und damit die Flexibilität Ihrer Klasse weiter erhöhen. Es gibt übrigens auch die umgekehrte Variante, nämlich ein Objekt in einen anderen Typ umzuwandeln; mehr dazu erfahren Sie im Abschnitt Typumwandlungsoperator ab Seite [*].

Hintergrund

So praktisch diese automatische Umwandlung auch sein mag - manchmal möchte man gerade diese vermeiden. Denn dabei kann es passieren, dass der Compiler temporäre Objekte erzeugt, an die der Programmierer gar nicht gedacht hat. Da Erzeugung und Vernichtung solcher Objekte aber auch Zeit und Speicherplatz kosten, sollte man versuchen, die vollständige Kontrolle zu behalten und unerwünschte Automatismen gar nicht erst zuzulassen. Außerdem könnten Typverletzungen durch ein Zuviel an automatischer Konvertierung unentdeckt bleiben.

Aus diesem Grund wurde im ANSI/ISO-Standard von C++ das Schlüsselwort explicit eingeführt. Sie schreiben es in die Klassendeklaration vor Konstruktoren, die nur ein Argument haben (oder bei denen die weiteren Argumente wegen der Vorgabewerte verzichtbar sind). Beispielsweise könnten wir für unser Datumsbeispiel eine eigene Klasse Jahr einführen. Diese hätte neben einer besseren Möglichkeit zur Typprüfung auch den Vorteil, dass wir dabei auf eine fehlende Jahrhundertangabe reagieren könnten.

class Jahr 

{

public:

  explicit Jahr(unsigned int _j);

  unsigned int getJahr() const;

private:

  unsigned int j;

};

 

Jahr::Jahr(unsigned int _j)

{

  if (_j < 10)

    j = _j + 2000;

  else

    if (_j < 100)

      j = _j + 1900;

    else

      j = _j;

}

Damit sind von folgenden Anweisungen einige nicht erlaubt:

SpeicherJahr(Jahr _j);  // Beispielfunktion

 

Jahr f()

{

  Jahr heuer(2000);  // erlaubt

 

  SpeicherJahr(2000); // nicht erlaubt!!

  SpeicherJahr(Jahr(2000)); // explizit: erlaubt

 

  return 99;  // nicht erlaubt!!

}

In der Klasse Datum speichern wir die Jahreszahl statt als Ganzzahl nun als Objekt vom Typ Jahr. Dabei müssen wir natürlich auch den Konstruktor anpassen.

class Datum

{

public:

  Datum(unsigned int _t, 

    unsigned int _m, Jahr _j);

  // ...

};

Auf diese Weise können wir beispielsweise vermeiden, dass die Schnittstelle falsch benutzt wird. Gerade beim Datum gibt es nämlich sehr unterschiedliche Schreibweisen. Ohne das Objekt Jahr wäre folgende Erzeugung auch richtig gewesen:

  Datum d1(2000,11,20);

Dieser Fehler kann nicht vom Compiler bemerkt werden, sondern erst zur Laufzeit von der Bereichsüberprüfung der Methode setze(). Aber selbst der bislang korrekte Aufruf

  Datum d2(20,11,2000);

führt nun zu einem Fehler. Denn dieser würde eine implizite Konvertierung von einer int-Zahl in ein Jahr-Objekt voraussetzen, die wir ausdrücklich untersagt haben. Die Erzeugung muss nun lauten:

  Datum d2(20,11,Jahr(2000));

Ähnliches kann man auch noch mit dem Monat machen, um die Schnittstellen ganz abzusichern. Für unser kleines Beispiel sind solche Wrapper sicherlich übertrieben; an kritischen Stellen Ihrer Programme können Sie auf diese Weise jedoch viel an Sicherheit und damit an Qualität gewinnen.


Destruktoren

Genauso wie es Methoden gibt, die bei der Erzeugung eines Objekts aufgerufen werden, gibt es auch welche, die bei seiner Vernichtung in Aktion treten.

Figure: Destruktoren räumen nicht mehr benötigte Objekte auf.



\includegraphics{images/bulldozer.eps}

Ein Destruktor übernimmt dann die Aufräumarbeit, wenn das Objekt nicht mehr benötigt wird. Ebenso wie bei den Konstruktoren gilt: Gibt der Autor der Klasse keinen Destruktor vor, erzeugt der Compiler automatisch einen. Sie werden in Ihren Klassen Destruktoren hautpsächlich dann verwenden, wenn Sie mit dynamisch verwaltetem Speicher arbeiten und das Objekt den Speicher, den es für sich in Anspruch genommen hat, am Ende seiner Lebenszeit wieder freigeben muss. Mehr dazu werde ich Ihnen daher erklären, wenn wir über dynamische Speicherverwaltung reden (ab Seite [*]).

Ein weiteres Aufgabenfeld für Destruktoren sind offene Datei- oder Datenbankverbindungen, die beim Löschen des Objekts geschlossen werden müssen.

Syntax des Destruktors

Der Destruktor hat denselben Namen wie die Klasse, allerdings mit einer vorangestellten Tilde ~, zum Beispiel:

class Datum

{

public:

  Datum();  // Standardkonstruktor

  ~Datum(); // Destruktor

  // ...

};

 

inline Datum::~Datum()

{

  cout << ``Hier ist der Destruktor!'' << endl;

}

Jede Klasse kann höchstens einen Destruktor haben. Dieser hat keinerlei Rückgabewert (wie der Konstruktor) und keine Argumente. Da er meist sehr wenig tut, bietet es sich an, ihn als inline zu deklarieren.

Gültigkeitsbereiche von Objekten

Für Objekte, die innerhalb eines Blocks oder einer Funktion angelegt werden, endet ihre Gültigkeit (ihre Lebensdauer) mit der schließenden Klammer }. Zu diesem Zeitpunkt wird auch der Destruktor aufgerufen. Wenn Sie ein Objekt außerhalb aller Funktionen deklarieren, nennt man es global (dasselbe gilt auch für Variablen). Globale Objekte sind in jeder Funktion sichtbar und dort wie lokale Objekte verwendbar. Für globale Objekte wird der Konstruktor vor der ersten Anweisung in main() und der Destruktor nach der Freigabe aller in main() instanziierten Objekte aufgerufen.

#include <iostream.h>

#include ``Datum.hh''

 

Datum heutigesDatum__;  // globales Objekt

 

int main()

{

  cout << ``Heute ist der ``;

  heutigesDatum__.ausgeben();

  return 0;

}

Globale Objekte sind fast immer Notlösungen. War man in C noch ziemlich großzügig, was den Umfang an globalen Variablen anging, so gibt es in C++ fast immer bessere Lösungen. Wenn es aber einmal doch eine globale Variable sein muss, sollten Sie sie entsprechend kennzeichnen, etwa mit dem Suffix _g oder zwei angehängten Unterstrichen wie in heutigesDatum__.

Beispiel: Benutzerinformationen

In diesem Beispiel wollen wir eine Klasse für Benutzer Ihres Linux-Rechners erstellen. Darin sollen der Login-Name, der echte Name und die Gruppenzuordnung gespeichert werden. Dazu müssen wir uns zunächst ansehen, wie Benutzer überhaupt durch Linux verwaltet werden.

Die Benutzerverwaltung unter Linux

Auf einem Einzelplatzsystem sind die Informationen über die eingetragenen Benutzer in der Datei /etc/passwd abgelegt. Dazu gehören der Benutzername, eine eindeutige ID, IDs für die Zugehörigkeit zu Benutzergruppen, das Passwort, der Name des Home-Verzeichnisses, die Shell, die beim Login gestartet wird, sowie eine textuelle Beschreibung, im Allgemeinen der vollständige Name des Benutzers. Bei einem vernetzten Rechner können diese Informationen auch zentral abgelegt sein und über den Network Information Service (NIS) zugänglich gemacht werden (früher als gelbe Seiten - yellow pages - bekannt). Diese Daten (außer dem Passwort) werden übrigens auch vom Kommando finger ausgegeben.

Das System stellt für den Zugriff auf diese Daten aus C- und C++-Programmen verschiedene Funktionen zur Verfügung:

Der Umgang mit diesen Funktionen erfordert etwas Zeiger-Syntax, auf die ich erst ab Seite [*] zu sprechen kommen werde. Sie können also entweder dort nachschlagen oder mir zunächst einmal glauben, dass die Anweisungen korrekt sind.

Die \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Datei pwd.h alleine genügt übrigens nicht. Sie ist nämlich selbst wieder von anderen Header-Dateien abhängig, die Sie ebenfalls einbinden müssen. Die komplette Liste lautet:

#include <pwd.h>

#include <sys/types.h>

#include <unistd.h>

Mit diesen dreien sollten Sie dann die gerade vorgestellten Funktionen problemlos nutzen können. Auch in der Beispieldatei Benutzer.cc, die Sie auf beiliegender CD-ROM finden, sind diese Header verwendet.

Die Klassendeklaration

Als Daten aus der Passwortdatei interessieren uns hier nur der Login-Name, der echte Name sowie die Gruppen-ID. Zur Speicherung der Texte verwenden wir die Klasse string, von der Sie an dieser Stelle eigentlich kaum etwas wissen müssen; die Zuweisung mit = ist sehr offensichtlich und die Ausgabe mit cout auch unproblematisch. (Wenn Sie doch mehr wissen wollen: Seite [*].)

Im öffentlichen Teil statten wir die Klasse mit einem Standardkonstruktor aus, der die Daten lediglich initialisiert, und zwei allgemeinen Konstruktoren, wobei der eine eine Benutzer-ID und der andere einen Benutzernamen erwartet. Ferner wollen wir auch eine nachträgliche Initialisierung mit einem Benutzernamen zulassen sowie das Objekt auf den aktuellen Benutzer setzen können und auch die enthaltenen Daten auf den Bildschirm ausgeben.

Unter diesen Methoden sind zwei fast identisch, was vielleicht auf den ersten Blick nicht so offensichtlich ist. Ich meine den allgemeinen Konstruktor, der die Attribute mit den Daten eines Benutzers belegt, von dem die ID bekannt ist, und das Setzen auf den aktuellen Benutzer. Denn von diesem erhält man nämlich auch die ID, wenn man die Funktion getuid() ruft. Somit lohnt es sich, die Funktionalität in eine private Methode zu stecken und diese aus den beiden öffentlichen aufzurufen.

Die Initialisierungsmethoden haben als Rückgabewert true oder false, je nachdem, ob sie fehlerfrei arbeiteten oder nicht. Somit erhalten wir folgende Deklaration:

class Benutzer

{

private:

  string login;

  string echterName;

  long   gruppenId;

  bool   init(uid_t _benutzerId);

 

public:

  Benutzer() : gruppenId(0) {}

  Benutzer(const string& _benutzerName)

    { init(_benutzerName); } 

  Benutzer(uid_t _benutzerId)

    { init(_benutzerId); } 

  bool init(const string& _benutzerName);

  bool setzeAufAktuellen();

  void ausgeben() const;

};

Im Standardkonstruktor müssen wir nur die Gruppen-ID mit einem Wert belegen, da die anderen beiden Attribute selbst Objekte sind und deren Standardkonstruktor aufgerufen wird.

Benutzerinformationen aus der ID

Die private init()-Methode arbeitet folgendermaßen:

bool Benutzer::init(uid_t _benutzerId)

{

  // Ausgabevariable deklarieren

  struct passwd* benutzer_info = 0;

 

  // Benutzer-Info holen

  benutzer_info = getpwuid(_benutzerId);

 

  // Falls ungleich: Benutzer existiert

  if (benutzer_info)

  {

    // Daten kopieren und ausgeben

    login = benutzer_info->pw_name;

    echterName = benutzer_info->pw_gecos;

    gruppenId = benutzer_info->pw_gid;

    cout << "Benutzer " << login

         << " heißt " << echterName << endl;

  }

  else

  {

    // Fehler melden

    cerr << "Benutzer mit Id " << _benutzerId

         << " nicht gefunden!" << endl;

    login = "";

    echterName = "";

    gruppenId = 0;

    return false;

  }

  

  return true;

}

Die Syntax mag Ihnen etwas schwer durchschaubar vorkommen, davon aber abgesehen ist der Aufbau dieser Funktion recht einfach. Die Informationen werden abgefragt und anschließend ausgewertet. Existiert der Benutzer, werden die Daten kopiert und teilweise ausgegeben; existiert er nicht, dann erscheint eine Fehlermeldung.

Der Aufbau der anderen init()-Methode ist fast identisch. Nur wird dort statt getpwuid() die Funktion getpwnam() aufgerufen. Ich lasse das Listing also hier weg.

Die beiden anderen Methoden der Klasse sind ziemlich elementar. Dabei ist setzeAufAktuellen() so klein, dass wir sie gleich als inline deklarieren können (siehe auch Seite [*]).

inline bool Benutzer::setzeAufAktuellen()

{

  return init(getuid());

}

Die Ausgabemethode setzen wir als konstant, da sie keine Datenelemente des Objekts verändert, sondern nur ausgibt. (Konstante Methoden hatten wir auf Seite [*] besprochen.)

void Benutzer::ausgeben() const

{

  cout << "Benutzer:    " << login << endl;

  cout << "Echter Name: " << echterName << endl;

  cout << "Gruppe:      " << gruppenId << endl;

  cout << endl;

}

Einsatz der Klasse

Anwenden können wir die Klasse auf verschiedene Arten:

int main()

{

  bool ergebnis;

  // Standardkonstruktor

  Benutzer u;

 

  // Konstruktor mit Argument

  // UID 0 entspricht root

  Benutzer root(0);

 

  cout << "Leerer Benutzer: " << endl;

  u.ausgeben();

 

  cout << "Objekt mit Root: " << endl;

  root.ausgeben();

 

  ergebnis = u.init("markus");

  if (ergebnis)

  {

    cout << "Initialisierter Benutzer markus: " 

         << endl;

    u.ausgeben();

  }

 

  ergebnis = u.setzeAufAktuellen();

  if (ergebnis)

  {

    cout << "Aktueller Benutzer: "<< endl;

    u.ausgeben();

  }

  return 0;

}

Die Ausgabe hängt dabei natürlich von Ihrem System ab. Wenn es dort beispielsweise keinen Benutzer markus gibt, werden Sie eine Fehlermeldung statt Benutzerinformationen erhalten. Bei mir war auf dem Bildschirm zu lesen:

Benutzer root heißt root (Superuser)

Leerer Benutzer:

Benutzer:

Echter Name:

Gruppe:      0

 

Objekt mit Root:

Benutzer:    root

Echter Name: root (Superuser)

Gruppe:      0

 

Benutzer markus heißt Markus Zimmermann

Initialisierter Benutzer markus:

Benutzer:    markus

Echter Name: Markus Zimmermann

Gruppe:      100

 

Benutzer thomas heißt Thomas Wieland

Aktueller Benutzer:

Benutzer:    thomas

Echter Name: Thomas Wieland

Gruppe:      100     

Sie sollten an diesem Beispiel Möglichkeiten und Grenzen des Einsatzes von Konstruktoren kennen lernen. Sie können damit ein Objekt gleich bei seiner Erzeugung mit sinnvollen Werten belegen lassen, wie hier mit den Benutzerinformationen. Allerdings ist es im Konstruktor kaum möglich, Fehler abzufangen und zu behandeln. Sie sollten daher eigentlich nur solche Methoden im Konstruktor aufrufen, die garantiert nicht schief gehen können. Unser Design von oben war in diesem Sinne nicht optimal.

Zusammenfassung

Folgende Gesichtspunkte aus diesem Abschnitt sollten Sie festhalten:


Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Der Kopierkonstruktor für eine Klasse X wird entweder in der Form X::X(X& a) oder (im Allgemeinen besser) als X::X(const X& a) deklariert. Warum nimmt man statt der Referenz auf X nicht einfach das Objekt X und deklariert X::X(X a)?

  3. Erweitern Sie die Klasse Rational aus Abschnitt [*] (Seite [*]) um einen Kopier- und einen Typumwandlungskonstruktor, der Ganzzahlen vom Typ long akzeptiert. Welche zusätzlichen Verwendungsmöglichkeiten ergeben sich dadurch?

  4. Schreiben Sie zwei Klassen A und B, die in ihren Konstruktoren und Destruktoren jeweils einen Text ausgeben (etwa Hier ist Konstruktor von A). Erweitern Sie diese Klassen zu einem Programm, in dem

    Beobachten Sie, wann welche Konstruktoren und Destruktoren aufgerufen werden und erklären Sie das Verhalten dieses Programms.


Vererbung und Polymorphismus

Eines der bedeutsamsten Konzepte der objektorientierten Programmierung ist die Vererbung. Sie hilft Ihnen bei der logischen Gliederung Ihrer Klassen und Objekte und erleichtert die Wiederverwendung von bestehendem Code. In diesem Abschnitt werden Sie erfahren, was es damit auf sich hat und worauf Sie achten müssen.

Basisklassen und abgeleitete Klassen

Sie wissen jetzt schon, dass Sie ein größeres objektorientiertes Programm nicht einfach beginnen sollten, indem Sie die erste Zeile Code eintippen. Der erste Schritt (oder einer der ersten Schritte) ist immer die Analyse, mit welchen Objekten das Programm eigentlich umgehen soll. Bilden Sie davon eine Abstraktion, gelangen Sie zu den Klassen. Wenn Sie diesem Ansatz folgen, werden Sie bei vielen Ihrer Programme auf das Problem stoßen, dass Sie zwei Objekte vorliegen haben, die sich zwar weitgehend ähneln, aber eben doch an einigen Stellen voneinander abweichen, wie das Raumfahrzeug und der Satellit in Abbildung [*]. (Im mittleren Drittel der Kästen stehen die Attribute, im unteren die Methoden.)

Figure: Ein Satellit kann kein Raumfahrzeug sein.



\resizebox*{10.5cm}{!}{\includegraphics{images/verwandte_klassen.eps}}



Die Lösung besteht nun darin, Gemeinsamkeiten der Klassen in einer neuen Klasse zusammenzufassen, der Basisklasse (oder Oberklasse). Diese ist dann der Stammvater der anderen. Ihre Nachfahren, die abgeleiteten Klassen (oder Unterklassen), besitzen alle Attribute und Methoden der Basisklasse sowie zusätzliche eigene. Eine abgeleitete Klasse erbt also die Eigenschaften und das Verhalten ihrer Oberklasse, ähnlich wie Eltern ihren Kindern große Nasen und Vorlieben für Süßigkeiten vererben.

Ein Beispiel dazu sehen Sie in Abbildung [*]Fig:Basisklasse. Dort ist der Zusammenhang noch aus einem anderen Blickwinkel dargestellt. In der Modellierungssprache UML verweist die Unterklasse nämlich durch den Pfeil auf ihre Oberklasse, die dann als Generalisierung bezeichnet wird.

Das bringt uns zu folgenden Merksätzen:

Figure: Abgeleitete Klassen erben Attribute und Methoden von ihren Basisklassen.



\resizebox*{10.5cm}{!}{\includegraphics{images/basisklasse.eps}}



Ihnen stehen also in der Unterklasse alle Attribute und Methoden der Oberklasse zur Verfügung. Sie können zwar noch weitere Elemente hinzufügen oder bestehende Methoden ändern, aber keine entfernen!

Vererbung in C++

Betrachten wir nun, wie dieses gerade so allgemein beschriebene Konzept in der Syntax von C++ aussieht. Bei der Basisklasse aus Abbildung [*] ist noch (fast) alles wie gewohnt.

class Raumobjekt 

{

protected:

  string bezeichnung;

  string herkunft;

  float hoehe;

  unsigned long geschw;

 

public:

  Raumobjekt();

  Raumobjekt(const Raumobjekt& _anderes);

  ~Raumobjekt();

  virtual bool setGeschwindigkeit(

    unsigned long _tempo);

  virtual unsigned long getGeschwindigkeit();

};

Ist Ihnen ein Unterschied zu unseren bisherigen Beispielen für Klassen aufgefallen? Ich habe hier anstatt private das Schlüsselwort protected verwendet. Das regelt den Zugriff unter Berücksichtigung der Vererbung - doch dazu später mehr, Sec:VererbZugriff. Außerdem habe ich hier noch ein neues Schlüsselwort verwendet, nämlich virtual. Dazu kommen wir auf Seite [*].

Die abgeleitete Klasse hat eine ganz ähnliche Form. Zur Kennzeichnung als Unterklasse fügt man lediglich bei der Klassendeklaration hinter dem Klassennamen die Oberklasse an, getrennt durch einen Doppelpunkt und die Zugriffsregelung, meist public. Danach müssen Sie auch nur die Klassenelemente angeben, die Sie hinzufügen beziehungsweise ändern wollen; die ererbten sind bereits automatisch enthalten.

class Raumfahrzeug : public Raumobjekt

{

protected:

  unsigned long hoechstgeschw;

  Bewegung zustand;

  Funktion grad;

 

public:

  Raumfahrzeug();

  Raumfahrzeug(const Raumfahrzeug& _anderes);

  ~Raumfahrzeug();

  void starten();

  void landen(); 

  void andocken(Raumfahrzeug& _anderes);

};

Überschreiben von Methoden

Halten wir fest: Eine Klasse erbt die Methoden ihrer Basisklasse. Nun hat sie aber unter Umständen zusätzliche Attribute, die Einfluss auf diese Methoden haben. Beispielsweise verfügt Raumfahrzeug über die Eigenschaft hoechstgeschw, welche bei der Erhöhung der Geschwindigkeit in der Methode setGeschwindigkeit() von Bedeutung ist. Der Ausweg ist, die Methode in der abgeleiteten Klasse wie angedeutet zu modifizieren. Was ist damit gemeint? Sie haben zwei Möglichkeiten:

  1. Die Methode in der abgeleiteten Klasse ersetzt die geerbte Methode komplett.
  2. Die Methode in der abgeleiteten Klasse ruft die Methode der Basisklasse auf und fügt noch eigene Befehle hinzu.
Um eine Methode zu überschreiben, deklarieren und implementieren Sie sie einfach in der Unterklasse erneut. Achten Sie aber darauf, dass sie in Namen und Signatur mit der Basisklasse übereinstimmen muss. Wenn Sie also etwa deklarieren

class Raumfahrzeug : public Raumobjekt

{

public:

  void setGeschwindigkeit(unsigned long _tempo);

  // weiter wie oben

};

können Sie diese völlig neu schreiben:

void Raumfahrzeug::setGeschwindigkeit(

  unsigned long _tempo)

{

  // eigener Code

}

oder Sie können die Funktion aus der Basisklasse aufrufen und dann ihren zusätzlichen Code anfügen - oder umgekehrt. Nun müssen Sie dem Compiler auch mitteilen, dass Sie nicht die aktuelle Methode nochmals (rekursiv) aufrufen wollen, sondern einen Zugriff auf die gleichnamige der Basisklasse beabsichtigen. Dazu geben Sie den Klassennamen mit an, getrennt durch den Bereichsoperator ::.

void Raumfahrzeug::setGeschwindigkeit(

  unsigned long _tempo)

{

  if (geschw < hoechstgeschw)

    Raumobjekt::setGeschwindigkeit(_tempo);

  // eventuell weiterer Code

}

Noch etwas können Sie an diesem kurzen Stück Code erkennen, nämlich dass Sie nichts erkennen können - Sie können im Allgemeinen nicht erkennen, ob ein Attribut oder eine Methode, auf die Sie zugreifen, zur Klasse selbst gehört (wie hoechstgeschw) oder zu einer Oberklasse (wie geschw).


Typumwandlung von abgeleiteten Klassen

Sie dürfen einem Objekt der Basisklasse immer ein Objekt der abgeleiteten Klasse zuweisen, zum Beispiel:

void f()

{

  Raumobjekt raum_obj;

  Raumfahrzeug raum_fahrz;

 

  raum_obj = raum_fahrz;

  // ...

}

Allerdings werden dabei nur die Datenelemente übernommen, die auch in der Oberklasse vorkommen. Umgekehrt ist eine solche Zuweisung nicht möglich.

Allgemein heißt das, dass die Zuweisung beziehungsweise die automatische Typumwandlung von einem Unter- in ein Oberklassenobjekt stets zulässig ist. Wenn die Unterklasse zusätzliche Attribute enthält, ist eine solche Umwandlung mit einem Informationsverlust verbunden, denn die zusätzlichen Werte fallen unter den Tisch. Im umgekehrten Fall ist genau dies auch der Grund, warum keine Zuweisung und keine Konvertierung möglich ist: Wenn die Unterklasse weitere Attribute enthält, welchen Wert sollen diese anschließend haben? Eine Erzeugung von Information ist eben nicht zulässig.

Die Möglichkeit zur automatischen Konvertierung von Unter- in Oberklasse erlaubt es Ihnen auch, Funktionen oder Methoden, die eigentlich ein Oberklassenobjekt erwarten, mit einem vom Typ der Unterklasse zu versorgen.

Nehmen wir beispielsweise an, es gäbe noch eine weitere Klasse namens Kampfflugzeug, die von Raumfahrzeug abgeleitet ist. Dann dürfen Sie schreiben:

void g()

{

  Kampfflugzeug  jaeger;

  Raumfahrzeug   station;

 

  // erwartet Typ Raumfahrzeug&

  station.andocken(jaeger);  

  // ...

}

Oftmals werden Sie nicht mit den Objekten selbst hantieren, sondern nur mit Referenzen darauf (wie auf Seite [*]). Für diese gilt dasselbe analog. Wie in diesem Beispiel gesehen, dürfen Sie der Methode andocken() statt einer Referenz auf ein Basisklassenobjekt (vom Typ Raumfahrzeug) auch eine Referenz eines Objekts einer abgeleiteten Klasse übergeben, nämlich von Kampfflugzeug.

Vererbungshierarchien

Es wäre fast langweilig, wenn Sie immer nur eine Ableitung von einer Klasse bilden könnten. In vielen Fällen ist es sinnvoll, mehrere Stufen von Ober- und Unterklassen zu definieren. Dabei wird die Schnittstelle der Klassen in jeder Stufe um neue Elemente erweitert. Auf diese Weise gelangt man von der ganz allgemeinen zur hochspezialisierten Klasse.

In Abbildung [*] sehen Sie ein Beispiel für eine so genannte Vererbungshierarchie. In einer solchen Darstellung steht die Basisklasse an der Spitze und darunter die jeweils von ihr abgeleiteten Klassen.

Figure: Die Vererbung über mehrere Stufen spiegelt die logische Struktur der Anwendungs- objekte wider.



\resizebox*{1\columnwidth}{!}{\includegraphics{images/hierarchie.eps}}



Besonders in Sammlungen von Klassen, so genannten Klassenbibliotheken, kommen solche Hierarchien von Klassen häufiger vor. Besonders beliebt sind sie für Benutzeroberflächen; beispielsweise basiert die beliebte grafische Linux-Benutzeroberfläche KDE auf der Klassenbibliothek Qt, die auch eine umfangreiche Hierarchie aufweist (siehe auch Seite [*] und Seite [*]).

Noch eine Warnung: Anfänger, die das Konzept der Vererbung erst einmal verinnerlicht haben, neigen oft dazu, damit allzu großzügig umzugehen. Nicht immer ist Vererbung das Allheilmittel! Erfahrene Programmierer setzen sie sogar relativ sparsam ein. Nur dort, wo wirklich zwischen zwei Klassen ein enger logischer Zusammenhang besteht, sollten Sie auch eine Basisklasse der beiden definieren, um die Gemeinsamkeiten zu nützen und doppelte Implementierungen zu vermeiden. Aber ein mehr oder weniger künstlich erzeugter oder nur auf Implementierungsaspekten begründeter Zusammenhang ist meist ein schlechter Grund für eine Hierarchiebildung.

Ein anderer Designfehler, der auch von Profis oft begangen wird, entsteht dadurch, dass beim Entwurf der Vererbungshierarchie zu stark auf die Daten und zu wenig auf die Ereignisse und Botschaften geachtet wird. Diese Herangehensweise bezeichnet man auch als data-driven design. Wenn sich eine Unterklasse nur dadurch von ihrer Oberklasse unterscheidet, dass sie zusätzliche Daten enthält, so spricht dies nicht für ein ausgeprägt objektorientiertes Denken des Entwicklers. Beim Entwerfen der Klassen Ihrer Anwendung sollten Sie daher eher ein event-driven design anstreben, das heißt weniger darauf achten, mit welchen Daten Sie es zu tun haben, sondern sich - besonders zu Beginn - eine genaues Bild von den Nachrichten machen, die Ihre Objekte miteinander austauschen. Wenn Sie dann abgeleitete Klassen bilden, sollten das nur solche sein, die eine zusätzliche Funktionalität gegenüber ihren Basisklassen aufweisen.

Vorteile der Vererbung

So weit die Theorie. Ich gehe davon aus, dass Sie sich trotzdem noch nicht so genau vorstellen können, wie man Vererbung einsetzt und welchen Nutzen sie hat. Hinsichtlich des Einsatzes kann ich Ihnen nur raten, immer wieder zu experimentieren und Programme zu schreiben; dann bekommen Sie am schnellsten ein Gefühl dafür, wie Sie die Mittel, die Ihnen die Sprache bietet, am besten einsetzen. Über den Nutzen will ich hier ein paar Worte sagen.

Vom Beginn dieses Abschnitts kennen Sie bereits einen Vorteil von Vererbung:

Weitere Vorzüge sind:

Vererbung ist ein wesentliches Merkmal objektorientierter Programmierung. An ihr zeigen sich einmal mehr die Vorteile gegenüber der strukturierten Programmierung (siehe auch Seite [*]):


       
Strukturierte Programmierung Objektorientierte Programmierung    
  Meist dupliziert man beim Hinzufügen einer ähnlichen Funktion die erste und ändert sie entsprechend ab (Copy/Paste ). Nachteil: Wird die Ausgangsfunktion später modifiziert, muss auch die andere angepasst werden. Hier bleibt die Funktionalität der Basisklasse erhalten; wird sie modifiziert, wird das in allen abgeleiteten Klassen wirksam.  

Erzeugung von Unterklassenobjekten

So sinnvoll und praktisch das Überschreiben von Methoden in einer Unterklasse auch ist, für einen Typ von Methode ist er nicht zulässig: für Konstruktoren und Destruktoren. Da diese im Namen mit dem Klassennamen übereinstimmen müssen, sind sie (leider) in jeder Klasse getrennt zu implementieren. Damit Ihr Objekt korrekt initialisiert wird, sollten Sie den Konstruktor der Basisklasse im Konstruktor der abgeleiteten Klasse aufrufen.

Das ist aber nur innerhalb eine Initialisierungsliste erlaubt. Für diese gilt dann folgende Reihenfolge:

  1. Konstruktor(en) der Oberklasse(n) (eine Klasse kann auch mehr als eine Oberklasse haben, doch dazu kommen wir später)
  2. Initialisierungen der Datenelemente der Klasse
Sie dürfen dort aber keine geerbten Datenelemente initialisieren! Das ist dem Konstruktor der Klasse vorbehalten, die diese definiert.

Der Standardkonstruktor wird automatisch immer aufgerufen; wenn Sie sich also mit diesem zufrieden geben wollen, brauchen Sie keinerlei eigenen Aufruf einfügen - das erledigt der Compiler für Sie. Anders sieht es aus, wenn Sie allgemeine oder Kopierkonstruktoren verwenden wollen. Hier geben Sie den Namen an und in Klammern das passende Argument dahinter. Für unser Beispiel führt das beim Kopierkonstruktor etwa zu folgendem Code:

Raumfahrzeug::Raumfahrzeug(

    const Raumfahrzeug& _anderes)

  : Raumobjekt(_anderes), 

    hoechstgeschw(_anderes.hoechstgeschw),

    zustand(_anderes.zustand),

    grad(_anderes.grad)

{}

Hier ist sogar mit der Initialisierungsliste die Aufgabe des Konstruktors vollständig erledigt, so dass der Methodenkörper leer bleiben kann.

Beachten Sie auch, dass wir in diesem Code auf die automatische Typumwandlung von Unterklasse auf Oberklasse vertrauen, um die es im letzten Abschnitt ging. Eigentlich erwartet der Kopierkonstruktor von Raumobjekt ja eine konstante Referenz auf ein Objekt dieser Klasse. Die zweite Zeile des Beispiels zeigt, dass er aber auch mit einem Objekt (namens _anderes) vom Typ der Unterklasse Raumfahrzeug anstandslos arbeitet, da hier die automatische Konvertierung zum Einsatz kommt.

Übrigens müssen Sie beim Destruktor nie denjenigen der Basisklasse selbst aufrufen. Da es davon immer nur einen pro Klasse gibt, erledigt der Compiler die Aufrufe selbstständig.

Hintergrund

An dieser Stelle möchte ich noch kurz auf die Frage eingehen, wie die Vererbung praktisch im Programm organisiert wird. Enthält ein Unterklassenobjekt einfach nur ein paar mehr Attribute, nämlich die geerbten, oder stecken da noch mehr Informationen drin?

In der Tat ist es so, dass ein Objekt im modernen C++ weiß, welche Basisklasse in ihm steckt. Dazu enthält es ein Subobjekt vom Typ dieser Basisklasse (Abbildung [*]). Normalerweise ist es für den Benutzer der Klasse unsichtbar. Es wird aber beispielsweise für die Konvertierung verwendet; immer wenn Sie ein Objekt in seine Oberklasse umwandeln, erhalten Sie dieses Subobjekt. Damit wird auch klar, warum die umgekehrte Konvertierung nicht möglich ist: Sie können zwar aus einem größeren Objekt einen Teil (nämlich besagtes Subobjekt) herausnehmen, aber nicht ein solches zu dem größeren aufblasen. Dafür müssten Sie dann schon eine Regel selbst definieren, etwa in Form eines Typumwandlungsoperators.

Figure: Objekte von Unterklassen enthalten Subobjekte ihrer Oberklassen.



\resizebox*{0.6\columnwidth}{!}{\includegraphics{images/unterklasse.eps}}

Bei der Erzeugung des Objekts spielt ebenfalls das enthaltene Teilobjekt aus der Basis eine Rolle. Im Konstruktor gilt nämlich folgende Regel für die Reihenfolge der Erzeugung:

  1. Konstruktor der Basisklasse wird aufgerufen.
  2. Konstruktor der Attribute, die selbst Objekte sind, wird aufgerufen, und zwar in der Reihenfolge ihrer Deklaration in der Klasse.
  3. Der Methodenkörper des Konstruktors wird abgearbeitet.
Wenn eines der beteiligten Objekte wieder Oberklassen- oder Elementobjekte enthält, verschachteln sich die Aufrufe entsprechend.

Bevor Sie also in den Methodenkörper gelangen, können unter Umständen noch eine Reihe anderer Aufrufe getätigt werden - und natürlich auch Fehler verursachen. Diese sind manchmal nicht leicht zu finden, da die Konstruktoraufrufe ja automatisch eingefügt werden und im Code nicht sichtbar sind. Erst wenn Sie also die gerade beschriebene Regel berücksichtigen, können Sie den genauen Ablauf der Aufrufe nachvollziehen.

Sehen wir uns als Beispiel nochmals die Klasse Raumfahrzeug an:

class Raumfahrzeug : public Raumobjekt

{

protected:

  unsigned long hoechstgeschw;

  Bewegung zustand;

  Funktion grad;

  // ...

};

Wenn wir davon ausgehen, dass auch Bewegung und Funktion Klassen sind, so beinhaltet der simple Standardkonstruktor

Raumfahrzeug::Raumfahrzeug()

: hoechstgeschw(0L)

{}

folgende Anweisungen und Aufrufe:

  1. Standardkonstruktor der Klasse Raumobjekt wird aufgerufen.
  2. Das Attribut hoechstgeschw wird mit 0 initialisiert.
  3. Standardkonstruktor der Klasse Bewegung wird aufgerufen.
  4. Standardkonstruktor der Klasse Funktion wird aufgerufen.
Beim Kopierkonstruktor, der am Anfang dieses Abschnitts steht, haben wir die Aufrufe jeweils explizit angegeben. Somit verstehen Sie jetzt auch, wieso die Initialisierung über eine Liste effektiver ist als über eine Anweisung im Methodenkörper: Für jedes Objekt, dessen Konstruktor nicht in der Initialisierungsliste angegeben ist, wird automatisch der Standardkonstruktor gerufen; wenn das Objekt aber mit anderen Werten vorbelegt werden soll, muss es bei dieser Vorgehensweise zweimal angefasst werden. Das können Sie vermeiden, wenn Sie gleich den passenden Konstruktor verwenden.


Zugriffsbeschränkungen

Sie kennen von Seite [*] bereits zwei Möglichkeiten, den Zugriff auf Klassenelemente zu erlauben oder zu beschränken:

Im Zusammenhang mit der Vererbung treten mit dieser strengen Trennung Probleme auf. Können Sie sich denken, welche? Abgeleitete Klassen enthalten zwar auch die privaten Elemente ihrer Basisklassen, können aber nicht darauf zugreifen. Um hier etwas mehr Flexibilität zu ermöglichen, gibt es noch einen weiteren Spezifizierer:

Beim Klassenentwurf sollten Sie also alle nach außen, das heißt aus anderen Klassen und Funktionen, nicht sichtbaren Klassenelemente als protected deklarieren, damit deren Status in eventuell davon zu bildenden Unterklassen erhalten bleibt. Nur solche Elemente, von denen Sie nicht möchten, dass eine abgeleitete Klasse sie anders als über die öffentlichen Methoden modifziert, sollten Sie als private bestimmen.

Dazu ein Beispiel:

class SpaceSound

{

private: 

  AudioDevice  device;

protected:

  short repetitions;

public:

  SpaceSound();

  virtual ~SpaceSound();

  virtual void play(SoundType _type);

};

SpaceSound::SpaceSound()

{

// erlaubt:

  device.reset();

  repetitions = 1;

}

 

class SingleSound : public SpaceSound

{

public: 

  SingleSound(); 

};

 

SingleSound::SingleSound()

{

// erlaubt:

  repetitions = 1;

 

// nicht erlaubt:

  device.reset();

}

 

void beep()

{

  SpaceSound aSpaceSound;

  SingleSound aSingleSound;

 

// erlaubt:

  aSpaceSound.play(BEAM);

  aSingleSound.play(BEAM);

 

// nicht erlaubt:

  aSpaceSound.device = myDevice;

  aSpaceSound.repetitions = 2;

}

Hier wird nochmals deutlich, in welchem Kontext welche Art von Zugriff erlaubt ist. Die Methoden der Klasse SpaceSound dürfen auf alle Elemente zugreifen. Die Methoden der Unterklasse SingleSound haben nur noch Zugriff auf die öffentlichen (public) und geschützten (protected) Elemente der Basisklasse. Und eine andere Funktion darf ohnehin nur die öffentlichen Einträge verwenden.

Hintergrund

Sie haben an der Syntax zur Vererbung gesehen, dass auch dabei die Art der Zugriffsbeschränkung eine Rolle spielt. Für die meisten Fälle reicht die public-Vererbung völlig aus. Wenn Sie sich also darüber keine weiteren Gedanken machen wollen, überspringen Sie diesen Abschnitt einfach.

Der Typ der Vererbung hat insbesondere Einfluss auf die Schnittstelle der Klasse, also auf die Art und Weise, wie sich die Klasse einem Programmierer, der sie verwendet, gegenüber darstellt. Bei der Ableitung mit public bleibt die ursprüngliche Schnittstelle komplett erhalten, das heißt, auch die abgeleitete Klasse verfügt über alle öffentlichen Attribute und Methoden wie die Basisklasse. Manchmal möchte man aber gerade das vermeiden. Dazu ein Beispiel: Stellen Sie sich vor, Sie wollen auf der Basis einer von einem Open-Source-Projekt übernommenen Klasse für Datenbankoperationen eine Klasse schreiben, die die Arbeit mit Ihren Unternehmensdaten übernimmt. Nun verfügen solche Klassen oft über eine sehr umfangreiche Schnittstelle, damit möglichst viele verschiedene Entwickler einen Nutzen davon haben. Für die Bereitstellung einer einfachen und sicheren Zugriffsschicht auf Ihre Daten können Sie aber gerade das nicht gebrauchen! Sie wollen ja gar nicht die breite Palette an Funktionalität nutzen, die Ihnen die Klasse bietet, sondern nur einen bestimmten Ausschnitt, den Sie auch mit einer eigenen Schnittstelle versehen wollen. Kurzum: Sie müssen die public-Elemente der Basisklasse verschwinden lassen.

Das erreichen Sie, indem Sie die Vererbung mit dem Spezifizierer protected vornehmen. Dann werden alle Elemente, die in der Basisklasse public sind, in der abgeleiteten Klasse protected (und damit nur noch für Ihre Klasse, aber nicht mehr für deren Benutzer sichtbar).

Noch schärfer reglementieren Sie den Zugriff, wenn Sie die Vererbung mit private spezifizieren. Dann sind nämlich alle von der Basisklasse geerbten Elemente in der abgeleiteten Klasse private. Man spricht dann auch von privater Vererbung.

Einen Überblick über diese Zusammenhänge gibt Ihnen auch Tabelle [*].


Table: Zugriffsbeschränkungen in Unterklassen werden durch die Spezifizierer bei der Vererbung festgelegt.

Noch ein paar Anmerkungen: Wenn Sie eine andere Vererbung als public wählen, bedeutet das auch, dass Sie von Ihrer Oberklasse nicht die Schnittstelle vererben, sondern nur die Implementierung. Das wird im Allgemeinen als nicht besonders guter Stil gewertet. In speziellen Fällen kann es aber gerechtfertigt sein.

Doch selbst wenn Sie sich in einer solchen Lage befinden, müssen Sie sich nicht zwangsläufig Stilbruch nachsagen lassen. Es gibt nämlich eine fast gleichwertige Alternative zur privaten Vererbung. Wenn Sie ein Objekt der vermeintlichen Basisklasse als Attribut in Ihre Klasse aufnehmen, können Sie auch eine völlig neue Schnittstelle definieren und auf die vorhandene Implementierung zurückgreifen. Aus dieser so genannten Wrapper-Klasse (siehe Seite [*]) rufen Sie dann eben nicht die Methoden als Klassenelemente, sondern über das Objekt auf. Der Nachteil ist natürlich, dass Sie dabei nur Zugriff auf die öffentlichen Elemente der Klasse haben und nicht auf die geschützten, wie es bei der privaten Vererbung der Fall gewesen wäre. Aber gerade beim Umgang mit universell konzipierten Klassen reicht die öffentliche Schnittstelle meist völlig aus, um dem Objekt all seine Funktionalität zu entlocken; Sie wollen ja dabei meist gar nicht direkt in die Implementierung eingreifen. Und zur Not können Sie Ihre Klasse noch mit der vorhandenen befreunden (siehe Seite [*]).


Mehrfachvererbung

Wie so oft im Leben hat man beim Bilden von Unterklassen manchmal auch die Qual der Wahl - wenn nämlich mehrere Klassen als Basis in Frage kommen. Hier bietet C++ einen Ausweg, über den andere objektorientierte Sprachen (etwa Java oder Delphis ObjectPascal) nicht verfügen: die Mehrfachvererbung, also die Ableitung von zwei oder mehr Basisklassen. Die Syntax ist recht einfach; Sie müssen nämlich nur die verschiedenen Klassen, durch Kommas getrennt, zusammen mit public oder einem anderen Spezifizierer hinter den Doppelpunkt schreiben, der den Beginn der Basisklassen symbolisiert.

Ein Beispiel: Wir waren ja bisher davon ausgegangen, dass unsere Klassen mit den Raumfahrzeugen für ein Simulationsspiel genutzt werden sollen. Nun enthalten diese einfache Schnittstellen für die sichtbaren Eigenschaften und Funktionen. Ein modernes Spiel muss aber auch über einen ansprechenden Sound verfügen. Dazu diene etwa folgende Klasse:

class SpaceSound

{

private:

  AudioDevice  device;

 

public:

  SpaceSound();

  virtual ~SpaceSound();

  virtual void play(SoundType _type);

};

Nehmen wir weiter an, unsere Klasse Raumfahrzeug stehe in der Klassenhierarchie aus Abbildung [*]Fig:Hierarchie, das heißt, sie hat Raumobjekt als Basisklasse. Wollen wir sie auch noch mit Sound versorgen, können wir sie von beiden Klassen ableiten:

class Raumfahrzeug : public Raumobjekt,

                     public SpaceSound

{

public:

  Raumfahrzeug();

  Raumfahrzeug(const Raumfahrzeug& _anderes);

  ~Raumfahrzeug();

  void starten();

  void landen(); 

  // ...

};

Nun können Sie in Ihren Raumfahrzeug-Objekten sowohl die Methoden von Raumobjekt als auch die von SpaceSound nutzen:

int launch()

{

  Raumfahrzeug r;

  r.starten();

  r.play(ROCKET_SOUND);

  r.setGeschwindigkeit(20000);

  // ...

}

Mehrfachvererbung \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} wird nicht besonders häufig eingesetzt. Einige Experten raten sogar generell davon ab, was dazu geführt hat, dass auch eine Reihe von Firmen sie in ihren Hausstandards untersagt haben. Das Problem, das Sie beim ersten Entwurf nie ganz vermeiden können, ist nämlich, dass zwei der Basisklassen selbst wieder von einer gemeinsamen Klasse abgeleitet sind. Und da jede Unterklasse ein Subobjekt ihrer Oberklasse enthält, hat das Unterste dieser Hierarchie gleich zwei Subobjekte der obersten Basisklasse, nämlich je eines über eine der Klassen aus der Mehrfachvererbung. Sie können sich vorstellen, dass das zu einer Vielzahl schwer auffindbarer und schlecht lösbarer Probleme führen kann. Zwar gibt es in C++ auch dafür einen Ausweg (auf den ich in diesem Rahmen leider nicht eingehen kann), aber ein Restrisiko bleibt. Wenn Sie also im Design Ihrer Klassen die Mehrfachvererbung vermeiden können, rate ich Ihnen, das auch zu tun. Es gibt meistens auch eine andere Lösung (beziehungsweise die Redundanz, die durch die damit verbundene doppelte Datenhaltung entsteht, ist oft zu verschmerzen).


Polymorphismus

Das Überladen von Methoden in abgeleiteten Klassen kann zu mehrdeutigen Anweisungen führen. Lassen Sie mich für ein Beispiel auf unsere Klassen Raumfahrzeug und Raumobjekt zurückkommen.

class Raumobjekt 

{

public:

  virtual bool setGeschwindigkeit(

    unsigned long _tempo);

  // ...

};

 

class Raumfahrzeug : public Raumobjekt

{

public:

  virtual bool setGeschwindigkeit(

    unsigned long _tempo);

  // ...

};

Dabei ist das Schlüsselwort virtual für uns neu. Es ist die Anweisung an den Compiler, den Aufruf der so gekennzeichneten Methode im Sinne des Polymorphismus zu interpretieren. Was virtual genau bewirkt, werden wir auf Seite [*] klären.

Stellen Sie sich nun vor, wir hätten eine Funktion, die ein Objekt von einer Position zu einer anderen bewegt. In dieser verwenden wir auch die Methode setGeschwindigkeit(), um eben die Bewegung überhaupt in Gang zu setzen.

void bewegeObjekt(

  const Position& _pos1,

  const Position& _pos2,

  Raumobjekt& _objekt)

{

  // ...

  _objekt.setGeschwindigkeit(5000);

  // ...

}

Die Klasse Position sei dabei einfach ein Paar von x/y-Werten. Wir programmieren sodann folgende Anweisungen:

int main()

{

  // ...

  Raumfahrzeug liberty;

  Position p1(0,10), p2(100, 10);

  

  bewegeObjekt( p1, p2, liberty);

  // ...

}

Was, erwarten Sie, passiert in diesem Programm? Fassen wir die Situation noch einmal zusammen:

Wir hatten oben festgestellt, dass ein Objekt einer Unterklasse ohne weiteres in ein Objekt seiner Oberklasse umgewandelt werden kann; somit ist dieser Aufruf durchaus korrekt. Unser Problem ist nur, dass es die verwendete Methode in zwei Versionen gibt, nämlich einmal in der Basisklasse und - in überladener Form - in der abgeleiteten Klasse. Welche davon wird denn nun aufgerufen? Was würden Sie als logisch empfinden?

Grundprinzip

Tatsächlich wird die Methode Raumfahrzeug::setGeschwindigkeit() gerufen, also die der Unterklasse. Dieses Prinzip gilt allgemein:

Geben sich Objekte von abgeleiteten Klasse als Objekte der Basisklasse aus, so werden beim Zugriff die Methoden der tatsächlichen Klasse des Objekts verwendet. Dieses Verhalten bezeichnet man als Polymorphismus.
Der Begriff Polymorphismus bedeutet Vielgestaltigkeit und meint, dass ein Objekt in verschiedener Gestalt auftreten kann. Ein Name im Programm kann Instanzen verschiedener Klassen angeben, wenn diese über eine gemeinsame Oberklasse verbunden sind. Sie können also aus der Deklaration des Namens nicht immer auf seine Gestalt im Programm schließen. Objekte können damit ganz individuell reagieren, obwohl sie nominell vom selben Typ sind. Rufen Sie etwa die Funktion mit verschiedenen Objekten auf, deren Klassen sämtlich die in der Funktion verwendeten Methoden überladen haben, so kann sich die Arbeitsweise der Funktion erheblich unterscheiden.

int main()

{

  // ...

  Raumstation orbitus;

  Schlachtschiff galactica;

  Satellit turbosat;

 

  bewegeObjekt( p1, p2, orbitus);

  bewegeObjekt( p2, p3, galactica);

  bewegeObjekt( p3, p1, turbosat);

  // ...

}

Innerhalb der Funktion bewegeObjekt() wird dann jeweils die Methode aus der zugehörigen Klasse aufgerufen. Das bedeutet natürlich auch, dass der Compiler beim Übersetzen dieser Funktion nicht den Verzweigungspunkt des Methodenaufrufs festlegen kann. Denn erst zur Laufzeit kann im Allgemeinen bestimmt werden, welche Methode gerade verwendet werden muss. Man bezeichnet das auch als späte Bindung, auf Englisch late binding. (Das hat nichts mit Ehe im Alter zu tun, sondern bezieht sich auf die Verbindung zwischen Aufruf einer Funktion und ihrem Beginn, die ja der Compiler beziehungsweise der Linker normalerweise herstellen.)

Und noch eine begriffliche Anmerkung: In konventionellen typisierten Sprachen wie Pascal oder Fortran kann jeder Wert und jede Variable für genau einen Typ interpretiert werden. In polymorphen Programmiersprachen wie C++ können sie mehr als einen Typ haben. Man unterscheidet daher zwischen den Begriffen

In unserem Beispiel ist Raumobjekt der statische Typ, während Raumfahrzeug, Raumstation, Schlachtschiff und Satellit die dynamischen Typen des Parameters _objekt in der Funktion bewegeObjekt() sind.


Virtuelle Methoden

Woher weiß aber eigentlich der Parameter, dass er nicht ein Raumobjekt ist, wie es doch in der Deklaration angegeben ist, sondern eigentlich ein Raumfahrzeug? Das liegt daran, dass das referenzierte Objekt nicht nur aus den puren Datenelementen besteht, sondern auch Informationen über seinen Typ enthält. Damit kann es zur Laufzeit identifiziert werden. Damit einem Objekt solche Typinformationen mitgegeben werden, müssen seine Methoden als virtuell gekennzeichnet sein. Dafür schreiben Sie das Schlüsselwort virtual vor die Methodendeklaration.

Sehen wir uns zunächst an, was geschieht, wenn Sie keine virtuellen Methoden verwenden. Für ein ganz anderes Beispiel springen wir vom Simulationsspiel zu Multimedia und definieren als Basisklasse für einen Multimedia-Clip:

class MediaClip

{

public:

  void play();

  // ...

};

Davon leiten wir als Spezialisierung eine Klasse AudioClip ab, in der dann die Schnittstelle mit Leben erfüllt wird und die Audiodaten hörbar werden:

class AudioClip : public MediaClip

{

public:

  void play();

  // ...

};

Wenn wir dann eine allgemeine Ausgabefunktion schreiben, übergeben wir dieser natürlich eine Referenz auf ein MediaClip-Objekt, um für alle Fälle gerüstet zu sein:

void ausgabe(const MediaClip& _clip)

{

  _clip.play();

  // ...

}

Im Hauptprogramm verwenden wir diese nun auch:

int main()

{

  AudioClip musikClip;

  // ...

  ausgabe(musikClip);

 

  return 0;

}

Und schon haben wir ein Problem: Da wir die Methode play() nicht als virtuell gekennzeichet haben, wird die Version aus der Basisklasse verwendet - und die Lautsprecher bleiben stumm! Ganz anders wäre es, wenn wir deklarieren würden:

class MediaClip

{

public:

  virtual void play();

  // ...

};

Und schon hören wir etwas! Das bedeutet, dass nur virtuelle Methoden bei polymorphen Objekten auch korrekt ausgewählt werden. Sie sollten also generell alle Methoden, die in einer abgeleiteten Klasse eventuell überladen werden, als virtual deklarieren.

Eigentlich muss die virtual-Deklaration nur in der Basisklasse stehen und kann bei der überschriebenen Methode in der Unterklasse entfallen. Damit berauben Sie sich aber der Möglichkeit, von der jetzigen Unterklasse wieder selbst abgeleitete Klassen zu bilden und die Methode abermals sinnvoll zu überschreiben. Daher empfehle ich Ihnen, stets alle Methoden, die in einer Basisklasse als virtuell gekennzeichnet sind, auch in allen davon abgeleiteten Klasse so zu deklarieren.

Und noch ein Tipp an dieser Stelle, auch wenn Sie die Beweggründe dafür jetzt vermutlich noch nicht verstehen: Sobald Sie in einer Klasse virtuelle Funktionen verwenden, schreiben Sie bitte auch einen virtuellen Destruktor, also etwa:

class MediaClip

{

public:

  virtual ~MediaClip() {}

  // ...

};

Er darf völlig ohne Funktionalität sein (wie Sie hier am leeren Methodenkörper erkennen). Sie verhindern damit, dass bei dynamisch polymorph erzeugten Objekten nur Speicher im Umfang der Basisklasse und nicht für die ganze Klasse freigegeben wird und damit Speicherlöcher entstehen. Übrigens weist Sie der GNU-Compiler (gelegentlich) auch darauf hin, wenn Sie virtuelle Methoden verwenden, Ihre Klasse aber über keinen virtuellen Destruktor verfügt.


Rein virtuelle Funktionen und abstrakte Klassen

Bei einer größeren Klassenhierarchie kann es oft sinnvoll sein, Basisklassen zu definieren, die nur die Schnittstellen (also die Methoden und deren Signatur) festlegen. Auf diese Weise können Sie allgemein vorgeben, wie bestimmte Zugriffe bei allen davon abgeleiteten Klassen auszusehen haben.

Sie können die Schnittstelle festlegen, indem Sie Methoden zur Klasse hinzufügen, die in ihr nur deklariert, aber nicht implementiert werden. Eine solche Methode bezeichnet man als rein virtuell; im Quelltext drücken Sie das durch ein =0 hinter der Methodendeklaration aus. Da eine solche Methode immer in der Unterklasse überschrieben werden muss, sollte sie stets als virtual deklariert sein. Eine Klasse mit rein virtuellen Methoden nennt man abstrakt.

Beispielsweise wollen wir vorgeben, dass alle Klassen, die vom Typ MediaClip abgeleitet sind, eine Methode play() besitzen, die keine Argumente hat und auch keinerlei Rückgabewert. Selbst wenn wir wollten, könnten wir eine solche Methode in der Basisklasse gar nicht implementieren, da wir hier noch keinen konkreten Medientyp haben und also gar nicht wissen, was und wie da überhaupt abgespielt werden soll. Somit fügen wir in die Deklaration eine rein virtuelle Methode ein:

class MediaClip

  // ...

  virtual void play() = 0;

};

Wenn Sie eine Klasse abstrakt entwerfen, hat das zwei unmittelbare Konsequenzen:

Der Zweck von abstrakten Klassen ist, dass Sie damit Schnittstellen definieren können. Abstrakte Klassen stellen also Schablonen für den Zugriff auf und die Arbeit mit der Klasse dar. Sie zwingen damit alle, die eine Unterklasse davon bilden, die rein virtuellen Methoden mit exakt der von Ihnen vorgegebenen Signatur zu implementieren.

Ein Vorteil, den Sie mit abstrakten Basisklassen haben, ist die Trennung von Spezifikation und Implementation. Auf diese Weise können Sie Ihre Datenhaltung vereinheitlichen, die Benutzeroberfläche Ihrer Anwendung konsistenter gestalten und detaillierte Vorgaben im Rahmen einer Teamentwicklung machen.

Ein weiterer Pluspunkt ist der Aufruf von Methoden, die in der Oberklasse noch gar nicht ausgeführt werden können. Das ist insbesondere unter Ausnutzung des Polymorphismus sehr effektiv. Wenn Sie zum Beispiel von MediaClip die Ableitungen AudioClip und VideoClip haben, können Sie einer zusätzlichen Funktion react() eine Referenz auf ein MediaClip-Objekt übergeben und dort die play()-Methode aufrufen:

void react(MediaClip& clip)

{

  clip.play();

  // ...

}

Die Funktion können Sie dann wahlweise mit einem AudioClip- oder mit einem VideoClip-Objekt aufrufen.

Wenn man eine größere Klassenhierarchie entwirft, in der viele Klassen mit einer Schnittstelle für den Benutzer ausgestattet sein sollen, neigt man oft dazu, Schnittstelle und Implementierung eng miteinander zu koppeln, das heißt, die Methoden genau so zu implementieren, wie sie in der Schnittstelle angegeben sind. Für die erste Version der Software ist das auch noch brauchbar. Probleme ergeben sich aber, wenn die Implementierung ergänzt oder ausgetauscht werden muss, die Schnittstelle aber erhalten bleiben soll - oder wenn umgekehrt die Schnittstelle sich bei gleich bleibender Implementierung ändern muss. Einen Ausweg zeigt Stroustrup in [STROUSTRUP 1998]: Sie sollten einfach zwei getrennte Hierarchien aufbauen, eine aus durchweg abstrakten Klassen für die Schnittstelle und eine aus konkreten für die Implementierung. Durch Mehrfachvererbung oder Instanziierung der Implementierungsklassen entstehen dann Zwischenklassen, die die Schnittstelle der einen Seite mit Hilfe des Codes der anderen Seite realisieren.

Zusammenfassung

Figure: Die Objektorientierung ruht auf drei fundamentalen Prinzipien.



\resizebox*{1\columnwidth}{!}{\includegraphics{images/saeulen.eps}}

Folgende Gesichtspunkte aus diesem Abschnitt sollten Sie festhalten:

Übungsaufgaben

  1. Wo liegt der Fehler in folgendem Programm:

    class B 

    {

    public:

      B(float _x) { d = _x; } 

      float d;

    };

     

    class A : public B 

    {

    public:

      A(int _k) { i = _k; }    

      int i;                 

    };

     

    int main() 

    {

      A a(3);

      return 0;

    }

  2. Finden Sie den Fehler in folgendem Programmausschnitt?

    class B 

    {

    public:

    // Feld mit 'i' int-Elementen anlegen

      B(int i = 80);   

    // Kopieren von 'anzahl' int-Elementen

      B(const B&);     

     

    private:

      int anzahl;

      Vector feld;

    };

     

    class A : public B 

    {

    public:

      A();

      A(const A& _a) 

      { 

        cout << "A(A&)" << endl;

        i = _a.i; 

      } 

     

    private:

      int i;

    };


next up previous contents index
Next: Programmieren mit C++ Up: C++-Entwicklung mit Linux Previous: Programmieren unter Linux   Contents   Index
thomas@cpp-entwicklung.de