Kapitel 2
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.

2.1 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.

2.1.1 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).



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

PIC


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.

2.1.2 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, welches um das Modulkonzept erweitert ist, entworfen 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 mehr 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 sowie die 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 aufwändig 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 Beliebtheit der objektorientierten Softwareentwicklung zu sein.

2.1.3 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.



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

PIC


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:

Für ein weiteres Beispiel denken Sie an eine grafische Benutzeroberfläche wie KDE, mit der Sie unter Linux vielleicht arbeiten. Ein Fenster besteht oft aus einer Vielzahl verschiedener Objekte: Balken zur Fortschrittsanzeige, Schaltflächen (Buttons) zum Auslösen von Aktionen oder aufklappbare Listen für Auswahlmöglichkeiten. Ein Beispiel sehen wir in Abbildung 2.3. Auf das Beispiel der Schaltflächen einer grafischen Anwendung werden wir noch öfter zurückkommen. Die Klassen, die uns dabei begegnen werden, orientieren sich in Aufbau und Struktur an den echten Klassen aus der Bibliothek Qt, die der KDE-Oberfläche und deren Applikationen zugrunde liegt (siehe auch doc.trolltech.com und Seite 779).



Abbildung 2.3: Der KsCD, ein CD-Player für KDE

PIC


Viele dieser Elemente sind zwar untereinander ähnlich, also vom gleichen Typ - wie etwa die Schaltflächen zum Vor- und Zurückschalten; jedes davon hat aber seine eigenen Beschriftungen und führt zu verschiedenen Aktionen. Aus Sicht des Programmierers haben wir also ein paar Schaltflächen, die sich z.B. folgendermaßen beschreiben lassen:

 

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. Eine Schaltfläche soll gerade gedrückt sein oder nicht. Diese Zustände gehen durch Mausklicks des Benutzers 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 zum Beispiel gerade keine CD eingelegt, ist die Schaltfläche zum Auswerfen nicht sinnvoll. Ein Anklicken darf daher zu keiner Aktion führen.

2.1.4 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 grafische Benutzeroberfläche programmieren wollen, müssen wir nicht für jede einzelne Schaltfläche 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 "Button" zu finden.

Fassen wir also die bisher verwendeten Attribute zu einer Schablone zusammen: Button verfügt über die Eigenschaften Beschriftung, Höhe, Breite, Symbol sowie MomentanAngeklickt. 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.



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

PIC


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:

2.1.5 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 auf Seite 65 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 eines konkreten Objekts und greifen dabei auf dessen Attribute und Zustände zu. Die Änderungen, die die Methode durchführt, sind dann nur in diesem Objekt gültig und haben auf andere Objekte derselben Klasse, 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,



Abbildung 2.5: 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.)

PIC


In Abbildung 2.5 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.

2.1.6 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.6). 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.



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

PIC


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.

2.1.7 Zusammenfassung

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

2.1.8 Übungsaufgaben

  1. 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?

  2. Die Stadtbücherei von Annahausen möchte auch mit der Zeit gehen und die Verwaltung ihres Buchbestandes mit einem Computer abwickeln. Auch in dieser Kleinstadt ist das Geld knapp und so entscheidet man sich für die eigene Entwicklung eines eigenen Programms, das folgende Aspekte berücksichtigen soll:

    Bearbeiten Sie zu dieser Problemstellung folgende Aufgaben:

    1. Wie sollten die Datenstrukturen des Programms (bei rein strukturierter Programmierung mit Verbünden) aussehen, um den genannten Anforderungen gerecht zu werden? Begründen Sie ggf. Ihre Entscheidung für eine von mehreren Alternativen kurz.
    2. Geben Sie Vorschläge an, wie Sie eine objektorientierte Version der Bibliotheksverwaltung aufbauen würden, d.h. welche Klassen und Objekte hier zu definieren sind und welche Nachrichten zwischen den Objekten fließen müssen.

2.2 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.

2.2.1 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 3 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. Haben Sie dagegen beispielsweise schon Erfahrung mit Java, so können Sie Ihren Stil sogar weitgehend beibehalten - denn in Java kann man nur so programmieren, wie man es in C++ eigentlich sollte.

2.2.2 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.



Abbildung 2.7: C++ ist eine Obermenge von C.

PIC


Festzuhalten bleibt: C++ ist eine Obermenge von C (Abbildung 2.7), 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.

2.2.3 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 bzw. 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 779) 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 ist schon seit langem GCC, also der GNU-C-Compiler, der in bewährter Weise als freie Software entstanden ist und auch so stets weiterentwickelt wurde. Nachdem dieser um immer mehr Programmiersprachen (außer C++ auch Objective-C, Ada, Fortran usw.) erweitert wurde, musste auch der Name angepasst werden. So steht die Abkürzung GCC nun für "GNU compiler collection"; da sich aber immer noch niemand an den neuen Namen und das damit verbundene neue Geschlecht im Deutschen gewöhnt hat, werde ich weiterhin von dem GCC sprechen. Seit der Version 3.0 ist der enthaltene C++-Compiler schon ziemlich konform zum ANSI/ISO-Standard. Mit der Version 3.4 vom Mai 2004 wurde diese Konformität nochmals um ein ganzes Stück verbessert.



Abbildung 2.8: Bei GCC schlüpft das GNU aus dem Ei.

PIC


Mit diesem werden wir im Folgenden arbeiten. Fast alle Beispiele sind aber noch mit den Vorgängerversionen 3.0 bis 3.3x übersetzbar, viele auch mit noch älteren Versionen. An den entsprechenden Stellen werde ich Sie natürlich auf Unterschiede hinweisen. (Es sind zum Glück nicht allzu viele.)

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

Aber der GCC ist nicht der einzige C++-Compiler, den es unter Linux gibt. Kommerziell erhältlich ist beispielsweise der Compiler der Portland Group (zu finden unter www.pgroup.com).

Auch den Intel-Compiler, der besonders gut für die Pentium- und Itanium-Architektur optimieren kann, gibt es für Linux. (Eine Evaluationsversion zum Download gibt es unter http://www.intel.com/software/products/compilers/clin/.) 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 [SCHMID 2001]). 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 seit Version 3 auch C++-fähig. Eine freie Version (die Open Edition) kann von der Website www.borland.com/kylix heruntergeladen werden.

2.2.4 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> 
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:      std::cout << "Summe bis " << i << ": ";  
18:      std::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 47 beschrieben, können wir also verzichten.

Sie finden übrigens alle in diesem Buch vorgestellen Beispielprogramme auf der zugehörigen Webseite www.cpp-entwicklung.de. Von dort können Sie sich ein Dateiarchiv herunterladen, das die Programme in Unterverzeichnissen der jeweiligen Kapitel enthält. 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 101).

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 Variablen 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 bei der Kompilierung bestimmte Warnungen aktivieren, erhalten Sie zu diesem Problem auch eine entsprechende Meldung. Da dies aber eben nicht immer der Fall ist, sollten Sie selbst ein wachsames Auge auf Initialisierungsfehler haben.

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 107) darauf zurück.

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

std::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 std::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 468) werde ich Ihnen noch ein paar Konventionen vorschlagen, wie Sie Ihre Programme schreiben können. Wie Sie 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 129).

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.

2.2.5 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 97) 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
double d = 3.14;
//Gleitkommazahl 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;

std::cout << k << std::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;

std::cout << k << std::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 zutage 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:

double d = 1.5;

int i = (int)d;

Bei der Umwandlung von double 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:

double d = 1.5;

int i = int(d);

(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 529).

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 0 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 { SICHTBAR, GEBDRUECKT, UNSICHTBAR} 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 0, 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.

2.2.6 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 Operator einzeln einzugehen, würde an dieser Stelle zu weit führen. Ich will Ihnen daher in Tabelle 2.1 auf Seite 110 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.



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



Operator Beschreibung Beispiel



Primäre Operatoren
() Klammerung von Ausdrücken j=3*(2+i);
[] Zugriff auf Elemente von Containern vec[0]=1.5;
. Direkter Auswahloperator meinObjekt.methode();
-¿ Indirekter Auswahloperator meinObjektzeiger-¿methode();
:: Bereichsoperator MeineKlasse::methode() {. . . }



   
Unäre Operatoren
* Dereferenzierungsoperator char i = *j;
& Referenzierungsoperator int* i = &j;
- Vorzeichenminus j = -i;
! Logisches NOT if (!existiert) {. . . }
++ Inkrementoperator i++; (äquivalent zu i=i+1;)
-- Dekrementoperator i--; (äquivalent zu i=i-1;)



   
Binäre Operatoren
* Multiplikation j = i * 2;
/ Division j = i / 2;
% Modulo (Rest einer Division) j = i % 2;
+ Addition j = i + 2;
- Subtraktion j = i - 2;
¡ Kleiner als if (i ¡ j) {. . . }
¿ Größer als if (i ¿ j) {. . . }
¡= Kleiner oder gleich if (i ¡= j) {. . . }
¿= Größer oder gleich if (i ¿= j) {. . . }
== Gleich if (i == j) {. . . }
!= Ungleich if (i != j) {. . . }
& Bitweises AND j = i & x02;
| Bitweises OR j = i | x02;
&& Logisches AND if ((i==j) && (i¡3)) {. . . }
|| Logisches OR if ((i==j) || (i¡3)) {. . . }



   
Zuweisungsoperatoren
= Zuweisung i = 2;
+= Zuweisung und Addition i += 2; (äquivalent zu i=i+2;)
-= Zuweisung und Subtraktion i -= 2;
*= Zuweisung und Multiplikation i *= 2;
/= Zuweisung und Division i /= 2;
%= Zuweisung und Modulo i %= 2;



   

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;

std::cout << i++ << `` `` << i << `` `` 

     << ++i << `` `` << i << std::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.

2.2.7 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:

 



Ausdruck Typ


77 int
zahl int, wenn zahl vom Typ int ist
zahl + 0.5 double
a ¿ b bool


  

 

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 113). 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 105). 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

2.2.8 Zusammenfassung

Aus diesem Abschnitt sollten Sie sich Folgendes merken:

2.2.9 Ü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>

     

    using namespace std;

     

    int main() {

      cout << "short int: "

           << sizeof(short int) << endl;

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

      cout << "long int: "

           << sizeof(long int) << endl;

      cout << "long long: "

           << sizeof(long long) << endl;

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

      cout << "double: "

           << sizeof(double) << endl;

      cout << "long double: "

           << sizeof(long 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. Welche Ausgabe erzeugt folgendes Programm? Versuchen Sie die Ausgabe erst vorherzusagen, bevor Sie das Programm ähnlich wie in der vorherigen Aufgabe übersetzen und starten.
    #include <iostream>

     

    using namespace std;

     

    int main() {

      int a = 4;

      double b = 8;

      int c = b;

     

      cout << a*b/c << endl;

      cout << a/c*b << endl;

      cout << a/b*c << endl;

      cout << 1/(c/a*b) << endl;

      return 0;

    }

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

    std::cout << x = (0 ? "null" : "ungleich 0");

2.3 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.

2.3.1 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.)

2.3.2 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 der 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.



Tabelle 2.2: Die wichtigsten Optionen des GNU-C++-Compilers



Option

Bedeutung

Seite



-c

Ende der Kompilierung bei der Objektdatei

129
-g

Hinzufügen von Informationen für den Debugger

126
-IDIR

Zusätzlicher Suchpfad DIR für Header-Dateien

129
-lBIBL

Hinzulinken der Bibliothek libBIBL.a

131
-LDIR

Zusätzlicher Suchpfad DIR für Bibliotheken

131
-o NAME

Erzeugung der Ausgabedatei NAME

126
-O

Optimierung des ausführbaren Programms auf Geschwindigkeit

132
-Wall

Ausgabe von Warnungen bei "unschönem" Code

127



   

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.

2.3.3 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 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!

2.3.4 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 6.2 auf Seite 676.

2.3.5 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:11: Error: expected primary-expression before "short" test.cc:11: Error: expected `;' before "short"

Sie sehen an diesem Beispiel bereits, dass die Meldungen des Compilers zuweilen schwierig zu interpretieren sind. Es braucht einiges an Übung, bis man weiß, welcher Fehler die Ursache für eine solche Meldung sein könnte.

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 -Wwarnungstyp. 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;

  std::cout << i << std::endl;

  return 0;

}

so führt das zu den Warnungen:

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

from `double'

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

test.cc:5: warning: unused variable `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.

Einige der Warnungen, die mit -Wall verbunden sind, werden erst ausgegeben, wenn die Optimierung aktiviert ist, z.B. die Warnung vor nicht initialisierten Variablen. Verwenden Sie daher am besten immer gleich die Kombination -Wall -O.

2.3.6 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.

2.3.7 Pfade zu Header-Dateien

Wir haben oben festgestellt (Seite 100), dass Header-Dateien normalerweise in den bekannten Verzeichnissen für die System-Header gesucht werden. (Diese hatte ich Ihnen auf Seite 53 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).

2.3.8 Vorkompilierte Header-Dateien

In größeren Projekten, als wir im Rahmen eines Buches wie diesem bewältigen können, arbeitet man üblicherweise mit vielen Funktionen und Klassen, die in ebenso vielen C++- und Header-Dateien untergebracht sind. Jedes Mal, wenn eine Header-Datei verwendet wird, muss der Compiler sie übersetzen. Bei zentralen Komponenten kann das recht häufig vorkommen. Dieselbe Datei immer wieder aufs Neue zu kompilieren, ist im Grunde viel überflüssige Arbeit.

Daher haben die Compiler-Entwickler schon seit längerem über dieses Problem nachgedacht. Eine mögliche Lösung ist es, die Header-Dateien bereits vorzukompileren und bei der Verwendung nur den Maschinencode dazuzuladen - aber sie eben nicht immer wieder vom Quellcode aus zu übersetzen. Diese Funktion der vorkompilierten Header-Dateien gibt es ab Version 3.4 nun auch beim GCC. Allerdings ist sie dort noch als "experimentell" gekennzeichnet; ihre Benutzung erfolgt also auf eigene Gefahr.

Wenn Sie mit vorkompilierten Headern arbeiten wollen, müssen Sie nichts weiter tun, als außer für Ihre C++-Dateien den Compiler auch noch für die Header-Dateien aufzurufen, also beispielsweise:

% g++ mein_header.h

Daraus erzeugt der GCC nun eine Datei mein_header.h.gch. Diese kann eine stattliche Größe haben (über 900 kB), denn sie umfasst auch alle eventuellen Einsatzmöglichkeiten der im Header definierten Datenstrukturen und Funktionen.

Binden Sie dann in einer C++-Datei mit #include ”mein_header.h” diesen Header ein, so sucht der Compiler in den angegebenen Suchpfaden (siehe oben) zunächst nach einer vorkompilierten Version dieses Headers und erst, wenn er keine findet, nach der Quelltextversion. Sollten Sie Probleme mit dieser Technik haben, was aufgrund des frühen Status durchaus gelegentlich vorkommen kann, können Sie die gch-Dateien gefahrlos wieder löschen und wie gewohnt Ihre Programme kompilieren.

Noch ein Hinweis: Der Aufruf wie oben, eine Header-Datei zu kompilieren, ist nur bei GCC Version 3.4 und höher zulässig. Bei früheren Versionen führt dieser zu einer Fehlermeldung.

2.3.9 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 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 3.5 ab Seite 443.

2.3.10 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

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 weitere besonderes Feature von GCC 3 ist die Sprungoptimierung auf Basis von Referenzläufen. Um diese 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.

2.3.11 Info-Seiten und Texteditoren

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.

Sehr viel einfacher und zeitgemäßer ist die Betrachtung mit einem Browser wie Konqueror. Dieser hat die Konvertierung gleich integriert, so dass Sie nur als Adresse info:/gcc eingeben müssen. Auf diese Weise können Sie bequem mit der Maus zwischen den einzelnen Seiten hin- und herspringen (Abbildung 2.9 auf Seite 137). Hinter dem Unterpunkt INVOKING GCC finden Sie beispielsweise die Compiler-Schalter.



Abbildung 2.9: Die Info-Seiten zum GCC, dargestellt im Konqueror

PIC


Ebenso leicht ist das aber auch mit dem Help-Browser gnome-help-browser der Arbeitsoberfläche GNOME möglich. Dieser hat gleichfalls die Konvertierung nach HTML eingebaut.

Eine weitere komfortable Möglichkeit zum Betrachten der Info-Seiten bietet der XEmacs-Editor; lesen Sie dazu Seite 632.

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 Mac OS 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? Ab Seite 601 werde ich Ihnen daher einige Editoren im Detail vorstellen.

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 7 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 770 finden Sie "Kochrezepte", wie Sie Konsolenprogramme - und um solche handelt es sich bei unseren Beispielen - einfach mit KDevelop erzeugen und bearbeiten können.

2.3.12 Zusammenfassung

Die wichtigsten Aspekte dieses Abschnitts waren:

2.3.13 Ü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. Sie schreiben ein Programm fotos.cpp zur Verwaltung einiger Bilder. Dazu laden Sie sich aus dem Internet eine Programmbibliothek libjpgcruncher.a und deren Header-Datei jpgcruncher.h herunter, mit deren Hilfe Sie die Bilder in der Auflösung verändern möchten. Die Funktionen, die diese Bibliothek bietet, wollen Sie in Ihrem Programm verwenden. Welche der folgenden Aussagen sind richtig, welche falsch? Begründen Sie Ihre Aussagen.

    1. Die Datei fotos.cpp kann mit dem Befehl

      % g++ -o fotos fotos.cpp jpgcruncher.h

      in ein ausführbares Programm fotos übersetzt werden.

    2. Zum Übersetzen von fotos.cpp benötigen Sie nur die Header-Datei jpgcruncher.h.
    3. Mit dem Befehl

      % g++ -c fotos.cpp

      erzeugen Sie eine Header-Datei fotos.h.

    4. Aus der kompilierten Datei fotos.o können Sie mit

      % g++ -O fotos fotos.o -ljpgcruncher

      ein ausführbares Programm erzeugen.

2.4 Klassen und Objekte

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

2.4.1 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 73 zurück. Als C++-Klasse könnten wir unseren Button folgendermaßen deklarieren:

1:  class Button 
2:  { 
3:  private: 
4:    string text; 
5:    unsigned int hoehe; 
6:    unsigned int breite; 
7:    bool momentanAngeklickt; 
8:    ToggleTyp typ; 
9:    ToggleStatus zustand; 
10:   
11:  public: 
12:    Button(); 
13:    ~Button(); 
14:    void toggle(); 
15:    void click(); 
16:    void setStatus(ToggleStatus _status); 
17:    ToggleStatus getStatus(); 
18:    void setHoehe(unsigned int _neue_hoehe); 
19:    unsigned int getHoehe(); 
20:    ToggleTyp getTyp(); 
21:    void setGruppe(ButtonGroup& _gruppe); 
22:    // Weitere Methoden ... 
23:  }; 
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 508).

Für Umschalttyp (Toggle-Typ) und -status gibt es nur eine beschränkte Zahl möglicher Werte. Wir verwenden daher Aufzählungstypen, die wir folgendermaßen definieren können:

enum ToggleTyp { STANDARD, TOGGLE, TRISTATE};

enum ToggleStatus { AN, AUS, UNVERAENDERT};

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.

Mit ToogleTyp ist folgende Funktionalität gemeint:

Die möglichen zwei beziehungsweise drei Zustände werden durch ToggleStatus ausgedrückt.

In den Zeilen 12 und 13 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 erfahren Sie 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 setHoehe() der Klasse Button könnte die Implementierung etwa lauten:

void Button::setHoehe(

  unsigned int _neue_hoehe)

{

  if (_neue_hoehe >1024) 

    return;

 

  hoehe = _neue_hoehe;

  return;

}

Die Methode hat alle Merkmale einer Funktion, das heißt Rückgabewert (hier allerdings vom Typ void), Funktionsname (setHoehe), Parameter (_neue_hoehe) und Funktionskörper (Genaueres ab Seite 169). Die Zugehörigkeit zur Klasse Button drückt sich einzig durch den Klassennamen mit dem Bereichsoperator aus.

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

2.4.2 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 Button

{

  // ...

};

 

class ButtonGroup

{

  // ...

};

 

int main(void) 

{

  Button       startButton;

  ButtonGroup  alleButtons;

 

  startButton.setStatus(AN);

  std::cout << ``startButton ist `` << startButton.getHoehe() 

            << `` Pixel hoch'' << std::endl;

  startButton.setGruppe(alleButtons);

  return 0;

}

 

2.4.3 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 255 - behandeln wollen.)



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

PIC


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 Button

{

private:

  unsigned int hoehe;

  ToggleStatus zustand;

  // ... weitere 

public:

  Button();

  ~Button();

  void setHoehe(unsigned int _neue_hoehe);

  unsigned int getHoehe();

  // ... weitere

};

(Dabei haben wir eine Regel angewendet, auf die ich auf Seite 188 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:

Button  startButton;

startButton.hoehe = 50;

so wird der Compiler diesen Zugriff mit der Fehlermeldung verwehren: member ‘hoehe’ is a private member of class ‘Button’. 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.

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 Button::changeRadioState(Button _anderer)

{

  zustand = (_anderer.zustand == AN)? AUS : AN;

  // ...

}

Hier soll das Verhalten von so genannten "Radio Buttons" programmiert werden. Nur einer der Buttons kann gleichzeitig gedrückt sein. Er fragt zur Änderung seines Zustands den des anderen Buttons ab und schaltet sich auf AUS, wenn der andere AN ist und umgekehrt. Wie selbstverständlich habe ich dabei auf das private Attribut zustand des Objekts _anderer zugegriffen. Ist Ihnen schon klar, wieso es sich dabei um einen Grenzfall handelt? Einerseits ist zustand Attribut der Klasse, deren Methode changeRadioState() 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.

2.4.4 Freunde

Wenn Sie einmal in die Situation 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.



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

PIC


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ärtsdeklaration

 

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ärtsdeklaration" 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ärtsdeklaration auf eine Klasse C hingewiesen, die an anderer Stelle deklariert werden kann, die aber hier das volle Zugriffsrecht auf A erhalten hat.

2.4.5 Zusammenfassung

Aus diesem Abschnitt sollten Sie sich merken:

2.4.6 Ü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 86) in C++. Beachten Sie dabei die Zugriffsbeschränkungen und die erforderlichen Methoden.
  3. Formulieren Sie die Klassen für die Bibliotheksverwaltung von Annahausen aus dem Abschnitt 2.1.8 (Seite 86) in C++. Beachten Sie auch in diesem Fall die Zugriffsbeschränkungen für die erforderlichen Methoden.
  4. Gegeben seien die folgenden Deklarationen und Definitionen:

    class Zufallszahl {

    public:

      Zufallszahl(unsigned int _maxwert);

      unsigned int gibZufallszahl();

     

    private:

      unsigned int maximalwert;

    };

     

    Zufallszahl z(10);

    Zufallszahl y(999);

    Welche der folgenden Programmzeilen sind korrekt? Begründen Sie Ihre Antworten.

      std::cout << z.gibZufallszahl() << std::endl;

      std::cout << y.gibZufallszahl(1000) << std::endl;

      std::cout << z::gibZufallszahl() << std::endl;

      y.maximalwert++;

  5. Betrachten Sie folgenden Codeausschnitt:

    class Pen {

    private:

      unsigned int width;

      friend class Brush;

     

    public:

      Pen(unsigned int _width);

      void setWidth(unsigned int _newWidth);

      unsigned int getWidth();

    };

     

    class Brush {

    private:

      typedef enum BrushStyle { SOLID, DOTTED, SQUARED, LINES };

      BrushStyle style;

     

    public:

      Brush(BrushStyle _style);

      void setBrushStyle(BrushStyle _newStyle);

    };

    Welche Aussagen über diesen Code sind korrekt, welche nicht?

    1. Man kann Objekte vom Typ Brush erzeugen.
    2. Ein Objekt der Klasse Pen hat eine Methode getWidth().
    3. Die Typdefinition BrushStyle ist in der Klasse Pen auch bekannt und kann benutzt werden.
    4. In den Methoden von Brush darf der Wert des Attributs width eines Pen-Objekts verändert werden.

      void Brush::setBrushStyle(BrushStyle _newStyle) {

        Pen p(5);

        p.width = (_newStyle == SOLID)? 3 : 5;

        // ...

      }

    5. Methoden von Pen dürfen in ähnlicher Form auf das Attribut style eines Brush-Objektes zugreifen.
    6. Objekte von Pen dürfen nur angelegt werden, wenn bereits schon mindestens ein Objekt vom Typ Brush vorhanden ist.

  6. Was versteht man unter einer "Vorwärtsdeklaration"? Wozu wird sie benötigt?

2.5 Namensräume

Größere Programme bestehen immer aus mehreren Quelltextdateien, die von verschiedenen Programmierern bearbeitet werden. Wenn dann nach einiger Zeit alle Teile zu einem Gesamtsystem zusammengefügt werden sollen, stellt man nicht selten fest, dass einzelne Mitarbeiter für ihre Konstanten, Funktionen oder Klassen dieselben Bezeichner verwendet haben. Im begrenzten Rahmen des Subsystems, das der jeweilige Entwickler zu verantworten hat, ist daran ja auch nichts auszusetzen. Mit der theoretischen Unabhängigkeit der einzelnen Programmteile ist es aber zu Ende, sobald alle zu einem Gesamtprogramm zusammengelinkt werden sollen. Der einzige Ausweg war in solchen Fällen, den entsprechenden Bezeichner in einem Modul umzubenennen - in der Hoffnung, dass dieser Schritt keine Seiteneffekte nach sich zieht. Viele Entwicklungsleiter haben zudem versucht, das Problem von vornherein dadurch zu vermeiden, dass sie für alle Bezeichner Präfixe vorgeschrieben haben, die das jeweilige Subsystem kennzeichnen sollten. Aber leider sind es ja nicht die ¿¿großen¡¡ Datentypen mit der Geschäftslogik, die kollidieren, sondern meistens die Hilfsgrößen (also etwa Konstanten wie OK oder ERROR); und für diese wurden die Namenskonventionen nur selten eingehalten.

2.5.1 Definition

Um dieses Problem zu überwinden, gibt es in C++ den Begriff des Namensraums (engl. namespace). So simpel die Syntax im Grunde ist, die Unterstützung durch die Compiler war doch lange Zeit rar. Erst in jüngster Zeit beherrschen alle namhaften Compiler dieses Sprachmerkmal - natürlich auch der GCC.

Stellen Sie sich einen Namensraum als benannten Block vor, ähnlich wie eine Struktur oder eine Klasse, nur dass man davon keine Instanzen bilden kann. Alle Bezeichner, die in diesem Block definiert werden, müssen von außen zusätzlich über den Namen des Namensraums angesprochen werden. Ihr Name allein genügt nicht mehr.

Als Beispiel betrachten wir folgende Situation: Zwei Programmierer, Max und Moritz, haben jeweils eine Komponente zu unserem Softwaresystem entwickelt. Dabei verwendet jeder von ihnen intern einen Algorithmus, der eine double-Zahl erhält und eine andere desselben Typs zurückliefert. Sie schreiben also jeweils eine Funktion algorithm(), die zu allem Überfluss auch noch von einer Konstante EPS abhängt. Wenn wir nun die Komponenten der beiden in unser Hauptprogramm integrieren, meldet der Linker:

moritz.o: In function `algorithm(double)':

moritz.o(.text+0x0): multiple definition of 

`algorithm(double)'

max.o(.text+0x0): first defined here

Um die beiden Funktionen für den Linker unterscheidbar zu machen, betten wir sie in einen Namensraum ein.

// Datei max.h

 

namespace Max

{

  double algorithm(double _x);

  extern const double EPS;

}

Das Schlüsselwort extern ist für Variablen und Konstanten übrigens so etwas wie der Prototyp für Funktionen: Es gibt an, dass die so deklarierte Variable zwar existiert, aber nicht hier, sondern an anderer Stelle definiert ist. Wenn Sie Ihre Konstanten (oder globalen Variablen) gleich in der Header-Datei definieren und diese in verschiedene Implementierungsdateien einbinden, beschwert sich wieder der Linker über doppelte Definitionen.

Bei der Verwendung von Namensräume sollten Sie Folgendes beachten:



Abbildung 2.12: Namensräume machen gleichlautende Bezeichner verschiedener Programmierer eindeutig.

PIC


Der Namensraum std

Der C++-Standard schreibt einen vordefinierten Namensraum std für alle Bestandteile der C++-Standardbibliothek vor (siehe Seite 401). In diesem befinden sich alle Funktionen der C-Bibliothek, alle Klassen der STL und so weiter. Ab Version 3.0 des GCC ist diese Vorgabe verpflichtend, so dass für den Zugriff auf Bibliotheksfunktionen eine der im nächsten Abschnitt angegebenen Vorgehensweisen erforderlich ist!!!

2.5.2 Zugriff auf Bezeichner in Namensräumen

Wenn Sie eine Funktion, eine Klasse oder einen anderen Bezeichner verwenden wollen, die in einem Namensraum deklariert sind, haben Sie zwei Möglichkeiten:

Diese zwei Varianten wollen wir uns jetzt genauer ansehen.

Explizite Angabe des Namensraums

Hierbei setzen Sie den Namen des Namensraums vor den Bezeichner und trennen die beiden durch den Bereichsoperator ::. Für unser Beispiel heißt das:

// Datei maxmoritzmain.cc

 

#include ``max.h''

#include ``moritz.h''

 

int main()

{

  double x = Max::algorithm(1.0);

  // ...

Auf diese Weise können wir die Funktion algorithm() eindeutig identifizieren.

Konsequenterweise gehört alles, was Sie nicht explizit in einen selbst definierten Namensraum gepackt haben, in den globalen Namensraum. Wollen Sie ausdrücklich betonen, dass Sie auf ein Element dieses globalen Namensraums Bezug nehmen möchten, so setzen Sie nur den Bereichsoperator davor, etwa:

  int* p = ::new int[10];

Das Schlüsselwort using

Sie können auch einen ganzen Namensraum bekannt machen, so dass Sie ohne weitere Angaben darauf zugreifen können. Dazu geben Sie das Schlüsselwort using gefolgt von namespace und dem Namen des Raums an. Haben Sie sich beispielsweise für die Lösung unseres Entwicklers Moritz entschieden, schreiben Sie zu Beginn Ihres Programms:

using namespace Moritz;

Anschließend werden alle Elemente dieses Namensraums so behandelt, als ob sie global verfügbar wären. Auf Konflikte weist Sie der Compiler gegebenenfalls hin.

// Datei maxmoritzmain.cc

 

#include ``max.h''

#include ``moritz.h''

 

int main()

{

  using namespace Moritz;

  double x = algorithm(1.0); // aus ``Moritz''

  // ...

Die using-Anweisung gilt dabei für den aktuellen Block. Wenn Sie sie also innerhalb einer Funktion verwenden, ist der Inhalt des Namensraums nur in dieser Funktion global bekannt; in einer anderen nicht mehr. Um einen Namensraum für eine ganze Implementierungsdatei verfügbar zu machen, gibt man using auch oft außerhalb aller Funktionen an, zum Beispiel gleich nach den #include-Anweisungen.

Wenn Ihnen der komplette Raum zu viel ist, erlaubt Ihnen using genauso die Bekanntmachung einzelner Funktionen oder Konstanten. In diesem Fall schreiben Sie dahinter den voll qualifizierten Namen, also mit Raum und Bereichsoperator. Anschließend ist dieser dann global, das heißt ohne Angabe des Namensraums, verfügbar.

int func(double x)

{

  using Moritz::EPS;

  if (x < EPS) {  // entspricht Moritz::EPS

  // ...

2.5.3 Zusammenfassung mehrerer Namensräume

Wenn sich Namensräume nicht überschneiden, kann man sie zu einem einzigen zusammenfassen, um sie besser handhaben zu können. Dazu macht man sie einfach global in einem neuen bekannt und übergibt diesem damit den Bezug. Sind etwa Max und Moritz ohne identische Bezeichner, können wir aus ihnen einen neuen Raum Algorithmen machen.

// Datei Algorithmen.h

 

namespace Algorithmen

{

  using namespace Max;

  using namespace Moritz;

}

Für diese Vorgehensweise ist ebenso eine selektive Aufnahme einzelner Bezeichner möglich.

2.5.4 Verschachtelte Namensräume

Das Konzept der Namensräume geht noch etwas weiter. Wenn unsere Programmierer ihrerseits wieder ihre einzelnen Komponenten in Namensräume eingeteilt hätten, erhielten wir innerhalb des Raumes Max einen weiteren, zum Beispiel Auxiliary. Der Zugriff erfolgt wieder auf die oben beschriebene Weise:

  //...

  if (x< Max::Auxiliary::EPS) {

  // ...

Bei mehreren verschachtelten Namensräumen wird der Zugriff natürlich schnell unhandlich, da man vor lauter Namensräumen den eigentlichen Bezeichner kaum noch sieht. Es gibt daher die Möglichkeit, für solche Namensketten einen Alias zu vergeben, der die vollständige Schachtelungstiefe abdeckt. Selbst für einstufige Namensräume eignen sich Aliase, um sehr lange Namen abzukürzen.

  // Der neue Raum MAD enthaelt drei 

  // verschachtelte Namensraeume

  namespace MAD = Max::Auxiliary::Debug;

Mit diesem Alias haben Sie nun unmittelbaren Zugriff auf den innersten Namensraum und können nicht nur viel Tipparbeit einsparen, sondern auch erheblich zur besseren Übersichtlichkeit Ihres Quelltextes beitragen.

2.5.5 Zusammenfassung

In diesem Abschnitt gab es wieder einiges Neues:

2.5.6 Übungsaufgaben

  1. Warum sind gleiche Namen für Funktionen auch dann nicht erlaubt, wenn sie sich in verschiedenen Implementierungsdateien (und damit verschiedenen Objektdateien) befinden?
  2. Was ist der Unterschied zwischen einem Namensraum und einer Klasse?
  3. Halten Sie es für sinnvoll, eine using-Anweisung in eine Header-Datei zu schreiben?

2.6 Funktionen und Methoden

2.6.1 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);

  std::cout << ``a = `` << a 

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

            << ``, a+c = `` << add(a,c) << std::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)

{

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

}

Es ist zwar nicht zwingend erforderlich, return auch in void-Funktionen zu verwenden. Gleichwohl ist es dennoch zulässig. Ein solcher return-Befehl 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.

2.6.2 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 142) ist also der Prototyp die Deklaration und die Funktion mit Körper die Definition.

Folgendes Beispiel zeigt den Umgang mit Prototypen:

1:  #include <iostream> 
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:    std::cout << "Ergebnis: " << z << std::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:



Datei Zeilen


funktionen.h 3-4
funktionen.cc 16-24
haupt.cc 1; 6-15


  

 

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.h''

 

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 -O haupt.cc funktionen.cc -o funktionen

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

% g++ -Wall -O -c haupt.cc

% g++ -Wall -O -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 finden Sie ab Seite 120.)

2.6.3 Ü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 nennt man gelegentlich auch 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 169) 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:

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

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

Beachten 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:    std::cout ¡¡ add(a,b);
9:    std::cout ¡¡ add(c,d);
10:    std::cout ¡¡ add(31,’a’);
11:    std::cout ¡¡ add(3.1, c);
12:    std::cout ¡¡ add(3.1, float(c));
13:    std::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 103 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 200 ff.).

2.6.4 Ü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> 
4:   
5:  int main(int argc, char* argv[]) 
6:  { 
7:    int i; 
8:   
9:    for(i=0; i<argc; i++) 
10:      std::cout << i << ". Argument: " << argv[i] << std::endl; 
11:   
12:    return 0; 
13:  } 
Die for-Schleife in Zeile 9, zu deren Syntax wir später noch genauer kommen (Seite 308), 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 298 vorstellen werde.

2.6.5 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++)

    std::cout << fillchar;

 

  std::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 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.

2.6.6 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 = 80;    // Variable

  int& h = height;    // Referenz auf height

 

  h += 10;

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

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

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

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 10 erhöht, ist auch height anschließend entsprechend größer. Die Ausgabe ist also:

Höhe: 90

Höhe: 91

Höhe: 92

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;

 

  std::cout << "big1: "<< big 

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

 

  swap_values (big, small);

  std::cout << "big2: "<< big 

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

 

  swap_refs (big, small);

  std::cout << "big3: "<< big 

       << " small3: "<< small << std::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 106) 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 Button

{

public:  

  void setHoehe(unsigned int _neue_hoehe);

  unsigned int getHoehe() const;

  // ...

};

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

void displayButton(const Button& bt)

{

  std::cout << ``Höhe: `` 

       << bt.getHoehe();  // erlaubt!

 

  bt.setHoehe(20);   // 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 immer 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.

2.6.7 Zugriffsroutinen

Sie erinnern sich sicherlich, dass ich Ihnen die Datenabstraktion als wichtiges Prinzip der objektorientierten Programmierung vorgestellt habe (auf Seite 82). Schlagen Sie am besten nochmals Abbildung 2.6 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 Button

{

private:

  unsigned int hoehe;

  // ...

public:  

  void setHoehe(unsigned int _neue_hoehe);

  unsigned int getHoehe() 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 int Button::getHoehe() const

{

  return hoehe;

}

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 setHoehe() gemacht: Wir setzen den Wert nicht, wenn die verlangte Hoehe über den Bildschirmdimensionen von maximal 1024 Pixel liegt.

void Button::setHoehe(unsigned int _neue_hoehe)

{

  if (_neue_hoehe >1024) 

    return;

 

  hoehe = _neue_hoehe;

  return;

}

Üblicherweise ignorieren die Programmierer bei Schreibmethoden den Rückgabewert; daher 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.

2.6.8 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 144) 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 den 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 Button.h

 

class Button

{

private:

  unsigned int hoehe;

  ToggleStatus zustand;

  // ...

 

public:

  unsigned int getHoehe() const

  {

    return hoehe;

  }

 

  ToggleStatus getStatus() const

  {

    return zustand;

  }

  // ...

};

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

Die zweite Variante hat beispielsweise folgende Form:

// Datei Button.h

 

class Button

{

public:

  unsigned int getHoehe() const;

  ToggleStatus getStatus() const;

  // ...

};

 

inline unsigned int Button::getHoehe() const

{

  return hoehe;

}

 

inline ToggleStatus Button::getStatus() const

{

  return zustand;

}

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

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

// Datei Button.h

 

class Button

{

public:

  unsigned int getHoehe() const;

  ToggleStatus getStatus() const;

  // ...

};

Die Implementation steht in einer eigenen Datei:

// Datei Button.cc

 

unsigned int Button::getHoehe() const

{

  return hoehe;

}

 

ToggleStatus Button::getStatus() const

{

  return zustand;

}

// ...

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.

2.6.9 Zusammenfassung

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

2.6.10 Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

    1. Wie viele Rückgabewerte kann eine Funktion haben?
    2. Was ist die Signatur einer Funktion?
    3. Kann eine Klasse zwei Methoden gleichen Namens enthalten, die sich nur in ihren Rückgabewerten unterscheiden?
    4. Nennen Sie je zwei Vorteile und Nachteile von inline-Funktionen.

  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. Sie wollen mit Hilfe von Referenzen eine Funktion zur Vertauschung der Inhalte zweier int-Variablen schreiben. Ihr erster Versuch lautet:

    void swapper(int& wert1, int& wert2)

    {

      int& temp = wert2;

      wert2 = wert1;

      wert1 = temp;

    }

    Beim Test stellen Sie fest, dass diese Variante nicht funktioniert. Warum?

  4. Binomialkoeffizienten sind mathematische Größen, die in der Kombinatorik eine wichtige Rolle spielen. Sie sind zwar als Produkte (bzw. Quotienten von Produkten) definiert, lassen sich aber effektiver als rekursive Funktion berechnen. Dabei gilt folgende Vorschrift:
    ( )   {  (n-1)+ (n-1)falls n > k > 0
 n  =      k     k-1
 k       1           falls n = k oder k = 0
    Erstellen Sie ein C++-Programm, das Binomialkoeffizienten auf Basis dieser Formel rekursiv berechnet. Lesen Sie dazu die Parameter n und k von der Kommandozeile ein. Testen Sie Ihr Programm mit geeigneten Werten.
  5. Man bezeichnet diejenigen natürlichen Zahlen k als perfekt, bei denen die Summe ihrer echten Teiler (außer k selbst) gerade wieder k ergibt. Beispielsweise hat 6 die Teile 1, 2 und 3; deren Summe ist wieder 6. Damit ist 6 eine perfekte Zahl.

    Schreiben Sie ein C++-Programm, das zwei natürliche Zahlen als untere und obere Grenze von der Kommandozeile einliest und dann alle perfekten Zahlen in diesem Intervall ausgibt. Wie viele perfekte Zahlen können Sie finden? (Hinweis: Es gibt mehr als 4!)

  6. Deklarieren Sie eine Klasse Lager, die folgende öffentliche Methoden enthalten soll:
    void einlagern(int stueckzahl);

    void entnehmen(int stueckzahl);

    int getBestand();

    Diese Methoden sollen mit einem privaten Attribut lagerbestand arbeiten, welches vom Typ int sein soll.

    1. Ist die Deklaration der ersten beiden Methoden sinnvoll? Welche andere Deklaration (besonders des Datentyps des Parameters) wäre besser?
    2. Muss dann auch lagerbestand anders deklariert werden?
    3. Implementieren Sie die Klasse vollständig. In welche zwei Dateien sollten Sie die Deklaration und Definition Ihrer Klasse ablegen?
    4. Schreiben Sie ein Hauptprogramm, mit dem Sie das Verhalten Ihrer Klasse Lager testen können.

  7. Eine rationale Zahl wird dargestellt durch r = ab mit ganzzahligen a und b, wobei b/=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 303) 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.

2.7 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.

2.7.1 Ü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.



Abbildung 2.13: Konstruktoren werden beim Erzeugen eines Objekts aufgerufen.

PIC


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.

2.7.2 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 Datumsobjekt 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 192), 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 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().

2.7.3 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.

2.7.4 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 durch 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 kann 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.

2.7.5 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)

{

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

       << std::endl;

}

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

Datum d1;

Datum d2(d1);



Abbildung 2.14: Ein Kopierkonstruktor erzeugt ein Objekt nach der Vorlage eines anderen.

PIC


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:

Button startButton;

Button stopButton;

 

// Zuweisung, kein Kopierkonstruktor:

stopButton = startButton; 

 

// Initialisierung mit Kopierkonstruktor:

Button pauseButton =startButton; 

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 380).

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.

2.7.6 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 205 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()

{