Twitter und Facebook-Anbindung
X
Tweet Follow @twitterapi
!!! Anbindung an twitter und facebook öffnen !!!

Wenn Ihnen mein Online-Buch gefällt,
dann bedanken Sie sich doch mit einer kleinen Spende...

F Objektorientiertes Programmierungen

F Objektorientiertes Programmierungen

In den folgenden Kapiteln werde ich mit Ihnen das Thema Objekten und Klassen besprechen. In der heutigen Zeit sind Objekte nicht mehr wegzudenken, zumal neuere Sprachen gar nicht mehr ohne Sie auskommen (z.B. Java). Zu beginn wird es erst einmal ein wenig ungewohnt, weil Sie in mancher Hinsicht umdenken müssen und gerade für sehr kleine Projekte, werden Sie den größeren Implementierungsaufwand zum Nutzen, nicht einsehen. Ich werde mir allerdings Mühe geben, immer mal auf komplexere Situationen hinzuweisen, um die Vorteile deutlich hervorheben zu können.

Zum Seitenanfang
Zum Inhaltsverzeichnis

25 Funktionszeiger

25 Funktionszeiger

Bisher habe ich eine kleine Tatsache unterschlagen, nämlich, dass man auch Zeiger auf Funktionen zeigen lassen kann und genau darum wird es in diesem Kapitel gehen. Normalerweise hätte man dieses Kapitel schon eher bringen können, aber in meinen Augen macht dieses Thema erst bei Klassen sinn.

Zum Seitenanfang
Zum Inhaltsverzeichnis

25.1 Wozu braucht man Funktionszeiger

25.1 Wozu braucht man Funktionszeiger

Im ersten Augenblick macht es nicht viel Sinn, einen Zeiger auf eine Funktion zu definieren, aber dieser Mechanismus ist eigentlich sehr interessant, wenn man Klassen bauen will, deren Funktionalität nachträglich erweitert werden soll. Nun könnte man denken, dass man dies auch mit Vererbung hin bekommt, aber ich meine was anderes.

Stellen Sie sich vor, Sie bauen eine Klasse, welche diverse Attribute besitzt und eine Menge für Sie erledigt. Jetzt soll die Klasse noch zwei Methoden besitzen, um ihre Attributwerte abzuspeichern bzw. sie zu laden. Allerdings wollen Sie sich jetzt noch nicht festlegen, wie und wo die Daten abgelegt werden sollen (z.B. nur in einer Textdatei, oder evtl. auch in einer Datenbank). Dann könnte es ja auch vorkommen, dass sich manche Objekte gar nicht abspeichern sollen. Wie bekommen Sie so etwas hin?

Ein Ansatz wäre, eine Basisklasse zu bauen, die sich weder laden noch speichern kann und dann für jede Erdenkliche Art der Datenhaltung, eine extra Klasse ableiten, welche dann lediglich jeweils die zwei Methoden überschreibt. Aber macht es wirklich Sinn, wegen einer oder zwei Methoden gleich eine neue Klasse zu bauen? Nicht unbedingt und genau hier setzen Funktionszeiger an.

Man kann in die Klasse einfach zwei Zeiger einbauen, die auf Funktionen Zeigen können, welche diese Aufgaben erledigen. Wenn man sich dann eine Instanz der Klasse erzeugt, kann man eine beliebige Funktion an diese Zeiger dranhängen und wenn dann in der Klasse festgestellt wird, dass die Zeiger auf etwas sinnvollen zeigen, können die Funktionen, auf welche die Zeiger verweisen, aufgerufen werden. So erweitert man also ggf. die Funktionalität einer Klasse, ohne sie umschreiben oder neu definieren zu müssen.

Dieses Konzept wird auch sehr oft bei Fensterklassen (grafische Programmierung) benutzt. Beispielsweise gibt es bei Schaltflächen mehrere Funktionszeiger oder auch Funktionshandler genannt, welche für diverse Aufgaben gedacht sind. Eine ist z.B. das reagieren auf einen Mausklick. Genauso gibt es Möglichkeiten, auf das drüber Ziehen der Maus zu reagieren, um die Schaltfläche einzufärben. Da diese Methoden einer Schaltfläche aber nicht zwingend zu ihr gehören, bieten sie nur Zeiger an, an welche man seine eigenen Methoden hängen kann und die auch nur aufgerufen werden, wenn es sie gibt. Normalerweise gehört zu dieser Thematik noch ein s.g. Eventhandling und ganz so, wie ich es eben erklärt habe, funktioniert es in der Realität nicht, aber ich denke, dass man hier gut versteht, warum Funktionszeiger benutzt werden bzw. notwendig sind.

Zum Seitenanfang
Zum Inhaltsverzeichnis

25.2 Ein kleines Beispiel

25.2 Ein kleines Beispiel

Da ich Ihnen jetzt bestimmt schon den Mund wässrig gemacht habe, will ich jetzt zeigen, wie man Funktionszeiger baut und sie einbindet. Für diesen Zweck habe ich eine kleine Testklasse gebaut, welche nur zwei Funktionszeiger für die Demonstration besitzt, sowie zwei Methoden, welche ggf. die Funktionen, auf welche die Zeiger verweisen, aufrufen. Schauen Sie sich zunächst den Header an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
					
#pragma once

#include <stdlib.h>

typedef void (*FAusgabe)();
typedef int (*FSumme)(int, int);

class CTest {
	private:
		FAusgabe	m_pFunc1;
		FSumme		m_pFunc2;

	public:
		// Standardkonstruktor - Initialisieren
		CTest(FAusgabe = NULL, FSumme = NULL);

		// Übergebene Funktion 1 ausführen
		void Execute1(void);

		// Übergebene Funktion 2 ausführen
		int Execute2(int iSummand1, int iSummand2);
};
					

Da die Schreibweise von Funktionszeigern etwas länger ausfallen und zudem Verwirrung stiften kann, habe ich in Zeile 5 und 6, zwei Typdefinitionen gemacht. Erstere bedeutet, dass ich mir einen Funktionszeiger mit dem Typnamen "FAusgabe" gebaut habe, welcher auf eine Funktion zeigen kann, welche weder einen Rückgabewert, noch Übergabeparameter besitzt. An diesen Zeiger können dann auch nur solche Funktionen gehangen werden. Die zweite Typendefinition bedeutet jetzt, dass ich einen Funktionszeiger, mit dem Typnamen "FSumme" definiere, welcher auf Funktionen zeigen kann, welche einen Integer zurückgeben und zwei Integer als Übergabeparameter besitzen. Wie diese Parameter im einzelnen heißen, spielt hier keinerlei Rolle.

In Zeile 10 und 11 definiere ich mir jetzt die zwei besagten Zeiger. Hier benutze ich jetzt die eben definierten Typennamen. Auffällig ist hier, dass man kein "*" benutzt. Um aber irgendwie trotzdem kenntlich zu machen, dass es sich um einen Pointer handelt, leite ich den Typnamen gerne mit einem großen "F" ein und lasse in den Variablennamen das kleine "p" einfließen. Manchmal sieht man bei dem Variablen - bzw. Attributnamen, auch den Präfix "pof", was so viel heißen soll wie "Pointer of Function".

In Zeile 15 definiere ich den Konstruktor und gebe dem Benutzer dieser Klasse die Möglichkeit, entsprechende Funktionen zu übergeben. Standardmäßig gehe ich aber davon aus, dass es keine gibt, was den Defaultwert für die Zeiger erklärt. Alternativ braucht man die Funktionen aber nicht im Destruktor mit übergeben und kann sich entsprechende Set-Methoden bauen. Dies wäre auch der übliche weg, aber das hätte das Beispiel zum einen unnötig aufgebläht und ich wollte auch mal einen anderen Weg aufzeigen.

Der Rest der Header-Datei sollte soweit klar sein und deswegen schauen wir uns nun die entsprechende CPP zu dieser Klasse an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
					
// Standardkonstruktor - Initialisieren ///////////////////////////////////////
CTest::CTest(FAusgabe pFunc1, FSumme pFunc2)
	: m_pFunc1(pFunc1)
	, m_pFunc2(pFunc2)
{} // CTest ///////////////////////////////////////////////////////////////////



// Übergebene Funktion 1 ausführen ////////////////////////////////////////////
void CTest::Execute1() {
	// Wenn dem Funktionszeiger etwas zugewiesen wurde
	if (this->m_pFunc1 != NULL) {
		this->m_pFunc1();
	} // end of if
} // Execute1 /////////////////////////////////////////////////////////////////



// Übergebene Funktion 2 ausführen ////////////////////////////////////////////
int CTest::Execute2(int iSummand1, int iSummand2) {
	if (this->m_pFunc2 != NULL) {
		return this->m_pFunc2(iSummand1, iSummand2);
	} else {
		return 0;
	} // end of if
} // Execute2 /////////////////////////////////////////////////////////////////
					

In Zeile 2 bis 5 sehen Sie jetzt die Implementierung des Konstruktors. Wie man sieht, kann man Funktionszeiger ganz normal initialisieren wie andere Attribute auch.

Die Methode "Execute1", welche in den Zeilen 10 bis 15 implementiert ist, sorgt dafür, dass die erste Funktion aufgerufen wird. Dies geschieht aber nur, wenn der Funktionszeiger auf etwas verweist. Der eigentliche Aufruf der Funktion sieht nun so aus, dass man so tut, als wäre der Pointer der Funktionsname und würde zur Klasse gehören.

Die Methode "Execute2", welche in Zeile 20 bis 26 implementiert ist, sorgt jetzt dafür, dass die zweite Funktion aufgerufen wird. Das Verhalten ist wieder genauso wie in der vorhergehenden Methode. Hier wird auch wieder die Funktion aufgerufen, auf welche der Zeiger verweist und dessen Ergebnis zurück gegeben.

Schauen Sie sich nun die "main" an, in welcher ich die zwei auszuführenden Funktionen implementiert habe und mir ein Objekt der eben definierten Klasse erzeuge, welches dann meine externen Funktionen benutzen wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
					
// Einfache Ausgabefunktion ///////////////////////////////////////////////////
void MeineAusgabeFunktion() {
	printf("Hallo Welt\n");
} // MeineAusgabeFunktion /////////////////////////////////////////////////////



// Einfache Summenfunktion ////////////////////////////////////////////////////
int MeineSummenFunktion(int iSummand1, int iSummand2) {
	return iSummand1 + iSummand2;
} // MeineSummenFunktion //////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	CTest oTest(&MeineAusgabeFunktion, &MeineSummenFunktion);

	oTest.Execute1();

	int iErgebnis	= oTest.Execute2(3, 4);
	printf("%i\n", iErgebnis);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Hallo Welt
7
		

In den Zeilen 2 bis 11 sehen Sie nun die zwei Funktionen, auf welche die Zeiger später verweisen sollen. Ich betone nochmals, dass die Funktionsköpfe genau so aussehen müssen, wie es die Definition der Funktionszeiger vorsieht.

In Zeile 17 erzeuge ich jetzt besagtes Objekt und übergebe die Adressen der beiden Funktionen, an den Konstruktor. Wichtig ist hier, dass man wirklich vor den Funktionsnamen den Adressoperator "&" schreibt, weil man sonst nichts übergeben würde, sondern die Funktionen aufruft und ggf. deren Rückgabewert übergibt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

22 Mehrfachvererbung

22 Mehrfachvererbung

Mit Mehrfachvererbung ist gemeint, dass eine Klasse von mehr als nur einer Basisklasse erben kann. Dies klingt zwar auf den ersten Anhieb ganz genial, aber der erfahrene Programmierer macht einen weiten Bogen um diesen Mechanismus, weil er einem unterm Strich mehr Probleme macht, als er Nutzen bringt. Der Vollständigkeit halber, möchte ich dieses Thema aber nicht unter den Tisch fallen lassen und werde ganz detailliert erklären, warum man sich mit diesem Mechanismus das Leben unnötig schwer macht.

Zum Seitenanfang
Zum Inhaltsverzeichnis

22.1 Ein harmloses Beispiel

22.1 Ein harmloses Beispiel

In meinem Beispiel erbt die Klasse "CCadillac" von "„CAuto" und von "CCabrio". Somit besitzt die Klasse alle Eigenschaften eines Autos und eines Cabrios.

Veranschaulicht eine Mehrfachvererbung

Im der Header-Datei würde dies dann folgendermaßen aussehen.

 1
 2
 3
					
class CCadillac : public CAuto, public CCabrio {
	// Prototypen der Klasse
};
					

Wie Sie in Zeile 1 sehen können, ist es also durchaus möglich, mehrere Basisklassen, mit Komma getrennt, anzugeben. Die Klasse kann dann auf die Attribute und Methoden beider Basisklassen zugreifen und auch über den entsprechenden Namespace, auf virtuelle Methoden zugreifen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

22.3 Virtuelles Erben

22.3 Virtuelles Erben

Ich hatte schon einmal angedeutet, dass es möglich ist, auch virtuell zu erben, hatte aber nicht weiter erklärt, was damit gemeint ist und wozu es gut ist. Dies werde ich an dieser Stelle nachholen.

Sie hatten ja feststellen müssen, dass es mit der Mehrfachvererbung einige Schwierigkeiten gibt. Mit einer virtuellen Vererbung kann man ein paar dieser Schwierigkeiten umschiffen. Ein großer Nachteil war ja, dass es zu Dopplungen in abgeleiteten Klassen kommt, die bei einer diamantförmigen Vererbung störend ist. Wenn man aber die mittleren Klassen virtuell von der obersten Klasse erben lässt, hat man dieses Problem nicht und das Speicherlayout ähnelt dem, welches wir uns zuerst gewünscht hätten. Ich passe also das eben genannte Beispiel leicht an und das ganze sieht dann wie folgt aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
					
class CTransportmittel {
	public:
		int m_iPositionX;
		int m_iPositionY;
		int m_iPositionZ;
};



class CFahrzeug : public virtual CTransportmittel {
	public:
		int m_iReader;
};

class CFlugzeug : public virtual CTransportmittel {
	public:
		int m_iTriebwerke;
};

class CSchiff : public virtual CTransportmittel {
	public:
		int m_iSegel;
};



class CWasserFlugzeug : public CFahrzeug, public CFlugzeug, public CSchiff {
	public:
		int m_iPontons;
};

// ...

printf("CTransportmittel %i\n", sizeof(CTransportmittel));
printf("CFahrzeug        %i\n", sizeof(CFahrzeug));
printf("CFlugzeug        %i\n", sizeof(CFlugzeug));
printf("CSchiff          %i\n", sizeof(CSchiff));
printf("CWasserFlugzeug  %i\n", sizeof(CWasserFlugzeug));
					

Ausgabe:

CTransportmittel 12
CFahrzeug        20
CFlugzeug        20
CSchiff          20
CWasserFlugzeug  40
		

Wie Sie sehen, hat sich vom Quelltext her nicht viel geändert, aber die Größen der Klassen haben sich wesentlich verändert. Wie kommen die Werte Zustande? Die Größe der Klasse "CTransportmittel" ist gleich geblieben und somit auch nachvollziehbar, aber die Klassen "CFahrzeug", "CFlugzeug", "CSchiff" und "CWasserFlugzeug" sind auf einmal anders.

Die drei mittleren Klassen, welche virtuell erben, haben jetzt auch noch einen Pointer zusätzlich auf eine eigene virtuelle Tabelle hinzubekommen. Rechnen wir mal nach. Zwölf Byte für die Basisklasse plus drei mal acht Byte für die mittleren Klassen (Attribut plus VT-Pointer) sind zusammen 36. Jetzt sagte ich noch, dass auch die Klasse "CWasserFlugzeug" einen Pointer auf die VT bekommt, plus die vier Byte für das eigene Attribut. Da hätten wir eine Gesamtsumme von 42. Die Ausgabe hat aber gezeigt, dass die Klasse nur 40 Byte groß ist. Wie kann das sein? Um dies zu verstehen, habe ich in folgender Grafik das Speicherlayout vereinfacht dargestellt.

Speicherlayout bei einer virtuellen Vererbung

Wie Sie sehen können, teilen sich "CFahrzeug" und "CWasserFlugzeug" einen VT-Pointer. Dies ist für dieses einfache Beispiel möglich, denn der Visual Studio Compiler ist extrem effizient und optimiert so weit wie er kann. Mit anderen Compilern oder komplexeren Strukturen, kann die Sache aber schon ganz anders aussehen.

Sie merken also, dass das Speicherlayout sehr komplex werden kann und es ist auch meistens so, dass die Klassen nicht mehr zusammenhängend im Speicher stehen, was nicht gerade Cachefreundlich ist. Wir umgehen damit zwar jetzt die Dopplungen aber wir erkaufen uns dies durch zwei Nachteile. Zum einen kommen jetzt zusätzliche virtuelle Tabellen hinzu, welche wiederum Speicher benötigen und für jedem Zugriff eine Indirektionsstufe mehr benötigen (es wird langsamer). Zum anderen ist es jetzt nicht mehr möglich, eine Instanz der Klassen "CFahrzeug", "CFlugzeug" und "CSchiff" zu erzeugen, da sie jetzt abstrakte Klassen sind. Wie sieht es denn jetzt mit dem Polymorphismus aus? Der Upcast funktioniert wieder einwandfrei, aber der Downcast bleibt nach wie vor ein großes Problem und die Argumentation fällt wieder genauso aus. Es wird sogar noch schlimmer. Bei einer einfachen Mehrfachvererbung war es zu Mindestens möglich, mit Hilfe von Zeigern auf Objekte eine Hierarchieebene nach unten zu casten. Da die Positionsattribute jetzt aber in die oberste Basisklasse gewandert sind, ist auch dies jetzt nicht mehr möglich.

Das Fazit ist also, virtuelle Vererbung scheint zwar im ersten Augenblick die Lage zu entschärfen, aber bei näherem Betrachten fängt man sich noch mehr Ärger ein. Sie sollten sich also merken, dass egal wie Sie es anstellen, eine Mehrfachvererbung immer ungünstig ist. Meistens kann man diese Problematiken mit einer gekonnten Aggregation/Komposition umgehen. Die von mir genannten Beispiele hinken sowieso, da man sich bei Computerspielen eher für s.g. Leichtgewichtsobjekte entscheidet und das Zeichnen nicht den Klassen überlässt, sondern eine eigene Renderklasse dafür implementiert. Ich werde zu einem späteren Zeitpunkt diese Thematik wieder aufgreifen, wenn es um die Design Pattern geht.

Zum Seitenanfang
Zum Inhaltsverzeichnis

22.2 Diamantförmige Vererbung

22.2 Diamantförmige Vererbung

Bisher habe ich Ihnen zwar prinzipiell gezeigt, wie eine Mehrfachvererbung vom Prinzip her funktioniert, aber ich konnte Ihnen noch nicht zeigen, welche negativen Konsequenzen sich daraus ergeben und warum dieses Konzept in vielen anderen Sprachen nicht implementiert wird.

Mit einer diamantförmigen Vererbung ist gemeint, dass es irgendwo eine Basisklasse gibt, wovon zwei oder mehr andere Klassen erben. Anschließend gibt es eine weitere Klasse, welche genau von diesen abgeleiteten Klassen erbt. Im Klassendiagramm ergibt sich das eine Art Raute oder Diamant. Dies soll folgendes Beispiel demonstrieren.

Diamantförmige Vererbung

Wie Sie sehen, habe ich mir ein Beispiel ausgedacht, welches zum einen komplexer ist und zum anderen vom bisherigen Beispiel abweicht. Dies war notwendig, damit ich Ihnen alle wichtigen Aspekte n6AUML,her bringen kann. Schauen Sie sich zunächst die Klassendefinitionen an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
					
class CTransportmittel {
	public:
		int m_iPositionX;
		int m_iPositionY;
		int m_iPositionZ;
};



class CFahrzeug : public CTransportmittel {
	public:
		int m_iReader;
};

class CFlugzeug : public CTransportmittel {
	public:
		int m_iTriebwerke;
};

class CSchiff : public CTransportmittel {
	public:
		int m_iSegel;
};



class CWasserFlugzeug : public CFahrzeug, public CFlugzeug, public CSchiff {
	public:
		int m_iPontons;
};
					

Man würde jetzt ganz spontan von folgendem Speicherlayout ausgehen.

Gewünschtes Speicherlayout bei einer diamantförmigen Vererbung

Schauen wir uns doch mit einem kleinen Testprogramm an, ob dies tatsächlich der Wahrheit entspricht.

 1
 2
 3
 4
 5
					
printf("CTransportmittel: %i\n", sizeof(CTransportmittel));
printf("CFahrzeug:        %i\n", sizeof(CFahrzeug));
printf("CFlugzeug:        %i\n", sizeof(CFlugzeug));
printf("CSchiff:          %i\n", sizeof(CSchiff));
printf("CWasserFlugzeug:  %i\n", sizeof(CWasserFlugzeug));
					

Ausgabe:

CTransportmittel: 12
CFahrzeug:        16
CFlugzeug:        16
CSchiff:          16
CWasserFlugzeug:  52
		

Ich denke, die ersten vier Werte sind nachvollziehbar. Fahrzeug, Flugzeug und Schiff erben die zwölf Byte der Basisklasse und bringen selbst noch jeweils vier Byte hinzu. Rechnet man jetzt aber vier Byte mal drei erbende Klassen, plus zwölf Byte der obersten Basisklasse, plus vier Byte für die Pontons, erhält man 28. Scheinbar sieht das Layout des Speichers doch anders aus.

Speicherlayout bei einer diamantförmigen Vererbung

Diese Abbildung gleicht schon eher der Realität. Rechnen wir mal nach. Sechzehn mal drei erbende Klassen plus vier Byte für die Pontons, ergibt 52. Ihnen sollte jetzt auffallen, dass es drei mal die Attribute für die Positionsangabe gibt. Wenn man also von außen auf ein Objekt der Klasse zugreift und man die Position ändern will, fragt sich jetzt, auf welche Positionsangabe man Zugriff hat, bzw. wie man diese unterscheiden kann. Genau diese Frage stellt sich der Compiler auch und wird einen normalen Zugriff bemängeln. Deshalb muss man immer den Namensraum mit angeben. Dies könnte dann wie folgt aussehen.

 1
 2
					
CWasserFlugzeug oWasserFlugzeug;
oWasserFlugzeug.CSchiff::m_iPositionX = 10;
					

Diese Schreibweise sieht nicht nur komisch aus, sie ist auch unpraktisch. Stellen Sie sich vor, Sie wollten tatsächlich mit dieser Klasse arbeiten und das Objekt würde sich bewegen. Um alles konsistent zu halten, müssten Sie also jeweils drei mal die X, Y und Z Position ändern. Im Zweifelsfall ist die Vererbungsstruktur noch größer und Sie wissen gar nicht, wie oft es diese Positionsangabe noch gibt.

Sie sehen also schon an diesem trivialen Beispiel, dass es zu großen Unannehmlichkeiten kommen kann. Dies ist der Grund, warum viele Programmiersprachen auf dieses Konzept verzichten. In Java ist z.B. eine Mehrfachvererbung nur in dem Sinne möglich, dass man von einer normalen und dann nur noch von zusätzlichen vollständig abstrakten Klassen (Interfaces) erbt. In diesem Fall kommt es zu keinen Dopplungen, da vollständig abstrakte Klassen weder Attribute noch Methoden bereitstellen. Es gibt, wie bereits erwähnt, nur Muster für Methoden, die in abgeleiteten Klassen zu implementieren sind.

Ein weiterer großer Nachteil ist, das man mit solchen Strukturen arge Probleme mit dem Polymorphismus bekommt. Der Upcast funktioniert in der Regel noch, aber spätestens der Downcast scheitert. Schauen wir uns folgenden Quelltext an.

 1
 2
 3
 4
 5
 6
 7
					
CWasserFlugzeug		oWasserFlugzeug;
CFlugzeug		oFlugzeug		= oWasserFlugzeug;
CTransportmittel	oTransportmittel	= oFlugzeug;

// Folgende Zeilen bringen einen Compilerfehler
oWasserFlugzeug					= static_cast<CWasserFlugzeug>(oFlugzeug);
oFlugzeug					= static_cast<CFlugzeug>(oTransportmittel);
					

Warum sind die Downcasts nicht mehr möglich? Zeile 6 funktioniert nicht, weil es keinen geeigneten benutzerdefinierten Konstruktor gibt. Sicher könnte man für die Klasse "CWasserFlugzeug" einen Konstruktor bauen, der sich aus einem Flugzeug erzeugen kann und in ihm kopiert man die Werte in die Positionsangaben für Fahrzeug, Flugzeug und Schiff, aber mit was initialisiert man dann "m_iReader", "m_iSegel" und "m_iPontons"? Man kommt ja an diese Informationen nicht mehr heran. Zeile 7 funktioniert aus den selben Gründen nicht. Auch hier würden wieder geeignete Konstruktoren fehlen, bei welchen noch mehr Informationen verloren gehen würden. Geeignete Konstruktoren lösen die Sache also nicht wirklich.

Wenn man jetzt Zeiger benutzt, kommt man um das Problem der Konstruktoren herum, da nur die Zeiger neu interpretiert werden müssen. Schauen wir uns ein Beispiel an.

 1
 2
 3
 4
 5
 6
 7
 8
					
CWasserFlugzeug*	pWasserFlugzeug		= new CWasserFlugzeug();
CFlugzeug*		pFlugzeug		= pWasserFlugzeug;
CTransportmittel*	pTransportmittel	= pFlugzeug;

pWasserFlugzeug					= static_cast<CWasserFlugzeug*>(pFlugzeug);
pFlugzeug					= static_cast<CFlugzeug*>(pTransportmittel);

delete pWasserFlugzeug;
					

Dieses Beispiel akzeptiert der Compiler. Zeile 5 und 6 funktionieren, welch die Klassen, auf welche die Zeiger verweisen, in einer direkten und eineindeutigen Vererbungshierarchie stehen. Eine Umwandlung von Transportmittel zurück zu einem Wasserflugzeug ist nicht möglich, da bei einem Zeiger keinerlei Typinformationen vorhanden sind und somit muss der Compiler davon ausgehen, dass es vorher kein Wasserfahrzeug war. Könnte ja ebenso gut ein Fahrzeug, Flugzeug oder Schiff gewesen sein.

Wenn man in den letzten Kapiteln aufgepasst hat, könnte man für genau letzten Fall ein reinterpretierenden Cast benutzen und der Compiler würde sich nicht beschweren, aber spätestens beim Freigeben des Wasserflugzeuges würde es böse krachen. Der Speicherbereich wird neu interpretiert und das hat zur folge, dass das Speicherlayout total durcheinander geworfen wird (ich verkneife mir an dieser Stelle ein Beispiel).

Sie sehen also, dass eine Mehrfachvererbung mehr Nachteile als Vorteile bringt. Man benötigt mehr Speicher, Variablen können doppelt auftreten und ein Polymorphismus ist nicht wirklich anwendbar. Wenn Sie also eine Mehrfachvererbung nutzen, dann bitte nur mit vollständig abstrakten Klassen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

19 Vererbung

19 Vererbung

Mit Vererbung ist gemeint, dass Klassen von s.g. Basisklassen Attribute und Methoden erben können. Das heißt, die Klasse hat dann eigene Attribute und Methoden, sowie jene der Basisklasse und ggf. auch die der Basisklasse der Basisklasse.

Ziel der Vererbung ist folgende. Angenommen man soll mehrere Klassen entwerfen und man stellt fest, dass es Gemeinsamkeiten gibt. Bisher musste man diese für jede Klasse einzeln implementieren, was redundanten Quelltext bedeutet. Die Grundidee der OOP ist nun, dass man diese Gemeinsamkeiten in einer Basisklasse (die oftmals rein abstrakt ist) zusammen fasst und die eigentlichen Klassen von dieser Erben lassen.

Immer dann, wenn man Gemeinsamkeiten in einer Basisklasse kapselt, spricht man von einer Generalisierung. Hat man einen allgemeinen Fall (Basisklasse) und leitet davon ausgehend andere Klassen ab, welche besondere Zusatzfunktionen haben, spricht man von einer Spezialisierung. Eine Basisklasse und seine Kindklasse stehen in einer s.g. "part of" Beziehung (Die Basisklasse ist Teil der Kindklasse).

Part of Beziehung zwischen zwei Klassen

Dieses Konzept ist für Neulinge etwas verwirrend, bietet aber sehr viele Vorteile. Um diese aufzuzeigen, werde ich alle Mittel und Wege an einem großen Beispiel erläutern.

Zum Seitenanfang
Zum Inhaltsverzeichnis

19.3 Geschützte Attribute und Methoden

19.3 Geschützte Attribute und Methoden

Immer dann, wenn es Sachen gibt, die man in abgeleiteten Klassen haben möchte, aber nicht nach außen zugreifbar machen will, dann deklariert man diese als "protected".

Ich habe z.B. in der Basisklasse "CFahrzeug" die Methode "Zeichnen" geschützt, da ich nicht möchte, dass sie von außen aufgerufen werden soll. Nun könnten Sie sich fragen, was dies für ein Sinn macht. Die Antwort ist, ich möchte vermeiden, dass man das Fahrzeug zeichnen kann, wenn zum einen noch nicht alle Komponenten erzeugt wurden und zum anderen auch nur, wenn es notwendig ist. Hat sich das Fahrzeug z.B. nicht von der Stelle bewegt, ist es unnötig es neu zu Zeichnen. Dies würde im schlimmsten Fall nur zu einem Ruckeln führen. Stattdessen soll dies nur durch die Methode "Aktualisieren" aufgerufen werden, welche ihrerseits erst einmal die Rahmenbedingen prüft und dann nur ggf. das Fahrzeug neu zeichnet.

Oftmals sieht man aber, dass gerade Zeichen-Methoden öffentlich sind. Mit meinem Beispiel wollte ich nur darauf hindeuten, dass man von Zeit zu Zeit anders denken muss.

Zum Seitenanfang
Zum Inhaltsverzeichnis

19.4 Virtuell und Abstrakt

19.4 Virtuell und Abstrakt

Was hat es nun mit diesem virtuell und abstrakt auf sich? Zunächst einmal ist zu sagen, dass diese Begriffe in den unterschiedlichsten Zusammenhängen verwendet werden und oftmals das Gleiche meinen. Dies ist allerdings nicht korrekt. Spricht man von einer virtuellen Methode, so ist damit gemeint, dass es sich um eine Methode handelt, die in der Basisklasse implementiert ist, aber durch eine abgeleitete Klasse überschrieben werden kann, aber nicht muss. Man nutzt dies hauptsächlich, um bestehende Funktionalitäten zu erweitern. In meinem Beispiel sehen Sie so etwas an der Methode "Zeichnen". Auch wenn sie in der Klasse "CMotorrad" bzw. in der Klasse "CAuto" im Klassendiagramm nicht aufgeführt ist, so könnte sie in den Basisklassen die Farbe des "Zeichenstiftes" festlegen und andere vorbereitende Dinge erledigen. Die abgeleiteten Klassen könnten diese dann einfach nur aufrufen und sich allein auf das eigentliche Zeichnen beschränken. Somit spart man wieder redundanten Quelltext für jede der abgeleiteten Klassen. Dies sähe z.B. folgendermaßen aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
			
void CMotorrad::Zeichnen() {
	// Prüfen ob alle Komponenten erzeugt wurden
	// ...

	// Stiftfarben setzen und Hintergrund neu malen
	// ...
}

void CHarleyDavidson::Zeichnen() {
	// Aufruf der virtuellen Methode der Basisklasse
	CMotorrad::Zeichnen();

	// Motorrad Zeichnen
	// ...
}
					

In der Definition der Klassen, also in den Header-Dateien, signalisiert man dies, indem man vor die entsprechende Methode das Schlüsselwort "virtual" schreibt. Dies genügt in der Basisklasse, da man aber nie weiß, welche Klassen von der Aktuellen später noch abgeleitet werden sollen, welche wiederum das Zeichnen anders implementieren und die Funktionalität der Basisklasse nutzen möchten, schreibt man das "virtual" auch vor die Methodendefinition der aktuellen Klasse. Wenn eine Methode einmal als virtuell gekennzeichnet ist, wird sie in der gesamten nachfolgenden Vererbungshierarchie virtuell bleiben (man kann sie also immer überschreiben). Dessen sollten Sie sich immer bewusst sein.

Des weiteren ist es durchaus möglich, die virtuelle Methode, einer in der Hierarchie weiter oben liegenden Klasse aufzurufen, falls man die Funktionalität der dazwischenliegenden Klassen nicht braucht bzw. sie sogar störend sind. Man ruft aber nie die virtuellen Methoden aller abgeleiteten Klassen auf, da es logischerweise zu Dopplungen kommt. In diesem Beispiel kann die Klasse "CHarleyDavidson" aber nicht auf die Methode "Zeichnen" der Klasse "CFahrzeug" zugreifen, da jene nur abstrakt ist, was mich zum nächsten Thema führt.

Mit abstrakt ist gemeint, dass es in der Basisklasse eine formale Definition einer Methode gibt, welche jedoch nicht implementiert ist. Man möchte damit eine formale Regel definieren, welche besagt, dass es prinzipiell in einer abgeleiteten Klasse diese Methode geben kann und wenn dem so ist, dann sieht sie entsprechend aus und auf keinen Fall anders. Jede Methode, welche abstrakt definiert ist, ist somit auch virtuell. Manchmal verwendet man dann auch die Bezeichnung "virtuell abstrakt". Wie Sie eine solche abstrakte Methode definieren können, werde ich jetzt zeigen.

 1
 2
 3
 4
 5
					
class CFahrzeug {
	protected:
		// Formale Definition einer abstrakten Zeichnen-Methode
		void Zeichnen(void) = 0;
};
					

Die Syntax für abstrakte Methoden sieht etwas merkwürdig aus, da man fast glauben könnte, dass es eine Inlinedeklaration ist. Tatsächlich ist es auch im Prinzip so etwas. Man bringt dem Compiler so bei, dass er in der entsprechenden CPP Datei nicht nach einer solchen Methode Ausschau halten muss.

An dieser Stelle sei noch erwähnt, dass nur Methoden virtuell und oder abstrakt sind.

Wenn man von abstrakten Basisklassen spricht, dann meint man Klassen, welche virtuell abstrakte Methoden besitzen. Eine sehr wichtige Eigenschaft dieser Klassen ist, dass man von ihnen keine Objektinstanz erzeugen kann (eben weil die Implementierung bestimmter Methoden fehlt). Aber wenn man nur von C++ ausgeht, kann eine Klasse auch als abstrakt definiert werden, obwohl sie es theoretisch nicht ist (keine abstrakten Methoden). Durch einen speziellen Modifikator kann dies erzwungen werden. In meinem großen Beispiel habe ich diesen Fall nicht bedacht, aber dennoch möchte ich Ihnen kurz zeigen, wie man eine solche Klasse definiert.

 1
 2
 3
					
class CBasisKlasse abstract {
	// Definition von Attributen und Methoden
};
					

Dann gibt es da noch rein abstrakte Klassen (pure abstract), welche ausschließlich aus virtuell abstrakten Methoden bestehen und keinerlei Attribute definieren. Man spricht auch von s.g. Interfaces. Dem Klassennamen wird hier gerne ein "I" statt ein "C" vorangestellt. Gerade in Java kommen Interfaces sehr oft zum Einsatz. Hier sehen Sie schon, dass abstrakt mehrere Bedeutungen haben kann. Alle haben sie aber eines gemeinsam, man kann von ihnen keine Objektinstanz erzeugen.

Es gibt zu guter Letzt auch noch virtuelle Klassen. Was das ist, werde ich spä erklären, wenn es um Mehrfachvererbung geht.

An dieser Stelle möchte ich Sie gleich auf einen Nachteil der Vererbung hinweisen. Wenn man tatsächlich Methoden in einer Basisklasse als virtuell deklariert, hat dies zur Folge, dass Objektinstanzen größer werden, als man meint. Dies soll folgendes Beispiel demonstrieren.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
					
class CBasis1 {
	public:
		int m_iBasis1;
		virtual void TestMethode1() {}
};



class CBasis2 {
	public:
		int m_iBasis2;
		void TestMethode2() {};
};



class CKindVonBasis1 : public CBasis1 {};

class CKindVonBasis2 : public CBasis2 {};

// ...

printf("%i %i\n", sizeof(CKindVonBasis1), sizeof(CKindVonBasis2));
					

Ausgabe:

8 4
		

Wie Sie sehen können, sind die Basisklassen und die abgeleiteten Klassen vom Aufbau identisch, aber trotzdem haben die zwei Klassen "CKindVonBasis1" und "CKindVonBasis2" eine unterschiedliche Größe. Dies liegt daran, dass bei der Klasse "CKindVonBasis1" ein Zeiger auf eine s.g. virtuelle Tabelle hinzugefügt wird. Die ist zwingend notwendig, da man ja virtuelle Methoden überschreiben kann und sie somit mehrfach vorhanden sein können (eben in unterschiedlichen Kontexten). Damit der Computer also weiß, welche Methode er aufzurufen hat, wird eine Liste geführt (die genaue Funktionsweise erspare ich mir an dieser Stelle, aber falls Sie interessiert sind, suchen Sie doch mal im Internet nach "virtual table"). Der Verweis auf eine solche Tabelle kostet 4 Byte. Nun könnte man sich denken, dass das erst einmal nicht so schlimm ist, aber wenn man irgendwann ein Spiel entwickelt mit einer Million Polygonen, macht es schon einen Unterschied, ob man 4 MB mehr Speicher benötigt oder nicht. Abgesehen davon können Methoden auch nicht mehr direkt aufgerufen werden. Im Hintergrund muss immer erst die richtige Methode gesucht werden (abgesehen davon muss auch erst die virtuelle Tabelle geladen werden). Dies macht einen Methodenaufruf noch langsamer.

Zum Seitenanfang
Zum Inhaltsverzeichnis

19.2 Privates, geschütztes und öffentliches Erben

19.2 Privates, geschütztes und öffentliches Erben

Als ich vorhin gesagt habe, dass man alle Attribute und Methoden der Basisklasse erbt, war dies nicht ganz die Wahrheit bzw. etwas unscharf formuliert, da es davon abhängt, wie man erbt. Am wenigsten Einschränkungen gibt es, wenn man "public" erbt. Dann stehen all die Attribute und Methoden zur Verfügung, welche in der Basisklasse "public" und "protected" sind. Alle anderen Sachen, welche "private" sind, werden verborgen, existieren aber noch im Hintergrund, denn wenn man eine Methode der Basisklasse aufruft, kann diese wiederum auf ihre privaten Attribute zugreifen. Man bekommt davon nur nichts mit, weil die privaten Attribute und Methoden in einer Art Blackbox versteckt sind.

Erbt man nun "privat", so ändert sich für die Klasse die erbt erst einmal nichts, aber von außen betrachtet wird schon einiges anders, da alle Attribute und Methoden der Basisklasse, auf einmal nicht mehr zur Verfügung stehen. Dies würde auch Klassen betreffen, die von der abgeleiteten Klasse abgeleitet sind. Ich habe diesen Fall nicht mit in das komplexe Beispiel eingebunden, aber ein möglicher Anwendungsfall wäre, dass man sich einen speziellen Container bauen möchte, welcher irgendwelche Elemente aufnehmen soll. Dazu erbt man am Besten von der Klasse "CList" ab, da man dann schon alle Funktionalitäten besitzt, welche zur Verwaltung von Listen notwendig sind. Nun möchte man aber nicht alle diese geerbten Methoden nach außen tragen, weil man kontrollieren will, was z.B. hinzugefügt oder entfernt wird. Um dies zu erreichen, würde man "privat" erben und entsprechende öffentliche Methoden bauen, die die gewünschten Überprüfungen durchführen und ggf. die Aktionen abblocken.

Ähnlich ist es, wenn man "protected" erbt, nur dass dann die öffentlichen Attribute und Methoden "protected" werden. Das hat zur Folge, dass sie zwar auch von außen nicht mehr zur Verfügung stehen, jedoch haben abgeleitete Klassen noch Zugriff. In folgender Grafik habe ich dies versucht zu visualisieren.

Öffentliches, geschütztes und privates Erben

Wie man sehen kann, verändert sich die Sichtbarkeit der Methoden in den abgeleiteten Klassen und in den vavon abgeleiteten Klassen, findet wiederum eine Veränderung statt. In meinem komplexen Beispiel bin ich aber auch auf diese Art der Vererbung nicht eingegangen, da man diese vielen Varianten nur sehr selten braucht und es immer schwer ist, entsprechende sinnvolle Fälle zu konstruieren. Ich werde also immer nur öffentlich erben.

Kommen wir aber nun wieder zurück zum eigentlichen Thema. Dort sehen Sie nun, dass z.B. die Klasse "CHarleyDavidson" von der Klasse "CMotorrad" abgeleitet ist, welche wiederum von der Klasse "CFahrzeug" erbt. Die Ableitungen sind öffentlich (was man dem UML-Klassendiagramm leider nicht entnehmen kann). Im folgenden Quelltextausschnitt sehen Sie, wie dies aussehen könnte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
					
class CFahrzeug {
	//...
};

class CMotorrad : public CFahrzeug {
	//...
};
						
class CHarleyDavidson : public CMotorrad {
	// ...
};
					

Vererbungspfad für die Klasse CHarleyDavidson

Die Klasse "CHarleyDavidson" besitzt jetzt also nicht nur einen Kickstarter, sondern auch einen Lenker, eine Kette sowie eine Farbe und eine Geschwindigkeit, nur mit dem feinen Unterschied, dass sie selbst an Kette, Lenker, Farbe und Geschwindigkeit nicht herankommt, da sie in den Basisklassen "CMotorrad" und "CFahrzeug" als privat deklariert sind. Dies wird auch an dem Minus im UML-Klassendiagramm kenntlich gemacht. Die Basisklassen müsste also noch entsprechende Getter bzw. Setter zur Verfügung stellen, damit der Zugriff möglich ist.

Auffällig ist auch, dass "CHarleyDavidson" nur von "CMotorrad" erben muss. Das zusätzliche Enthalten der Attribute und Methoden der Klasse "CFahrzeug" ist automatisch dadurch gegeben, dass "CMotorrad" von "CFahrzeug" erbt. Man hat also eine Vererbungshierarchie und die geerbten Sachen werden an alle abgeleiteten Klassen weiter vererbt, welche jene an weiter vererbte Klassen weiterreichen. Für dieses Beispiel bedeutet dies, dass ich in den entsprechenden Auto - und Motorradklassen Implementierungsaufwand spare. Gerade wenn ich zehn weitere Motorräder und zwanzig weitere Autotypen benötigen würde, spare ich enorm viel redundanten Quelltext.

Zum Seitenanfang
Zum Inhaltsverzeichnis

19.1 Das Vererbungsbeispiel

19.1 Das Vererbungsbeispiel

Um alle Aspekte der Vererbung, Aggregation und Kompisition, sowie des Polymorphismus zu zeigen, habe ich mir ein komplexeres Beispiel einfallen lassen. Ich gebe gleich vorweg, dass mein Lösungsansatz nicht perfekt ist und dass es hier und dort Sachen gibt, die man anders machen sollte. An entsprechenden Stellen werde ich aber darauf eingehen und das Für und Wider diskutieren.

Nun, stellen Sie sich vor, Sie wollten ein Computerspiel entwerfen, welches ein Rennsimulator sein soll. Dabei sollen folgende Kriterien erfüllt sein.

Für das Modellieren von Klassenhierarchien ist es üblich, dies mit UML zu machen, da dies weltweit verstanden wird und man so gewisse Teilaufgaben auch an andere Firmen weitergeben kann. Ich habe für das Beispiel ein solches UML-Klassendiagramm entworfen.

UML-Klassendiagramm für die Klassenhierarchien des Beispieles

Nun sieht das ganze auf den ersten Blick sehr umfangreich aus, aber keine Angst, ich werde in den folgenden Kapitel immer nur kleine Teile raus greifen und so das komplette Beispiel, Stück für Stück erläutern. Die obige Grafik dieht also zunächst nur für den Überblick und der Orientierung.

Zum Seitenanfang
Zum Inhaltsverzeichnis

26 Ausnahmebehandlungen II

26 Ausnahmebehandlungen II

Da Sie jetzt wissen, wie Objekte funktionieren und wie man sie erzeugt, werde ich jetzt, wie versprochen, erneut auf das Thema des Exceptionhandlings zurückkommen. Ich hatte Ihnen bereits gezeigt, wie man auf unerwartete Fehler reagieren kann, aber wie es überhaupt zu solchen Exceptions kommt, musste ich ihnen bis jetzt vorenthalten. Ich werde Ihnen also in diesem Kapitel zeigen, wie man selbst solche Ausnahmen erzeugt und wie man dann gezielt aus diesen Exceptionobjekten Informationen über die Fehlerursache entnehmen kann.

Zum Seitenanfang
Zum Inhaltsverzeichnis

26.2 Mehrere Ausnahmen gleichzeitig Abfangen

26.2 Mehrere Ausnahmen gleichzeitig Abfangen

Nun kann es aber vorkommen, dass eine Vielzahl an Fehlern auftreten kann, welche unglücklicherweise auch alle verschiedene Typen sein können. Um dem entgegenzukommen, ist es möglich, mehrere "catch" Blöcke zu definieren. Dies könnte dann wie folgt aussehen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
					
// ...

try {
	// ...
} catch (const int iErrorCode) {
	printf("Fehler %i\n", iErrorCode);
} catch (const char* astrErrorMessage) {
	printf("Fehler: %s\n", astrErrorMessage);
} catch (...) {
	printf("Ein unbekannter Fehler ist aufgetreten!\n");
} // end of try
					

Dieses Spiel kann man natürlich beliebig weit treiben und der letzte Fall mit den drei Punkten, ist keines Wegs Pflicht.

Zum Seitenanfang
Zum Inhaltsverzeichnis

26.3 Eigene Exceptionobjekte

26.3 Eigene Exceptionobjekte

Allerdings ergibt sich jetzt eine kleine Schwierigkeit. Man kann immer nur einen Datentyp zurück geben und somit ist es nicht ohne weiteres möglich, z.B. einen Fehlercode und einen Fehlertext zu werfen. Ein Lösungsansatz wäre, sich eine Struktur zu bauen, welche einen Integer und einen String aufnehmen kann, aber spätestens wenn man mehrere verschiedene Exceptions feuern will, wird diese Herangehensweise auch lästig, da man jedes mal aufs neue überlegen muss, welchen Fehlercode und welchen Text man im Einzelnen angeben muss. Gerade in sehr großen Projekten, müsste man sich erst eine Dokumentation heran ziehen oder selbst im Quelltext auf die Suche gehen. Um der ganzen Sache aus dem Weg zu gehen, ist es also klüger, sich ein universelles Ausnahmeobjekt zu bauen und dieses zu werfen. Im Konstruktor des Objektes kann man dann über eine Enumeration, den Fehlertyp wählen und die Codevervollständigung zeigt einem gleich alle Möglichkeiten an. Diese Herangehensweise ist nicht nur viel ergonomischer, sondern spart gelegentlich auch viel Zeit und Nerven.

Im folgenden Quelltext habe ich eine solche universelle Exceptionklasse definiert. Zunächst also die Header-Datei.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
					
#pragma once

// Mögliche Fehlermeldungen
#define DEF_STR_EXCEPTION_1 "Fehlersituation 1 ist eingetreten!\n"
#define DEF_STR_EXCEPTION_2 "Fehlersituation 2 ist eingetreten!\n"
#define DEF_STR_EXCEPTION_3 "Fehlersituation 3 ist eingetreten!\n"



// Universelle Exceptionklasse
class CMyException {
	public:
		// Mögliche Fehlerarten
		enum EErrorType	{	eetSituation1,
						eetSituation2,
						eetSituation3,
						eetCount,
						eetNone	};

		// Konstruktor
		CMyException(EErrorType eErrorType);

		// Gibt den Fehlercode zurück
		int		GetErrorCode();
		// Gibt den Fehlertext zurück
		const char*	GetErrorMessage();

	private:
		EErrorType		m_eType;
		static const char*	s_aMessages[];
};
					

Wie Sie in den Zeile 4 bis 6 sehen, habe ich zunächst, an einer zentralen Stelle, alle möglichen Fehlertexte zusammengefasst. Dies erleichtert später beispielsweise die Korrektur der Texte hinsichtlich Inhalt und Rechtschreibfehlern.

Ab Zeile 11 erfolgt dann die Klassendefinition. Wie Sie in den Zeilen 14 bis 18 sehen können, erstelle ich mir einen Aufzählungstyp mit drei verschiedenen Elementen. Jene Bezeichnungen sollten Sie gut durchdenken, den jene werden nach außen getragen und beim aufrufen des Konstruktors, durch die Codevervollständigung, angezeigt.

Anschließend folgen noch die Definitionen für den Konstruktor, zwei Methoden zum Holen des Fehlercodes und des Fehlertextes, sowie zweier Membervariablen. Auffällig hierbei sollte sein, dass das Attribut "s_aMessages", in Zeile 30, eine statische Klassenvariable ist. Dies ist nicht zwangsläufig notwendig, aber wie Sie gleich sehen werden, erspare ich mir damit ein wenig Code, weil ich zum einen im Konstruktor nicht extra Speicher anfordern muss und somit auch keinen Destruktor benötige, um den Speicher wieder freizugeben.

Nachfolgend sehen Sie nun die Implementierung der drei Methoden in der zugehörigen CPP Datei.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
					
#include "MyException.h"

// Statische Klassenvariable initialisieren
const char* CMyException::s_aMessages[]	= {	DEF_STR_EXCEPTION_1,
						DEF_STR_EXCEPTION_2,
						DEF_STR_EXCEPTION_3};

// Konstruktor ////////////////////////////////////////////////////////////////
CMyException::CMyException(EErrorType eErrorType) { 
	m_eType = eErrorType;
} // CMyException /////////////////////////////////////////////////////////////



// Gibt den Fehlercode zurück /////////////////////////////////////////////////
int CMyException::GetErrorCode() {
	return (int)m_eType;
} // GetErrorCode /////////////////////////////////////////////////////////////



// Gibt den Fehlertext zurück /////////////////////////////////////////////////
const char* CMyException::GetErrorMessage() {
	return s_aMessages[m_eType];
} // GetErrorMessage //////////////////////////////////////////////////////////
					

Da ich an dieser Stelle davon ausgehe, dass Sie das Klassenkonzept und Arrays soweit verstanden haben, spare ich mir an dieser Stelle die Erklärung zum Quelltext, zumal nichts spannendes passiert. Gleiches gilt für die Implementierung in der "main". Der Vollständigkeit halber, sehen Sie nachstehend trotzdem ein kleines Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
#include <stdio.h>
#include "MyException.h"



// Wirft ein Exceptionobjekt //////////////////////////////////////////////////
void Test() {
	throw CMyException(CMyException::eSituation1);
} // Test /////////////////////////////////////////////////////////////////////

// ...

try {
	Test();		
} catch (CMyException oException) {
	printf("Fehler (%i): %s\n",	oException.GetErrorCode(),
					oException.GetErrorMessage());
} // end of try
					

Ausgabe:

Fehler (0): Fehlersituation 1 ist eingetreten!
		

Interessant ist hier lediglich die Zeile 8. Der Aufruf des Konstruktors "CMyException", erzeugt ein statisches Objekt mir den gewünschten Fehlerinformationen und genau jenes wird gefeuert und im "catch" Block aufgefangen und ausgewertet. Dies ist soweit unkritisch und erzeugt keinerlei Speicherlecks, da das Objekt statisch erzeugt wurde. Sie brauchen/dürfen es also nicht im "catch" Block freigeben.

Zum Seitenanfang
Zum Inhaltsverzeichnis

26.1 Die throw Anweisung

26.1 Die throw Anweisung

Mit der "throw" Anweisung kann man Ausnahmen an die aufrufende Funktion senden. Der einfachste Weg ist, einfach einen Text zu übergeben. Im "catch" Block kann diese Nachricht aufgefangen und ausgegeben werden. Im folgenden Beispiel baue ich mir eine kleine Funktion, welche einen Wert durch einen anderen teilen soll und das Ergebnis zurück gibt. Falls der zweite Wert null ist, werfe ich eine Ausnahme.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
					
// Sicheres Teilen ////////////////////////////////////////////////////////////
float Devide(float fValue1, float fValue2) {
	// Wenn der zweite Wert 0 ist
	if (fValue2 == 0.0f) {
		throw "Durch 0 darf nicht geteilt werden!\n";
	} else {
		return fValue1 / fValue2;
	} // end of if
} // Devide ///////////////////////////////////////////////////////////////////

// ...

try {
	printf("%g\n", Devide(8.0f, 0.0f));
} catch (const char* pcstrErrorMessage) {
	printf("Fehler: %s", pcstrErrorMessage);
} // end of try
					

In Zeile 5 sehen Sie, dass es einfach reicht, hinter das "throw" eine Konstante zu schreiben, also in dem Fall ein Satz. Dieser kann dann, in Zeile 16, entgegen genommen werden. Sie müssen sich nur im klaren sein, welche Art von Typ geworfen wird. Alternativ könnten Sie auch eine Zahl, also ein "const int" oder eine normale Integervariable feuern. Am häufigsten werden jedoch konstante Strings übergeben, so wie Sie dies im Beispiel sehen können.

Zum Seitenanfang
Zum Inhaltsverzeichnis

26.4 Exceptions weiterleiten

26.4 Exceptions weiterleiten

Hin und wieder kommt es vor, dass eine Exception an einer Stelle ausgelöst wird, an welcher man sie überhaupt nicht gebrauchen kann, da sie in der fünften Unterfunktion ausgelöst wird und man in den dazwischen liegenden Funktionen nicht darauf reagieren will bzw. sogar garnicht kann. Dies klingt jetzt ein wenig verwirrend, aber ich werde dies an einem keinen Beispiel klar machen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
					
// Wirft eine Exception ///////////////////////////////////////////////////////
int Unterfunktion2() {
	throw "Ich bin ein Fehler an einer ungluecklichen Stelle!";
	return 0;
} // Unterfunktion2 ///////////////////////////////////////////////////////////



// Ruft eine Funktion auf, welche eine Exception wirft ////////////////////////
void Unterfunktion1() {
	char*	pstrText	= new char[80];
	printf("Speicher fuer Text wurde reserviert\n");

	int	iErgebnis	= Unterfunktion2();

	delete pstrText;
	printf("Speicher fuer Text wurde wieder freigegeben\n");

	printf("Das Ergebnis der Funktion ist %i\n", iErgebnis);
} // Unterfunktion1 ///////////////////////////////////////////////////////////


// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	try {
		Unterfunktion1();
	} catch (const char* pcstrMessage) {
		printf("Fehler: %s\n", pcstrMessage);
	} // end of try

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Speicher fuer Text wurde reserviert
Ich bin ein Fehler an einer ungluecklichen Stelle!
		

Allein an der Ausgabe sollten Sie jetzt erkennen, dass etwas nicht so läuft, wie gewünscht, aber warum ist dies so? In dem Moment, in welchem eine Exception ausgelöst wird, werden alle Funktionen sofort beendet, welche nicht die Ausnahme abfangen. Für mein Beispiel bedeutet dies, dass die Funktion "Unterfunktion1" auch beendet wird, da ich hier die Exception nicht abfange. Dies hat zur Folge, dass die Funktion nicht mehr dazu kommt, den reservierten Speicher wieder freizugeben und ich erzeuge ein Speicherleck. Nun könnte ich diese Funktion auch folgendermaßen abändern.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
// Ruft eine Funktion auf, welche eine Exception wirft ////////////////////////
void Unterfunktion1() {
	char*		pstrText	= new char[80];
	printf("Speicher fuer Text wurde reserviert\n");

	try {
		int	iErgebnis	= Unterfunktion2();
	} catch (const char astrMessage) {
		printf("Fehler: %s\n", astrMessage);
	} // end of try

	delete pstrText;
	printf("Speicher fuer Text wurde wieder freigegeben\n");

	printf("Das Ergebnis der Funktion ist %i\n", iErgebnis);
} // Unterfunktion1 ///////////////////////////////////////////////////////////
					

Mal abgesehen davon, dass ich jetzt in Zeile 15 nicht mehr auf die Variable "iErgebnis" zugreifen könnte, da sie innerhalb des "try" Blockes definiert wurde und ihr Wert zudem unbestimmt wäre, da die Funktion "Unterfunktion2" nicht dazu kommt, einen Wert zurückzugeben, wäre mein Problem damit nicht gänzlich gelöst, da die Funktion "Unterfunktion1" normal weiter läuft, obwohl wichtige Informationen fehlen, die zur Weiterverarbeitung wichtig sein könnten. Zudem bekommt die Hauptfunktion nicht mit, dass irgendwo etwas schief gelaufen ist. Sinnvoller wäre es also, auch die Funktion "Unterfunktion1" zu beenden bzw. sogar die Ausnahme weiter zu reichen. Dies sähe z.B. so aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
// Ruft eine Funktion auf, welche eine Exception wirft ////////////////////////
void Unterfunktion1() {
	char*		pstrText	= new char[80];
	printf("Speicher fuer Text wurde reserviert\n");

	try {
		int	iErgebnis	= Unterfunktion2();
		printf("Das Ergebnis der Funktion ist %i\n", iErgebnis);
	} catch (const char astrMessage) {
		printf("Fehler: %s\n", astrMessage);
		delete pstrText;
		printf("Speicher fuer Text wurde wieder freigegeben\n");

		throw astrMessage;
	} // end of try
} // Unterfunktion1 ///////////////////////////////////////////////////////////
					

In Zeile 14 sehen Sie jetzt, dass ich aufgefangene Exception an die "main" weiterleite. Dieses Vorgehen wäre durchaus denkbar, aber hat trotzdem noch seine Schwachstellen. Stellen Sie sich vor, dass Sie jetzt mehrere Funktionen aufrufen und auch mehrere unterschiedliche Exceptions auftreten könnten, von denen Sie sogar im schlimmsten Falle nicht den Typ wissen. Sicher denken Sie jetzt, dass Sie ja mehrere "catch", Blöcke definieren könnten und falls ein Fehlertyp auftritt, den Sie nicht kennen, könnten Sie ja die Sache mit den drei Punkten machen und in diesem Fall eine neue Ausnahme erzeugen. Aber damit zerstören Sie nicht nur Informationen über die eigentliche Fehlerursache, sondern haben zudem viel Schreibaufwand (besonders, wenn es noch vier, fünf andere Aufrufebenen gibt). Aus diesem Grund gibt es einen s.g. "re-throw" Mechanismus, welcher die aufgefangene Exception ungesehen weiter leitet. Dies sieht dann wie folgt aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
					
// Ruft eine Funktion auf, welche eine Exception wirft ////////////////////////
void Unterfunktion1() {
	char*		pstrText	= new char[80];
	printf("Speicher fuer Text wurde reserviert\n");

	try {
		int	iErgebnis	= Unterfunktion2();
		printf("Das Ergebnis der Funktion ist %i\n", iErgebnis);
	} catch (...) {
		delete pstrText;
		printf("Speicher fuer Text wurde wieder freigegeben\n");

		throw;		// Nur innerhalb des catch Blockes möglich
	} // end of try
} // Unterfunktion1 ///////////////////////////////////////////////////////////
					

Ausgabe:

Speicher fuer Text wurde reserviert
Speicher fuer Text wurde wieder freigegeben
Ich bin ein Fehler an einer ungluecklichen Stelle!
		

Sie sehen also in Zeile 9, dass es gar nicht nötig ist, auf alle möglichen Exceptions zu reagieren und in Zeile 13 sehen Sie zudem, dass ein einfaches aufrufen von "throw" reicht, um die gefangene Ausnahme weiterzuleiten. Zudem konnte ich die Ausgabe der Fehlermeldung wieder löschen und kann die "main" sich darum kümmern lassen. Wie der Kommentar schon verrät, darf man diese Art der Ausnahmeauslösung nur innerhalb des "catch" Blockes tätigen. Außerhalb benötigt das "throw" immer noch einen Wert (egal was für einen und von welchem Typ). Sie sehen zudem anhand der Ausgabe, dass jetzt der Speicher für den String, ordnungsgemäß freigegeben wird. Leider müssen Sie dieses "re-throw" in alle Aufrufebenen einbauen, in welchen Sie nicht konkret auf die Exception eingehen wollen, allerdings hält sich der Aufwand noch in Grenzen, da man wie gesagt, nicht auf alle erdenklichen Fälle reagieren muss.

Abschließend möchte ich Sie noch auf die vordefinierten Exceptionklassen der Standardbibliothek hinweisen, welche zum einen von Standardfunktionen gefeuert werden und zum anderen überschrieben werden können. Beispielsweise finden Sie im Header "exception" die Klassen "bad_alloc" und "bad_exception". Im Header "stdexcept" gibt es u.a. die Klassen "invalid_argument", "length_error", "overflow_error" und "range_error". Zudem gibt es noch die Header "typeinfo" und "ios", in welchen weitere Exceptionklassen definiert wurden. Ich möchte allerdings an dieser Stelle nicht im einzelnen auf diese Klassen eingehen und verweise Sie diesbezüglich auf das MSDN, in welchen Sie mehr Informationen über dieses Thema und bereitstehende Klassen, erhalten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

27 Showcase II

27 Showcase II

coming soon...

Zum Seitenanfang
Zum Inhaltsverzeichnis

16 Meine erste Klasse

16 Meine erste Klasse

Als erstes sei erwähnt, dass üblicherweise jede Klasse in separate Header - und CPP Dateien implementiert wird. Nur in sehr speziellen Fällen, packt man in ein Dateipaar zwei oder mehrere Klassen und das auch nur, wenn die Klassen sehr klein sind. Ein Beispiel dafür ist z.B. eine spezielle Listenklasse, deren Inhalte aus kleinen Objekten anderer eigener Klassen bestehen.

Das folgende Beispiel ist sehr rudimentär und mit Absicht auch sehr schlank gehalten, da ich zunächst das Grundgerüst erklären möchte.

Ich fange zunächst mit der Header-Datei an. Prinzipiell kann man in neueren IDE's Assistenten benutzen, welche einem beim Erstellen von Klassen, sehr unter die Arme greifen (in Visual Studio z.B. erledigt dies der Klassenassistent), aber für den Anfang empfehle ich, dies händisch zu tun. Falls Sie irgendwann nicht mehr weiter kommen oder Sie sich unschlüssig sind, können Sie ihn benutzen. Dann sollten Sie sich aber genau anschauen, welcher Code generiert wurde.

Zum Seitenanfang
Zum Inhaltsverzeichnis

16.1 Die Datei TestKlasse.h

16.1 Die Datei TestKlasse.h

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
					
// Definition eines neuen Klassendatentyps
class CTestKlasse {
	public:
		// Standardkonstruktor-Methode
		CTestKlasse(void);
		// 1. Benutzerdefinierte Konstruktor-Methode
		CTestKlasse(int iValue);
		// 2. Benutzerdefinierte Konstruktor-Methode
		CTestKlasse(int iValue1, int iValue2);
		// Kopierkonstruktor-Methode
		CTestKlasse(const CTestKlasse oTestObjekt);
		// Destruktor
		~CTestKlasse(void);

		// Speichert eine Zahl
		int m_iZahl1;
		// Speichert eine andere Zahl
		int m_iZahl2;

		// Gibt interne Werte aus
		void Ausgabe(void);
};
					

In Zeile 2 sehen Sie den Klassenkopf. Er erinnert an die Definition einer Struktur. Später werde ich Ihnen aber noch zeigen, dass dies hier nur die halbe Wahrheit ist, aber fürs erste soll das reichen.

In Zeile 3 steht jetzt etwas neues, auf was ich bisher noch nicht weiter eingegangen bin. Gemeint ist der Modifikator "public". Er ist dafür verantwortlich, dass die Attribute und Methoden, nach außen sichtbar sind. Weiter möchte ich an dieser Stelle nicht darauf eingehen, da ich im Kapitel der Vererbung, diese Sachen sehr ausführlich erläutern werde.

In den Zeilen 5 bis 13 ist jetzt die Definition der Konstruktoren und des Destruktors. Auffällig an ihnen ist wie erwähnt, dass diese zwei Methoden keinen Rückgabewert besitzen.

In Zeile 16 und 18 habe ich zwei Attribute definiert. Auffällig ist hier das "m_"; vor dem Variablenname. Diese Konvention ist üblich, aber nicht Pflicht. Man bringt mit ihr zum Ausdruck, dass es sich um eine Membervariable handelt. Dies soll später sicher stellen, dass man bei der Implementierung der Methoden genau sieht, ob es sich bei einer Variablen um eine Member - oder eine lokal definierte Variable handelt. Wie gesagt, es ist eine von vielen Möglichkeiten, seinen Quelltext zu strukturieren und Sie müssen sich nicht daran halten, aber ich lege es Ihnen ans Herz, dies doch zu tun, da dies, gerade für Andere, die später evtl. mit Ihrem Zeug klar kommen müssen, das Leben sehr erleichtert.

Zum Schluss, in Zeile 21, habe ich noch eine Methode "Ausgabe" definiert, welche später nur die Werte von "m_iZahl1" und "m_iZahl2"zurück geben soll. Hier macht man in der Regel kein "m_" davor, da es in der Praxis nicht so wichtig ist, da man ja eine Klasse so aufbaut, dass sie sich selbst verwaltet und somit die Funktionsaufrufe größtenteils Methodenaufrufe sind.

Zum Seitenanfang
Zum Inhaltsverzeichnis

16.2 Die Datei TestKlasse.cpp

16.2 Die Datei TestKlasse.cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
					
#include <stdio.h>
#include "TestKlasse.h"



// Standardkonstruktor ////////////////////////////////////////////////////////
CTestKlasse::CTestKlasse()
	:m_iZahl1(0)
	,m_iZahl2(0)
{} // CTestKlasse /////////////////////////////////////////////////////////////



// 1. Benutzerdefinierter Konstruktor /////////////////////////////////////////
CTestKlasse::CTestKlasse(int iValue)
	:m_iZahl1(iValue)
	,m_iZahl2(iValue)
{} // CTestKlasse /////////////////////////////////////////////////////////////



// 2. Benutzerdefinierter Konstruktor /////////////////////////////////////////
CTestKlasse::CTestKlasse(int iValue1, int iValue2)
	:m_iZahl1(iValue1)
	,m_iZahl2(iValue2)
{} // CTestKlasse /////////////////////////////////////////////////////////////



// Kopierkonstruktor //////////////////////////////////////////////////////////
CTestKlasse::CTestKlasse(const CTestKLasse& oTestObjekt)
	:m_iZahl1(oTestObjekt.m_iZahl1)
	,m_iZahl2(oTestObjekt.m_iZahl1)
{} // CTestKlasse /////////////////////////////////////////////////////////////



// Destruktor /////////////////////////////////////////////////////////////////
CTestKlasse::~CTestKlasse() {
} // ~CTestKlasse /////////////////////////////////////////////////////////////



// Gibt interne Werte aus /////////////////////////////////////////////////////
void CTestKlasse::Ausgabe(void) {
	// Variante 1
	printf("%i"\n, m_iZahl1);

	// Variante 2
	printf("%i"\n, this->m_iZahl2);
} // Ausgabe //////////////////////////////////////////////////////////////////
					

Ab Zeile 7 sehen Sie jetzt die Implementierungen, der zuvor definierten Methoden. Auffällig ist hier der s.g. Namespace vor den Methodennamen. Dies ist zwingend notwendig, damit der Compiler später weiß, welche Methode zu welcher Klassendefinition gehört. Es könnte ja sein, dass es noch eine andere Klasse im Projekt gibt, die eine Methode mit dem gleichen Namen hat und spätestens dann würde es zu Verwirrungen kommen.

Das Schema sieht so aus:
<Rückgabewert> <Klassenname>::<Methodenname>([<Parameterliste>]) {<Anweisung>}

Weiterhin auffällig ist Zeile 8 und 9. Hier tritt etwas auf, was Sie so noch nicht gesehen haben und was auch nur bei Konstruktoren funktioniert. Wie Sie vielleicht schon erahnen können, werden so die Attribute einer Klasse initialisiert. Sie werden später noch sehen, dass dies Teilweise sogar notwendig ist (Stichwort statische Klassenvariablen oder Basisklassenaufrufe).

Eine weitere Auffälligkeit ist, dass ich, wie Sie in den Zeilen 7, 16, 25 und 34 sehen, gleich vier verschiedene Konstruktoren gebaut habe, welche sich, so sagt man, gegenseitig überladen. Dem Standardkonstruktor erkennt man daran, dass ihm keine Werte übergeben werden. Jener kann vom Compiler automatisch erstellt werden, allerdings werden dann keine Membervariablen initialisiert. Falls Sie also sicherstellen wollen, dass vernünftige Startwerte vorliegen, müssen Sie selber einen Konstruktor bauen. Die Benutzerdefinierten Konstruktoren sind dafür da, dass Sie von außen sinnvolle Startwerte übergeben können. Der Kopierkonstruktor spielt eine Besondere Rolle. Für kleinere Sachen benötigt man ihn meistens nicht, aber spätestens, wenn man Operatoren überladen will, ist er notwendig. Wie der Name schon sagt, ist er dafür da, sich anhand eines anderen Objektes zu erzeugen.

Bis zur Zeile 51 sollte jetzt alles klar sein, aber ab dieser Zeile möchte ich Ihnen zeigen, wie Klassen auf ihre eigenen Attribute zugreifen können (das Gleiche gilt dann auch für die Methoden). Wie Sie sehen, gibt es zwei verschiedene Varianten. Variante 1 spart Code, aber Variante 2 ist eindeutiger und in anderen Sprachen sogar Vorschrift. Hier sehen Sie das erste mal den s.g. "this" Pointer, welcher eine Referenz auf die Klasse selbst darstellt. Prinzipiell kann man sich dies in C++ sparen, weil man an dem "m_" bereits erkennt, dass es sich um ein Attribut und keine lokale Variable handelt und somit das "this" doppelt gemoppelt wäre. Aber wie gesagt, in anderen Sprache ist dies sogar Pflicht und gerade für Anfänger ist es ratsam, das "this" immer mit hinzuschreiben (es sticht mehr ins Auge).

Zum Seitenanfang
Zum Inhaltsverzeichnis

16.3 Die Datei main.cpp

16.3 Die Datei main.cpp

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
					
#include "TestKlasse.h"



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	// Variante 1
	CTestKlasse oObjekt1;					// Standardkonstruktor
	oObjekt1.Ausgabe();
	
	CTestKlasse oObjekt2(1, 2);				// Benutzerdefinierter Konstruktor
	oObjekt2.Ausgabe();

	// Variante 2
	CTestKlasse* pObjekt3 = new CTestKlasse(oObject2);	// Kopierkonstruktor
	pObjekt->Ausgabe();
	delete pObjekt;
	
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

0
0
1
2
1
2			
		

Mit oben stehenden Code, können Sie nun die kleine Testklasse Testen. Hierfür gibt es zwei Varianten. Wie Sie in Zeile 5 und 6 sehen, habe ich ein Objekt der Klasse "CTestKlasse" auf dem Stack erzeugt und rufe die Methode "Ausgabe" auf. Bei Objekten auf dem Stack, braucht man sich nicht um die Speicherverwaltung zu kümmern und man greift auf die Attribute und Methoden über den Punk zu, genauso wie man dies bei Strukturen macht. In den Zeilen 8 und 9 wird ein weiteres Objekt auf dem Stack erzeugt, allerdings unter Verwendung eines benutzerdefinierten Konstruktors.

In den Zeilen 12 bis 14 zeige ich, wie man sich ein dynamisches Objekt erzeugt und damit arbeitet. Wie Sie vielleicht bereits vermuten, benötigt man dazu Pointer. Auch die Speicherverwaltung muss selbst gemanagt werden und der Zugriff auf die Attribute und Methoden geschieht über den Pfeil.

Dem aufmerksamen Leser ist vielleicht aufgefallen, dass ich für die Variablennamen, unterschiedliche Schreibweisen verwendet habe. Die Objekte auf dem Stack habe ich mit einem kleinen "o" eingeleitet, was signalisieren soll, dass ich mich nicht um die Freiage kümmern muss. Das dynamische Objekt, habe ich allerdings wieder mit einem kleinen "p" eingeleitet, was darauf schließen soll, dass die Speicherverwaltung hier selbst durchgeführt werden muss. Man kann allerdings nicht mehr erkennen, dass es sich um ein Objekt handelt. Hier kommt man an einen Punkt, wo es immer wichtiger wird, den Variablen, sprechende Namen zu verpassen, da man irgendwann nicht mehr alles mit einleitenden Buchstaben erklären kann. In diesem Fall hätte ich die Variable zwar auch mit "poftk" einleiten können (was soviel heißen soll wie Pointer of TestKlasse), aber wer soll da durch sehen und spätestens wenn es mal Klassen gibt deren Anfangsbuchstaben gleich sind, wird auch dieser Ansatz einen nicht weit bringen. Von daher empfehle ich nur das kleine "p" zu benutzen und den Rest so zu benennen, dass jeder erkennt dass hinter dem Pointer, ein Objekt klemmt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

17 Ein paar Philosophien

17 Ein paar Philosophien

Bevor ich weiter mache, werde ich auf ein paar Konventionen eingehen, welche allgemein üblich sind. Sie brauchen sich zwar nicht dran zu halten, aber ich lege es Ihnen wieder ans Herz, zumal man im Internet viele Projekte findet, welche genau diesen Richtlinien folgen und wenn Sie die Grundideen der Konventionen verstehen, blickt Sie auch schneller durch andere Quelltexte durch.

Zum Seitenanfang
Zum Inhaltsverzeichnis

17.1 Was ist privat und was ist public

17.1 Was ist privat und was ist public

Wie bereits erwähnt, gibt es für Klassen die Möglichkeit, Attribute und Methoden zu veröffentlichen oder sie zu verstecken (Information hiding). Dies geschieht mit den Modifikatoren "private" (verstecken) und "public" (veröffentlichen). Mit verstecken ist gemeint, dass man zwar innerhalb der Methoden einer Klasse, auf die Sachen zugreifen kann, aber von Außen nicht. Wenn man sich also ein Objekt der Klasse erzeugt, kann man nur auf Attribute und Methoden zugreifen, welche öffentlich sind. Aber eigentlich ist das nicht ganz richtig, da man nur im Kontext der Klasse darauf zugreifen kann. Baut man sich also einen Kopierkonstruktor, so kann man auch auf die privaten Attribute des übergebenen Objektes zugreifen, obwohl es so scheint, als würde man von außen zugreifen.

Nun, warum macht man dies? Der Grund ist zum einen, weil man so die Schnittstellen schmaler machen kann, da interne Verwaltungsfunktionen, die Außenwelt nicht interessiert und zum anderen ist es oft nicht erwünscht, dass man einfach so Werte ändert, an welchen evtl. noch andere Sachen mit dran hängen. Beispielsweise könnte es sein, dass bestimmte Werte nicht erlaubt sind oder, um auf die Computerspiele zurück zu kommen, wäre es ungünstig, die Farbe eines Autos zu ändern, ohne die neue Farbe dann auch zu Zeichnen. Die Vorteil die sich daraus ergeben sind, dass der Programmierer, welcher dann die Klasse verwenden soll, weniger falsch machen kann und seine Codevervollstädigung nur die Sachen anzeigt, die er braucht bzw. verwenden soll.

Aus diesen Gründen, macht man Attribute in der Regel alle privat. Methoden werden nur dann veröffentlicht, wenn sie auch wirklich dazu dienen, ein Objekt von außen zur Arbeit zu bewegen. Meistens rufen diese dann private Methoden auf, welche die eigentliche Arbeit verrichten.

Man braucht nicht vor jedes Attribut und vor jede Methode "private" oder "public" schreiben, da solche Anweisungen immer so lange gelten, bis etwas anderes vereinbart wird. Prinzipiell kann man zwar private und öffentliche Sachen mischen, dies ist aber weder üblich, noch fördert es die Lesbarkeit. Somit fasst man jene Attribute und Methoden in Gruppen zusammen.

Wenn man weder "private" noch "public" schreibt, so ist alles automatisch privat (kann aber von Compiler zu Compiler abweichend sein. MS Visual C++ Compiler macht es so). Dies sollte man aber tunlichst unterlassen, da es später zu ungewollten Missverständnissen kommen kann und oder irgendwann aus privaten Sachen, auf einmal öffentliche werden, weil jemand ein paar Zeilen darüber, eine öffentliche Methode deklariert.

In UML stellt man private Attribute und Methoden mit einem "-" dar, wohingegen den öffentlichen Sachen ein "+" vorangestellt wird. Des weiteren gibt es noch die s.g. geschützten Attribute und Methoden, auch "protected" genannt, welche mit einer "#" gekennzeichnet werden. Diese geschützten Sachen spielen aber erst eine Rolle, wenn es um die Vererbung geht und deshalb werde ich erst im übernächsten Kapitel näher darauf eingehen. Ganz grob kann ich aber jetzt schon verraten, dass sich die geschützten Attribute und Methoden, fast so wie die privaten verhalten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

17.2 Getter und Setter

17.2 Getter und Setter

Da man also Attribute üblicherweise privat macht, braucht man gelegentlich trotzdem Möglichkeiten, diese von Außen zu modifizieren. Dafür implementiert man s.g. Get - und Set-Methoden, wobei Set-Methoden jene sind, welche neue Werte setzen und die anderen um die internen Werte abzurufen.

Für die Beispielklasse von vorhin, wäre es also besser gewesen, "m_iZahl1" und "m_iZahl2" privat zu machen und entsprechende Getter und Setter zu schreiben. Für das kleine Testprojekt macht dies nicht viel Sinn, zumal es ein viel höherer Aufwand ist, aber trotzdem werde ich Ihnen dies mal zeigen. Deswegen überarbeite ich die Header-Datei und das Ergebnis sieht dann wie folgt aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
					
class CTestKlasse {
	public:
		// Konstruktor
		CTestKlasse(int iValue1, int iValue2);
		// Destruktor
		~CTestKlasse(void);

		// Gibt interne Werte aus
		void	Ausgabe(void);

		int	GetZahl1(void);
		void	SetZahl1(int iValue);
		int	GetZahl2(void);
		void	SetZahl2(int iValue);

	private:
		// Speichert eine Zahl
		int m_iZahl1;
		// Speichert eine andere Zahl
		int m_iZahl2;
};
					

Wie Sie klar sehen, sind die Attribute jetzt in den privaten Bereich verlagert worden und im öffentlichen Bereich, sind jetzt vier neue Methoden hinzugekommen.

Nun fehlen noch die Implementierungen in der CPP und deshalb hier kurz der Quelltext, welcher ergänzt wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
					
// Gibt den Wert von Zahl1 zurück /////////////////////////////////////////////
int CTestKlasse::GetZahl1(void) {
	return this->m_iZahl1;
} // GetZahl1 /////////////////////////////////////////////////////////////////


// Ändert den Wert von Zahl1 //////////////////////////////////////////////////
void CTestKlasse::SetZahl1(int iValue) {
	this->m_iZahl1 = iValue;
} // SetZahl1 /////////////////////////////////////////////////////////////////



// Gibt den Wert von Zahl2 zurück /////////////////////////////////////////////
int CTestKlasse::GetZahl2(void) {
	return this->m_iZahl2;
} // GetZahl2 /////////////////////////////////////////////////////////////////


// Ändert den Wert von Zahl2 //////////////////////////////////////////////////
void CTestKlasse::SetZahl2(int iValue) {
	this->m_iZahl2 = iValue;
} // SetZahl2 /////////////////////////////////////////////////////////////////
					
Zum Seitenanfang
Zum Inhaltsverzeichnis

23 Überladen von Operatoren

23 Überladen von Operatoren

Nachdem Sie jetzt wissen, was es mit der Vererbung zu tun hat, werde ich jetzt auf eine andere sehr nette Sache eingehen, welche den Umgang mit Objekten sehr vereinfachen kann, was aber zur Implementierungszeit etwas mehr Denkleistung fordert. Die Rede ist vom überladen von Operatoren. Was bedeutet dies überhaupt?

Nun, Sie wissen; was Operatoren sind und es ist auch einsehbar, dass man sie nur auf Primitivtypen (char, int, float) anwenden kann. Falsch, es geht auch mit Objekten, allerdings muss man dafür Methoden zur Verfügung stellen, welche dies ermöglichen.

Ich werde in diesem Kapitel Zeigen, wie man Objekte miteinander z.B. addieren, vergleichen, eine Ein - bzw. Ausgabe realisieren kann und was sonst noch so geht.

Zum Seitenanfang
Zum Inhaltsverzeichnis

23.5 Ein - und Ausgabe (<< und >>)

23.5 Ein - und Ausgabe (<< und >>)

Eine sehr nette Funktionalität, ist das überladen der Serialisierungsoperatoren "<<" und ">>". In Kombination mit "cin" und "cout", kann man auf diese Art und Weise, Werte für Objekte einlesen lassen (immer nur ein Wert einlesbar) oder für das Objekt eine Ausgabe realisieren (dabei spielt es keine Rolle, ob mit Ein- und Ausgabe von oder auf die Konsole oder von oder aus Dateien gemeint ist).

Das klingt ja alles ganz spannend, aber bevor ich das zeige, muss ich ein wenig ausholen, denn diese zwei Operatoren kann man nicht als Methoden der Klasse implementieren, sondern muss sie als globale Funktionen bereitstellen. Damit jene dann auf das Objekt zugreifen können, müsste man normalerweise entweder entsprechende Getter und Setter bauen oder alle benötigten Sachen "public" machen, was unter Umständen nicht gewollt sein könnte. Um dies zu vermeiden, kann man eine "Freundschaft" mit der Klasse eingehen. Das klingt zwar jetzt ein wenig komisch, ist aber tatsächlich möglich. Man realisiert dies, indem man die Funktionsprototypen der entsprechenden Funktionen, welche auf die privaten Elemente eines Objektes zugreifen möchten, in die Klassendefinition mit aufnehmen und der Klasse mitteilt, dass dies "Freunde" sind. Dies sieht für das Beispiel so aus.

 1
 2
 3
 4
 5
 6
 7
					
#include <iostream>

// ...

		// Serialisieren
		friend std::ostream& operator<< (std::ostream&, const CInteger&);
		friend std::istream& operator>> (std::istream&, CInteger&);
					

Natürlich sollte man direkt in der CPP-Datei der Klasse, diese globalen Funktionen packen. Andernfalls könnten andere diese Schnittstelle nutzen, um Inhalte des Objektes zu manipulieren und somit das gesamte Konzept der OOP aus hebeln. Also, wenn Sie Freunde definiert, dann implementieren Sie sie an einer Stelle, an der man sie nicht ersetzen kann (In der CPP-Datei der Klasse könnte man sie zwar auch manipulieren, wenn jemand anderes diese Datei besitzt, aber dann hat er eh vollen Zugriff). Schauen wir uns nun die entsprechende Implementierung dieser zwei Methoden an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
					
// Ein - und Ausgabe //////////////////////////////////////////////////////////
std::ostream& operator<< (std::ostream& oStream, const CInteger& oInt) {
	oStream << "Wert=" << oInt.m_iValue << endl;
	return oStream;
}

std::istream& operator>> (std::istream& oStream, CInteger& oInt) {
	oStream >> oInt.m_iValue;
	return oStream;
} // operator<< ///////////////////////////////////////////////////////////////
  
// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt(5);

std::cin >> oObjekt;
std::cout << oObjekt;

// oder auch
CInteger* pZeigerAufObjekt = new CInteger(5);

std::cin >>* pZeigerAufObjekt;
std::cout << *pZeigerAufObjekt;

delete pZeigerAufObjekt;
					

Wie Sie in Zeile 1 und 6 sehr schön sehen, besitzen diese Funktionen nicht mehr den Namespace der Klasse und gehören somit nicht offiziell zu ihr. Wie Sie aber in Zeile 2 und 7 sehen, ist es trotzdem möglich, auf das private Attribut "m_iValue" zuzugreifen.

Zu dem Rückgabe - und dem ersten Übergabeparameter ist zu sagen, dass es sich hier um Referenzen auf ein Streamobjekt handelt, welches die Mechanismen von "cin" und "cout" wieder spiegeln. Der zweite Parameter ist dann unser Objekt. Da ich in der zweiten Funktion nur eine Ausgabe mache, kann ich getrost eine konstante Referenz übergeben, was bei der ersten Funktion nicht möglich ist, da ich das Objekt ja manipuliere, also den eingelesenen Wert eintragen will.

Zum Seitenanfang
Zum Inhaltsverzeichnis

23.3 Berechnungen (+, -, ++, --)

23.3 Berechnungen (+, -, ++, --)

Da nun eine Grundlage geschaffen ist, komme ich jetzt zu den Berechnungsoperatoren. Jene sind zum Teil unär und zum Teil binär. Ich werde dann jeweils darauf hinweisen, um was es sich handelt.

Zuerst werde ich die Klassendefinition in der Header-Datei, um folgende Einträge, im "public" Bereich, erweitern.

 1
 2
 3
 4
 5
 6
 7
 8
 9
					
		// unär
		CInteger	operator+() const;	// Vorzeichen
		CInteger	operator-() const;	// Vorzeichen
		CInteger&	operator++(void);	// linksseitig
		CInteger	operator++(int);	// Rechtsseitig

		// binär
		CInteger	operator+(const CInteger&) const;
		CInteger	operator+(const int&) const;
					

Hier scheint es auf den ersten Blick Dopplungen zu geben, aber wie Sie evtl. schon an den Kommentaren erkennen können, haben diese vermeintlichen Dopplungen ihre Berechtigung (je nachdem, was man vor hat). Was dahinter steckt, werden Sie gleich sehen. Des weiteren gibt es jetzt Methoden, hinter welchen ein "const" steht und Sie fragen sich jetzt bestimmt, was es damit auf sich hat. Nun, das ist zum einen rein optional und soll zum anderen dem Benutzer dieser Methoden zeigen, das durch diese Methoden das eigentliche Objekt unberührt bleibt. Dies ist u.a. eine wichtige Eigenschaft von binären Operatoren. Kommen wir nun zur Implementierung.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
					
// Vorzeichen /////////////////////////////////////////////////////////////////
CInteger CInteger::operator+(void) const {
	return *this;
}

CInteger CInteger::operator-(void) const {
	return CInteger(-this->m_iValue);
} // operator- ////////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt1(5);
CInteger oObjekt2(7);

oObjekt1			= +oObjekt2;
oObjekt1			= -oObjekt1;

// oder auch
CInteger* pZeigerAufObjekt1	= new CInteger(5);
CInteger* pZeigerAufObjekt2	= new CInteger(7);

*pZeigerAufObjekt1		= +(*pZeigerAufObjekt2);
*pZeigerAufObjekt2		= -(*pZeigerAufObjekt2);

delete pZeigerAufObjekt1;
delete pZeigerAufObjekt2;
					

Diese zwei Methoden erlauben es also, positive oder negative Vorzeichen zu benutzen. Wie bereits erwähnt, wird dabei aber nicht der eigene Wert manipuliert, sondern eine manipulierte Kopie zurückgegeben. Warum gibt man jetzt aber keine Referenz zurück wie vorhin? Überlegen wir uns folgende Situation. Sie wollen schreiben "oA = -oB". Jetzt wird zuerst die Operatormethode "-" des Objektes "oB" aufgerufen, welche ein neues Objekt (nennen wir es mal "oX") zurück gibt. Anschließend wird die Operatormethode "="; des Objektes "oA" aufgerufen und eine Kopie von "oX" übergeben. Jetzt braucht zwar die Operatormethode "=" eine Referenz, aber Vorsicht, was ist damit gemeint? Der Referenzierungsoperator hat etwas mit call-by-reference zu tun hat und wenn man so etwas baut, übergibt man die Variable normal und Dereferenziert sie nicht extra (in dem Fall müsste die Methode ja ein Pointer entgegen nehmen). Wenn Sie jetzt Kopfschmerzen haben, dann ist das normal, denn auch darüber muss man eine weile Nachdenken, bevor man es einsieht.

Die erste Methode ist reine Kür, da lediglich das Objekt selbst zurückgegeben wird. Aber Moment mal, jetzt wird das Objekt selbst zurückgegeben und vorhin war es eine Referenz? Das hat schon seine Richtigkeit, denn erinnern Sie sich. Vorhin wollte ich keine Kopie des Objektes erzeugen, sondern wirklich die Referenz haben. Jetzt will ich aber eine Kopie und wenn man im Debugg-Modus ist, wird man evtl. auch sehen, dass nach dem "return", der Kopierkonstruktor aufgerufen wird. Ich kann es nicht oft genug sagen, aber man muss sich immer vor Augen halten, dass eine Referenz und eine Adresse nicht immer das gleiche sind. Es kommt echt darauf an, in welchem Kontext man diesen Begriff verwendet. Jetzt könnten Sie sich aber fragen, wenn wir hier eh nichts gemacht wird, warum brauche ich so etwas überhaupt. Die Antwort ist, dass es später solche Ausdrücke wie "oA = +oB" geben könnte und dann ist dieser Operator notwendig.

Die zweite Methode ist dazu da, den negativen Wert zurückzugeben. Auch hier wird nicht das eigene Objekt manipuliert, sondern nur eine manipulierte Kopie zurückgegeben. Genau genommen erzeuge ich mir sogar eine Kopie und gebe sie zurück, welche dann wiederum kopiert wird, da mein in der Methode erzeugtes Objekt, nach dem Methodenaufruf, freigegeben wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
					
// Inkrementieren /////////////////////////////////////////////////////////////
CInteger& CInteger::operator++(void) {
	this->m_iValue++;
	return *this;
}

CInteger CInteger::operator++(int) {
	CInteger oResult(this->m_iValue);
	this->m_iValue++;
	return oResult;
} // operator++ ///////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt1(5);
CInteger oObjekt2(7);

oObjekt1			= ++oObjekt2;
oObjekt1			= oObjekt2++;

// oder auch
CInteger* pZeigerAufObjekt1	= new CInteger(5);
CInteger* pZeigerAufObjekt2	= new CInteger(7);

*pZeigerAufObjekt1		= ++(*pZeigerAufObjekt2);
*pZeigerAufObjekt2		= (*pZeigerAufObjekt2)++;

delete pZeigerAufObjekt1;
delete pZeigerAufObjekt2;
					

Die erste Methode implementiert den linksseitigen "++" Operator. Hier wird erst der eigene Wert um eins erhöht und anschließend zurückgegeben. Da hier der eigene Wert des Objektes modifiziert wird, ist diese Methode nicht mit "const" deklariert und es wird wieder die eigene Referenz zurückgegeben (aus mittlerweile bekannten Gründen).

Die zweite Methode implementiert den rechtsseitigen "++" Operator. Hier wird erst der Wert zurück gegeben und anschließend der eigene Wert um eins erhöht. Um dies zu realisieren, benötige ich also eine Kopie des Originals, welches dann auch zurückgegeben wird und kann anschließend den Wert der Membervariable um 1 erhöhen. Da ich wie gesagt hier nur eine Kopie zurück gebe, ist der Rückgabeparameter auch anders, als beim linksseitigen Inkrementierungsoperator.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
					
// Addition ///////////////////////////////////////////////////////////////////
CInteger CInteger::operator+(const CInteger& oIntRight) const {
	return CInteger(this->m_iValue + oIntRight.m_iValue);
}

CInteger CInteger::operator+(const int& iIntRight) const {
	return CInteger(this->m_iValue + iIntRight);
} // operator+ ////////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt1(5);
CInteger oObjekt1(7);
CInteger oObjekt3(9);

oObjekt1			= oObjekt2 + oObjekt3;
oObjekt1			= oObjekt2 + 15;

// oder auch
CInteger* pZeigerAufObjekt1	= new CInteger(5);
CInteger* pZeigerAufObjekt2	= new CInteger(7);
CInteger* pZeigerAufObjekt3	= new CInteger(9);

*pZeigerAufObjekt1		= *pZeigerAufObjekt2 + *pZeigerAufObjekt3;
*pZeigerAufObjekt2		= *pZeigerAufObjekt2 + 15;

delete pZeigerAufObjekt1;
delete pZeigerAufObjekt2;
delete pZeigerAufObjekt3;
					

In der ersten Methode habe ich jetzt den eigentlichen "+" Operator implementiert, mit welchem ich jetzt zwei Objekte miteinander addieren kann. Die anderen Berechnungsmethoden (-, *, /, %, &, | usw.), sähen äquivalent aus. Berechne ich "oA + oB", wird der die Operatormethode "+" von "oA" aufgerufen und der Übergabeparameter "oIntRight" wäre dann "oB". Da hier das eigene Objekt nicht verändert wird, ist diese Methode als konstant definiert und muss logischerweise wieder ein neues Objekt zurück geben.

Die Zweite Methode ist fast so ähnlich, nur das ich hier zusätzlich erlaube, dass man mein Objekt auch mit einem Integer addieren kann. Entsprechend modifiziert, ist dies auch für andere Datentypen möglich.

Zum Seitenanfang
Zum Inhaltsverzeichnis

23.1 Allgemein zu Operatoren

23.1 Allgemein zu Operatoren

Bevor ich richtig anfange, sollte ich doch ein paar Worte im Vorfeld verlieren. Das Überladen von Operatoren macht nicht bei jeder Klasse Sinn. Im vorigen Kapitel ging es um diverse Fahrzeuge und die lassen sich bekanntlich schlecht miteinander verrechnen, aber hin und wieder gibt es Klassen, mit denen es wiederum sehr gut geht und dies sind meistens Klassen, welche irgendwelche Werte oder Zeichen(ketten) repräsentieren.

Bevor man anfängt, muss man einen geeigneten Kopierkonstruktor implementieren. Hat man eine flache Objektstruktur, welche nur Primitivtypen enthält, reicht der Standardkonstruktor. In dem Moment, wo man etwas dynamisch erzeugt (z.B. Arrays oder eingebettete Objekte) ist ein eigener Kopierkonstruktor vonnöten.

Sobald man dies getan hat, muss man sich überlegen, welche Operatoren man braucht und ob diese vonnöten und realisierbar sind. Überladen kann man z.B. Vergleiche (=, <, > usw.), Zuweisungen (=, (), [], usw.), Berechnungen (+, -, *, /, +=, ++ usw.), bis hin zu dem Punk, den Pfeil sowie "new" und "delete". Eine vollständige Liste, findet man in der Hilfe bzw. im MSDN. Dort sind auch die Prioritäten ersichtlich und ob es sich um s.g. unäre (einseitige) oder binäre (zweiseitige) Operatoren handelt.

Um all dies zu zeigen, habe ich mir ein kleines Beispiel ausgedacht. Es geht um eine Klasse, welche lediglich einen Integerwert verwaltet und den besonders kreativen Namen "CInteger" haben wird. Nun könnten Sie sich fragen, warum ich das ausgerechnet mit dem Integer mache, da dies scheinbar absolut unnötig ist. Da stimme ich Ihnen zu, aber man kann sehr schön zeigen, wie man das Überladen von Operatoren realisiert, ohne viel Quelltext zu schreiben. Im Internet findet man häufig Beispiele mit einer Klasse, welche einen Bruch oder eine komplexe Zahl darstellt. Allerdings sind die Berechnungen innerhalb der Klasse und das ganze Beiwerk, oftmals so umfangreich, dass der eigentliche Fokus verloren geht. Ich zeige Ihnen also, wie es prinzipiell funktioniert und Sie können sich das dann bei Gelegenheit adaptieren.

Ich weiße explizit darauf hin, dass ich für meine Klasse keinen Kopierkonstruktor benötige und somit auch keinen implementieren werde.

Zum Seitenanfang
Zum Inhaltsverzeichnis

23.6 Sonstige Operatoren (cast, (), [])

23.6 Sonstige Operatoren (cast, (), [])

Zum Schluss werde ich noch auf ein paar andere Operatoren eingehen, welche man überladen kann, wobei ich nicht auf alle eingehen werde, da ihre Verwendung zum einen mit größter Vorsicht zu genießen sind und zum anderen, sie in der Praxis sehr selten benötigt werden. Andere hingegen sind allerdings sehr nützlich, wie z.B. die Typumwandlungsoperatoren oder die Klammer bzw. Funktionsoperatoren. Dafür modifiziere ich ein letztes mal die Header-Datei wie folgt.

 1
 2
 3
 4
 5
 6
					
		// Typumwandlungen
		operator	float(void) const;

		// Funktionsoperatoren
		void		operator()(int, int) const;
		void		operator[](int) const;
					

Ich fangen zunächst mit der Implementierung für den Typumwandlungsoperator an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
// Typumwandlung //////////////////////////////////////////////////////////////
CInteger::operator float(void) const {
	return (float)this->m_iValue;
} // operator float ///////////////////////////////////////////////////////////
  
// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger	oObjekt(5);
float		fZahl1			= (float)oObjekt

// oder auch
CInteger*	pZeigerAufObjekt	= new CInteger(5);
float		fZahl2			= (float)*pZeigerAufObjekt;

delete pZeigerAufObjekt;
					

Wie Sie am Beispiel sehr schön erkennen könn, ist es jetzt möglich, die Objekt durch eine Typumwandlung zu jagen. Für jede Umwandlung die gewünscht ist, muss man eine entsprechende Methode implementieren. Aber, warum braucht man dies? Nun, ich habe vorhin Operatoren für die Berechnung von Ausdrücken bereitgestellt, aber einen Fall habe ich dabei unter den Tisch Fallen lassen. Möchte an z.B. berechnen "oA = 5 + oB", dann wird dies der Compiler nicht zulassen. Ein möglicher Ausweg wäre jetzt zu schreiben "oA = 5 + (int)oB". Aber auch das könnte man anders handhaben (z.B. durch einen geeigneten expliziten Kopierkonstruktor, der aus der 5 ein Objekt zaubert oder man macht dies händisch mit "oA = CInteger(5) + oB").

Nun zu den Klammer - oder auch Funktionsoperatoren genannt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
					
// Funktionsoperatoren ////////////////////////////////////////////////////////
void CInteger::operator() (int iValue1, int iValue2) const {
	std::cout << "Es wurde " << iValue1 << " und " << iValue2 << " uebergeben\n";
}

void CInteger::operator[] (int iValue) const {
	std::cout << "Hole z.B. " << iValue << ". Element\n";
} // operator[] ///////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt(5);

oObjekt(1, 2);
oObjekt[7];

// oder auch
CInteger* pZeigerAufObjekt = new CInteger(5);

(*pZeigerAufObjekt)(1, 2);
(*pZeigerAufObjekt)[7];

delete pZeigerAufObjekt;
					

Mit diesen Operatoren, kann man Objekte wie Funktionen verwenden. Dabei kann man selbst den Rückgabetyp beliebig festlegen und zumindest bei dem Operator mit den runden Klassern, kann man beliebig viele Parameter übergeben. Bei den eckigen Klammen jedoch immer nur ein Parameter. Im Falle meiner Klasse, machen diese Operatoren nicht wirklich viel Sinn, aber stellen Sie sich vor, Sie hätten eine Matrixklasse, die intern ein Array mit Vektoren besitzt. Mit dem "()" Operator könnte man jetzt z.B. den Wert aus dem zweidimensionalen Feld holen und mit dem "[]" Operator einen Vektor, der an übergebenen Index steht. Sicherlich kann man sich für diese Aufgaben auch Get-Methoden entwerfen, die sogar vom Aufbau her identisch wären, aber mit den zwei erwähnten Operatoren, lässt es sich schöner arbeiten. Trotzdem sollten Sie sich genau überlegen, ob Sie einen solchen Mechanismus implementieren wollen oder nicht, denn gerade für Anfänger, ist eine solche Syntax extrem verwirrend und hinzu kommt, dass die Klammeroperatoren, absolut keine Aussage mehr darüber treffen, was sie eigentlich tun.

Zu guter Letzt, möchte ich noch ein paar Worte über die Operatoren verlieren, welche ich in diesem Tutorial unterschlagen habe. Beispielsweise ist es möglich, den Pfeiloperator "->" zu überschreiben. Allerdings kann man sich bei falscher Implementation, so den Zugriff auf ein Objekt verbauen. Des weiteren ist es möglich, "new" und "delete" zu überladen. Letzteres ist aber auch mit aller höchster Vorsicht zu genießen, weil man hier effektiv Einfluss auf die Speicherverwaltung nimmt. Profis benutzen derartige Überladungen, um eine elegante Speicherprotokollierung zu erstellen. Falls Sie mehr dazu wissen wollen, empfehle ich entsprechende Artikel im Internet zu lesen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

23.2 Zuweisungen (=, +=)

23.2 Zuweisungen (=, +=)

Bevor ich anfange etwas zu berechnen, benötige ich einen Zuweisungsoperator. Im folgenden werden Sie sehen, wie meine Klasse definiert ist und wie man dann das "=" realisiert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
					
// Klasse, welche einen Integer repräsentiert
class CInteger {
	private:
		int m_iValue;

	public:
		// Eigener Konstruktor
		CInteger(int);

		// (binäre) Zuweisung
		CInteger&	operator=(const CInteger&);
		CInteger&	operator=(const int&);
		CInteger&	operator+=(const CInteger&);
		CInteger&	operator+=(const int&);
};
					

Dies ist erst einmal die Grundlegende Klassenstruktur, welche in der Header-Datei steht und welche ich im Verlauf dieses Kapitels erweitern werde. Warum ich die einzelnen Methoden so definiert habe, wie ich sie definiert habe, werde ich dann bei der Implementierung jeder einzelnen, erläutern.

Auffällig ist hier eine neue Art der Schreibweise. Ich habe bei den Methoden-Prototypen nur noch den Übergabetyp angegeben und keinen Namen der Variable. Dies ist bei Prototypen immer möglich und ich habe dies lediglich gemacht, um Ihnen mal zu zeigen, dass es auch anders geht. Es wäre nicht falsch, wenn Sie Variablennamen mit hinschreibt. In der Regel lässt man sie aber nur Weg, wenn keine Missverständnisse auftreten können (ist nur für die Codevervollständigung notwendig). Hat man Beispielsweise eine Matrixklasse und übergibt im Konstruktor eine Zeilen - und Spaltenanzahl, wäre es fatal, den Variablennamen nicht mit anzugeben, da man sonst nicht wüsste, welcher der beiden was ist.

 1
 2
 3
 4
					
// Eigener Konstruktor ////////////////////////////////////////////////////////
CInteger::CInteger(int iValue)
	: m_iValue(iValue)
{} // CInteger ////////////////////////////////////////////////////////////////
					

Der Konstruktor ist nicht wirklich spannend und sollte soweit klar sein. Mit ihm realisiere ich die Initialisierung der Art:

 1
 2
 3
					
CInteger	oMeinObjekt(15);
// bzw.
CInteger*	pZeigerAufMeinObjekt = new CInteger(15);
					

Aber nun wird es spannend!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
					
// Einfache Zuweisungen ///////////////////////////////////////////////////////
CInteger& CInteger::operator=(const CInteger& oInt) {
	this->m_iValue = oInt.m_iValue;
	return *this;
}

CInteger& CInteger::operator=(const int& iInt) {
	this->m_iValue = iInt;
	return *this;
} // operator= ////////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt1(5);
CInteger oObjekt2(7);

oObjekt1			= oObjekt2;
oObjekt2			= 14;

// oder auch
CInteger* pZeigerAufObjekt1	= new CInteger(5);
CInteger* pZeigerAufObjekt2	= new CInteger(7);

*pZeigerAufObjekt1		= *pZeigerAufObjekt2;
*pZeigerAufObjekt2		= 14;

// pZeigerAufObjekt1 = pZeigerAufObjekt2 wäre böse, da dort nicht der = Operator aufgerufen
// werden würde, sondern lediglich die Adressen kopiert und das erste Objekt somit in der Luft
// hängen würde.

delete pZeigerAufObjekt1;
delete pZeigerAufObjekt2;
					

Die erste Methode sorgt dafür, dass man einem Objekt der Instanz "CInteger", ein anderes Objekt des gleichen Typs, zuweisen kann. Wie Sie hier sehen, braucht man dann bei der Implementierung, natürluch einen Variablennamen, weil man sonst nicht auf den entsprechenden Wert zugreifen könnte.

Das Objekt was zugewiesen werden soll, steckt im Methodenparameter. Auffällig hierbei ist das "&". Dies bedeutet, dass ich kein neues Objekt benötige (im Hintergrund würde sonst der Kopierkonstruktor aufgerufen werden), sondern dass mir die Referenz auf das original Objekt genügt (Stichwort call-by-reference). Da ich jetzt das übergebene Objekt normalerweise verändern könnte, habe ich noch das "const" vorangestellt, damit ich dies zum einen nicht darf und zum anderen der Benutzer weiß, dass mit seinem Original nichts passiert.

Der Rückgabewert ist jetzt allerdings erläuterungsbedürftig. Sie sehen, dass ich wieder eine Referenz zurück gebe und wenn Sie sich die Zeile 3 und 8 anschauen, sehen Sie, dass ich die Referenz auf den dereferenzierten Zeiger auf sich selbst zurück gebe oder anders ausgedrückt, ich nehme das Objekt, worauf der eigene Zeiger zeigt (also sich selbst) und gebe das als Referenz zurück, was nicht zu verwechseln mit der Adresse des Objektes ist. Das ist eher wie call-by-reference, nur rückwärts. Nun, was bedeutet und warum mach ich das? Angenommen Sie schreiben "oA = oB". Dann wird die Operatormethode "=" des Objektes "oA" aufgerufen. Somit bezieht sich das "*this" auf "oA". Wenn Sie dann einfach nur ein Objekt zurückgeben würden, wird "oA" mit einer Kopie überschrieben. Dies scheint nicht weiter tragisch, aber schreiben wir "*pA = *pB", zeigt "pA" auf einmal auf etwas ganz anderes und das ursprüngliche Objekt wäre nicht mehr erreichbar und somit entstünde eine Speicherleiche. Um dies zu verhindern geben wir nicht eine Kopie, sondern das eigentliche Objekt, also die Referenz (und noch einmal nicht zu verwechseln mit der Adresse) zurück, damit ein Zeiger immer noch auf das Selbe zeigt und keine Speicherlecks entstehen. Ich weiß, das klingt jetzt erst einmal verwirrend, aber Sie müssen nur lange genug darüber nachdenken und dann werden Sie es einsehen. Dafür sollte aber der Rest der ersten Methode klar sein.

Die Zweite Methode ermöglicht es nun, einem Objekt ein Integer zuzuweisen. Auch hier wird wieder nur eine konstante Referenz übergeben und zurück kommt die eigene Referenz des Objektes. Über diesen Mechanismus kann man noch weitere "=" Operatoren überladen, damit dies auch für long, float usw. funktioniert.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
					
// Auf Objekt drauf addieren //////////////////////////////////////////////////
CInteger& CInteger::operator+=(const CInteger& oInt) {
	this->m_iValue += oInt.m_iValue;
	return *this;
}

CInteger& CInteger::operator+=(const int& iInt) {
	this->m_iValue += iInt;
	return *this;
} // operator+= ///////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt1(5);
CInteger oObjekt2(7);

oObjekt1			+= oObjekt2;
oObjekt2			+= 14;

// oder auch
CInteger* pZeigerAufObjekt1	= new CInteger(5);
CInteger* pZeigerAufObjekt2	= new CInteger(7);

*pZeigerAufObjekt1		+= *pZeigerAufObjekt2;
*pZeigerAufObjekt2		+= 14;

delete pZeigerAufObjekt1;
delete pZeigerAufObjekt2;
					

Die erste Methode ermöglicht jetzt ein Objekt mit sich selbst und einem anderen zu Addieren. Da dies auch wieder eine unäre Operation ist, sehen die Übergabe - und Rückgabewerte, genauso aus, wie zuvor. Der einzige Unterschied ist lediglich, dass jetzt innerhalb der Methode, nicht mehr nur das "=", sondern das "+=" für die Werte angewendet wird.

Die zweite Methode ist wieder das Äquivalent mit einem Integer. Auch hier kann man weitere Methoden deklarieren, um die Zuweisung mit Floats usw. zu realisieren. Die Implementierung für "-=", "*=" und "/=" bzw. "%=", sehen dann entsprechend ähnlich aus.

Zum Seitenanfang
Zum Inhaltsverzeichnis

23.4 Vergleiche (==)

23.4 Vergleiche (==)

Natürlich ist es auch möglich, die Vergleichsoperatoren zu überladen. Wie das geht, werde ich jetzt am "==" Operator demonstrieren. Schauen Sie sich zuerst an, wie die Header-Datei erweitert werden mussn.

 1
 2
 3
					
		// Vergleich (Binär)
		bool		operator==(const CInteger&) const;
		bool		operator==(const int&) const;
					

Und jetzt der entsprechende Teil für die CPP-Datei

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
					
// Vergleichen ////////////////////////////////////////////////////////////////
bool CInteger::operator==(const CInteger& oInt) const {
	return this->m_iValue == oInt.m_iValue;
}

bool CInteger::operator==(const int& iInt) const {
	return this->m_iValue == iInt;
} // operator== ///////////////////////////////////////////////////////////////

// ...

// Ermöglicht wird jetzt z.B. so etwas
CInteger oObjekt1(5);
CInteger oObjekt2(7);

if ((oObjekt1 == oObjekt2) || (oObjekt1 == 15)) {
	// ...
} // end of if

// oder auch
CInteger* pZeigerAufObjekt1	= new CInteger(5);
CInteger* pZeigerAufObjekt2	= new CInteger(7);

if ((*pZeigerAufObjekt1 == *pZeigerAufObjekt2) || (*pZeigerAufObjekt1 == 15)) {
	// ...
} // end of if

delete pZeigerAufObjekt1;
delete pZeigerAufObjekt2;
					

Ich denke mal, das man jetzt nicht mehr so viel erklären muss, da mittlerweile nichts mehr spannendes neues passiert. Es wird wieder die Methode des links vom "==" stehenden Objekt aufgerufen und der Übergabeparameter ist wieder das rechts stehende Glied. Hier wird wieder unterschieden, ob reechter Hand ein Objekt der selben Klasse oder ein Integer steht. Die Implementierungen für andere Vergleichsoperatoren (<. <=, >, >= und !=) und oder anderen Typen, sieht logischerweise wieder ähnlich aus. Auch diese Methoden ändern das Objekt nicht und somit sind die Methoden wieder als konstant deklariert. "Logisch", sollte auch der Rückgabeparameter sein.

Zum Seitenanfang
Zum Inhaltsverzeichnis

20 Polymorphismus

20 Polymorphismus

Der Polymorphismus ist mit einer der genialsten Vorteile, die sich aus einer glücklichen Vererbungshierarchie ergibt. Zu deutsch heißt es Vielgestaltigkeit. Ein Objekt kann also mehrere Formen annehmen. Bevor ich Ihnen aber zeigen kann was ich damit meine, muss ich mit Ihnen noch kurz ein anderes Thema behandeln, nämlich den Mechanismus, mit welchen man es erreicht, dass ein Objekt eine andere Gestalt annehmen kann.

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.3 Wozu braucht man Polymorphismus

20.3 Wozu braucht man Polymorphismus

Stellen Sie sich vor, Sie bauen tatsächlich ein Rennspiel mit den von mir gezeigten Klassen. Nun wollen Sie die Logik für das eigentliche Rennen bauen, in welchem beliebige Fahrzeuge gegeneinander antreten sollen. Nun hat man zunächst ein Problem. Selbst wenn man die Anzahl der Fahrzeuge festsetzt, muss man immer noch verschiedene Typen in Variablen unterbringen, wobei der Typ aber erst zur Laufzeit feststeht. Genau hier kommt der Polymorphismus ins Spiel und das ursprüngliche Problem ist keines mehr. Sie erzeugen sich also das gewünscht Fahrzeug (egal ob Mercedes oder Harley Davidson) und speichern es in einem Array, welches Fahrzeuge aufnehmen kann.

In folgender Grafik habe ich dies mal Schematisch dargestellt. Wichtig dabei ist, dass die Objekte, trotz der Typumwandlung, intern erhalten bleiben, auch wenn man nicht mehr auf alle Attribute und Methoden zugreifen kann.

Schematische Darstellung für einen Polymorphismus

Das eigentlich geniale daran kommt aber noch. Wenn die Klasse "CFahrzeug" eine virtuelle Methode "Zeichnen" bereitstellt und alle anderen Klassen diese überschreiben, kann man sich den Downcast sparen, um jene Methode aufzurufen. Intern bleiben die Objekte wie sie ursprünglich waren und da sie eine virtuelle Tabelle besitzen, weiß der Computer, dass er nicht die Methode des Fahrzeuges, sondern des gewünschten Objektes aufrufen soll. In jener Methode stehen dann auch wieder die einzigartigen Attribute und Methoden der einzelnen Klasse zur Verfügung. Man braucht sich also nicht zu merken, an welcher Stelle im Array was steht. Nur wenn man auf Sachen zugreifen will, die nicht in der Basisklasse stehen, muss man wieder explizit Downcasten und man muss sich somit merken, was wo stand. Hier mal ein Beispiel.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
					
class CFahrzeug { 
	public:
		virtual void Aktualisieren() {
			printf("CFahrzeug::Aktualisieren\n");
		} // Aktualisieren
};

class CAuto : public CFahrzeug {
	public:
		virtual void Aktualisieren() {
			printf("CAuto::Aktualisieren\n");
		} // Aktualisieren
};

class CMotorrad : public CFahrzeug {
	public:
		virtual void Aktualisieren() { 
			printf("CMotorrad::Aktualisieren\n");
		} // Aktualisieren
};

// ...

CFahrzeug*	aFahrzeuge[3];
CFahrzeug*	pFahrzeug	= new CFahrzeug();
CAuto*		pAuto		= new CAuto();
CMotorrad*	pMotorrad	= new CMotorrad;

aFahrzeuge[0]			= pFahrzeug;	// Suchen der richtigen Methode
aFahrzeuge[1]			= pAuto;	// Suchen der richtigen Methode
aFahrzeuge[2]			= pMotorrad;	// Suchen der richtigen Methode

aFahrzeuge[0]->Aktualisieren();
aFahrzeuge[1]->Aktualisieren();
aFahrzeuge[2]->Aktualisieren();

delete aFahrzeuge[2];
delete aFahrzeuge[1];
delete aFahrzeuge[0];
					

Ausgabe:

CFahrzeug::Aktualisieren
CAuto::Aktualisieren
CMotorrad::Aktualisieren
		

Wie Sie sehen konnten, habe ich eine ganz ganz spartanische Implementierung der drei Klassen "CFahrzeug", "CAuto" und "CMotorrad" vorgenommen und lediglich eine virtuelle Methode namens "Zeichnen" implementiert, wobei jene nur den eigenen Klassennamen auf der Konsole ausgibt. Anschließend erzeuge ich ein Array, welches Fahrzeuge aller Art aufnehmen kann, sowie drei Objekte (je eines pro Klasse), welche ich in das Array einhänge. Anschließend rufe ich die Methode auf und man sollte meinen, da es sich jetzt allgemein um Fahrzeuge handelt, dass immer "CFahrzeug::Aktualisieren" ausgegeben wird. Dank der virtuellen Tabelle eines jeden Objektes, wird aber immer die eigene Methode aufgerufen und die Ausgabe entspricht den jeweiligen Klassennamen. Falls eine Klasse die gewünschte Methode nicht implementiert, wird die Methode der Basisklasse aufgerufen.

Hier sollte aber eines schon auffallen. Genau weil man nur Sachen aufrufen kann, die in der Basisklasse vorkommen, neigt man dazu, immer mehr Code so variabel zu gestalten, dass man ihn in eine Basisklasse verschieben kann. Das hat zur Folge, dass die Kindklassen irgendwann nur noch zu Datencontainern werden. Dann stellt sich wieder die Frage, ob man wirklich Vererbung braucht. Wie angedeutet, braucht man virtuelle Methoden, welche die Bearbeitung von Objekten langsamer macht. Sie sollten also nicht auf Teufel komm raus eine Basisklasse bauen, nur um all Ihre Objekte in einem Array halten zu können. Schaffen Sie nur da eine Basisklasse, wo es vom reinen Sprachgebrauch auch sinnvoll wäre. Auto und Fahrzeug ist noch sinnvoll, aber wenn man jetzt einen Mensch hat und möchte Mensch und Fahrzeug gleichzeitig verwalten, macht es nicht viel Sinn, eine Klasse "CDingsda" zu erzeugen. Später weiß keiner mehr, was damit gemeint ist. Sicher könnte man diese Klasse auch "C3DModel" nennen und das wäre zu mindestens sinnvoller bzw. nachvollziehbarer. Trotzdem sollten Sie solche Scherze vermeiden, da im allgemeinen Verständnis ein Fahrzeug nicht wirklich etwas mit einem Menschen zu tun hat (abgesehen davon, das er es fährt, aber dann würde man eher eine Aggregation als eine Vererbung annehmen).

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.2 Was ist Polymorphismus

20.2 Was ist Polymorphismus

Wie bereits erwähnt, ist der Polymorphismus einer der genialsten Vorteile, welcher sich aus einer Vererbung ergibt. Gemeint ist, dass ein Objekt die Form seiner Basisklasse und oder deren Basisklasse annehmen kann. Diese Verwandlung ist auch umkehrbar. Man kann zum Beispiel sagen, dass ein Ferrari ein Auto ist und das ein Auto ein Fahrzeug ist. Genauso kann man sagen, dass ein Ferrari ein Fahrzeug ist. Somit kann ich aus einem Ferrari ein Auto machen. In Folge stehen mir dann auch nur die Attribute und Methoden zur Verfügung, welche ein Auto hat. Später kann ich dann aus diesem Auto wieder ein Ferrari machen. Letzter Schritt funktioniert aber nur, wenn es wirklich mal ein Mercedes war.

Die Umwandlung von Ferrari nach Auto nennt man einen "impliziten Upcast". Implizit, weil man bei der Umwandlung keine Castfunktion aufrufen braucht. Upcast meint, dass ich mich in der Vererbungshierarchie noch oben bewege. Hier mal ein Beispiel.

 1
 2
 3
					
CFerrari	oF40(...);
CAuto		oAllgemeinesAuto	= oF40;	// keine händische Typumwandlung
CFahrzeug	oAllgemeinesFehrzeug	= oF40;	// keine händische Typumwandlung
					

Die Rückverwandlung von einem Auto in ein Ferrari nennt man "expliziten Downcast". Mit explizit ist gemeint, dass man hier sehr wohl eine Typumwandlung vornehmen muss. Dem Compiler ist zwar bekannt, dass ein Ferrari ein Auto ist, aber umgekehrt kann man nicht zwangsläufig sagen, dass ein Auto ein Ferrari ist. Es könnte ja auch ein Opel sein. Downcast mein, dass man sich in der Vererbungshierarchie nach unten bewegt. Ich erweitere eben gezeigtes Beispiel, um dies zu verdeutlichen.

 4
 5
					
oF40	= static_cast<CFerrari>oAllgemeinesAuto;	// Typumwandlung nötig
oF40	= static_cast<CFerrari>oAllgemeinesFehrzeug;	// Typumwandlung nötig
					

Sie fragen sich jetzt bestimmt, was man mit diesem Mechanismus anstellen kann. Die Antwort auf diese Frage folgt im nächsten Unterkapitel.

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.1 Typumwandlungen II

20.1 Typumwandlungen II

Wie ich bereits in Kapitel 1.2 erwähnt habe, spielt die Typumwandlung bei Klassen eine größere Rolle und deswegen werde ich an dieser Stelle diese Thematik erneut aufgreifen und etwas ausführlicher besprechen.

Man unterscheidet zwischen füf verschiedenen Grundarten der Typumwandlung.

Des weiteren findet man im Internet noch diverse andere Typumwandlungen (union_cast, number_cast, truncate_cast, ...), welche aber an dieser Stelle viel zu weit führen würden, nicht Standard sind und auch nur für die absoluten Hardcoreprogrammierer interessant sein könnten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.1.1 C Casting

20.1.1 C Casting

Das klassische C Casting ist genau diese Art von Typumwandlung, welche ich in Kapitel 1.2 angesprochen habe. In modernen Quelltexten findet man so etwas allerdings eher selten, da diese Art des Castings noch aus den Uhrzeiten stammt. Erstaunlicherweise funktioniert es zwar in Visual Studio auch bei Klassen, aber allein die Tatsache, dass es eigentlich nicht funktionieren dürfte, sollte Sie abschrecken.

Der Vollständigkeit halber, sehen Sie im folgendem Quelltext noch einmal ein klassisches C Casting. Sie sollten aber ab jetzt die Finger davon lassen!

 1
 2
 3
 4
 5
					
float	fWert		= 1234.5678;
int	iGerundet1	= (int)fWert;	// Variante 1
int	iGerundet2	= int(fWert);	// Variante 2

printf("Abgerundet: %i und %i", iGerundet1, iGerundet2);
					

Ausgabe:

Abgerundet 1234 und 1234
		
Zum Seitenanfang
Zum Inhaltsverzeichnis

20.1.5 reinterpret Casting

20.1.5 reinterpret Casting

Diese Typumwandlung ist etwas besonderes und auch die gefährlichste Variante. Mit ihr kann man Speicherbereiche neu interpretieren. Was meine ich damit? Angenommen, Sie haben einen Float und machen ein statisches Casten in einen Int. Dann wird der Wert des Floats so im Speicher mgeschrieben, dass ein Integer daraus wird. Das Ergebnis im Arbeitsspeicher sieht also nach der Konvertierung total anders aus. Beim Reinterpretieren, bleibt jede 1 und 0 an der Stelle wo sie war, was zur folge hat, dass man nicht genau voraussagen kann, was am Ende dabei herauskommt.

Ein denkbarer Anwendungsfall ist, dass man wirklich sehen möchte, wie gewisse Datentypen im Speicher abgelegt werden. In diesem Fall würde man eine reinterpret Typumwandlung auf beispielsweise einen unsigned int machen. Dies will ich jetzt einmal Demonstrieren.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
					
// Ausgabe eines Integers als Binärzahl //////////////////////////////////
void Ausgabe(unsigned int iWert) {
	unsigned int	iErgebnis			= iWert;
	unsigned int	aSpeicher[32];
	int		iPosition			= 31;

	do { // Umwandlung Dezimal -> Binär
		aSpeicher[iPosition--]			= iErgebnis % 2;
		iErgebnis				/= 2;
	} while (iErgebnis > 0);

	// Mit Nullen auffüllen
	while (iPosition >= 0) aSpeicher[iPosition--]	= 0;

	printf("Dezimal: %11i - Binaer: ", iWert);
	
	for (iPosition = 0; iPosition < 32; iPosition++) | 
		printf("%u", aSpeicher[iPosition]);
	} // end of for
	
	printf("\n");
} // Ausgabe //////////////////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argtv) {
	float* pWert1			= new float(-20.25f);
	float* pWert2			= new float(0.125f);

	unsigned int* pKonvertiert1	= reinterpret_cast<unsigned int*>(pWert1);
	unsigned int* pKonvertiert2	= reinterpret_cast<unsigned int*>(pWert2);

	Ausgabe(*pKonvertiert1);
	Ausgabe(*pKonvertiert2);

	delete pWert1;
	delete pWert2;

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Dezimal: -1046347776 - Binaer: 11000001101000100000000000000000
Dezimal:  1040187392 - Binaer: 00111110000000000000000000000000
		

Sie sehen also deutlich, dass der Speicherbereich komplett neu interpretiert wird und dass es ohne weiteres nicht möglich ist, den neuen Wert abzuschätzen. Wenn man sich nur den Wert des Integers anschaut, fragt man sich, wie jener zustande kommt. In dem Moment, wo man diese Dezimalzahl in eine binäre umwandelt und ausgibt, sieht man dann, dass der Binärwert, nach IEEE 754, tatsächlich dem Wert des Floats entspricht.

Ein weiterer Anwendungsfall wäre, eine Adresse (also den Wert eines Zeigers) in einem Array aus Integern zwischenzuspeichern. Genauso kann man auch Zeiger auf ganz andere Sachen umbiegen. Diese Art der Typumwandlung ist also eine Art Baseballschläger, mit dem man fast alles gnadenlos umwandeln kann. Also, auch hier sollten Sie sehr vorsichtig sein!

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.1.4 dynamic Casting

20.1.4 dynamic Casting

Ich hatte bereits erwähnt, dass es bisher keinerlei Prüfmechanismen gab, welche eine Konvertierung in nicht voneinander abhängige Typen, verhindert hat und genau hier setzt die dynamische Typumwandlung an. Wenn man jetzt versucht aus einem Zeiger auf ein Fahrzeug, ein Zeiger auf ein Motorrad zu wandeln, dann wird zu mindestens geprüft, ob ein Motorrad in irgend einer Form von Fahrzeug abgeleitet wurde und es somit möglich sein kann, dass es sich bei dem Speicherbereich, auf welchen verwiesen wird, um ein Motorrad handeln kann. Dynamisches Casting funktioniert also nur bei polymorphen Klassen.

 1
 2
 3
 4
					
CMotorrad* pMotorrad1	= new CMotorrad();
CFahrzeug* pFahrzeug	= pMotorrad1;			// hoch casten

CMotorrad* pMotorrad2	= dynamic_cast<CMotorrad*>(pFahrzeug);	// runter casten
					

Diese Art der Typumwandlung wird eher selten gemacht, da sie recht teuer ist. Abgesehen von den zusätzlichen Prüfungen zur Laufzeit, muss jedes Objekt zusätzliche Typinformationen mit sich herumtragen, damit die Überprüfung weiß, was sich ursprünglich hinter einem Zeiger verborgen hat (Objekte benötigen mehr Speicherplatz). Sie sollten sich also von diesem Mechanismus distanzieren und ihn nur im Notfall verwenden.

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.1.3 static Casting

20.1.3 static Casting

Das statische Casten ist die Mutter der Castings in C++ und funktioniert im Grunde genommen wie das klassische C Casting, nur mit dem Unterschied, dass man wieder ein Funktionstemplate benutzt. Bei der Umwandlung selbst werden keine Überprüfungen vorgenommen, aber falls man etwas casten will, was offensichtlich nicht funktioniert, wird man schon durch den Compiler darauf hingewiesen. In den nächsten Kapiteln wird Ihnen genau diese Art der Typumwandlung begegnen.

Im folgenden Beispiel zeige ich, wie man einen spezifischen Zeiger in einen Allgemeinen und wieder zurück wandelt.

 1
 2
 3
 4
 5
 6
 7
					
int*	pWert1	= new int(10);
void*	pWert2	= static_cast<void*>(pWert1);
long*	pWert3	= static_cast<long*>(pWert2);

printf("%li", *pWert3);

delete pWert1;
					

Ausgabe:

10
		

In Zeile 1 erzeuge ich mir einen Pointer auf einen Integer und initialisiere ihn gleich mit dem Wert 10. Anschließend, in Zeile 2, erzeuge ich mir einen "void" Zeiger, welchen ich auf den gleichen Wert zeigen lasse, allerdings jetzt ohne Angabe auf die Struktur, auf welche der Zeiger verweist. In Zeile 3 erzeuge ich jetzt einen Zeiger auf einen "long" Wert und lasse ihn, mittels statischem Casten, auf den selben Wert zeigen. Jetzt wird der Speicher, auf welchen der Zeiger verweist, als "long" interpretiert und wie Sie das an der Ausgabe sehen, hat alles wie gewünscht, funktioniert.

Zum Seitenanfang
Zum Inhaltsverzeichnis

20.1.2 const Casting

20.1.2 const Casting

Hin und wieder steht man vor dem Problem, dass man einen konstanten Wert bzw. eine Variable oder Funktion, welche mit "const" qualifiziert wurde, in eine dynamische Variable überführen möchte oder muss. Hierfür gab es bisher keine Möglichkeit. Das s.g. const Casting bietet hier einen Ausweg. Aber Vorsicht! Diese Umwandlung ist nur vorübergehend! Mann darf anschließend den Wert nicht ändern, da man sonst den konstanten Speicherbereich beschädigt und das Programm somit abstürzen wird. Seien Sie sich dessen immer bewusst und verwenden Sie folgenden Mechanismus, nur im Notfall.

 1
 2
 3
 4
 5
					
const char*	pcstrWert1	= "Hallo Welt";
char*		pstrWert2;
pstrWert2			= const_cast<char*>(pcstrWert1);

printf("%s", pstrWert2);
					

Ausgabe:

Hallo Welt
		

In Zeile 1 erzeuge ich mir also einen konstanten String mit einem konstanten Inhalt. Jene Variable weiße ich dann in Zeile 3, mit Hilfe der "const_case" Funktion, einem Zeiger auf ein dynamischen String zu. Die Syntax sieht ein wenig merkwürdig aus, aber das liegt daran, dass es sich hier um ein Funktionstemplate handelt. Was das genau ist und warum man das so schreiben muss, werde ich im entsprechenden Kapitel erklären. Wichtig im Moment ist nur, dass man den gewünschten Ergebnistyp, in spitze Klammern vor den runden Klammern, angibt. Alle weiteren Typumwandlungen werden auf ähnlichen Funktionstemplates basieren und stehen auch erst ab C++ zur Verfügung.

Der "const cast" kann und wird für ganz üble Hacks benutzt. Es geht nämlich eigentlich weniger darum eine klassische Konstante so zu manipulieren, dass sie nicht mehr konstant ist. Vielmehr hebelt man damit den Mechanismus aus, welcher mit einem konstanten Funktionsparameter erzwungen werden soll. Angenommen Sie übergeben einer Funktion eine Variable als konstante Referenz. Mit dem Modifikator "const", will der Ersteller der Funktion Ihnen zeigen, dass er den Wert nicht manipuliert und normalerweise könnte er es auch nicht. Da es sich aber nicht um eine richtige Konstante handelt und somit eine Änderung theoretisch nicht den Speicher kaputt machen würde, kann man mit dieser Art der Typumwandlung, die Variable wieder beschreibbar machen. Sie merken also, dass man hier eigentlich nur hässliche Sachen machen kann und deswegen Finger weg!

Zum Seitenanfang
Zum Inhaltsverzeichnis

18 Statische Attribute und Methoden

18 Statische Attribute und Methoden

Bevor ich mich der Vererbung zuwende, möchte ich auf eine Kleinigkeit eingehen, welche zwar im Alltag weniger Anwendung findet, aber trotzdem wichtig ist. Es handelt sich, wie die Überschrift schon sagt, um statische Attribute und Methoden. Generell sind dies Attribute und Methoden einer Klasse zugeordnet und keiner Objektinstanz. Das heißt, man kann sie Abfragen bzw. aufrufen, ohne eine Instanz der Klasse zu besitzen. Da dies etwas merkwürdig klingt, werde ich dies an zwei kleinen Beispielen demonstrieren.

Zum Seitenanfang
Zum Inhaltsverzeichnis

18.2 Beispiel II

18.2 Beispiel II

Eine weitere Besonderheit dieser statischen Klassenattribute ist, dass alle Objektinstanzen sich diese teilen. So hat man quasi einen Art globale Klassenvariable. Ein typischer Anwendungsfall wäre z.B., dass man einen Instanzzähler bauen will, welcher festhält, wie viele Objekte der Klasse bereits instantiiert wurden.

Folgendes Beispiel soll dies demonstrieren, wobei ich der Übersicht halber, nicht zwischen Header - und CPP-Datei unterscheide.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
					
// Definition der Klasse //////////////////////////////////////////////////////
class CStatischesMitzaehlen {
	public:
		CStatischesMitzaehlen(void);

	private:
		static unsigned int s_iInstCounter;
		const unsigned int m_iInstanceID;
};

// Initialisieren der Klassenvariable
unsigned int CStatischesMitzaehlen::s_iInstCounter = 0;

// Implementierung des Konstruktors ///////////////////////////////////////////
CStatischesMitzaehlen::CStatischesMitzaehlen()
	: m_iInstanceID(++CStatischesMitzaehlen::s_iInstCounter)
{
	printf("Es wurde die %i. Instanzen der Klasse erzeugt\n", m_iInstanceID);
} // CStatischesMitzaehlen ////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	CStatischesMitzaehlen oObjekt1;
	CStatischesMitzaehlen oObjekt2;
	CStatischesMitzaehlen oObjekt3;

	printf("\nEs wurden insgesamt %i Instanzen erzeugt", CStatischesMitzaehlen::s_iInstCounter);
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Es wurde die 1. Instanzen der Klasse erzeugt
Es wurde die 2. Instanzen der Klasse erzeugt
Es wurde die 3. Instanzen der Klasse erzeugt

Es wurden insgesamt 3 Instanzen erzeugt
		

In den Zeilen 2 bis 9, definiere ich mir zunächst eine Klasse (dies würde man normalerweise in der Header-Datei machen). In Zeile 7 habe ich ein statisches Attribut deklariert, in welchem die Anzahl der erstellten Objektinstanzen gespeichert werden soll. Zudem benötigt man noch ein Attribut, welches die Instanznummer enthält, die jedem einzelnen Objekt zugeordnet ist. Genau dies geschieht in Zeile 8. Auffällig daran ist, dass ich dieses Attribut als Konstante definiert habe, da ich eine eineindeutige ID erzeugen möchte, die später nicht mehr geändert werden kann.

In den Zeile 14 bis 21, ist dann die Implementierung der Klasse zu sehen, welche typischerweise in die entsprechende CPP-Datei zur Klasse gepackt wird. In diesem Beispiel handelt es sich lediglich um den Konstruktor. Die erste wichtige Sache hier, ist bereits in Zeile 14 zu sehen. Dort habe ich die statische Klassenvariable initialisiert. Dies macht man üblicherweise im Kopf der CPP Datei und es ist wichtig, dass man hier erneut den Typen des Attributes angibt. In Zeile 20 sehen Sie nun, wie das konstante Attribut initialisiert wird. Normalerweise macht man so eine Initialisierung der Konstanten, bereits in der Header-Datei, aber da ich den Wert ja dynamisch setzen will, ist dies der einzig mögliche Weg. Man kann Konstanten also nur in der Headerdatei oder zwischen Konstruktorkopf und dem Anweisungsblock der Methode setzen. In Zeile 20 wäre dies nicht mehr möglich. Zu diesem Konstruktor sei noch erwähnt, dass man normalerweise keine Ausgaben in Konstruktoren packt, aber da ich auf eine zusätzliche Ausgabemethode verzichten wollte, habe ich dies so gelöst.

Die Zeilen 25 bis 32 steht dann nichts neues. Hier erzeuge ich lediglich ein paar Objekte, um zu demonstrieren, wie sich diese Klasse verhält.

Ein weiteres Anwendungsgebiet für statische Klassenattribute, wären s.g. "Singleton", auf welche ich aber erst in einem späteren Kapitel eingehen werde. Hier sei nur schon erwähnt, dass es sich um Klassen handelt, von denen man nur eine Instanz erzeugen kann.

Zum Seitenanfang
Zum Inhaltsverzeichnis

18.1 Beispiel I

18.1 Beispiel I

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
24
25
26
27
					
// Klasse mit statischen Attributen und Methoden //////////////////////////////
class CStatischesZeug {
	public:
		static const int m_iNameLength = 80;
		static char* ClassName();
};

// Implementierung einer statischen Methode ///////////////////////////////////
char* CStatischesZeug::ClassName() {
	char* pstrResult = new char[m_iNameLength];
	strcpy_s(pstrResult, m_iNameLength, "CStatischesZeug");

	return pstrResult;
} // ClassName ////////////////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	char*	pstrName	= CStatischesZeug::ClassName();
	int	iLength		= CStatischesZeug::m_iNameLength;

	printf("Name der statischen Klasse: %s\n", pstrName);
	printf("Laenge des Klassennamens:   %i",   iLength);

	delete [] pstrName;
	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Name der statischen Klasse: CStatischesZeug
Laenge des Klassennamens:   80
		

In der Klassendefinition, in Zeile 1 bis 4, steht jetzt vor dem Attribut und vor der Methode das Schlüsselwort "static". Ansonsten hat sich hier nichts weiter verändert.

Auch die Implementierung der Methode in Zeile 7 bis 12, sieht erst einmal nicht anders aus, als gewohnt. Allerdings gibt es hier eine Kleinigkeit, die zunächst nicht offensichtlich ist. In statischen Methoden darf und kann man den "this" Zeiger nicht benutzen. Dies würde ja voraussetzen, dass es eine Instanz der Klasse gibt, auf welche referenziert werden kann, was bei statischen Sachen, nicht der Fall ist.

In der "main" Funktion ist jetzt etwas entscheidendes anders. In Zeile 17 und 18, greife ich jetzt auf die statische Methode und das statische Attribut der Klasse zu, ohne mir eine Objektinstanz der Klasse zu erzeugen. Sehr markant an solchen Aufrufen bzw. Zugriffen, sind der explizite Zugriff über den Namespace der Klasse. Grob gesagt kann man sich also statische Attribute und Methoden als globale Variablen bzw. Funktionen vorstellen, welche innerhalb einer Klasse gekapselt sind und nur mit dem vorangestellten Klassennamen aufrufbar sind.

Zum Seitenanfang
Zum Inhaltsverzeichnis

15 Grundlagen

15 Grundlagen

Bevor ich mit Ihnen voll in die Programmierung einsteigen kann, muss ich zunächst ein paar begriffliche Sachen mit Ihnen klären, damit Sie später auch wissen, von was ich rede. Zudem werde ich auf grundlegende Philosophien eingehen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

15.3 Konstruktor und Destruktor

15.3 Konstruktor und Destruktor

Konstruktor und Destruktor sind zwei grundlegende Methoden, welche jede Klasse besitzt. Auch wenn man sie nicht implementiert, werden s.g. Standardkonstruktoren und Standarddestruktoren, im Hintergrund, benutzt. Das Besondere an ihnen ist, dass sie nichts zurück geben (noch nicht einmal "void"). Sie können jedoch Übergabeparameter besitzen. Eine weitere Besonderheit ist, dass diese zwei Methoden niemals händisch aufgerufen werden, wobei man mit dieser Formulierung vorsichtig sein muss, da man zu mindestens den Konstruktor, indirekt doch aufruft und es sich bei der Vererbung wieder anders verhält.

Der Konstruktor ist für die Initialisierung des Objektes gedacht und setzt in der Regel nur Standardwerte für die Attribute bzw. erzeugt interne Objekte. Der Konstruktor heißt immer wie die Klasse selbst. Er kann zudem in mehreren Varianten auftreten (kann überladen werden). Man unterscheidet prinzipiell zwischen Standardkonstruktor, eigene bzw. benutzerdefinierte Konstruktoren und dem s.g. Kopierkonstruktor.

Der Destruktor ist nun das genaue Gegenteil. Er ist dafür gedacht, den Laden dicht zu machen. Er nimmt aufräumarbeiten vor, wie das Freigeben von Speicher interner Variablen / Attribute. Der Destruktor heißt auch wie die Klasse selbst, nur das ihm eine Tilde voran gestellt wird.

Wie dies alles genau aussieht, werden Sie im nächsten Kapitel sehen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

15.2 Warum sollte man Klassen benutzen

15.2 Warum sollte man Klassen benutzen

Der Wohl größte Vorteil von Klassen, ist die s.g. Vererbung und der Polymorphismus. Was dies genau ist und worin die Vor - und Nachteile liegen, werde ich später erläutern und demonstrieren.

Ein weiterer Aspekt von Klassen ist, dass man die Attribute und Methoden unterschiedlichen Sichtbarkeiten zuordnen kann. Damit kann man verhindern, dass bestimmte Sachen von außen aufgerufen bzw. abgerufen oder überschrieben werden. Gerade bei Attributen macht das sehr viel Sinn, da es ungünstig wäre, ein Passwort auszulesen. Des weiteren ist es auch sinnvoll, Membervariablen zu schützen, welche nur bestimmte Werte annehmen dürfen. Hier wäre eine vorherige Prüfung wichtig. Aber auch so manche Methode sollte nicht von außen aufgerufen werden dürfen, wie z.B. solche, die von gewissen Ausgangszuständen ausgehen, die nicht immer vorhanden sind.

Noch ein großer Vorteil der Klassen, liegt in ihrer Wiederverwendbarkeit, da sie meist so entworfen wurden, dass sie autark laufen können. Damit meine ich, dass sie sich selbst verwalten und im Optimalfall, von keinen äußeren Faktoren abhängen (Ausführen kann man sie deshalb noch lange nicht). Daraus resultiert eine bessere Möglichkeit, Aufgaben in Teams aufzuteilen. Team A braucht nicht zu wissen, wie Team B ein Problem löst. Team A muss lediglich wissen, wie man die Klasse von Team B anspricht.

Durch die Zusammenfassung von Attributen und Methoden, wird im allgemeinen der Quelltext lesbarer und es ergeben sich kleinere Vorteile bei der Implementierung der Methoden. Da Attribute für Methoden quasi global sind, kann man sich bei den Methoden, Übergabeparameter sparen und somit wird der Methodenkopf schlanker, was wiederum der besseren Verständlichkeit beiträgt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

15.1 Was sind Objekte und Klassen

15.1 Was sind Objekte und Klassen

Ganz formal sind Objekte Instanzen einer Klasse. Sicher klingt diese Definition sehr steif und ich denke nicht, dass ein Neuling jetzt schlauer ist. Also, was ist damit gemeint? Eine Klasse ist ein selbst definierter Datentyp, genauso wie "int" oder "float" ein Datentyp ist. Das Objekt ist dann die Variable des neuen Datentyps. Eine Klasse ist also eine Art Bauplan, wohingegen ein Objekt ein Haus ist, welches nach diesem Bauplan errichtet wurde. Andere Beispiele wären die DNS, welche vorgibt, wie ein Mensch wächst oder ein Förmchen (Klasse), mit welchem man Kekse (Objekte) aus dem Teig (Speicher) ausstechen kann.

Prinzipiell können sie, wie ein Struktur, Werte aufnehmen. Das Neue für Sie an einer Klasse ist nun, dass sie auch noch Funktionen aufnehmen können (streng genommen geht das heutzutage auch bei Strukturen, aber dazu später mehr). Wenn man Funktionen in einer Klasse kapselt, spricht man von Methoden einer Klasse (also ein Synonym). Die Werte nennt man Attribute oder auch Membervariablen und Eigenschaften, wobei man mit letzter Bezeichnung sehr vorsichtig sein muss, da in anderen Sprachen, wie z.B. Delphi, Eigenschaften etwas anderes sind (auch bei der COM-Technologie, welche Sprachen neutral ist).

Auch wenn man jetzt scheinbar alles in eine Klasse packen kann, was man irgendwann mal brauchen könnte, macht man dies nicht. Es werden immer nur Attribute und Methoden aufgenommen, welche für die innere Verwaltung und Funktionalität, notwendig ist. Beispielsweise könnte eine Klasse "Auto" eine Methode "Fahren" haben, aber man wird in diese Klasse niemals ein Hauptmenü mit einbauen. Eine Ausgabe hingegen macht da schon mehr Sinn, obwohl man dies nicht gleich einsehen würde. Denkt man aber an ein Computerspiel, ist es schon Sinnvoll, wenn sich das Auto selber zeichnen könnte.

Eine Besonderheit in C++ ist, dass man im Gegensatz zu den meisten anderen Sprachen, Objekte auch ganz normal auf dem Stack ablegen kann und nicht zwingend mit einem Pointer auf sie zugreifen muss. Dies wirkt einigen Nachteile der objektorientierten Programmierung entgegen. Des weiteren versucht der Compiler die Objekte möglichst zusammenhängend im Speicher anzulegen (ähnlich Strukturen oder Arrays), was cachefreundlich ist.

Zum Seitenanfang
Zum Inhaltsverzeichnis

21 Alternativen zur Vererbung

21 Alternativen zur Vererbung

Ich hatte ja bereits erwähnt, das die Vererbung ein paar kleine Nachteile hat, wenn man Methoden als virtuell definiert. Zudem ist es auch nicht klug, z.B. das Zeichnen von Elementen auf verschiedene Methoden zu verteilen (es würden ja wieder langsame Funktionsaufrufe im Hintergrund stattfinden und zusammenhängender Code wird auseinandergerissen - das erschwert die Fehlersuche). Deswegen möchte ich Ihnen in diesem Kapitel zwei weitere Konzepte vorstellen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

21.3 Mischformen

21.3 Mischformen

Eine strikte Trennung zwischen Aggregation und Komposition sorgt zwar für ein sauberen Aufbau und hat viel Stiel, aber irgendwie sieht die Realität anders aus. Das fängt schon damit an, dass man oft mehrere gleichartige Objekte komponieren muss. Dies ist dann aber streng genommen keine Aggregation mehr. Genauso ist es bei der Aggregation hinderlich (und auch nicht sehr performant), wenn man immer erst Prüfen muss, ob es das Objekt gibt. Oft sieht man, dass zwar ein Aggregationsmechanismus angeboten wird, aber wenn man den Standardkonstruktor aufruft (und somit nichts übergibt), wird das Unterobjekt intern erzeugt und auch nur in diesem Fall freigegeben. Somit reduziert man die Prüfungen und ist flexibler.

Zum Seitenanfang
Zum Inhaltsverzeichnis

21.1 Aggregation

21.1 Aggregation

In diesem Kapitel geht es um das Einbinden von Objekten innerhalb von Objekten, denn genau dies ist mit Aggregation gemeint. Eine Aggregation ist immer eine 1 zu n Beziehung mit einem anderen Objekt, wobei jenes nicht zwangsläufig existieren muss. Im UML Diagramm werden diese Beziehungen mit einer Raute an der Relationslinie gekennzeichnet, wobei die Raute an jenem Objekt steht, welche den Container darstellt. Zudem schreibt man an die Relationslinien die entsprechenden Kardinalitäten, also auf deutsch gesagt, das Verhältnis der Beziehung (z.B. 1 zu 1, 1 zu n oder n zu m - wobei n auch oft durch ein * ausgedrückt wird).

Veranschaulichung einer Aggregation

In obiger Grafik sehen Sie, dass die Klasse "CFahrzeug" die Objekte der Klasse "CRaeder" aggregiert. Das hat jetzt mehrere Vorteile. Mir ist es jetzt möglich ein Fahrzeug ohne Räder zu erzeugen. Das ist praktisch, falls ich später ein Boot implementieren möchte, was in der Regel keine Räder hat. Genauso kann ich die Räder vom Fahrzeug im Nachhinein lösen (z.B für ein Unfall bei welchem das Fahrzeug die Räder verliert) und/oder ersetzen (z.B. bei einem Boxenstopp).

Was haben nun die Kardinalitäten zu sagen? Man muss sie immer beidseitig betrachten, also aus beiden Richtungen. Jedes Rad ist nur einem Fahrzeug zugeordnet, aber jedem Fahrzeug können keine oder mehrere Räder zugeordnet seien. Statt dem "n" hätte ich auch vier nehmen können, aber wenn ich später einen LKW mit aufnehmen möchte, bräuchte ich definitiv mehr als vier Räder und deswegen habe ich es allgemein gehalten.

Aber was bedeutet dies jetzt für die Implementierung? Schauen wir uns zunächst die Header-Datei an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
					
// Basisklasse für alle Fahrzeuge
class CFahrzeug {
	private:
		CRaeder**	m_pRaeder;
		int 		m_iRadanzahl;

		// ...

	public:
		// Benutzerdefinierter Konstruktor
		CFahrzeug(CRaeder** pReader = NULL, int iRadanzahl = 0);

		// ...
};
					

Wie man sehr schön erkennen kann, befindet sich in der Klasse ein Zeiger auf ein Array der mit Zeigern auf Räder. Zeiger sind für Aggregationen üblich, da die Objekte (bzw. ihre Referenz) dem Konstruktor übergeben werden (auch optional noch später über Set-Methoden). Alternativ könnten sie ja auch nicht vorhanden sein (es wird NULL übergeben), und dies kann man nur durch Pointer realisieren. Die Speicherverwaltung wird außerhalb der Klasse vorgenommen.

Nun zur Implementierung in der zugehörigen CPP Datei des Fahrzeuges.

 1
 2
 3
 4
 5
 6
 7
 8
 9
					
#include "Fahrzeug.h"



// Benutzerdefinierter Konstruktor
CFahrzeug::CFahrzeug(CRaeder** pRaeder, int iRadanzahl)
	: m_pRaeder(pRaeder)
	, m_iRadanzahl(iRadanzahl)
{} ////////////////////////////////////////////////////////////////////////////
					

Was hier passiert, dürfte nicht weiter spannend sein. Im benutzerdefinierten Konstruktor werden die entsprechenden Komponenten übergeben und zugewiesen. Einen Destruktor gibt es nicht, da die Speicherverwaltung außerhalb des Objektes stattfindet und so nichts freigegeben werden muss.

Bei aggregierten Objekten muss man also vor jeder Verwendung (also in allen Methoden, welche auf diese Objekte zugreifen wollen) erst prüfen, ob ein gültiger Zeiger vorliegt, bevor man ihn dereferenziert, um auf seine Member zuzugreifen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

21.2 Komposition

21.2 Komposition

Die Komposition ist fast das Gleiche wie die Aggregation, nur handelt es sich hier üblicherweise um eine strikte 1 zu 1 Beziehung. Mit strikt meine ich, dass das Containerobjekt nicht ohne das komponierte Objekt auskommt bzw. lebensfähig ist. Während ein Mensch z.B. seine Kleider aggregiert (man hat sie nicht immer an und wenn man sie an hat, dann sind es solche, die erst weit nach der eigenen Geburt produziert und ihm Laden übergeben wurden), ist die Beziehung zu seinem Herzen eine Komposition. Der Mensch kann nur mit Herz existieren. Es entsteht mit seiner Entstehung und es hört genau dann auf zu schlagen, wenn er stirbt. Das zeigt uns, das bei einer Komposition das Containerobjekt selbst die Steuerung und somit die Speicherverwaltung vornimmt. üblicherweise benutzt man hier keine Zeiger. Sie werden im Konstruktor erzeugt und im Destruktor freigegeben. In UML wird diese Beziehung ebenfalls mit einer Linie und einer Raute daran ausgedrückt, nur mit dem Unterschied, dass die Raute ausgemalt ist.

Die meisten Relationen meines Beispieles sind Kompositionen, da ich Zeiger und somit unnötige Indirektionen umgehen möchte. Zudem macht es oft auch wenig Sinn, ein Motorrad ohne Kette oder Lenker zu haben (zu mindestens in einem Computerspiel).

Veranschaulichung einer Komposition

Schauen wir uns nun die Definition in der Header-Datei an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
					
#include "Fahrzeug.h"



// Basisklasse für alle Motorräder
class CMotorrad : public CFahrzeug {
	private:
		CLenker		m_oLenker;
		CKette		m_oKette;

		// ...

	public:
		// Benutzerdefinierter Konstruktor
		CMotorrad(int iLenkerart);

		// ...
};
					

Und jetzt die Implementierung in der CPP Datei.

 1
 2
 3
 4
 5
 6
 7
 8
 9
					
#include "Motorrad.h"



// Benutzerdefinierter Konstruktor ////////////////////////////////////////////
CMotorrad::CMotorrad(int iLenkerart)
	: m_oLenker(iLenkerart)
	, m_oKette()
{} ////////////////////////////////////////////////////////////////////////////
					

Wie Sie sehen können, benutze ich keine Zeiger und die Objekte werden im Konstruktor erzeugt. Hätte ich mich auch hier für Zeiger auf die komponierten Objekte entschieden, bräuchte ich also noch einen Destruktor, um den Speicher wieder freizugeben.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24 Templates

24 Templates

In diesem Kapitel werde ich eine Möglichkeit vorstellen, mit welcher man noch mehr Flexibilität bekommt. Es handelt sich um den Mechanismus der Templates (auch Muster genannt). Mit ihnen kann man sich das Leben als Programmierer noch mehr vereinfachen als mit Klassen, allerdings möchte ich die Euphorie noch in Grenzen halten, da sich aus Templates ein paar wesentliche Nachteile ergeben.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.3 Funktionstemplates

24.3 Funktionstemplates

Als erstes fange ich mit Funktionstemplates an, weil man hier am schnellsten zeigen kann, was eigentlich im Hintergrund passiert. Im ersten Schritt werde ich also ein ganz kleines Template entwerfen, welches nichts anderes macht, als zwei Datentypen zu addieren. Das macht zwar in der Realität absolut keinen Sinn, weil man für so etwas keine Funktion braucht, aber man sieht sehr schön was passiert und wie es gemacht wird. Schauen wir uns mal an, wie so ein kleines Projekt mit einem solchen Template aussehen könnte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
					
#include <stdio.h>

// Funktionstemplate //////////////////////////////////////////////////////////
template<typename TResultDataType, typename TParameterDataType>
TResultDataType Addiere(TParameterDataType tElement1, TParameterDataType tElement2) {
	return static_cast<TResultDataType>(tElement1) + static_cast<TResultDataType>(tElement2);
} // Addieren /////////////////////////////////////////////////////////////////



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main(int argc, char** argv) {
	int iValue1		= 10;
	int iValue2		= 20;
	long int iErgebnis	= Addiere<long int, int>(iValue1, iValue2);

	double dValue1		= 1.5;
	double dValue2		= 2.7;
	float fErgebnis	= Addiere<float, double>(dValue1, dValue2);

	printf("Ergebnis der Addition ist %li\n", iErgebnis);
	printf("Ergebnis der Addition ist %g\n", fErgebnis);

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

Ergebnis der Addition ist 30
Ergebnis der Addition ist 4.2
		

In Zeile 4 bis 7 siehen Sie, wie man ein Funktionstemplate implementiert. Jedes Template wird mit dem Schlüsselwort "template" eingeleitet, gefolgt von einer geöffneten spitzen Klammer, danach eine Parameterliste, einer schließenden spitzen Klammer und abschließend der Definition der Funktion oder Klasse (letzteres erkläre ich später). Was ist mit Parameterliste gemeint? Sie beinhaltet meistens eines der drei Schlüsselwörter "typename", "class" oder "template", gefolgt von einem frei wählbaren Typenbezeichner, welchen man innerhalb des Templates benutzt. Anschließend kann man mit Komma getrennt, weitere dieser Paare definieren. Den Unterschied zwischen "typename" und "class", werde ich im nächsten Unterkapitel erläutern. Auf den Parametertyp "template" werde ich nicht weiter eingehen, da man es zum einen selten benutzt und es zum anderen die ganze Sache nicht leichter macht. Gemeint ist aber, dass man innerhalb eines Templates, ein existierendes Template einbinden kann. Somit erreicht man eine Verschachtlung.

Nach der geschlossenen Spitzen Klammer in Zeile 4, steht nun der eigentliche Funktionskopf in Zeile 5. Normalerweise schreibt man dies in eine Zeile, aber mit einem Zeilenumbruch, sieht es übersichtlicher aus. Zuerst steht der erste definierte Datentypbezeichner als Rückgabewert, dann der Funktionsname und abschließend, in den runden Klammern, die Übergabeparameter, jeweils vom zweiten definierten Typenbezeichner und natürlich noch die Variablennamen.

Zeile 6 ist nicht weiter spannend, da hier nichts atemberaubendes passiert. Ich caste lediglich die zwei übergebenen Parameter, in den Ergebnistyp um, bevor ich sie miteinander addiere. Bei der Addition spielt dies keine große Rolle, aber im Falle einer Division wäre dies wichtiger.

Interessanter wird es dann aber wieder in der "main", in den Zeilen 12 bis 25. Hier benutze ich nun das eben definierte Template. In den Zeilen 15 und 19 rufe ich das Funktinstemplate auf und wie Sie sehen können, muss man auch hier wieder die spitzen Klammern verwenden. Innerhalb dieser Klammern, gibt man jetzt den Ergebnisdatentyp und den Parameterdatentyp an, mit welcher das Template arbeiten soll (Sie erinnern sich vielleicht noch, dass diese Syntax auch bei den verschiedenen Castings benutzt wurde). Dies hat zur Folge, dass einmal jedes "TResultDataType" in "long int" und jedes "TParameterDataType" in "int" umgewandelt wird und das andere mal jedes "TResultDataType" durch "float" und "TParameterDataType" durch "double" ersetzt wird. Der Compiler generiert also folgenden Quelltext im Hintergrund, wobei die eigentliche Templatedefinition restlos verschwindet.

 1
 2
 3
 4
 5
 6
 7
					
long int Addiere(int tElement1, int tElement2) {
	return static_cast<long int>(tElement1) + static_cast<long int>(tElement2);
}

float Addiere(double tElement1, double tElement2) {
	return static_cast<float>(tElement1) + static_cast<float>(tElement2);
}
					

Hier sehen Sie jetzt, dass das Template wirklich als Kopiervorlage benutzt wurde und das für "TResultDataType" und "TParameterDataType" die gewünschten Typen ersetzt wurden. Ich betone noch einmal an dieser Stelle, dass diese Ersetzung nur stattfindet, wenn man das Template benutzt. Vergisst man also innerhalb des Templates z.B. ein Semikolon oder ähnliches, ohne das Template einzubinden, wird der Compiler keinen Fehler erkennen, da das Template schlicht und einfach, genauso wie Kommentare, weggelassen bzw. rausgeschmissen wird.

Wie gesagt, dieses Beispiel macht nicht viel Sinn, aber man sieht schön, dass ich aus einer händisch geschriebenen Funktion, unterm Strich zwei verschiedene generiert bekommen habe. Würde ich dieses Template noch für zehn andere Datentypen verwenden, würden entsprechend noch zehn weitere Funktionen generiert werden.

Ein denkbarer Anwendungsfall für Funktionstemplates ist z.B., dass man Elemente aus einer Binärdatei entnehmen, sortieren und anschließend wieder in eine andere Datei speichern will, denn um auf Binärdateien zugreifen zu können, benötigt man immer einen speziellen Datentyp. Falls es nun Dateien geben könnte mit Integern und welche mit Floats, kann man sich ein Template bauen, welches den Typen erst einmal rein formal behandelt und anschließend ruft man das Funktionstemplate mit dem entsprechenden Datentyp auf.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.5 Weiteres zu Templates

24.5 Weiteres zu Templates

Die ganze Thematik der Templates ist sehr umfangreich und ich werde nicht auf jede Besonderheit eingehen, aber ein paar Sachen möchte ich noch im Ansatz erwähnen, da sie den Umgang erleichtern. Sie sollten dieses Unterkapitel aber nur lesen, wenn Sie die letzten Kapitel vollständig verstanden haben, denn ich nehme mit jetzt nicht mehr die Zeit, alles bis ins kleinste Detail zu erklären. Ich werde wirklich nur noch zu den neuen Sachen ein paar Worte verlieren.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.5.1 Defaulttypen

24.5.1 Defaulttypen

Genauso wie Sie das von Funktionen her kennen, ist es möglich, den Templateparametern Defaultwerte zuzuweisen. Dies hat zur Folge, dass der Aufruf etwas vereinfacht wird. Im folgenden Beispiel werde ich dies kurz anhand einer Templatefunktion zeigen. Dieser Mechanismus funktioniert aber auch bei Templateklassen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
					
// Gibt das Ergebnis der Division zurück //////////////////////////////////////
template<typename TResultType = float>
TResultType Dividieren(int iDivident, int iDivisor) {
	// Wenn der Divisor gleich 0 ist
	if (iDivisor == 0) {
		return static_cast<TResultType>(0);
	} else {
		return static_cast<TResultType>(iDivident) / static_cast<TResultType>(iDivisor);
	} // end of if
} // Dividieren ///////////////////////////////////////////////////////////////

// ...

printf("%i ", Dividieren<int>(10, 4));	// Nutzt expliziten Typen
printf("%g\n", Dividieren<>(10, 4));	// Nutzt Defaulttypen
					

Ausgabe:

2 2.5
		

Wie Sie sehen können, habe ich in Zeile 2 hinter den Datentyp noch einen Defaultdatentyp angegeben und somit benötige ich keine Angabe beim Aufruf, wie Sie in Zeile 15 sehen können. Wichtig ist aber, dass man trotzdem die spitzen Klammern schreibt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.5.5 Type Traits

24.5.5 Type Traits

Wie ich mehrfach erwähnte, ist es eher unpraktisch, ein und die selbe Methode mehrfach zu definieren, nur um alle Datentypen abzudecken. Es stellt sich also die Frage, ob man dies nicht eleganter lösen kann. Hier kommen die s.g. Type Traits ins Spiel. Die Grundidee ist, dass man einen Mechanismus baut, mit welchem man später erkennt, um welchen Templatetypen es sich handelt und dann mit einer einfachen if Anweisung unterscheidet. So wird zwar die Implementierung der entsprechenden Methode größer, aber man braucht nur noch eine. Hier also mal ein vereinfachtes Beispiel für einen Type Trait.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
					
template<typename Ttype> struct STypeTrait {
	static const bool	IsPointer	= false;
	static const bool	IsInt		= false;
	static const bool	IsFloat		= false;
	static const bool	IsUnknown	= true;
};

template<typename Ttype> struct STypeTrait<TType*> {
	static const bool	IsPointer	= true;
	static const bool	IsInt		= false;
	static const bool	IsFloat		= false;
	static const bool	IsUnknown	= false;
};

template<> struct STypeTrait<int> {
	static const bool	IsPointer	= false;
	static const bool	IsInt		= true;
	static const bool	IsFloat		= false;
	static const bool	IsUnknown	= false;
};

template<> struct STypeTrait<float> {
	static const bool	IsPointer	= false;
	static const bool	IsInt		= false;
	static const bool	IsFloat		= true;
	static const bool	IsUnknown	= false;
};
					

Auch hier habe ich wieder ein allgemeines Template und Spezialisierungen des selben gebaut. Viel Logik wird hier nicht implementiert. Der einzige Zweck ist mir eine Mechanismus zu erzeugen, mit welchem ich Prüfen kann, von welchem Typ ein Templateparameter ist. Zudem ist es mir jetzt auch möglich, zwischen normalen Datentypen und Pointern zu unterscheiden. Dies ist ganz praktisch, wenn man später mal unterscheiden möchte, ob ein Objekt, oder ein Zeiger auf ein Objekt übergeben wurde, da man bei erstem üder den Punktoperator auf die Member zugreift und bei letzterem über den Pfeiloperator.

Ich werde jetzt das bisherige Beispiel wieder aufgreifen, aber so modifizieren, dass ich genau die eben erstellten Templatestrukturen dafür verwenden kann, um Typprüfungen vorzunehmen.

31
32
33
34
35
36
37
38
39
40
41
42
43
44
					
// Sehr einfacher Datencontainer //////////////////////////////////////////////
template<typename TDataType, unsigned int TSize> class CContainer {
	public:
		typedef TDataType			TType;
		typedef CContainer<TDataType, TSize>	TSelf;

		// Standardkonstruktor ////////////////////////////////////////////
		CContainer(); /////////////////////////////////////////////////////

		// ...

	private:
		TDataType m_aItems[TSize];
}; // CContainer //////////////////////////////////////////////////////////////
					

Die Klassendefinition ist wieder fast die Gleiche, nur dass ich die Defaulttypen für die Templateparameter entfernt habe. Interessant wird jetzt der modifizierte Standardkonstruktor.

48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
					
// Standardkonstruktor ////////////////////////////////////////////////////////
template<typename TDataType, unsigned int TSize>
CContainer<TDataType, TSize>::CContainer() {
	if (STypeTrait<TDataType>::IsUnknown) {
		printf("Nicht implementierter Typ\n");
		return;
	} else if (STypeTrait<TDataType>::IsPointer) {
		printf("Pointer\n");

		// Alles Initialisieren
		for (unsigned int iCount = 0; iCount < TSize; ++iCount) {
			m_aItems[iCount] = NULL;
		} // end of for
	} else if (STypeTrait<TDataType>::IsInt) {
		printf("Integer\n");

		// Alles Initialisieren
		for (unsigned int iCount = 0; iCount < TSize; ++iCount) {
			m_aItems[iCount] = 0;
		} // end of for
	} else if (STypeTrait<TDataType>::IsFloat) {
		printf("Float\n");

		// Alles Initialisieren
		for (unsigned int iCount = 0; iCount < TSize; ++iCount) {
			m_aItems[iCount] = static_cast<TDataType>(0);
		} // end of for
	} // end of if
} // CContainer ///////////////////////////////////////////////////////////////
					

Wie Sie sehen können, kann ich jetzt innerhalb einer Methode auf die verschiedenen Datentypen eingehen und so getrennte Initialisierungen vornehmen. Ganz so einfach ist es dann aber auch nicht, wie Sie in Zeile 73 sehen können. Es ist mir nicht möglich dem Float den Wert "0.0f" zuzuweisen, da sich der Compiler genau an dieser Stelle beschweren wird, wenn man dem Template ein Pointer übergibt. Der Wert null ist aber an dieser Stelle nicht schädlich und bringt nur eine Warnung, die Sie getrost ignorieren können.

Damit Sie sehen, dass der ganze Spaß auch funktioniert, hier noch der Teil für den Aufruf und die Verwendung der modifizierten Klasse.

80
81
82
83
84
85
86
87
88
					
typedef CContainer<double*, 16>	CPoinerContainer;
typedef CContainer<float, 16>	CFloatContainer;
typedef CContainer<int, 16>	CIntContainer;
typedef CContainer<char, 16>	CCharContainer;

CPoinerContainer	oContainer1;
CFloatContainer		oContainer2;
CIntContainer		oContainer3;
CCharContainer		oContainer4;
					

Ausgabe:

Pointer
Float
Integer
Nicht implementierter Typ
		

Wie Sie sehen können, funktioniert diese Lösung perfekt. Falls Sie sich mehr für dieses Thema und die daraus resultierenden Möglichkeiten informieren wollen, schauen Sie einfach im Internet nach. Dort finden Sie unzählige Artikel und Beispiele, sowie ganze Bibliotheken zu diesem Thema.

Abschließend möchte ich noch sagen, dass dies lediglich ein kleiner Vorgeschmack auf dessen ist, was mit Templates alles möglich ist und wie man Sie verwenden kann. Es gibt ganze Bücher zu diesem Thema und deswegen gehe ich nicht weiter darauf ein. Sie sollten lediglich das Grundkonzept verstanden haben, da es auch andere Sprachen gibt (z.B. Java), in welchem Templates möglich sind (wenn auch nicht in dem Umfang wie in C++).

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.5.4 Spezialisierung von Templates

24.5.4 Spezialisierung von Templates

Das eben gezeigte Beispiel hatte unter anderem eine große Schwäche. Der implementierte Container konnte zwar Daten halten, aber nicht sinnvoll initialisieren. Es fehlt also im Standardkonstruktor eine Schleife, in welcher jedem Element ein sinnvoller Startwert zugewiesen wird. Doch welcher Wert ist Sinnvoll? Sicher könnte man im Falle von Integern und Zeigern den Wert null zuweisen, aber schon bei Floats wird es kritisch, denn man müsste "0.0f" angeben. Noch verzwickter wird es, wenn der Container irgendwelche Objekte halten soll. Was können wir also tun?

Die Antwort auf diese Frage ist die Spezialisierung von Templates. Die Grundidee ist, dass man einen allgemein gültigen Fall definiert und dann noch weitere, welche auf spezielle Datentypen eingeht. Jetzt müssten Sie eigentlich mit der Stirn runzeln, denn der Vorteil von Templates ist doch, dass man nur einmal Code schreiben muss und er dann universell einsetzbar ist. Eine erneute Implementierung ist doch genau das Gegenteil. Stellen wir diesen Aspekt aber mal ganz kurz in den Hintergrund und schauen wir uns zunächst das Prinzip an.

Im folgenden Beispiel greife ich das Beispiel von eben auf, wobei ich in der Klassendefinition die Implementierung des Standardkonstruktors entferne (es wird nur noch der Prototyp dastehen) und unterhalb der Klassendefinition mehrfach spezialisiert implementiere. Die Klassendefinition selbst spare ich mir an dieser Stelle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
					
// Standardkonstruktor ////////////////////////////////////////////////////////
template<typename TDataType, unsigned int TSize>
CContainer<TDataType, TSize>::CContainer() {
	printf("Allgemeines Template\n");
	// keine Initialisierung
} // CContainer ///////////////////////////////////////////////////////////////



template<>
CContainer<float>::CContainer() {
	printf("Spezialisiertes Template 1\n");
	for (unsigned int iCount = 0; iCount < GetSize(); ++iCount) {
		m_aItems[iCount] = 0.0f;
	} // end of for
} // CContainer ///////////////////////////////////////////////////////////////



template<>
CContainer<float, 32>::CContainer() {
	printf("Spezialisiertes Template 2\n");
	for (unsigned int iCount = 0; iCount < 32; ++iCount) {
		m_aItems[iCount] = 0.0f;
	} // end of for
} // CContainer ///////////////////////////////////////////////////////////////



template<>
CContainer<int>::CContainer() {
	printf("Spezialisiertes Template 3\n");
	for (unsigned int iCount = 0; iCount < GetSize(); ++iCount) {
		m_aItems[iCount] = 0;
	} // end of for
} // CContainer ///////////////////////////////////////////////////////////////



// Achtung, jede Templatedefinition mit unterschiedlichen Templateparametern
// bedeutet auch ein unterschiedlicher Datentyp (nicht polymorph)! 
typedef CContainer<float>	CsmallFloatContainer;
typedef CContainer<float, 32>	CbigFloatContainer;
typedef CContainer<int>		CsmallIntContainer;
typedef CContainer<double>	CDoubleContainer;

// ...

CSmallFloatContainer		oContainer1;
CBigFloatContainer		oContainer2;
CSmallIntContainer		oContainer3;
CPoinerContainer		oContainer4;
					

Ausgabe

Spezialisiertes Template 1
Spezialisiertes Template 2
Spezialisiertes Template 3
Allgemeines Template
		

Wie Sie sehen können, habe ich den Standardkonstruktor jetzt mehrfach definiert. Ich überlade ihn aber nicht, sondern ich definiere ihn für verschiedene Szenarien. Zu Beginn, also in den Zeilen 2 bis 6, muss immer die allgemeine Form dastehen, in welcher der allgemeine Fall abgehandelt wird. Da man allgemein nicht sagen kann, wie ein Wert initialisiert werden muss, passiert hier nicht wirklich etwas.

Jetzt fängt es an spannend zu werden. Die nachfolgenden Implementierungen sehen jetzt anders aus. Schauen wir uns zuerst Zeile 10 an. Hier wird wieder das Template angegeben, zu welchem die Methode gehört, aber seltsamerweise habe ich jetzt keine Templateparameter angegeben. Immer dann, wenn Sie so etwas sehen, handelt es sich um eine Spezialisierung eines Templates. Warum muss ich hier keinen Templatetyp angeben? Die Antwort folgt in Zeile 11. Hier folgt jetzt der Methodenkopf und hier wird in den spitzen Klammern ein Primitivtyp angegeben. Somit habe ich aus dem allgemeinen Fall "TDataType" den speziellen Fall "float" definiert. Auch in den Zeilen 12 bis 16 taucht der Type "TDataType" nicht mehr auf. Sicher fragen Sie sich jetzt, was mit "TSize" geworden ist. Da es sich um keinen Templatetyp handelt (viel mehr um eine Art Konstante), braucht man hier nichts anzugeben. Allerdings benötigt ich die Größe dann doch, wenn ich in der for Schleife die Werte initialisieren will. Da "TSize" in diesem Kontext nicht definiert ist, kann ich es nicht verwenden. Somit braucht die Klasse also einen Getter, um genau diesen Wert zu liefern.

Schauen wir uns nun Zeile 20 bis 26 an. Auf den ersten Blick passiert hier das Gleiche, aber bei näherem Betrachten entdeckt man in Zeile 21 einen Unterschied. Dieses mal spezialisiere ich nicht nur den Templatetypen, sondern auch den Primitivtypen. In dem Moment, in welchem ich so etwas mache, kann ich diesen konstanten Wert auch innerhalb der Methode benutzen und somit benötigt ich nicht mehr die Methode "GetSize".

In den Zeilen 30 bis 36 sehen Sie eine Spezialisierung für den Templatetypen Integer. Da ich hier wieder keinen Primitivtypen angegeben habe, muss ich wieder auf den klasseninternen Getter zurückgreifen.

In den Zeilen 42 bis 45 sehen Sie erneute Typendefinitionen. Sie ähneln denen aus dem letzten Beispiel und sollen noch einmal verdeutlichen, dass es sich immer um andere Typen handelt, wenn man das Template mit anderen Parametern benutzt.

Abschließend sehen Sie dann, dass ich lediglich verschiedene Objekte erzeuge, wobei die unterschiedlichen Spezialisierungen genutzt werden. Anhand der Ausgabe sehen Sie, welche Spezialisierung genau benutzt wird. Nur für den Fall, dass keiner der Templateparameter den spezialisierten entspricht, wird die allgemeine Definition benutzt.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.5.3 Typdefinitionen innerhalb von Templates

24.5.3 Typdefinitionen innerhalb von Templates

Gerade bei Templateklassen macht es Sinn, zu Beginn öffentliche Typdefinitionen zu deklarieren, um sich später den Aufruf bzw. die Deklaration von Variablen, zu vereinfachen und übersichtlicher zu gestalten. Folgendes Beispiel soll dies demonstrieren.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
					
// Sehr einfacher Datencontainer //////////////////////////////////////////////
template<typename TDataType = float, unsigned int TSize = 16>
class CContainer {
	public:
		typedef TDataType			TType;	// Für externen Gebrauch
		typedef CContainer<TDataType, TSize>	TSelf;	// Für internen Gebrauch



		// Standardkonstruktor ////////////////////////////////////////////////
		CContainer() {} ///////////////////////////////////////////////////////

		// Kopierkonstruktor //////////////////////////////////////////////////
		CContainer(TSelf& oContainer) {
			for (unsigned int iCount = 0; iCount < TSize; ++iCount) {
				m_aItems[iCount] = oContainer.m_aItems[iCount];
			} // end of for
		} // CContainer ///////////////////////////////////////////////////////

		// Setzt den Wert im Container an einer gewünschten Position //////////
		inline void SetValue(unsigned int iIndex, TDataType tValue) {
			// Wenn der Index gültig ist
			if (iIndex < TSize)	m_aItems[iIndex] = tValue;
			else			throw "Invalid Index";
		} // GetValue /////////////////////////////////////////////////////////

		// Holt den Wert an einer gewünschten Position aus dem Container //////
		inline TDataType GetValue(unsigned int iIndex) {
			// Wenn der Index gültig ist
			if (iIndex < TSize)	return m_aItems[iIndex];
			else			throw "Invalid Index";
		} // GetValue /////////////////////////////////////////////////////////

		// Gibt die Größe des Containers zurück ///////////////////////////////
		inline unsigned int GetSize() { return TSize; } ///////////////////////

	private:
		TDataType m_aItems[TSize];
};

// ...

// Achtung, jede Templatedefinition mit unterschiedlichen Templateparametern
// bedeutet auch ein unterschiedlicher Datentyp (nicht polymorph)! 
typedef CContainer<>		CSmallFloatContainer;	// float, 16
typedef CContainer<int>		CSmallIntContainer;	// int, 16
typedef CContainer<float, 128>	CBigFloatContainer;	// float, 128

// ...

CSmallFloatContainer		oContainer1;
CSmallFloatContainer::TType	fWert = 12.3f;
oContainer1.SetValue(0, fWert);

CSmallFloatContainer 		oContainer2(oContainer1);
fWert = oContainer2.GetValue(0);

printf("%i %g\n", oContainer2.GetSize(), fWert);
					

Ausgabe:

16 12.3
		

Zunächst ist zu sagen, dass ich mich der Übersichtlichkeit halber für die Inlineschreibweise entschieden habe. Zudem spart es Platz und richtet den Blick mehr aufs wesentliche. Das obige Beispiel implementiert eine Templateklasse, welches ein statisches Array kapselt. Zudem habe ich gleich in Zeile 2 festgelegt, dass es standardmäßig 16 Floats aufnehmen kann.

In den Zeilen 5 und 6 sehen Sie nun besagt öffentliche Typdefinitionen. Die erste ist sehr praktisch für den Zugriff von außen, wohingegen die zweite nützlich für den internen Zugriff ist, wie Sie in Zeile 14 sehen. Dort habe ich einen Kopierkonstruktor definiert. Ihm muss man ja immer eine Referenz auf ein Objekt gleichen Typs übergeben. Im Falle einer Templateklasse bedeutet dies, dass nicht nur der Klassenname ausreicht, sondern dass man auch hier wieder die spitzen Klammern und die Templateparameterdefinition benötigt. Durch die vorhergehende Typdefinition, also dem Alias, schafft man einen wesentlich schöneren Ausdruck und man läuft nicht Gefahr, dass man einen Parameter vergisst.

Der Rest der Klasse ist nicht weiter spannend. Ich habe lediglich einen Setter und zwei Getter eingebaut, welche das Holen bzw. Ändern der Werte ermöglichen.

Spannender sind dann wieder die Zeilen 45 bis 47. Erneut habe ich ein paar Typdefinitionen vorgenommen, um die Deklaration der Container zu vereinfachen. Besonders wichtig an dieser Stelle ist, dass jede Deklaration einen anderen Typ zur Folge hat. Es ist also nicht möglich, einen Container zu erzeugen, welcher Integer - und Floatcontainer aufnimmt. Es sind einfach unterschiedliche Typen/Klassen und ein Array kann immer nur einen Typ bzw. eine Klasse aufnehmen. Selbst ein Container mit 16 Floats und ein anderer mit 17 Floats, stellen unterschiedliche Typen/Klassen dar!

In Zeile 52 sehen Sie jetzt, wofür die erste öffentliche Typdefinition innerhalb der Templateklasse gut ist. In diesem Beispiel ist es zwar offensichtlich, dass man sich hier eine Variable vom Typ Float anlegen muss, aber die gezeigte Methode ist sicherer. Würde man beispielsweise einen anderen Container mit Integern erzeugen (also einfach den Templateparameter ändern), bräuchte man nicht noch diese Variable zu ändern (und ggf. auch nicht alle anderen im Programm). Man ist also wesentlich flexibler und typsicherer.

Der Rest ist dann nicht weiter spannend. Ich erzeuge einfach einen Container, packe eine Zahl rein, erzeuge mir eine Kopie des ersten Kontainers und hole dort den kopierten Wert wieder raus. Anschließend wird er ausgegeben.

Ich hoffe, ich konnte Ihnen ein weiteres mal die Deklaration von eigenen Typdefinitionen schmackhaft machen und den Umgang mit Templates erleichtern.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.5.2 Primitivtypen als Templateparameter

24.5.2 Primitivtypen als Templateparameter

Bisher hatte ich nur erwähnt, dass es möglich ist, den Templates Typen zu übergeben, welche dann innerhalb des Templates generisch verwendet werden können. Man kann aber auch einfach Werte übergeben. Sie haben dann eine ähnliche Wirkung wie Konstanten. Im folgenden Beispiel erstelle ich ein Template zum erzeugen eines Arrays mit einem generischen Datentyp und gewünschter Größe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
					
// Erzeugt ein generisches Array //////////////////////////////////////////////
template<typename TArrayType = int, unsigned int TSize = 16>
TArrayType* Create() {
	return new TArrayType[TSize];
} // Create ///////////////////////////////////////////////////////////////////

// ...

int* aList = Create<>();	// Erzeugt ein Array mit 10 Integern
					

Wie Sie sehr schön erkennen können kann man auch für diese Primitivtypen Standardwerte festlegen und somit einen sehr kurzen Ausdruck für das Erzeugen des Arrays verwenden. Zugegeben, dieses Beispiel macht nicht viel Sinn. Zum einen braucht man das Erzeugen nicht in ein Template zu verpacken und dann könnte man der Funktion auch die Größe als Parameter übergeben. Anders sieht es da aber schon aus, wenn man ein statisches Array erzeugen wollte. In dem Fall benötigt man einen konstanten Wert, der schon zur Compilezeit feststeht. Genau dies erreicht man mit dieser Art Templateparametern. Wichtig ist bei diesem Szenario, dass man im Aufruf der Templatefunktion auch einen konstanten Wert benutzt.

Natürlich funktioniert dieser Mechanismus auch bei Templateklassen und in der Tat macht er auch bei jenen mehr Sinn (z.B. für die Implementierung einer Vektorklasse).

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.2 Nachteile

24.2 Nachteile

Ich habe auch schon erwähnt, dass man sich mit Templates ein paar Nachteile mit an Bord holt. Das trivialste ist, dass die Implementierung des Templates ein wenig Übung erfordert, weil man eine etwas merkwürdige Syntax benutzen muss. Auch der Umgang mit dem Template erfordert ein wenig seltsamen Code, was dem allgemeinen Verständnis des Programms nicht beiträgt. Sie sollten also erst Templates selber nutzen, wenn Sie wirklich fit darin sind. Das gleiche gilt für das Modifizieren von Templates auf eigene Bedürfnisse.

Ein weiterer klarer Nachteil ist, dass Templates erst vom Compiler übersetzt werden, wenn man sie benutzt. Tut man dies nicht, werden auch keine Fehler erkannt. Daraus ergibt sich auch der Nachteil, dass man den vom Compiler erzeugten tatsächlichen Quelltext, nicht zu Gesicht bekommt, was zur Folge hat, dass man entsprechenden Abschnitt etwas schwieriger Debuggen kann. Zudem erhöht man die Compilezeiten, was bei größeren Projekten einen spürbaren Unterschied ausmacht.

Der größte Nachteil ist, dass man kein Informationhiding vornehmen kann. Damit ist gemeint, dass man normalerweise anderen Entwicklern nur die Header-Datei im Klartext und die zugehörigen vorkompilierten CPP Dateien (als Objektcode), zur Verfügung stellen braucht. Somit sehen andere nicht, wie man die eine oder andere Sache gelöst hat (was ja gut sein kann, wenn es um Firmengeheimnisse geht). Bei Templates hat man ein Problem. Da es sich ja um eine Kopiervorlage handelt, muss sie im Textformat vorliegen und man hat nicht viele Chancen, bestimmte Algorithmen zu tarnen. Dieses Problem relativiert sich aber, da man Templates eh nicht für sehr komplexe Sachen benutzt, sondern eher für Listen usw. und bei denen gibt es nicht viel zu verstecken. Mit Hilfe einer Spezialisierung kann man dann doch wieder Quelltexte verbergen, aber dazu später mehr.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.4 Klassentemplates

24.4 Klassentemplates

Da Sie nun wisst, wie man Funktionstemplates baut, werde ich jetzt noch eins drauf legen und über Klassentemplates sprechen. Doch bevor es richtig zur Sache geht, muss ich noch ein paar Dinge erklären.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.4.1 Hinweise

24.4.1 Hinweise

Bevor man sich ein Template baut (egal ob Funktionstemplate oder Klassentemplate), empfehle ich, die Klasse oder Funktion ganz normal zu bauen und erst danach ein Template daraus zu machen. Somit kann man sich erst einmal ganz formal auf die eigentliche Funktionalität konzentrieren. Erst wenn alles funktioniert, sollten Sie aus dem Spezialfall, ein allgemeines Template machen.

Templates bieten außerdem die Möglichkeit, Defaultwerte zu definieren. Damit kann man sich diverse Konstanten im Programm sparen. Wie so etwas aussieht, werde ich dann auch gleich zeigen.

Ein weiteres sehr nettes Feature von Templates ist, dass man sie spezialisieren kann. Damit ist gemeint, dass man zwar ein allgemeines Template bauen kann, aber gewisse Methoden für spezielle Datentypen, anders definieren kann. Was damit gemeint ist, werde ich dann auch noch zeigen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.4.2 Die Vorbereitung eines Templates

24.4.2 Die Vorbereitung eines Templates

Wie eben erwähnt, sollte man sich erst einmal eine konkrete Klasse bauen, bevor man dann ein Template daraus macht. Deswegen sehen Sie hier zunächst eine ganz normale Stackklasse, welche Float Werte aufnehmen kann. Schauen wir uns also zuerst die Header-Datei an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
					
#pragma once

// Stackklasse, welche Floats aufnehmen kann
class CFloatStack {
	private:
		struct SStackElement {
			float		fValue;
			SStackElement*	pNext;
		};

		// Zeiger auf das Oberste Element
		SStackElement*		m_pStackPointer;

	public:
		// Initialisieren
		CFloatStack();
		// Freigeben
		~CFloatStack();

		// Etwas auf den Stack legen
		void	Append(float fItem);
		// Etwas vom Stack runter nehmen
		float	Remove();
};
					

Der Einfachheit halber, habe ich lediglich die zwei zusätzlichen Methoden "Append", zum Hinzufügen von Elementen und "Remove", zum Entfernen von Elementen, eingebaut. Sicher könnte man noch weitere Methoden einbauen, um z.B. alle Elemente auf dem Stack auszugeben, aber darum soll es jetzt nicht gehen. Schauen Sie sich nun die Implementierung in der zugehörigen CPP Datei an.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
					
#include <stdlib.h>
#include "FloatStack.h"



// Initialisieren /////////////////////////////////////////////////////////////
CFloatStack::CFloatStack() 
	: m_pStackPointer(NULL)
{} // CFloatStack /////////////////////////////////////////////////////////////



// Freigeben //////////////////////////////////////////////////////////////////
CFloatStack::~CFloatStack() {
	// Solange noch etwas auf dem Stack ist
	while (this->m_pStackPointer != NULL) this->Remove();
} // ~CFloatStack /////////////////////////////////////////////////////////////



// Etwas auf den Stack legen //////////////////////////////////////////////////
void CFloatStack::Append(float fItem) {
	SStackElement* pElement	= new SStackElement();
	pElement->fValue	= fItem;
	pElement->pNext		= this->m_pStackPointer;
	this->m_pStackPointer	= pElement;
} // Append ///////////////////////////////////////////////////////////////////



// Etwas vom Stack runter nehmen //////////////////////////////////////////////
float CFloatStack::Remove() {
	// Nur wenn was auf dem Stack liegt
	if (this->m_pStackPointer != NULL) {
		SStackElement* pElement	= this->m_pStackPointer;
		this->m_pStackPointer	= pElement->pNext;
		float fReturn		= pElement->fValue;
		pElement->pNext		= NULL;
		delete pElement;

		return fReturn;
	} else {
		return 0.0f;
	} // end of if
} // Remove ///////////////////////////////////////////////////////////////////
					

Bitte verzeihen Sie mir, dass ich hier etwas sparsam mit den Kommentaren war, aber auf die Funktion der Klasse, kommt es hier weniger an. Damit Sie sehen, dass es funktioniert, zeige ich noch kurz, wie die Implementierung in der "main" aussehen könnte.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
#include <stdio.h>
#include "FloatStack.h"



// Hauptfunktion der Anwendung ////////////////////////////////////////////////
int main (int argc, char** argv) {
	CFloatStack oStack;
	oStack.Append(3.7f);
	oStack.Append(4.6f);

	printf("%g\n", oStack.Remove());
	printf("%g\n", oStack.Remove());

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Ausgabe:

4.6
3.7
		

Jetzt, da die Klasse gebaut und getestet wurde, kann ich mich daran machen, aus ihr ein Template zu bauen.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.4.3 Das Umwandeln in ein Template

24.4.3 Das Umwandeln in ein Template

Bevor Sie mit der Umwandlung anfangen können, sollten Sie sich etwas bewusst machen. Ich erwähnte ja bereits, dass man mit Templates Schwierigkeiten mit dem Informationhiding hat, weil man ja quasi eine Kopiervorlage baut. Jeder Quelltext muss also ersichtlich sein. Dies gilt natürlich auch für die CPP Datei. Dies hat zur Folge, dass es nicht mehr reicht, die Header-Datei in der "main" zu inkludieren, weil man nicht nur die reinen Funktionsprototypen braucht, sondern auch die Methodenimplementierungen (jene sollen ja durch den Compiler generiert werden).

Jetzt haben Sie also drei Möglichkeiten.

  1. Sie inkludieren in der "main" die H und die CPP Datei oder
  2. Sie inkludieren die CPP Datei am Ende der H Datei oder
  3. Sie definieren die Methoden in der H Datei als Inline-Methode.

Ich entscheide mich für eine Mischung aus Variante 2 und 3, da es zum einen komisch aussieht bzw. sehr unüblich ist, eine CPP Datei in der "main" zu inkludieren und ich zum anderen Zeigen möchte, wie man eine Templatemethode in einer anderen Datei implementiert, da dies alles andere als nahe liegend ist. Ich werde also den Konstruktor, DestruKtor "inline" implementieren und die Methoden "Append" und "Remove" auslagern. Schauen wir uns also zuerst die neue Header-Datei an, aber ich warne Sie jetzt schon, denn es wird ziehmlich merkwürdig / lustig.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
					
#pragma once



// Klassentemplate für eine Stackklasse die beliebige Elemente aufnehmen kann
template<typename TDataType>
class CStack {
	private:
		struct SStackElement {
			TDataType	tValue;
			SStackElement*	pNext;
		};

		// Zeiger auf das Oberste Element
		SStackElement*		m_pStackPointer;

	public:
		// Initialisieren
		inline CStack() : m_pStackPointer(NULL)	{}

		// Freigeben
		inline ~CStack() {
			while (this->m_pStackPointer != NULL) this->Remove();
		}

		// Etwas auf den Stack legen
		void		Append(TDataType tItem);
		// Etwas vom Stack runter nehmen
		TDataType	Remove();
};

#include "Stack.cpp"
					

Gleich in Zeile 6 und 7 sehen Sie die Templatedefinition für die Klasse. Auch sie beginnt wieder mit dem Schlüsselwort "template". Bis auf die Tatsache, dass ich jetzt eine Klasse definiert habe, gibt es keine weiteren Unterschiede zur Definition eines Funktionstemplates. Wie Sie sehen, wurde aus jedem "float" der neu definierte und später zu ersetzende Datentyp "TDataType" gemacht.

Ab Zeile 19 bis 26 wird jetzt der Konstruktor und Destruktor als Inline-Methode deklariert und ihr zugehörige Funktionsweise implementiert. Bis auf das Schlüsselwort "inline", welches noch nicht einmal notwendig ist, passiert hier nichts besonderes. Es wurde lediglich der Methodenprototyp, durch die komplette Methodenimplementierung ersetzt und auch von der Syntax her, sollte alles klar sein.

In Zeile 29 und 31 stehen dann ganz normal die Prototypen der Methoden, welche ich außerhalb des Templates, also in einer anderen Datei, implementieren werde. Wichtig ist aber die Zeile 33. Hier inkludiere ich jetzt die entsprechende CPP Datei, was eigentlich sehr unüblich, aber für meine Herangehensweise notwendig ist. Das "Include" muss auch unbedingt am Ende der Header-Datei stehen. Aber wenn Sie jetzt glauben, dass dies schon merkwürdig war, dann schauen Sie sich folgenden Quelltext an, welchen ich in die CPP Datei geschrieben habe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
					
#ifndef STACK_CPP
	#define STACK_CPP
	#include <stdlib.h>
	#include "Stack.h"
	
	
	// Etwas auf den Stack legen //////////////////////////////////////////////
	template<typename TDataType>
	void CStack<TDataType>::Append(TDataType tItem) {
		SStackElement* pElement	= new SStackElement();
		pElement->tValue	= tItem;
		pElement->pNext		= this->m_pStackPointer;
		this->m_pStackPointer	= pElement;
	} // Append ///////////////////////////////////////////////////////////////
	
	
	
	// Etwas vom Stack runter nehmen //////////////////////////////////////////
	template<typename TDataType>
	TDataType CStack<TDataType>::Remove() {
		// Nur wenn was auf dem Stack liegt
		if (this->m_pStackPointer != NULL) {
			SStackElement* pElement	= this->m_pStackPointer;
			this->m_pStackPointer	= pElement->pNext;
			TDataType tReturn	= pElement->tValue;
			pElement->pNext		= NULL;
			delete pElement;
	
			return tReturn;
		} else {
			return (TDataType)0;
		} // end of if
	} // Remove ///////////////////////////////////////////////////////////////
#endif
					

Die erste Merkwürdigkeit sollte hier das "Include" der Header-Datei sein, da ich ja in der Header-Datei, bereits die CPP Datei inkludiert habe. Auf den ersten Blick sollte man meinen, dass man jetzt eine gegenseitige Abhängigkeit geschaffen hat und sich die Dateien jetzt im Kreis inkludieren. Das bringt mich auch gleich zur zweiten Merkwürdigkeit. Normalerweise wäre es auch so, dass sich die Dateien im Kreis inkludieren, aber die CPP Datei besitzt jetzt ihren eigenen Includewächter, wie Sie dies sonst nur aus Header-Dateien kennen. Gemeint ist dieses "#ifndef" und anschließende "#define" in Zeile 1 und 2. Wichtig ist hier, dass man nicht den Präprozessorbefehl "#pragma once" benutzen darf, da jener die komplette Datei inkludiert oder nicht. Das Include der Header-Datei ist aber unbedingt erforderlich und nur die reinen Methoden dürfen nicht doppelt vorkommen. Das "Include" der Header-Datei ist im Zweifelsfall auch nicht kritisch, da sich jene selbst vor doppelten Import schützt.

Aber so richtig Merkwürdig wird es dann in Zeile 8 und 9 und spätestens dort, sollten Ihnen die Nackenhaare zu Berge stehen. Was wird hier gemacht? Zunächst wird wieder rein Formal das Template definiert mit seiner Parameterliste. Dies ist notwendig, um zu definieren, dass kommender Abschnitt, mit zu dem bereits definiertem Template gehört. Dies muss dann auch für jede Methode gemacht werden, wie Sie dies auch in Zeile 19 und 20 sehen. Anschließend fängt man jetzt an, den Methodenheader zu definieren. Es geht los mit dem Rückgabetyp. Anschließend folgt der Klassennamen als Namespace, um die Zugehörigkeit zur Klasse festzulegen. Allerdings handelte es sich um eine Templateklasse, was zur Folge hat, dass man nach dem Klassennamen eine spitze Klammer öffnen muss und den definierten Ersetzungstyp übergibt (ggf. auch mehrere, falls mehrere definiert wurden). Hier benötigt man allerdings nur die Parameter, welche die Methode benötigt. Dann schließt man die Spitze Klammer, setzt den doppelten Doppelpunkt und schreibt den Methodennamen, mit den zugehörigen Parametern der Methode in runden Klammern. Abschließend schreibt man dann noch, wie gewohnt in geschweiften Klammern, den eigentlichen Quelltext der Methode. Dort passiert jetzt nichts mehr aufregendes. Man ersetzt lediglich die vorher festen Datentypen, durch die neu definierten Templatedatentypen.

Sie sehen also, dass es durchaus möglich ist, die Implementierung der Methoden in eine andere Datei auszulagern, allerdings auf Kosten der Verständlichkeit. Ich empfehle Ihnen also, nur Inline-Methoden, wie ich es beim Konstruktor und Destruktor gemacht habe, zu schreiben. Sie sollteen sich allerdings diesen Abschnitt genau anschauen, da es, gerade in den internen Bibliotheken von Visual Studio, gerne mal in dieser Art gemacht wird und Sie nachvollziehen können sollten, was passiert, wenn Sie mal einen solchen Quelltext sehen.

Abschließend möchte ich noch zeigen, wie man dieses Template in der "main" einbindet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
					
#include <stdio.h>
#include "Stack.h"



// Hauptfunktion der Anwendung //////////////////////////////////////////////// 
int main (int argc, char** argv) {
	CStack<float> oStack;
	oStack.Append(3.7f);
	oStack.Append(4.6f);

	printf("%g\n", oStack.Remove());
	printf("%g\n", oStack.Remove());

	return 0;
} // main /////////////////////////////////////////////////////////////////////
					

Wie Sie gleich in Zeile 2 sehen können, braucht man nur die Header-Datei der Templateklasse einzubinden. In Zeile 8 wird jetzt ein Objekt der Templateklasse instantiiert und ähnlich wie Sie das bei den Funktionstemplates gesehen haben, benötigt man jetzt nach dem Klassennamen spitze Klammern in welchen man jetzt den gewünschten Datentyp angibt. Sie erhalten also als Resultat, wieder einen Stack mit Floats. Der Rest ist dann wie gehabt.

Die Ausgabe ist wieder, und warum sollte es auch anders sein, die Gleiche und deswegen gebe ich sie auch nicht noch einmal an.

Zum Seitenanfang
Zum Inhaltsverzeichnis

24.1 Vorteile

24.1 Vorteile

Wie bereits erwähnt, kann man sich noch mehr Arbeit ersparen, weil man flexibler ist, aber was meine ich damit? Nun, stellen Sie sich eine Liste vor. Dort haben Sie in der Regel nur die Möglichkeit, Elemente eines Typs aufzunehmen wie z.B. Integer. Falls Sie irgendwo im Programm (oder auch in einem neuen Projekt), solch eine Liste wieder brauchen, allerdings mit Strings, dann haben Sie ein Problem. Normalerweise müsste man den Quelltext der Liste kopieren und auf den neuen Datentyp anzupassen. Mal abgesehen vom Arbeitsaufwand, könnten sich auch wieder diverse Tippfehler einschleichen und auf Dauer ist dies nicht praktikabel.

Ein Lösungsansatz wäre, sich eine Liste aus "void" Pointern zu bauen. Dann könnte man über entsprechendes Typumwandlungen, die gewünschten Elemente in die Liste ein - und aushängen. Aber auch das hat viele Nachteile. Da nun jeder erdenklicher Datentyp in der Liste stehen kann, weiß man später beim Zugriff auf die Liste nicht mehr, was man zurück bekommt. Ein Zurückcasten kann dann also arg in die Hose gehen. Zudem ist man gezwungen Pointer zu benutzen und hat dadurch unnötige Indirektionen.

Ein weiterer Ansatz wäre, sich für alle Arten von Typen eine Klasse zu bauen und jene dann von einer abstrakten Basisklasse erben zu lassen. Tatsächlich wird dies sogar in manchen Sprachen wie Delphi oder Java gemacht. Dort gibt es eine Klasse "TObject" bzw. "object", von der jede Klasse erbt. Aber auch das würde Sie nur bedingt zum Ziel bringen, da man bei einem Zurückcasten, im Zweifelsfall wieder nicht genau weiß, um was es sich für ein Datentyp handelt. Hinzu kommt, dass man dann oftmals virtuelle Methoden braucht und was das für Folgen hat, habe ich bereits ausführlich diskutiert.

Wie können Sie sich also aus diesem Schlamassel heraus winden? Die Antwort sind Templates. Der Nachteil für das ständige anpassen einer funktionierenden Liste auf einen neuen Datentyp, war doch der Schreibaufwand. Genau da kommen die Ihnen Templates entgegen, weil sie ganz grob gesagt, eine Art abstrakte Kopiervorlage sind. Man entwickelt es ganz allgemein und kann es später auf mehrere unterschiedliche Fälle anwenden.

Nun kann man diesen Mechanismus aber nicht nur für Klassen benutzen, sondern auch für Funktionen. Angenommen Sie möchten eine Funktion bauen, die zwei Werte miteinander verrechnet und einen ganz speziellen Ergebnistyp liefern soll. Normalerweise müsste man also wieder für jeden erdenklichen Datentyp eine separate Funktion bauen, die sich gegenseitig überladen (was schon einmal nicht ohne weiteres geht, falls sich die Parameter der Funktion nicht unterscheiden). Auch hier kann über ein Template eine allgemeine "Kopiervorlage" definiert werden. Wie das alles funktioniert, werde ich noch zeigen.

Ich hatte Ihnen zu Beginn mal die Defines gezeigt. Mit Ihnen konnte man auch Quelltextbausteine erzeugen. Der große Nachteil an ihnen ist, dass man im Debug-Modus nicht sieht, was sich hinter einem Define verbirgt und wenn man sogar eine ganze Funktion so implementiert hat, kann man jene nicht debuggen. Templates leisten genau dies und sie können sogar noch weit mehr Aufgabengebiete abdecken. Ab jetzt sollten Sie also vermindert mit Defines arbeiten.

Zum Seitenanfang
Zum Inhaltsverzeichnis

© Copyright by Thomas Weiß, 2009 - 2012