C++-Programmierung/ Eigene Datentypen definieren/ Methoden
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. Eine 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.
Konstante Methoden
[Bearbeiten]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.
Sinn und Zweck konstanter Objekte
[Bearbeiten]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 des 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.
Zugriffsmethoden
[Bearbeiten]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:
class A{
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 bei der 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
[Bearbeiten]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.
class A{
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 zuzulassen.
#include <iostream>
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 Operatoren überladen werden Sie noch ein Beispiel zu dieser Technik kennenlernen, welches in der Praxis oft zu sehen ist.
Code-Verdopplung vermeiden
[Bearbeiten]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 Funktion natürlich noch etwas mehr tun, zum Beispiel den Zugriff protokollieren und den aktuellen Wert auf dem Bildschirm ausgeben. Dann 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. Hierfü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; so kann die Methode, die mehr darf, (später) erweitert werden um Aktivitäten, die nur sie darf, ohne die Aufrufstruktur umstellen zu müssen.
// nicht-konstante Version von B()
int& A::B(){
// Konstantheit hinzucasten
A const* obj_myself_but_const = static_cast< A const* >(this);
// konstante Methodenversion aufrufen
int const& result = obj_myself_but_const->B();
// Konstantheit vom Rückgabewert wegcasten
return const_cast< int& >(result);
Erläuterung:
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.
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.
class A{
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 vermutlich wegoptimieren.
Call by reference
[Bearbeiten]Im Gegensatz zu den vorangegangenen 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.
class A{
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 denn, 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.
Wir empfehlen inline
[Bearbeiten]In den Kapiteln über Funktionen und Headerdateien haben Sie bereits einiges über inline
-Funktionen erfahren. An dieser Stelle soll dieses Wissen nun auf den Gebrauch von inline
-Klassenmethoden übertragen werden. Das folgende kleine Beispiel zeigt eine Klasse mit einer inline
-Methode.
Das Schlüsselwort inline
steht vor der Methodendefinition, es kann auch in der Deklaration innerhalb der Klasse angegeben werden. Letzteres ist jedoch unübersichtlich und daher nicht zu empfehlen. Damit der Compiler den Methodenaufruf auch durch den Methodenrumpf ersetzen kann, steht die Methodendefinition ebenso wie die Klassendefinition in der Headerdatei und nicht in einer, eventuell zugehörigen, Implementierungsdatei. Wenn Sie eine Funktion direkt innerhalb einer Klassendefinition definieren, ist sie implizit inline
, da solche Funktionen nahezu immer sehr kurz sind und sich somit gut zur Ersetzung eignen.
class A{
public:
// methode ist implizit inline
int methode()const { return a_; }
private:
int a_;
};
Einige moderne Compilersuiten sorgen, je nach Einstellungen, dafür, dass Methoden inline
verwendet werden, auch wenn sie nicht als inline
deklariert wurden! In vielen Fällen können die Compiler ohnehin am besten selbst entscheiden, welche Funktionen gute inline
-Kandidaten sind und welche nicht.