In diesem Kapitel will ich Ihnen die Grundlagen der objektorientierten Programmierung in C++ vorstellen. Dabei haben wir ein umfangreiches Pensum vor uns:
Da Sie hier erst Schritt für Schritt die elementaren Begriffe der C++-Programmierung kennen lernen, sind die Beispiele leider vorwiegend Codeausschnitte und keine richtigen Programme. Denn dazu sind immer Sprachelemente notwendig, die zu dem Zeitpunkt eben noch nicht besprochen sind. Aus diesem Grund verlangt dieses Kapitel von Ihnen einiges an Durchhaltevermögen, führt Sie aber in sehr knapper Form durch alle Tiefen der objektorientierten Programmierung mit C++. Im nächsten Kapitel werden wir dann die erlernten Konzepte praktisch anwenden.
Das Erlernen der objektorientierten Programmierung ist für zwei Gruppen von Menschen schwierig:
Dabei wage ich zu behaupten, dass es die erste Gruppe noch etwas leichter hat als die zweite. Sie hat nämlich keine eingefahrenen Denk- und Vorgehensmuster, die sie erst überwinden muss. Allerdings sind die ersten Schritte in jeglicher Programmierung stets schwierig, so dass von einem echten Vorsprung nicht die Rede sein kann.
Wenn Sie den Stellenmarkt für Programmierer und Informatiker durchblättern, werden Sie feststellen, dass heute fast überall Kenntnisse in objektorientierter Programmierung gefragt sind - egal ob C++ oder Java. In der Tat werden immer mehr Entwicklungsprojekte nach diesem Muster (vornehm ausgedrückt: Paradigma) durchgeführt. Der Erfolg ließ aber lange auf sich warten. Die erste objektorientierte Programmiersprache gab es nämlich bereits 1967. Zum einen war es sicher das Image als Sprache für wissenschaftliche "Spielereien" , die eine weite Verbreitung verhinderten. Zum anderen war es erst der Durchbruch der grafischen Benutzeroberflächen wie Windows Anfang der neunziger Jahre, der zur Popularität der Objektorientierung führte. Denn ein Fenster, eine Schaltfläche oder ein Menü sind auf ganz natürliche Weise Objekte, wodurch sich schnell zeigte, dass Fensteranwendungen am einfachsten und schnellsten objektorientiert zu programmieren sind. Darüber hinaus führte der wachsende Zeit- und Kostendruck bei den Softwarehäusern dazu, dass Aspekte wie Wartbarkeit und Wiederverwendbarkeit eine immer größere Bedeutung erhielten. Und der objektorientierte Ansatz verspricht mehr als alle anderen, diese Ansprüche zu erfüllen.
Wenn Sie gerade mit dem Programmieren begonnen haben, kommen Ihnen die meisten Programme vielleicht simpel und schnell überschaubar vor. Alle, die schon etwas mehr Erfahrung haben, werden mir aber zustimmen, dass moderne Softwaresysteme alles andere als einfach sind. Ein Informationssystem von heute ist aus vielerlei Gründen komplex, und das leider allzu oft in sehr hohem Maße. Häufig ist bereits die Problemstellung sehr komplex, denn der Anwender möchte oft alle seine Probleme mit einer Software in den Griff bekommen. Da ein solches System schon aufgrund seiner Größe und der Terminvorgaben nicht von einer Person allein erstellt werden kann, kommt noch die Schwierigkeit hinzu, den Entwicklungsprozess vernünftig zu steuern. Außerdem soll immer auch eine hohe Flexibilität gewährleistet werden, um mit den Investitionen von heute den Anforderungen von morgen noch gewachsen zu sein. Und schließlich darf man auch nicht vergessen, dass es sich in einem Computer immer nur um ein Modell der Wirklichkeit handelt, das alle Schwächen, die mit dieser Modellierung einhergehen, permanent in sich trägt; schon dieses Problem allein kann zu mancherlei unliebsamen Überraschungen führen, die man natürlich so weit wie möglich begrenzen will.
Warum erzähle ich Ihnen das alles? Sie sollen einen ersten Eindruck davon bekommen, dass Problemlösung mit Hilfe einer Software nicht bedeutet, sofort an den Rechner zu stürzen und mit den ersten Codezeilen zu beginnen. Es wird vielmehr immer wichtiger, sich zunächst viele Gedanken über den Aufbau des Programms (und die Organisation des Projekts) zu machen. Denn letztlich können Sie die Komplexität nur durch die Aufteilung des Systems in immer kleinere Einheiten beherrschen (Abbildung 2.1).
|
|
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 Parametern übergeben bekommt, mit diesen eine Aufgabe erledigt und anschließend einen Wert zurückliefern kann. Das Programm fährt direkt hinter der Stelle fort, an der die Prozedur aufgerufen wurde.
Der modulare Ansatz geht noch einen Schritt weiter und fasst zusätzlich Prozeduren von verwandter Funktionalität zu Modulen zusammen, die gröbere Untereinheiten des Programms darstellen. Das kann sowohl die Organisation des Entwicklungsprojekts erleichtern, da man jeweils einen Programmierer mit einem Modul betrauen kann, als auch das Testen, da hierbei die Module bereits separat einmal getestet werden können und später "nur" noch das korrekte Zusammenspiel untersucht werden muss. (Beispiele für Module sind die "Units" in Turbo/Borland Pascal oder die "Module" in Fortran90. Bereits recht früh gab es zudem die Programmiersprache Modula-2, die ebenfalls als ein Pascal, welches um das Modulkonzept erweitert ist, entworfen wurde.)
Jedes Modul sollte dabei über eine klar definierte Schnittstelle verfügen. (Darunter versteht man die Gesamtheit aller Prozeduren, die von einem anderen Modul aus aufgerufen werden können.) Dann kann man auch das Gesamtsystem in hierarchische Schichten von Modulen aufteilen, wobei die Module der oberen Schichten mehr abstrakte und komplexe Funktionalität bieten, die sie durch Aufruf darunter liegender Module erreichen, die dann die konkreteren Anweisungen enthalten (die so genannte "untere Ebene").
Die Daten, mit denen das Programm umgeht, fasst man zu Strukturen zusammen, also zu Gruppen mit eigenem Namen, die einen Datentyp festlegen. Jedes Modul kann dabei eine eigene Datenmenge haben; diese gibt es nach Bedarf ganz oder teilweise an nachgeordnete Module zur Bearbeitung weiter.
Diese Vorgehensweise war viele Jahre gang und gäbe. Mit der Zeit haben sich aber einige Probleme herauskristallisiert, die immer wieder auftauchten und daher typisch für diesen Ansatz sind.
Zunächst ist es äußerst schwierig, die Robustheit des Codes selbst zu gewährleisten. (Damit meint man Aspekte wie Sicherheit vor Abstürzen, Ausfallsicherheit sowie die Fähigkeit mit unvorhergesehenen Situationen umgehen zu können.) Speicher für Datenstrukturen muss dynamisch, das heißt zur Laufzeit des Programms, reserviert werden, um flexibel zu bleiben. Dieser Speicher muss jedoch auch explizit, also durch eine Programmanweisung, wieder freigegeben werden; zudem darf ein Programm nicht in Bereiche schreiben, die es nicht reserviert hat. Die Probleme, die allein aus diesem Zusammenhang herrühren, sind legendär und treten am gravierendsten bei Verwendung der Programmiersprache C auf.
Zudem ist der Compiler nicht in der Lage, bei Parameterübergaben und Zuweisungen zu überprüfen, ob die Datentypen überhaupt zueinander passen. Gerade beim Austausch inkompatibler Informationen kommt es immer wieder zu Verfälschungen und schweren Rundungsfehlern, zum Beispiel wegfallende Vorzeichen.
Schwerwiegender noch sind indessen die Probleme, die die strukturierte Programmierung hinsichtlich der Projektorganisation mit sich bringt.
Was ist also das Fazit? Mit einem strukturierten Ansatz ist die Wartung häufig sehr aufwändig und kostenintensiv und die Wiederverwendbarkeit stark eingeschränkt. Damit sind neue Projekte mit größerer Komplexität kaum möglich. Natürlich gilt dieses Resümee nicht zwangsläufig für alle auf diese Art entwickelte Software. In der Praxis hat sich aber gezeigt, dass die beschriebenen Probleme leider recht oft auftraten.
Sie dürfen daraus jedoch auch nicht den Umkehrschluss ziehen, dass mit einem objektorientierten Ansatz alle Schwierigkeiten vorbei sind. Ein Allheilmittel ist auch dieser nicht, denn auch ein objektorientiert durchgeführtes Projekt kann schief gehen. Allerdings unterstützen die Konzepte Sie sehr stark bei der Bewältigung dieser (und anderer) Probleme. Und gerade das scheint einer der wesentlichen Gründe für die Beliebtheit der objektorientierten Softwareentwicklung zu sein.
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:
| |
Viele dieser Elemente sind zwar untereinander ähnlich, also vom gleichen Typ - wie etwa die Schaltflächen zum Vor- und Zurückschalten; jedes davon hat aber seine eigenen Beschriftungen und führt zu verschiedenen Aktionen. Aus Sicht des Programmierers haben wir also ein paar Schaltflächen, die sich z.B. folgendermaßen beschreiben lassen:
| |
|
Ein weiteres wichtiges Merkmal ist das Verhalten eines Objekts. Dieses hängt ab vom Zustand, in dem das Objekt sich augenblicklich befindet. Eine Schaltfläche soll gerade gedrückt sein oder nicht. Diese Zustände gehen durch Mausklicks des Benutzers ineinander über. Ändert sich der Zustand eines Objekts, so ändern sich oft auch seine Verhaltensweisen.
Auch kann es verschiedene Gruppen von Zuständen geben, die gegenseitig aufeinander Einfluss haben. Ist zum Beispiel gerade keine CD eingelegt, ist die Schaltfläche zum Auswerfen nicht sinnvoll. Ein Anklicken darf daher zu keiner Aktion führen.
Grob gesprochen haben unsere Objekte jeweils dieselben Verhaltensweisen. Worin sie sich wirklich unterscheiden, sind die Werte ihrer Eigenschaften und Zustände. Wenn wir also eine grafische Benutzeroberfläche programmieren wollen, müssen wir nicht für jede einzelne Schaltfläche alle Verhaltensmuster implementieren (dann hätte uns unser objektorientierter Zugang eine Menge zusätzlicher Arbeit bereitet!), sondern wir versuchen, eine allgemein gültige Beschreibung für alle Objekte vom Typ "Button" zu finden.
Fassen wir also die bisher verwendeten Attribute zu einer Schablone
zusammen: Button verfügt über die Eigenschaften Beschriftung, Höhe,
Breite, Symbol sowie MomentanAngeklickt. Damit haben wir unser Wissen
über die Elemente abstrahiert. Eine solche Abstraktion nennt man eine
Klasse. Sie ist eine allgemein gültige Beschreibung von Arten von
Objekten.
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:
Button, Objekte PlayButton und
StopButton).
Wenn wir uns allein mit Eigenschaften und Zustände bei unseren Objekten zufrieden geben würden, wären unsere Programme ziemlich langweilig. Sie würden nämlich nur eine sehr statische Sichtweise auf die Objekte wiedergeben. Sie sagen aber noch nichts über das Verhalten der Objekte aus, also über die Dynamik. Dazu brauchen wir noch Funktionen und Prozeduren.
Wir haben auf Seite 65 gesehen, dass bei der strukturierten Programmierung Daten und Funktionen getrennt sind. Einige der Probleme, die wir für diesen Ansatz identifiziert haben, lassen sich darauf zurückführen. In der objektorientierten Programmierung hingegen bilden Daten und Funktionen eine Einheit. Denn das Verhalten der Objekte hängt ja oft unmittelbar von den Werten ihrer Attribute ab. Das Shuttle kann nicht schneller fliegen, als seine Höchstgeschwindigkeit es erlaubt.
Wie bringen wir nun ein Objekt dazu, sich auf die eine oder andere Art zu verhalten? Wir "sagen" es ihm einfach. Da wir nicht direkt mit ihm sprechen können, senden wir ihm eine Nachricht. Das ist nämlich genau die Art, wie Objekte untereinander kommunizieren: Sie schicken sich gegenseitig Nachrichten, die dann zu Änderungen des Zustands oder eines Attributs führen können.
Die Funktionen, die zu einer Klasse gehören, nennt man Methoden (oder Operationen). Sie dienen dazu, Nachrichten zu versenden beziehungsweise zu behandeln. Methoden arbeiten meist innerhalb eines konkreten Objekts und greifen dabei auf dessen Attribute und Zustände zu. Die Änderungen, die die Methode durchführt, sind dann nur in diesem Objekt gültig und haben auf andere Objekte derselben Klasse, die eventuell im Augenblick daneben noch existieren, keinen Einfluss.
Von besonderer Bedeutung ist das Prinzip der so genannten Prozessabstraktion: Für den Absender einer Nachricht ist es normalerweise unwichtig und daher auch unbekannt,
In Abbildung 2.5 sind einige mögliche Vorgänge bei der Kommunikation zwischen Objekten dargestellt. Zunächst schickt das erste Objekt eine Nachricht an das zweite. Das kann diese Anfrage nicht alleine beantworten und fragt daher bei einem dritten Objekt nach. Dieses gibt auch eine Antwort zurück. Anschließend führt das zweite Objekt noch eine Änderung seines Zustands durch. Danach kann es die Antwort auf die ursprüngliche Anfrage erstellen und an den Absender schicken.
In wirklichen Programmen werden Sie nur selten etwas finden, das wie eine tatsächliche Nachrichtensendung aussieht. Normalerweise sind es Funktionen, die Sie an einem Objekt aufrufen. Im Gegensatz zur strukturierten Programmierung müssen Sie aber in einem objektorientierten Programm stets angeben, zu welchem Objekt die Methode gehört, die Sie rufen wollen.
Die Sichtweise der Nachrichten bringt uns noch zu einem anderen Aspekt. Wenn man ein Objekt nur dann modifizieren kann, wenn man ihm eine Nachricht schickt, heißt das praktisch, dass von außen kein direkter Zugriff auf die Daten, also die Eigenschaften und Zustände, möglich ist. Man sagt dazu, die Daten sind gekapselt. Da folglich die Daten nach außen, also außerhalb des jeweiligen Objekts, nicht sichtbar sind, können andere Objekte diese weder direkt lesen noch direkt verändern. Sie müssen dazu immer das besitzende Objekt benachrichtigen (Abbildung 2.6). Dieses kann dann selbst bestimmen, wie es auf eine solche Nachricht reagieren will. Es kann dem Wunsch nachkommen - oder ihn zurückweisen. Das Objekt behält also jederzeit die volle Kontrolle über seine Daten.
|
|
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:
Konto und Bankkunde mit
einigen Eigenschaften, die Sie für relevant halten, und
definieren Sie ein paar mögliche Methoden.
Bearbeiten Sie zu dieser Problemstellung folgende Aufgaben:
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 3 zum Glück so gut wie alle Sprachkonstrukte des ANSI/ISO-Standards. Gleichzeitig bedeutet ein gutes Programm zu schreiben jedoch nicht, möglichst alle neuesten Features aneinander zu hängen. Gerade wenn Sie noch wenig Erfahrung mit C++ haben, sollten Sie für Ihre Programme auf die allzu trickreichen Konstrukte verzichten. Haben Sie dagegen beispielsweise schon Erfahrung mit Java, so können Sie Ihren Stil sogar weitgehend beibehalten - denn in Java kann man nur so programmieren, wie man es in C++ eigentlich sollte.
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.7), das heißt, es hat alle Syntaxeigenschaften von C, aber noch einige darüber hinaus. Der Vorteil ist, dass bereits bestehender C-Code problemlos in jedes C++-Programm eingebunden werden kann - sofern man das möchte und für sinnvoll hält.
Unix und die Programmiersprache C sind sehr eng miteinander verbunden. C wurde für Unix konzipiert und so gut wie alle Unix-Implementierungen wurden in C programmiert. Auch der Linux-Kernel ist in C geschrieben.
Mit der Zeit haben allerdings auch die Entwickler unter Unix die Vorteile des objektorientierten Ansatzes erkannt. Er mag vielleicht bei systemnahen Programmen nicht so offensichtlich sein, so dass hier auch heute noch C vorherrscht. Bei Anwendungsprogrammen, insbesondere solchen mit grafischer Benutzeroberfläche, ist hingegen der Gewinn bei Konzeption, arbeitsteiliger Programmierung und Wartung bzw. Verbesserung deutlich spürbar. Für die Entwicklung von Benutzeroberflächen stehen heute eine Reihe sehr guter C++-Bibliotheken (zum Beispiel wxWindows, V oder Qt) zur Verfügung. Auf der Bibliothek Qt basiert beispielsweise die gesamte Arbeitsumgebung KDE (siehe www.kde.org und Seite 779) und die dafür geschriebenen Anwendungen. Sie sehen also, wenn Sie sich für C++ unter Linux entscheiden, liegen Sie nicht völlig falsch.
Der Standard-Compiler unter Linux ist schon seit langem GCC, also der GNU-C-Compiler, der in bewährter Weise als freie Software entstanden ist und auch so stets weiterentwickelt wurde. Nachdem dieser um immer mehr Programmiersprachen (außer C++ auch Objective-C, Ada, Fortran usw.) erweitert wurde, musste auch der Name angepasst werden. So steht die Abkürzung GCC nun für "GNU compiler collection"; da sich aber immer noch niemand an den neuen Namen und das damit verbundene neue Geschlecht im Deutschen gewöhnt hat, werde ich weiterhin von dem GCC sprechen. Seit der Version 3.0 ist der enthaltene C++-Compiler schon ziemlich konform zum ANSI/ISO-Standard. Mit der Version 3.4 vom Mai 2004 wurde diese Konformität nochmals um ein ganzes Stück verbessert.
Mit diesem werden wir im Folgenden arbeiten. Fast alle Beispiele sind aber noch mit den Vorgängerversionen 3.0 bis 3.3x übersetzbar, viele auch mit noch älteren Versionen. An den entsprechenden Stellen werde ich Sie natürlich auf Unterschiede hinweisen. (Es sind zum Glück nicht allzu viele.)
Der genaue Umgang mit diesem Compiler wird Ihnen im Abschnitt 2.3 auf Seite 120 erläutert.
Aber der GCC ist nicht der einzige C++-Compiler, den es unter Linux gibt. Kommerziell erhältlich ist beispielsweise der Compiler der Portland Group (zu finden unter www.pgroup.com).
Auch den Intel-Compiler, der besonders gut für die Pentium- und Itanium-Architektur optimieren kann, gibt es für Linux. (Eine Evaluationsversion zum Download gibt es unter http://www.intel.com/software/products/compilers/clin/.) Seit der Version 6.0 ist er voll kompatibel zum GCC. Das geht so weit, dass sogar Module, die mit ihm übersetzt wurden, problemlos mit anderen GCC-kompilierten verlinkt werden können. Daraus ergibt sich eigentlich bereits, dass er auch mit dem gdb-Debugger ohne Schwierigkeiten zusammenarbeitet. Durch seine ausgefeilten Optimierungsfähigkeiten kann er für das eine oder andere Projekt durchaus interessant sein (siehe auch [SCHMID 2001]). Für den Kontext dieses Buches müssen wir ihn jedoch aufgrund seiner GCC-Kompatibilität nicht gesondert behandeln.
Außerdem ist die Entwicklungsumgebung Kylix von Borland seit Version 3 auch C++-fähig. Eine freie Version (die Open Edition) kann von der Website www.borland.com/kylix heruntergeladen werden.
Wenn Sie das Buch bis hierher gelesen haben, werden Sie vermutlich schon ungeduldig darauf warten, wann es denn endlich mit dem "richtigen Programmieren" losgeht. Daher nun gleich das erste Programm.
In den Lehrbüchern zur Programmierung hat es sich eingebürgert, jede Einführung in eine neue Sprache mit einem Programm zu beginnen, das nichts tut, außer die Worte "Hello World!" auszugeben. Sie nehmen es mir bestimmt nicht übel, wenn ich uns diesen Schritt erspare, und werfen mit mir einen Blick auf das folgende Programm:
| 1: | /* Das erste Programm: |
| 2: | Summe der Zahlen von 1 bis 10 |
| 3: | */ |
| 4: |
| 5: | #include <iostream> |
| 6: |
| 7: | int main(void) |
| 8: | { |
| 9: | // Variable deklarieren und initialisieren |
| 10: | int zahl; |
| 11: | zahl = 0; |
| 12: |
| 13: | // Schleife durchlaufen |
| 14: | for (int i = 1; i <= 10; i++) |
| 15: | { |
| 16: | zahl += i; |
| 17: | std::cout << "Summe bis " << i << ": "; |
| 18: | std::cout << zahl << "\n"; |
| 19: | } |
| 20: | } |
| 21: |
Das Programm ist so einfach, dass es nur einer Datei mit diesem Stück Code bedarf. Auf eine Header-Datei, wie auf Seite 47 beschrieben, können wir also verzichten.
Sie finden übrigens alle in diesem Buch vorgestellen Beispielprogramme auf der zugehörigen Webseite www.cpp-entwicklung.de. Von dort können Sie sich ein Dateiarchiv herunterladen, das die Programme in Unterverzeichnissen der jeweiligen Kapitel enthält. Das soll Sie aber nicht davon abhalten, auch mal das eine oder andere Beispiel selbst einzutippen. Denn meistens macht man dabei einen Tippfehler und lernt damit gleich, wie der Compiler auf solche Fehler reagiert.
Gleich die ersten drei Zeilen sind ein Kommentar. Darunter versteht man einen Text, eine Anmerkung, die vom Compiler ignoriert wird. Sie dient lediglich dazu, den Programmtext für einen menschlichen Leser verständlicher zu machen. Bei einem so kleinen Programm mag Ihnen das nicht besonders wichtig vorkommen. Bei größeren Programmen ist eine gute Kommentierung aber oft entscheidend. Wenn Sie etwa Ihr Programm ein paar Monate beiseite legen und dann noch genau wissen wollen, was es anstellt, sind gute Kommentare eine große Hilfe. Dasselbe gilt, wenn Sie Programme verstehen wollen, die andere geschrieben haben.
Viele Programmierer empfinden das Kommentieren als lästige Pflicht - und genauso sehen ihre Kommentare dann auch aus. Dabei kann ich Ihnen nur raten, möglichst viel zu kommentieren, auch Dinge, die Ihnen momentan selbstverständlich erscheinen; vielleicht sehen andere das nicht so. Als Faustregel kann man sagen: Ein gut kommentiertes Programm verfügt über mindestens ebenso viele Programm- wie Kommentarzeilen.
In C++ gibt es zwei Möglichkeiten, um Kommentare zu deklarieren:
//, dann gehören alle Zeichen bis zum
Zeilenende zum Kommentar. In der nächsten Zeile geht es
dann wieder mit Programmcode weiter. Diese Möglichkeit eignet
sich eher für einzeilige Kommentare sowie Anmerkungen hinter
Befehlen.
/* bis
*/ Teil des Kommentars, egal ob sie in einer oder in mehreren
Zeilen liegen. Auf diese Weise können Sie längere Bemerkungen
in den Quelltext einbauen.Eine weitere nützliche Anwendung von Kommentaren ist, einzelne Anweisungen "auszukommentieren". Wenn Sie etwa testen wollen, was Ihr Programm ohne eine bestimmte Anweisung macht, setzen Sie einfach die beiden Schrägstriche davor und schon ignoriert der Compiler diesen Befehl.
In fast jedem Programm müssen Sie mit irgendwelchen Daten umgehen. Dazu brauchen Sie Speicherstellen im Rechner, in denen Sie diese Daten ablegen. Eine solche Speicherstelle bezeichnet man als Variable, eben weil sie ihren Wert (das heißt ihren Inhalt) im Verlauf des Programms ändern kann. In C++ unterscheidet man die Variablen nach ihrem Typ, also ob sie Zahlen oder Wörter oder etwas anderes aufnehmen können. Zahlen sind dabei sehr wichtige Typen; es gibt für ganze Zahlen und für Dezimalbrüche mehrere Typen - je nach Zahlenbereich, der sich mit ihnen darstellen lassen soll. Doch dazu später mehr (auf Seite 101).
Jede Variable hat einen Namen. In unserem Beispiel gibt es zwei
Variablen, nämlich zahl und i. Bevor Sie eine Variable verwenden können,
müssen Sie sie zunächst deklarieren. Das heißt, Sie schreiben etwa (wie in
Zeile 10)
und geben damit an, dass Sie eine Variable mit dem Namen zahl
vom Typ int (wie Integer, also Ganzzahl) im folgenden Programm
verwenden wollen. Durch diese Anweisung reserviert der Compiler
den nötigen Speicherplatz und merkt sich, dass die Variable dieses
Namens mit diesem Speicherplatz verbunden ist. Sie können auch
mehrere Variablen desselben Typs zusammen deklarieren, indem Sie sie
durch Kommas getrennt aneinander setzen. Zudem ist es erlaubt, der
Variable gleich bei der Deklaration einen Wert zuzuweisen, sie also zu
initialisieren.
Bei der Namensgebung für Variablen sind Sie ziemlich frei, wenngleich
Sie ein paar Regeln beachten müssen: Der Name muss mit einem
Buchstaben oder einem Unterstrich (_) beginnen und darf dann aus Groß-
und Kleinbuchstaben sowie Ziffern und Unterstrichen bestehen. Nicht
erlaubt sind Leerzeichen und Sonderzeichen. Die maximale Länge ist 250
Zeichen, aber mehr als fünfzehn bis zwanzig dürfte bereits ziemlich
unpraktisch sein. Bedenken Sie auch, dass C++ zwischen Groß- und
Kleinschreibung unterscheidet; Sie müssen also alle Namen genau so
verwenden, wie Sie sie deklariert haben.
Eine Unart hat C++ von C geerbt: Variablen werden bei der Deklaration nicht initialisiert. Das bedeutet, sie haben nach der Deklaration keinen definierten Wert, sondern können alle möglichen Werte aufweisen. Das ist besonders dann wichtig, wenn Sie mit dieser Variablen gleich weiterarbeiten und sie etwa erhöhen, wie in Zeile 16. Dort wird nämlich zum aktuellen Wert eine Zahl addiert. Ist bereits der aktuelle Wert undefiniert (was er in diesem Beispiel aber dank der Initialisierung in Zeile 11 nicht ist), ist es das Ergebnis genauso. Daher sollten Sie jede Variable gleich nach der Deklaration mit einem sinnvollen Wert initialisieren, um solchen unangenehmen Effekten vorzubeugen. Wenn Sie bei der Kompilierung bestimmte Warnungen aktivieren, erhalten Sie zu diesem Problem auch eine entsprechende Meldung. Da dies aber eben nicht immer der Fall ist, sollten Sie selbst ein wachsames Auge auf Initialisierungsfehler haben.
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
Von dieser Sorte Operatoren gibt es in C++ noch einige weitere; daher komme ich gleich (auf Seite 107) darauf zurück.
Eine besondere Art von Operatoren wird für die Ein- und Ausgabe verwendet. In obigem Beispiel konnten Sie in Zeile 17 lesen:
Der ¡¡-Operator bedeutet dabei nichts weiter, als dass alles, was rechts von
ihm steht, nach links "weitergereicht" wird. Dieses "Weiterreichen" endet am
std::cout, das die Ausgabe auf dem Bildschirm (zum Beispiel im
Shell-Fenster) repräsentiert. Mit dieser Zeile geben wir also zwei Texte mit
einer Zahl in der Mitte aus.
Und noch etwas sehen Sie an diesem Beispiel: Jede Anweisung in
C++ endet mit einem Semikolon. Eine Ausnahme bilden dabei
Kontrollstrukturen wie die for-Schleife. Nach diesen folgt entweder eine
einzelne Anweisung oder ein Block. Solche Blöcke kennzeichnet man in
C++ mit geschweiften Klammern { und }. Jede Funktion besteht
beispielsweise aus einem Block. Selbstverständlich kann man auch mehrere
Blöcke ineinander verschachteln.
Um dabei den Überblick zu bewahren, empfehle ich Ihnen, jede geschweifte Klammer in eine eigene Zeile zu setzen. Das ist nämlich noch eine Eigenart von C++: So gut wie alle Leerzeilen und Zeilenumbrüche werden vom Compiler ignoriert. Ob Sie also zum Beispiel bei einer Variablendeklaration zwischen dem Typ und dem Variablennamen ein oder zehn Leerzeichen einfügen, spielt überhaupt keine Rolle. Die Freiheiten, die Sie folglich bei der Gestaltung des Quelltextes haben, sollten Sie dazu nutzen, den Programmtext möglichst lesbar (für Sie und andere!) zu schreiben. Ein paar Grundregeln dabei sind:
Da aber die Gestaltung des Programmtextes letztlich Geschmackssache ist, gehen die Meinungen naturgemäß auseinander, was nun die beste Art des Schreibens sei. In einem späteren Abschnitt (ab Seite 468) werde ich Ihnen noch ein paar Konventionen vorschlagen, wie Sie Ihre Programme schreiben können. Wie Sie es dann tatsächlich tun, überlasse ich aber Ihnen. Die Hauptsache ist, Sie (und eventuell Ihr Team) schreiben alles nach einheitlichen Regeln. Dann ist im Allgemeinen schon ein wichtiger Schritt getan, dass das Programm lesbar und damit wartbar bleibt.
Eine weitere typische Eigenart der C-Kompilierung ist die Verwendung eines Präprozessors. Meistens merken Sie seine Arbeit gar nicht. Vor dem eigentlichen Compilerlauf wird aber immer noch eine Vorverarbeitung gemacht. Der Präprozessor bereitet den Code für den Compiler auf, indem er etwa die angegebenen Header-Dateien einfügt oder Makros expandiert. (Makros sind so etwas wie Abkürzungen von ein paar Anweisungen.) Er kann sogar über Bedingungen gesteuert werden, so dass der Compiler dann nur bestimmte Teile des Quelltextes übersetzt.
Anweisungen für den Präprozessor erkennen Sie immer am
Doppelkreuzzeichen #. Für den Anfang müssen Sie lediglich den Befehl
#include kennen (verwendet in Zeile 5). Damit geben Sie an, dass Sie die
Header-Datei verwenden möchten, deren Namen Sie in spitzen Klammern
(¡ und ¿) oder in Anführungzeichen “...“ dahinter gesetzt haben. Der
Unterschied dabei ist: Steht der Dateiname in normalen Anführungzeichen,
so wird die Header-Datei auch im aktuellen Verzeichnis gesucht.
Verwendet man die spitzen Klammern, so beschränkt sich die Suche auf
die Verzeichnisse für die System-Header und eigens angegebene
Verzeichnisse (über den Aufrufparameter -I für den Compiler, siehe Seite
129).
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 97) erklärt, erfolgt die Festlegung bei der Deklaration der Variablen, also zum Beispiel:
int zahl; | //Variable als Integer deklariert |
char x, y, z; | //Drei Variablen als einfache Zeichen |
double d = 3.14; | //Gleitkommazahl gleich initialisiert |
C++ hat zum einen eine Reihe von eingebauten Typen (so genannten Standard- oder elementaren Datentypen), bietet aber zum anderen auch die Möglichkeit, eigene Datentypen darauf aufbauend zu definieren, unter anderem Klassen.
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; // | |
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.
unsigned long l;
Die Vorgabe ist immer int; daher können Sie für eine vorzeichenlose
Ganzzahl auch einfach schreiben:
unsigned u;
long und double zur Vergrößerung des Wertebereichs
auf 8 bzw. 12 Byte den Zusatz long erhalten.
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
voranstellen, also beispielsweise int p = 0x1AF7.
´a´. Diese Konstante kann man dann einer Variablen
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 dennoch die dabei angewandten Prinzipien kennen, um beurteilen zu können, ob die automatische Umwandlung auch die ist, die Sie wollen.
Völlig unproblematisch ist die Umwandlung von einem Datentyp mit
einem kleineren auf einen mit einem größeren Wertebereich, also
beispielsweise von short auf int. Vorsicht ist beim umgekehrten Fall
geboten. Hierbei können Informationen verloren gehen und sogar völlig
andere Ergebnisse herauskommen, als Sie vielleicht erwarten. Ein
Beispiel:
std::cout << k << std::endl;
Welche Ausgabe erwarten Sie? Das richtige Ergebnis wäre 49284, auf dem
Bildschirm aber erscheint -16252. Das liegt daran, dass die Anzahl
der Bits der Variablen zum Speichern dieser Zahl nicht ausreichen.
Das Ergebnis wird zwar richtig gebildet und in die Speicherstelle
geschrieben; aber die vorderen Bits fehlen. Zudem wird das höchste
Bit von Ganzzahlen (sofern sie nicht als unsigned deklariert sind)
stets als Vorzeichen angesehen. Wenn Sie also zum höchsten Wert
32767 noch 1 addieren, enthält Ihre Variable anschließend den Wert
-32768.
Wenn noch Konstanten im Quelltext stehen, kann man von Hand nachrechnen und aufmerksam werden. Wenn aber andere Variablen beteiligt sind, wird das schon schwieriger, zum Beispiel:
long l = 222L;
k= l*l;
std::cout << k << std::endl;
Sie sollten also immer darauf achten, dass Sie bei Zuweisungen (oder Übergaben an Unterprogramme und so weiter) keinen Informationsverlust und damit eine eventuell unerwünschte Typumwandlung riskieren.
Bei der gegenseitigen Zuweisung von ganzen und Dezimalzahlen sind nicht so viele Fallstricke ausgelegt. Sie sollten lediglich daran denken, dass der gesamte Teil nach dem Komma abgetrennt wird, wenn Sie als Ergebnis eine Ganzzahl angeben. Beispielsweise liefert
als Resultat 1.
C++-Compiler haben zwar eine gewisse Flexibilität bei der impliziten Typumwandlung, sind aber meist viel genauer als C-Compiler. Das liegt daran, dass C++ eine streng typisierte Sprache ist, wie man sagt. Jede Variable muss über einen eindeutigen Typ verfügen, und bei Zuweisungen und Vergleichen müssen die Variablen beziehungsweise Konstanten als kompatibel bekannt sein, sonst wird eine Fehlermeldung ausgegeben. Überhaupt weist Sie der Compiler mit Hilfe von Warnungen oft auf mögliche Fehler oder Mehrdeutigkeiten hin, etwa wenn er annimmt, dass Sie eine Anweisung anders gemeint haben, als sie nun im Quelltext steht. Lesen Sie diese Meldungen aufmerksam und versuchen Sie stets die Ursachen dafür zu verstehen. Auf diese Weise werden Sie auch viel über die richtige Programmierung lernen.
Anstatt sich auf die automatische Typkonvertierung zu verlassen, kann es auch manchmal sinnvoll sein, dem Compiler eine Regel zur Umwandlung vorzuschreiben. So können Sie Missverständnissen vorbeugen, die sonst in Form von Warnungen zutage treten würden.
Für die explizite Typumwandlung verwenden die C-Programmierer einen sehr bildlichen Ausdruck, nämlich cast, das englische Wort für "Gipsverband". Um einer Variablen einen solchen anzulegen, haben Sie in C++ verschiedene Möglichkeiten. Die C-Variante ist, den Typ in runden Klammern vor die Variable zu schreiben, und zwar dort, wo an ihrer Stelle der umgewandelte Wert stehen soll, zum Beispiel:
int i = (int)d;
Bei der Umwandlung von double nach int werden alle Nachkommastellen
abgeschnitten; eine Rundung findet nicht statt.
Die andere Möglichkeit ist, die Variable in Klammern zu setzen und den Typ davor:
int i = int(d);
(Wer ganz sicher gehen will, kann sogar beide Varianten gleichzeitig verwenden ...)
Im neuen C++-Standard gibt es noch ein paar ausgefeiltere Methoden zur expliziten Typkonvertierung. Da Sie aber erst noch einige weitere Begriffe kennen müssen, um diese zu verstehen, verschieben wir die Diskussion auf später (Seite 529).
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
wobei 0 den Erfolgsfall, 1 die Information, 2 die Warnung, 3 den Fehler und
4 den Abbruch darstellen. Dann können Sie bei Zuweisungen oder
Vergleichen aber nicht sofort erkennen, welche Art von Status gemeint ist.
Außerdem wäre es zulässig, dass Sie der Variablen zum Beispiel den Wert
10 zuweisen, was außerhalb des Wertebereichs 0 bis 4 liegt und sich nicht
mehr sinnvoll interpretieren lässt.
Hier sollten Sie einen Aufzählungstyp verwenden. Sie deklarieren ihn
mit dem Schlüsselwort enum, einem Typnamen und einer Liste von
Werten, eingeschlossen in geschweiften Klammern und getrennt durch
Kommas. Für unseren Status können wir schreiben:
ERROR, FATAL};
(Die Schreibweise für die Werte ist zwar beliebig; ich empfehle Ihnen aber die Konvention, Konstanten mit Großbuchstaben zu schreiben, um sie leichter von Variablen unterscheiden zu können.)
Damit können Sie nun Variablen definieren:
und gegebenfalls auch gleich initialisieren:
Es ist auch zulässig, gleich bei der Deklaration eines Aufzählungstyps Variablen davon zu definieren, etwa:
{ WERKTAG, SONNTAG, FEIERTAG} tariftag;
Wenn Sie den Aufzählungstyp nur bei einer Variablendefinition brauchen und sonst nicht mehr, können Sie sogar den Typnamen weglassen und ihn anonym deklarieren, zum Beispiel:
Obwohl Sie damit neue Datentypen deklarieren, werden Aufzählungstypen
intern als natürliche Zahlen verwaltet. Dabei erhält die erste Konstante
immer den Wert 0, die folgenden jeweils einen um 1 erhöhten Wert. Dies
zeigt, dass Sie Werte eines Aufzählungstyps auch nach int umwandeln
können - allerdings nicht umgekehrt.
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 Operator einzeln einzugehen, würde an dieser Stelle zu weit führen. Ich will Ihnen daher in Tabelle 2.1 auf Seite 110 eine Liste mit den wichtigsten Operatoren geben. Wenn Ihnen verschiedene nicht gleich klar sind, müssen Sie sich deshalb keine Sorgen machen: Wenn Sie etwas Erfahrung in der C++-Programmierung gewonnen haben, werden Sie auch alle Operatoren verstehen; auf einige werde ich im Folgenden auch noch genauer eingehen.
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Operatoren werden zu so vielen Zwecken eingesetzt, dass teilweise
schon die Zeichen auf der Tastatur nicht auszureichen scheinen. Tatsächlich
gibt es ein paar Operatoren, deren Bedeutung vom Kontext abhängt, in
dem sie verwendet werden. Die größte Bandbreite hat dabei das
Kaufmanns-Und &. Es dient als Referenzierungsoperator, wenn kein
Argument davor kommt - sonst ist es eine bitweise AND-Verknüpfung.
Außerdem macht man dabei aus einer Variablendeklaration eine
Referenzdeklaration. Mit der Zeit werden Sie deutlich sicherer im Lesen
von C++-Programmen werden, so dass Ihnen diese Unterschiede
irgendwann offensichtlich erscheinen. Für den Augenblick ist es hilfreich,
wenn Sie die Quelltexte sehr konzentriert durchsehen und sich bei jeder
Anweisung nach deren Bedeutung fragen.
Schon bei den Grundrechenarten kann es ein wesentlicher Unterschied sein, welche Rechnung man bei größeren Termen zuerst ausführt. Dort regelt beispielsweise der Grundsatz "Punkt vor Strich", dass zunächst multipliziert und dann erst addiert wird. Eine solche Reihenfolge der Abarbeitung der Operatoren braucht auch jede Programmiersprache, sonst müssten endlos viele Klammern gesetzt werden, die den Programmtext äußerst unübersichtlich werden ließen.
In Tabelle 2.1 sind die Operatoren bereits nach ihren Prioriäten sortiert, das heißt, dass zunächst die primären Operatoren ausgewertet werden, dann die unären, anschließend die binären und am Ende die Zuweisungsoperatoren. Auch innerhalb jeder Gruppe kann es noch Reihenfolgen geben. So hat die Multiplikation erwartungsgemäß Vorrang vor der Addition, aber auch die Subtraktion vor dem Vergleich.
Eine Sonderstellung nehmen die Operatoren für Inkrementierung ++ und
Dekrementierung -- ein. Sie können nämlich sowohl vor als auch hinter der
Variablen stehen, auf die sie wirken. Man nennt diese beiden Stellungen
auch Präfix und Postfix.
Die Operatoren werden meist nicht als einziger Bestandteil einer Anweisung verwendet, sondern in kompliziertere Ausdrücke eingebaut. Die Regel ist: Steht der Operator vor der Variablen, wird er zuerst ausgeführt und dann erst der Ausdruck ausgewertet. Steht er dagegen dahinter, wird erst der gesamte Ausdruck ausgewertet und dann der Operator angewandt. Zum Beispiel:
Hier wird k noch mit dem aktuellen Wert von j berechnet und erst nach
der Zuweisung wird j um 1 erhöht.
Anfänger empfinden diese Schreibweise stets als verwirrend; die erfahrenen Programmierer sehen sie dagegen als besonders elegant und kompakt an. Überlegen (oder probieren) Sie mal selbst, welche Ausgabe der folgende Code erzeugt:
std::cout << i++ << `` `` << i << `` ``
<< ++i << `` `` << i << std::endl;
Unbedingt vermeiden sollten Sie, eine Variable, die in einem Ausdruck mehrfach vorkommt, auch mit mehreren Inkrementoperatoren zu versehen. Das Ergebnis kann dann schnell unvorhersagbar werden. (Um kein irreführendes Vorbild zu geben, spare ich mir das Beispiel zu diesem Fall. Der Code im letzten Abschnitt ist bereits schlimm genug!)
Einer der Operatoren, die in Tabelle 2.1 fehlen, ist ?: für die Bedingung. Er
hat folgende allgemeine Form:
Bedingung ? Ausdruck1 : Ausdruck2
Die Verwendung ist ganz einfach: Ist die Bedingung wahr, wird Ausdruck1 ausgewertet, ist sie falsch, dann Ausdruck2. Zum Beispiel:
Falls der Wert der Variablen a ein anderer als 5 ist, bekommt c den Wert
25, ansonsten wird c auf 50 gesetzt.
Zwei weitere Operatoren sind aus gutem Grund nicht in Tabelle 2.1 aufgelistet: die Maximum- und Minimumoperatoren. Sie gibt es nämlich nur beim GCC. Wenn Sie diese also einsetzen, müssen Sie sich bewusst sein, dass andere Compiler mit diesen Operatoren nichts anfangen können und eine Fehlermeldung ausgeben. Ihr Code wird sich dann nur noch mit dem GCC übersetzen lassen!
Wenn Sie also in kompakter Schreibweise ein Maximum oder Minimum ermitteln wollen, geht das ganz leicht:
weist c das Maximum von a und b zu. Ebenso ergibt
das Minimum von a und b. Die Operatoren arbeiten nur auf numerischen
Datentypen.
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
Die Prioritäten, nach denen ein Ausdruck ausgewertet wird, richten sich nach denen der beteiligten Operatoren (siehe Seite 113). So wird etwa bei
zunächst b * c berechnet und das Resultat dann zu a addiert.
Der Typ, den der Wert eines arithmetischen Ausdrucks erhält, wird durch den höchstwertigen Teilausdruck bestimmt, also durch den Typ mit dem größten Informationsumfang. Betrachten wir beispielsweise folgende Variablen:
short s;
char c;
double d;
Dann erhalten die Werte der Verknüpfungen dieser Variablen folgende Typen:
c + i // Typ int
s * d // Typ double
Sie können dieser automatischen Typfestlegung durch explizite Typumwandlung zuvorkommen (siehe Seite 105). Das kann unter anderem dann nützlich sein, wenn das Resultat sonst durch Rundungen verfälscht würde, zum Beispiel:
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 nichtexistenten 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?
using namespace std;
int main() {
cout << "short int: "
<< sizeof(short int) << endl;
cout << "int: " << sizeof(int) << endl;
cout << "long int: "
<< sizeof(long int) << endl;
cout << "long long: "
<< sizeof(long long) << endl;
cout << "float: " << sizeof(float) << endl;
cout << "double: "
<< sizeof(double) << endl;
cout << "long double: "
<< sizeof(long double) << endl;
return 0;
}
Geben Sie das Programm ein und speichern Sie es in der Datei typesize.cc. Geben Sie dann in der Shell ein:
Wenn Sie keinen Fehler gemacht haben, ist nun die ausführbare Datei typesize neu in Ihrem Verzeichnis. Starten Sie diese und vergleichen Sie deren Ausgaben mit Ihren Vorhersagen.
using namespace std;
int main() {
int a = 4;
double b = 8;
int c = b;
cout << a*b/c << endl;
cout << a/c*b << endl;
cout << a/b*c << endl;
cout << 1/(c/a*b) << endl;
return 0;
}
x soll ausgegeben werden, ob sie 0 ist oder nicht.
Was ist hier falsch?
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:
Wenn keine Fehler auftreten, gibt der Compiler auch keinerlei Text auf den Bildschirm aus. Nach kurzer Zeit meldet sich lediglich die Eingabeaufforderung der Shell wieder. Im aktuellen Verzeichnis gibt es dann eine neue ausführbare Datei namens a.out. (Das ist seit alten C-Zeiten der Standardname für alle Programme.)
Wenn Ihr Programm aus mehreren Quelltextdateien besteht, können Sie auch mehrere hintereinander angeben. Dabei dürfen die Dateien auch in mehreren Verzeichnissen liegen, Sie müssen dazu nur jeweils den vollständigen Pfad angeben, zum Beispiel:
Der GNU-Compiler kennt eine Fülle von Optionen, mit denen sich der Übersetzungsvorgang darüber hinaus beeinflussen lässt.
| |||||||||||||||||||||||||||||||||||||||||||||||||||
Die wichtigsten sind in Tabelle 2.2 zusammengefasst; ich werde sie Ihnen im Folgenden kurz erläutern. Die Reihenfolge, in der Sie die Optionen angeben, ist völlig egal. Sie müssen lediglich sicherstellen, dass jeweils eine Option und ihr zugehöriger Parameter zusammen stehen.
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:
Sie müssen immer auch einen Dateinamen hinter die Option -o schreiben;
sonst kann es passieren, dass der Compiler Ihre Programmdatei für den
gewünschten Namen hält und diese einfach überschreibt!
Jeder Programmierer macht Fehler. Um diese aufzudecken, gibt es Debugger (vom englischen Wort "bug", das eigentlich Käfer, umgangssprachlich auch Programmierfehler bedeutet; ob man in den ersten riesigen Elektronenhirnen mal tatsächlich Käfer fand, ist nicht zweifelsfrei überliefert). Debugger sind Hilfsprogramme, mit denen Sie das Programm quasi beim Ablauf beobachten können. Sie können es an bestimmten Stellen stoppen, Werte von Variablen ausgeben lassen oder es schrittweise ablaufen lassen.
Damit der Debugger die Anweisungen im ausführbaren Programm den
Quelltextzeilen zuordnen kann, braucht er entsprechende Informationen.
Diese werden durch die Option -g in das Programm eingefügt.
Im normalen Ablauf verhält sich das Programm kaum anders als ohne diese Option; es ist allerdings teilweise erheblich größer. (Die Objektdateien können bis zum Zehnfachen größer werden, die ausführbaren Dateien dagegen meist um zehn bis zwanzig Prozent, bei größeren Projekten aber auch mehr.) Erst ein Debugger kann die enthaltenen Informationen nutzen. Mehr dazu in Abschnitt 6.2 auf Seite 676.
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:
long l= 222L;
erhalten Sie die Meldungen (und noch ein paar mehr):
test.cc:11: Error: expected primary-expression before "short" test.cc:11: Error: expected `;' before "short"
Sie sehen an diesem Beispiel bereits, dass die Meldungen des Compilers zuweilen schwierig zu interpretieren sind. Es braucht einiges an Übung, bis man weiß, welcher Fehler die Ursache für eine solche Meldung sein könnte.
Manchmal wird ein Syntaxfehler aber gar nicht als solcher erkannt, da die Anweisung in einem anderen Kontext durchaus korrekt sein könnte. Eine solche Fehlerinterpretation verwirrt den Compiler dann bei der Übersetzung der nachfolgenden Anwendungen so sehr, dass es meist Fehlermeldungen "hagelt". Wenn Sie an der ersten Stelle, an der ein Problem auftrat, nichts finden können, sollten Sie daher auch die Zeilen darüber betrachten, ob sich in diesen nicht die Ursache finden lässt.
Anders verhält es sich mit Warnungen. Diese sind nicht so schwer wiegend, dass dadurch der Übersetzungsvorgang gleich eingestellt werden müsste. Andererseits handelt es sich dabei um potenzielle Probleme, die im einfachsten Fall ignoriert werden, im schwersten Fall aber zur Laufzeit einige unerwünschte Effekte hervorrufen können. So weist Sie der Compiler darauf hin, dass Sie etwas unschön geschrieben haben oder mehr als nötig, dass er etwas anders auffasst, als Sie es vermutlich gemeint haben, und so weiter.
Warnungen werden nicht standardmäßig ausgegeben, sondern Sie
müssen sie mit einem Compiler-Schalter anfordern. Für fast jede Warnung
gibt es eine eigene Option, jeweils von der Form -Wwarnungstyp. Zum
Glück gibt es aber auch Schalter, die ganze Gruppen von Warnungen
aktivieren. Einer ist dabei besonders wichtig: Mit -Wall schalten Sie alle
Warnungen ein, die auf "unschönen" Code hindeuten. Dabei handelt es sich
beispielsweise um nicht oder unzureichend initialisierte Variablen,
Typverwechslungen, missverständliche Klammerungen oder unbenutzte
Variablen. Sie sollten die Warnungen ernst nehmen und solchen Code
vermeiden. Wenn wir etwa schreiben:
{
short s, p;
int i=10.0/8;
std::cout << i << std::endl;
return 0;
}
so führt das zu den Warnungen:
from `double'
test.cc:5: warning: unused variable `s'
test.cc:5: warning: unused variable `p'
Die Schreibweise -Wall steht zwar für "warnings, all", ist aber nicht
zufällig auch als "wall", also "Wand", zu lesen. Sie können sich dabei eine
Schutzmauer um Ihren Code vorstellen; wenn er sie überwindet, ist er
schon mal in ganz guter Verfassung. Daher empfehle ich Ihnen, diesen
Schalter immer zu verwenden und dabei zu versuchen, alle daraus
resultierenden Warnungen zu vermeiden.
Einige der Warnungen, die mit -Wall verbunden sind, werden erst
ausgegeben, wenn die Optimierung aktiviert ist, z.B. die Warnung vor
nicht initialisierten Variablen. Verwenden Sie daher am besten immer gleich
die Kombination -Wall -O.
Wenn Sie Programme schreiben, die aus mehreren Dateien bestehen, so geben Sie diese üblicherweise nicht alle auf einmal beim Aufruf des Compilers an. Wenn Sie nämlich in einer davon einen Fehler gefunden haben, diesen korrigieren und die Übersetzung neu starten, werden auch alle davor stehenden nochmals kompiliert. Der GGC, genauer das Kommando g++, kann mit zwei verschiedenen Typen von Eingabedateien aufgerufen werden: mit C++-Dateien, das heißt Quelltext, und mit Objektdateien. Bei Programmen mit mehr als einer Quelldatei darf man die einzelnen Quellen nur bis zum Objektcode übersetzen, da sonst die Abhängigkeiten von den anderen Dateien nicht aufgelöst werden könnten. Dazu bedarf es für jede Datei eines eigenen Compiler-Aufrufs. Sind alle Dateien übersetzt, können sie mit einem weiteren Aufruf zusammengelinkt werden.
Um den Compiler anzuweisen, beim Objektcode aufzuhören und das
Linken zu einem ausführbaren Programm gar nicht erst zu versuchen,
verwenden Sie die Option -c. Besteht der Quelltext Ihres Programms
beispielsweise aus den Dateien datei1.cc und datei2.cc, so können Sie den
Compiler folgendermaßen aufrufen:
% g++ -c datei2.cc
% g++ -o prog datei1.o datei2.o
Auf diese Weise haben Sie die eigentliche Kompilierung der Quelltexte entkoppelt und trotzdem aus diesen die ausführbare Datei prog erzeugt.
Wir haben oben festgestellt (Seite 100), dass Header-Dateien normalerweise in den bekannten Verzeichnissen für die System-Header gesucht werden. (Diese hatte ich Ihnen auf Seite 53 bereits vorgestellt.) Steht der Dateiname in normalen Anführungzeichen, so wird nach der Header-Datei zusätzlich im aktuellen Verzeichnis gesucht.
Gelegentlich werden Sie für die Übersetzung aber auch Header-Dateien
benötigen, die weder in den Systemverzeichnissen noch im aktuellen
Verzeichnis stehen. Dann können Sie zusätzlich Verzeichnisse angeben, in
denen der Compiler nach Headern suchen soll. Dazu verwenden Sie die
Option -I, hinter der Sie ohne ein Leerzeichen dazwischen das Verzeichnis
angeben. Dabei können Sie relative Pfade (etwa -I../../inc) genauso
verwenden wie absolute (zum Beispiel -I/opt/kde/include) oder solche mit
Umgebungsvariablen (beispielsweise -I$HOME/include).
Daher haben die Compiler-Entwickler schon seit längerem über dieses Problem nachgedacht. Eine mögliche Lösung ist es, die Header-Dateien bereits vorzukompileren und bei der Verwendung nur den Maschinencode dazuzuladen - aber sie eben nicht immer wieder vom Quellcode aus zu übersetzen. Diese Funktion der vorkompilierten Header-Dateien gibt es ab Version 3.4 nun auch beim GCC. Allerdings ist sie dort noch als "experimentell" gekennzeichnet; ihre Benutzung erfolgt also auf eigene Gefahr.
Wenn Sie mit vorkompilierten Headern arbeiten wollen, müssen Sie nichts weiter tun, als außer für Ihre C++-Dateien den Compiler auch noch für die Header-Dateien aufzurufen, also beispielsweise:
Daraus erzeugt der GCC nun eine Datei mein_header.h.gch. Diese kann eine stattliche Größe haben (über 900 kB), denn sie umfasst auch alle eventuellen Einsatzmöglichkeiten der im Header definierten Datenstrukturen und Funktionen.
Binden Sie dann in einer C++-Datei mit #include ”mein_header.h”
diesen Header ein, so sucht der Compiler in den angegebenen Suchpfaden
(siehe oben) zunächst nach einer vorkompilierten Version dieses
Headers und erst, wenn er keine findet, nach der Quelltextversion.
Sollten Sie Probleme mit dieser Technik haben, was aufgrund des
frühen Status durchaus gelegentlich vorkommen kann, können Sie die
gch-Dateien gefahrlos wieder löschen und wie gewohnt Ihre Programme
kompilieren.
Noch ein Hinweis: Der Aufruf wie oben, eine Header-Datei zu kompilieren, ist nur bei GCC Version 3.4 und höher zulässig. Bei früheren Versionen führt dieser zu einer Fehlermeldung.
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:
-l. Ebenso wie bei den Header-Dateien
durchsucht der Linker nämlich eine Liste von Systemverzeichnissen
nach der so angegebenen Bibliothek. Wenn Sie die Datei libBibl.a
einbinden möchten, verwenden Sie die Option -lBibl. Das bedeutet,
Sie lassen bei dieser Option den Namensbeginn lib ebenso weg wie die
Endung .a.
Wenn Sie noch andere Verzeichnisse in die Suche einschließen wollen,
können Sie dies mit der Option -L tun. Diese arbeitet ganz analog zu
der für Include-Dateien. Die Option -L wirkt dabei indes nur
auf die -l-Befehle, die Sie danach angeben. Somit sollten Sie
stets zunächst die Suchpfade und dann die Binde-Kommandos
schreiben.
Bibliotheken sind eigentlich nur Archive, die aus einzelnen Objektdateien zusammengesetzt sind. Der Linker sucht sich dabei alle Symbole (Konstanten, globale Variablen, Klassen, Funktionen usw.) heraus, die im restlichen Programm zwar verwendet, aber nicht definiert werden. Auch dabei ist die Reihenfolge entscheidend. Wenn Sie eingeben:
so wird die Bibliothek libbasis.a noch vor dem Objekt-File datei2.o durchsucht. Der Linker ist dabei so intelligent, dass er nur die Teile der Bibliothek zur ausführbaren Datei hinzufügt, die auch tatsächlich verwendet wurden. Mit "verwendet" ist dabei gemeint, dass eine der Objektdateien, die vor der Bibliothek vom Linker verarbeitet wurde, einen Aufruf einer Bibliotheksfunktion enthält. Befindet sich jedoch in datei2.o auch ein Aufruf einer anderen Funktion aus der Bibliothek, so wird dieser nicht mehr berücksichtigt. Sie erhalten folglich vom Linker die Fehlermeldung: "Unreferenced symbol [...] in datei2.o".
Wie Sie dynamische Bibliotheken einbinden und eigene Bibliotheken erstellen, erfahren Sie in Abschnitt 3.5 ab Seite 443.
Normalerweise versucht der Compiler, die ihm gestellte Aufgabe, also das Übersetzen eines Programms, möglichst schnell zu erledigen. Das ist während des Entwicklungszyklus auch für den Programmierer hilfreich. Ist das Programm aber einmal fertig, soll es so schnell und effizient wie möglich seine Arbeit verrichten. Dafür schalten Sie die Optimierung ein.
Compiler-Optimierung ist ein eigenes wissenschaftliches Fachgebiet. Der GCC war lange Zeit dafür bekannt, dass er besser optimiert als viele seiner kommerziellen Konkurrenten. Unter Linux liegt er immer noch in der Spitzengruppe. In älteren Programmierbüchern können Sie oft lesen, wie man Code schreiben sollte, damit er vom Compiler optimal umgesetzt werden kann. Aufgrund der Fähigkeiten des automatischen Optimierers können Sie sich diese Feinheiten heute sparen. Natürlich sollten Sie immer noch Ihre Algorithmen möglichst ökonomisch organisieren - Sie müssen aber nicht mehr bestimmte Reihenfolgen von Anweisungen oder Befehlen beachten, um Variablen in die Prozessorregister unterzubringen. Sie müssen nicht einmal wissen, was der Optimierer eigentlich macht, denn es interessiert Sie ja nur, dass Ihr Programm möglichst schnell läuft.
Die Standardoption zum Einschalten der Optimierung ist -O. Damit
fordern Sie den Compiler auf, die Größe und die Laufzeit des Programms
so gering wie möglich zu halten. Zum Beispiel:
Es gibt dazu sogar noch Steigerungen: Mit -O2 und -O3 schalten Sie
noch zusätzliche Optimierungsverfahren ein. Zuweilen kann das aber
des Guten zu viel werden, denn manche Programme zeigen bei zu
hohem Optimierungsniveau ein unvorhersagbares Verhalten bis hin
zu Abstürzen. Daher ist das einfache -O meist die Methode der
Wahl.
Wenn Sie noch höhere Geschwindigkeit erreichen wollen, können Sie
den Compiler anweisen, die Eigenheiten der CPU Ihres Computers
bestmöglich zu nutzen. Dazu dient die Familie der Compiler-Schalter, die
mit -m beginnt, also -m386 (auf Intel-Plattformen der Vorgabewert),
-m486, -mpentium und -mpentiumpro (neben PentiumPro auch für
Pentium II und III). Seit Version 3.0 gibt es auch eine Anpassung für
AMD-Prozessoren mit -mcpu=athlon. Ein so optimiertes Programm läuft
natürlich auf Rechnern mit Prozessoren der vorhergehenden Generationen
nicht mehr in allen Fällen.
-fprofile-arcs. Dadurch erzeugt der GCC einen Graphen, der den
Programmfluss repräsentiert, und sucht darin einen Baum, der den
Graphen aufspannt. Diese Information wird dann dazu genutzt zu
bestimmen, welche Blöcke wie optimiert werden müssen.
-fbranch-probabilities. Dadurch erreichen Sie, dass bei zukünftigen
Abläufen die Daten für den jeweils wahrscheinlichsten nächsten
Schritt in die Prozessorregister geladen werden, um durch dieses
Vorausschauen die Geschwindigkeit zu steigern.Diese Technik hat jedoch enge Grenzen. Sie funktioniert dann am besten, wenn sich die Art, wie ein Programm benutzt wird, meist gleicht. Das kann zum Beispiel bei umfangreichen mathematischen Berechnungen der Fall sein. Je mehr Unterschiede es in der Ablauflogik und -reihenfolge gibt, desto geringer wird der Effekt dieser Optimierung sein.
Eine etwas längere Liste mit Optionen finden Sie auf der man-Seite zu g++. Dazu geben Sie einfach ein:
Die vollständige Aufstellung aller Compiler-Schalter steht auf den info-Seiten zu gcc/g++. Damit ist eine Dokumentationsform aus der Frühzeit der Unix-Programmierung gemeint. Es gibt heute noch auf allen Unix-Plattformen das Werkzeug info, um sich diese Seiten anzusehen. Allerdings ist die Bedienung so gewöhnungsbedürftig, dass ich allen Anfängern nur davon abraten kann.
Sehr viel einfacher und zeitgemäßer ist die Betrachtung mit
einem Browser wie Konqueror. Dieser hat die Konvertierung gleich
integriert, so dass Sie nur als Adresse info:/gcc eingeben müssen.
Auf diese Weise können Sie bequem mit der Maus zwischen den
einzelnen Seiten hin- und herspringen (Abbildung 2.9 auf Seite 137).
Hinter dem Unterpunkt INVOKING GCC finden Sie beispielsweise die
Compiler-Schalter.
Ebenso leicht ist das aber auch mit dem Help-Browser gnome-help-browser der Arbeitsoberfläche GNOME möglich. Dieser hat gleichfalls die Konvertierung nach HTML eingebaut.
Eine weitere komfortable Möglichkeit zum Betrachten der Info-Seiten bietet der XEmacs-Editor; lesen Sie dazu Seite 632.
Vielleicht hatten Sie während des Lesens ab und zu schon den Wunsch, auch ein kleines Programm zu schreiben und zu übersetzen. Ich habe schon viel über Programmierung erzählt, aber noch nichts darüber, wie man den Quelltext eigentlich eintippt. Der Grund dafür ist, dass es unter Linux zwar den C++-Compiler gibt, aber nicht einen vorherrschenden Editor (auch wenn manche den Emacs dafür halten). Es gibt eine große Bandbreite an Programmen, mit denen Sie einen Text, aber auch speziell einen Programmtext bearbeiten können. Entsprechend vielseitig ist die Palette der Bedienphilosophien. Viele Editoren sind meiner Erfahrung nach für Anfänger ungeeignet, da dabei sehr eigenwillige Tastenkombinationen nötig sind. Und da ich davon ausgehe, dass Sie vor Ihrer Beschäftigung mit Linux schon einige Erfahrungen mit Windows oder Mac OS gemacht haben, möchte ich Sie lieber auf Werkzeuge hinweisen, deren Handhabung Ihnen etwas vertrauter vorkommen müsste. Es macht doch keinen Sinn, wenn Sie sich nicht nur in eine neue Programmiersprache, sondern gleichzeitig auch in einen ungewohnten Texteditor einarbeiten müssten, oder? Ab Seite 601 werde ich Ihnen daher einige Editoren im Detail vorstellen.
Vielleicht ist Ihnen aber auch der Wechsel zwischen Editor und Shell, die Handeingabe des Compiler-Aufrufs und des Programmstarts zu mühsam und Sie sehnen sich nach einer Umgebung, aus der heraus Sie alles mit ein paar Mausklicks erledigen können. Solche Umgebungen gibt es natürlich unter Linux auch; in Kapitel 7 werde ich Ihnen einige davon vorstellen. Eine der heute wichtigsten ist KDevelop. Sie können alle weiteren Beispiele bequem mit KDevelop entwickeln, ohne sich viel mit dem Compiler, seinem Aufruf und seinen Parametern auseinander setzen zu müssen (obschon Sie diese Techniken im Grunde auch beherrschen sollten). Ab Seite 770 finden Sie "Kochrezepte", wie Sie Konsolenprogramme - und um solche handelt es sich bei unseren Beispielen - einfach mit KDevelop erzeugen und bearbeiten können.
Die wichtigsten Aspekte dieses Abschnitts waren:
g++. Als Argument erwartet dieser zumindest den Namen der
Quelltextdatei.
-c erreichen Sie, dass die Kompilierung bei der
Objektdatei endet.
-g bewirkt, dass Informationen für den Debugger
hinzugefügt werden.
-IDIR können Sie einen zusätzlichen Suchpfad
namens DIR für Header-Dateien festlegen.
-lBIBL führt zum Hinzulinken der Bibliothek
libBIBL.a.
-LDIR 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.
Welchen (unerwünschten) Effekt hätte er?
-g?
myprog.cc übersetzen und mit der Bibliothek /usr/lib/libm.a
zu einer ausführbaren Datei namens myprog zusammenbinden
wollen?
sowie
in ein ausführbares Programm fotos übersetzt werden.
erzeugen Sie eine Header-Datei fotos.h.
ein ausführbares Programm erzeugen.
Auf den Seiten 66 ff. haben Sie den Klassenbegriff bereits in abstrakter Form kennen gelernt. Nun wollen wir uns ansehen, wie man Klassen und Objekte in C++ formuliert.
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.
{
// ...
};
Nach der schließenden Klammer muss ein Semikolon stehen! Wenn Sie dieses vergessen (was ein sehr häufiger Anfängerfehler ist!), interpretiert der Compiler den nachfolgenden Code falsch und überschüttet Sie mit völlig unverständlichen Fehlermeldungen.
Kommen wir auf unser Beispiel von Seite 73 zurück. Als C++-Klasse
könnten wir unseren Button folgendermaßen deklarieren:
| 1: | class Button |
| 2: | { |
| 3: | private: |
| 4: | string text; |
| 5: | unsigned int hoehe; |
| 6: | unsigned int breite; |
| 7: | bool momentanAngeklickt; |
| 8: | ToggleTyp typ; |
| 9: | ToggleStatus zustand; |
| 10: |
| 11: | public: |
| 12: | Button(); |
| 13: | ~Button(); |
| 14: | void toggle(); |
| 15: | void click(); |
| 16: | void setStatus(ToggleStatus _status); |
| 17: | ToggleStatus getStatus(); |
| 18: | void setHoehe(unsigned int _neue_hoehe); |
| 19: | unsigned int getHoehe(); |
| 20: | ToggleTyp getTyp(); |
| 21: | void setGruppe(ButtonGroup& _gruppe); |
| 22: | // Weitere Methoden ... |
| 23: | }; |
string; dies ist eine Klasse für
Zeichenketten, die wir später noch öfter verwenden werden (ganz genau ab
Seite 508).
Für Umschalttyp (Toggle-Typ) und -status gibt es nur eine beschränkte Zahl möglicher Werte. Wir verwenden daher Aufzählungstypen, die wir folgendermaßen definieren können:
enum ToggleStatus { AN, AUS, UNVERAENDERT};
Diese Deklarationen können sowohl innerhalb als auch außerhalb der Klasse stehen. Sie müssen aber stets bekannt sein, wenn sie das erste Mal verwendet werden. Daher setzen wir sie in diesem Beispielprogramm unmittelbar über die Klasse.
Mit ToogleTyp ist folgende Funktionalität gemeint:
STANDARD ist ein einfacher Button, den man klickt, um eine
Aktion auszulösen.
TOGGLE kennzeichnet einen Button, der zwischen zwei
Zuständen umschalten kann, zum Beispiel "gedrückt" und "nicht
gedrückt".
TRISTATE-Button kann sogar drei Zustände annehmen. Die möglichen zwei beziehungsweise drei Zustände werden durch
ToggleStatus ausgedrückt.
In den Zeilen 12 und 13 stehen die Deklarationen des Konstruktors und des Destruktors. Dies sind Funktionen, die immer dann automatisch aufgerufen werden, wenn ein Objekt von dieser Klasse erzeugt beziehungsweise vernichtet wird. Aber dazu erfahren Sie in einem der nächsten Abschnitte mehr.
Wahrscheinlich ist Ihnen nach dieser Überschrift nicht ganz klar, was jetzt schon wieder auf Sie zukommt. Was ist denn der Unterschied zwischen Deklaration und Definition einer Klasse? Man macht ihn hauptsächlich an den Methoden fest:
Kurz gefasst bedeutet dies: Deklaration = Schnittstelle, Definition = Implementation.
Wenn Sie Ihr Programm aus mehreren Dateien zusammensetzen, hinterlegen Sie die Deklaration der Klasse am besten in einer Header-Datei und die Definition in einer zugehörigen Quelltextdatei. Auf diese Weise können Sie ein Stück Prozessabstraktion verwirklichen: Andere, die Ihre Klasse verwenden wollen, müssen nur die Header-Datei, also die Deklaration, kennen; die Definition kann im Verborgenen bleiben. (Wie wir später sehen werden, ist die Realität leider etwas komplizierter. Es ist nämlich auch erlaubt, die Definition einer Methode gleich inmitten der Klassendeklaration zu schreiben. Im Augenblick können Sie aber bei dieser Trennung bleiben.)
Wenn Sie eine Methode implementieren wollen, müssen Sie außer
deren Namen dem Compiler auch angeben, zu welcher Klasse sie
gehört. Dazu verwenden Sie den Bereichsoperator ::. Für die Methode
setHoehe() der Klasse Button könnte die Implementierung etwa
lauten:
unsigned int _neue_hoehe)
{
if (_neue_hoehe >1024)
return;
hoehe = _neue_hoehe;
return;
}
Die Methode hat alle Merkmale einer Funktion, das heißt Rückgabewert
(hier allerdings vom Typ void), Funktionsname (setHoehe), Parameter
(_neue_hoehe) und Funktionskörper (Genaueres ab Seite 169). Die
Zugehörigkeit zur Klasse Button drückt sich einzig durch den Klassennamen
mit dem Bereichsoperator aus.
Später (Seite 192) werden wir die Möglichkeiten der Positionen, an denen Sie Deklaration und Definition unterbringen können, noch genauer angeben.
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 ButtonGroup
{
// ...
};
int main(void)
{
Button startButton;
ButtonGroup alleButtons;
startButton.setStatus(AN);
std::cout << ``startButton ist `` << startButton.getHoehe()
<< `` Pixel hoch'' << std::endl;
startButton.setGruppe(alleButtons);
return 0;
}
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. (Es gibt auch noch eine dritte Ebene, protected, die wir aber erst später -
ab Seite 255 - behandeln wollen.)
Wenn Sie eine Zugriffsbeschränkung angeben, gilt diese für alle
nachfolgenden Elemente - so lange, bis eine andere Beschränkung kommt
oder die Klassendeklaration endet. Fehlt in einer Klasse die Festlegung der
Zugriffsbeschränkung ganz, so werden alle Elemente als private
behandelt.
In obigem Beispiel haben wir die beiden Zugriffsebenen in folgender Form verwendet:
{
private:
unsigned int hoehe;
ToggleStatus zustand;
// ... weitere
public:
Button();
~Button();
void setHoehe(unsigned int _neue_hoehe);
unsigned int getHoehe();
// ... weitere
};
(Dabei haben wir eine Regel angewendet, auf die ich auf Seite 188 noch
ausführlicher eingehen werde: Deklarieren Sie Attribute Ihrer Klassen nach
Möglichkeit als private und die Zugriffsmethoden als public. So haben Sie
immer unter Kontrolle, wie Benutzer eines Objektes die Attribute lesen
beziehungsweise verändern dürfen.)
Wenn Sie in Ihrem Programm etwa die Anweisung schreiben:
startButton.hoehe = 50;
so wird der Compiler diesen Zugriff mit der Fehlermeldung verwehren:
member ‘hoehe’ is a private member of class ‘Button’. Sie dürfen als
Benutzer der Klasse nur die Attribute lesen oder ändern und nur die
Methoden aufrufen, die als public deklariert sind.
Anders sieht es in den Methoden der Klasse selbst aus. Hier dürfen Sie stets auf alle Elemente zugreifen, natürlich auch auf die privaten.
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:
{
zustand = (_anderer.zustand == AN)? AUS : AN;
// ...
}
Hier soll das Verhalten von so genannten "Radio Buttons" programmiert werden.
Nur einer der Buttons kann gleichzeitig gedrückt sein. Er fragt zur Änderung
seines Zustands den des anderen Buttons ab und schaltet sich auf AUS, wenn der
andere AN ist und umgekehrt. Wie selbstverständlich habe ich dabei auf das
private Attribut zustand des Objekts _anderer zugegriffen. Ist Ihnen
schon klar, wieso es sich dabei um einen Grenzfall handelt? Einerseits ist
zustand Attribut der Klasse, deren Methode changeRadioState() wir hier
gerade implementieren; daher sollten wir auch die volle Zugriffsmöglichkeit
haben. Andererseits geht es ja dabei um ein anderes Objekt als das,
dessen Methode ausgeführt wird; darf dieses denn freien Zugang dazu
haben?
Die Antwort ist: ja. Die Zugriffsbeschränkung gilt auf der Ebene der Klassen und nicht der Objekte. Wenn in einer Methode ein Objekt derselben Klasse auftaucht, so hat die Methode Zugang zu allen Elementen des Objekts, öffentliche wie private, zum Lesen wie zum Schreiben. Anders ließen sich viele Arten der komplexeren Zusammenarbeit zwischen Objekten kaum realisieren.
Wenn Sie einmal in die Situation geraten, dass Sie ohne die Zugriffsmöglichkeit auf private Elemente einer anderen Klasse nicht weiterkommen, deutet das meistens auf einen Designfehler hin. Allerdings gibt es in der Praxis durchaus Fälle, bei denen ein Verzicht auf den Zugriff einen sehr viel umständlicheren und damit meist langsameren Code mit sich bringen würde.
Zum Glück gibt es da in C++ das Schlüsselwort friend, das es
Ihnen erlaubt, die Zugriffsbeschränkungen zu umgehen. Damit teilen
Sie dem Compiler innerhalb der Klassendeklaration mit, welche
anderen Elemente vollen Zugriff auf die Klasse erhalten. Diese können
sein:
Im Englischen packt man diese Eigenschaft in ein leicht zweideutiges Wortspiel: "Only friends may touch your private things."
Zusätzlich zu den Klassenelementen deklarieren Sie also die Freunde, die dann so behandelt werden, als gehörten sie "zur Familie", das heißt zur Klasse. Ein Beispiel:
class B
{
void m(A _a);
};
class A
{
private:
int i;
public:
void init();
friend void B::m(A _a);
friend void f(A _a, int i);
friend class C;
};
Um diesen Code zu verstehen, müssen Sie zunächst einmal wissen, was eine
"Vorwärtsdeklaration" ist. Manchmal ist man in der unangenehmen
Situation, dass Klasse A die Klasse B verwendet und umgekehrt!
Welche man aber auch immer als Erstes deklariert, stets ist die
andere dabei unbekannt. Aus diesem Grund ist es in C++ möglich,
zunächst einmal nur class und den Klassennamen zu schreiben - ohne
die Klassenelemente. Dabei geben Sie dem Compiler zu verstehen:
Nachher gebe ich noch die ganze Klasse an, aber im Augenblick
reicht es, wenn du weißt, dass es eine Klasse dieses Namens geben
wird.
In unserem Beispiel wird zunächst A nur als Klassenname eingeführt,
dann B deklariert; dadurch kann die Methode m() von B bereits mit einem
Argument vom Typ A arbeiten. Anschließend folgt die vollständige
Deklaration von A, in der wir wiederum Teile von B hernehmen und sie als
friend deklarieren.
Die Methode B::m() erhält durch die Deklaration als friend von A das
Privileg, das Attribut i von A lesen und gegebenenfalls verändern zu
dürfen. Dieses Vorrecht räumen wir damit aber nur der Methode
B::m() ein! Andere Methoden von B (sofern vorhanden) haben kein
Zugriffsrecht auf A::i (und gegebenenfalls andere private Elemente von
A).
Beachten Sie, dass in diesem Beispiel nur die Variable i sowie die
Methode init() Elemente von A sind. Die Funktion f() ist global, das heißt
überall sichtbar. Zudem haben wir in einer erneuten Vorwärtsdeklaration
auf eine Klasse C hingewiesen, die an anderer Stelle deklariert werden
kann, die aber hier das volle Zugriffsrecht auf A erhalten hat.
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
Button::setHoehe().
.".
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 86) in C++. Beachten Sie dabei die
Zugriffsbeschränkungen und die erforderlichen Methoden.
public:
Zufallszahl(unsigned int _maxwert);
unsigned int gibZufallszahl();
private:
unsigned int maximalwert;
};
Zufallszahl z(10);
Zufallszahl y(999);
Welche der folgenden Programmzeilen sind korrekt? Begründen Sie Ihre Antworten.
std::cout << z.gibZufallszahl() << std::endl;
std::cout << y.gibZufallszahl(1000) << std::endl;
std::cout << z::gibZufallszahl() << std::endl;
y.maximalwert++;
class Pen {
private:
unsigned int width;
friend class Brush;
public:
Pen(unsigned int _width);
void setWidth(unsigned int _newWidth);
unsigned int getWidth();
};
class Brush {
private:
typedef enum BrushStyle { SOLID, DOTTED, SQUARED, LINES };
BrushStyle style;
public:
Brush(BrushStyle _style);
void setBrushStyle(BrushStyle _newStyle);
};
Welche Aussagen über diesen Code sind korrekt, welche nicht?
Brush erzeugen.
Pen hat eine Methode getWidth().
BrushStyle ist in der Klasse Pen auch
bekannt und kann benutzt werden.
Brush darf der Wert des Attributs
width eines Pen-Objekts verändert werden.
Pen p(5);
p.width = (_newStyle == SOLID)? 3 : 5;
// ...
}
Pen dürfen in ähnlicher Form auf das Attribut
style eines Brush-Objektes zugreifen.
Pen dürfen nur angelegt werden, wenn bereits
schon mindestens ein Objekt vom Typ Brush vorhanden
ist.
Größere Programme bestehen immer aus mehreren Quelltextdateien, die
von verschiedenen Programmierern bearbeitet werden. Wenn dann nach
einiger Zeit alle Teile zu einem Gesamtsystem zusammengefügt werden
sollen, stellt man nicht selten fest, dass einzelne Mitarbeiter für ihre
Konstanten, Funktionen oder Klassen dieselben Bezeichner verwendet
haben. Im begrenzten Rahmen des Subsystems, das der jeweilige
Entwickler zu verantworten hat, ist daran ja auch nichts auszusetzen. Mit
der theoretischen Unabhängigkeit der einzelnen Programmteile ist es aber
zu Ende, sobald alle zu einem Gesamtprogramm zusammengelinkt werden
sollen. Der einzige Ausweg war in solchen Fällen, den entsprechenden
Bezeichner in einem Modul umzubenennen - in der Hoffnung, dass dieser
Schritt keine Seiteneffekte nach sich zieht. Viele Entwicklungsleiter haben
zudem versucht, das Problem von vornherein dadurch zu vermeiden,
dass sie für alle Bezeichner Präfixe vorgeschrieben haben, die das
jeweilige Subsystem kennzeichnen sollten. Aber leider sind es ja nicht
die ¿¿großen¡¡ Datentypen mit der Geschäftslogik, die kollidieren,
sondern meistens die Hilfsgrößen (also etwa Konstanten wie OK oder
ERROR); und für diese wurden die Namenskonventionen nur selten
eingehalten.
Um dieses Problem zu überwinden, gibt es in C++ den Begriff des Namensraums (engl. namespace). So simpel die Syntax im Grunde ist, die Unterstützung durch die Compiler war doch lange Zeit rar. Erst in jüngster Zeit beherrschen alle namhaften Compiler dieses Sprachmerkmal - natürlich auch der GCC.
Stellen Sie sich einen Namensraum als benannten Block vor, ähnlich wie eine Struktur oder eine Klasse, nur dass man davon keine Instanzen bilden kann. Alle Bezeichner, die in diesem Block definiert werden, müssen von außen zusätzlich über den Namen des Namensraums angesprochen werden. Ihr Name allein genügt nicht mehr.
Als Beispiel betrachten wir folgende Situation: Zwei Programmierer, Max
und Moritz, haben jeweils eine Komponente zu unserem Softwaresystem
entwickelt. Dabei verwendet jeder von ihnen intern einen Algorithmus, der
eine double-Zahl erhält und eine andere desselben Typs zurückliefert. Sie
schreiben also jeweils eine Funktion algorithm(), die zu allem Überfluss
auch noch von einer Konstante EPS abhängt. Wenn wir nun die
Komponenten der beiden in unser Hauptprogramm integrieren, meldet der
Linker:
moritz.o(.text+0x0): multiple definition of
`algorithm(double)'
max.o(.text+0x0): first defined here
Um die beiden Funktionen für den Linker unterscheidbar zu machen, betten wir sie in einen Namensraum ein.
namespace Max
{
double algorithm(double _x);
extern const double EPS;
}
Das Schlüsselwort extern ist für Variablen und Konstanten übrigens so
etwas wie der Prototyp für Funktionen: Es gibt an, dass die so deklarierte
Variable zwar existiert, aber nicht hier, sondern an anderer Stelle definiert
ist. Wenn Sie Ihre Konstanten (oder globalen Variablen) gleich in der
Header-Datei definieren und diese in verschiedene Implementierungsdateien
einbinden, beschwert sich wieder der Linker über doppelte Definitionen.
Bei der Verwendung von Namensräume sollten Sie Folgendes beachten:
namespace-Deklaration ist kumulativ. Das bedeutet, wenn
Sie nach einer Deklaration im weiteren Verlauf oder einer anderen
Header-Datei eine weitere angeben, wird dadurch nicht die erste
ungültig, sondern die Elemente der zweiten Deklaration werden
in den bestehenden Namensraum aufgenommen.
stdDer C++-Standard schreibt einen vordefinierten Namensraum std für alle
Bestandteile der C++-Standardbibliothek vor (siehe Seite 401). In
diesem befinden sich alle Funktionen der C-Bibliothek, alle Klassen
der STL und so weiter. Ab Version 3.0 des GCC ist diese Vorgabe
verpflichtend, so dass für den Zugriff auf Bibliotheksfunktionen eine der
im nächsten Abschnitt angegebenen Vorgehensweisen erforderlich
ist!!!
Wenn Sie eine Funktion, eine Klasse oder einen anderen Bezeichner verwenden wollen, die in einem Namensraum deklariert sind, haben Sie zwei Möglichkeiten:
using-Direktive; dann sind die Namen im
gesamten aktuellen Gültigkeitsbereich bekannt.Diese zwei Varianten wollen wir uns jetzt genauer ansehen.
Hierbei setzen Sie den Namen des Namensraums vor den Bezeichner und
trennen die beiden durch den Bereichsoperator ::. Für unser Beispiel heißt
das:
#include ``max.h''
#include ``moritz.h''
int main()
{
double x = Max::algorithm(1.0);
// ...
Auf diese Weise können wir die Funktion algorithm() eindeutig
identifizieren.
Konsequenterweise gehört alles, was Sie nicht explizit in einen selbst definierten Namensraum gepackt haben, in den globalen Namensraum. Wollen Sie ausdrücklich betonen, dass Sie auf ein Element dieses globalen Namensraums Bezug nehmen möchten, so setzen Sie nur den Bereichsoperator davor, etwa:
usingSie können auch einen ganzen Namensraum bekannt machen, so dass Sie
ohne weitere Angaben darauf zugreifen können. Dazu geben Sie das
Schlüsselwort using gefolgt von namespace und dem Namen des Raums an.
Haben Sie sich beispielsweise für die Lösung unseres Entwicklers Moritz
entschieden, schreiben Sie zu Beginn Ihres Programms:
Anschließend werden alle Elemente dieses Namensraums so behandelt, als ob sie global verfügbar wären. Auf Konflikte weist Sie der Compiler gegebenenfalls hin.
#include ``max.h''
#include ``moritz.h''
int main()
{
using namespace Moritz;
double x = algorithm(1.0); // aus ``Moritz''
// ...
Die using-Anweisung gilt dabei für den aktuellen Block. Wenn Sie sie also
innerhalb einer Funktion verwenden, ist der Inhalt des Namensraums nur
in dieser Funktion global bekannt; in einer anderen nicht mehr. Um einen
Namensraum für eine ganze Implementierungsdatei verfügbar zu machen,
gibt man using auch oft außerhalb aller Funktionen an, zum Beispiel gleich
nach den #include-Anweisungen.
Wenn Ihnen der komplette Raum zu viel ist, erlaubt Ihnen using
genauso die Bekanntmachung einzelner Funktionen oder Konstanten. In
diesem Fall schreiben Sie dahinter den voll qualifizierten Namen, also mit
Raum und Bereichsoperator. Anschließend ist dieser dann global, das heißt
ohne Angabe des Namensraums, verfügbar.
{
using Moritz::EPS;
if (x < EPS) { // entspricht Moritz::EPS
// ...
Wenn sich Namensräume nicht überschneiden, kann man sie zu einem
einzigen zusammenfassen, um sie besser handhaben zu können. Dazu
macht man sie einfach global in einem neuen bekannt und übergibt
diesem damit den Bezug. Sind etwa Max und Moritz ohne identische
Bezeichner, können wir aus ihnen einen neuen Raum Algorithmen
machen.
namespace Algorithmen
{
using namespace Max;
using namespace Moritz;
}
Für diese Vorgehensweise ist ebenso eine selektive Aufnahme einzelner Bezeichner möglich.
Das Konzept der Namensräume geht noch etwas weiter. Wenn unsere
Programmierer ihrerseits wieder ihre einzelnen Komponenten in
Namensräume eingeteilt hätten, erhielten wir innerhalb des Raumes Max
einen weiteren, zum Beispiel Auxiliary. Der Zugriff erfolgt wieder auf die
oben beschriebene Weise:
if (x< Max::Auxiliary::EPS) {
// ...
Bei mehreren verschachtelten Namensräumen wird der Zugriff natürlich schnell unhandlich, da man vor lauter Namensräumen den eigentlichen Bezeichner kaum noch sieht. Es gibt daher die Möglichkeit, für solche Namensketten einen Alias zu vergeben, der die vollständige Schachtelungstiefe abdeckt. Selbst für einstufige Namensräume eignen sich Aliase, um sehr lange Namen abzukürzen.
// verschachtelte Namensraeume
namespace MAD = Max::Auxiliary::Debug;
Mit diesem Alias haben Sie nun unmittelbaren Zugriff auf den innersten Namensraum und können nicht nur viel Tipparbeit einsparen, sondern auch erheblich zur besseren Übersichtlichkeit Ihres Quelltextes beitragen.
In diesem Abschnitt gab es wieder einiges Neues:
x
= Max::algorithm(1.0);
using-Anweisung; dann sind die
Namen im gesamten aktuellen Gültigkeitsbereich bekannt.
Es lassen sich entweder vollständige Namensräume bekannt
machen (etwa using namespace Moritz;) oder auch nur
einige Elemente daraus (zum Beispiel using Moritz::EPS;).
namespace-Deklaration ist kumulativ, das heißt, eine weitere
Deklaration desselben Namensraums fügt lediglich neue Elemente
hinzu.
using-Anweisung in eine
Header-Datei zu schreiben?
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 z = x+y;
return z;
}
An diesem Codeausschnitt erkennen Sie alle Bestandteile einer Funktion:
int
add
( int x, int y)
{ und }
return z; (bei Rückgabetyp void nicht nötig) Zu all diesen Teilen ist natürlich noch einiges zu sagen. Vorher noch ein
typografischer Hinweis: Es hat sich eingebürgert, in Büchern zwei
Klammern hinter Bezeichner zu setzen, die sich auf eine Funktion beziehen,
zum Beispiel in Sätzen wie: "Mit add() erreichen Sie die Addition zweier
Ganzzahlen." Das sagt noch nichts über Art und Umfang der Argumentliste
aus, sondern soll Sie lediglich daran erinnern, dass es dabei um eine
Funktion und nicht um eine Variable geht. Ich will mich auch in diesem
Buch daran halten.
In C++ muss jede Funktion einen Typ für den Wert angeben, den sie
zurückliefert. Manchmal ist es aber auch gar nicht nötig oder sinnvoll, dass
eine Funktion überhaupt einen Rückgabewert hat. In diesem Fall geben Sie
als Typ void an.
Was macht man nun mit einem solchen Wert? Der Programmteil, der die Funktion aufruft, kann diesen an allen Stellen einsetzen, wo er sonst eine Variable oder Konstante angeben würde (in obiger Form allerdings nur dort, wo lediglich der Wert benötigt wird), also etwa:
{
int a = 5;
int b = 12;
int c = add(a,b);
std::cout << ``a = `` << a
<< ``, c = `` << c
<< ``, a+c = `` << add(a,c) << std::endl;
return 0;
}
Dieses Programm hat dann die Ausgabe:
Übrigens: Selbst wenn eine Funktion einen Wert zurückgibt, müssen Sie ihn nicht beachten. Sie dürfen auch schreiben:
int b = 12;
add(a,b);
auch wenn das hier keinen Sinn machen würde. Bei Funktionen mit
Rückgabetyp void ist das hingegen die übliche Form des Aufrufs. Allgemein
kommt es aber häufiger vor, dass Rückgabewerte ignoriert werden.
Beispielsweise geben viele Funktionen Statusinformationen darüber zurück,
wie gut (oder schlecht) sie ihre Aufgabe erfüllen konnten. Viele
Anwender solcher Funktionen interessieren sich nicht für den Status und
übergehen ihn. Das kann manchmal aber auch gefährlich werden, wenn
etwa aufgetretene Fehler aus diesem Grund zunächst unentdeckt
bleiben.
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 viel häufiger Funktionen (beziehungsweise Methoden) verwenden, die im Durchschnitt wesentlich kürzer sind als bei der prozeduralen Programmierung.
Innerhalb des Funktionskörpers können Sie die Funktionsparameter wie normale Variablen verwenden; zusätzlich können Sie natürlich auch noch lokale Variablen definieren. Außerdem ist es selbstverständlich erlaubt, aus einer Funktion wieder andere Funktionen aufzurufen. (Sie dürfen sogar die Funktion selbst wieder aufrufen; man spricht dann von einer rekursiven Funktion - aber das ist ein eigenes Thema.)
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.
Ist der Rückgabetyp void, muss am Ende der Funktion keine
return-Anweisung stehen (auch nicht das Schlüsselwort return), wie in
folgendem Beispiel:
{
std::cout << ``Das Ergebnis ist: `` << z << std::endl;
}
Es ist zwar nicht zwingend erforderlich, return auch in void-Funktionen zu
verwenden. Gleichwohl ist es dennoch zulässig. Ein solcher return-Befehl
bietet sich insbesondere dann an, wenn Sie zwischendrin die Funktion
verlassen möchten - etwa wenn eine bestimmte Bedingung erfüllt
ist:
{
if (status==0)
return;
// Tue etwas anderes
// ...
}
Wenn Sie allerdings bei Funktionen mit irgendeinem anderen
Rückgabetyp als void die return-Anweisung am Ende vergessen, meldet der
Compiler einen Fehler.
Bevor Sie eine Funktion verwenden können, müssen Sie dem Compiler
zunächst mitteilen, dass es eine Funktion dieses Namens gibt, wie viele und
welche Parameter sie hat und welchen Typ sie zurückliefert. Dies geschieht
mit einem so genannten Prototyp der Funktion. Der Prototyp sieht
genauso aus wie die Funktion selbst bis auf den Funktionskörper;
dieser fehlt und wird durch ein einfaches Semikolon ; ersetzt. (Es ist
sogar erlaubt, die Namen der Argumente wegzulassen und nur ihre
Typen anzugeben.) Analog zu den Klassen (siehe Seite 142) ist
also der Prototyp die Deklaration und die Funktion mit Körper die
Definition.
Folgendes Beispiel zeigt den Umgang mit Prototypen:
| 1: | #include <iostream> |
| 2: |
| 3: | int add(int x, int y); |
| 4: | void ausgabe( int z); |
| 5: |
| 6: | int main() |
| 7: | { |
| 8: | int a = 5; |
| 9: | int b = 12; |
| 10: | int c = add(a,b); |
| 11: | ausgabe(c); |
| 12: | ausgabe(add(a,c)); |
| 13: | return 0; |
| 14: | } |
| 15: |
| 16: | int add(int x, int y) |
| 17: | { |
| 18: | return (x+y); |
| 19: | } |
| 20: |
| 21: | void ausgabe( int z) |
| 22: | { |
| 23: | std::cout << "Ergebnis: " << z << std::endl; |
| 24: | } |
main() werden sie dann aufgerufen (Zeilen
10-12) und ab Zeile 17 definiert.
Da eine C++-Datei von oben nach unten kompiliert wird, sind im Prinzip Funktionen, die oben stehen, im weiteren Verlauf bekannt, so dass für diese keine Prototypen benötigt werden. Wenn das Programm aber weiter wächst, kann es jedoch schnell zu Problemen führen, wenn Sie sich ausschließlich auf die Reihenfolge verlassen. Ich rate Ihnen daher, für alle Ihre Funktionen Prototypen an den Beginn Ihrer Quelltextdatei zu setzen.
Wenn Ihr Programm aus mehreren Quelltextdateien besteht, müssen Sie die Prototypen in die Header-Dateien schreiben. Am besten verwenden Sie für jede Quelltextdatei eine eigene Header-Datei. Bei einem so kleinen Beispiel wie oben ist eine solche Trennung zwar nicht nötig; will man aber doch eine Aufteilung machen, könnte diese folgendermaßen aussehen:
| Datei | Zeilen |
| funktionen.h | 3-4 |
| funktionen.cc | 16-24 |
| haupt.cc | 1; 6-15 |
Auf eine Header-Datei können Sie verzichten, wenn die Quelltextdatei keine
Funktionen oder Klassen enthält, die Sie von anderen Dateien aus
verwenden wollen. Hier ist dies haupt.cc; dort befindet sich ja nur die
main()-Funktion, also der Hauptteil des Programms. Gegenüber dem
obigen Code müssen Sie bei der Implementierung in getrennten
Dateien dort noch einen #include-Befehl für die Header-Datei mit den
Funktionsprototypen einfügen, also:
int main()
{
// weiter wie bisher
Beim Aufruf des Compilers müssen Sie dann auch beide Quelltextdateien übersetzen; wie erwähnt können Sie das entweder auf einmal machen:
oder Sie übersetzen jede Datei einzeln und müssen bei Fehlern nur diese Übersetzung wiederholen:
% g++ -Wall -O -c funktionen.cc
% g++ haupt.o funktionen.o -o funktionen
Mit den ersten beiden Befehlen kompilieren Sie die jeweilige Quelltextdatei bis zur Objektdatei, mit dem letzten binden Sie beide zu einem ausführbaren Programm zusammen. (Näheres zu den Compiler-Optionen finden Sie ab Seite 120.)
Im Gegensatz zu anderen Programmiersprachen wird in C++ eine Funktion nicht nur anhand des Namens, sondern anhand des Namens und der Liste der Argumente identifiziert. (Beides zusammen nennt man gelegentlich auch Signatur der Funktion.) Damit erhalten Sie die Freiheit, zwei Funktionen gleichen Namens zu verwenden, die sich aber in ihren Argumenten unterscheiden. Man bezeichnet das auch als Überladung des Funktionsnamens.
Wir hatten oben (Seite 169) eine Funktion add() definiert, um zwei
Ganzzahlen zu addieren. Nun kann man natürlich auch Dezimalbrüche
addieren; das deckt unsere Funktion aber nicht ab. Wir definieren
also eine zweite Funktion mit demselben Namen (!), aber anderen
Argumenten:
{
return x+y;
}
Wenn Sie in Ihrem Programm die Funktion add() aufrufen, erkennt der
Compiler an den Typen der Argumente, welche der ihm bekannten
Funktionen er verwenden soll, beispielsweise:
std::cout << add( 1.5, 2.2); // double-Version
Beachten Sie aber, dass der Rückgabewert nicht zur Signatur gehört. Sie können also keine zwei Funktionen definieren, die zwar denselben Namen und dieselbe Argumentliste haben, jedoch über verschiedene Typen für den Rückgabewert verfügen, zum Beispiel:
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: | std::cout ¡¡ add(a,b); |
| 9: | std::cout ¡¡ add(c,d); |
| 10: | std::cout ¡¡ add(31,’a’); |
| 11: | std::cout ¡¡ add(3.1, c); |
| 12: | std::cout ¡¡ add(3.1, float(c)); |
| 13: | std::cout ¡¡ add(b,c); |
| 14: | return 0; |
| 15: | } |
(Zum besseren Verständnis können Sie auch nochmals die Regeln für die implizite Typumwandlung ab Seite 103 nachschlagen.) Sehen wir uns die Aufrufe also genauer an:
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 Ganzzahlwerten 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 200 ff.).
Eine besondere Funktion in C++ ist main(). Auch sie gibt es in allen
C++-Implementierungen in mindestens zwei überladenen Varianten:
int main( int argc, char* argv[]);
Bei der zweiten Version erhalten Sie als Parameter noch die Argumente, die
beim Programmaufruf in der Shell hinter dem Programmnamen angegeben
wurden. Dabei ist argc die Anzahl der Argumente in der Kommandozeile;
diese ist immer mindestens 1, denn als erstes Argument wird der
Programmname angesehen. In argv stehen dann die Argumente selbst, und
zwar in Form eines zweidimensionalen Feldes vom Typ char. Der
Umgang damit ist aus C++-Sicht etwas verzwickt, weshalb ich
bisher verzichtet habe, darauf einzugehen. Um die Argumente nur
auszugeben, benötigen Sie aber nicht viele Kenntnisse. Es genügt zu
wissen, dass Sie mit argv[i] das i-te Argument in der Kommandozeile
erhalten.
Folgendes Programm gibt die übergebenen Argumente auf den Bildschirm aus:
| 1: | // commandline.cc |
| 2: |
| 3: | #include<iostream> |
| 4: |
| 5: | int main(int argc, char* argv[]) |
| 6: | { |
| 7: | int i; |
| 8: |
| 9: | for(i=0; i<argc; i++) |
| 10: | std::cout << i << ". Argument: " << argv[i] << std::endl; |
| 11: |
| 12: | return 0; |
| 13: | } |
for-Schleife in Zeile 9, zu deren Syntax wir später noch genauer
kommen (Seite 308), durchläuft einfach alle Argumente vom ersten bis
zum letzten, wie in argc angegeben. Der Aufruf dieses Programms
mit
führt zur Ausgabe:
1. Argument: erstes
2. Argument: zweites
3. Argument: drittes
Auf diese Weise können Sie Ihre Programme durch beim Aufruf übergebene Werte steuern oder initialisieren. Sie kennen dieses Verhalten ja bereits von vielen Unix-Tools, unter anderen vom Compiler selbst.
Wenn Sie sehr umfangreiche Listen von Kommandozeilenargumenten
verarbeiten wollen, empfehle ich Ihnen den Einsatz der Bibliotheksroutine
getopt(), die ich Ihnen auf Seite 298 vorstellen werde.
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:
void printNumber(float number, int fillsize=0, char fillchar=' ');
// Implementation
void printNumber(float number, int fillsize, char fillchar)
{
int i;
for(i=0; i<fillsize; i++)
std::cout << fillchar;
std::cout << number;
}
Wie Sie sehen, erscheinen die Vorgabewerte nur in der Deklaration und nicht in der Definition der Funktion. (Wenn es keinen Prototyp gibt, dürfen die Vorgabewerte aber auch in der Implementation stehen.)
Die Funktion printNumber() kann mit einem, mit zwei oder mit drei
Parametern aufgerufen werden, jedoch nur in der angegebenen Reihenfolge.
Somit sind folgende drei Aufrufe identisch:
printNumber(3.5, 0);
printNumber(3.5, 0, ' ');
Wenn Sie aber vor der 3.5 noch 10 Nullen einfügen möchten, können Sie die
Funktion auch so aufrufen:
Zwei Aspekte müssen Sie beim Arbeiten mit Vorgabewerten aber unbedingt beachten:
fillchar setzen wollen,
auch fillsize setzen. Es ist Ihnen sicher jetzt schon klar, dass Vorgabewerte für Parameter
zuweilen recht praktisch sein können. Hat etwa eine Funktion (oder eine
Methode!) in den allermeisten Fällen dieselben Aufrufparameter, so
machen Sie diese einfach zu den Vorgabewerten und sparen sich eine
Menge Schreibarbeit. Aber auch aus Sicht der etwas abstrakteren
Objektorientierung sind Vorgabewerte willkommen - denn sie sorgen für
Erweiterbarkeit. Stellen Sie sich vor, Sie haben bereits ein größeres
Programm geschrieben, das an vielen verschiedenen Stellen eine
Funktion updateDisplay() aufruft. Nun kommen Sie in die Situation, ein
weiteres Argument zu der Funktion hinzufügen zu müssen. In einer
Programmiersprache ohne die Möglichkeit von Vorgabewerten müssten Sie
nun an allen Stellen, an denen updateDisplay() aufgerufen wird, Ihr
Programm ändern und einen Standardwert hinzufügen. In C++
kann dagegen sämtlicher Code beibehalten werden, wenn Sie diesen
Standardwert als Vorgabe verwenden. Neu zu schreibende Aufrufe können
indessen den zusätzlichen Parameter nutzen.
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& h = height; // Referenz auf height
h += 10;
std::cout << ``Höhe: `` << height++ << std::endl;
std::cout << ``Höhe: `` << h++ << std::endl;
std::cout << ``Höhe: `` << height << std::endl;
Wie lautet die Ausgabe, die dieser Codeausschnitt hervorruft? Überlegen
Sie vor dem Weiterlesen erst selbst, was die Anweisungen bedeuten
könnten. In diesem Beispiel sind height und h zwei Namen für einen
Speicherplatz. Wenn man also h um 10 erhöht, ist auch height anschließend
entsprechend größer. Die Ausgabe ist also:
Höhe: 91
Höhe: 92
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.
Bei Klassen ist die Sache etwas komplizierter. Attribute einer Klasse dürfen auch Referenzen sein. Es ist aber nicht zulässig, diese innerhalb der Klassendeklaration zu initialisieren. Das muss im Konstruktor geschehen, und zwar in der Initialisierungsliste vor dem Methodenkörper. Doch dies werden wir später noch genauer besprechen.
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:
{
int temp = x;
x = y;
y = temp;
}
Sie soll dazu dienen, die Werte, die in den beiden Variablen x und y
gespeichert sind, gegeneinander auszutauschen. Erfüllt sie diesen Zweck
Ihrer Meinung nach?
Die Antwort ist leider nein. Es findet zwar ein Austausch statt, aber das Hauptprogramm erfährt davon nichts. Die Variablen, die dieser Funktion übergeben werden, haben hinterher immer noch dieselben Inhalte wie vorher.
Ganz anders sieht die Lage aus, wenn die Parameter als Referenzen übergeben werden (auf Englisch "call by reference" genannt). Dann sind diese nämlich nicht im Unterprogramm eigenständige Speicherstellen, die nach dem Rücksprung wieder freigegeben werden, sondern Aliasnamen für die Variablen im Hauptprogramm. Jede Änderung, die das Unterprogramm, also die Funktion, an den Parametern vornimmt, wirkt unmittelbar auf die im Hauptprogramm definierten und der Funktion übergebenen Variablen. Um das zu erreichen, müssen wir obige Funktion nur ein klein wenig abändern:
{
int temp = x;
x = y;
y = temp;
}
Wie Sie sehen, haben die Parameter nun nicht mehr den Typ int, sondern
int&. Ein kleines Hauptprogramm zeigt Ihnen, dass der Aufruf der beiden
Funktionen swap_values() und swap_refs() zwar identisch, die Wirkung
aber völlig unterschiedlich ist.
{
int big = 10;
int small = 20;
std::cout << "big1: "<< big
<< " small1: "<< small << std::endl;
swap_values (big, small);
std::cout << "big2: "<< big
<< " small2: "<< small << std::endl;
swap_refs (big, small);
std::cout << "big3: "<< big
<< " small3: "<< small << std::endl;
return 0;
}
Welche Ausgabe erwarten Sie von diesem Programm? Wenn Sie jetzt noch unsicher sind, probieren Sie es am besten gleich aus.
Wenn Sie einer Funktion ein Objekt per Wert übergeben (im Fachenglisch "call by value" genannt), wird eine Kopie des Objekts angelegt, mit dem die Funktion dann arbeitet. Bei größeren Objekten kann der Aufwand für das Erstellen einer solchen Kopie beträchtlich sein. Daher wäre es besser, diese als Referenz zu übergeben, denn dabei bekommt die Funktion nur den Verweis auf die Speicherstelle, was sehr schnell und einfach abgewickelt werden kann. Das Problem dabei ist jedoch, dass damit der Funktion das Recht eingeräumt wird, das Objekt in jeder beliebigen Form zu verändern, was ja meist nicht beabsichtigt ist. Gerade in größeren Programmen stellt dies eine Fehlerquelle dar, die Sie vermeiden sollten.
Um dieses Problem in den Griff zu bekommen, verwenden Sie das
Schlüsselwort const. Damit können Sie jede Variable und jede Referenz als
konstant deklarieren, so dass sie nicht mehr verändert werden kann. Wenn
Sie also in Ihrem Programm einen bestimmten Wert mehrfach benötigen,
sollten Sie ihn an zentraler Stelle (zum Beispiel global in der Hauptdatei
des Projekts oder in einer entsprechenden Header-Datei) als Konstante
definieren und später dann nur noch diesen Namen für den entsprechenden
Wert verwenden. Ein Vorteil dabei ist, dass Sie später, wenn Sie das
Programm mit einem anderen Wert übersetzen wollen, diesen nur einmal
ändern müssen und ihn im Allgemeinen auch sofort finden. Zum
Beispiel:
Wie bei den Aufzählungstypen (Seite 106) empfehle ich Ihnen auch bei allen anderen Konstanten die Schreibung in Großbuchstaben.
Doch zurück: Was hilft uns das Schlüsselwort const beim Problem der
Parameterübergabe? Sie bauen die Funktion so, dass sie nicht eine einfache
Referenz als Argument erhält, sondern eine konstante Referenz. Dann hat
sie beispielsweise folgende Form:
Auf diese Weise haben Sie
a beim
Aufruf der Funktion kopiert werden muss, und gleichzeitig
Am Aufruf sieht man nicht, ob da ein Objekt, eine Referenz oder eine konstante Referenz übergeben wird. Wenn Sie allerdings wissen, dass Ihr Objekt innerhalb der Funktion als Konstante gilt (und nur dort!), können Sie auch sicher sein, dass es sich nach dem Aufruf noch in genau demselben Zustand befindet wie vorher.
Vielleicht ist Ihnen aufgefallen, dass ich die ganze Zeit von "Objekten" gesprochen habe, während vorher immer allgemeiner von "Variablen" die Rede war. Der Grund dafür ist, dass konstante Referenzen im Allgemeinen nur für Objekte benutzt werden. Sie können natürlich auch konstante Referenzen auf Standardtypen verwenden; da diese stets kaum größer als die Referenz sind (zum Teil sogar kleiner), haben Sie dadurch jedoch keinen Gewinn. Ich empfehle Ihnen daher, für Standardtypen den Wert und für Instanzen von Klassen eine konstante Referenz als Übergabe an eine Funktion einzusetzen.
Obwohl es eigentlich nichts mit Referenzen zu tun hat, wollen wir uns kurz noch mit dem Thema "Konstante Methoden" beschäftigen. Nachdem Sie jetzt wissen, wie Sie einer Funktion ein konstantes Objekt übergeben, ist Ihnen vermutlich noch nicht so ganz klar, was Sie in der Funktion mit dem Objekt machen dürfen und was nicht. Und wenn es Ihnen schon nicht klar ist, werden Sie es dem Compiler sicher ebenfalls kaum begreiflich machen können.
Anweisungen innerhalb des Funktionskörpers dürfen ja ohnehin nur auf die öffentlichen Attribute und Methoden des Objekts zugreifen (wenn wir den Sonderfall der Freunde außer Acht lassen). Die Attribute dürfen bei einem konstanten Objekt nur gelesen, aber nicht überschrieben werden. Was ist aber mit den Methoden? Wenn Sie eine Methode eines Objekts aufrufen, wissen Sie nicht, was darin genau passiert. Möglicherweise finden darin Modifikationen an den Attributen statt - genau das, was wir mit dem konstanten Objekt verhindern wollten.
Ich kann Sie beruhigen: Solche Methoden darf die Funktion nicht
aufrufen, das verhindert bereits der Compiler. Welche aber dann? Hier
kommt das Schlüsselwort const abermals zum Einsatz. Damit eine Methode
für ein konstantes Objekt aufgerufen werden darf, müssen Sie diese in der
Klasse als const deklarieren. Dieses const muss nach der Argumentliste und
vor dem Semikolon beziehungsweise dem Methodenkörper stehen. (Wenn
Deklaration und Definition einer Methode getrennt sind, muss const bei
beiden stehen, sonst fasst der Compiler die Definition als eine andere
Signatur auf.)
Sehen wir uns das an einem Beispiel an:
{
public:
void setHoehe(unsigned int _neue_hoehe);
unsigned int getHoehe() const;
// ...
};
Hier haben wir zwei Methoden deklariert; von diesen ist lediglich
getHoehe() auch bei konstanten Objekten erlaubt.
{
std::cout << ``Höhe: ``
<< bt.getHoehe(); // erlaubt!
bt.setHoehe(20); // verboten!
// ...
}
Auf diese Weise können Sie als Autor der Klasse festlegen, welche
Methoden ein Benutzer aufrufen darf, der über eine konstante Referenz auf
ein Objekt davon verfügt. Falls Sie in die Versuchung geraten, mit const
einen "Etikettenschwindel" zu betreiben, also eine Methode so zu
deklarieren, obwohl Sie in ihr Veränderungen an den Attributen vornehmen
(etwa reine Lesezugriffe, die aber als Nebeneffekte andere Attribute
aktualisieren), verweigert sich Ihnen der Compiler. Denn const heißt auch
konstant.
Ebenso wenig sollten Sie mit const halbherzig umgehen, das heißt, es
nach Belieben mal anfügen und mal weglassen. Fragen Sie sich besser bei
jeder Methode, ob diese eine Veränderung des Objekts bewirken soll oder
nicht; im letzteren Fall deklarieren Sie sie immer als const. Somit werden
Sie übrigens auch daran gehindert, die stets fehlerträchtigen Nebeneffekte
zu programmieren - schon mancher Benutzer einer Klasse hat sich
gewundert, warum sein Objekt nach einem Lesezugriff plötzlich verändert
war.
Sie erinnern sich sicherlich, dass ich Ihnen die Datenabstraktion als wichtiges Prinzip der objektorientierten Programmierung vorgestellt habe (auf Seite 82). Schlagen Sie am besten nochmals Abbildung 2.6 nach: Dort wird gezeigt, dass im Idealfall andere Objekte nur über Nachrichten auf die Daten eines Objekts zugreifen können. Nachrichten werden aber als Methoden des Empfängers implementiert.
Was heißt das nun konkret hinsichtlich der Implementierung? Für die Datenelemente Ihrer Klasse empfiehlt sich folgende Vorgehensweise:
private und nur Methoden
als public.
get beziehungsweise set, gefolgt vom Namen des
Attributs.
const.In unserer Beispielklasse haben Sie bereits solche Methoden gesehen:
{
private:
unsigned int hoehe;
// ...
public:
void setHoehe(unsigned int _neue_hoehe);
unsigned int getHoehe() const;
// ...
};
Diese Art des Zugriffs über Methoden hat den Vorteil, dass Sie die volle
Kontrolle über die Möglichkeiten der Modifikation von Objekten Ihrer
Klasse behalten. In vielen Fällen wird die get-Methode nichts weiter tun,
als den Wert des Datenelements zurückzuliefern, ebenso wie die
set-Methode meist lediglich das Attribut auf den gewünschten Wert
setzt.
{
return hoehe;
}
Aber manchmal (und dieser Fall kann erst im Laufe der Entwicklung des
Programms erkennbar werden!), müssen diese Methoden noch zusätzliche
Aufgaben erfüllen, etwa die Plausibilität der zu setzenden Werte
überprüfen. Das haben wir ja auch in unserer Implementation von
setHoehe() gemacht: Wir setzen den Wert nicht, wenn die verlangte
Hoehe über den Bildschirmdimensionen von maximal 1024 Pixel
liegt.
{
if (_neue_hoehe >1024)
return;
hoehe = _neue_hoehe;
return;
}
Üblicherweise ignorieren die Programmierer bei Schreibmethoden den
Rückgabewert; daher verwendet man meist void. Um dann auf gravierende
Fehler hinzuweisen, gibt es noch verschiedene Mittel und Wege, denen Sie
im Laufe des Buches begegnen werden.
Was passiert eigentlich bei einem Funktions- oder Methodenaufruf? Grob gesprochen wird der Programmfluss angehalten, die Parameter in einen speziellen Bereich (genannt Stack) kopiert, der Ausgangspunkt vermerkt und an den Beginn der Funktion gesprungen. Nach deren Abarbeitung werden ihre lokalen Variablen wieder gelöscht, der Stack aufgeräumt und das Hauptprogramm fortgesetzt. Bei sehr kurzen Funktionen, wie sie bei der objektorientierten Programmierung häufiger als anderswo auftauchen (zum Beispiel als Zugriffsmethoden), dauert die Verwaltung des Aufrufs länger als die Abarbeitung selbst.
Abhilfe bieten da so genannte inline-Funktionen. Durch die Angabe
dieses Schlüsselworts vor einer Funktion erreichen Sie, dass für die
Funktion kein Aufruf im oben genannten Sinn erzeugt wird, sondern der
Funktionskörper direkt an die Stelle eingefügt wird (eben "in line", in der
Zeile). Es ist dann also keine "echte" Funktion mehr, sondern nur ein Stück
Code im Hauptprogramm.
Erfahrenen C-Programmierern kommt jetzt sicher der Gedanke
an Makros. Auch diese bewirken eine Ersetzung (schon durch den
Präprozessor); allerdings können bei diesen keine Datentypen für die
Argumente definiert werden. Bei inline-Funktionen bleibt die Prüfung des
Argumenttyps sowie der sonstigen Syntax genauso erhalten, als ob es sich
um eine tatsächliche Funktion handeln würde.
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:
{
return x+y;
}
Beim Aufruf sehen Sie nicht, ob Sie es mit einer "echten" oder einer
inline-Funktion zu tun haben.
{
int p=3, q=5;
// ...
int r = add(p,q);
Die letzte Zeile wird so kompiliert, als ob da stünde:
Einige weitere Aspekte sollten Sie in diesem Zusammenhang beachten:
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 144) haben wir nur davon gesprochen, wie man
Methoden außerhalb der Klassendeklaration definiert. Mit dem jetzigen
Wissen können Sie sich auch für eine andere Stelle entscheiden, an der Sie
Ihre Methode implementieren.
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 den oben beschriebenen Gründen
innerhalb der Header-Datei implementieren. Diese Möglichkeit
wählt man oft bei etwas längeren Methoden (mit mehr als
zwei Zeilen), die aber noch sehr einfach sind, also beispielsweise
keine Schleifen enthalten, oder wenn man auf Klassenelemente
zugreifen muss, die erst später deklariert werden.
Sehen wir uns die drei Varianten in der Praxis an. Die erste Möglichkeit führt etwa zu folgendem Code:
class Button
{
private:
unsigned int hoehe;
ToggleStatus zustand;
// ...
public:
unsigned int getHoehe() const
{
return hoehe;
}
ToggleStatus getStatus() const
{
return zustand;
}
// ...
};
Nicht vergessen: Obgleich nicht ausdrücklich inline dabeisteht, sind es diese
Methoden trotzdem.
Die zweite Variante hat beispielsweise folgende Form:
class Button
{
public:
unsigned int getHoehe() const;
ToggleStatus getStatus() const;
// ...
};
inline unsigned int Button::getHoehe() const
{
return hoehe;
}
inline ToggleStatus Button::getStatus() const
{
return zustand;
}
Dies alles steht noch in der Header-Datei, z.B. in Button.h.
Anders sieht dies beim dritten Fall aus. Hier findet sich im Include-File nur noch:
class Button
{
public:
unsigned int getHoehe() const;
ToggleStatus getStatus() const;
// ...
};
Die Implementation steht in einer eigenen Datei:
unsigned int Button::getHoehe() const
{
return hoehe;
}
ToggleStatus Button::getStatus() const
{
return zustand;
}
// ...
Bei Zugriffsmethoden wie diesen würde man sicher eine der beiden ersten Varianten bevorzugen, wobei die allererste für mich hier übersichtlicher ist - weiß man dabei doch gleich, wie und wo die Methode implementiert ist. Manche Programmierer bevorzugen generell die zweite Möglichkeit, denn dabei bleibt die Klassendeklaration in kompakter Form und wird nicht durch Funktionskörper "zerrissen". Es ist zuweilen sogar nötig, diese Variante zu wählen, etwa wenn die Methode auf andere Klassenelemente zugreifen muss, die erst später deklariert werden.
In diesem Abschnitt haben Sie eine Menge gelernt. Die wichtigsten Aspekte waren:
return-Anweisung.
void definiert.
void als Rückgabetyp hat, muss sie nicht
zwingend durch eine return-Anweisung abgeschlossen werden. Sie
kann jedoch ein return enthalten, etwa um ein Verlassen der
Funktion an einer bestimmten Stelle zu erreichen.
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.
inline-Funktionen.
void displayText()
displayText()
{
std::cout << "Hello World!" << std::endl;
}
int main()
{
tuDochWas();
return 0;
}
void tuDochWas()
{
std::cout << Ich tu jetzt was! << std::endl;
}
Geben Sie diese Programme in Ihren Rechner ein und versuchen Sie, sie zu übersetzen. Welche Fehlermeldung(en) gibt der Compiler aus? Korrigieren Sie die Fehler und starten Sie die Übersetzung erneut.
int-Variablen schreiben. Ihr erster Versuch
lautet:
{
int& temp = wert2;
wert2 = wert1;
wert1 = temp;
}
Beim Test stellen Sie fest, dass diese Variante nicht funktioniert. Warum?

Schreiben Sie ein C++-Programm, das zwei natürliche Zahlen als untere und obere Grenze von der Kommandozeile einliest und dann alle perfekten Zahlen in diesem Intervall ausgibt. Wie viele perfekte Zahlen können Sie finden? (Hinweis: Es gibt mehr als 4!)
Lager, die folgende öffentliche Methoden
enthalten soll:
void entnehmen(int stueckzahl);
int getBestand();
Diese Methoden sollen mit einem privaten Attribut lagerbestand
arbeiten, welches vom Typ int sein soll.
lagerbestand anders deklariert werden?
Lager testen können.
mit ganzzahligen a
und b, wobei b
0. Addition und Multiplikation sind wie bekannt
definiert. Schreiben Sie eine Klasse Rational, die eine rationale
Zahl repräsentiert. Diese soll über die Rechenfunktionen add,
sub, mult und div verfügen, wobei der Bruch stets vollständig
gekürzt gespeichert werden soll. Die Klasse habe folgende
Struktur:
{
private:
long zaehler, nenner;
long ggT (long _a,long _b);
public:
Rational();
void add(Rational& _a, Rational& _b);
void sub(Rational& _a, Rational& _b);
void mult(Rational& _a, Rational& _b);
void div(Rational& _a, Rational& _b);
void set(long _z, long _n);
void kuerzen();
void ausgabe();
};
Die Methode ggT(), die den größten gemeinsamen Teiler (ggT) nach
dem euklidischen Algorithmus (siehe auch Seite 303) ermittelt, ist
dabei wie folgt definiert:
{
while(_y)
{
long r = _x % _y;
_x = _y;
_y = r;
}
return (_x);
}
Schreiben Sie die restlichen Methoden und testen Sie Ihre Klasse in einer Beispielanwendung.
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. Ein einfaches, schon klassisches Beispiel für einen selbst definierten
Datentyp ist ein Datum, das einen Tag im Kalender repräsentiert. Ich will
in diesem Abschnitt eine solche Klasse mit Ihnen aufbauen, um die
verschiedenen Arten von Konstruktoren deutlich zu machen. Die
Ausgangsversion sei:
{
private:
unsigned int t, m, j;
public:
void setze(unsigned int _t,
unsigned int _m, unsigned int _j);
void setzeAufHeute();
void ausgeben() const;
};
Hier haben wir wieder die Klassenstruktur vor uns, die wir im vorletzten
Abschnitt kennen gelernt haben. Die Attribute t, m, j (für Tag, Monat und
Jahr) sind private Datenelemente, können also nur in Methoden
desselben Objekts gelesen und verändert werden. Um sie von außen
zu setzen, gibt es die öffentliche Methode setze(), die das Datum
auf die angegebenen Werte setzt, sowie setzeAufHeute(), die das
Datumsobjekt mit dem aktuellen Systemdatum initialisiert. Die
dritte öffentliche Methode ausgeben() ist als konstant deklariert
(const hinter den runden Klammern); das deutet darauf hin, dass
darin nur Attribute gelesen, jedoch nicht verändert werden - was ja
auch von einer Ausgabefunktion erwartet werden darf. Konstante
Methoden dürfen auch für konstante Referenzen des Objekts aufgerufen
werden.
Je nach Form und Aufgabe unterscheidet man mehrere Arten von Konstruktoren:
Im Folgenden wollen wir die verschiedenen Konstruktoren genauer betrachten.
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:
{
private:
unsigned int t, m, j;
public:
Datum(); // Standardkonstruktor
void setze(unsigned int _t,
unsigned int _m, unsigned int _j);
void setzeAufHeute();
void ausgeben() const;
};
Bei einem Datumsobjekt ist es sehr unschön, wenn sich die Attribute in einem undefinierten Zustand befinden, dies gilt auch für den Zeitpunkt unmittelbar nach seiner Erzeugung. Selbst ein "0.0.0" ist als Datum unbrauchbar. Ganz praktisch könnte es dagegen sein, wenn das Objekt gleich mit dem heutigen Datum vorbelegt wird. Genau das erledigt unser Konstruktor:
{
setzeAufHeute();
}
Konstruktoren werden oft als inline-Methoden implementiert (siehe Seite
192), also direkt in die Klassendeklaration geschrieben. Dann ist auch dem
Leser der Header-Datei sofort klar, mit was ein Objekt durch den
Konstruktor eigentlich initialisiert wird. So wie der Konstruktor hier
steht, würde man ihn am ehesten in die Header-Datei nach der
Klassendeklaration schreiben.
Ich sprach oben davon, dass eine der Aufgaben des Konstruktors sei, Speicherplatz für die Attribute bereitzustellen. Vielleicht fragen Sie sich nun, warum Sie davon in dieser Methode nichts sehen. Ganz einfach: Es passiert automatisch. Ebenso wie bei lokalen Variablen - unabhängig davon, ob ihr Typ einfach oder eine Klasse ist - kann das System selbstständig ermitteln, wie viel Speicher die Attribute benötigen, und diesen entsprechend reservieren. Das ändert sich erst, wenn Sie selbst Felder oder Objekte dynamisch anlegen wollen, das heißt selbst die Anweisung zur Speicherreservierung geben. Auch das kann innerhalb eines Konstruktors geschehen. Doch dazu kommen wir später noch.
Jetzt sehen wir uns an, wo der Konstruktor aufgerufen wird. In einem Hauptprogramm steht beispielsweise:
{
Datum heute; // Impliziter Konstruktoraufruf
heute.ausgeben();
//...
An der Stelle, wo ein Objekt vom Typ Datum erzeugt wird, wird auch
dessen Konstruktor aufgerufen.
Natürlich können Sie auch mit der Klasse arbeiten und Objekte davon erzeugen, wenn diese keinen Konstruktor hat. Allerdings sind dann die Attribute in einem unbestimmten Zustand - genauso wie Variablen, die Sie nur deklariert, aber nicht initialisiert haben. Ich möchte Ihnen daher empfehlen, bei allen Ihren Objekten zumindest einen Standardkonstruktor zu definieren.
Achten Sie aber darauf, im Konstruktor wirklich nur die nötigsten Initialisierungen vorzunehmen und ihn möglichst klein zu halten. Da er beispielsweise keinen Rückgabewert hat, werden mögliche Fehler nicht ohne weiteres vom Programm bemerkt. Richten Sie im Zweifelsfall lieber eine zusätzliche Methode ein, in der dann kritische Initialisierungen vorgenommen werden können.
Die Einfügung des Konstruktoraufrufs klappt übrigens auch bei Verschachtelungen, das heißt bei Klassen, deren Attribute wieder Objekte anderer Klassen sind. Wenn Sie beispielsweise eine Klasse haben wie:
{
private:
Datum datum;
//...
public:
Tabelleneintrag();
// ...
};
so wird der Konstruktor von Datum automatisch bei der Abarbeitung des
Konstruktors von Tabelleneintrag aufgerufen, und zwar noch vor der ersten
Anweisung im Funktionskörper von Tabelleneintrag::Tabelleneintrag().
Allgemeine Konstruktoren 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.
{
public:
Datum();
Datum(unsigned int _t,
unsigned int _m, unsigned int _j);
Datum(unsigned int _t);
// ...
};
Mit dem ersten neuen Konstruktor wollen wir einfach alle drei Datumsteile
setzen; da wir für diese Aufgabe schon die Methode setze() vorgesehen
haben, können wir sie auch gleich verwenden:
unsigned int _m, unsigned int _j)
{
setze( _t, _m, _j);
}
Die Unterstriche sind eine persönliche Konvention von mir; auf diese Weise
kann ich Parameter der Methode besser von Attributen unterscheiden.
Manche Programmierer machen es aber auch genau umgekehrt und
beginnen die Namen aller Attribute mit einem Unterstrich oder einem m_
von "member". Wie Sie es damit halten, überlasse ich ganz Ihnen; nur sollten
Sie mit Ihrer Konvention konsequent sein und diese überall durchhalten.
Denn der Zweck solcher Regeln ist ja letztlich, den Code für sich und
andere möglichst lesbar zu halten.
Die gerade gezeigte Vorgehensweise empfiehlt sich übrigens auch allgemein: Wenn Sie zulassen möchten, dass Ihr Objekt entweder von einem allgemeinen Konstruktor oder einem expliziten Methodenaufruf initialisiert wird, verwenden Sie einfach im Konstruktor auch diese Methode. Auf diese Weise haben Sie den Code konsistent gehalten und die unnötige Verdopplung von Anweisungen vermieden.
Unser zweiter neuer Konstruktor soll nur den Tag als Parameter haben, wobei Monat und Jahr dieselben wie für den heutigen Tag sein sollen.
{
setzeAufHeute();
t = _t;
}
Was hier noch fehlt, ist eine Plausibilitätsprüfung, denn nicht jede Zahlenkombination ist ja ein gültiges Datum. Vielleicht überlegen Sie sich einmal selbst, wie eine solche aussehen könnte.
Auch allgemeine Konstruktoren werden automatisch bei der Definition eines Objekts aufgerufen. Die Argumente geben Sie dabei in Klammern hinter dem Objektnamen an.
{
// Standardkonstruktor
Datum heute;
// Konstruktor mit 3 Argumenten
Datum ostern(4,4,1999);
// Konstruktor mit 1 Argument
Datum gestern(20);
// ...
}
Der Compiler sucht dabei anhand der Anzahl und Typen der Parameter, welchen Konstruktor er aufrufen muss.
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.
{
private:
Datum& dat;
Date(); // privater Konstruktor
public:
Date(Datum& _dat) :
dat(_dat) {}
void set(unsigned int _t,
unsigned int _m, unsigned int _j)
{ dat.setze(_t, _m, _j); }
void setToToday()
{ dat.setzeAufHeute(); }
void print() const
{ dat.ausgeben(); }
};
Hier kann keine Instanz erzeugt werden, ohne dass die Elementvariable dat
initialisiert wird. Da die anderen Methoden auch direkt auf sie zugreifen, ist diese
Restriktion sinnvoll und notwendig. Will ein Benutzer der Klasse mittels der
Zeile
ein Objekt vom Typ Date erzeugen, so erhält er vom Compiler die schlichte
Meldung: "Date::Date() is private within this context."
Beachten Sie außerdem, dass Referenzen als Attribute immer initialisiert
werden müssen. Selbst wenn der Standardkonstruktor privat ist, müssen Sie in
dessen Implementierung das Attribut dat initialisieren - und das bereits in der
Initialisierungsliste, zu der wir jetzt kommen.
Beim Aufruf eines Konstruktors wird noch vor dem Betreten des Methodenkörpers Speicherplatz für die Datenelemente bereitgestellt. Anschließend wird erst der Code in diesem Block abgearbeitet; darin haben wir bisher die Datenelemente mit Werten versehen.
Auf diese Weise wird aber doppelt auf die Attribute zugegriffen: einmal bei der Erzeugung und einmal bei der Zuweisung. C++ bietet die Möglichkeit, beides in einem Schritt zusammen zu erledigen. Dazu verwendet man eine so genannte Initialisierungsliste. Dabei geben Sie hinter der schließenden runden Klammer der Argumentliste, getrennt durch einen Doppelpunkt, die Attribute an, wobei die zu verwendenden Werte in runden Klammern dahinter stehen. Bei unserem Beispiel hat das etwa folgende Form:
unsigned int _m, unsigned int _j)
: t(_t), m(_m), j(_j)
{
}
Dabei sind die Größen t, m und j die Attribute (deklariert im
private-Abschnitt der Klasse Datum), die wir durch diesen Konstruktor mit
den übergebenen Werten belegen. Eine Prüfung, ob die angegebenen
Werte in einem gültigen Bereich liegen, findet dabei allerdings nicht
statt.
Wie Sie sehen, kann in diesem Fall der Methodenkörper sogar ganz leer sein. Beim Aufruf des Konstruktors wird zuerst die Initialisierungsliste abgearbeitet, und zwar in der Reihenfolge, wie die Attribute in der Klasse deklariert sind - und nicht, wie sie in der Liste stehen! Generell sollten Sie daher die Datenelemente in Ihren Initialisierungslisten stets in derselben Reihenfolge aufführen wie in der Klasse. (Besonders kritisch wird dies aber erst, wenn ein Datenelement auf ein anderes angewiesen ist.)
Eine Klasse kann nicht nur Variablen und Funktionen enthalten, von denen
bisher immer die Rede war, sondern auch Konstanten und Referenzen.
Bei diesen stellt sich das Problem der Initialisierung noch wesentlich
drängender. Denn normalerweise müssen Konstanten und Referenzen gleich
bei ihrer Deklaration initialisiert werden, etwa (bei bekannter Klasse
Kreis):
// ...
Kreis aktuellerKreis;
Kreis& k = aktuellerKreis;
Sind sie aber Bestandteil einer Klasse, so ist es nicht erlaubt, sie gleich bei ihrer Deklaration zu initialisieren, denn Zuweisungen innerhalb der Klassendeklaration sind in C++ nicht möglich. Folgender Code ist also nicht gültig:
class Kreisliste
{
private:
const unsigned int MAX_SIZE = 1000; // nicht gültig!
Kreis aktuellerKreis;
Kreis& k = aktuellerKreis; // nicht gültig!
// ...
};
Diese Klasse müssen Sie zunächst ohne die Initialisierung der Konstanten und
Referenzen, die sie enthält, deklarieren. (Dann brauchen Sie eigentlich das
Attribut aktuellerKreis gar nicht mehr, oder?)
{
private:
const unsigned int MAX_SIZE;
Kreis& k;
// ...
public:
Kreisliste(Kreis& _k);
// ...
};
Hier kommt jetzt die Initialisierungsliste ins Spiel. Denn Konstanten und Referenzen müssen durch eine solche Liste initialisiert werden - ein anderer Weg ist nicht erlaubt! Beim Konstruktor schreiben Sie dann beispielsweise:
: MAX_SIZE(1000), k(_k) // eventuell weitere
{
// ...
}
Eine solche Initialisierung müssen Sie übrigens bei jedem Konstruktor dieser Klasse angeben.
Auf diese Weise erhalten Sie die Möglichkeit, zusätzliche Arten von Elementen in Ihre Klassen aufzunehmen.
: t(_datum.t), m(_datum.m), j(_datum.j)
{
std::cout << ``Hier ist der Kopierkonstruktor!''
<< std::endl;
}
Sie können diesen Konstruktor aufrufen wie andere auch, etwa:
Datum d2(d1);
Es gibt aber noch eine - anfangs eventuell verwirrende - Möglichkeit, den Kopierkonstruktor aufzurufen, nämlich in Form einer Zuweisung:
Datum d2 = d1;
Und obwohl hier das Gleichheitszeichen an eine Zuweisung denken lässt, ist es keine Zuweisung, sondern eine Initialisierung! Wenn Sie aber bedenken, dass auch Variablen von Standardtypen, die Sie gleich bei der Deklaration initialisieren, mit dem Gleichheitszeichen ihre Werte erhalten, ist diese Syntax nur konsequent. Trotzdem ist die hier vorgenommene strenge Unterscheidung zwischen Zuweisung und Initialisierung gerade für den Anfänger oft schwierig. Daher nochmals ein Beispiel:
Button stopButton;
// Zuweisung, kein Kopierkonstruktor:
stopButton = startButton;
// Initialisierung mit Kopierkonstruktor:
Button pauseButton =startButton;
Sie können sich also merken: Der Kopierkonstruktor tritt nur dann in Aktion, wenn ein neues Objekt erzeugt wird, aber nicht, wenn ein bereits bestehendes einen neuen Wert erhält.
Eine Klasse braucht jedoch mindestens immer dann einen Kopierkonstruktor, wenn sie dynamisch angelegten Speicherplatz verwaltet. Denn der automatisch erzeugte kopiert im Allgemeinen nur den Anfangspunkt dieses Speichers, so dass das kopierte Objekt einen Verweis auf denselben Speicherbereich erhält wie das ursprüngliche. Und das will man beim Kopieren ja vermeiden! Daher gilt auch die Faustregel: Immer wenn für eine Klasse ein Kopierkonstruktor erforderlich ist, braucht sie auch einen Zuweisungsoperator. Mehr Details dazu erkläre ich Ihnen aber später, wenn wir den Umgang mit dynamisch angelegtem Speicher genauer unter die Lupe nehmen (ab Seite 380).
Manchmal ist es sinnvoll, wenn eine Funktion oder eine Methode ein Objekt zurückliefert. Dies kann einmal in Form einer Referenz geschehen, etwa:
Das Problem dabei ist jedoch, dass sich diese Referenz auf ein Objekt beziehen muss, das auch nach Ende der Funktion noch gültig ist. Wenn also eine Methode eine Referenz auf ein Attribut zurückliefert, ist das völlig in Ordnung (solange dadurch nicht der Zugriffsschutz zu sehr unterwandert wird ...). Wenn aber die Funktion ein Objekt zurückliefern will, das sie lokal erst erzeugt hat, ist die Referenz völlig untauglich.
Dabei ist es doch auch möglich, ein ganzes Objekt als Ergebnis einer Funktion oder Methode zurückzugeben. Sie müssen lediglich seine Klasse als Rückgabetyp der Funktion angeben. Sehen wir uns folgendes Beispiel an, um zu erkennen, was dabei so alles vor sich geht:
| 1: | Datum Log::getLogDate() |
| 2: | { |
| 3: | Datum d(29,2,2000); // zu Testzwecken fix |
| 4: | return d; // Objekt wird zurückgegeben |
| 5: | } |
| 6: |
| 7: | int main() |
| 8: | { |
| 9: | Log logObject; |
| 10: | Datum date = logObject.getLogDate(); |
| 11: | // ... |
| 12: | } |
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:
Dann können Sie die Typumwandlung in die gewünschten Bahnen lenken und einfach schreiben:
{
Datum d1(``29.02.2000'');
Wenn Ihnen auf Anhieb klar ist, dass diese Syntax funktioniert, sehen wir uns gleich folgende Anweisung an:
// ...
d2 = ``03.03.2001'';
Das ist eigentlich eine Kurzschreibweise für zwei getrennte Schritte.
Zunächst wird ein temporäres Objekt unter Zuhilfenahme des
Typumwandlungskonstruktors erzeugt und anschließend wird dieses dem
bestehenden, nämlich d2, zugewiesen.
Noch deutlicher wird das an folgendem Beispiel: Nehmen Sie an, Sie haben eine Funktion, um eine Log-Nachricht auszugeben. Diese erhält neben der Nachricht selbst noch das Datum als Argument:
Dann dürfen Sie dank des Typumwandlungskonstruktors schreiben:
{