C++-Programmierung/ Eigene Datentypen definieren
Aus Wikibooks
Zielgruppe:
Anfänger
Lernziel:
Das Arbeiten mit Klassen verstehen.
Inhaltsverzeichnis |
Das Klassenkonzept
Klassen sind ein wesentlicher Bestandteil der objektorientierten Programmierung. Objektorientiert zu programmieren heißt, Daten und die Funktionen, die darauf angewendet werden, möglichst dicht zusammen zu bringen. Eine Klasse tut genau das: sie beinhaltet Daten und Funktionen. Nach außen hin können die Daten in der Regel nicht direkt zugegriffen werden. Die Verarbeitung der Daten erfolgt über Funktionen der Klasse.
Korrekterweise enthält eigentlich nicht die Klasse die Daten, sondern die Objekte, die von dieser Klasse erzeugt werden. Die Klasse selbst beschreibt lediglich, welche Daten ein Objekt enthalten kann und die Funktionen, die darauf angewendet werden können. Eine Klasse ist also vergleichbar mit einem Datentyp, ein Objekt entspricht dann einer Variablen dieses Datentyps. Tatsächlich werden die Begriffe üblicherweise synonym verwendet.
[Bearbeiten] Ein eigener Datentyp
Nun wird es aber Zeit, dass wir auch mal eine eigene Klasse schreiben.
class Auto{
public:
Auto(int tankgroesse, float tankinhalt, float verbrauch);
void info()const;
bool fahren(int km);
void tanken(float liter);
private:
int m_tankgroesse;
float m_tankinhalt;
float m_verbrauch;
};
Auto::Auto(int tankgroesse, float tankinhalt, float verbrauch):
m_tankgroesse(tankgroesse),
m_tankinhalt(tankinhalt),
m_verbrauch(verbrauch)
{}
void Auto::info()const{
std::cout << "In den Tank passen " << m_tankgroesse << " Liter Treibstoff.\n";
std::cout << "Aktuell sind noch " << m_tankinhalt << " Liter im Tank.\n";
std::cout << "Der Wagen verbraucht " << m_verbrauch << " Liter pro 100km.\n";
std::cout << std::endl;
}
bool Auto::fahren(int km){
std::cout << "Fahre " << km << "km.\n";
m_tankinhalt -= m_verbrauch*km/100;
if(m_tankinhalt < 0.0f){
m_tankinhalt = 0.0f;
std::cout << "Mit dem aktuellen Tankinhalt schaffen Sie die Fahrt leider nicht.\n";
std::cout << "Der Wagen ist unterwegs liegengeblieben, Zeit zu tanken!\n";
}
std::cout << std::endl;
}
void Auto::tanken(float liter){
std::cout << "Tanke " << liter << " Liter.\n";
m_tankinhalt += liter;
if(m_tankinhalt > m_tankgroesse){
m_tankinhalt = m_tankgroesse;
std::cout << "Nicht so übereifrig! Ihr Tank ist jetzt wieder voll.\n";
std::cout << "Sie haben aber einiges daneben gegossen!\n";
}
std::cout << std::endl;
}
Diese Klasse demonstriert vieles, was Sie im Laufe dieses Abschnittes noch kennenlernen werden. Für den Moment sollten Sie wissen, dass diese Klasse 3 verschiedene Daten beinhaltet. Diese Daten sind die 3 Variablen deren Namen mit m_ beginnen. Vier Funktionen arbeiten auf diesen Daten.
Das m_ steht für Member oder Mitglied, es ist allerdings lediglich eine Möglichkeit, eine Variable als Mitglied einer Klasse zu kennzeichnen. Innerhalb einer Klassenfunktion kann man so schnell erkennen, welche Variablen Membervariablen sind und welche Parameter oder lokale Variablen. Eine Membervariable kann auch jeden anderen gültigen Variablennamen haben. Klassenfunktionen werden auch oft als Memberfunktionen oder schlicht als Methoden bezeichnet. Im Nachfolgendem wird hierfür immer der Begriff „Methode“ verwendet.
Sie haben nun gesehen, wie die Klasse aufgebaut ist, und in den folgenden Kapiteln wird dieser Aufbau genauer erläutert. Jetzt sollen Sie jedoch erst einmal den Vorteil einer Klasse verstehen, denn um eine Klasse zu benutzen, müssen Sie keine Ahnung haben, wie diese Klasse intern funktioniert.
Auto wagen(80, 60.0f, 5.7);
wagen.info();
wagen.tanken(12.4f);
wagen.info();
wagen.fahren(230);
wagen.info();
wagen.fahren(12200);
wagen.info();
wagen.tanken(99.0f);
wagen.info();
}
Aktuell sind noch 60 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100km.
Tanke 12.4 Liter.
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 72.4 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100km.
Fahre 230km.
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 59.29 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100km.
Fahre 12200km.
Mit dem aktuellen Tankinhalt schaffen Sie die Fahrt leider nicht.
Der Wagen ist unterwegs liegengeblieben, Zeit zu tanken!
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 0 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100km.
Tanke 99 Liter.
Nicht so übereifrig! Ihr Tank ist jetzt wieder voll.
Sie haben aber einiges daneben gegossen!
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 80 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100km.
In der ersten Zeile von main() wird ein Auto-Objekt mit dem Namen wagen erstellt. Anschließend werden Methoden dieses Objekts aufgerufen, um die Daten darin zu verwalten. Von den Daten innerhalb des Objekts kriegen Sie beim Arbeiten mit dem Objekt überhaupt nichts mit. Lediglich die Ausgabe verrät, dass die drei Methoden untereinander über diese Daten „kommunizieren“.
Die vierte Methode (jene, die mit dem Klassennamen identisch ist) wird übrigens auch aufgerufen. Gleich in der ersten Zeile von main() wird diese Methode genutzt, um das Objekt zu erstellen. Daher bezeichnet man diese Methode auch als „Konstruktor“, dazu aber später mehr.
Erstellen und Zerstören
In C++-Klassen gibt es zwei besondere Arten von Methoden: Konstruktoren und den Destruktor. Ein Konstruktor wird beim Anlegen eines Objektes ausgeführt, der Destruktor vor der „Zerstörung“ desselben. Der Name des Konstruktors ist immer gleich dem Klassennamen, der Destruktor entspricht ebenfalls dem Klassennamen, jedoch mit einer führendem Tilde ( ~).
Konstruktoren und Destruktoren haben keinen Rückgabetyp, auch nicht void. Der Konstruktor kann nicht als Methode aufgerufen werden, beim Destruktor ist dies hingegen möglich (aber nur selten nötig, dazu später mehr).
[Bearbeiten] Konstruktor
Jede Klasse hat einen oder mehrere Konstruktoren. Ein solcher Konstruktor dient zur Initialisierung eines Objektes. Im Folgenden wird eine Klasse Bruch angelegt, die über den Konstruktor die Membervariablen m_zaehler und m_nenner initialisiert.
public:
Bruch(int z, int n):
m_zaehler(z), // Initialisierung von m_zaehler
m_nenner(n) // Initialisierung von m_nenner
{}
private:
int m_zaehler;
int m_nenner;
};
int main(){
Bruch objekt(7, 10); // Der Bruch 7/10 oder auch 0.7
}
Wie Sie sehen, ist der Methodenrumpf von Bruch::Bruch leer. Die Initialisierung findet in den beiden Zeilen über dem Rumpf statt. Nach dem Prototyp wird ein Doppelpunkt geschrieben, darauf folgt eine Liste der Werte, die initialisiert werden sollen. Vielleicht erinnern Sie sich noch, dass es zur Initialisierung zwei syntaktische Varianten gibt:
Innerhalb der Initialisierungsliste ist nur die zweite Variante zulässig. Auch in der Hauptfunktion main() findet eine Initialisierung statt. Wie Sie sehen, ist hier ebenfalls nur die zweite Variante möglich, da objekt zwei Werte erwartet.
Beachten Sie, dass die Initialisierung der Variablen einer Klasse in der Reihenfolge erfolgt, in der sie in der Klasse deklariert wurden. Die Initialisierungsreihenfolge ist unabhängig von der Reihenfolge, in der sie in der Initialisierungsliste angegeben wurden. Viele Compiler warnen, wenn man so etwas macht, da derartiger Code möglicherweise nicht das tut, was man erwartet.
Natürlich ist es wie bei Funktionen möglich (und in der Regel zu empfehlen), die Methodendeklaration von der Methodendefinition zu trennen. Die Definition sieht genau so aus, wie bei einer normalen Funktion. Der einzige auffällige Unterschied besteht darin, dass dem Methodennamen der Klassenname vorangestellt wird, getrennt durch den Bereichsoperator ( ::).
public:
Bruch(int z, int n); // Deklaration
private:
int m_zaehler;
int m_nenner;
};
Bruch::Bruch(int z, int n): // Definition
m_zaehler(z), // Initialisierung von m_zaehler
m_nenner(n) // Initialisierung von m_nenner
{}
int main(){
Bruch objekt(7, 10); // Der Bruch 7/10 oder auch 0.7
}
Wie bereits erwähnt, hat der Konstruktor keinen Rückgabetyp, daher wird auch in der Definition keiner angegeben. Bei den Basisdatentypen ist es bezüglich der Performance übrigens egal, ob Sie diese initialisieren oder zuweisen. Sie könnten also den gleichen Effekt erzielen, wenn Sie statt der Initialisierungsliste eine Zuweisung im Funktionsrumpf benutzen. Allerdings gilt dies wirklich nur für die Basisdatentypen, bei komplexen Datentypen ist die Initialisierung oft deutlich schneller. Außerdem können konstante Variablen ausschließlich über die Initialisierungsliste einen Wert erhalten. Nun aber noch mal ein einfaches Beispiel, in dem der Funktionsrumpf des Konstruktors zur Anfangswertzuweisung dient:
public:
Bruch(int z, int n = 1); // Deklaration
private:
int m_zaehler;
int m_nenner;
};
Bruch::Bruch(int z, int n){ // Definition
m_zaehler = z;
m_nenner = n;
}
Wie Sie sehen entfällt der Doppelpunkt, wenn Sie die Initialisierungsliste nicht nutzen. Natürlich können Sie auch Initialisierungsliste und Funktionsrumpf parallel benutzen.
Irgendwann werden Sie sicher einmal in die Verlegenheit kommen ein Array als Membervariable innerhalb Ihrer Klasse zu Deklarieren. Leider gibt es keine Möglichkeit Arrays zu initialisieren, sie müssen immer im Konstruktorrumpf mittels Zuweisung ihren Anfangswert erhalten. Entsprechend ist es auch nicht möglich ein Memberarray mit konstanten Daten zu erstellen. Wenn Sie also zwingend eine Initialisierung benötigen, müssen Sie wohl oder übel Einzelvariablen erstellen.
[Bearbeiten] Defaultparameter
Sie können einem Konstruktor ebenso Defaultparameter vorgeben wie einer gewöhnlichen Funktion. Die Syntax ist hierbei identisch.
public:
Bruch(int z, int n = 1); // Deklaration
private:
int m_zaehler;
int m_nenner;
};
Bruch::Bruch(int z, int n): // Definition
m_zaehler(z),
m_nenner(n)
{}
[Bearbeiten] Mehrere Konstruktoren
Auch das Überladen des Konstruktors funktioniert wie das Überladen einer Funktion. Deklarieren Sie mehrere Prototypen innerhalb der Klasse und schreiben für jeden eine Definition:
public:
Bruch(int z); // Deklaration
Bruch(int z, int n); // Deklaration
private:
int m_zaehler;
int m_nenner;
};
Bruch::Bruch(int z): // Definition
m_zaehler(z),
m_nenner(1)
{}
Bruch::Bruch(int z, int n): // Definition
m_zaehler(z),
m_nenner(n)
{}
Wenn mehrere Konstruktoren das gleiche tun, ist es oft sinnvoll, diese gleichen Teile in eine eigene Methode (üblicherweise mit dem Namen init()) zu schreiben. Das ist kürzer, übersichtlicher, meist schneller und hilft auch noch bei der Vermeidung von Fehlern. Den wenn Sie den Code nachträglich ändern, dann müssten Sie diese Änderungen sonst für jeden Konstruktor vornehmen. Nutzen Sie eine init()-Methode, müssen Sie denn Code nur innerhalb von dieser einmal ändern.
public:
A(double x);
A(int x);
private:
void init();
int m_x;
int m_y;
};
A::A(double x):
m_x(x) {
init();
}
A::A(int x):
m_x(x) {
init();
}
void A::init(){
m_y=7000;
}
Dieses Beispiel ist zugegebenermaßen ziemlich sinnfrei, aber das Prinzip der init()-Funktion wird deutlich. Wenn Sie sich nun irgendwann entscheiden, m_y als Anfangswert 3333 zuzuweisen, dann müssen Sie dies nur in der init()-Funktion ändern und nicht in jedem einzelnen Konstruktor.
[Bearbeiten] Standardkonstruktor
Als Standardkonstruktor bezeichnet man einen Konstruktor, der keine Parameter erwartet. Ein Standardkonstruktor für unsere Bruch-Klasse könnte zum Beispiel folgendermaßen aussehen:
public:
Bruch(); // Deklaration Standardkonstruktor
private:
int m_zaehler;
int m_nenner;
};
Bruch::Bruch(): // Definition Standardkonstruktor
m_zaehler(0),
m_nenner(1)
{}
Natürlich könnten wir in diesem Beispiel den Konstruktor überladen, um neben dem Standardkonstruktor auch die Möglichkeiten zur Initialisierung zu haben. In diesem Beispiel bietet es sich jedoch an, dafür Defaultparameter zu benutzen. Das folgende kleine Beispiel erlaubt die Initialisierung mit einer ganzen Zahl und mit einer gebrochenen Zahl (also zwei Ganzzahlen: Zähler und Nenner). Außerdem wird auch gleich noch der Standardkonstruktor bereitgestellt, welcher den Bruch mit 0 initialisiert, also den Zähler auf Null und den Nenner auf Eins setzt.
public:
// Erlaubt 3 verschiedene Aufrufmöglichkeiten, darunter die des Standardkonstruktors
Bruch(int z = 0, int n = 1);
private:
int m_zaehler;
int m_nenner;
};
Bruch::Bruch(int z, int n): // Definition des Konstruktors
m_zaehler(z),
m_nenner(n)
{}
Im Kapitel "Leere Klassen" werden Sie noch einiges mehr über den Standardkonstruktor erfahren.
[Bearbeiten] Kopierkonstruktor
Neben dem Standardkonstruktor hat der Kopierkonstruktor noch eine besondere Bedeutung. Er erstellt, wie der Name bereits andeutet, ein Objekt einer Klasse, anhand eines bereits vorhanden Objektes. Der Parameter des Kopierkonstruktors ist also immer eine Referenz auf ein konstantes Objekt der selben Klasse. Der Kopierkonstruktor unserer Bruch-Klasse hat folgende Deklaration.
Wenn wir keinen eigenen Kopierkonstruktor schreiben, erstellt der Compiler einen für uns. Dieser implizite Kopierkonstruktor initialisiert alle Membervariablen mit den entsprechenden Werten der Membervariablen im übergebenen Objekt. Für den Moment ist diese vom Compiler erzeugte Variante ausreichend. Später werden Sie Gründe kennenlernen, die es notwendig machen, einen eigenen Kopierkonstruktor zu schreiben. Wenn Sie die Nutzung des Kopierkonstruktors verbieten wollen, dann schreiben Sie seine Deklaration in den private-Bereich der Klassendeklaration. Das bewirkt einen Kompilierfehler, wenn jemand versucht den Kopierkonstruktor aufzurufen.
Neben dem Kopierkonstruktor erzeugt der Compiler übrigens auch noch eine Kopierzuweisung; auch zu dieser werden Sie später noch mehr erfahren. Auch diese können Sie verbieten, indem Sie sie im private deklarieren. Wie dies geht, erfahren Sie im Kapitel zu Operatorüberladung.
[Bearbeiten] Destruktor
Im Gegensatz zum Konstruktor, gibt es beim Destruktor immer nur einen pro Klasse. Das liegt daran, dass ein Destruktor keine Parameter übergeben bekommt. Sein Aufruf erfolgt in der Regel implizit durch den Compiler bei der Zerstörung eines Objektes. Für den Anfang werden Sie mit dem Destruktor wahrscheinlich nicht viel anfangen können, da es mit den Mitteln, die Sie bis jetzt kennen, kaum nötig werden kann, dass bei der Zerstörung eines Objektes Aufräumungsarbeiten ausgeführt werden. Das folgende kleine Beispiel enthält einen Destruktor, der einfach gar nichts tut:
public:
~A(); // Deklaration Destruktor
};
A::~A(){ // Definition Destruktor
// Hier könnten "Aufräumungsarbeiten" ausgeführt werden
}
Wenn Sie den Abschnitt über Speicherverwaltung gelesen haben, werden Sie wissen, wie nützlich der Destruktor ist. Im Moment reicht es, wenn Sie mal von ihm gehört haben. Auch über den Destruktor werden Sie im Kapitel "Leere Klassen" weitere Informationen erhalten.
[Bearbeiten] Beispiel mit Ausgabe
Um noch einmal deutlich zu machen, an welchen Stellen Konstruktor und Destruktor aufgerufen werden, geben wir einfach innerhalb der Methoden eine Nachricht aus:
class A{
public:
A(); // Deklaration Konstruktor
~A(); // Deklaration Destruktor
void print(); // Deklaration einer Methode
};
A::A(){ // Definition Konstruktor
std::cout << "Innerhalb des Konstruktors" << std::endl;
}
A::~A(){ // Definition Destruktor
std::cout << "Innerhalb des Destruktors" << std::endl;
}
void A::print(){ // Definition der print()-Methode
std::cout << "Innerhalb der Methode print()" << std::endl;
}
int main(){
A objekt; // Anlegen des Objekts == Konstruktoraufruf
objekt.print(); // Aufruf der Methode print()
return 0;
} // Ende von main(), objekt wird Zerstört == Destruktoraufruf
Innerhalb der Methode print()
Innerhalb des Destruktors
[Bearbeiten] Objekte Erstellen und Zerstören
[Bearbeiten] Im Stack (deutsch: Stapel)
Effektive Objekte können, nur für den aktuellen Gültigkeitsbereich, auf dem sog. Stack erstellt werden. Der Stack ist ein Speicherbereich für lokale Variablen eines Moduls. Beim Verlassen eines Gültigkeitsbereichs werden diese Objekte automatisch zerstört. Alle vorigen Beispiele in diesem Abschnitt zeigen wie Objekte auf dem Stack erstellt und zerstört werden. Größere Objekte, große Speicherblocke, sollten nur in Beispielanwendungen auf dem Stack erstellt werden, dieser Bereich ist stark begrenzt und sollte ausschließlich für lokale und temporäre Daten genutzt werden. Moderne Compiler begrenzen diesen Bereich auf 1MegaByte. Wenn Sie größere Objekte auf den Stack legen wollen, müssen Sie die maximale Heapgröße modifizieren! Tun Sie dies nicht, erhalten Sie höchst bemerkenswerte Meldungen von Laufzeitumgebung und/oder Betriebssystem.
[Bearbeiten] Auf dem Heap (deutsch: Halde)
Effektive Objekte können dynamisch und permanent, bis zum Ende der Laufzeit des Moduls, erstellt werden. Dies erfolgt im sog. Heap. Der Heap entspricht meistens dem nicht vorgespeicherten Datensegment für das gesamte Programm. Dazu verwendet man operator new. Wenn ein Objekt nicht mehr benötigt wird, muss es bei dieser Variante manuell zerstört werden und zwar mit operator delete. Weiterführende Konzepte wie Smart-Pointer, können das Zerstören beim Verlassen von Gültigkeitsbereichen automatisieren. Diese Operatoren kann man auch für Felder verwenden, dann muss bei der Zerstörung operator delete [] verwendet werden.
Der Heap hat den eklatanten Vorteil, das die Grenzen des zuteilbaren Speichers nur vom Betriebssystem und physikalischer Speichermenge gezogen werden und nicht von Compiler- und Linkereinstellungen. Ein weiterer Vorteil ist, dass alle Elemente einer Klasse dann auch auf dem Heap liegen.
Wir verwenden Klasse 'a' aus vorigem Beispiel:
{
A * pObjekt(0); // Zeiger auf ein A-Objekt
pObjekt = new A; // Instantiieren auf dem Heap, Standardkonstruktor verwenden
delete A; // Zerstören
char * pszMemory = new char[0x100000]; // 1 Megabyte auf dem Heap alloziieren
delete [] pszMemory; // Speicherblock wieder freigeben
A * ar_Objekte = new A[50]; // 50 Objekte von A anlegen
delete [] ar_Objekte;
return 0;
}
Weitere Optionen zur Verwendung von new, delete, new [] und delete [] gibt es auch.
Privat und öffentlich
Mit den Schlüsselwörtern public und private wird festgelegt, von wo aus auf ein Mitglied einer Klasse zugegriffen werden kann. Auf Member, die als public deklariert werden, kann von überall aus zugegriffen werden, sie sind also öffentlich verfügbar. private-deklarierte Member lassen sich nur innerhalb der Klasse ansprechen, also nur innerhalb von Methoden derselben Klasse. Typischerweise werden Variablen private deklariert, während Methoden public sind. Eine Ausnahme bilden Hilfsmethoden, die gewöhnlich im private-Bereich deklariert sind, wie etwa die init()-Methode, die von verschieden Konstruktoren aufgerufen wird, um Codeverdopplung zu vermeiden.
Standardmäßig sind die Member einer Klasse private. Geändert wird der Sichtbarkeitsbereich durch die beiden Schlüsselwörter gefolgt von einem Doppelpunkt. Alle Member die darauffolgend deklariert werden, fallen in den neuen Sichtbarkeitsbereich.
// private Member
public:
// öffentliche Member
private:
// private Member
protected:
// geschützte Member, wird später erläutert
};
Zwischen den Sichtbarkeitsbereichen kann somit beliebig oft gewechselt werden. In diesem Buch folgen wir der Regel, als erstes die öffentlichen Member zu deklarieren und erst danach die privaten. Die umgekehrte Variante findet jedoch ebenfalls eine gewisse Verbreitung. Wie üblich gilt, entscheiden Sie sich für eine Variante und wenden Sie diese konsequent an. Wollen Sie eine Klasse erstellen, deren Inhalt standardmäßig öffentlich ist, verwenden Sie hierzu das Schlüsselwort struct.
// öffentliche Member
private:
// private Member
public:
// öffentliche Member
protected:
// geschützte Member, wird später erläutert
};
Klassen und const
Auch für Klassen gilt üblicherweise: Verwenden Sie const wann immer es möglich ist. Wie Sie bereits wissen, sollte const immer verwendet werden, wenn eine Variable nach der Initialisierung nicht mehr verändert werden soll.
Da Klassen Datentypen sind, von denen Instanzen (also Variablen) erstellt werden können, ist es natürlich möglich ein Objekt zu erstellen, das konstant ist. Da der Compiler jedoch davon ausgehen muss, dass jede Methode der Klasse die Daten (und somit das Objekt) verändert, sind Methodenaufrufe für konstante Objekte nicht möglich. Eine Ausnahme bilden jene Methoden, die ebenfalls als const gekennzeichnet sind. Ein solche Methode kann zwar problemlos Daten aus dem Objekt lesen, aber niemals darauf schreiben und auch für Dritte keine Möglichkeit bereitstellen, objektinterne Daten zu verändern.
[Bearbeiten] Konstante Methoden
Unsere Beispielklasse Auto enthält die konstante Methode info(), sie greift nur lesend auf die Membervariablen zu, um ihre Werte auszugeben. Wenn Sie ein konstantes Auto Objekt erstellen, können Sie info() problemlos aufrufen. Versuchen Sie jedoch fahren() oder tanken() aufzurufen, wird Ihr Compiler dies mit einer Fehlermeldung quittieren.
Wie Sie an diesem Beispiel sehen, lässt sich eine Methode als konstant auszeichnen, indem nach der Parameterliste das Schlüsselwort const angegeben wird. Diese Auszeichnung folgt also auch der einfachen Regel: const steht immer rechts von dem, was konstant sein soll, in diesem Fall die Methode. Da const zum Methodenprototyp zählt, muss es natürlich auch bei der Definition der Methode angegeben werden.
Es sei noch einmal explizit darauf hingewiesen, dass sich die Konstantheit einer Methode lediglich auf die Membervariablen der zugehörigen Klasse auswirkt. Es ist problemlos möglich, eine als Parameter übergebene Variable zu modifizieren.
Eine konstante Methode kann ausschließlich andere konstante Methoden des eigenen Objektes aufrufen, denn der Aufruf einer nicht-konstanten Methode könnte ja Daten innerhalb des Objektes ändern.
[Bearbeiten] Sinn und Zweck konstanter Objekte
Vielleicht haben Sie sich bereits gefragt, wofür es gut sein soll, ein Objekt als konstant auszuzeichnen, wenn der Zweck eines Objektes doch darin besteht, mit den enthaltenen Daten zu arbeiten. Beim Erstellen eines konstanten Objektes können Sie es einmalig über den Konstruktor mit Werten belegen. In diesem Fall haben Sie von einem konstanten Objekt das gleiche, wie bei konstanten Variablen von Basisdatentypen.
Oft ist es jedoch nicht möglich, alle Einstellungen zu einem Objekt über den Konstruktoraufruf festzulegen. Es fördert die Übersichtlichkeit schließlich nicht, wenn man, etwa 20 verschiedene Konstruktoren mit je etwa 50 Parametern hat. Der Ansatz, Klassen so zu gestalten, dass man immer alle Werte über den Konstruktor festlegen kann, hat also seine Grenzen. In diesem Fall hat es einfach keinen Sinn, ein Objekt bei der Erstellung konstant zu machen, denn die Einstellungen werden erst nach dem Erstellen der Objektes vorgenommen.
Wenn Sie ein so erstelltes Objekt nun allerdings an eine Funktion übergeben und diese Funktion keine Veränderungen an dem Objekt vornimmt, ist die Wahrscheinlichkeit groß, dass der Parameter ein konstantes Objekt ist. Innerhalb einer solchen Funktion wäre das Objekt also konstant.
[Bearbeiten] Zugriffsmethoden
Zugriffsmethoden sollten eigentlich vermieden werden, aber manchmal sind sie nützlich. Eine Zugriffsmethode macht nichts anderes, als eine Membervariable lesend oder schreibend zugreifbar zu machen:
public:
void SetWert(int wert) { m_wert = wert; }
int GetWert()const { return m_wert; }
private:
int m_wert;
};
Get-Methoden sind immer konstant, da sie den Wert ja nur lesend zugreifbar machen sollen. Eine Set-Methode kann dagegen nie mit einem konstanten Objekt benutzt werden. Im Normalfall sollten Sie jedoch keine „Getter“ oder „Setter“ benötigen, wenn doch, müssen Sie sich Gedanken darüber machen, ob das Objekt die Logik möglicherweise nicht ausreichend kapselt.
Solche Einzeiler werden normalerweise einfach direkt in die Funktionsdeklaration geschrieben, dadurch sind Sie auch gleich automatisch als inline ausgezeichnet. Dazu müssen Sie nur das Semikolon durch den Funktionsrumpf ersetzen. Sollten Sie dennoch lieber eine eigene Definition für solche Methoden machen wollen, dann achten Sie darauf, diese als Definition als inline zu kennzeichnen. Falls Sie mit Headerdateien arbeiten, dann beachten Sie, dass der Funktionsrumpf bei inline-Methoden während des Kompilierens bekannt sein muss. Die Definition muss also mit in die Headerdatei, nicht wie gewöhnlich in die Quelldatei.
Überladen…
Sie haben bereits gelernt, dass Funktionen überladen werden können, indem für den gleichen Funktionsnamen mehrere Deklarationen mit verschiedenen Parametern gemacht werden. Auch bei Klassenkonstruktoren haben Sie Überladung bereits kennengelernt. Für gewöhnliche Memberfunktionen ist eine Überladung ebenfalls nach den Ihnen bereits bekannten Kriterien möglich. Zusätzlich können Sie Memberfunktionen aber anhand des eben vorgestellten const-Modifizierers überladen.
public:
void methode(); // Eine Methode
void methode()const; // Die überladene Version der Methode für konstante Objekte
};
Natürlich können auch Methoden mit Parametern auf diese Weise überladen werden. Die nicht-konstante Version wird immer dann aufgerufen, wenn Sie mit einem nicht-konstanten Objekt der Klasse arbeiten. Analog dazu wird die konstante Version aufgerufen, wenn Sie mit einem konstanten Objekt arbeiten. Wenn Sie nur eine konstante Version deklarieren, wird immer diese aufgerufen.
Sinnvoll ist diese Art der Überladung vor allem dann, wenn Sie einen Zeiger oder eine Referenz auf etwas innerhalb des Objekts zurückgeben. Wie Sie sich sicher erinnern, kann eine Überladung nicht anhand des Rückgabetyps einer Funktion (oder Methode) gemacht werden. Das folgende Beispiel wird Ihnen zeigen, wie Sie eine const-Überladung nutzen können, um direkte Manipulation von Daten innerhalb eines Objekts nur für nicht-konstante Objekte zulassen.
class A{
public:
A():m_b(7) {} // m_b mit 7 initialisieren
int& B() { return m_b; } // Zugriff lesend und schreibend
int const& B()const { return m_b; } // Zugriff nur lesend
private:
int m_b; // Daten
};
int main(){
A objekt; // Ein Objekt von A
A const objekt_const; // Ein konstantes Objekt von A
std::cout << objekt.B() << std::endl; // Gibt 7 aus
std::cout << objekt_const.B() << std::endl; // Gibt 7 aus
objekt.B() = 9; // setzt den Wert von m_b auf 9
// objekt_const.B() = 9; // Produziert einen Kompilierfehler
std::cout << objekt.B() << std::endl; // Gibt 9 aus
std::cout << objekt_const.B() << std::endl; // Gibt 7 aus
}
Im Kapitel über Operatorüberladung werden Sie noch ein Beispiel zu dieser Technik kennenlernen, welches in der Praxis oft zu sehen ist.
[Bearbeiten] Code-Verdopplung vermeiden
Im Beispiel von eben geben die beiden Funktionen lediglich eine Referenz auf eine Membervariable innerhalb des Objekts zurück. In der Regel wird eine solche Funktionen natürlich noch etwas mehr tun. Daher wäre es nötig, zwei Funktionen zu schreiben, die den gleichen Code enthalten. Das wiederum ist ausgesprochen schlechter Stil. Stellen Sie sich nur vor, Sie möchten die Funktion später aus irgendwelchen Gründen ändern, dann müssten Sie alle Änderungen an zwei Stellen im Code vornehmen.
Daher ist es sinnvoll, wenn eine Variante die andere aufruft. Hiefür sind einige Tricks nötig, da Sie einer der beiden Varianten beibringen müssen, eine Methode aufzurufen, die eigentlich nicht zum const-Modifizierer des aktuellen Objekts passt. Die konstante Variante verspricht niemals eine Änderung am Objekt vorzunehmen, sie ist in ihren Möglichkeiten also stärker eingeschränkt. Die nicht konstante Version darf hingegen alles, was auch die konstante Version darf. Somit ist es sinnvoll, die konstante Version von der nicht-konstanten aufrufen zu lassen.
Um nun der nicht-konstanten Version beizubringen, dass sie ihr konstantes Äquivalent aufrufen soll, müssen wir zunächst einmal aus dem aktuellen Objekt ein konstantes Objekt machen. Jede Klasse enthält eine spezielle Variable, den sogenannten this-Zeiger, der innerhalb einer Membervariable einen Zeiger auf das aktuelle Objekt repräsentiert. Diesen this-Zeiger casten wir in einen Zeiger auf ein konstantes Objekt.
int A::B(){
A const* objekt_const = static_cast< A const* >(this); // Konstantheit dazu casten
Nun haben wir einen Zeiger auf das Objekt, über den wir nur konstante Methoden aufrufen können. Das Problem ist nun, dass die aufgerufene Methode natürlich auch eine Referenz auf eine konstante Variable aufruft.
Da wir ja wissen, dass das aktuelle Objekt eigentlich gar nicht konstant ist, können wir die Konstantheit für die zurückgegebene Referenz guten Gewissens entfernen. Allerdings ist der static_cast, den Sie bereits kennen, nicht dazu in der Lage. Um Konstantheit zu entfernen, benötigen Sie den const_cast. Beachten Sie jedoch, dass dieser Cast wirklich nur auf Variablen angewendet werden darf, die eigentlich nicht konstant sind!
Wenn Sie diese Anweisungen in einer Zusammenfassen, sieht Ihre Klasse nun folgendermaßen aus.
public:
// m_b mit 7 initialisieren
A():m_b(7) {}
// Ruft B()const auf
int& B() { return const_cast< int& >( static_cast< A const* >(this)->B() ); }
int const& B()const { return m_b; }
private:
int m_b; // Daten
};
Wie schon gesagt, sieht diese Technik in unserem Beispiel überdimensioniert aus. Aber auch an dieser Stelle möchte ich Sie auf das Beispiel im Kapitel zur Operatorüberladung verweisen, wo sie, aufgrund der umfangreicheren konstanten Version, bereits deutlich angenehmer erscheint. Mit Performanceeinbußen haben Sie an dieser Stelle übrigens nicht zu rechnen. Ihr Compiler wird die casting-Operationen wegoptimieren.
Call by reference
Im Gegensatz zu den vorangegangen Kapiteln dieses Abschnitts, geht es diesmal nicht darum, wie man Objekte aufbaut, sondern wie man mit ihnen arbeitet. Im Kapitel über Funktionen hatten Sie schon ersten Kontakt mit der Wertübergabe als Referenz. Wie dort bereits erwähnt, ist es für Klassenobjekte effizienter, sie als Referenz an eine Funktion zu übergeben.
Bei der Übergabe einer Variablen als Wert muss von dieser Variable erst eine Kopie angefertigt werden. Zusätzlich führt der Kopierkonstruktor an dieser Stelle möglicherweise noch irgendwelche zusätzlichen Operationen aus, die Zeit kosten. Bei einer Übergabe als Referenz muss hingegen nur die Speicheradresse des Objekts kopiert werden. Wenn wir dann noch dafür sorgen, dass die Referenz auf ein konstantes Objekt verweist, haben wir eine fast kostenlose Übergabe und gleichzeitig die Sicherheit, dass die übergebene Variable innerhalb der Funktion nicht verändert wird.
public:
int a, b, c, d;
};
class B{
public:
// Das übergebene A-Objekt ist innerhalb der Methode konstant
void methode(A const& parameter_name);
};
Die Methode von B kann auf die 4 Variablen von A lesend zugreifen, sie aber nicht verändern. Die Übergabe als Wert ist dann sinnvoll, wenn innerhalb einer Methode ohnehin eine Kopie der Variablen benötigt wird.
Nun können auf das A-Objekt beliebige lesende oder schreibende Operationen angewendet werden. Da sie auf einer Kopie ausgeführt werden, bleibt auch hier das Originalobjekt unverändert, aber eben zu dem Preis, dass zusätzlich Zeit und Speicherplatz benötigt werden, um eine Kopie des Objekts zu erstellen. In den meisten Fällen ist es nicht nötig, innerhalb einer Methode, Schreiboperationen auszuführen. Verwenden Sie daher nach Möglichkeit die „Call by reference“-Variante.
Bei Rückgabewerten sollten Sie natürlich auf Referenzen verzichten, es sei den, Sie wissen wirklich was Sie tun. Andernfalls kann es schnell passieren, dass Sie eine Referenz auf eine Variable zurückgeben, die außerhalb der Methode gar nicht mehr Existiert. Das wiederum führt zufallsbedingt zu Laufzeitfehlern und Programmabstürzen.
Operatoren überladen
Im Kapitel über Zeichenketten haben Sie gelernt, das es sich bei std::string um eine Klasse handelt. Dennoch war es Ihnen möglich, mehrere std::string-Objekte über den +-Operator zu verknüpfen oder einen String mittels = bzw. += an einen std::string zuzuweisen bzw. anzuhängen. Der Grund hierfür ist, das diese Operatoren für die std::string-Klasse überladen wurden. Das heißt ihnen wurde in Zusammenhang mit der Klasse std::string eine neue Bedeutung zugewiesen.
Um Operatoren überladen zu können müssen Sie zunächst einmal wissen, dass diese in C++ im Prinzip nur eine spezielle Schreibweise für Funktionen sind. Das folgende kleine Beispiel demonstriert den Aufruf der überladenen std::string in ihrer Funktionsform:
#include <string>
int main(){
std::string a = "7", b = "3", c;
c = a + b;
std::cout << c << std::endl;
c.operator=(operator+(a,b));
std::cout << c << std::endl;
}
Wie Ihnen beim Lesen des Beispiels vielleicht schon aufgefallen ist, wurde der Zuweisungsoperator als Methode von std::string überladen. Der Verkettungsoperator ist hingegen als gewöhnliche Funktion überladen. Dieses Vorgehen ist üblich, aber nicht zwingend. Die Deklarationen der beiden Operatoren sehen folgendermaßen aus:
// Zuweisungsoperator als Member von std::string
std::string& operator=(std::string const& assign_string);
};
// Verkettungsoperator als globale Funktion
std::string operator+(std::string const& lhs, std::string const& rhs);
Da es sich bei std::string um ein typedef von eine Templateklasse handelt, sehen die Deklarationen in der C++-Standardbibliothek noch etwas anders aus, das ist für unser Beispiel aber nicht relevant. Auf die Rückgabetypen der verschiedenen Operatoren wird später in diesem Kapitel noch näher eingegangen.
Wenn Sie den Zuweisungsoperator als globale Funktion überladen wollten, müssten Sie ihm zusätzlich als ersten Parameter eine Referenz auf ein std::string-Objekt übergeben. Würden Sie den Verkettungsoperator als Methode der Klasse deklarieren wollen, entfiele der erste Parameter. Er würde durch das Objekt ersetzt, für das der Operator aufgerufen wird. Da die Operatorfunktion nichts an dem Objekt ändert, für das sie aufgerufen wird, ist sie außerdem konstant. Allerdings hätten Sie an dieser Stelle ein Problem, sobald Sie einen std::string zum Beispiel mit einem C-String verknüpfen wollen.
Bei einer Operatorüberladung als Methode ist der erste Operand immer ein Objekt der Klasse. Für den Zuweisungsoperator ist das in Ordnung, da ja immer etwas an einen std::string zugewiesen werden soll. Wenn Sie also folgende Operatorüberladungen in der Klasse vornahmen, werden Sie bei der Nutzung der Klasse schnell auf Probleme stoßen.
// Zuweisung eines anderen std::string's
std::string& operator=(std::string const& assign_string);
// Zuweisung eines C-Strings
std::string& operator=(char const assign_string[]);
// Verkettung mit anderen std::string's
std::string operator+(std::string const& rhs)const;
// Verkettung mit C-Strings
std::string operator+(char const rhs[])const;
};
int main(){
std::string std_str_1 = "std_1";
std::string std_str_2 = "std_2";
char c_str_1[80] = "c_str";
std_str_1 = std_str_2; // Geht
std_str_1 = c_str_1; // Geht
// c_str_1 = std_str_1; // Geht nicht (soll es auch nicht!)
std_str_1 + std_str_2; // Geht
std_str_1 + c_str_1; // Geht
// c_str_1 + std_str_2; // Geht nicht (sollte es aber)
}
Die in der Klasse deklarierten Formen lassen sich problemlos nutzen. Wenn wir aber wollen, dass man auch „C-String + std::string“ schreiben kann, müssen wir noch eine weitere Operatorüberladung global deklarieren. Da dies jedoch uneinheitlich aussieht und obendrein die Klassendeklaration unnötig aufbläht, deklariert man alle Überladungen des Verkettungsoperators global. Das Ganze sieht dann folgendermaßen aus.
std::string operator+(std::string const& lhs, char const rhs[]);
std::string operator+(char const lhs[], std::string const& rhs);
[Bearbeiten] Definition der überladenen Operatoren
[Bearbeiten] Codeverdopplung vermeiden
[Bearbeiten] Ein-/Ausgabeoperatoren überladen
[Bearbeiten] Spaß mit „falschen“ Überladungen
Mit der Operatorüberladung lässt sich auch einiger Unsinn anstellen. Die folgende kleine Klasse verhält sich wie ein normaler int, allerdings rechnet der Plusoperator minus, und umgekehrt. Gleiches gilt für die Punktoperationen. Natürlich sollten Sie solch unintuitives Verhalten in ernst gemeinten Klassen unbedingt vermeiden, aber an dieser Stelle demonstriert es noch einmal schön die Syntax zur Überladung.
#include <ios>
class Int{
public:
// Konstruktor (durch Standardparameter gleichzeitig Standardkonstruktor)
Int(int value = 0): value(m_value) {}
// Überladene kombinierte Rechen-/Zuweisungsoperatoren
Int const& operator+=(Int const& rhs) { m_value -= rhs.m_value; return *this; }
Int const& operator-=(Int const& rhs) { m_value += rhs.m_value; return *this; }
Int const& operator*=(Int const& rhs) { m_value /= rhs.m_value; return *this; }
Int const& operator/=(Int const& rhs) { m_value *= rhs.m_value; return *this; }
private:
int m_value;
// Friend-Deklarationen für Ein-/Ausgabe
friend std::ostream& operator<<(std::ostream& os, Int const& rhs);
friend std::istream& operator>>(std::istream& is, Int& rhs);
};
// Überladene Rechenoperatoren rufen kombinierte Versionen auf
Int operator+(Int const& lhs, Int const& rhs) { return Int(lhs) += rhs; }
Int operator-(Int const& lhs, Int const& rhs) { return Int(lhs) -= rhs; }
Int operator*(Int const& lhs, Int const& rhs) { return Int(lhs) *= rhs; }
Int operator/(Int const& lhs, Int const& rhs) { return Int(lhs) /= rhs; }
// Definition Ein-/Ausgabeoperator
std::ostream& operator<<(std::ostream& os, Int const& rhs) { return os << rhs.m_value; }
std::istream& operator>>(std::istream& is, Int& rhs) { return is >> rhs.m_value; }
[Bearbeiten] Präfix und Postfix
Für einige Klassen kann es sinnvoll sein, den Increment- und den Decrement-Operator zu überladen. Da es sich um unäre Operatoren handelt, sie also nur einen Parameter haben, werden sie als Klassenmember überladen. Entsprechend übernehmen sie überhaupt keine Parameter mehr, da dieser ja durch das Klassenobjekt, für das sie aufgerufen werden ersetzt wird. Als Beispiel nutzen wir noch einmal unsere Int-Klasse, diesmal aber ohne die Operatoren mit der falschen Funktionalität zu überladen.
public:
// ...
Int& operator++() { ++m_value; return *this; }
Int& operator--() { --m_value; return *this; }
// ...
};
// ...
int main(){
Int value(5);
std::cout << value << std::endl; // value ist 5, Ausgabe ist 5
std::cout << ++value << std::endl; // value ist 6, Ausgabe ist 6
std::cout << --value << std::endl; // value ist 5, Ausgabe ist 5
}
So weit, so gut, aber wir können nicht value++ oder value-- schreiben. Diese Variante überlädt also nur die Präfix-Version der Operatoren, aber wie schreibt man nun die Postfix-Überladung? Wie Sie wissen, kann eine Funktion nur anhand ihrer Parameter oder, im Fall einer Methode, auch ihres const-Qualifizieres überladen werden. Aber weder die Prafix- noch die Postfixvariante übernehmen einen Parameter und auch der const-Qualifizier ist in keinem Fall gegeben, weil die Operatoren ja immer das Objekt verändern.
Aus diesem Grund hat man sich eine Kompromisslösung ausdenken müssen. Die Postfix-Operatoren übernehmen einen int, der jedoch innerhalb der Funktionsdefinition nicht verwendet wird. Er dient einzig, um dem Compiler mitzuteilen das es sich um eine Postfix-Operatorüberladung handelt.
public:
// ...
// Präfix (also ++Variable)
Int& operator++() { ++m_value; return *this; }
Int& operator--() { --m_value; return *this; }
// Postfix (also Variable++)
Int const operator++(int) { Int tmp = *this; ++m_value; return tmp; }
Int const operator--(int) { Int tmp = *this; --m_value; return tmp; }
// ...
};
// ...
int main(){
Int value(5);
std::cout << value << std::endl; // value ist 5, Ausgabe ist 5
std::cout << ++value << std::endl; // value ist 6, Ausgabe ist 6
std::cout << --value << std::endl; // value ist 5, Ausgabe ist 5
std::cout << value++ << std::endl; // value ist 6, Ausgabe ist 5
std::cout << value-- << std::endl; // value ist 5, Ausgabe ist 6
std::cout << value << std::endl; // value ist 5, Ausgabe ist 5
}
Im folgenden wird auf den Inkrementoperator (++) Bezug genommen, für den Dekrementoperator (--) gilt natürlich das Gleich, mit dem Unterschied, dass die Werte erniedrigt werden.
In diesem Beispiel sehen Sie auch schön den Unterschied zwischen Präfix- und Postfix-Variante. Bei der Präfix-Variante wird der Wert um 1 erhöht und anschließend zurückgegeben. Der Postfix-Operator erhöht den Wert zwar um 1, gibt anschließend aber den Wert zurück, den das Objekt vor der Erhöhung hatte. Entsprechend sind auch die Rückgabewerte der Operatoren. Bei Präfix wird eine Referenz auf sich selbst zurückgeliefert, bei Postfix hingeben ein konstantes temporäres Objekt, das den alten Wert des Objektes enthält, für das der Operator aufgerufen wurde. Die Implementierung ist entsprechend. Die Präfix-Version erhöht den Wert und liefert sich dann selbst zurück, während die Postfix-Variante zunächst eine Kopie der aktuellen Objektes erstellt, dann den Wert erhöht und schließlich denn zwischengespeicherten, alten Wert zurückgibt.
Wie früher schon einmal erwähnt, ist es effizienter den Präfix-Operator zu benutzen, wenn prinzipiell beide Varianten möglich wären. Bei den Basisdatentypen, kann der Compiler einen Performance-Nachteil wegoptimieren. Bei Klassen für die diese Operatoren überlanden wurden, kann sich der Compiler jedoch nicht darauf verlassen, dass die Präfix-Variante das gleiche macht, wie die Postfix-Variante. Daher ist er bei Klassen nicht oder zumindest nur sehr eingeschränkt in der Lage an dieser Stelle zu optimieren. Aus Gründen der Einheitlichkeit sollte daher, auch bei Basisdatentypen, die Präfix-Variante bevorzugt werden.
Der Rückgabewert der Postfix-Variante ist übrigens aus gutem Grund konstant. Den Präfix-Operator können Sie beliebig oft hintereinander für das gleiche Objekt aufrufen. Auch wenn in den meisten Fällen der Aufruf der Plus-Zuweisung (+=) möglich und effizienter ist. Für die Postfix-Variante ist ein mehrfacher Aufruf nicht sinnvoll, wie im folgenden Demonstriert.
++++++i; // i ist 8
// Effizienter währe "i += 3;"
i++++++; // i ist 9
// Lässt sich nur übersetzen wenn Rückgabewert nicht const
Beim mehrfachen Aufruf der Postfix-Variante werden bis auf den ersten, alle Aufrufe für temporäre Objekte aufgerufen. Hinzu kommt noch, dass der Rückgabewert ja immer der alte Wert ist. Somit ist die Operation für jeden Folgeaufruf die Gleiche, während der berechnete Wert, als temporäres Objekt, sogleich verworfen wird. Ist der Rückgabetyp konstant, kann kein Nicht-Konstanter-Operator für das Objekt aufgerufen werden und eine solch unsinnige Postfix-Operatorkette resultiert in einem Compilierfehler.
[Bearbeiten] Übersicht
[Bearbeiten] Rückgabetypen
[Bearbeiten] Casten
Klassen als Datenelemente einer Klasse
Analog zu Basistyp-Variablen, können Sie auch Klassenobjekte als Member einer Klasse deklarieren. Dies funktioniert genau so, wie Sie es schon kennen.
Nun enthält die Klasse B ein Objekt der Klasse A.
[Bearbeiten] Verweise untereinander
Manchmal ist es nötig, dass zwei Klassen einen Verweis auf die jeweils andere enthalten. In diesem Fall muss die Klassendeklaration von der Klassendefinition getrennt werden. Wie auch bei Funktionen gilt, dass jede Definition immer auch eine Deklaration ist und das Deklaration beliebig oft gemacht werden können, während Definition nur einmal erfolgen dürfen.
Um ein Klassenobjekt zu Deklarieren, muss die gesamte Klassendefinition bekannt sein. Wenn Sie hingegen nur einen Zeiger oder eine Referenz auf ein Klassenobjekt deklarieren möchten, genügt es, wenn die Klasse deklariert wurde.
class B{
// B enthält eine Referenz auf ein A-Objekt
A& a_objekt;
};
class A{
// A enthält ein B-Objekt
B objekt;
};
Falls Ihnen jetzt die Frage im Hinterkopf herumschwirrt, ob es möglich ist, dass beide Klassen ein Objekt der jeweils anderen enthalten, dann denken Sie noch mal darüber nach, was diese Aussage bedeuten würde. Die Antwort auf diese Frage ist selbstverständlich nein.
Freunde
[Bearbeiten] Freundschaft unter Klassen
Klassen können andere Klassen als Freunde betrachten. Wie im echten Leben sind diese Betrachtungen nicht immer beidseitig. Grundsätzlich gilt: Wenn eine Klasse eine andere Klasse als Freund betrachtet, darf diese Freund-Klasse auf die privaten Inhalte der Klasse zugreifen.
{
friend class Buch;
unsigned int m_nRevision; // implizit privat
};
class Buch
{
public:
Seite s19;
void jetzt_testen(void)
{
test();
}
private:
void test(void)
{
s19.m_nRevision = 1;
}
};
In diesem Beispiel sieht man eine solche Beziehung.
- Buch darf auf die private Variable m_nRevision zugreifen.
- Ein direkter Zugriff auf Seite::m_nRevision ist sogar nur von Buch aus möglich.
- Die Methode Buch::test kann niemals direkt aufgerufen werden.
Folgende Verwendung dieser Klassen schlägt fehl:
{
Buch dasBuch;
dasBuch.s19.m_nRevision = 9; // Kein Zugriff auf private Mitgliedsvariable von Seite
return 0;
}
{
friend class Seite;
public:
static void Seite_Rev_Setzen(Seite & s, unsigned int nRev)
{
s.m_nRevision = nRev; // Kein Zugriff auf private Mitgliedsvariable von Seite
}
};
int main()
{
Seite dieSeite;
freundlich::Seite_Rev_Setzen(dieSeite, 123);
return 0;
}
Das letzte Beispiel zeigt die einseitige Freundschaftsbeziehung, der Klasse freundlich. Auch wenn Klasse freundlich eine andere Klasse als Freund betrachtet, heisst das nicht, dass diese Klasse eine gegenseitige Freundschaft eingeht.
Leere Klassen?
|
Dieses Kapitel ist leider noch nicht vorhanden… |
|
|
Wenn Sie Lust haben können Sie das Kapitel Leere Klassen? selbst schreiben oder einen Beitrag dazu leisten. |
Der Taschenrechner wird Klasse
|
Dieses Kapitel ist leider noch nicht vorhanden… |
|
|
Wenn Sie Lust haben können Sie das Kapitel Der Taschenrechner wird Klasse selbst schreiben oder einen Beitrag dazu leisten. |
Was OOP noch kann…
|
Dieses Kapitel ist leider noch nicht vorhanden… |
|
|
Wenn Sie Lust haben können Sie das Kapitel Was OOP noch kann… selbst schreiben oder einen Beitrag dazu leisten. |
Zusammenfassung
|
Zu diesem Abschnitt existiert leider noch keine Zusammenfassung… |
|
|
Wenn Sie Lust haben können Sie die Zusammenfassung zum Abschnitt Eigene Datentypen definieren selbst schreiben oder einen Beitrag dazu leisten. |