3
Objektorientierung 1

Dieses Kapitel behandelt die folgenden Themen:

Image       Klasse und Objekt

Image       Konstruktion und Initialisierung von Objekten

Image       Destruktoren

Image       Der Weg von der Problemstellung zu Klassen und Objekten

Image       Gegenseitige Abhängigkeit von Klassen

Es wird zwischen der Beschreibung von Objekten und den Objekten selbst unterschieden. Die Beschreibung besteht aus Attributen und Operationen. Attribute bestehen aus einem Namen und Angaben zum Datenformat der Attributwerte. Eine Kontobeschreibung könnte so aussehen:

Attribute:

Inhaber: Zeichenkette (String)

IBAN: Zeichenkette (String)

Betrag: Zahl

Dispo-Zinssatz in %: Zahl

Operationen:

überweisen(Ziel-Kontonummer, Betrag)

abheben(Betrag)

einzahlen(Betrag)

Die IBAN ist die internationale Bankkontonummer. Eine Aufforderung ist nichts anderes als der Aufruf einer Operation, die auch Methode genannt wird. Ein tatsächliches Konto k1 enthält konkrete Daten, also Attributwerte, deren Format mit dem der Beschreibung übereinstimmen muss. Die Tabelle zeigt k1 und ein weiteres Konto k2. Beide Konten haben dieselben Attribute, aber verschiedene Werte für die Attribute.

Tabelle 3.1: Attribute und Werte zweier Konten

Attribut

Wert für Konto k1

Wert für Konto k2

Inhaber

Roberts, Julia

Depp, Johnny

IBAN

DE27998765000012573001

DE25123456000054688490

Betrag

-200,30 €

1222,88 €

Dispo-Zinssatz

13,75 %

13,75 %

Julia will Johnny 1000 € überweisen. Bei einer Überweisung wird die IBAN angegeben. Dem Objekt k1 wird also der Auftrag mit den benötigten Daten mitgeteilt: k1.überweisen( "DE25123456000054688490", 1000.00). »DE25123456000054688490« ist die (hier fiktive) IBAN. Die letzten zehn Stellen der IBAN enthalten die Kontonummer, die von der empfangenden Bank extrahiert wird, um den Empfänger zu ermitteln. Anderes Beispiel: Johnny will 22 € abheben. Die Aufforderung wird an k2 gesendet: k2.abheben(22).

Es scheint natürlich etwas merkwürdig, wenn einem Konto ein Auftrag gegeben wird. In der objektorientierten Programmierung werden Objekte als Handelnde aufgefasst, die auf Anforderung selbstständig einen Auftrag ausführen, entfernt vergleichbar einem Sachbearbeiter in einer Firma, der seine eigenen Daten verwaltet und mit anderen Sachbearbeitern kommuniziert, um eine Aufgabe zu lösen.

Image       Die Beschreibung eines tatsächlichen Objekts gibt seine innere Datenstruktur und die möglichen Operationen oder Methoden an, die auf die inneren Daten anwendbar sind.

Image       Zu einer Beschreibung kann es kein, ein oder beliebig viele Objekte geben.

Die Beschreibung eines Objekts in der objektorientierten Programmierung heißt Klasse. Die tatsächlichen Objekte heißen auch Instanzen einer Klasse.

Auf die inneren Daten eines Objekts nur mithilfe der vordefinierten Methoden zuzugreifen, dient der Sicherheit der Daten und ist ein allgemein anerkanntes Prinzip. Das Prinzip wird Datenabstraktion oder Geheimnisprinzip genannt. Wer die Methoden konstruiert hat, weiß ja, wie die Daten konsistent (das heißt widerspruchsfrei) bleiben und welche Aktivitäten mit Datenänderungen verbunden sein müssen. Zum Beispiel muss eine Erhöhung des Kontostands mit einer Gutschrift oder einer Einzahlung verbunden sein. Außerdem wird jeder Buchungsvorgang protokolliert. Es darf nicht möglich sein, dass jemand anderes die Methoden umgeht und direkt und ohne Protokoll seinen eigenen Kontostand erhöht. Wenn Sie die Unterlagen eines Kollegen haben möchten, greifen Sie auch nicht einfach in seinen Schreibtisch, sondern Sie bitten ihn darum, dass er sie Ihnen gibt.

Die hier verwendete Definition einer Klasse als Beschreibung der Eigenschaften einer Menge von Objekten wird im Folgenden beibehalten. Gelegentlich findet man in der Literatur andere Definitionen, auf die nicht weiter eingegangen wird.

Die Programmierung, wie Sie sie bis jetzt kennengelernt haben, erlaubt es durchaus, unzulässige Funktionen auf Daten anzuwenden, zum Beispiel eine Buchung auf ein Konto unter Umgehung von Kontrollmechanismen vorzunehmen. Hier lernen Sie den Begriff Abstrakter Datentyp kennen, der das vermeiden soll und der die Grundlage für den Begriff Klasse bildet. Eine Klasse Ort dient als durchgängiges Beispiel. Objekte müssen vor ihrer Verwendung erzeugt werden und sinnvolle Daten enthalten. Die Erzeugung und Initialisierung ist die Aufgabe der verschiedenen Konstruktoren. Ein vollständiges Beispiel zum Rechnen mit rationalen Zahlen demonstriert das bis dahin Gelernte. Destruktoren sind zum Aufräumen da – sie zerstören nicht mehr benötigte Objekte.

3.1 Datentyp und Objekt

Ein Datentyp beschreibt den Wertebereich seiner Objekte und die damit verbundenen Operationen. Ein Objekt in C++ belegt Speicherplatz und hat einen (Daten-)Typ. In der Zeile int x = 1; ist int der Typ, definiert durch den Wertebereich, den ein Objekt dieses Typs annehmen kann, und die damit möglichen Operationen wie + oder -. x erfüllt das Kriterium für Objekte: x hat einen Typ und es belegt Speicher.

Ein Objekt hat einen inneren Zustand (= der Wert), der durch mit diesem Objekt mögliche Operationen verändert werden kann.

Eine Deklaration gibt einem Objekt einen Namen. Es besitzt eine Identität, die es von einem beliebigen anderen Objekt unterscheidet, selbst wenn beide genau gleiche Daten enthalten. Die Identität zu einem bestimmten Zeitpunkt wird in C++ durch eine eindeutige Position im Speicher nachgebildet. Zwei Objekte können niemals dieselbe Adresse haben, es sei denn, ein Objekt ist im anderen enthalten. In einem Programm braucht man Hilfsmittel, um auf ein Objekt zuzugreifen. Der Name des Objekts kann dazu dienen, auch eine Referenz, ein Zeiger oder eine Funktion. Der Begriff Identität für nicht zugreifbare Objekte (temporäre Objekte in manchen Fällen) ist sinnlos.

3.2 Abstrakter Datentyp

Ein Abstrakter Datentyp ist ein Verbund von Daten zusammen mit der Definition aller zulässigen Operationen, die auf diese Daten zugreifen. Um unzulässige Zugriffe und damit auch versehentliche Fehler zu vermeiden, werden die Daten gekapselt, indem zusammengehörige Daten und Funktionen zusammengefasst werden.

Der Sinn liegt darin, den richtigen Gebrauch der Daten sicherzustellen. Die tatsächliche Implementierung der Datenstrukturen ist nach außen nicht sichtbar. Deshalb werden Datenstrukturen eines Abstrakten Datentyps ausschließlich durch die mit diesen Daten möglichen Operationen beschrieben. Von der internen Darstellung wird abstrahiert. Die Daten werden, unterstützt durch die Programmiersprache, so gekapselt, dass ein Zugriff ausschließlich über eine Funktion (oder einen Operator) geschieht. Abbildung 3.1 verdeutlicht das Prinzip. Die Funktion als öffentliche Schnittstelle gehört zur Datenkapsel und ist der einzige Zugang. Eine direkte Änderung der Daten unter Umgehung der Funktion ist unmöglich.

Abbildung 3.1: Abstrakter Datentyp

Die konkrete Implementierung einer Funktion, das heißt, wie sie im Einzelnen auf die Daten wirkt, spielt in diesem Zusammenhang keine Rolle. Zur Verwendung eines Abstrakten Datentyps reicht die Spezifikation der Zugriffsoperation aus.

Abstrakter Datentyp = Datentypen + Funktionen

Unter »Funktionen« sind auch Operationen zu verstehen. So wird etwa das +-Zeichen nicht Funktion, sondern Operator genannt. Ferner sind logisch zusammengehörige Dinge an einem Ort konzentriert. Die zusammen mit Daten gekapselten Funktionen heißen im Folgenden Elementfunktionen (englisch member functions). Der Zugriff auf die Daten soll nur über Elementfunktionen (auch Methoden genannt) möglich sein. Die Begriffe Elementfunktion und Methode werden synonym verwendet, obwohl der aus der Programmiersprache Smalltalk stammende Begriff Methode eigentlich besser auf die später zu besprechenden virtuellen Funktionen von C++ passt. Zum Vergleich sei der Zugriff auf zwei Koordinaten x und y eines Punkts auf verschiedene Arten gezeigt.

Image       Unstrukturierter Zugriff

int x{100}; // x-Koordinate eines Punkts int y{0}; // y-Koordinate eines Punkts

Der Nachteil besteht darin, dass an jeder Stelle im Programm x und y ungeschützt verändert werden können. Eine Erweiterung der Funktionalität, zum Beispiel Protokollierung der Änderungen in einer Datei, muss an jeder Stelle nachgetragen werden

Image       Strukturierter Zugriff
Hier werden die Daten in eine Struktur gepackt. Der verändernde Zugriff geschieht über eine Funktion:

struct Punkt { int x, y; } einPunkt; void aendern(Punkt& p, int x, int y) { // ... Plausibilitätsprüfung p.x = x; p.y = y; // ... Protokollierung } // Aufruf aendern(einPunkt, 10, 800);

Der Vorteil besteht in der Gruppierung logisch zusammengehöriger Daten und der Änderung über eine Funktion. Eine Erweiterung der Funktionalität ist leicht realisierbar. Der Nachteil besteht darin, dass auch hier ein zusätzlicher Zugriff an der Funktion vorbei möglich ist, zum Beispiel

einPunkt.x = -3000;
3.3 Klassen

Eine Klasse ist ein Spezialfall eines Datentyps, genauer: ein Abstrakter Datentyp, der in einer Programmiersprache formuliert ist. Eine Klasse ist damit auch die Beschreibung von Objekten oder, anders ausgedrückt, die Abstraktion von ähnlichen Eigenschaften und Verhaltensweisen ähnlicher Objekte. Eine Klasse definiert die Struktur aller nach ihrem Muster erzeugten Objekte. In C++ dient die Klasse dazu, dem Compiler die Beschreibung von später zu definierenden Objekten mitzuteilen.

Auch ein Objekt einer Klasse hat einen inneren Zustand, der durch andere Objekte oder Elemente der in der Programmiersprache vorgegebenen Datentypen dargestellt wird. Der Zustand kann sich durch Aktivitäten des Objekts ändern, das heißt, durch Operationen, die auf Objektdaten ausgeführt werden. Die von jedem benutzbaren Operationen bilden die öffentliche Schnittstelle, in einer C++-Klasse gekennzeichnet durch das Schlüsselwort public.

Ein Objekt ist die konkrete Ausprägung einer Klasse, es belegt im Gegensatz zur Klasse Bereiche im Speicher, die mit definierten Bitmustern belegt sind, die Werte von Objekteigenschaften darstellen. So kann »15 €« der Wert der Eigenschaft Kontostand sein. Oder »rot« ist der Wert der Eigenschaft Farbe. Eigenschaften werden auch Attribute genannt, die bestimmte Werte besitzen. Der Zustand eines Objekts, der durch die zu den Attributen gehörenden Werte beschrieben wird, ist im Sinne des Abstrakten Datentyps nicht direkt änderbar, sondern nur über öffentliche Methoden. Die Attribute werden deshalb normalerweise mit dem Schlüsselwort private deklariert. Eine in C++ formulierte Klasse hat eine typische Gestalt:

class Klassenname { public: Typ elementfunktion1(); Typ elementfunktion2(); // und weitere ... private: Typ attribut1; Typ attribut2; // und weitere ... };

Die reine Deklaration einer Elementfunktion wird auch Prototyp genannt. Eine Elementfunktion ist im Gegensatz zu den in Kapitel 2 beschriebenen Funktionen nicht frei, sondern an die Klasse gebunden. Zunächst betrachten wir ein einfaches Beispiel eines abstrakten Datentyps, nämlich einen Ort, der aus den X- und Y-Koordinaten besteht, sofern man sich auf zwei Dimensionen beschränkt. An Operationen seien vorgesehen:

getX()

X-Koordinate zurückgeben

getY()

Y-Koordinate zurückgeben

aendern()

X- und Y-Koordinaten ändern

Die Klasse wird Ort1 genannt, weil sie noch Änderungen unterliegt. Sie ist wie folgt definiert:

Daten und Methoden werden in einer Klasse zusammengefasst. Nach dem Schlüsselwort class folgen der Klassenname und ein durch geschweifte Klammern begrenzter Block mit den Deklarationen von Daten (Attributen) und Funktionen (Methoden). Der Gültigkeitsbereich von Klassenelementen ist lokal zu der Klasse: Die Daten sind privat, das heißt, sie sind von außen nicht zugreifbar, sodass Anweisungen wie xKoordinate = 13; außerhalb der Klasse unmöglich sind. Das Schlüsselwort private könnte entfallen, weil die Voreinstellung private ist, wenn die privaten Daten vor dem public-Bereich kommen. Alles nach dem Schlüsselwort public Deklarierte ist öffentlich zugänglich. Die Funktionen heißen Elementfunktionen oder Methoden, von denen es private und öffentliche geben kann. Eine Datenänderung kann ausschließlich über eine geeignete Methode erfolgen, zum Beispiel aendern(). Die x- und die y-Koordinate haben anfangs undefinierte Werte. C++ initialisiert sie nicht automatisch mit 0. Das Schlüsselwort const oben drückt aus, dass die damit ausgezeichneten Elementfunktionen das Objekt nicht verändern können und deswegen auch auf konstante Objekte anwendbar sind. getX() und getY() geben nur Zahlen zurück, ohne die privaten Daten zu verändern, während aendern() die vorherigen Werte der privaten Variablen überschreibt.

Objekterzeugung und -benutzung

Wie wird nun ein Ort in einem Programm benutzt? Zunächst muss ein Objekt erzeugt werden, denn die Klassendeklaration beschreibt nur ein Objekt. Wir nehmen an, dass die Deklaration der Klasse in einer Datei Ort1.h vorliegt. Der Mechanismus der Objekterzeugung ist die übliche Variablendefinition, wobei die Klasse den Datentyp darstellt:

Zur Aufteilung von Schnittstelle und Implementation werden Klassen wie Strukturen (struct) behandelt, wie in Abschnitt 2.4.3 (Seite 148 f.) beschrieben. Die Implementation der Funktionen sei in der Datei Ort1.cpp abgelegt (Beschreibung siehe unten), die eine Zeile #include"Ort1.h" enthält. Bei der Variablendefinition wird für ein Objekt Speicherplatz bereitgestellt. Dazu wird beim Ablauf des Programms an der Stelle der Definition eine besondere Klassenfunktion aufgerufen, die Konstruktor genannt wird. Oben wird ein Objekt namens einOrt1 definiert, das durch den impliziten Aufruf eines Konstruktors erzeugt wird und Speicherplatz für die Daten belegt. Der Programmcode der Funktionen ist selbstverständlich nur einmal für alle erzeugten Ort1-Objekte vorhanden.

Der Konstruktor wird vom System automatisch bereitgestellt, kann aber auch selbst definiert werden. Zunächst hat das Objekt keinen definierten Zustand. Der Zustand wird im Beispiel erstmals mit dem Aufruf einOrt1.aendern(100, 200); definiert. Entsprechend der Notation Objektname.Anweisung(gegebenenfalls Daten) erhält das Objekt den Auftrag, die Koordinaten auf (100, 200) zu ändern. Wie das Objekt diese Dienstleistung erbringt, ist in der Methode aendern() versteckt.

Im informationstechnischen Sprachgebrauch heißen Dinge (Objekte, Rechner etc.), die eine Dienstleistung erbringen, Server. Die Dienstleistung wird für einen Client (deutsch: Klient, Kunde) erbracht, der selbst ein Rechner oder Objekt sein kann. Im obigen Programm ist das main()-Programm der Client und das Objekt einOrt1 ist der Server, der beauftragt wird, eine Koordinatenänderung durchzuführen. Die Datei Ort1.cpp enthält die Implementation der Methoden:

Durch den Klassennamen und den Bereichsoperator :: wird die Methode als zur Klasse Ort1 gehörig gekennzeichnet. Daher darf innerhalb der Funktion auf die privaten Daten zugegriffen werden. Das lauffähige Programm entsteht durch Übersetzen von Ort1.cpp und main.cpp und Linken der durch die Übersetzung entstandenen Dateien ort1.o und main.o.

g++ -c Ort1.cpp -o Ort1.o g++ -c main.cpp -o main.o g++ Ort1.o main.o -o programm.exe

Die Datei Ort1.h wird während der Übersetzung der *.cpp-Dateien gelesen. Übersetzen und Linken kann mit dem Befehl make erfolgen, falls keine Entwicklungsumgebung benutzt wird. Voraussetzung ist eine Datei namens makefile, die die Übersetzung steuert (mehr dazu siehe Kapitel 19). In den von [CPP] herunterladbaren Beispielen ist dies stets der Fall.

3.3.1 const-Objekte und Methoden

Objekte können wie einfache Variablen als konstant deklariert werden. Um jegliche Änderungen zu vermeiden, dürfen Methoden von konstanten Objekten nur dann aufgerufen werden (ausgenommen Konstruktoren und Destruktoren), wenn sie als konstante Elementfunktionen deklariert und definiert sind. Nehmen wir an, wir würden Bezug nehmend auf das vorangegangene Beispiel ein konstantes Ort-Objekt co definieren. Nehmen wir ferner an, wir hätten nach der Deklaration der Methode getX() das Wort const vergessen. Der Aufruf getX(); für das konstante Ort-Objekt riefe dann eine Fehlermeldung des Compilers hervor.

const Ort1 co = einOrt1; // konstantes Objekt mit den Koordinaten von einOrt1 von oben int x = co.getX(); // Fehlermeldung, falls die const-Deklaration bei getX() vergessen wurde

Wenn aber in der Deklaration (und Definition!) das Schlüsselwort const angegeben wird, erhält die damit ausgezeichnete Methode das Privileg, für konstante und nicht konstante Objekte aufgerufen werden zu können. Ein konstantes Objekt kann nicht durch const- oder andere Funktionen geändert werden, selbst dann nicht, wenn es per Referenz übergeben wird. Von dieser Regel gibt es zwei Ausnahmen:

1.      Die const-Eigenschaft kann durch eine explizite Typumwandlung umgangen werden (englisch casting the const away). Nicht empfehlenswert!

2.      In einer Klasse können Variablen mit dem Schlüsselwort mutable versehen werden. Es ist erlaubt, diese Attribute durch eine Methode zu ändern, auch wenn das Objekt konstant ist, zu dem das Attribut gehört. Der Sinn des Schlüsselworts liegt darin, dass eine Implementation sicherstellen will, dass einerseits Objekte nicht geändert werden, andererseits ein schneller Zugriff möglich sein soll, was die Änderung interner Verwaltungsinformationen erfordert. Beispielsweise könnte man sich in einer konstanten Liste die zuletzt benutzte Position für einen Zugriff merken (Stichwort »cache«), um beim nächsten Zugriff schnell zu sein.

Ein const-Qualifizierer wird auch beim Überladen von Methoden ausgewertet, er gehört also zum Typ der Funktion. Man kann zwei Methoden mit gleicher Parameterliste, aber verschiedener Wirkung schreiben, die sich nur durch const unterscheiden. Davon wird in den folgenden Kapiteln noch Gebrauch gemacht.

Image

Const Correctness

Der in der C++-Literatur gebrauchte englische Begriff const correctness bedeutet, mit dem Schlüsselwort const Objekte gegen Änderungen zu schützen. Der Compiler würde dann eine Änderung als Fehler anzeigen. Daher die Empfehlung: Verwenden Sie const oder constexpr möglichst früh und so oft wie sinnvoll möglich.

Image

3.3.2 inline-Elementfunktionen

In Abschnitt 2.6 (Seite 155) haben Sie den inline-Mechanismus für kleine Funktionen kennengelernt. Die Programmierung mit Klassen verwendet typischerweise viele kleine Funktionen, sodass mit inline ein erheblicher Effizienzgewinn möglich ist. Dabei gibt es zwei Möglichkeiten:

Image       Deklaration und Definition innerhalb der Klasse. Beispiel:

Diese Variante hat den Vorteil der Kürze. Sie ist besonders für kleine Klassen geeignet.

Image       Deklaration und Definition innerhalb der Header-Datei:

Diese Variante hat den Vorteil, dass die Implementierung der Methoden nicht direkt innerhalb der Klassendeklaration sichtbar ist. Dadurch kann eine Klassendeklaration übersichtlicher werden.

Image

auto bei inline-Funktionen

Der Rückgabetyp von inline-Funktionen, bei denen die Deklaration gleichzeitig die Definition ist, kann auto sein. D.h., in den Listings 3.4 und 3.5 könnte der Rückgabetyp von getX() bzw. getY() auto sein.

Image

3.4 Initialisierung und Konstruktoren

Objekte können während der Definition initialisiert, also mit sinnvollen Anfangswerten versehen werden. Es wurde bereits erwähnt, dass eine besondere Elementfunktion namens Konstruktor diese Arbeit neben der Bereitstellung von Speicherplatz übernimmt. Die Syntax von Konstruktoren ähnelt der von Funktionen, nur dass der Klassenname den Funktionsnamen ersetzt. Außerdem haben Konstruktoren keinen Return-Typ, auch nicht void. Im Sinn der Abstrakten Datentypen sind die Methoden einer Klasse für sinnvolle und konsistente Änderungen eines Objekts zuständig. Konstruktoren haben die Verantwortung, dass sich ein Objekt vom Augenblick der Entstehung an in einem korrekten Zustand befindet. Nun gibt es mehrere Arten von Initialisierungen, unterschieden durch verschiedene Arten von Konstruktoren, die im Folgenden beschrieben werden.

3.4.1 Standardkonstruktor

Falls kein Konstruktor angegeben wird, wird einer vom System automatisch erzeugt (implizite Konstruktordeklaration). Ohne die direkte Initialisierung der Attribute (siehe nächster Abschnitt) enthalten dann die Daten des Objekts unbestimmte Werte. Dieser vordefinierte Konstruktor (englisch default constructor) kann auch selbst geschrieben werden, um Attribute bei Anlage des Objekts zu initialisieren. Der Standardkonstruktor hat keine Parameter. Für eine Klasse X wird er einfach mit X(); deklariert. Bei der Definition wird der Bezugsrahmen der Klasse angegeben, um dem Compiler mitzuteilen, dass es sich um eine Methode der Klasse X handelt, also X::X() {...}.

In unserem Beispiel soll erreicht werden, dass bei Erzeugung eines Ort1-Objekts sofort gültige Koordinaten eingetragen werden. Die Klassendeklaration in Ort1.h muss im public-Teil um die Zeile Ort1(); ergänzt werden:

Initialisierung mit konstruktorinterner Liste

In der Implementationsdatei Ort1.cpp wird der Standardkonstruktor definiert mit der Wirkung, dass jedes neue Ort1-Objekt sofort mit den Nullpunktkoordinaten initialisiert wird:

Warum ist diese Alternative nicht empfehlenswert? Beim Aufruf des Konstruktors wird zunächst, noch vor Betreten des Blocks, Speicherplatz für die Elemente xKoordinate und yKoordinate beschafft. Dann wird im zweiten Schritt der Programmcode innerhalb der geschweiften Klammern ausgeführt und die Aktualparameter werden zugewiesen. Mit der Initialisierungsliste (englisch member initializer list), werden beide Schritte zu einem zusammengefasst. Dabei richtet sich Reihenfolge der Initialisierung nach der Reihenfolge innerhalb der Klassendeklarationen, nicht nach der Reihenfolge in der Liste! xKoordinate wird zuerst initialisiert. Wenn eine Initialisierung auf dem Ergebnis einer anderen aufbaut, wäre eine falsche Reihenfolge verhängnisvoll. Um solche Fehler zu vermeiden, sollen alle Elemente der Initialisierungsliste in der Reihenfolge ihrer Deklaration aufgeführt werden.

Die vorgezogene Abarbeitung der Liste wird auch benutzt, um Objekte oder Größen zu initialisieren, die innerhalb des Codeblocks konstant sind. Mit einer Zuweisung kann man keine Konstanten initialisieren. Der Code-Block mus nicht leer sein. In ihm könnte, wenn gewünscht, eine Plausibilitätsprüfung der übergebenen Parameter untergebracht werden.

Die Wirkung des obigen Konstruktors in einem Anwendungsprogramm wird in diesem Beispiel deutlich:

Ort1 einOrtObjekt; cout << einOrtObjekt.getX() << ’\n’ // 0 << einOrtObjekt.getY(); // 0
Image

Hinweis

Bei den Konstruktoren des nächsten Abschnitts können Parameter in Klammern übergeben werden. Ein Standardkonstruktor muss ohne Klammern aufgerufen werden! Eine Ausnahme ist die Erzeugung temporärer Objekte, etwa in der Anweisung return Ort();, in der ein temporär erzeugtes Objekt zurückgegeben wird.

Image

Ort1 nochEinOrtObjekt(); // Fehler!

Diese Zeile wird vom Compiler nämlich nicht als Definition eines neuen Objekts, sondern als Deklaration einer Funktion nochEinOrtObjekt() verstanden, die keine Parameter hat und ein Ort1-Objekt zurückgibt. Eine Fehlermeldung des Compilers gibt es erst, wenn das nicht vorhandene Objekt nochEinOrtObjekt benutzt werden soll.

3.4.2 Direkte Initialisierung der Attribute

Oben werden im Standardkonstruktor die Attribute des Objekts mit den Koordinaten des Nullpunkts initialisiert. Eine Alternative besteht darin, den Standardkonstruktor wegzulassen und die Attribute direkt in der Definition zu initialisieren:

3.4.3 Allgemeine Konstruktoren

Allgemeine Konstruktoren können im Gegensatz zu Standardkonstruktoren Parameter haben und genau wie Funktionen überladen werden. Das heißt, dass es mehrere allgemeine Konstruktoren mit unterschiedlichen Parameterlisten geben kann. Die zu den nachstehenden Definitionen gehörigen Prototypen sind in der Klassendeklaration nachzutragen. Wenn mindestens ein allgemeiner Konstruktor selbst definiert worden ist, wird vom System kein Standardkonstruktor erzeugt. Eine versehentliche Initialisierung mit unbestimmten Daten ist damit ausgeschlossen.

Varianten des Konstruktoraufrufs

Aufruf des Konstruktors heißt Definition des Objekts:

Ort1 nochEinOrt1(70, 90); // Objektdefinition = Konstruktoraufruf (Argumenteliste)

Wenn es mehrere allgemeine Konstruktoren gibt, sucht sich der Compiler den passenden heraus, indem er Anzahl und Datentypen der Argumente der Parameterliste der Konstruktordefinition mit der Angabe im Aufruf vergleicht.

Vorgegebene Parameterwerte in Konstruktoren

Das auf Seite 127 (Abschnitt 2.2.4) beschriebene Verfahren, Funktionsparametern einen Wert vorzugeben, ist auf Konstruktoren übertragbar. Die vorgegebenen Werte müssen in der Deklaration angegeben werden, hier also in der Datei Ort1.h:

Dieser Konstruktor erlaubt zum Beispiel

Ort1 nochEinOrt(70); // xKoordinate = 70, yKoordinate = 100! Ort1 nochEinOrt(70, 90); // Vorgabewert wird überschrieben; oder allg. Konstruktor? (s.u.)

Dabei ist wie bei Funktionen darauf zu achten, dass eine Koexistenz von überladenen Konstruktoren stets zu eindeutigen Aufrufen führt. Variante 7 kann nicht zusammen mit dem oben angegebenen allgemeinen Konstruktor verwendet werden, weil Aufrufe mit zwei Argumenten nicht eindeutig einem der beiden zugeordnet sein können. Der allgemeine Konstruktor und der Standardkonstruktor werden kombiniert, indem alle Parameter Vorgabewerte erhalten.

Die Klasse Ort wird weiter unten gebraucht. Deswegen wird sie hier unter dem neuen Namen Ort (statt Ort1) aufgeführt.

Man könnte in der Methode aendern() eine Kontrollausgabe zur Dokumentation auf den Bildschirm bringen. Eine Änderung ohne Dokumentation wäre dann nicht möglich. Hier wird darauf verzichtet. Warum?

Image       Zwei verschiedene Dinge sollen nicht von derselben Methode erledigt werden.

Image       Eine Ausgabe (oder Eingabe) in einer Methode, die eigentlich eine andere Aufgabe hat, verhindert den universellen Einsatz. Zum Beispiel ließe sich die Methode nicht ohne Weiteres in einem System mit grafischer Benutzungsoberfläche verwenden.

Eine Protokollierung darf die Benutzung nicht beeinträchtigen und muss daher anders realisiert werden – abhängig vom Anwendungsfall. Zum Beispiel könnten Protokollausgaben in eine Datei geschrieben werden.

Alle Methoden der Klasse Ort sind sehr kurz und der Einfachheit halber inline. Der Konstruktor definiert einen Ort (0, 0), sofern keine Koordinaten angegeben werden. Die freie Funktion entfernung() wird noch benötigt und anzeigen() gibt die Koordinaten im Format (x, y) aus.

3.4.4 Kopierkonstruktor

Ein Kopierkonstruktor wird im Englischen copy constructor oder copy initializer genannt. Er dient dazu, ein Objekt mit einem anderen zu initialisieren. Der erste (und im Allgemeinen einzige) Parameter des Kopierkonstruktors ist eine Referenz auf ein Objekt derselben Klasse. Die Deklaration eines Kopierkonstruktors der Klasse X lautet X(X&);. Weil ein Objekt, das dem Kopierkonstruktor als Argument dient, nicht verändert werden soll, wird es als Referenz auf const übergeben: X(const X&). Falls kein Kopierkonstruktor vorgegeben wird, wird bei Bedarf vom System einer erzeugt, der die einzelnen Elemente des Objekts kopiert. Die Elemente können selbst wieder Objekte sein, deren Kopierkonstruktor dann wiederum aufgerufen wird, sei es ein selbst definierter oder der vom System bereitgestellte. Die Kopie jedes Grunddatentyps ist eine bitweise Abbildung des Speicherbereichs. Der Kopierkonstruktor der Klasse Ort wird wie der Standardkonstruktor in den public-Bereich der Klasse Ort geschrieben. Er ist wie folgt definiert:

Eigentlich braucht es keinen eigenen Kopierkonstruktor für die schlichten Elemente der Klasse Ort, weil der vom System erzeugte genügen würde. Er wurde nur geschrieben, um den Aufruf auf dem Bildschirm dokumentieren zu können. Wenn schon ein Kopierkonstruktor mit besonderen Aktionen geschrieben wird, darf die Kopie der einzelnen Elemente nicht vergessen werden. Die Syntax der Initialisierung unterscheidet sich nicht vom Üblichen:

Ort einOrt(19, 39); Ort derZweiteOrt = einOrt; // Aufruf des Kopierkonstruktors // gleichwertig ist: Ort derZweiteOrt(einOrt); cout << derZweiteOrt.getX() << ’˽’ << derZweiteOrt.getY(); // 19 39

Diese Definition erzeugt ein Objekt derZweiteOrt, das mit den Werten des bereits vorhandenen Objekts einOrt initialisiert wird. Auch wenn das Gleichheitszeichen verwendet wird, handelt es sich hier um eine Initialisierung und nicht um eine Zuweisung, zwei Dinge, die in ihrer Bedeutung streng unterschieden werden.

Ein Kopierkonstruktor wird nur dann benutzt, wenn ein neues Objekt erzeugt wird, aber nicht bei Zuweisungen, also Änderungen von Objekten. Bei Zuweisungen wird der vom System bereitgestellte Zuweisungsoperator benutzt, sofern kein eigener definiert wurde – auch das ist möglich, wie Sie sehen werden.

Ort o1, o2; // allg. Konstruktoren mit Vorgabewerten (0,0) o1 = Ort(8,7); // allgemeiner Konstruktor + Zuweisung o2 = o1; // Zuweisung Ort o3(o1); // Kopierkonstruktor
Image

Merke:

Die Übergabe von Objekten an eine Funktion per Wert und die Rückgabe eines Ergebnisobjekts wird ebenfalls als Initialisierung betrachtet, ruft also den Kopierkonstruktor auf. Der Compiler kann allerdings zu Optimierungszwecken den Aufruf durch eine effizientere Initialisierung ersetzen.

Image

Dies lässt sich am folgenden Beispiel zeigen, wobei angenommen wird, dass der Kopierkonstruktor wie oben mit einer Ausgabeanweisung versehen ist. Es gebe eine Funktion ortsverschiebung(), die auf einen gegebenen Ort eine bestimmte Entfernung in x- bzw. y-Richtung hinzuaddieren soll. Die Funktion greift nicht auf private Attribute eines Ort-Objekts zu und braucht daher keine Elementfunktion zu sein. Diese Funktion soll innerhalb main() benutzt werden, zum Beispiel:

Die Übergabe per Referenz auf const in der Parameterliste kann einiges an Geschwindigkeitsgewinn bringen, falls man keine Objektkopie in der Funktion benötigt.

Image

Übergabe an den Kopierkonstruktor per Referenz

Üblicherweise werden kleine, nicht zu verändernde Objekte einer Funktion per Wert übergeben und große per Referenz auf const. Dies gilt nicht für den Kopierkonstruktor selbst! Er muss einen Referenzparameter haben. Der Grund: Wenn er als Ort(Ort ort) deklariert wäre anstatt als Ort(const Ort& ort), würde er sich selbst aufrufen.

Image

Automatische Optimierung der Rückgabe

Der oben in Listing 3.14 definierte Kopierkonstruktor würde in der Funktion ortsverschiebung() ohne Optimierung zweimal aufgerufen: das erste Mal bei der Übergabe des Objekts einOrt an die Funktion und das zweite Mal während der Ausführung der return-Anweisung. Es ist eine Kopie notwendig (Parameter derOrt), die geändert und zurückgegeben wird, weil einOrt beim Aufrufer selbst nicht geändert werden soll. Von dem zurückgegebenen, geänderten Argument derOrt benötigt man jedoch keine Kopie. Es kann direkt an der Stelle eingesetzt werden, an der es gebraucht wird. So wird in Listing 3.15 der Kopierkonstruktor nur noch ein zweites Mal aufgerufen, nämlich bei der Initialisierung des Objekts verschobenerOrt.

Wenn es um die Rückgabe temporärer Objekte geht, wird das Objekt direkt an der Stelle des Ziels initialisiert (siehe Listing 3.16). Diese Optimierung der Rückgabe eines Werts wird englisch return value optimization genannt, abgekürzt RVO. Wenn das Objekt einen Namen hat, ist der Begriff entsprechend named return value optimization.

Um temporäre Objekte einzusparen, wird der Funktionsaufruf

Ort einOrt = erzeugeOrt(10, 20); letzlich als

Ort einOrt = Ort(10, 20); interpretiert. Der in der Funktion zurückgegebene Ausdruck wird direkt eingesetzt.

Image

Klassen nicht überfrachten

Die Funktion ortsverschiebung() in Listing 3.15 ist als freie Funktion realisiert, nicht als Elementfunktion, obwohl das möglich wäre. Die Kapselung in einer Klasse soll so stark wie möglich sein. Deshalb sollen Funktionen, die nicht den direkten Zugriff auf die Objektattribute benötigen, freie Funktionen sein.

Image

Image

Übung

3.1 Schreiben Sie eine Klasse Person, die nur die Attribute Name (Typ std::string) und Alter (Typ int) hat. Die Klasse soll die Methoden getName() und getAlter() zum Abfragen der Attribute enthalten. setName(const std::string& neuerName) und setAlter(int neuesAlter) dienen zum Ändern der Attribute. Schreiben Sie dazu ein main()-Programm, das ein Person-Objekt für die 22-jährige Annabella Meier anlegt und zum Beispiel die folgenden Ausgaben tätigt:

Annabella Meier ist 22 Jahre alt. Annabella Meier hatte Geburtstag. Sie ist jetzt 23 Jahre alt. Sie hat auch geheiratet. Ihr Name ist jetzt Annabella Schulz.

Im main()-Programm sind die Namen und Altersangaben der Ausgabe natürlich durch die entsprechenden Funktionsaufrufe zu bewerkstelligen.

Erweitern Sie anschließend die Klasse (und den Konstruktor) um das Attribut Geschlecht. Dazu fügen Sie in der Datei Person.h vor der Klasse Person die Deklaration enum class Geschlecht {m, w, d}; ein. Geben Sie im main()-Programm mithilfe der Elementfunktionen istFrau() bzw. istMann() und istDivers() aus, ob die dort angelegte Person Frau, Mann oder eine diverse Person ist.

Image

3.4.5 Typumwandlungskonstruktor

Der Typumwandlungskonstruktor dient zur Umwandlung anderer Datentypen in die gewünschte Klasse. Der erste Parameter des Typumwandlungskonstruktors ist verschieden vom Typ der Klasse. Falls weitere Parameter folgen, was im Allgemeinen nicht der Fall sein wird, müssen sie Initialisierungswerte haben. Im Grunde ist der Typumwandlungskonstruktor nichts anderes als der Spezialfall eines allgemeinen Konstruktors, der etwas anders eingesetzt wird. Hier wird ein Typumwandlungskonstruktor gezeigt, der einen Eingabestrom (istream) als Parameter hat und der die Koordinaten mit den eingelesenen Werten initialisiert. Der istream) wird als Referenz übergeben, weil keine Kopie von ihm angelegt werden soll (und kann).

Listing 3.17: Typumwandlungskonstruktor

// im public-Bereich von Ort.h (Seite 185) einfügen: // Typumwandlungskonstruktor für das Einlesen der Koordinaten Ort(std::istream &istr) { // vorläufige Version istr >> xKoordinate >> yKoordinate; if(!istr) { std::cerr << "Ort-Konstruktor:˽falsches˽Eingabeformat.˽Abbruch!\n"; exit(1); } }

Ein Beispiel zeigt die Anwendung:

Die Typprüfung des Compilers wird eingeschränkt. Die Einschränkung wird in der folgenden Zeile deutlich, weil die Funktion anzeigen(const Ort&) von Seite 185 eigentlich kein istream-Objekt als Argument akzeptiert.

anzeigen(cin); // Umgehen der Typprüfung
Image

Tipp

Im Allgemeinen möchte man sowohl die Möglichkeit der Typumwandlung haben als auch die Typprüfung durch den Compiler, damit keine Fehler durch implizite Typumwandlungen entstehen. Das Schlüsselwort explicit erlaubt es, explizite Typumwandlungen durchzuführen, aber andere, vielleicht unbeabsichtigte, zu verbieten. Es ist fast immer sinnvoll, Konstruktoren, die nur einen Parameter haben, als explicit zu deklarieren.

Image

Mögliche Anwendung (siehe cppbuch/k3/typumwandlung/main.cpp):

anzeigen(cin); // jetzt ein Fehler! anzeigen(Ort(cin)); // erlaubte explizite Typumwandlung
3.4.6 Konstruktor und mehr vorgeben oder verbieten

Der Compiler erzeugt automatisch einen Konstruktor ohne Parameter, einen Kopierkonstruktor, einen Destruktor (die Erläuterung der unbekannten Begriffe folgt bald) und einen Zuweisungsoperator, falls eine Klasse diese nicht zur Verfügung stellt. Um mehr Kontrolle darüber zu erlauben, sind die syntaktischen Konstruktionen = default und = delete eingeführt worden. Das folgende Beispiel zeigt den Einsatz:

Ohne die f(double)-Deklaration wäre wegen der automatischen Typumwandlung ein Aufruf von f(int) mit einem double-Argument möglich. Alternativ könnten die zu verbietenden Methoden ohne =delete im private-Bereich liegen. Dabei genügen die Prototypen, eine Implementation wird nicht gebraucht. Allerdings würden in diesem Fall private Aufrufe vom Compiler nicht beanstandet. Erst der Linker würde eine fehlende Implementation bemerken. Deshalb ist die Lösung mit = delete vorzuziehen.

3.4.7 Einheitliche Initialisierung und Sequenzkonstruktor

Die Initialisierung von Objekten kann auf verschiedene Weise geschehen, zum Beispiel mit dem Zuweisungsoperator (int i = 0; obwohl es sich hier nicht um eine Zuweisung handelt), mit runden Klammern (int j(9);) oder mit geschweiften Klammern (int k {100};). Um eine einheitliche Initialisierungsmöglichkeit zu schaffen, wird die Initialisierung mit geschweiften Klammern in jedem Fall erlaubt. Damit kann etwa int i {}; oder int i {0}; statt int i = 0; geschrieben werden. Diese Möglichkeit gilt auch für Objekte, wobei automatisch der richtige Konstruktor gewählt wird. Betrachten wir eine Klasse Zahlenfolge und das zugehörige main()-Programm:

Diese Zahlenfolge kann nur null bis zwei Werte aufnehmen – nicht sehr sinnvoll. Die Klasse wird jedoch anschließend für den Fall beliebig vieler Werte erweitert.

Man sieht, dass im Fall geschweifter Klammern der passende Konstruktor herausgesucht wird. Nun kann es vorkommen, dass ein Objekt mit beliebig vielen Werten initialisiert werden soll, wie Sie das bereits bei der Klasse vector gesehen haben. Für die Initialisierung von Objekten einer selbstgeschriebenen Klasse mit beliebig vielen Werten, die innerhalb von geschweiften Klammern angegeben werden, muss ein Sequenzkonstruktor geschrieben werden, wie er unter anderem für die Klasse vector existiert. Ein Sequenzkonstruktor nimmt ein Objekt der vorgegebenen Klasse initializer_list als Parameter. Sie wird per Wert übergeben. Das kostet jedoch keine Performance, weil sie intern nur einen Verweis trägt. Dieser Liste können die beliebig vielen Werte entnommen werden. Die Klasse Zahlenfolge wird entsprechend erweitert:

Das main()-Programm wird um eine Folge erweitert, die von den bisherigen Konstruktoren nicht abgedeckt wird. Beachten Sie, dass der Sequenzkonstruktor bei der Initialisierung mit geschweiften Klammern bevorzugt aufgerufen wird. Nur wenn runde Klammern verwendet werden, kommen die anderen Konstruktoren zum Tragen.

Mit der initializer_list haben Sie nun ein Werkzeug an der Hand, um eigene Objekte mit beliebig vielen Werten zu initialisieren.

initializer_list und for-Schleifen

Eine Initialisierungsliste kann auch direkt in einer Schleife angegeben werden, um einen Bereich zu definieren, der von der Schleife abgearbeitet werden soll:

3.4.8 Delegierender Konstruktor1

Auf Seite 183 wird die Initialisierung eines Objekts mit Listen in der Konstruktordefinition gezeigt. Eine weitere neue Möglichkeit ist der Aufruf eines anderen Konstruktors derselben Klasse in der Initialisierungsliste. Der Konstruktor delegiert so seine Aufgabe an einen anderen Konstruktor. Der Sinn besteht darin, die Initialisierung von Attributen ohne Code-Duplikation zu erreichen. Ein Beispiel noch ohne delegierenden Konstruktor:

In diesem Beispiel ist ein Teil der im Konstruktor zu erledigenden Aufgaben in eine Funktion ausgelagert worden, die Initialisierung von attr1 und attr2 jedoch nicht. Im Fall von Anpassungen sind ggf. beide Konstruktoren zu ändern, was fehleranfällig sein kann. Eine Delegation der Konstruktoraufgaben würde Letzteres vermeiden helfen. Der Programmcode der Funktion init() wird in denjenigen Konstruktor verlegt, den der andere aufruft:

Die Klasse ist kürzer und lesbarer geworden, weil der zweite Konstruktor nunmehr fast leer ist. Das folgende Beispiel zeigt, welcher Konstruktor aufgerufen wird:

Klasse k1(5, 20); // Konstruktor 1: Daten: 5, 20 Klasse k2; // Konstruktor 2, Konstruktor 1 rufend: Daten: 1, 42
3.4.9 constexpr-Konstruktor und -Methoden

constexpr-Funktionen sind von Seite 156 bekannt. Dasselbe Prinzip gilt auch für Konstruktoren und Methoden. Betrachten Sie dazu das folgende Beispiel:

Image

Anmerkung

Wenn bei einem mit constexpr deklarierten Kreis-Objekt zur Compilationszeit r >= 0 gilt, wird toter Code gleich eliminiert, d.h., die if-Anweisung gibt es im Compilationsergebnis nicht mehr. Wenn jedoch r < 0 gilt, gibt es eine Fehlermeldung des Compilers, weil std::cerr nicht constexpr ist. Der Vorteil: Das Programm scheitert bereits zur Compilationszeit, wenn ein offensichtlich falscher Wert als Literal übergeben wird. Beispiel: constexpr Kreis murks(-3.0); Für ein Nicht-constexpr-Objekt (Definition ohne constexpr) gilt das nicht. Der übergebene Wert wird erst zur Laufzeit geprüft, sodass es keine Fehlermeldung des Compilers gibt, wohl aber im Fehlerfall eine zur Laufzeit des Programms. Entsprechendes gilt für die Funktion setRadius().

Image

Die Zahl pi ist eine double-Konstante, die den Wert für die Zahl π enthält. Die Konstante ist im Header <numbers> definiert. Der Header <numbers> enthält weitere mathematische Konstanten, darunter die bekanntesten wie e, log2e, log10e, ln

Zeilen 7-9: constexpr stößt die Auswertung zur Compilationszeit an. Dies ist möglich, weil in Zeile 7 ein Literal (100.0) übergeben wird. Die Werte von cr und cf werden berechnet, bevor das Programm überhaupt startet.

Zeile 10: Die Ausgabe erfordert ein Laufen des Programms, kann also nicht zur Compilierzeit ausgeführt werden.

Zeilen 12-15: constexpr fehlt. Der Programmcode wird erst zur Laufzeit ausgeführt.

Zeilen 17-18: constexpr bei der Deklaration der setRadius()-Methode ist hier beim Aufruf nicht wirksam, weil k1 nicht constexpr ist. Allerdings ist das obige constexpr-Objekt ck mit einer Anweisung ck.setRadius(9.07) nicht änderbar: Alle constexpr-Objekte sind gleichzeitig const. Warum setRadius() trotzdem als constexpr deklariert ist, zeigt sich bei Zeile 22.

Zeile 22: ck ist constexpr und 0.5 ist ein Literal. Das Objekt skaliert wird zur Compilationszeit berechnet. Hier wird deutlich, warum die Nicht-const-Methode setRadius() als constexpr deklariert ist: Sie wird innerhalb der globalen constexpr-Funktion skalieren() gebraucht. Ohne die Deklaration als constexpr würde der Aufruf kreis.setRadius(k.get-Radius() * faktor); in Kreis.h scheitern.

Zeile 26: Das Objekt skaliert1 wird zur Laufzeit des Programms berechnet.

Image       Die Wirkung von constexpr-Konstruktoren und -Methoden ist wie folgt:

1.      Ein constexpr-Objekt (ck oben) ist immer auch const. Seine Erzeugung setzt einen constexpr-Konstruktor voraus, literale Argumente und dass im Compilationsergebnis kein Programmcode steht, der eine Ausführung zur Laufzeit voraussetzt. Eine Ausgabe auf der Konsole verhindert beispielsweise eine Auswertung zur Compilationszeit. Siehe dazu die Anmerkung auf Seite 196.

2.      Für ein anderes Objekt, sei es const oder nicht (Objekt k oben), wird der constexpr-Spezifizierer bei der Konstruktor-Deklaration vom Compiler ignoriert (Zeile 12 oben).

3.      Wenn die Bedingung der if-Abfrage im Kreis-Konstruktor zutrifft, gibt es bei einem constexpr-Objekt eine Fehlermeldung des Compilers, weil die Anweisung nicht zur Compilationszeit ausgeführt werden kann. Ist das Objekt nicht constexpr, wird das Programm compiliert und die Fehlermeldung gibt es zur Laufzeit.

// mit negativem Radius Fehler provozieren: Kreis falsch1(-3.0); // Fehlermeldung zur Laufzeit constexpr Kreis falsch2(-3.0); // Fehlermeldung zur Compilationszeit

4.      Auch inline deklarierte Elementfunktionen wie die in der Klasse Kreis oben können constexpr sein. Für eine Berechnung zur Compilationszeit müssen die Objektdaten, mit denen sie arbeiten, natürlich zur Compilationszeit bekannt sein. Das heißt, es muss mindestens einen constexpr-Konstruktor geben.

5.      Bei nicht-literalen Objekten wird der constexpr-Spezifizierer der Deklaration der Elementfunktion vom Compiler ignoriert (Zeilen 12-13 oben).

6.      constexpr trägt nichts zum Typ bei. constexpr-Elementfunktionen sind also nicht automatisch const (etwa setRadius() oben). Deswegen muss bei nicht verändernden Funktionen const bei der Deklaration hingeschrieben werden (siehe Deklarationen von getRadius() und getFlaeche()).

7.      constexpr-Elementfunktionen, die nicht const sind, können ein constexpr-Objekt nicht verändern. Sie können aber innerhalb anderer constexpr-Funktionen aufgerufen werden, um einen constexpr-Wert zur Compilationszeit zu berechnen.

8.      Die Definition von constexpr-Elementfunktionen gehört in eine Header-Datei (Begründung siehe Hinweis zu den freien constexpr-Funktionen auf Seite 158).

Image

Tipp:

Setzen Sie constexpr ein, wenn eine Berechnung bereits zur Compilationszeit möglich ist. Der Vorteil besteht in der schnelleren Performance, weil die Berechnungen schon vor Start des Programms erledigt sind.

Image

3.5 Beispiel Rationale Zahlen
3.5.1 Aufgabenstellung

Es folgt ein vollständiges Beispiel für eine Klasse, mit deren Objekten gerechnet werden kann. Es soll eine Bibliothek zum Rechnen mit rationalen Zahlen programmiert werden. Rationale Zahlen sind Zahlen, die durch einen Bruch darstellbar sind, wobei Zähler und Nenner ganzzahlig sein müssen. Dabei soll ein Anwender rationale Zahlen mit dem Datentyp Rational definieren können. Mit diesen Zahlen sollen alle Grundrechenarten ohne Genauigkeitsverlust durchführbar sein. Alle Ergebnisse von Rechnungen mit rationalen Zahlen sollen in bereits gekürzter Form vorliegen. Negative Zahlen sind durch einen negativen Zähler zu repräsentieren.

Schnittstelle

Ein die Klasse benutzendes Programm muss die Datei Rational.h per #include-Anweisung einlesen. Die Objektdatei Rational.o muss eingebunden (»gelinkt«) werden. Die Definition einer rationalen Zahl r wird wie eine übliche Variablendeklaration geschrieben, zum Beispiel: Rational r;. Die folgenden Funktionen sollen von der Bibliothek bereitgestellt werden, wobei die Operanden a und b vom Typ Rational, int oder long int sein können. Im Kapitel 8 wird gezeigt, wie diese Funktionen direkt durch die üblichen mathematischen Operatoren ersetzt werden.

r.getZaehler();

Zähler zurückgeben

r.getNenner();

Nenner zurückgeben

r.set( z, n);

Setzen der Werte für Zähler und Nenner

r.kehrwert();

r enthält danach den Kehrwert

Rechenfunktionen:

r = add(a, b); r = a + b r = sub(a, b); r = a - b r = mult(a, b); r = a * b r = div(a, b); r = a / b

Kurzformoperationen:

r.add(a); r += a r.sub(a); r -= a r.mult(a); r *= a r.div(a); r /= a

Ein- und Ausgabe:

eingabe(r);

Dialogeingabe der rationalen Zahl r

ausgabe(r);

Ausgabe der rationalen Zahl im Format Zähler/Nenner

Beschränkungen und Hinweise

Nach jeder Operation sollen die Zahlen gekürzt werden. Eine Bereichsüberprüfung ist der Einfachheit halber nicht vorgesehen. Anwender sind demnach selbst dafür verantwortlich, dass der für den Datentyp long zutreffende Bereich ihres Rechners in Zwischenrechnungen nicht überschritten wird. In einer kommerziellen Version sollte dieser Fall wenigstens zu einer Fehlermeldung führen. Falls der Nenner null wird, soll das Programm mit einer Fehlermeldung abbrechen. Die constexpr-Möglichkeiten von Seite 195 sollen nicht berücksichtigt werden, um das Beispiel nicht zu überfrachten. Dazu gibt es eine entsprechende Übungsaufgabe.

Man kann sich vorstellen, dass die Funktionen zur Ein- und Ausgabe Elementfunktionen der Klasse sein könnten, weil sie genau wissen müssen, wie auf die übergebenen rationalen Zahlen zugegriffen wird. Andererseits sind die Ein- und die Ausgabe Funktionen, die mit dem eigentlichen Zweck, nämlich dem Rechnen mit rationalen Zahlen, nichts zu tun haben und auch der Zugriff auf Zähler und Nenner ist möglich. Deshalb werden Ein- und Ausgabe als einfache Funktionen, also nicht als Elementfunktionen realisiert. Der Aufruf ist dann zum Beispiel ausgabe(r) statt r.ausgabe().

3.5.2 Entwurf

In diesem Abschnitt werden die Algorithmen, die die mathematische Grundlage des Programms bilden, sowie einige Überlegungen zur Implementierung dargestellt. Für die folgenden arithmetischen Ausdrücke gilt:

x.z ist der Zähler der rationalen Zahl x.

x.n ist der Nenner der rationalen Zahl x.

Addition

r = a + b :

Subtraktion

r = ab :

Multiplikation

r = a ∗ b :

Division

r = a/b :

Kehrwert bilden

1/r :

Vertauschen von Zähler und Nenner

Die Rechenregeln für die Kurzformoperatoren sind entsprechend. Im Programm können Vereinfachungen vorgenommen werden etwa der Art, dass die Subtraktion auf die Addition einer negativen Zahl zurückgeführt wird. Für den Datentyp der rationalen Zahl wird die Klasse Rational deklariert, die als private Attribute nur den Zähler und den Nenner hat. Die in der Anforderungsdefinition vorgeschriebenen Methoden werden in die Klasse übernommen. Hinzu kommt noch die Methode kuerzen(), die intern benötigt wird. Beim Betrachten der Operationen ist festzustellen:

Image       Eine Operation wie r.add(a) ändert das Objekt r, aber nicht das Objekt a. Weil Objekte nur über Methoden geändert werden können (abstrakter Datentyp!), ist add() eine Methode oder Elementfunktion. Der Parameter a kann per Wert oder per Referenz auf const übergeben werden. Letzteres spart das Erzeugen einer lokalen Kopie, lohnenswert bei großen Objekten. Ein Objekt des Typs Rational ist jedoch relativ klein, weswegen dieser Punkt hier keine große Rolle spielt. Die Funktion add() gibt nichts zurück, der Rückgabetyp ist void.

Image       Eine Operation wie r = add(a, b) ändert nicht die Parameter a und b. Für die Parameterübergabe gelten daher die Argumente des vorhergehenden Punkts. Die Funktion add(a, b) erzeugt eine rationale Zahl als Ergebnis, die der Variablen r zugewiesen wird. Der Rückgabetyp ist also Rational. Da die Funktion wegen getZaehler() und getNenner() nicht auf private Attribute zugreift, kann sie eine freie Funktion sein.

Weil Operationen mit gemischten Datentypen möglich sein sollen, muss ein Mechanismus dafür bereitgestellt werden. Zwei Möglichkeiten sind üblich:

1.      Die arithmetischen Methoden und Funktionen können überladen werden, sodass für die beiden Operanden folgende Kombinationen erlaubt sind:
Methode, zum Beispiel
void Rational::add(Rational)
void Rational::add(long)

und freiee Funktion, zum Beispiel
Rational add(Rational, Rational)
Rational add(Rational, long)
Rational add(long, Rational)
int
-Werte würden implizit nach long konvertiert werden. Pro freier Rechenoperation würden drei Methoden anstatt einer benötigt, die allerdings teilweise aufeinander zugreifen könnten.

2.      Wenn die Anzahl der Methoden und Funktionen klein gehalten werden soll, muss dem Compiler eine Möglichkeit zur Typumwandlung zur Verfügung gestellt werden, wenn er die Daten an die Rechenfunktionen übergibt. Dies geschieht am günstigsten durch einen Typumwandlungskonstruktor, wie er im letzten Abschnitt behandelt wurde. Er darf hier natürlich nicht explicit sein!

Image

Hinweis

Große unveränderliche Objekte sollen bei der Parameterübergabe per Referenz auf const übergeben werden. Objekte der Klasse Rational sind jedoch klein, sodass die Übergabe per Wert gewählt wird. Außerdem: Falls das Argument ein temporäres Objekt ist, hat der Compiler bei dieser Art der Übergabe die Chance, den Aufruf des Kopierkonstruktors zu eliminieren.

Image

Wenn die Entscheidung für die zweite Variante getroffen würde, müsste es einen Konstruktor Rational(long z) geben, der eine rationale Zahl mit dem Zähler z und dem Nenner 1 erzeugt. Derselbe Effekt lässt sich aber auch durch einen Vorgabeparameter erreichen, d.h. Rational(long z = 0, long n = 1). Wenn nun ohne Angabe von Daten die Zahl 0/1 erzeugt werden soll, kann auch z einen Vorgabewert erhalten. Der Konstruktor wirkt damit je nach Aufruf als allgemeiner Konstruktor oder als Typumwandlungskonstruktor. Die Klassendeklaration in der Datei Rational.h lautet damit:

Der Funktion ausgabe() kann der ostream (z.B. für die Ausgabe in eine Datei) mitgegeben werden. Ohne Angabe kommt die Standardausgabe cout zur Geltung.

Die Methode kuerzen() wird implizit von den anderen Methoden aufgerufen und könnte daher privat sein. Die public-Eigenschaft schadet aber nicht. Die Klasse rational könnte auch constexpr sein – das Thema der Übungsaufgabe 3.4 am Ende des Abschnitts.

Image

[[nodiscard]] und Klasse

Im Listing 3.30 steht [[nodiscard]] auch nach dem Schlüsselwort class, nicht nur vor einer Funktion, wie bisher. Die Wirkung ist, dass der Rückgabewert aller Funktionen, die ein Rational-Objekt per Wert zurückgeben, verwendet werden muss – andernfalls gibt es eine Warnung. Damit werden versehentliche sinnlose Aufrufe wie add(a, b); entdeckt.

Image

Fehlerbetrachtung

Ein Überlauf des Zahlenbereichs soll nicht geprüft werden, wohl aber der Fall, dass ein Nenner 0 wird. Dies kann in den Methoden eingabe(), kehrwert(), set() und div() geschehen, wobei Letztere nicht betrachtet werden muss, wenn man die Division durch die Multiplikation mit dem Kehrwert implementiert. Mithilfe von assert() wird geprüft, ob der Nenner 0 ist. Bei der Eingabe ist dieses Vorgehen sicher nicht sehr benutzungsfreundlich und könnte daher modifiziert werden.

3.5.3 Implementation

Die Implementation der Methoden und der freien Funktionen sind in der Datei Rational.cpp abgelegt. Um einen Bruch zu kürzen, muss man den größten gemeinsamen Teiler kennen. Eine mögliche Funktion dafür ist die Hilfsfunktion ggt():

long ggt(long x, long y) { long rest = 0; while (y > 0) { rest = x % y; x = y; y = rest; } return x; }

Verwendet wird ein modifizierter Euklid-Algorithmus, in dem Subtraktionen durch die schnellere Restbildung ersetzt werden. Die erste, langsamere Fassung des Algorithmus kennen Sie von Seite 75. Aber: ggt() entspricht der Bibliotheksfunktion std::gcd() (Seite 813). »gcd« steht für greatest common divisor. Der Einfachheit und Kürze wegen wird deshalb std::gcd() verwendet.

Listing 3.31: Implementation der Klasse Rational (cppbuch/k3/rational/Rational.cpp)

#include "Rational.h" #include <cassert> #include <numeric> // gcd() // Elementfunktionen void Rational::add(Rational r) { zaehler = zaehler * r.nenner + r.zaehler * nenner; nenner = nenner * r.nenner; kuerzen(); } void Rational::sub(Rational r) { zaehler = zaehler * r.nenner - r.zaehler * nenner; nenner = nenner * r.nenner; kuerzen(); } void Rational::mult(Rational r) { zaehler = zaehler * r.zaehler; nenner = nenner * r.nenner; kuerzen(); } void Rational::div(Rational r) { zaehler = zaehler * r.nenner; nenner = nenner * r.zaehler; kuerzen(); } void Rational::set(long z, long n) { zaehler = z; nenner = n; assert(nenner != 0); kuerzen(); } void Rational::kehrwert() { long temp = zaehler; zaehler = nenner; nenner = temp; assert(nenner != 0); } void Rational::kuerzen() { // Vorzeichen merken und Betrag bilden int sign = 1; if (zaehler < 0) { sign = -sign; zaehler = -zaehler; } if (nenner < 0) { sign = -sign; nenner = -nenner; } long teiler = std::gcd(zaehler, nenner); zaehler = sign * zaehler / teiler; // Vorzeichen restaurieren nenner = nenner / teiler; } // Es folgen die globalen arithmetischen Funktionen für die // Operationen mit 2 Parametern (binäre Operationen). Rational add(Rational a, Rational b) { // Die eigentliche Berechnung muss hier nicht wiederholt werden, sondern // die bereits vorhandenen Funktionen für die Kurzformen der Addition usw. // können vorteilhaft wiederverwendet werden. Dazu wird auf a, das per // Wert übergeben wird und daher eine Kopie darstellt, das Argument b // addiert. Das Ergebnis wird zurückgegeben. a.add(b); return a; } Rational sub(Rational a, Rational b) { a.sub(b); // siehe Diskussion bei add() return a; } Rational mult(Rational a, Rational b) { a.mult(b); // siehe Diskussion bei add() return a; } Rational div(Rational z, Rational n) { z.div(n); // siehe Diskussion bei add() return z; } // Funktionen für Ein- und Ausgabe void eingabe(Rational& r) { long int z {0L}; long int n {0L}; std::cin >> z >> n; assert(n != 0); r.set(z, n); r.kuerzen(); } void ausgabe(Rational r, std::ostream& os) { os << r.getZaehler() << "/" << r.getNenner() << ’\n’; }

Image

Übung

3.2 Schreiben Sie die Funktionen add(long a, Rational b) und add(Rational a, long b), die bei Abwesenheit der Vorgabewerte im Konstruktor erforderlich wären.

Image

Testdokumentation

Um die Klasse Rational zu testen, wurde die Datei cppbuch/k3/rational/main.cpp geschrieben, die Rational.h einbindet. Die aus Rational.cpp durch Compilation entstandene Datei Rational.o muss dazu gelinkt werden. Das ausführbare Programm (z.B. projekt.exe) bringt die Testausgaben auf den Bildschirm. Mit der Anweisung projekt.exe > test.erg werden alle Ausgaben in die Datei test.erg geschrieben (einschließlich der Aufforderung zur Eingabe der Zahlen!).

Ergebnisse des Testprogramms

Die Testergebnisse werden hier aus Platzgründen und weil sie überaus langweilig zu lesen sind, nicht abgedruckt. Probieren Sie das Testprogramm aus und erweitern Sie es mit zusätzlichen Prüfungen, um Fehlern auf die Spur zu kommen!

Image

Übungen

3.3 Schreiben Sie eine Klasse IntMenge, bestehend aus den zwei Dateien IntMenge.h und IntMenge.cpp, sowie ein Testprogramm main.cpp entsprechend den Regeln dieses Kapitels. Die Klasse soll als mathematische Menge für ganze Zahlen dienen. Es sollen nur die folgenden einfachen Funktionen möglich sein, auf Operationen mit zwei Mengen wie Vereinigung und Durchschnitt werde verzichtet:

Image       void hinzufuegen(int el)
Element el hinzufügen, falls es noch nicht existiert, andernfalls nichts tun.

Image       void entfernen(int el)
entfernt Element el, falls es vorhanden ist, andernfalls nichts tun.

Image       bool istMitglied(int el) gibt an, ob el in der Menge enthalten ist.

Image       int size() gibt die Anzahl der gespeicherten Elemente zurück.

Image       void anzeigen()
gibt alle Elemente auf der Standardausgabe aus. Weil von außen nicht auf die Folge der Elemente zugegriffen werden kann, wird diese Funktion nicht als freie Funktion, sondern wie die anderen auch als Elementfunktion realisiert.

Image       void loeschen() löscht alle Elemente.

Image       int getMax() und int getMin() geben das größte bzw. kleinste Element zurück.

Benutzen Sie intern zum Speichern der Werte ein vector<int>-Objekt. Ein Auszug einer Anwendung könnte etwa wie folgt aussehen:

Diese Aufgabe ist eine Vorübung für das Thema einer Klasse »Menge«. Die C++-Bibliothek stellt für Mengen die Klasse set, die in Abschnitt 27.4.1 beschrieben wird, zur Verfügung. 3.4 Bauen Sie die Klasse Rational so um, dass für literale Argumente alle Rechenoperationen zur Compilationszeit ausgeführt werden. Diese Aufgabe ist nur für diejenigen, die den Abschnitt 3.4.9 schon gelesen haben. Ein entsprechendes main()-Programm könnte wie folgt aussehen:

Image

3.6 Destruktoren

Destruktoren dienen dazu, Aufräumarbeiten für nicht mehr benötigte Objekte zu leisten. Wenn Destruktoren nicht vorgegeben werden, werden sie vom System automatisch erzeugt (implizite Deklaration). Der häufigste Zweck ist die Speicherfreigabe, wenn der Gültigkeitsbereich eines Objekts verlassen wird. Konstruktoren haben die Aufgabe, Ressourcen zu beschaffen, Destruktoren obliegt es, sie wieder freizugeben. Die Reihenfolge des Aufrufs der Destruktoren ist umgekehrt wie die der Konstruktoren.

Destruktoren haben keine Parameter und keinen Rückgabetyp. In der Deklaration wird eine Tilde ~ vorangestellt. Im Beispiel werden nummerierte Testobjekte erzeugt. Um den Ablauf verfolgen zu können, sind Konstruktor und Destruktor mit Ausgabeanweisungen versehen. Die Gültigkeit oder Lebensdauer eines Objekts endet, wie schon aus Abschnitt 1.7 bekannt, an der durch eine schließende geschweifte Klammer markierten Grenze des Blocks, in dem das Objekt definiert wurde. Genau dann wird das Objekt zerstört, das heißt, dass der von diesem Objekt belegte Speicherplatz freigegeben wird.

Daraus folgt, dass durchaus außerhalb von main() einige Aktivitäten stattfinden können:

Image       Falls es globale Objekte gibt, wird ihr Konstruktor vor der ersten Anweisung von main() aufgerufen.

Image       Innerhalb des äußersten Blocks von main() definierte Objekte werden erst beim Verlassen von main() freigegeben.

Image       Wegen der umgekehrten Reihenfolge der Destruktoraufrufe werden globale Objekte zuletzt freigegeben.

Die Ausgabe des Programms belegt, dass die Objekte nach der letzten Anweisung ihres Blocks zerstört werden.

Die Ausgabe des Programms ist:

Objekt 0 wird erzeugt. main wird begonnen Objekt 1 wird erzeugt. neuer Block Objekt 2 wird erzeugt. Block wird verlassen Objekt 2 wird zerstört. main wird verlassen Objekt 1 wird zerstört. Objekt 0 wird zerstört.

Der Destruktor von Objekten mit statischer Lebensdauer (static oder globale Objekte) wird nicht nur beim Verlassen eines Programms mit return, sondern auch beim Verlassen mit exit() aufgerufen. Im Gegensatz zum normalen Verlassen eines Blocks wird der Speicherplatz bei exit() jedoch nicht freigegeben.

3.7 Wie kommt man zu Klassen und Objekten? Ein Beispiel

Es kann hier keine allgemeine Methode gezeigt werden, wie man von einer Aufgabe zu Klassen und Objekten kommt. Es wird jedoch anhand eines Beispiels ein erster Eindruck vermittelt, wie der Weg von einer Problemstellung zum objektorientierten Programm aussehen kann.

Es geht hier um ein Programm, das zu einer gegebenen Personalnummer den Namen heraussucht. Ähnlichkeiten mit der Aufgabe 1.25 von Seite 112 sind beabsichtigt. Gegeben sei eine Datei daten.txt mit den Namen und den Personalnummern der Mitarbeiter. Dabei folgt auf eine Zeile mit dem Namen eine Zeile mit der Personalnummer. Das #-Zeichen ist die Endekennung. Der Inhalt der Datei ist:

Hans Nerd 06325927 Juliane Hacker 19236353 Michael Ueberflieger 73643563 #

Einige Analyse-Überlegungen

Um die Problemstellung zu verdeutlichen, wird sie aus verschiedenen Blickwinkeln betrachtet. Es handelt sich dabei nur um Möglichkeiten, nicht um den einzig wahren Lösungsansatz (den es nicht gibt).

1.      In der Analyse geht es zunächst einmal darum, den typischen Anwendungsfall (englisch use case) in der Sprache des (späteren Programm-)Anwenders zu beschreiben. Ein ganz konkreter Anwendungsfall, Szenario genannt, ist ein weiteres Hilfsmittel zum Verständnis dessen, was das Programm tun soll.

2.      Im zweiten Schritt wird versucht, beteiligte Objekte, ihr Verhalten und ihr Zusammenwirken zu identifizieren.

Image

Anwendungsfall (use case)

Das Programm wird gestartet. Alle Namen und Personalnummern werden zur Kontrolle ausgegeben (weil es hier nur wenige sind). Anschließend erfragt das Programm eine Personalnummer und gibt daraufhin den zugehörigen Namen aus oder aber die Meldung, dass der Name nicht gefunden wurde. Die Abfrage soll beliebig oft möglich sein. Wird X oder x eingegeben, beendet sich das Programm.

Image

Für einen konkreten Anwendungsfall (= Szenario) wird die oben dargestellte Datei daten.txt verwendet.

Image

Szenario

Das Programm wird gestartet und gibt aus:

Hans Nerd 06325927 Juliane Hacker 19236353 Michael Ueberflieger 73643563

Anschließend erfragt das Programm eine Personalnummer. Die Person vor dem Bildschirm (User) gibt 19236353 ein. Das Programm gibt Juliane Hacker aus und fragt wieder nach einer Personalnummer. Jetzt wird 99999 eingegeben. Das Programm meldet nicht gefunden! und fragt wieder nach einer Personalnummer. Jetzt wird X eingegeben. Das Programm beendet sich.

Image

Objekte und Operationen identifizieren

Im nächsten Schritt wird versucht, die beteiligten Objekte und damit ihre Klassen zu identifizieren und eine Beschreibung ihres Verhaltens zu finden.

In der nicht-objektorientierten Lösung zur Vorläuferaufgabe 1.25 werden alle Aktivitäten in main() abgehandelt. Das ist unvorteilhaft, weil die Funktionalität damit nicht einfach in ein anderes Programm transportiert werden kann. Deswegen bietet es sich an, die Aktivitäten in ein eigens dafür geschaffenes Objekt zu verlegen. Die Klasse dazu sei hier etwas hochtrabend Personalverwaltung genannt. Was müsste so ein Objekt tun?

1.      Die Datei daten.txt lesen und die gelesenen Daten speichern. Der Einfachheit halber wird hier angenommen, dass keine andere Datei zur Auswahl steht.

2.      Die Daten auf dem Bildschirm ausgeben.

3.      Einen Dialog mit dem Benutzer führen, in dem nach der Personalnummer gefragt wird.

Diese drei Punkte und die Kenntnis der Datei führen zu entsprechenden Schlussfolgerungen. Dabei sind im ersten Schritt die Substantive (Hauptworte) als Kandidaten für Klassen zu sehen und Verben (Tätigkeitsworte) als Methoden. Passivkonstruktionen sollen dabei vorher stets in Aktivkonstruktionen verwandelt werden, d.h. ausgeben ist besser als die Ausgabe erfolgt.

1.      Eine Auswahl der Datei ist hier nicht vorgesehen. Ein Objekt der Klasse Personalverwaltung soll daher schon beim Anlegen die Datei einlesen und die Daten speichern. Das übernimmt am besten der Konstruktor, dem der Dateiname übergeben wird.

Image       Die gelesenen Daten gehören zu Personen. Jede Person hat einen Namen und eine Personalnummer. Es bietet sich an, Name und Personalnummer in einer Klasse Person zu kapseln. Aus Gründen der Einfachheit sollen Vor- und Nachname nicht getrennt gehalten werden; ein Name genügt.

Image       Die Personalnummer soll nicht als int vorliegen, sondern als string, damit nicht führende Nullen (siehe Datei oben) beim Einlesen verschluckt werden oder zu einer Interpretation als Oktalzahl führen. Außerdem könnte es Nummernsysteme mit Buchstaben und Zahlen geben.

Image       Die Klasse Personalverwaltung soll die Daten speichern. Dafür bietet sich ein vector<Person> als Attribut an.

2.      Das Tätigkeitswort ausgeben legt nahe, eine gleichnamige Methode ausgeben() vorzusehen. In der Methode werden Name und Personalnummer einer Person ausgegeben. Es muss also entsprechende Methoden in der Klasse Person geben, etwa getName() und getPersonalnummer(). Diese Methoden würden innerhalb der Funktion ausgeben() aufgerufen werden.

3.      Dialog führen legt nahe, eine Methode dialogfuehren() oder kurz dialog() vorzusehen.

Weil nur ein erster Eindruck vermittelt werden soll und die Problemstellung einfach ist, wird auf eine vollständige objektorientierte Analyse (OOA) und ein entsprechendes Design (OOD) verzichtet und auf die Literatur verwiesen, die die OOA/D-Thematik behandelt, zum Beispiel [Oe]. In diesem einfachen Fall konzentrieren wir uns gleich auf eine Lösung mit C++. Eine main()-Funktion könnte wie folgt aussehen:

Die Klasse Person ist einfach zu entwerfen:

Auch die Klasse Personalverwaltung ist nach den obigen Ausführungen nicht schwierig, wenn man sich zunächst auf die Prototypen der Methoden beschränkt:

Für die Implementierung der Methoden der Klasse Personalverwaltung muss man sich mehr Gedanken machen. Das überlasse ich Ihnen (siehe die nächste Aufgabe)! Die Lösung dürfte aber nicht schwer sein, wenn Sie die Aufgabe 1.25 von Seite 112 gelöst oder deren Lösung nachgesehen haben.

Image

Übungen

3.5 Implementieren Sie die oben deklarierten Methoden der Klasse Personalverwaltung in einer Datei Personalverwaltung.cpp.

3.6 Wie können Sie mit C++ erreichen, dass ein Attribut direkt, also ohne Einsatz einer Methode, zwar gelesen, aber nicht verändert werden kann? Beispiel:

Wie sieht die Realisierung in der Klasse MeineKlasse aus? Tipp: Denken Sie an eine Referenz als Attribut.

3.7 Schreiben Sie auf Basis der Lösung der Taschenrechner-Aufgabe 2.10 von Seite 151 eine Klasse Taschenrechner, die einen eingegebenen String verarbeitet. Die Anwendung könnte wie folgt aussehen:

Der auf den Seiten 134-135 verwendete Funktionsaufruf cin.get(c); muss dabei durch den Aufruf einer Funktion ersetzt werden, die das jeweils nächste Zeichen des übergebenen Anfrage-Strings holt.

Image

3.8 Gegenseitige Abhängigkeit von Klassen

Was tun, wenn bei zwei Klassen jede die Methoden der jeweils anderen Klasse benutzt? Es nutzt nichts, die Header-Datei der jeweils anderen Klasse miteinzuschließen, weil der Compiler die nötigen Informationen nicht bekommt. Betrachten wir zwei Header-Dateien, die sich aufeinander beziehen:

// Datei A.h #ifndef A_h #define A_h #include"B.h" .... usw. #endif
// Datei B.h #ifndef B_h #define B_h #include"A.h" .... usw. #endif

Wenn A.h zuerst gelesen wird, wird bei Ausführung der dritten Zeile B.h eingelesen. Die Ausführung der dritten Zeile von B.h scheitert jedoch, weil A_h nun definiert ist und der Rest von A.h nicht zur Kenntnis genommen wird. Die Lösung des Problems besteht in der Vorwärtsdeklaration:

// Datei A.h #ifndef A_h #define A_h class B; //Vorwärtsdeklaration class A { public: void benutzeB(const B&); void eineAMethode(); // ... usw. }; #endif
// Datei B.h #ifndef B_h #define B_h class A; //Vorwärtsdeklaration class B { public: void machWasMitA(A*); void eineBMethode() const; // ... usw. }; #endif

Die Notation A* bedeutet »Zeiger2 auf Objekt der Klasse A«. In den Header-Dateien werden gegenseitig nur die Klassennamen bekannt gemacht. Die Kenntnisnahme der Methoden wird auf die Implementierungsdateien verschoben. Dies funktioniert dann, wenn die Header-Dateien ausschließlich Zeiger oder Referenzen der jeweils anderen Klasse enthalten, aber keine Methodenaufrufe. Dies kann leicht erreicht werden, wenn auf inline-Methoden verzichtet wird, die Methoden der anderen Klasse benutzen. Die dazu notwendige Struktur wird für zwei Klassen gezeigt, eine Erweiterung auf die gegenseitige Abhängigkeit mehrerer Klassen ist nach diesem Muster leicht möglich. Die Implementierungsdateien schließen die Header-Dateien auch der anderen Klasse ein, wobei die Reihenfolge der Include-Anweisungen keine Rolle spielt. Nun können Methoden der jeweils anderen Klasse problemlos in den Implementierungsdateien aufgerufen werden.

// Datei A.cpp #include"A.h" #include"B.h" void A::benutzeB(const B& b) { b.eineBMethode(); } void A::eineAMethode() { .... usw.
// Datei B.cpp #include"B.h" #include"A.h" void B::machWasMitA(A* pA) { pA->eineAMethode(); } void B::eineBMethode() const { .... usw.

Es gibt natürlich auch Fälle, wo einer Klasse die Größe eines Objekts einer anderen Klasse bekannt sein muss, wenn zum Beispiel die Klasse A ein Objekt der Klasse B aggregiert. In diesem Fall hilft die Vorwärtsdeklaration, unsymmetrisch angewendet, ebenfalls weiter, wie das folgende Listing zeigt. Damit kann natürlich nicht der unwahrscheinliche Fall gelöst werden, dass die Klasse A ein Objekt der Klasse B aggregieren und Klasse B ein Objekt der Klasse A einschließen soll. Wer das unbedingt möchte, sollte seinen Entwurf noch einmal überdenken. Wenn es denn sein muss, kann dieser Fall mit als Zeiger auf A bzw. B realisierten Attributen gelöst werden, an die der Konstruktor zur Laufzeit dynamisch erzeugte Objekte hängt.

// Datei A.h #ifndef A_h #define A_h // Klasse B einschließen: #include"B.h" class A { public: void benutzeB(const B&); void eineAMethode(); private: B einB; // aggregiertes Objekt // .... usw. }; #endif
// Datei B.h #ifndef B_h #define B_h // Vorwärtsdeklaration: class A; class B { public: void machWasMitA(A*); void eineBMethode() const; // .... usw. }; #endif

1 Dieser Abschnitt kann beim ersten Lesen übersprungen werden.

2 Zeiger werden erst in Kapitel 4 besprochen, aber hier der Vollständigkeit halber mit erwähnt.