In diesem Kapitel will ich Ihnen die Grundlagen der objektorientierten Programmierung in C++ vorstellen. Dabei haben wir ein umfangreiches Pensum vor uns:
Das Erlernen der objektorientierten Programmierung ist für zwei Gruppen von Menschen schwierig:
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 Fenster-Anwendungen 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.
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 Investionen 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 diskretes 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).
|
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.
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.
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 Parameter ü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 Pascal mit Modulen konzipiert wurde.)
Jedes Modul sollte dabei über eine klar definierte Schnittstelle verfügen. (Darunter versteht man die Gesamtheit aller Prozeduren, die von einem anderen Modul aus aufgerufen werden können.) Dann kann man auch das Gesamtsystem in hierarchische Schichten von Modulen aufteilen, wobei die Module der oberen Schichten stärker abstrakte und komplexe Funktionalität bieten, die sie durch Aufruf darunter liegender Module erreichen, die dann die konkreteren Anweisungen enthalten (die so genannte untere Ebene).
Die Daten, mit denen das Programm umgeht, fasst man zu Strukturen zusammen, also zu Gruppen mit eigenem Namen, die einen Datentyp festlegen. Jedes Modul kann dabei eine eigene Datenmenge haben; diese gibt es nach Bedarf ganz oder teilweise an nachgeordnete Module zur Bearbeitung weiter.
Diese Vorgehensweise war viele Jahre gang und gäbe. Mit der Zeit haben sich aber einige Probleme herauskristallisiert, die immer wieder auftauchten und daher typisch für diesen Ansatz sind.
Zunächst ist es äußerst schwierig, die Robustheit des Codes selbst zu gewährleisten. (Damit meint man Aspekte wie Sicherheit vor Abstürzen, Ausfallsicherheit, Fähigkeit mit unvorhergesehenen Situationen umgehen zu können.) Speicher für Datenstrukturen muss dynamisch, das heißt zur Laufzeit des Programms, reserviert werden, um flexibel zu bleiben. Dieser Speicher muss jedoch auch explizit, also durch eine Programmanweisung, wieder freigegeben werden; zudem darf ein Programm nicht in Bereiche schreiben, die es nicht reserviert hat. Die Probleme, die allein aus diesem Zusammenhang herrühren, sind legendär und treten am gravierendsten bei Verwendung der Programmiersprache C auf.
Zudem ist der Compiler nicht in der Lage, bei Parameterübergaben und Zuweisungen zu überprüfen, ob die Datentypen überhaupt zueinander passen. Gerade beim Austausch inkompatibler Informationen kommt es immer wieder zu Verfälschungen und schweren Rundungsfehlern, zum Beispiel wegfallende Vorzeichen.
Schwerwiegender noch sind indessen die Probleme, die die strukturierte Programmierung hinsichtlich der Projektorganisation mit sich bringt.
Sie dürfen daraus jedoch auch nicht den Umkehrschluss ziehen, dass mit einem objektorientierten Ansatz alle Schwierigkeiten vorbei sind. Ein Allheilmittel ist auch dieser nicht, denn auch ein objektorientiert durchgeführtes Projekt kann schief gehen. Allerdings unterstützen die Konzepte Sie sehr stark bei der Bewältigung dieser (und anderer) Probleme. Und gerade das scheint einer der wesentlichen Gründe für die Beliebheit der objektorientierten Softwareentwicklung zu sein.
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.
![]()
|
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 die Simulationsspiele, die zurzeit sehr beliebt sind. Meistens muss der Spieler dabei eine Siedlung gründen und aufbauen und an die verschiedensten Einflüsse denken, die ihm dabei in die Quere kommen können. Die ganze Szenerie besteht dabei üblicherweise aus einer Vielzahl unterschiedlicher Objekte, die mehr oder weniger ein Eigenleben führen. Wenn wir also beispielsweise eine Kolonialisierung des Planeten Antares-3 programmieren wollen, brauchen wir unter anderem ein paar Raumfahrzeuge:
![]() |
|
![]() |
Aus beiden Beispielen wird deutlich, wodurch sich ein Objekt charakterisiert: nämlich durch seine Eigenschaften (auch Attribute genannt). Die Arten von Eigenschaften sind bei jeder Gruppe von Objekten dieselben, ihre Werte indessen verschieden. Auch im realen Leben halten wir ja Gegenstände anhand ihrer Eigenschaften auseinander.
Ein weiteres wichtiges Merkmal ist das Verhalten eines Objekts. Dieses hängt ab vom Zustand, in dem das Objekt sich augenblicklich befindet. Ein Raumfahrzeug soll auch in unserer Simulation stehen, starten, fliegen und landen. Diese Zustände gehen durch Befehle des Kommandanten (oder unseres Programms) ineinander über. Ändert sich der Zustand eines Objekts, so ändern sich oft auch seine Verhaltensweisen.
Auch kann es verschiedene Gruppen von Zuständen geben, die gegenseitig aufeinander Einfluss haben. Ist das Raumfahrzeug beispielsweise schwer angeschossen, kann es überhaupt nicht mehr fliegen. Die Änderung von einsatzbereit in abgeschossen bewirkt, dass nur noch bestimmte Bewegungszustände möglich sind.
Grob gesprochen haben unsere Objekte jeweils dieselben Verhaltensweisen. Worin sie sich wirklich unterscheiden, sind die Werte ihrer Eigenschaften und Zustände. Wenn wir also eine Simulation programmieren wollen, müssen wir nicht für jedes einzelne Raumfahrzeug alle Verhaltensmuster implementieren (dann hätte uns unser objektorientierter Zugang eine Menge zusätzlicher Arbeit bereitet!), sondern versuchen, eine allgemein gültige Beschreibung für alle Objekte vom Typ Raumfahrzeug zu finden.
Fassen wir also die bisher verwendeten Attribute zu einer Schablone zusammen: Raumfahrzeug
verfügt über die Eigenschaften Bezeichnung
, Herkunft
, Höhe
,
Höchstgeschwindigkeit
, BewegungsZustand
sowie
GradDerFunktionstüchtigkeit
. Damit haben wir unser Wissen
über die Elemente abstrahiert. Eine solche Abstraktion nennt man eine Klasse.
Sie ist eine allgemein gültige Beschreibung von Arten von Objekten.
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.
Damit kennen Sie auch schon die wesentlichen Kennzeichen einer Klasse:
Raumfahrzeug
, Objekte Shuttle_Enterprise
und Antarianer_Schiff
).
Wenn wir uns allein mit Eigenschaften und Zustände bei unseren Objekten zufrieden geben würden, wären unsere Programme ziemlich langweilig. Sie würden nämlich nur eine sehr statische Sichtweise auf die Objekte wiedergeben. Sie sagen aber noch nichts über das Verhalten der Objekte aus, also über die Dynamik. Dazu brauchen wir noch Funktionen und Prozeduren.
Wir haben Sec:Problem_struktur gesehen, dass bei der strukturierten Programmierung Daten und Funktionen getrennt sind. Einige der Probleme, die wir für diesen Ansatz identifiziert haben, lassen sich darauf zurückführen. In der objektorientierten Programmierung hingegen bilden Daten und Funktionen eine Einheit. Denn das Verhalten der Objekte hängt ja oft unmittelbar von den Werten ihrer Attribute ab. Das Shuttle kann nicht schneller fliegen, als seine Höchstgeschwindigkeit es erlaubt.
Wie bringen wir nun ein Objekt dazu, sich auf die eine oder andere Art zu verhalten? Wir sagen es ihm einfach. Da wir nicht direkt mit ihm sprechen können, senden wir ihm eine Nachricht. Das ist nämlich genau die Art, wie Objekte untereinander kommunizieren: Sie schicken sich gegenseitig Nachrichten, die dann zu Änderungen des Zustands oder eines Attributs führen können.
Die Funktionen, die zu einer Klasse gehören, nennt man Methoden (oder Operationen). Sie dienen dazu, Nachrichten zu versenden beziehungsweise zu behandeln. Methoden arbeiten meist innerhalb einer konkreten Instanz einer Klasse und greifen dabei auf die Attribute und Zustände dieses Objekts zu. Die Änderungen, die die Methode durchführt, sind dann nur in dieser Instanz gültig und haben auf andere Instanzen, die eventuell im Augenblick daneben noch existieren, keinen Einfluss.
Von besonderer Bedeutung ist das Prinzip der so genannten Prozessabstraktion: Für den Absender einer Nachricht ist es normalerweise unwichtig und daher auch unbekannt,
In Abbildung 2.4 sind einige mögliche Vorgänge bei der Kommunikation zwischen Objekten dargestellt. Zunächst schickt das erste Objekt eine Nachricht an das zweite. Das kann diese Anfrage nicht alleine beantworten und fragt daher bei einem dritten Objekt nach. Dieses gibt auch eine Antwort zurück. Anschließend führt das zweite Objekt noch eine Änderung seines Zustands durch. Danach kann es die Antwort auf die ursprüngliche Anfrage erstellen und an den Absender schicken.
In wirklichen Programmen werden Sie nur selten etwas finden, das wie eine tatsächliche Nachrichtensendung aussieht. Normalerweise sind es Funktionen, die Sie an einem Objekt aufrufen. Im Gegensatz zur strukturierten Programmierung müssen Sie aber in einem objektorientierten Programm stets angeben, zu welchem Objekt die Methode gehört, die Sie rufen wollen.
Die Sichtweise der Nachrichten bringt uns noch zu einem anderen Aspekt. Wenn man ein Objekt nur dann modifizieren kann, wenn man ihm eine Nachricht schickt, heißt das praktisch, dass von außen kein direkter Zugriff auf die Daten, also die Eigenschaften und Zustände, möglich ist. Man sagt dazu, die Daten sind gekapselt. Da folglich die Daten nach außen, also außerhalb des jeweiligen Objekts, nicht sichtbar sind, können andere Objekte diese weder direkt lesen noch direkt verändern. Sie müssen dazu immer das besitzende Objekt benachrichtigen (Abbildung 2.5). Dieses kann dann selbst bestimmen, wie es auf eine solche Nachricht reagieren will. Es kann dem Wunsch nachkommen -- oder ihn zurückweisen. Das Objekt behält also jederzeit die volle Kontrolle über seine Daten.
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.
Sie sollten sich zu diesem Thema folgende Punkte einprägen:
Ein beliebtes Beispiel in der Literatur ist die Bankanwendung.
Konto
und Bankkunde
mit einigen Eigenschaften,
die Sie für relevant halten, und definieren Sie ein paar mögliche Methoden.
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.
C++ hat nicht ohne Grund in seinem Namen das C. Es wurde nämlich ursprünglich
(ca. 1980) entworfen, um die unter Unix verbreitete Programmiersprache C um das
Klassenkonzept zu erweitern. Daher taufte der Entwickler, Bjarne Stroustrup,
seine Sprache zunächst C with Classes. Da es sich um eine Weiterentwicklung
von C handelt, wurde 1983 der Begriff C++ geprägt. In C bedeutet der Operator
++
nämlich eine Erhöhung der daneben stehenden Variable um 1. (So gesehen
ist es übrigens syntaktisch falsch, das zweite Plus wegzulassen ...)
Bis in die frühen neunziger Jahre hinein gab es auch kaum eigene Compiler für C++, also Programme, die aus dem Quelltext ein maschinenlesbares und -ausführbares Programm machen. Meist verwendete man einen Compiler, der aus C++-Code dann C-Code erstellte, den man anschließend mit einem normalen C-Compiler weiterbehandeln konnte. Sie können sich sicherlich denken, dass dieses Verfahren einige Probleme mit sich brachte und nicht optimal sein konnte. Heute verwendet man daher nur noch eigene C++-Compiler, die direkt Maschinencode erzeugen.
Da C++ schnell eine große Anhängerschaft fand, wurde es nötig, die Syntax, also die Regeln für die Sprache, formal zu standardisieren. Seit 1991 arbeitet eine gemeinsame Kommission der amerikanischen Standardisierungsbehörde ANSI und der internationalen ISO an einem Standard der Sprache C++. Eine Reihe von Entwürfen hat die Kommission in dieser Zeit veröffentlicht, die alle von den Compiler-Herstellern als De-facto-Standards aufgegriffen und bei der Entwicklung ihrer Werkzeuge berücksichtigt wurden. (Eine genaue Schilderung der Entwicklungsgeschichte findet sich in [STROUSTRUP 1994].) Im Herbst 1998 wurde dann tatsächlich ein Standard verabschiedet, der die Sprache äußerst detailliert und in ihrem maximalen Umfang beschreibt. Doch auch damit hört die Arbeit nicht auf. Es liegen schon wieder neue Entwürfe für Erweiterungen vor.
Die grundlegenden Sprachelemente werden selbstverständlich von allen aktuellen Compilern unterstützt. Die einzelnen Werkzeuge unterscheiden sich nur darin, wie umfangreich und wie gut sie auch die neueren Erweiterungen abdecken. Der GNU-Compiler etwa, der in diesem Buch verwendet wird, kennt ab Version 2.95 zum Glück so gut wie alle Sprachkonstrukte des ANSI/ISO-Standards. Gleichzeitig bedeutet ein gutes Programm zu schreiben jedoch nicht, möglichst alle neuesten Features aneinander zu hängen. Gerade wenn Sie noch wenig Erfahrung mit C++ haben, sollten Sie für Ihre Programme auf die allzu trickreichen Konstrukte verzichten.
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.
Festzuhalten bleibt: C++ ist eine Obermenge von C (Abbildung 2.6), das heißt, es hat alle Syntax-Eigenschaften 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.
Unix und die Programmiersprache C sind sehr eng miteinander verbunden. C wurde für Unix konzipiert und so gut wie alle Unix-Implementierungen wurden in C programmiert. Auch der Linux-Kernel ist in C geschrieben.
Mit der Zeit haben allerdings auch die Entwickler unter Unix die Vorteile des objektorientierten Ansatzes erkannt. Er mag vielleicht bei systemnahen Programmen nicht so offensichtlich sein, so dass hier auch heute noch C vorherrscht. Bei Anwendungsprogrammen, insbesondere solchen mit grafischer Benutzeroberfläche, ist hingegen der Gewinn bei Konzeption, arbeitsteiliger Programmierung und Wartung/Verbesserung deutlich spürbar. Für die Entwicklung von Benutzeroberflächen stehen heute eine Reihe sehr guter C++-Bibliotheken (zum Beispiel wxWindows, V oder Qt) zur Verfügung. Auf der Bibliothek Qt basiert beispielsweise die gesamte Arbeitsumgebung KDE (siehe www.kde.org) und die dafür geschriebenen Anwendungen. Sie sehen also, wenn Sie sich für C++ unter Linux entscheiden, liegen Sie nicht völlig falsch.
Der Standard-Compiler unter Linux war lange Zeit der GCC, also der GNU-C-Compiler, der in bewährter Weise als freie Software entstanden war. Obwohl er auch um einen C++-Compiler (namens g++) erweitert wurde, konnten die Entwickler nicht mit der Geschwindigkeit mithalten, mit der sich die Sprache C++ weiterentwickelte. Eine kleine Gruppe machte sich 1997 daran, auf der Basis des GCC einen Schritt weiter zu gehen und zusätzliche Features zu unterstützen. Das Projekt nannte sich experimental GNU compiler suite, abgekürzt egcs, gesprochen eggs. Im April 1999 machten die Verantwortlichen dann der Parallelität der Entwicklungen ein Ende und erklärten den egcs zum offiziellen GNU-Compiler. Seit der Version 2.95 vom Sommer 1999 gibt es wieder nur eine GCC, was nun GNU compiler collection heißen soll; da sich noch niemand an den neuen Namen und das damit verbundene neue Geschlecht im Deutschen gewöhnt hat, werde ich weiterhin von dem GCC sprechen.
Mit diesem werden wir im Folgenden arbeiten. Er unterstützt so gut wie alle Sprachmerkmale des aktuellen Standards. Auf die wenigen Aspekte, die noch nicht so vollständig unterstützt werden, kommen wir in diesem Rahmen ohnehin nicht zu sprechen.
Der genaue Umgang mit diesem Compiler wird Ihnen im Abschnitt
auf Seite
erläutert.
Aber der GCC ist nicht der einzige C++-Compiler, den es unter Linux gibt. Kommerziell
erhältlich sind beispielsweise die Compiler der Portland Group (zu
finden unter www.pgroup.com),
von Kuck & Associates (siehe www.kai.com/C_plus_plus) oder von Metrowerks
(www.metrowerks.com, siehe auch Seite ). Die Firma Borland/Inprise bietet ebenfalls
eine Kommandozeilenversion ihres C++-Compilers zum freien Download für Linux
an (erhältlich unter www.borland.com).
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:cout << "Summe bis " << i << ": ";
18:cout << zahl << "\n";
19:}
20:}
21:
Haben Sie schon eine Idee, welchen Zweck es haben könnte? Zunächst natürlich soll es Ihnen ein paar lexikalische Elemente der Sprache C++ vor Augen führen. Zur Ausgabe kommen wir später ...
Das Programm ist so einfach, dass es nur einer Datei mit diesem Stück Code bedarf.
Auf eine Header-Datei, wie auf Seite beschrieben, können
wir also verzichten.
Sie finden übrigens alle in diesem Buch vorgestellen Beispielprogramme auch auf beiligender CD-ROM, im Unterverzeichnis des jeweiligen Kapitels. Das soll Sie aber nicht davon abhalten, auch mal das eine oder andere Beispiel selbst einzutippen. Denn meistens macht man dabei einen Tippfehler und lernt damit gleich, wie der Compiler auf solche Fehler reagiert.
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 Programmen 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:
//
, dann gehören alle Zeichen bis zum Zeilenende
zum Kommentar. In der nächste Zeile geht es dann wieder mit Programmcode weiter.
Diese Möglichkeit eignet sich eher für einzeilige Kommentare sowie Anmerkungen
hinter Befehlen.
/*
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.
In fast jedem Programm müssen Sie mit irgendwelchen Daten umgehen. Dazu brauchen
Sie Speicherstellen im Rechner, in denen Sie diese Daten ablegen. Eine solche
Speicherstelle bezeichnet man als Variable, eben weil
sie ihren Wert (das heißt ihren Inhalt) im Verlauf des Programms ändern kann.
In C++ unterscheidet man die Variablen nach ihrem Typ, also ob sie Zahlen oder
Wörter oder etwas anderes aufnehmen können. Zahlen sind dabei sehr wichtige
Typen; es gibt für ganze Zahlen und für Dezimalbrüche mehrere Typen -- je nach
Zahlenbereich, der sich mit ihnen darstellen lassen soll. Doch dazu später mehr
(auf Seite ).
Jede Variable hat einen Namen. In unserem Beispiel gibt es zwei Variablen, nämlich
zahl
und i
. Bevor Sie eine Variable verwenden können, müssen Sie
sie zunächst deklarieren. Das heißt, Sie schreiben etwa (wie in Zeile 10)
int zahl;
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, dass die Variable dieses Namens mit diesem Speicherplatz verbunden
ist. Sie können auch mehrere Variablen desselben Typs zusammen deklarieren,
indem Sie sie durch Kommas getrennt aneinander setzen. Zudem ist es erlaubt,
der Variable gleich bei der Deklaration einen Wert zuzuweisen, sie also zu initialisieren.
Bei der Namensgebung für Variablen sind Sie ziemlich frei, wenngleich Sie ein paar
Regeln beachten müssen: Der Name muss mit einem Buchstaben oder einem Unterstrich
(_
) beginnen und darf dann aus Groß- und Kleinbuchstaben sowie Ziffern
und Unterstrichen bestehen. Nicht erlaubt sind Leerzeichen und Sonderzeichen.
Die maximale Länge ist 250 Zeichen, aber mehr als fünfzehn bis zwanzig dürfte
bereits ziemlich unpraktisch sein. Bedenken Sie auch, dass C++ zwischen Groß-
und Kleinschreibung unterscheidet; Sie müssen also alle Namen genau so verwenden,
wie Sie sie deklariert haben.
Eine Unart hat C++ von C geerbt: Variablen werden bei der Deklaration nicht initialisiert. Das bedeutet, sie haben nach der Deklaration keinen definierten Wert, sondern können alle möglichen Werte aufweisen. Das ist besonders dann wichtig, wenn Sie mit dieser Variable gleich weiterarbeiten und sie etwa erhöhen, wie in Zeile 16. Dort wird nämlich zum aktuellen Wert eine Zahl addiert. Ist bereits der aktuelle Wert undefiniert (was er in diesem Beispiel aber dank der Initialisierung in Zeile 11 nicht ist), ist es das Ergebnis genauso. Daher sollten Sie jede Variable gleich nach der Deklaration mit einem sinnvollen Wert initialisieren, um solchen unangenehmen Effekten vorzubeugen. Wenn Sie allerdings Glück haben, ist bereits der Compiler so intelligent und belegt die Werte der Variablen vor. Der GCC setzt in diesem Fall die Variable auf 0. Ein solches Wohlverhalten dürfen Sie aber nicht überall erwarten.
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;
Ein besondere Art von Operatoren wird für die Ein- und Ausgabe verwendet. In obigem Beispiel konnten Sie in Zeile 17 etwa lesen:
cout << ``Summe bis `` << i << ``: ``;
«
-Operator bedeutet dabei nichts weiter, als dass alles, was rechts
von ihm steht, nach links weiter gereicht wird. Dieses Weiterreichen
endet am cout
, das die Ausgabe auf dem Bildschirm (zum Beispiel im Shell-Fenster)
repräsentiert. Mit dieser Zeile geben wir also zwei Texte mit einer Zahl in
der Mitte aus.
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:
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
).
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.
Mit der Festlegung des Datentyps bestimmen Sie die Art, wie der Compiler die
Informationen interpretiert und wie er sie im Speicher ablegt. Auch die Art
der Operationen, die mit dieser Variablen erlaubt sind, hängen von ihrem Datentyp
ab. Wie oben (Seite ) erklärt, erfolgt die Festlegung
bei der Deklaration der Variablen, also zum Beispiel:
int zahl; |
// Variable als Integer deklariert |
char x, y, z; |
// Drei Variablen als einfache Zeichen |
float d = 3.14; |
// Fließkommazahl gleich initialisiert |
C++ hat zum einen eine Reihe von eingebauten Typen (so genannten Standard- oder elementaren Datentypen), bietet aber zum anderen auch die Möglichkeit, eigene Datentypen darauf aufbauend zu definieren, unter anderem Klassen.
Diese unterscheidet man nach ihrer Verwendung:
true
oder false
annehmen.
bool is_positive = (a>0);
char a = 'B'; //
1 Byte (-128 bis 127)
Buchstaben entsprechen dabei ihrem Code im ANSI-Zeichensatz; das 'B'
ist z.B. der Wert 65.
short i = 1; |
// | 2 Byte (-32.768 bis 32.767) |
int i = -6; |
// | 4 Byte (von -2.147.483.648 bis 2.147.483.647) |
long i = 123456789L; |
// | 4 oder 8 Byte (plattformabhängig!) |
Dabei sind short
und long
nur abkürzende Schreibweisen für short int
beziehungsweise long int
.
float x = 1.23f; |
// | 4 Byte (1.2E-38f bis 3.4E38f) |
double y = 1.23e98; |
// | 8 Byte (2.2E-308 bis 1.8E308) |
(Vorsicht: Obwohl es auf Deutsch Gleitkommazahl heißt, müssen Sie in C++ alle Zahlen nach der angelsächsischen Konvention eingeben, also mit einem Dezimalpunkt.)
char
, short
, int
und long
den Zusatz unsigned
bekommen (double
zur Vergrößerung
des Wertebereichs den Zusatz
long
), zum Beispiel
unsigned long l;
int
; daher können Sie für eine
vorzeichenlose Ganzzahl auch einfach schreiben:
unsigned u;
void
. Damit ist eigentlich ein nichtexistenter
Wert gemeint. Man verwendet void
vor allem um anzuzeigen, dass ein Unterprogramm
keine Übergabeparamter braucht oder keine Werte zurückliefert.
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:
int
interpretiert.
Wenn Sie eine lange Ganzzahl angeben wollen, müssen Sie das
L
dahinter
setzen.
double
angesehen. Wenn Sie nur die einfache Genauigkeit
ausdrücken möchten, bedarf es eines f
dahinter.
0x
vorausstellen, also beispielsweise int p = 0x1AF7
.
´a´
.
Diese Konstante kann man dann einer Variable vom Typ char
zuweisen.
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 aber ein wenig die dabei angewandten Prinzipien kennen, um beurteilen zu können, ob die automatische Umwandlung auch die ist, die Sie wollen.
Völlig unproblematisch ist die Umwandlung von einem Datentyp mit einem kleineren auf
einen mit einem größeren Wertebereich, also beispielsweise von short
auf int
. Vorsicht ist beim umgekehrten Fall geboten. Hierbei können Informationen
verloren gehen und sogar völlig andere Ergebnisse herauskommen, als Sie vielleicht
erwarten. Ein Beispiel:
short k = 222 * 222;
cout << k << endl;
unsigned
deklariert
sind) stets als Vorzeichen angesehen. Wenn Sie also zum höchsten Wert 32767
noch 1 addieren, enthält Ihre Variable anschließend den Wert -32768.
Wenn noch Konstanten im Quelltext stehen, kann man von Hand nachrechnen und aufmerksam werden. Wenn aber andere Variablen beteiligt sind, wird das schon schwieriger, zum Beispiel:
short k;
long l = 222L;
k= l*l;
cout << k << endl;
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;
C++-Compiler haben zwar eine gewisse Flexiblitä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.
Anstatt sich auf die automatische Typkonvertierung zu verlassen, kann es auch manchmal sinnvoll sein, dem Compiler eine Regel zur Umwandlung vorzuschreiben. So können Sie Missverständnissen vorbeugen, die sonst in Form von Warnungen zu Tage treten würden.
Für die explizite Typumwandlung verwenden die C-Programmierer einen sehr bildlichen Ausdruck, nämlich cast, das englische Wort für Gipsverband. Um einer Variable einen solchen anzulegen, haben Sie in C++ verschiedene Möglichkeiten. Die C-Variante ist, den Typ in runden Klammern vor die Variable zu schreiben, und zwar dort, wo an ihrer Stelle der umgewandelte Wert stehen soll, zum Beispiel
float f = 1.5;
int i = (int)f;
float
nach int
werden alle Nachkommastellen
abgeschnitten; eine Rundung findet nicht statt.
Die andere Möglichkeit ist, die Variable in Klammern zu setzen und den Typ davor:
float f = 1.5;
int i = int(f);
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 ).
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;
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 Variable 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};
Damit können Sie nun Variablen definieren
Status error_code;
Status rueckgabe = SUCCESS;
enum Abrechnungstag
{ WERKTAG, SONNTAG, FEIERTAG} tariftag;
enum { EINSATZFAEHIG, DEFEKT,
AUSGEMUSTERT} zustand;
1
erhöht. Folglich können Sie
Werte eines Aufzählungstyps auch nach int
umwandeln, allerdings nicht
umgekehrt.
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.
Auf jeden einzeln einzugehen, würde an dieser Stelle zu weit führen. Ich will
Ihnen daher in Tabelle Tab:Operatoren eine Liste
mit den wichtigsten Operatoren geben. Wenn Ihnen Verschiedene
nicht gleich klar sind, müssen Sie sich deshalb keine Sorgen machen: Wenn Sie
etwas Erfahrung in der C++-Programmierung gewonnen haben, werden Sie auch alle
Operatoren verstehen; auf einige werde ich im Folgenden auch noch genauer eingehen.
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 |
&& |
Logisches AND | if ((i==j) && (i<3)) {...} |
|
Logisches OR | if ((i==j) |
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 Referenzendeklaration.
Mit der Zeit werden Sie höchstwahrscheinlich 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.
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 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.
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 Variable, 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++;
k
noch mit dem aktuellen Wert von j
berechnet und erst
nach der Zuweisung wird j
um 1 erhöht.
Anfänger empfinden diese Schreibweise stets als verwirrend; die erfahrenen Programmierer sehen sie dagegen als besonders elegant und kompakt an. Überlegen (oder probieren) Sie mal selbst, welche Ausgabe der folgende Code erzeugt:
unsigned int i = 2;
cout << i++ << `` `` << i << `` ``
<< ++i << `` `` << i << endl;
Einer der Operatoren, die in Tabelle fehlen, ist
?:
für die Bedingung. Er hat folgende allgemeine Form:
Die Verwendung ist ganz einfach: Ist die Bedingung wahr, wird Ausdruck1 ausgewertet, ist sie falsch, dann Ausdruck2. Zum Beispiel:Bedingung
?
Ausdruck1
:
Ausdruck2
c = (a!=5) ? 25 : 50;
a
ein anderer als 5 ist, bekommt c
den Wert 25, ansonsten wird c
auf 50 gesetzt.
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));
a + b * c
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;
i * s // Typ int
c + i // Typ int
s * d // Typ double
i / (double) s // Typ double
Aus diesem Abschnitt sollten Sie sich Folgendes merken:
//
kommentieren Sie alles bis zum Zeilenende aus, mit /*
alles
bis zum abschließenden */
. Jedes Programm sollte mindestens so viele
Kommentar- wie Anweisungszeilen enthalten.
#include
binden Sie Header-Dateien mit Deklarationen
von Bibliotheksfunktionen und Datenstrukturen anderer Quelltextdateien ein.
short
, int
und long
. Fließkommazahlen können mit einfacher (als float
) oder
mit doppelter Genauigkeit (double
) angegeben werden. Die Typen char
, short
,
int
und long
können den Zusatz unsigned
erhalten. Der Datentyp
void
steht für einen nicht existenten Wert.
int
eine Zahl mit Dezimalstellen zuweist?
sizeof()
hat den
Zweck, den Speicherverbrauch eines Datentyps oder einer Variablen zu bestimmen.
Welche Ausgabe liefert folgendes Programm?
#include <iostream.h>
int main() {
cout << "short int: "
<< sizeof(short int) << endl;
cout << "int: " << sizeof(int) << endl;
cout << "long int: "
<< sizeof(long int) << endl;
cout << "float: " << sizeof(float) << endl;
cout << "double: "
<< sizeof(double) << endl;
return 0;
}
% g++ typesize.cc -o typesize
x
soll ausgegeben werden, ob sie 0 ist oder nicht.
Was ist hier falsch?
cout << x = (0 ? ``null'' : ``ungleich 0'');
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.
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.)
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 Ihr Programm aus mehreren Quelltextdateien besteht, können Sie auch mehrere hintereinander angeben. Dabei dürfen die Dateien auch in mehreren Verzeichnissen liegen, müssen Sie dazu nur jeweils den vollständigen Pfad angeben, zum Beispiel:
% g++ mein_programm.cc datei2.cc ../gen/liste.cc
Die wichtigsten sind in Tabelle 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.
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
-o
schreiben; sonst kann es passieren, dass der Compiler Ihre Programmdatei für
den gewünschten Namen hält und diese einfach überschreibt!
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, also an bestimmten Stellen stoppen, Werte von Variablen ausgeben oder um einzelne Schritte weiterlaufen lassen können.
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
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;
test.cc: In function `int main()':
test.cc:9: parse error before `long'
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 Compilerschalter
anfordern. Für fast jede Warnung gibt es eine eigene Option, jeweils von der
Form -W
warnungs-typ
. Zum Glück gibt es aber auch Schalter, die
ganze Gruppen von Warnungen aktivieren. Eine ist dabei besonders wichtig: Mit
-Wall
schalten Sie alle Warnungen
ein, die auf unschönen Code hindeuten.
Dabei handelt es sich beispielsweise um nicht oder unzureichend initialisierte
Variablen, Typverwechslungen, missverständliche Klammerungen oder unbenutzte
Variablen. Sie sollten die Warnungen ernst nehmen und solchen Code vermeiden. Wenn wir etwa schreiben:
int main()
{
short s, p;
int i=10.0/8;
cout << i << endl;
return 0;
}
test.cc:6: warning: initialization to `int'
from `double'
test.cc:5: warning: unused variable `short int s'
Sie sehen daran auch, dass Sie der Compiler bei einem Durchlauf nicht auf alles gleichzeitig
hinweist. Hier ist beispielsweise die Variable p
genauso unbenutzt wie s
, wird jedoch nicht
angemahnt. Erst wenn Sie s
entfernen würden, käme die Warnung für p
.
Die Schreibweise -Wall
steht zwar für warnings, all, ist aber nicht
zufällig auch als wall, also Wand, zu lesen. Sie können sich dabei
eine Schutzmauer um Ihren Code vorstellen; wenn er sie überwindet, ist er schon
mal in ganz guter Verfassung. Daher empfehle ich Ihnen, diesen Schalter immer
zu verwenden.
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 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 -- und das jeweils mit einem eigenen Compileraufruf für jede Datei. Sind alle Files ü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
Wir haben oben festgestellt (Seite ), dass Header-Dateien
normalerweise in den bekannten Verzeichnissen für die System-Header gesucht
werden. (Diese hatte ich Ihnen auf Seite
schon mal
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
).
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:
% g++ -o prog datei1.o datei2.o meine_bib.a
-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 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
Normalerweise versucht der Compiler, die ihm gestellte Aufgabe, also das Übersetzen eines Programms, möglichst schnell zu erledigen. Das ist während des Entwicklungszyklus sicherlich auch für den Programmierer hilfreich. Ist das Programm dann aber einmal fertig und soll seine Arbeit verrichten, so sind Sie vermutlich daran interessiert, dass es diese so schnell wie möglich vollbringt. 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 -- aber die Beachtung von bestimmten Reihenfolgen von Anweisungen oder Befehle, um Variablen in die Prozessorregister unterzubringen, sind nicht mehr nötig. Sie müssen nicht einmal wissen, was der Optimierer eigentlich macht, denn Sie interessiert es 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 dazu. 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 mehr 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). Ein so optimiertes Programm
läuft natürlich auf Rechnern mit Prozessoren der vorhergehenden Generationen
nicht mehr.
Eine etwas längere Liste mit Optionen finden Sie auf der man-Seite zu g++. Dazu geben Sie einfach ein:
% man g++
Eine wesentliche Vereinfachung der Bedienung können Sie schon mit xinfo erreichen. Dann erhalten Sie die Informationen wenigstens in einem X-Window und können etwas mit der Maus klicken.
Sehr viel einfacher und zeitgemäßer ist allerdings die Betrachtung mit einem Web-Browser
wie Netscape. Zur Konvertierung dient ein kleines Programm namens
inf2html, mit dem jede angeforderte Info-Seite sofort
nach HTML umgewandelt wird. Es ist Bestandteil der SuSE-Distribution; Sie können
es über deren FTP-Server beziehen (wahrscheinlich unter der Adresse ftp://ftp.suse.com/pub/suse/i386/current/suse/doc1/inf2htm.rpm)
beziehungsweise über Spiegel-Server davon. Es ist indessen nicht nur auf diese
Distribution beschränkt, sondern ist ein einfaches CGI-Skript, das die Dateien
in /usr/info aufbereiten kann. Auf diese Weise können Sie bequem mit
der Maus zwischen den einzelnen Seiten hin- und herspringen (Abbildung Fig:Info2HTML).
Ebenso leicht ist das aber auch mit dem Help-Browser gnome-help-browser der Arbeitsoberfläche GNOME möglich. Dieser hat ebenfalls die Konvertierung nach HTML eingebaut. Dasselbe gilt für die Hilfe-Anwendung des K-Desktop, kurz KDE. Wenn Sie etwa die Seiten zum GCC mit kdehelp betrachten wollen, genügt der Befehl
% kdehelp 'info:(gcc)'
info:/gcc
.
Eine weitere komfortable Möglichkeit zum Betrachten der Info-Seiten bietet der XEmacs-Editor;
lesen Sie dazu Seite .
Die Eingangsseite zeigt Ihnen eine Liste aller Programmpakete, zu denen Dokumentation
im Info-Format vorhanden ist. Dort müssen Sie nur nach dem Stichwort gcc
suchen und dieses anklicken. Dann sollten Sie die in Abbildung
dargestellte Seite vor sich sehen. Hinter dem Unterpunkt INVOKING GCC
finden Sie beispielsweise die Compilerschalter.
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, wie man den Quelltext eigentlich eintippt. Der Grund dafür ist, dass es unter Linux zwar den C++-Compiler gibt, aber nicht einen vorherrschenden Editor (auch wenn manche den Emacs dafür halten). Es gibt eine große Bandbreite an Programmen, mit denen Sie einen Text, aber auch speziell einen Programmtext bearbeiten können. Entsprechend vielseitig ist die Palette der Bedienphilosophien. Viele Editoren sind meiner Erfahrung nach für Anfänger ungeeignet, da dabei sehr eigenwillige Tastenkombinationen nötig sind. Und da ich davon ausgehe, dass Sie vor Ihrer Beschäftigung mit Linux schon einige Erfahrungen mit Windows oder MacOS gemacht haben, möchte ich Sie lieber auf Werkzeuge hinweisen, deren Handhabung Ihnen etwas vertrauter vorkommen müsste. Es macht doch keinen Sinn, wenn Sie sich nicht nur in eine neue Programmiersprache, sondern gleichzeitig auch in einen ungewohnten Texteditor einarbeiten müssten, oder?
Später (ab Seite ) will ich Ihnen noch einige Editoren
im Detail vorstellen sowie verschiedene integrierte Entwicklungsumgebungen besprechen.
Damit nicht alles aus dem Zusammenhang gerissen wird, Sie aber jetzt schon Ihre
ersten Programme schreiben können, möchte ich an dieser Stelle einen Editor
herausgreifen und Ihnen ein paar Tipps zur Arbeit mit NEdit geben.
Bei NEdit handelt es sich um einen Texteditor mit Fensteroberfläche
für Textdateien und Programmtexte (Abbildung Fig:Nedit-Fenster).
Er lehnt sich insbesondere an die unter Windows und MacOS verwendeten Konventionen
an und bietet zum Beispiel Menüs, Dialogfenster, Bearbeitungsmöglichkeiten mit
der Maus sowie die gewohnten Shortcuts.
Viele Distributionen beinhalten bereits NEdit. Natürlich können Sie sich auch mit der neuesten Version aus dem Internet versorgen; dort gibt es sogar schon vorkompilierte Versionen. Sehen Sie dazu am besten auf der Homepage www.fnal.gov/nedit nach. Auch auf beiliegender CD-ROM finden Sie dieses Programm.
Die Bedienung erfolgt in gewohnter Weise. Ein sehr hilfreiches Feature ist das
Syntax-Highlighting, das die verschiedenen Sprachelemente wie Schlüsselwörter,
Präprozessordirektiven oder Kommentare in Farbe und Schrifttyp voneinander abhebt.
Um es einzuschalten, wählen Sie den Menüpunkt PREFERENCES HIGHLIGHT
SYNTAX. Damit es bei jedem Start des Programms auch wieder aktiviert ist, müssen
Sie es mittels PREFERENCES
DEFAULT SETTINGS
SYNTAX
HIGHLIGHTING
ON einschalten und dann die Voreinstellungen abspeichern.
Das Programm erkennt an der Dateierweiterung .cc, dass es sich um einen C++-Quelltext handelt. Entsprechend sind Einrückungen und Ähnliches automatisch eingestellt, so dass Sie immer einen Block auch in derselben Spalte beginnen können. Wenn Sie an die Stelle hinter einer Klammer kommen, wird die zugehörige Klammer kurz durch ein rotes Quadrat markiert.
Ausschneiden, Kopieren und Einfügen können Sie sowohl über das EDIT-Menü als auch mit den Tastenkombinationen Strg+X, Strg+C beziehungsweise Strg+V erledigen. Mit dem Menü SEARCH können Sie nach Textstellen suchen. Und das Speichern geht mittels Strg+S mit links.
NEdit verfügt über eine Online-Hilfe mit allen wichtigen Benutzungsmerkmalen und eine ausführliche Dokumentation im HTML-Format. Wenn Sie also einmal einen Sachverhalt nicht als selbsterklärend erachten, finden Sie dort sicherlich Rat.
Und nun kann ich Ihnen nur raten, die gezeigten Beispiel immer wieder zu laden, zu verändern und zu erweitern -- und natürlich auch eigene Programme zu schreiben. Sicher werden Sie dabei immer wieder mit den verschiedensten Fehlermeldungen des Compilers konfrontiert werden. Aber nur auf diese Weise werden Sie mit der Zeit seine Arbeitsweise verstehen und zu einem korrekten Programmierstil gelangen, mit dem Sie Ihre eigentlichen Aufgaben angehen können.
Die wichtigsten Aspekte dieses Abschnitts waren:
g++
.
Als Argument erwartet dieser zumindest den Namen der Quelltext-Datei.
-c
erreichen Sie, dass die Kompilierung bei der Objekt-Datei
endet.
-g
bewirkt, dass Informationen für den Debugger hinzugefügt
werden.
-I
DIR
können Sie einen zusätzlichen Suchpfad
namens DIR
für Header-Dateien festlegen.
-l
BIBL
führt zum Hinzulinken der Bibliothek libBIBL.a.
-L
DIR
geben Sie einen zusätzlichen Suchpfad namens
DIR
für Bibliotheken an.
-o
NAME
können Sie der Ausgabedatei den Namen NAME
geben.
-O
benutzen, optimiert der Compiler das ausführbare
Programm auf Geschwindigkeit.
-Wall
verwenden, damit Sie alle Warnungen bei
unschönem Code erhalten.
% g++ meinprog.cc
% g++ -I../.. -c -o mydata.cc
myprog.cc
übersetzen und mit der Bibliothek /usr/lib/libm.a zu einer ausführbaren
Datei namens myprog zusammenbinden wollen?
Auf den Seiten ff. haben Sie den Klassenbegriff bereits
in abstrakter Form kennen gelernt. Nun wollen wir uns ansehen, wie man Klassen
und Objekte in C++ formuliert.
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:
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
{
// ...
};
Kommen wir auf unser Beispiel von Seite zurück. Als
C++-Klasse könnten wir unser
Raumfahrzeug
folgendermaßen deklarieren:
1:class Raumfahrzeug
2:{
3:private:
4:string bezeichnung;
5:string herkunft;
6:float hoehe;
7:unsigned long hoechstgeschw;
8:unsigned long geschw;
9:Bewegung zustand;
10:Funktion grad;
11:12:
public:
13:Raumfahrzeug();
14:~Raumfahrzeug();
15:void starten();
16:void landen();
17:bool setGeschwindigkeit(unsigned long _tempo);
18:unsigned long getGeschwindigkeit();
19:void andocken(Raumfahrzeug _anderes);
20:};
Die meisten Attribute der Klasse haben Standarddatentypen, die Sie ja bereits
kennen. Neu ist der Typ string
; dies ist eine Klasse für Zeichenketten,
die wir später noch öfter verwenden werden.
Für Bewegung und Grad der Funktionstüchtigkeit gibt es nur eine beschränkte Zahl möglicher Werte. Wir verwenden daher Aufzählungstypen, die wir folgendermaßen definieren können:
enum Bewegung { STEHT, STARTET, FLIEGT,
LANDET, HYPERRAUM};
enum Funktion { VOLL, ANGESCHOSSEN,
DEFEKT, ZERSTOERT};
In den Zeilen 13 und 14 stehen die Deklarationen des Konstruktors und des Destruktors. Dies sind Funktionen, die immer dann automatisch aufgerufen werden, wenn ein Objekt von dieser Klasse erzeugt beziehungsweise vernichtet wird. Aber dazu in einem der nächsten Abschnitte mehr.
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:
Wenn Sie Ihr Programm aus mehreren Dateien zusammensetzen, hinterlegen Sie die Deklaration der Klasse am besten in einer Header-Datei und die Definition in einer zugehörigen Quelltextdatei. Auf diese Weise können Sie ein Stück Prozessabstraktion verwirklichen: Andere, die Ihre Klasse verwenden wollen, müssen nur die Header-Datei, also die Deklaration kennen; die Definition kann im Verborgenen bleiben. (Wie wir später sehen werden, ist die Realität leider etwas komplizierter. Es ist nämlich auch erlaubt, die Definition einer Methode gleich inmitten der Klassendeklaration zu schreiben. Im Augenblick können Sie aber bei dieser Trennung bleiben.)
Wenn Sie eine Methode implementieren wollen, müssen Sie außer deren
Namen dem Compiler auch angeben, zu welcher Klasse sie gehört. Dazu verwenden
Sie den Bereichsoperator ::
. Für die
Methode setGeschwindigkeit()
der Klasse Raumfahrzeug
lautet die
Implementierung etwa:
bool Raumfahrzeug::setGeschwindigkeit(
unsigned long _tempo)
{
if (_tempo >hoechstgeschw)
return false;
geschw = _tempo;
return true;
}
bool
), Funktionsname (setGeschwindigkeit
), Parameter (_tempo
)
und Funktionskörper (Genaueres ab Seite Raumfahrzeug
drückt sich einzig durch den Klassennamen mit
dem Bereichsoperator aus.
Später (Seite ) werden wir die Möglichkeiten der
Positionen, an denen Sie Deklaration und Definition unterbringen können, noch
genauer fassen.
Eine Klasse ist nur eine Schablone, sozusagen ein abstrakter Bauplan für das konkrete Objekt. Ist sie erst einmal definiert, gilt sie als Datentyp. Wir können sie dann wie jeden anderen Datentyp verwenden und Variablen davon bilden. Man spricht bei einer Variablen, die als Typ eine Klasse hat, von einem Objekt dieser Klasse oder auch von einer Instanz der Klasse (auch wenn das eine nicht ganz korrekte Übersetzung des englischen Begriffs instance ist).
Wenn Sie auf Elemente eines Objekts zugreifen wollen, müssen Sie den Namen des
Objekts zusammen mit dem Namen des Elements angeben, getrennt durch einen Punkt
´.
´. Also etwa
class Raumfahrzeug
{
// ...
};
int main(void)
{
Raumfahrzeug ufo;
Raumfahrzeug shuttle;
ufo.setGeschwindigkeit(10000);
cout << ``Ufo fliegt ``
<< ufo.getGeschwindigkeit()
<< `` km/s'' << endl;
shuttle.andocken(ufo);
return 0;
}
Zugriffsbeschränkungen regeln, ob andere Klassen auf ein Klassenelement zugreifen dürfen oder nicht. Dabei gibt es folgende Möglichkeiten:
public:
Die nachfolgenden Elemente und Methoden unterliegen
keiner Zugriffsbeschränkung, sie können daher beliebig verändert beziehungsweise
aufgerufen werden.
private:
Die nachfolgenden Elemente und Methoden sind
ausschließlich innerhalb der Klasse selbst zugreifbar.
protected
, die wir aber erst später
-- ab Seite
Wenn Sie eine Zugriffsbeschränkung angeben, gilt
diese für alle nachfolgenden Elemente -- solange, bis eine andere Beschränkung
kommt oder die Klassendeklaration endet. Fehlt in einer Klasse die Festlegung
der Zugriffsbeschränkung ganz, so werden alle Elemente als private
behandelt.
In obigem Beispiel haben wir die beiden Zugriffsebenen in folgender Form verwendet:
class Raumfahrzeug
{
private:
unsigned long geschw;
Bewegung zustand;
// ... weitere
public:
Raumfahrzeug();
~Raumfahrzeug();
bool setGeschwindigkeit(unsigned long _tempo);
unsigned long getGeschwindigkeit();
// ... weitere
};
private
und die Zugriffsmethoden als public
. So haben Sie
immer unter Kontrolle, wie Benutzer eines Objektes die Attribute lesen beziehungsweise
verändern dürfen.)
Wenn Sie in Ihrem Programm etwa die Anweisung schreiben:
Raumfahrzeug ufo;
ufo.geschw = 250000;
public
deklariert sind.
Anders sieht es in den Methoden der Klasse selbst aus. Hier dürfen Sie stets
auf alle Elemente zugreifen, natürlich auch auf die privaten. Die Methode setGeschwindigkeit()
am Ende von Abschnitt ist dafür ein Beispiel.
Wenn Sie erst einmal ein wenig mit Klassen und Objekten gearbeitet haben und über die einfachen Beispiele hinausgehen, werden Sie sicher schnell auf einen Grenzfall stoßen. Betrachten Sie folgende Methode:
void Raumfahrzeug::andocken(Raumfahrzeug _anderes)
{
geschw = _anderes.geschw;
// ...
}
geschw
des Objekts _anderes
zugegriffen. Ist Ihnen schon klar, wieso es sich
dabei um einen Grenzfall handelt? Einerseits ist geschw
Attribut der
Klasse, deren Methode andocken()
wir hier gerade implementieren; daher
sollten wir auch die volle Zugriffsmöglichkeit haben. Andererseits geht es ja
dabei um ein anderes Objekt als das, dessen Methode ausgeführt wird; darf dieses
denn freien Zugang dazu haben?
Die Antwort ist: ja. Die Zugriffsbeschränkung gilt auf der Ebene der Klassen und nicht der Objekte. Wenn in einer Methode ein Objekt derselben Klasse auftaucht, so hat die Methode Zugang zu allen Elementen des Objekts, öffentliche wie private, zum Lesen wie zum Schreiben. Anders ließen sich viele Arten der komplexeren Zusammenarbeit zwischen Objekten kaum realisieren.
Wenn Sie einmal in die Lage geraten, dass Sie ohne die Zugriffsmöglichkeit auf private Elemente einer anderen Klasse nicht weiterkommen, deutet das meistens auf einen Designfehler hin. Allerdings gibt es in der Praxis durchaus Fälle, bei denen ein Verzicht auf den Zugriff einen sehr viel umständlicheren und damit meist langsameren Code mit sich bringen würde.
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:
Zusätzlich zu den Klassenelementen deklarieren Sie also die Freunde, die dann so behandelt werden, als gehörten sie zur Familie, das heißt zur Klasse. Ein Beispiel:
class A; // Vorwärts-Deklaration
class B
{
void m(A _a);
};
class A
{
private:
int i;
public:
void init();
friend void B::m(A _a);
friend void f(A _a, int i);
friend class C;
};
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äumer wir damit aber nur der B::m()
ein! Andere Methoden von B
(sofern vorhanden) haben kein Zugriffsrecht
auf A::i
(und gegebenenfalls andere private Elemente von A
).
Beachten Sie, dass in diesem Beispiel nur die Variable i
sowie die Methode
init()
Elemente von A
sind. Die Funktion f()
ist global,
das heißt überall sichtbar. Zudem haben wir in einer erneuten Vorwärts-Deklaration
auf eine Klasse C
hingewiesen, die an anderer Stelle deklariert werden
kann, die aber hier das volle Zugriffsrecht auf A
erhalten hat.
Aus diesem Abschnitt sollten Sie sich merken:
class
. Klassen haben folgende Merkmale:
Zugriffsbeschränkungen für ihre Elemente, Konstruktoren und Destruktoren, Datenelemente
(Attribute) sowie Elementfunktionen (Methoden). Hinter der abschließenden Klammer
}
muss ein Semikolon stehen.
::
, etwa Raumfahrzeug::setGeschwindigkeit()
.
.
.
public
folgenden Elemente unterliegen
keiner Zugriffsbeschränkung, während alle unter private
nur innerhalb
der Klasse selbst zugänglich sind.
friend
erlaubt es Funktionen, einzelnen Methoden anderer
Klassen oder sogar ganzen Klassen, die Zugriffsbeschränkungen zu umgehen.
Konto
und Bankkunde
aus dem Abschnitt
2.1.8 (Seite
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;
}
int
add
( int x, int y)
{
und }
return z;
(bei Rückgabetyp void
nicht nötig)
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.
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 diese an allen Stellen einsetzen, wo er sonst eine Variable oder Konstante angeben würde (in obiger Form allerdings nur dort, wo lediglich der Wert benötigt wird), also etwa:
int main()
{
int a = 5;
int b = 12;
int c = add(a,b);
cout << ``a = `` << a
<< ``, c = `` << c
<< ``, a+c = `` << add(a,c) << endl;
return 0;
}
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);
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.
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
).
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
.
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 wesentlich mehr 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.)
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:
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.
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.
void
, muss am Ende der Funktion keine return
-Anweisung
stehen (auch nicht das Schlüsselwort return
), wie in folgendem Beispiel:
void ausgabe( int z)
{
cout << ``Das Ergebnis ist: `` << z << endl;
}
return
-Anweisung
vergessen, meldet der Compiler einen Fehler.
Bevor Sie eine Funktion verwenden können, müssen Sie dem Compiler zunächst mitteilen,
dass es eine Funktion dieses Namens gibt, wie viele und welche Parameter sie
hat und welchen Typ sie zurückliefert. Dies geschieht mit einem so genannten
Prototyp der Funktion. Der Prototyp sieht genauso aus wie die Funktion
selbst bis auf den Funktionskörper; dieser fehlt und wird durch ein einfaches
Semikolon ;
ersetzt. (Es ist sogar erlaubt, die Namen der Argumente wegzulassen
und nur ihre Typen anzugeben. Analog zu den Klassen (siehe Seite )
ist also der Prototyp die Deklaration
und die Funktion mit Körper die Definition.
Folgendes Beispiel zeigt den Umgang mit Prototypen:
1:#include <iostream>
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:14:
return 0;
15:}
16:17:
int add(int x, int y)
18:{
19:return (x+y);
20:}
21:22:
void ausgabe( int z)
23:{
24:cout << "Ergebnis: " << z << endl;
25:}
In den Zeilen 3 und 4 befinden sich hier 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 der Verlass auf die Reihenfolge schnell zu Problemen führen. 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 die folgendermaßen aussehen:
Datei | Zeilen |
---|---|
funktionen.hh | 3-4 |
funktionen.cc | 17-25 |
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.hh''
int main()
{
// weiter wie bisher
% g++ -Wall haupt.cc funktionen.cc -o funktionen
% g++ -Wall -c haupt.cc
% g++ -Wall -c funktionen.cc
% g++ haupt.o funktionen.o -o funktionen
Im Gegensatz zu anderen Programmiersprachen wird in C++ eine Funktion nicht nur anhand des Namens, sondern anhand des Namens und der Liste der Argumente identifiziert. (Beides zusammen bezeichnet man gelegentlich auch als Signatur der Funktion). Damit erhalten Sie die Freiheit, zwei Funktionen gleichen Namens zu verwenden, die sich aber in ihren Argumenten unterscheiden. Man bezeichnet das auch als Überladung des Funktionsnamens.
Wir hatten oben (Seite ) eine Funktion
add()
definiert,
um zwei Ganzzahlen zu addieren. Nun kann man natürlich auch Dezimalbrüche addieren;
das deckt unsere Funktion aber nicht ab. Wir definieren also eine zweite Funktion
mit demselben Namen (!), aber anderen Argumenten:
double add( double x, double y)
{
return x+y;
}
add()
aufrufen, erkennt der Compiler
an den Typen der Argumente, welche der ihm bekannten Funktionen er verwenden
soll, beispielsweise:
cout << add( 3, 5); // int-Version
cout << add( 1.5, 2.2); // double-Version
int calc( float a);
bool calc( float a); // Führt zu Fehlermeldung!
Gerade wenn zwei Funktionen wie unsere beiden add()
dieselbe Anzahl von
Argumenten haben und zudem nur Standardtypen verwenden, ist es für den Compiler
machmal schwer, eindeutig die passende Funktion zu einem bestimmten Aufruf
zu finden. Betrachten Sie etwa folgenden Code und überlegen Sie sich vor dem
Weiterlesen, welche Funktion in den jeweiligen Zeilen aufgerufen wird:
1: int main()
2: {
3: double a= 100.2;
4: double b= 333.777;
5: int c= 15;
6: int d= 2726;
7:
8: cout << add(a,b);
9: cout << add(c,d);
10: cout << add(31,'a');
11: cout << add(3.1, c);
12: cout << add(3.1, float(c));
13: cout << add(b,c);
14: return 0;
15: }
(Zum besseren Verständnis können Sie auch nochmals die Regeln für die implizite
Typumwandlung ab Seite nachschlagen). Sehen
wir uns die Aufrufe also genauer an:
double
- beziehungsweise
int
-Werte als Parameter übergeben, so dass die jeweiligen Funktionen aufgerufen
werden.
'a'
, also vom Typ char
.
Dieser Typ zählt auch zu den Ganzzahl-Werten und kann nach int
konvertiert
werden. Es kommt also die (int,int)
-Variante zum Einsatz.
3.1
--
standardmäßig double
-- durch Rundung nach int
umgewandelt werden,
zum anderen lässt sich auch die int
-Variable c
leicht nach double
konvertieren. Was soll der Compiler also tun? Da er ratlos ist, gibt er eine
Fehlermeldung aus: call of overloaded `add (double, int )' is ambiguous.
Hier müssen wir als Programmierer eingreifen. Eine Lösung sehen Sie in Zeile
12: Durch die explizite Umwandlung von c
nach float
wird die Eindeutigkeit
wiederhergestellt.
Genauso wie Funktionen können Sie auch Methoden in Klassen überladen. Insbesondere
beim Konstruktor macht man regelmäßig davon Gebrauch; doch dazu später mehr
(Seite ff.).
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[]);
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:cout << i << ". Argument: "
11:<< argv[i] << endl;
12:13:
return 0;
14:}
15:
Die for
-Schleife in Zeile 7, zu deren Syntax wir später noch genauer
kommen (Seite ), durchläuft einfach alle Argumente
vom ersten bis zum letzten, wie in
argc
angegeben. Der Aufruf dieses
Programms mit
% commandline erstes zweites drittes
0. Argument: commandline
1. Argument: erstes
2. Argument: zweites
3. Argument: drittes
Wenn Sie sehr umfangreiche Listen von Kommandozeilenargumenten verarbeiten wollen,
empfehle ich Ihnen den Einsatz der Bibliotheksroutine getopt()
, die ich
Ihnen auf Seite vorstellen werde.
In C++ können Sie für einzelne Argumente einer Funktion Vorgabewerte (engl. default values) festlegen, das heißt Werte, die verwendet werden, wenn der entsprechende Parameter im Aufruf der Funktion fehlt.
Schauen wir uns ein Beispiel an:
// Deklaration
void printNumber(float number, int fillsize=0,
char fillchar=´ ´);
// Implementation
void printNumber(float number, int fillsize,
char fillchar)
{
int i;
for(i=0; i<fillsize; i++)
cout << fillchar;
cout << number;
}
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, ´ ´);
3.5
noch 10 Nullen einfügen möchten, können Sie
die Funktion auch so aufrufen:
printNumber(3.5, 10, ´0´);
fillchar
setzen wollen,
auch fillsize
setzen.
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.
Eine Referenz ist ein neuer Name, ein Alias für eine
Variable. Sie können damit ein und denselben Speicherplatz unter zwei verschiedenen
Namen im Programm ansprechen. Jede Modifikation der Referenz ist eine Modifikation
der Variablen selbst -- und umgekehrt. Der Typ der Referenz entspricht dem
der Variablen; allerdings ist eine Referenz eine Abart davon. Sie wird gekennzeichnet
durch das Zeichen ´&
´ nach der Typbezeichnung.
Wenn Ihnen das jetzt etwas abstrakt vorkommt, befinden Sie sich in guter Gesellschaft. Referenzen sind für die meisten C++-Neulinge etwas schwer durchschaubar. Daher gleich ein Beispiel:
int height = 800; // Variable
int& h = height; // Referenz auf h
h += 100;
cout << ``Höhe: `` << height++;
cout << ``Höhe: `` << h++;
cout << ``Höhe: `` << height;
height
und h
zwei Namen für einen Speicherplatz.
Wenn man also h
um 100
erhöht, ist auch height
anschließend
entsprechend größer. Die Ausgabe ist also:
Höhe: 900
Höhe: 901
Höhe: 902
Für den Umgang mit Referenzen sollten Sie sich ein paar Grundregeln einprägen:
&
steht immer direkt hinter
dem Typ. Wenn Sie ihn an anderer Stelle verwenden, hat er (leider) eine völlig
andere Bedeutung.
int& h; // falsch!
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.
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;
}
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;
}
int
, sondern
int&
. Ein kleines Hauptprogramm zeigt Ihnen, dass der Aufruf der beiden
Funktionen swap_values()
und swap_refs()
zwar identisch, die
Wirkung aber völlig unterschiedlich ist.
int main (void)
{
int big = 10;
int small = 20;
cout << "big1: "<< big
<< " small1: "<< small << endl;
swap_values (big, small);
cout << "big2: "<< big
<< " small2: "<< small << endl;
swap_refs (big, small);
cout << "big3: "<< big
<< " small3: "<< small << endl;
return 0;
}
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 ein 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;
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);
a
beim Aufruf der
Funktion kopiert werden muss, und gleichzeitig
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.
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 Befreundung 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 ja nicht, was darin genau passiert. Möglicherweise finden darin ja Modifikationen an den Attributen statt -- genau das, was wir mit dem konstanten Objekt ja verhindern wollten.
Ich kann Sie beruhigen: Solche Methoden darf die Funktion nicht aufrufen, das
verhindert bereits der Compiler. Welche aber dann? Hier kommt das Schlüsselwort
const
abermals zum Einsatz. Damit eine Methode für ein konstantes Objekt
aufgerufen werden darf, müssen Sie diese in der Klasse als const
deklarieren.
Dieses const
muss nach der Argumentliste und vor dem Semikolon
beziehungsweise dem Methodenkörper stehen. (Wenn Deklaration und Definition
einer Methode getrennt sind, muss const
bei beiden stehen, sonst fasst
der Compiler die Definition als eine andere Signatur auf.)
Sehen wir uns das an einem Beispiel an:
class Raumfahrzeug
{
public:
bool setGeschwindigkeit(unsigned long _tempo);
unsigned long getGeschwindigkeit() const;
// ...
};
getGeschwindigkeit()
auch bei konstanten Objekten erlaubt.
void zeigeStatus(const Raumfahrzeug& rf)
{
cout << ``Geschwindigkeit: ``
<< rf.getGeschwindigkeit(); // erlaubt!
rf.setGeschwindigkeit(0); // verboten!
// ...
}
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 Seiteneffekte andere Attribute aktualisieren), verweigert sich Ihnen der
Compiler. Denn const
heißt auch konstant.
Ebenso wenig sollten Sie mit const
halbherzig umgehen, das heißt, es
nach Belieben mal anfügen und mal weglassen. Fragen Sie sich besser bei jeder
Methode, ob diese eine Veränderung des Objekts bewirken soll oder nicht; im
letzteren Fall deklarieren Sie sie stets als const
. Somit werden Sie
übrigens auch daran gehindert, die stets fehlerträchtigen Nebeneffekte zu programmieren
-- schon mancher Benutzer einer Klasse hat sich gewundert, warum sein Objekt
nach einem Lesezugriff plötzlich verändert war.
Sie erinnern sich sicherlich, dass ich Ihnen die Datenabstraktion als wichtiges
Prinzip der objektorientierten Programmierung vorgestellt habe (auf Seite ).
Schlagen Sie am besten nochmals Abbildung 2.5 nach: Dort
wird gezeigt, dass im Idealfall andere Objekte nur über Nachrichten auf die
Daten eines Objekts zugreifen können. Nachrichten werden aber als Methoden des
Empfängers implementiert.
Was heißt das nun konkret hinsichtlich der Implementierung? Für die Datenelemente Ihrer Klasse empfiehlt sich folgende Vorgehensweise:
private
und nur Methoden als public
.
get
beziehungsweise
set
, gefolgt vom Namen des Attributs.
const
.
class Raumfahrzeug
{
private:
unsigned long geschw;
// ...
public:
bool setGeschwindigkeit(unsigned long _tempo);
unsigned long getGeschwindigkeit() const;
// ...
};
get
-Methode nichts weiter tun, als den Wert
des Datenelements zurückzuliefern, ebenso wie die set
-Methode meist lediglich
das Attribut auf den gewünschten Wert setzt.
unsigned long
Raumfahrzeug::getGeschwindigkeit() const
{
return geschw;
}
setGeschwindigkeit()
gemacht: Wir setzen
den Wert nicht, wenn die verlangte Geschwindigkeit über der Höchstgeschwindigkeit
unseres Raumfahrzeugs liegt.
bool Raumfahrzeug::setGeschwindigkeit(
unsigned long _tempo)
{
if (_tempo > hoechstgeschw)
return false;
geschw = _tempo;
return true;
}
bool
ist hier übrigens nur ein Vorschlag. Da viele Programmierer
bei Schreibmethoden den Rückgabewert ohnehin zu ignorieren pflegen, verwendet
man meist void
. Um dann auf gravierende Fehler hinzuweisen, gibt es noch
verschiedene Mittel und Wege, denen Sie im Laufe des Buches begegnen werden.
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 gemerkt 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.
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;
}
inline
-Funktion
zu tun haben.
void calc()
{
int p=3, q=5;
// ...
int r = add(p,q);
int r = p+q;
inline
nicht erzwingen.
Der ANSI/ISO-Standard schreibt vor, dass eine solche Deklaration immer nur eine
Empfehlung an den Compiler darstellt, an die er sich halten kann, aber nicht
muss.
inline
deklarieren.
Selbst wenn der Compiler dies akzeptiert und umsetzt, besteht dadurch die Gefahr,
dass Ihr Code stark aufgebläht und die Optimierung behindert wird.
inline
deklarierten Funktion sollte immer
in einer Header-Datei stehen. Wenn Sie nämlich im Header den Prototyp angeben
und erst in der Implementationsdatei die Funktion als inline
kennzeichnen,
wird bei der Verwendung in anderen Dateien jeweils ein normaler Aufruf erzeugt,
was spätestens beim Linken zu Problemen führt.
Zugriffsmethoden von Klassen sind die idealen Kandidaten für die inline
-Deklaration.
Sie sind meist sehr kurz, werden aber ziemlich oft benötigt. Oben (Seite )
haben wir nur davon gesprochen, wie man Methoden außerhalb der Klassendeklaration
definiert. Mit dem jetzigen Wissen können Sie sich auch für eine andere Stelle
entscheiden, an der Sie Ihre Methode implementieren.
inline
behandelt,
auch wenn das entsprechende Schlüsselwort fehlt. Das ist bei sehr kurzen Methoden
(ein oder zwei Zeilen) durchaus üblich.
inline
-Funktion in
der Header-Datei. Wenn Sie Ihre Methode als inline
gelten lassen
wollen, sollten Sie sie aus oben beschriebenen Gründen innerhalb der Header-Datei
implementieren. Diese Möglichkeit wählt man oft bei etwas längeren Methoden
(mit mehr als zwei Zeilen), die aber noch sehr einfach sind, also beispielsweise
keine Schleifen enthalten, oder wenn man auf Klassenelemente zugreifen muss,
die erst später deklariert werden.
// Datei Raumfahrzeug.h
class Raumfahrzeug
{
private:
unsigned long geschw;
float hoehe;
// ...
public:
unsigned long getGeschwindigkeit() const
{
return geschw;
}
float getHoehe() const
{
return hoehe;
}
// ...
};
inline
dabeisteht, sind
es diese Methoden trotzdem.
Die zweite Variante hat beispielsweise folgende Form:
// Datei Raumfahrzeug.h
class Raumfahrzeug
{
public:
unsigned long getGeschwindigkeit() const;
float getHoehe() const;
// ...
};
inline unsigned long
Raumfahrzeug::getGeschwindigkeit() const
{
return geschw;
}
inline float Raumfahrzeug::getHoehe() const
{
return hoehe;
}
Anders sieht dies beim dritten Fall aus. Hier findet sich im Include-File nur noch:
// Datei Raumfahrzeug.h
class Raumfahrzeug
{
public:
unsigned long getGeschwindigkeit() const;
float getHoehe() const;
// ...
};
// Datei Raumfahrzeug.cc
unsigned long
Raumfahrzeug::getGeschwindigkeit() const
{
return geschw;
}
float Raumfahrzeug::getHoehe() const
{
return hoehe;
}
// ...
In diesem Abschnitt haben Sie eine Menge gelernt. Die wichtigsten Aspekte waren:
return
-Anweisung.
void
definiert.
main()
-Funktion verfügen. Sie ist der
Einstiegspunkt in das Programm. Der Rückgabewert von main()
kann von
der Shell, die das Programm startet, ausgewertet werden.
inline
deklariert, wird bei der Kompilierung
der Aufruf vollständig durch den Funktionskörper ersetzt. Das bietet sich besonders
bei Zugriffsmethoden auf Klassenelemente an.
#include <iostream>
void displayText()
displayText()
{
cout << "Hello World!" << endl;
}
#include <iostream>
int main()
{
tuDochWas();
return 0;
}
void tuDochWas()
{
cout << Ich tu jetzt was! << endl;
}
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();
};
ggT()
, die den größten gemeinsamen Teiler (ggT) nach dem
euklidischen Algorithmus (siehe auch
Seite
long Rational::ggT(long _x, long _y)
{
while(_y)
{
long r = _x % _y;
_x = _y;
_y = r;
}
return (_x);
}
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.
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.
Konstruktoren haben einige besondere Eigenschaften, die sie von allen anderen Methoden unterscheiden:
void
).
Sie dürfen keinen Rückgabetyp bei der Deklaration eines Konstruktors angeben
und auch keine void
-Anweisungen mit Argument verwenden.
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;
};
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 Methoden 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.
Je nach Form und Aufgabe unterscheidet man mehrere Arten von Konstruktoren:
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;
};
Datum::Datum()
{
setzeAufHeute();
}
inline
-Methoden implementiert (siehe Seite
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();
//...
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.
Die Einfügung des Konstruktoraufrufs klappt übrigens auch bei Verschachtelungen. Wenn Sie beispielsweise eine Klasse haben wie:
class Tabelleneintrag
{
private:
Datum datum;
//...
public:
Tabelleneintrag();
// ...
};
Datum
automatisch bei der Abarbeitung des
Konstruktors von Tabelleneintrag
aufgerufen, und zwar noch vor der ersten
Anweisung im Funktionskörper von Tabelleneintrag::Tabelleneintrag()
.
Allgemeine Konstruktoren 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);
// ...
};
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);
}
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;
}
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);
// ...
}
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(); }
};
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;Date
erzeugen, so erhält er vom Compiler die
schlichte Meldung: Date::Date()
is private within this context.
Beim Aufruf eines Konstruktors wird noch vor dem Betreten des Methodenkörpers Speicherplatz für die Datenelemente bereitgestellt. Anschließend wird erst der Code in diesem Block abgearbeitet; darin haben wir bisher die Datenelemente mit Werten versehen.
Auf diese Weise wird aber doppelt auf die Attribute zugegriffen: einmal bei der Erzeugung und einmal bei der Zuweisung. C++ bietet die Möglichkeit, beides in einem Schritt zusammen zu erledigen. Dazu verwendet man eine so genannte Initialisierungsliste. Dabei geben Sie hinter der schließenden runden Klammer der Argumentliste, getrennt duch einen Doppelpunkt, die Attribute an, wobei die zu verwendenden Werte in runden Klammern dahinter stehen. Bei unserem Beispiel hat das etwa folgende Form:
Datum::Datum(unsigned int _t,
unsigned int _m, unsigned int _j)
: t(_t), m(_m), j(_j)
{
}
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.)
Eine Klasse darf nicht nur Variablen und Funktionen enthalten, von denen bisher immer die
Rede war, sondern auch Konstanten und Referenzen.
Bei diesen stellt sich das Problem der Initialisierung noch wesentlich drängender.
Denn normalerweise müssen Konstanten und Referenzen gleich bei ihrer Deklaration
initialisiert werden, etwa (bei bekannter Klasse Kreis
):
const unsigned int MAX_SIZE = 1000;
// ...
Kreis aktuellerKreis;
Kreis& k = aktuellerKreis;
class Kreis;
class Kreisliste
{
private:
const unsigned int MAX_SIZE = 1000; // nicht gültig!
Kreis aktuellerKreis;
Kreis& k = aktuellerKreis; // nicht gültig!
// ...
};
aktuellerKreis
gar nicht mehr, oder?)
class Kreisliste
{
private:
const unsigned int MAX_SIZE;
Kreis& k;
// ...
public:
Kreisliste(Kreis& _k);
// ...
};
Kreisliste::Kreisliste(Kreis& _k)
: MAX_SIZE(1000), k(_k) // eventuell weitere
{
// ...
}
Auf diese Weise bekommen Sie also die Möglichkeit, zusätzliche Arten von Elementen in Ihre Klassen aufzunehmen.
Datum::Datum(const Datum& _datum)
: t(_datum.t), m(_datum.m), j(_datum.j)
{
cout << ``Hier ist der Kopierkonstruktor!''
<< endl;
}
Datum d1;
Datum d2(d1);
Es gibt aber noch eine -- anfangs eventuell verwirrende -- Möglichkeit, den Kopierkonstruktor aufzurufen, nämlich in Form einer Zuweisung:
Datum d1;
Datum d2 = d1;
Raumfahrzeug ufo;
Raumfahrzeug transporter;
// Zuweisung, kein Kopierkonstruktor:
transporter = ufo;
// Initialisierung mit Kopierkonstruktor:
Raumfahrzeug jaeger = ufo;
Ein 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 ).
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();
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:In Zeile 1 finden Sie den Kopf der MethodeDatum 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:}
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.
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);
};
int main()
{
Datum d1(``29.02.2000'');
Datum d2;
// ...
d2 = ``03.03.2001'';
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);
int main()
{
log_message(``21.02.2001'', ``Jetzt geht´s los!'');
// ...
}
int main()
{
{
Datum temp(``21.02.2001'');
log_message(temp, ``Jetzt geht´s los!'');
}
// ...
}
So praktisch diese automatische Umwandlung auch sein mag -- manchmal möchte man gerade diese vermeiden. Denn dabei kann es passieren, dass der Compiler temporäre Objekte erzeugt, an die der Programmierer gar nicht gedacht hat. Da Erzeugung und Vernichtung solcher Objekte aber auch Zeit und Speicherplatz kosten, sollte man versuchen, die vollständige Kontrolle zu behalten und unerwünschte Automatismen gar nicht erst zuzulassen. Außerdem könnten Typverletzungen durch ein Zuviel an automatischer Konvertierung unentdeckt bleiben.
Aus diesem Grund wurde im ANSI/ISO-Standard von C++ das Schlüsselwort explicit
eingeführt. Sie schreiben es in die Klassendeklaration vor Konstruktoren, die
nur ein Argument haben (oder bei denen die weiteren Argumente wegen der Vorgabewerte verzichtbar sind). Beispielsweise könnten wir für unser Datumsbeispiel
eine eigene Klasse Jahr
einführen. Diese hätte neben einer besseren Möglichkeit
zur Typprüfung auch den Vorteil, dass wir dabei auf eine fehlende Jahrhundertangabe
reagieren könnten.
class Jahr
{
public:
explicit Jahr(unsigned int _j);
unsigned int getJahr() const;
private:
unsigned int j;
};
Jahr::Jahr(unsigned int _j)
{
if (_j < 10)
j = _j + 2000;
else
if (_j < 100)
j = _j + 1900;
else
j = _j;
}
SpeicherJahr(Jahr _j); // Beispielfunktion
Jahr f()
{
Jahr heuer(2000); // erlaubt
SpeicherJahr(2000); // nicht erlaubt!!
SpeicherJahr(Jahr(2000)); // explizit: erlaubt
return 99; // nicht erlaubt!!
}
Datum
speichern wir die Jahreszahl statt als Ganzzahl nun
als Objekt vom Typ Jahr
. Dabei müssen wir natürlich auch den Konstruktor
anpassen.
class Datum
{
public:
Datum(unsigned int _t,
unsigned int _m, Jahr _j);
// ...
};
Jahr
wäre folgende Erzeugung auch richtig
gewesen:
Datum d1(2000,11,20);setze()
. Aber selbst der bislang
korrekte Aufruf
Datum d2(20,11,2000);int
-Zahl in ein Jahr
-Objekt voraussetzen, die wir ausdrücklich
untersagt haben. Die Erzeugung muss nun lauten:
Datum d2(20,11,Jahr(2000));
Genauso wie es Methoden gibt, die bei der Erzeugung eines Objekts aufgerufen werden, gibt es auch welche, die bei seiner Vernichtung in Aktion treten.
Ein Destruktor übernimmt dann die Aufräumarbeit, wenn das Objekt nicht mehr
benötigt wird. Ebenso wie bei den Konstruktoren gilt: Gibt der Autor der Klasse
keinen Destruktor vor, erzeugt der Compiler automatisch einen. Sie werden in
Ihren Klassen Destruktoren hautpsächlich dann verwenden, wenn Sie mit dynamisch
verwaltetem Speicher arbeiten und das Objekt den Speicher, den es für sich in
Anspruch genommen hat, am Ende seiner Lebenszeit wieder freigeben muss. Mehr
dazu werde ich Ihnen daher erklären, wenn wir über dynamische Speicherverwaltung
reden (ab Seite ).
Ein weiteres Aufgabenfeld für Destruktoren sind offene Datei- oder Datenbankverbindungen, die beim Löschen des Objekts geschlossen werden müssen.
Der Destruktor hat denselben Namen wie die Klasse, allerdings mit einer vorangestellten
Tilde ~
, zum Beispiel:
class Datum
{
public:
Datum(); // Standardkonstruktor
~Datum(); // Destruktor
// ...
};
inline Datum::~Datum()
{
cout << ``Hier ist der Destruktor!'' << endl;
}
inline
zu deklarieren.
Für Objekte, die innerhalb eines Blocks oder einer Funktion angelegt werden,
endet ihre Gültigkeit (ihre Lebensdauer)
mit der schließenden Klammer
}
.
Zu diesem Zeitpunkt wird auch der Destruktor aufgerufen. Wenn Sie ein Objekt
außerhalb aller Funktionen deklarieren, nennt man es
global (dasselbe gilt auch für Variablen). Globale Objekte sind in jeder
Funktion sichtbar und dort wie lokale Objekte verwendbar. Für globale Objekte
wird der Konstruktor vor der ersten Anweisung in main()
und der Destruktor
nach der Freigabe aller in main()
instanziierten Objekte aufgerufen.
#include <iostream>
#include ``Datum.hh''
Datum heutigesDatum__; // globales Objekt
int main()
{
cout << ``Heute ist der ``;
heutigesDatum__.ausgeben();
return 0;
}
_g
oder zwei
angehängten Unterstrichen wie in heutigesDatum__
.
In diesem Beispiel wollen wir eine Klasse für Benutzer Ihres Linux-Rechners erstellen. Darin sollen der Login-Name, der echte Name und die Gruppen-Zuordnung gespeichert werden. Dazu müssen wir uns zunächst ansehen, wie Benutzer überhaupt durch Linux verwaltet werden.
Auf einem Einzelplatzsystem sind die Informationen über die eingetragenen Benutzer in der Datei /etc/passwd abgelegt. Dazu gehören der Benutzername, eine eindeutige ID, IDs für die Zugehörigkeit zu Benutzergruppen, das Passwort, der Name des Home-Verzeichnisses, die Shell, die beim Login gestartet wird, sowie eine textuelle Beschreibung, im Allgemeinen der vollständige Name des Benutzers. Bei einem vernetzten Rechner können diese Informationen auch zentral abgelegt sein und über den Network Information Service (NIS) zugänglich gemacht werden (früher als gelbe Seiten -- yellow pages -- bekannt). Diese Daten (außer dem Passwort) werden übrigens auch vom Kommando finger ausgegeben.
Das System stellt für den Zugriff auf diese Daten aus C- und C++-Programmen verschiedene Funktionen zur Verfügung:
getuid()
liefert die User-ID des aktuellen Benutzers, der
das Programm startet.
getpwuid()
erwartet eine Benutzer-ID und gibt dazu alle Daten aus /etc/passwd
zurück. Dies geschieht in Form einer Struktur (gekennzeichnet durch
das Schlüsselwort struct
); das ist auch nur eine Klasse, bei der alle nicht anders gekennzeichneten Elemente
public
sind. Die Struktur passwd
ist in usr/include/pwd.h
definiert.
getpwnam()
liefert dasselbe Ergebnis wie getpwuid()
, hat jedoch
als Argument den Login-Namen des Benutzers.
string
, von der Sie an dieser Stelle eigentlich kaum etwas wissen
müssen; die Zuweisung mit =
ist sehr offensichtlich und die Ausgabe mit
cout
auch unproblematisch. (Wenn Sie doch mehr wissen wollen: Seite Im öffentlichen Teil statten wir die Klasse mit einem Standardkonstruktor aus, der die Daten lediglich initialisiert, und zwei allgemeinen Konstruktoren, wobei der eine eine Benutzer-ID und der andere einen Benutzernamen erwartet. Ferner wollen wir auch eine nachträgliche Initialisierung mit einem Benutzernamen zulassen sowie das Objekt auf den aktuellen Benutzer setzen können und auch die enthaltenen Daten auf den Bildschirm ausgeben.
Unter diesen Methoden sind zwei fast identisch, was vielleicht auf den ersten
Blick nicht so offensichtlich ist. Ich meine den allgemeinen Konstruktor, der
die Attribute mit den Daten eines Benutzers belegt, von dem die ID bekannt ist,
und das Setzen auf den aktuellen Benutzer. Denn von diesem erhält man nämlich
auch die ID, wenn man die Funktion getuid()
ruft. Somit lohnt es sich, die Funktionalität in eine private Methode zu stecken
und diese aus den beiden öffentlichen aufzurufen.
Die Initialisierungsmethoden haben als Rückgabewert true
oder false
, je
nachdem, ob sie fehlerfrei arbeiteten oder nicht. Somit erhalten wir folgende
Deklaration:
class Benutzer
{
private:
string login;
string echterName;
long gruppenId;
bool init(uid_t _benutzerId);
public:
Benutzer() :
gruppenId(0) {}
Benutzer(const string& _benutzerName)
{ init(_benutzerName); }
Benutzer(uid_t _benutzerId)
{ init(_benutzerId); }
bool init(const string& _benutzerName);
bool setzeAufAktuellen();
void ausgeben() const;
};
Die private init()
-Methode arbeitet folgendermaßen:
bool Benutzer::init(uid_t _benutzerId)
{
// Ausgabevariable deklarieren
struct passwd* benutzer_info = 0;
// Benutzer-Info holen
benutzer_info = getpwuid(_benutzerId);
// Falls ungleich: Benutzer existiert
if (benutzer_info)
{
// Daten kopieren und ausgeben
login = benutzer_info->pw_name;
echterName = benutzer_info->pw_gecos;
gruppenId = benutzer_info->pw_gid;
cout << "Benutzer " << login
<< " heißt " << echterName << endl;
}
else
{
// Fehler melden
cerr << "Benutzer mit Id " << _benutzerId
<< " nicht gefunden!" << endl;
login = "";
echterName = "";
gruppenId = 0;
return false;
}
return true;
}
Der Aufbau der anderen init()
-Methode ist fast identisch. Nur wird dort
statt getpwuid()
die Funktion getpwnam()
aufgerufen. Ich lasse
das Listing also hier weg.
Die beiden anderen Methoden der Klasse sind ziemlich elementar. Dabei ist setzeAufAktuellen()
so klein, dass wir sie gleich als inline
deklarieren können (siehe auch
Seite ).
inline bool Benutzer::setzeAufAktuellen()
{
return init(getuid());
}
void Benutzer::ausgeben() const
{
cout << "Benutzer: " << login << endl;
cout << "Echter Name: " << echterName << endl;
cout << "Gruppe: " << gruppenId << endl;
cout << endl;
}
Anwenden können wir die Klasse auf verschiedene Arten:
int main()
{
bool ergebnis;
// Standardkonstruktor
Benutzer u;
// Konstruktor mit Argument
// UID 0 entspricht root
Benutzer root(0);
cout << "Leerer Benutzer: " << endl;
u.ausgeben();
cout << "Objekt mit Root: " << endl;
root.ausgeben();
ergebnis = u.init("markus");
if (ergebnis)
{
cout << "Initialisierter Benutzer markus: "
<< endl;
u.ausgeben();
}
ergebnis = u.setzeAufAktuellen();
if (ergebnis)
{
cout << "Aktueller Benutzer: "<< endl;
u.ausgeben();
}
return 0;
}
markus
gibt, werden Sie eine Fehlermeldung statt Benutzerinformationen
erhalten. Bei mir war auf dem Bildschirm zu lesen:
Benutzer root heißt root (Superuser)
Leerer Benutzer:
Benutzer:
Echter Name:
Gruppe: 0
Objekt mit Root:
Benutzer: root
Echter Name: root (Superuser)
Gruppe: 0
Benutzer markus heißt Markus Zimmermann
Initialisierter Benutzer markus:
Benutzer: markus
Echter Name: Markus Zimmermann
Gruppe: 100
Benutzer thomas heißt Thomas Wieland
Aktueller Benutzer:
Benutzer: thomas
Echter Name: Thomas Wieland
Gruppe: 100
Folgende Gesichtspunkte aus diesem Abschnitt sollten Sie festhalten:
void
).
~
.
Sein häufigster Zweck ist die Freigabe von dynamisch angelegtem Speicher.
X
wird entweder in
der Form X::X(X& a)
oder (im Allgemeinen besser) als X::X(const X& a)
deklariert. Warum nimmt man statt der
Referenz auf X
nicht einfach das Objekt X
und deklariert X::X(X a)
?
Rational
aus Abschnitt long
akzeptiert. Welche zusätzlichen Verwendungsmöglichkeiten
ergeben sich dadurch?
A
und B
, die in ihren Konstruktoren
und Destruktoren jeweils einen Text ausgeben (etwa Hier ist Konstruktor von
A). Erweitern Sie diese Klassen zu einem Programm, in dem
A
ein Attribut vom Typ B
hat,
A
gibt,
A
in main()
gibt,
A
in einem untergeordneten Block der Funktion main()
gibt.
Eines der bedeutsamsten Konzepte der objektorientierten Programmierung ist die Vererbung. Sie hilft Ihnen bei der logischen Gliederung Ihrer Klassen und Objekte und erleichtert die Wiederverwendung von bestehendem Code. In diesem Abschnitt werden Sie erfahren, was es damit auf sich hat und worauf Sie achten müssen.
Sie wissen jetzt schon, dass Sie ein größeres objektorientiertes Programm nicht
einfach beginnen sollten, indem Sie die erste Zeile Code eintippen. Der erste
Schritt (oder einer der ersten Schritte) ist immer die Analyse, mit welchen
Objekten das Programm eigentlich umgehen soll. Bilden Sie davon eine Abstraktion,
gelangen Sie zu den Klassen. Wenn Sie diesem Ansatz folgen, werden Sie bei vielen
Ihrer Programme auf das Problem stoßen, dass Sie zwei Objekte vorliegen haben,
die sich zwar weitgehend ähneln, aber eben doch an einigen Stellen voneinander
abweichen, wie das Raumfahrzeug und der Satellit in Abbildung .
(Im mittleren Drittel der Kästen stehen die Attribute, im unteren die Methoden.)
Die Lösung besteht nun darin, Gemeinsamkeiten der Klassen in einer neuen Klasse zusammenzufassen, der Basisklasse (oder Oberklasse). Diese ist dann der Stammvater der anderen. Ihre Nachfahren, die abgeleiteten Klassen (oder Unterklassen), besitzen alle Attribute und Methoden der Basisklasse sowie zusätzliche eigene. Eine abgeleitete Klasse erbt also die Eigenschaften und das Verhalten ihrer Oberklasse, genauso wie Eltern ihren Kindern große Nasen und Vorlieben für Süßigkeiten vererben.
Ein Beispiel dazu sehen Sie in Abbildung Fig:Basisklasse.
Dort ist der Zusammenhang noch aus einem anderen Blickwinkel dargestellt. In
der Modellierungssprache UML
verweist die Unterklasse nämlich durch
den Pfeil auf ihre Oberklasse, die dann als Generalisierung bezeichnet
wird.
Das bringt uns zu folgenden Merksätzen:
Betrachten wir nun, wie dieses gerade so allgemein beschriebene Konzept in der
Syntax von C++ aussieht. Bei der Basisklasse aus Abbildung
ist noch (fast) alles wie gewohnt.
class Raumobjekt
{
protected:
string bezeichnung;
string herkunft;
float hoehe;
unsigned long geschw;
public:
Raumobjekt();
Raumobjekt(const Raumobjekt& _anderes);
~Raumobjekt();
virtual bool setGeschwindigkeit(
unsigned long _tempo);
virtual unsigned long getGeschwindigkeit();
};
private
das Schlüsselwort protected
verwendet.
Das regelt den Zugriff unter Berücksichtigung der Vererbung -- doch dazu später
mehr, Sec:VererbZugriff. Außerdem habe ich hier noch ein
neues Schlüsselwort verwendet, nämlich virtual
. Dazu kommen wir
auf Seite
Die abgeleitete Klasse hat eine ganz ähnliche Form. Zur Kennzeichnung als Unterklasse
fügt man lediglich bei der Klassendeklaration hinter dem Klassennamen die Oberklasse
an, getrennt durch einen Doppelpunkt und die Zugriffsregelung, meist public
.
Danach müssen Sie auch nur die Klassenelemente angeben, die Sie hinzufügen beziehungsweise
ändern wollen; die ererbten sind bereits automatisch enthalten.
class Raumfahrzeug : public Raumobjekt
{
protected:
unsigned long hoechstgeschw;
Bewegung zustand;
Funktion grad;
public:
Raumfahrzeug();
Raumfahrzeug(const Raumfahrzeug& _anderes);
~Raumfahrzeug();
void starten();
void landen();
void andocken(Raumfahrzeug& _anderes);
};
Halten wir fest: Eine Klasse erbt die Methoden ihrer Basisklasse. Nun hat sie
aber unter Umständen zusätzliche Attribute, die Einfluss auf diese Methoden
haben. Beispielsweise verfügt Raumfahrzeug
über die Eigenschaft hoechstgeschw
,
welche bei der Erhöhung der Geschwindigkeit in der Methode setGeschwindigkeit()
von Bedeutung ist. Der Ausweg ist, die Methode in der abgeleiteten Klasse wie
angedeutet zu modifizieren. Was ist damit gemeint? Sie haben zwei Möglichkeiten:
class Raumfahrzeug : public Raumobjekt
{
public:
void setGeschwindigkeit(unsigned long _tempo);
// weiter wie oben
};
void Raumfahrzeug::setGeschwindigkeit(
unsigned long _tempo)
{
// eigener Code
}
::
.
void Raumfahrzeug::setGeschwindigkeit(
unsigned long _tempo)
{
if (geschw < hoechstgeschw)
Raumobjekt::setGeschwindigkeit(_tempo);
// eventuell weiterer Code
}
hoechstgeschw
) oder zu einer Oberklasse (wie geschw
).
Sie dürfen einem Objekt der Basisklasse immer ein Objekt der abgeleiteten Klasse zuweisen, zum Beispiel:
void f()
{
Raumobjekt raum_obj;
Raumfahrzeug raum_fahrz;
raum_obj = raum_fahrz;
// ...
}
Allgemein heißt das, dass die Zuweisung beziehungsweise die automatische Typumwandlung von einem Unter- in ein Oberklassenobjekt stets zulässig ist. Wenn die Unterklasse zusätzliche Attribute enthält, ist eine solche Umwandlung mit einem Informationsverlust verbunden, denn die zusätzlichen Werte fallen unter den Tisch. Im umgekehrten Fall ist genau dies auch der Grund, warum keine Zuweisung und keine Konvertierung möglich ist: Wenn die Unterklasse weitere Attribute enthält, welchen Wert sollen diese anschließend haben? Eine Erzeugung von Information ist eben nicht zulässig.
Die Möglichkeit zur automatischen Konvertierung von Unter- in Oberklasse erlaubt es Ihnen auch, Funktionen oder Methoden, die eigentlich ein Oberklassenobjekt erwarten, mit einem vom Typ der Unterklasse zu versorgen.
Nehmen wir beispielsweise
an, es gäbe noch eine weitere Klasse namens KampfFlugzeug
, die von Raumfahrzeug
abgeleitet ist. Dann dürfen Sie schreiben:
void g()
{
KampfFlugzeug jaeger;
Raumfahrzeug station;
// erwartet Typ Raumfahrzeug&
station.andocken(jaeger);
// ...
}
andocken()
statt einer Referenz auf ein Basisklassenobjekt (vom Typ Raumfahrzeug
)
auch eine Referenz eines Objekts einer abgeleiteten Klasse übergeben, nämlich
von KampfFlugzeug
.
Es wäre fast langweilig, wenn Sie immer nur eine Ableitung von einer Klasse bilden könnten. In vielen Fällen ist es sinnvoll, mehrere Stufen von Ober- und Unterklassen zu definieren. Dabei wird die Schnittstelle der Klassen in jeder Stufe um neue Elemente erweitert. Auf diese Weise gelangt man von der ganz allgemeinen zur hochspezialisierten Klasse.
In Abbildung sehen Sie ein Beispiel für eine so genannte
Vererbungshierarchie. In einer solchen Darstellung
steht die Basisklasse an der Spitze und darunter die jeweils von ihr abgeleiteten
Klassen.
![]()
|
Besonders in Sammlungen von Klassen, so genannten Klassenbibliotheken,
kommen solche Hierarchien von Klassen häufiger vor. Besonders beliebt sind sie
für Benutzeroberflächen; beispielsweise basiert die beliebte grafische Linux-Benutzeroberfläche
KDE auf der Klassenbibliothek Qt, die auch eine umfangreiche
Hierarchie aufweist (siehe auch Seite ).
Noch eine Warnung: Anfänger, die das Konzept der Vererbung erst einmal verinnerlicht haben, neigen oft dazu, damit allzu großzügig umzugehen. Nicht immer ist Vererbung das Allheilmittel! Erfahrene Programmierer setzen sie sogar relativ sparsam ein. Nur dort, wo wirklich zwischen zwei Klassen ein enger logischer Zusammenhang besteht, sollten Sie auch eine Basisklasse der beiden definieren, um die Gemeinsamkeiten zu nützen und doppelte Implementierungen zu vermeiden. Aber ein mehr oder weniger künstlich erzeugter oder nur auf Implementierungsaspekten begründeter Zusammenhang ist meist ein schlechter Grund für eine Hierarchiebildung.
Ein anderer Designfehler, der auch von Profis oft begangen wird, entsteht dadurch, dass beim Entwurf der Vererbungshierarchie zu stark auf die Daten und zu wenig auf die Ereignisse und Botschaften geachtet wird. Diese Herangehensweise bezeichnet man auch als data-driven design. Wenn sich eine Unterklasse nur dadurch von ihrer Oberklasse unterscheidet, dass sie zusätzliche Daten enthält, so spricht dies nicht für ein ausgeprägt objektorientiertes Denken des Entwicklers. Beim Entwerfen der Klassen Ihrer Anwendung sollten Sie daher eher ein event-driven design anstreben, das heißt weniger darauf achten, mit welchen Daten Sie es zu tun haben, sondern sich -- besonders zu Beginn -- eine genaues Bild von den Nachrichten machen, die Ihre Objekte miteinander austauschen. Wenn Sie dann abgeleitete Klassen bilden, sollten das nur solche sein, die eine zusätzliche Funktionalität gegenüber ihren Basisklassen aufweisen.
So weit die Theorie. Ich gehe davon aus, dass Sie sich trotzdem noch nicht so genau vorstellen können, wie man Vererbung einsetzt und welchen Nutzen sie hat. Hinsichtlich des Einsatzes kann ich Ihnen nur raten, immer wieder zu experimentieren und Programme zu schreiben; dann bekommen Sie am schnellsten ein Gefühl dafür, wie Sie die Mittel, die Ihnen die Sprache bietet, am besten einsetzen. Über den Nutzen will ich hier ein paar Worte sagen.
Vom Beginn dieses Abschnitts kennen Sie bereits einen Vorteil von Vererbung:
Strukturierte Programmierung | Objektorientierte Programmierung |
---|---|
Meist dupliziert man beim Hinzufügen einer ähnlichen Funktion die erste und ändert sie entsprechend ab (Copy/Paste). Nachteil: Wird die Ausgangsfunktion später modifiziert, muss auch die andere angepasst werden. | Hier bleibt die Funktionalität der Basisklasse erhalten; wird sie modifiziert, wird das in allen abgeleiteten Klassen wirksam. |
So sinnvoll und praktisch das Überschreiben von Methoden in einer Unterklasse auch ist, für einen Typ von Methode ist er nicht zulässig: für Konstruktoren und Destruktoren. Da diese im Namen mit dem Klassennamen übereinstimmen müssen, sind sie (leider) in jeder Klasse getrennt zu implementieren. Damit Ihr Objekt korrekt initialisiert wird, sollten Sie den Konstruktor der Basisklasse im Konstruktor der abgeleiteten Klasse aufrufen.
Das ist aber nur innerhalb eine Initialisierungsliste erlaubt. Für diese gilt dann folgende Reihenfolge:
Der Standardkonstruktor wird automatisch immer aufgerufen; wenn Sie sich also mit diesem zufrieden geben wollen, brauchen Sie keinerlei eigenen Aufruf einfügen -- das erledigt der Compiler für Sie. Anders sieht es aus, wenn Sie allgemeine oder Kopierkonstruktoren verwenden wollen. Hier geben Sie den Namen an und in Klammern das passende Argument dahinter. Für unser Beispiel führt das beim Kopierkonstruktor etwa zu folgendem Code:
Raumfahrzeug::Raumfahrzeug(
const Raumfahrzeug& _anderes)
: Raumobjekt(_anderes),
hoechstgeschw(_anderes.hoechstgeschw),
zustand(_anderes.zustand),
grad(_anderes.grad)
{}
Beachten Sie auch, dass wir in diesem Code auf die automatische Typumwandlung von Unterklasse
auf Oberklasse vertrauen, um die es im letzten Abschnitt ging. Eigentlich erwartet
der Kopierkonstruktor von Raumobjekt
ja eine konstante Referenz auf ein
Objekt dieser Klasse. Die zweite Zeile des Beispiels zeigt, dass er aber auch
mit einem Objekt (namens _anderes
) vom Typ der Unterklasse Raumfahrzeug
anstandslos arbeitet, da hier die automatische Konvertierung zum Einsatz kommt.
Übrigens müssen Sie beim Destruktor nie denjenigen der Basisklasse selbst aufrufen. Da es davon immer nur einen pro Klasse gibt, erledigt der Compiler die Aufrufe selbstständig.
An dieser Stelle möchte ich noch kurz auf die Frage eingehen, wie die Vererbung praktisch im Programm organisiert wird. Enthält ein Unterklassenobjekt einfach nur ein paar mehr Attribute, nämlich die geerbten, oder stecken da noch mehr Informationen drin?
In der Tat ist es so, dass ein Objekt im modernen C++ weiß, welche Basisklasse
in ihm steckt. Dazu enthält es ein Subobjekt vom Typ dieser Basisklasse (Abbildung
). Normalerweise ist es für den Benutzer der Klasse unsichtbar.
Es wird aber beispielsweise für die Konvertierung verwendet; immer wenn Sie ein Objekt in seine
Oberklasse umwandeln, erhalten Sie dieses Subobjekt. Damit wird auch klar, warum
die umgekehrte Konvertierung nicht möglich ist: Sie können zwar aus einem größeren
Objekt einen Teil (nämlich besagtes Subobjekt) herausnehmen, aber nicht ein
solches zu dem größeren aufblasen. Dafür müssten Sie dann schon eine Regel
selbst definieren, etwa in Form eines Typumwandlungsoperators.
Bei der Erzeugung des Objekts spielt ebenfalls das enthaltene Teilobjekt aus der Basis eine Rolle. Im Konstruktor gilt nämlich folgende Regel für die Reihenfolge der Erzeugung:
Bevor Sie also in den Methodenkörper gelangen, können unter Umständen noch eine Reihe anderer Aufrufe getätigt werden -- und natürlich auch Fehler verursachen. Diese sind manchmal nicht leicht zu finden, da die Konstruktoraufrufe ja automatisch eingefügt werden und im Code nicht sichtbar sind. Erst wenn Sie also die gerade beschriebene Regel berücksichtigen, können Sie den genauen Ablauf der Aufrufe nachvollziehen.
Sehen wir uns als Beispiel nochmals die Klasse Raumfahrzeug
an:
class Raumfahrzeug : public Raumobjekt
{
protected:
unsigned long hoechstgeschw;
Bewegung zustand;
Funktion grad;
// ...
};
Bewegung
und Funktion
Klassen
sind, so beinhaltet der simple Standardkonstruktor
Raumfahrzeug::Raumfahrzeug()
: hoechstgeschw(0L)
{}
Raumobjekt
wird aufgerufen.
hoechstgeschw
wird mit 0 initialisiert.
Bewegung
wird aufgerufen.
Funktion
wird aufgerufen.
Sie kennen von Seite bereits
zwei Möglichkeiten, den Zugriff auf Klassenelemente zu erlauben oder zu beschränken:
public:
Die nachfolgenden Elemente und Methoden unterliegen keiner Zugriffsbeschränkung,
sie können daher beliebig modifiziert beziehungsweise aufgerufen werden.
private:
Die nachfolgenden Elemente und Methoden sind ausschließlich
innerhalb der Klasse selbst zugreifbar.
protected:
Die nachfolgenden Elemente und Methoden
sind ausschließlich innerhalb der Klasse selbst und allen mit public
davon abgeleiteten Klassen zugreifbar.
protected
deklarieren, damit deren Status in eventuell davon zu bildenden Unterklassen
erhalten bleibt. Nur solche Elemente, von denen Sie nicht möchten, dass eine
abgeleitete Klasse sie anders als über die öffentlichen Methoden modifziert,
sollten Sie als private
bestimmen.
Dazu ein Beispiel:
class SpaceSound
{
private:
AudioDevice device;
protected:
short repetitions;
public:
SpaceSound();
virtual ~SpaceSound();
virtual void play(SoundType _type);
};
SpaceSound::SpaceSound()
{
// erlaubt:
device.reset();
repetitions = 1;
}
class SingleSound : public SpaceSound
{
public:
SingleSound();
};
SingleSound::SingleSound()
{
// erlaubt:
repetitions = 1;
// nicht erlaubt:
device.reset();
}
void beep()
{
SpaceSound aSpaceSound;
SingleSound aSingleSound;
// erlaubt:
aSpaceSound.play(BEAM);
aSingleSound.play(BEAM);
// nicht erlaubt:
aSpaceSound.device = myDevice;
aSpaceSound.repetitions = 2;
}
SpaceSound
dürfen auf alle Elemente zugreifen.
Die Methoden der Unterklasse SingleSound
haben nur noch Zugriff auf die
öffentlichen (public
) und geschützten (protected
)
Elemente der Basisklasse. Und eine andere Funktion
darf ohnehin nur die öffentlichen Einträge verwenden.
Sie haben an der Syntax zur Vererbung gesehen, dass auch dabei die Art der Zugriffsbeschränkung
eine Rolle spielt. Für die meisten Fälle reicht die public
-Vererbung
völlig aus. Wenn Sie sich also darüber keine weiteren Gedanken machen wollen,
überspringen Sie diesen Abschnitt einfach.
Der Typ der Vererbung hat insbesondere Einfluss auf die Schnittstelle der Klasse,
also auf die Art und Weise, wie sich die Klasse einem Programmierer, der sie
verwendet, gegenüber darstellt. Bei der Ableitung mit public
bleibt die
ursprüngliche Schnittstelle komplett erhalten, das heißt, auch die abgeleitete
Klasse verfügt über alle öffentlichen Attribute und Methoden wie die Basisklasse.
Manchmal möchte man aber gerade das vermeiden. Dazu ein Beispiel: Stellen Sie
sich vor, Sie wollen auf der Basis einer von einem Open-Source-Projekt übernommenen
Klasse für Datenbankoperationen eine Klasse schreiben, die die Arbeit mit Ihren
Unternehmensdaten übernimmt. Nun verfügen solche Klassen oft über eine sehr
umfangreiche Schnittstelle, damit möglichst viele verschiedene Entwickler einen
Nutzen davon haben. Für die Bereitstellung einer einfachen und sicheren Zugriffsschicht
auf Ihre Daten können Sie aber gerade das nicht gebrauchen! Sie wollen ja gar
nicht die breite Palette an Funktionalität nutzen, die Ihnen die Klasse bietet,
sondern nur einen bestimmten Ausschnitt, den Sie auch mit einer eigenen Schnittstelle
versehen wollen. Kurzum: Sie müssen die public
-Elemente der Basisklasse
verschwinden lassen.
Das erreichen Sie, indem Sie die Vererbung mit dem Spezifizierer protected
vornehmen. Dann werden alle Elemente, die in der Basisklasse public
sind,
in der abgeleiteten Klasse protected
(und damit nur noch für Ihre Klasse,
aber nicht mehr für deren Benutzer sichtbar).
Noch schärfen reglementieren Sie den Zugriff, wenn Sie die Vererbung mit private
spezifizieren. Dann sind nämlich alle von der Basisklasse geerbten Elemente
in der abgeleiteten Klasse private
. Man spricht dann auch von privater
Vererbung.
Einen Überblick über diese Zusammenhänge gibt Ihnen auch Tabelle .
Art der Vererbung |
public |
protected |
private |
ergibt in der abgeleiteten Klasse | |||
public in Basisklasse |
public |
protected |
private |
---|---|---|---|
protected in Basisklasse |
protected |
protected |
private |
private in Basisklasse |
private |
private |
private |
Noch ein paar Anmerkungen: Wenn Sie eine andere Vererbung als public
wählen,
bedeutet das auch, dass Sie von Ihrer Oberklasse nicht die Schnittstelle vererben,
sondern nur die Implementierung. Das wird im Allgemeinen als nicht besonders
guter Stil gewertet. In speziellen Fällen kann es aber gerechtfertigt sein.
Doch selbst wenn Sie sich in einer solchen Lage befinden, müssen Sie sich nicht
zwangsläufig Stilbruch nachsagen lassen. Es gibt nämlich eine fast gleichwertige
Alternative zur privaten Vererbung. Wenn Sie ein Objekt der vermeintlichen Basisklasse
als Attribut in Ihre Klasse aufnehmen, können Sie auch eine völlig neue Schnittstelle
definieren und auf die vorhandene Implementierung zurückgreifen. Aus dieser
so genannten Wrapper-Klasse (siehe Seite )
rufen Sie dann eben nicht die Methoden als Klassenelemente, sondern über das
Objekt auf. Der Nachteil ist natürlich, dass Sie dabei nur Zugriff auf die öffentlichen
Elemente der Klasse haben und nicht auf die geschützten, wie es bei der privaten
Vererbung der Fall gewesen wäre. Aber gerade beim Umgang mit universell konzipierten
Klassen reicht die öffentliche Schnittstelle meist völlig aus, um dem Objekt
all seine Funktionalität zu entlocken; Sie wollen ja dabei meist gar nicht direkt
in die Implementierung eingreifen. Und zur Not können Sie Ihre Klasse noch mit
der vorhandenen befreunden (siehe Seite
).
Wie so oft im Leben hat man beim Bilden von Unterklassen manchmal auch die
Qual der Wahl -- wenn nämlich mehrere Klassen als Basis in Frage kommen. Hier
bietet C++ einen Ausweg, über den andere objektorientierte Sprachen (etwa Java
oder Delphis ObjectPascal) nicht verfügen: die Mehrfachvererbung, also
die Ableitung von zwei oder mehr Basisklassen. Die Syntax ist recht einfach;
Sie müssen nämlich nur die verschiedenen Klassen, durch Kommas getrennt, zusammen
mit public
oder einem anderen Spezifizierer hinter den Doppelpunkt schreiben,
der den Beginn der Basisklassen symbolisiert.
Ein Beispiel: Wir waren ja bisher davon ausgegangen, dass unsere Klassen mit den Raumfahrzeugen für ein Simulationsspiel genutzt werden sollen. Nun enthalten diese einfache Schnittstellen für die sichtbaren Eigenschaften und Funktionen. Ein modernes Spiel muss aber auch über einen ansprechenden Sound verfügen. Dazu diene etwa folgende Klasse:
class SpaceSound
{
private:
AudioDevice device;
public:
SpaceSound();
virtual ~SpaceSound();
virtual void play(SoundType _type);
};
Raumfahrzeug
stehe in der Klassenhierarchie
aus Abbildung Raumobjekt
als Basisklasse. Wollen wir sie auch noch mit Sound versorgen,
können wir sie von beiden Klassen ableiten:
class Raumfahrzeug : public Raumobjekt,
public SpaceSound
{
public:
Raumfahrzeug();
Raumfahrzeug(const Raumfahrzeug& _anderes);
~Raumfahrzeug();
void starten();
void landen();
// ...
};
Raumfahrzeug
-Objekten sowohl die Methoden von
Raumobjekt
als auch die von SpaceSound
nutzen:
int launch()
{
Raumfahrzeug r;
r.starten();
r.play(ROCKET_SOUND);
r.setGeschwindigkeit(20000);
// ...
}
Das Überladen von Methoden in abgeleiteten Klassen kann zu mehrdeutigen Anweisungen
führen. Lassen Sie mich für ein Beispiel auf unsere Klassen Raumfahrzeug
und Raumobjekt
zurückkommen.
class Raumobjekt
{
public:
virtual bool setGeschwindigkeit(
unsigned long _tempo);
// ...
};
class Raumfahrzeug : public Raumobjekt
{
public:
virtual bool setGeschwindigkeit(
unsigned long _tempo);
// ...
};
virtual
für uns neu. Es ist die Anweisung
an den Compiler, den Aufruf der so gekennzeichneten Methode im Sinne des Polymorphismus
zu interpretieren. Was virtual
genau bewirkt, werden wir auf Seite
Stellen Sie sich nun vor, wir hätten eine Funktion, die ein Objekt von einer
Position zu einer anderen bewegt. In dieser verwenden wir auch die Methode setGeschwindigkeit()
,
um eben die Bewegung überhaupt in Gang zu setzen.
void bewegeObjekt(
const Position& _pos1,
const Position& _pos2,
Raumobjekt& _objekt)
{
// ...
_objekt.setGeschwindigkeit(5000);
// ...
}
Position
sei dabei einfach ein Paar von x/y-Werten. Wir programmieren
sodann folgende Anweisungen:
int main()
{
// ...
Raumfahrzeug liberty;
Position p1(0,10), p2(100, 10);
bewegeObjekt( p1, p2, liberty);
// ...
}
liberty
vom Typ Raumfahrzeug
erzeugt.
bewegeObjekt()
erwartet aber ein Objekt vom Typ Raumobjekt
.
Von diesem ruft sie die Methode setGeschwindigkeit()
auf.
Tatsächlich wird die Methode Raumfahrzeug::setGeschwindigkeit()
gerufen,
also die der Unterklasse. Dieses Prinzip gilt allgemein:
Geben sich Objekte von abgeleiteten Klasse als Objekte der Basisklasse aus, so werden beim Zugriff die Methoden der tatsächlichen Klasse des Objekts verwendet. Dieses Verhalten bezeichnet man als Polymorphismus.Der Begriff Polymorphismus bedeutet Vielgestaltigkeit und meint, dass ein Objekt in verschiedener Gestalt auftreten kann. Ein Name im Programm kann Instanzen verschiedener Klassen angeben, wenn diese über eine gemeinsame Oberklasse verbunden sind. Sie können also aus der Deklaration des Namens nicht immer auf seine Gestalt im Programm schließen. Objekte können damit ganz individuell reagieren, obwohl sie nominell vom selben Typ sind. Rufen Sie etwa die Funktion mit verschiedenen Objekten auf, deren Klassen sämtlich die Methoden überladen haben, die in der Funktion verwendet werden, so kann sich die Arbeitsweise der Funktion erheblich unterscheiden.
int main()
{
// ...
Raumstation orbitus;
Schlachtschiff galactica;
Satellit turbosat;
bewegeObjekt( p1, p2, orbitus);
bewegeObjekt( p2, p3, galactica);
bewegeObjekt( p3, p1, turbosat);
// ...
}
bewegeObjekt()
wird dann jeweils die Methode aus der zugehörigen
Klasse aufgerufen. Das bedeutet natürlich auch, dass der Compiler beim Übersetzen
dieser Funktion nicht den Verzweigungspunkt des Methodenaufrufs festlegen kann.
Denn erst zur Laufzeit kann im Allgemeinen bestimmt werden, welche Methode gerade
verwendet werden muss. Man bezeichnet das auch als späte
Bindung, auf Englisch late binding. (Das hat nichts mit Ehe im Alter
zu tun, sondern bezieht sich auf die Verbindung zwischen Aufruf einer Funktion
und ihrem Beginn, die ja der Compiler beziehungsweise der Linker normalerweise
herstellen.)
Und noch eine begriffliche Anmerkung: In konventionellen typisierten Sprachen wie Pascal oder Fortran kann jeder Wert und jede Variable für genau einen Typ interpretiert werden. In polymorphen Programmiersprachen wie C++ können sie mehr als einen Typ haben. Man unterscheidet daher zwischen den Begriffen
Raumobjekt
der statische Typ, während Raumfahrzeug
,
Raumstation
, Schlachtschiff
und Satellit
die dynamischen
Typen des Parameters _objekt
in der Funktion bewegeObjekt()
sind.
Woher weiß aber eigentlich der Parameter, dass er nicht ein Raumobjekt
ist, wie es doch in der Deklaration angegeben ist, sondern eigentlich ein Raumfahrzeug
?
Das liegt daran, dass das referenzierte Objekt nicht nur aus den puren Datenelementen
besteht, sondern auch Informationen über seinen Typ enthält. Damit kann es zur
Laufzeit identifiziert werden. Damit einem Objekt solche Typinformationen mitgegeben
werden, müssen seine Methoden als
virtuell gekennzeichnet sein. Dafür schreiben Sie das Schlüsselwort virtual
vor die Methodendeklaration.
Sehen wir uns zunächst an, was geschieht, wenn Sie keine virtuellen Methoden verwenden. Für ein ganz anderes Beispiel springen wir vom Simulationsspiel zu Multimedia und definieren als Basisklasse für einen Multimedia-Clip:
class MediaClip
{
public:
void play();
// ...
};
AudioClip
ab, in der
dann die Schnittstelle mit Leben erfüllt wird und die Audiodaten hörbar werden:
class AudioClip : public MediaClip
{
public:
void play();
// ...
};
MediaClip
-Objekt, um für alle Fälle gerüstet
zu sein:
void ausgabe(const MediaClip& _clip)
{
_clip.play();
// ...
}
int main()
{
AudioClip musikClip;
// ...
ausgabe(musikClip);
return 0;
}
play()
nicht als virtuell
gekennzeichet haben, wird die Version aus der Basisklasse verwendet - und die
Lautsprecher bleiben stumm! Ganz anders wäre es, wenn wir deklarieren würden:
class MediaClip
{
public:
virtual void play();
// ...
};
virtual
deklarieren.
Eigentlich muss die virtual
-Deklaration nur in der Basisklasse stehen und kann
bei der überschriebenen Methode in der Unterklasse entfallen. Damit berauben
Sie sich aber der Möglichkeit, von der jetzigen Unterklasse wieder selbst abgeleitete
Klassen zu bilden und die Methode abermals sinnvoll zu überschreiben. Daher
empfehle ich Ihnen, stets alle Methoden, die in einer Basisklasse als virtuell
gekennzeichnet sind, auch in allen davon abgeleiteten Klasse so zu deklarieren.
Und noch ein Tipp an dieser Stelle, auch wenn Sie die Beweggründe dafür jetzt vermutlich noch nicht verstehen: Sobald Sie in einer Klasse virtuelle Funktionen verwenden, schreiben Sie bitte auch einen virtuellen Destruktor, also etwa:
class MediaClip
{
public:
virtual ~MediaClip() {}
// ...
};
Bei einer größeren Klassenhierarchie kann es oft sinnvoll sein, Basisklassen zu definieren, die nur die Schnittstellen (also die Methoden und deren Signatur) festlegen. Auf diese Weise können Sie allgemein vorgeben, wie bestimmte Zugriffe bei allen davon abgeleiteten Klassen auszusehen haben.
Sie können die Schnittstelle festlegen, indem Sie Methoden zur Klasse hinzufügen,
die in ihr nur deklariert, aber nicht implementiert werden. Eine solche
Methode bezeichnet man als rein virtuell; im Quelltext drücken Sie das
durch ein =0
hinter der Methodendeklaration aus. Da eine solche Methode
immer in der Unterklasse überschrieben werden muss, sollte sie stets als virtual
deklariert sein. Eine Klasse mit rein virtuellen Methoden nennt man abstrakt.
Beispielsweise wollen wir vorgeben, dass alle Klassen, die vom Typ MediaClip
abgeleitet sind, eine Methode play()
besitzen, die keine Argumente hat
und auch keinerlei Rückgabewert. Selbst wenn wir wollten, könnten wir eine solche
Methode in der Basisklasse gar nicht implementieren, da wir hier noch keinen
konkreten Medientyp haben und also gar nicht wissen, was und wie da überhaupt
abgespielt werden soll. Somit fügen wir in die Deklaration eine rein virtuelle
Methode ein:
class MediaClip
{
// ...
virtual void play() = 0;
};
Der Zweck von abstrakten Klassen ist, dass Sie damit Schnittstellen definieren können. Abstrakte Klassen stellen also Schablonen für den Zugriff auf und die Arbeit mit der Klasse dar. Sie zwingen damit alle, die eine Unterklasse davon bilden, die rein virtuellen Methoden mit exakt der von Ihnen vorgegebenen Signatur zu implementieren.
Ein Vorteil, den Sie mit abstrakten Basisklassen haben, ist die Trennung von Spezifikation und Implementation. Auf diese Weise können Sie Ihre Datenhaltung vereinheitlichen, die Benutzeroberfläche Ihrer Anwendung konsistenter gestalten und detaillierte Vorgaben im Rahmen einer Teamentwicklung machen.
Ein weiterer Pluspunkt ist der Aufruf von Methoden, die in der Oberklasse noch
gar nicht ausgeführt werden können. Das ist insbesondere unter Ausnutzung des
Polymorphismus sehr effektiv. Wenn Sie zum Beispiel von MediaClip
die Ableitungen
AudioClip
und VideoClip
haben, können Sie einer zusätzlichen Funktion
react()
eine Referenz auf ein MediaClip
-Objekt übergeben und dort
die play()
-Methode aufrufen:
void react(MediaClip& clip)
{
clip.play();
// ...
}
AudioClip
- oder mit
einem VideoClip
-Objekt aufrufen.
Wenn man eine größere Klassenhierarchie entwirft, in der viele Klassen mit einer Schnittstelle für den Benutzer ausgestattet sein sollen, neigt man oft dazu, Schnittstelle und Implementierung eng miteinander zu koppeln, das heißt, die Methoden genau so zu implementieren, wie sie in der Schnittstelle angegeben sind. Für die erste Version der Software ist das auch noch brauchbar. Probleme ergeben sich aber, wenn die Implementierung ergänzt oder ausgetauscht werden muss, die Schnittstelle aber erhalten bleiben soll -- oder wenn umgekehrt die Schnittstelle sich bei gleich bleibender Implementierung ändern muss. Einen Ausweg zeigt Stroustrup in [STROUSTRUP 1998]: Sie sollten einfach zwei getrennte Hierarchien aufbauen, eine aus durchweg abstrakten Klassen für die Schnittstelle und eine aus konkreten für die Implementierung. Durch Mehrfachvererbung oder Instanziierung der Implementierungsklassen entstehen dann Zwischenklassen, die die Schnittstelle der einen Seite mit Hilfe des Codes der anderen Seite realisieren.
Folgende Gesichtspunkte aus diesem Abschnitt sollten Sie festhalten:
Raumfahrzeug
, aber auch als Objekt der Klasse Raumobjekt
verwendet werden.
class B
{
public:
B(float _x) { d = _x; }
float d;
};
class A : public B
{
public:
A(int _k) { i = _k; }
int i;
};
int main()
{
A a(3);
return 0;
}
class B
{
public:
// Feld mit 'i' int-Elementen anlegen
B(int i = 80);
// Kopieren von 'anzahl' int-Elementen
B(const B&);
private:
int anzahl;
Vector feld;
};
class A : public B
{
public:
A();
A(const A& _a)
{
cout << "A(A&)" << endl;
i = _a.i;
}
private:
int i;
};
(C) T. Wieland, 2001. Alle Rechte vorbehalten.