C++/Tutorial: 10. Klassen und Objekte
Wir haben nun schon ziemlich viele Werkzeuge der Sprache kennen gelernt, aber immer noch keine Idee wie man so etwas wie den string Typ realisieren kann.
Wir wissen zwar, dass es irgendwie mithilfe von const char* gehen muss, aber nicht wie wir uns jetzt einen Typen draus basteln
Es handelt sich dabei um so genannte Objekttypen, dahinter stecken die Ideen der Objektorientierten Programmierung. OOP war vor ein paar Jahrzehnten ein Schlagwort in der Programmierung, heute ist es zwar längst nicht mehr der Inbegriff der modernen Programmierung, aber wird immer noch zum weitesten Teil angewand.
C++ war ursprünglich nur "C with Classes", mittlerweile hat es sich zu einer echten multiparadigmischen Sprache weiter entwickelt und ist vor allem in der generischen Programmierung noch sehr stark geworden(so ist auch die Standardbibliothek aufgebaut), dennoch liegen die Wurzeln der Sprache darin die prozedurale Programmiersprache C um Hilfsmittel(und nur Hilfsmittel, man kann in C sehr wohl objektorientiert Programmieren, die Sprache unterstützt es nur nicht direkt. Ein typischer Anfängerfehler ist es die Hilfsmittel (z.B. Klassen) mit der objektorientierten Programmierung an sich gleichzusetzen) zur objektorientierten Programmierung zu erweitern, den sogenannten Klassen.
Inhaltsverzeichnis
Objekte
Aber bevor wir uns mit den Hilfsmittel zur Objektorientierten Programmierung befassen wollen wir zunächst einmal rudimentär durchsprechen, was die Objektorientierte Programmierung im Ansatz ist und was ein Objekt ist. Die OOP findet ihre Analogie in der realen Welt und fasst alles als Objekt auf, ein Objekt hat Funktionen und Eigenschaften, wenn wir uns im Raum umsehen merken wir auch, dass das auf alle Objekte zutrifft, mir fällt spontan der Computerbildschirm auf, die Funktionen die er anbietet wären zum Beispiel einschalten oder drehen(Pivot-Funktion). Diese Funktionen ändern Eigenschaften des Objektes, wenn wir ihn einschalten, ändert sich die Eigenschaft so, dass der Bildschirm eingeschaltet ist und wenn wir ihn drehen ändert sich der Winkel, was ebenso eine Eigenschaft ist.
Wenn wir uns dieses Beispiel weiter betrachten werden wir bemerken, dass wir die Eigenschaften niemals direkt verändern, sondern das Objekt die Eigenschaften selbst verwaltet und wir nur durch Funktionen, sprich Interaktion mit dem Objekt die Eigenschaften modifizieren können und schon haben wir das Grundprinzip eines Objektes ganz gut aufgezeigt: Jedes Objekt verwaltet seinen eigenen Status autonom und der Programmablauf entsteht aus Interaktionen zwischen den Objekten. Sich darunter etwas vorzustellen ist nicht ganz so einfach, deswegen kommen wir jetzt mal zur Praxis.
Klassen
Es gibt verschiedene Ansätze, wie man Objektorientierte Programmierung unterstützt, am weitesten verbreitet hat sich das Prinzip der Klassen, ebenfalls recht verbreitet ist eine Prototypenbasierte Objektorientierte Programmierung, C++ bietet Klassen.
Klassen kann man als eine Art Rezept für Objekte ansehen, Richtlinien wie die Objekte auszusehen haben. Wenn wir eine Klasse definieren definieren wir damit einen Datentyp, wenn wir eine Variable vom Typ der Klasse erstellen, erstellen wir damit bereits ein "Objekt" dieses Types.
Also wir definieren wir eine solche Klasse? Klassen sind komplexe Sprachkonstrukte, es ist schwierig sich ohne Beispiel etwas darunter vorzustellen, von daher:
class monitor { public: void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; }; void monitor::turn_power_on() { m_power = true; } void monitor::pivot(int angle) { m_angle = angle; } void monitor::show() { //Tue irgendetwas sinnvolles, zeige ein Bild, wenn der Monitor an ist und richte dich dabei nach dem Winkel } int main() { monitor m; m.turn_power_on(); m.pivot(90); m.show(); }
Der Code oben besteht aus zwei Teilen, der Klassendefinition, in der wiederum Funktionen deklariert werden(das ist der class{...}; block), sowie der Definition der Funktionen(monitor::*), wie auch bei globalen Funktionen ist diese Trennung sinnvoll, wenn wir die Klasse in mehreren Dateien verwenden wollen(dazu kommt die Klassendefinition in den Header)
Okay, schauen wir uns einmal unsere Klasse an
class monitor { public: void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; };
Zunächst fällt auf, dass die Grundstruktur der Klassendefinition identisch mit der einer enum- oder structdefinition ist und mit dem Schlüsselwort class eingeleitet wird.
Dann fällt vielleicht auf, dass der Inhalt der Klasse in 2 Teile aufgeteilt ist public: und private:, der public: Teil ist das, auf das wir später von außen zugreifen können, mit m.show() z.B. auf die show-Funktion, auf den privaten Teil können wir von außen nicht zugreigen, m.m_angle geht nicht!(später mehr dazu)
Unser Monitor hat 3 Funktionen, über die wir mit ihm interagieren können, turn_power_on() um ihn einzuschalten, pivot(int angle) um ihn um den Winkel "angle" zu drehen und show() um dann auch ein Bild anzuzeigen. Der Monitor merkt sich seinen Status(der sein Verhalten bei show() beeinflusst!) mithilfe zweier privater Membervariablen "m_power", die besagt ob der Monitor eingeschaltet ist und m_angle, die speichert um wie viel Grad der Monitor gedreht ist.
Das reicht für uns schon um ein Objekt vom Typ monitor zu erzeugen, der Linker assoziiert die Funktionen des Objektes dann mit
void monitor::turn_power_on() { m_power = true; } void monitor::pivot(int angle) { m_angle = angle; } void monitor::show() { //Tue irgendetwas sinnvolles, zeige ein Bild, wenn der Monitor an ist und richte dich dabei nach dem Winkel }
show haben wir hier noch nicht definiert, wie sollen wir auch einen Code für ein derartiges Beispiel schreiben? Wichtig ist, dass show im Gegensatz zu turn_power_ob und pivot die Membervariablen erstens nicht verändert und sich zweitens aufgrund dieser immer anders verhält. turn_power_on und pivot ändern hingegen nur die Membervariablen des Objektes(Erinnerung: wie bei struct gelten die Variablen nicht Klassenweit sondern für jede Instanz der Klasse, also für jedes Objekt, zwei Monitorobjekte m1 und m2 haben beide für sich diese Variablen und die Funktionen ändern nur die Variablen des Objektes auf das sie aufgeruen werden).
Die Funktionsdefinition ist fast so wie die Definition einer freien Funktion, nur wird dem Name der Funktion monitor:: also allgemein KLASSENBEZEICHNER:: vorangestellt.
Objekte mögen zu dem Zeitpunkt wie structs mit Memberfunktionen und private: gesehen werden und so falsch ist das bisher auch gar nicht, in der Objektorientierten Programmierung mit C++ sind Klassen jedoch ein Schlüsselkonzept das ganze Programm wird auf ihnen aufgebaut, das ist ein Unterschied zu structs, die nur Daten zusammenfassen und ganz selten auch mal andere structs als Member haben.
Datenkapselung und Zugriffsmodifier
Nun gehen wir mal ein wenig auf die Details ein, wir haben gesagt, dass Variablen üblicherweise nicht von außen modifiziert werden, Grundlage dieses Prinzips ist das Prinzip der Datenkapselung. Sinn dahinter ist, dass es egal ist, wie das Objekt den Monitor nun anschaltet, es kann sich auch im Laufe der Zeit ändern, Hauptsache das Verhalten des Objektes bleibt gleich(das Problem mit öffentlichen Membervariablen ist also ähnlich wie bei globalen Variablen). Den Anwender einer Klasse interessiert also nur das "Interface", welches hier aus den 3 Funktionen besteht und sonst aus allem besteht was "public" ist. public: und private: sind zwei von drei Zugriffsspezifizierer in C++, die Reihenfolge von ihnen ist egal, wichtig ist nur, dass public: und private: alle nachfolgenden Member, sowohl Funktionen, als auch Variablen, mit den entsprechenden Zugriffsrestriktionen versehen. Es ist nicht einmal nötig einen Zugriffsspezifierer zu haben, per Default sind alle Member innerhalb einer Klasse private:
struct
Und da kommen wir auch schon zurück zu unserer struct. In der Tat sind structs fast identisch mit Klassen. Bisher haben wir allerdings nur die POD-Strukturen kennen gelernt, das sind Strukturen, wie sie auch schon in C möglich waren, die nur Daten halten, in der Tat können structs in C++ aber identisch mit Klassen verwendet verwendet werden, es gibt nur einen einzigen Unterschied: in einer struct sind alle Member per Default public: und deswegen werden structs auch hauptsächlich für PODs, Funktoren etc. verwendet, also immer dann, wenn alle Member public sein sollen.
friend
Wie bereits erwähnt können wir mit private: den Zugriff auf die Member so beschränken, dass nur andere Member drauf zugreifen können, wir können aber auch Ausnahmen spezifizieren,
externe Funktionen und Klassen die Zugriff auf die privaten Member haben, das bricht allerdings die Datenkapselung und ist daher zu vermeiden. Dieser Bruch wird zwar meistens überbewertet, da Member und Friends hier genau gleich stark "brechen", aber man sollte es dennoch vermeiden, zumal es sehr selten notwendig ist.
Also wie funktioniert friend? Wir deklarieren eine Funktion oder Klasse in der Klasse einfach erneut und stellen der Deklaration ein friend voran, wollen wir z.B. aus der mainmethode auf die privaten Member zugreifen können ginge das so:
class monitor { friend int main(); public: void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; }; int main() { monitor m; m.m_power = true; //wir schlagen den Bildschirm ein und ziehen die Drähte zusammen, die den Bildschirm anschalten anstatt einfach auf den Knopf zu drücken. }
Es könnte auch friend class user; heißen, wenn man aus einer ganzen Klasse namens "user" auf die privaten Member zugreifen können soll.
Der this Pointer
Eine spezielle Variable, die in jeder Klasse automatisch existiert ist "this". Es handelt sich dabei um einen Pointer auf das eigene Objekt. Wir könnten zum Beispiel auch schreiben
this->m_angle = angle;
Das ist aber überflüssig, da immer erst im eigenen Objekt gesucht wird. Sinn macht this dann, wenn sich das Objekt selbst irgendwo übergeben wird. Wie überall sonst auch, sollte man hier wenn möglich auf Zeiger verzichten und lieber mit dem Objekt selbst bzw. Referenzen darauf arbeiten (also einfach den this-Zeiger dereferenzieren *this).
Konstruktoren
Mit dem bisherigen Wissen können wir aber noch lange nicht string nachschreiben und in der Tat können Klassen noch viel mehr, als nur das bisher beschriebene.
Zunächst nehmen wir erstmal unser fiktives Beispiel, wir können den Monitor anschalten und ihn drehen, aber wir können bisher nicht sagen, dass er von anfang an aus sein soll, oder dass er um 0° gedreht sein soll. Was wir also wollen ist die Variablen beim anlegen eines Objektes gleich zu initialisieren, außerdem wollen wir eventuell bei instanzierung der Klasse(anlegen des Objektes) auch gleich Code ausführen, wir benötigen also einen sogenannten "Konstruktor" oder Kurz "Ctor".
Der Konstruktor wird aufgerufen, wenn das Objekt angelegt wird. Syntaktisch sieht er aus wie eine Funktion mit dem Namen der Klasse und ohne Rückgabetyp, wenn wir also immer einen Text ausgeben wollen, wenn ein Objekt einer Klasse (nennen wir sie bar) neu angelegt wird, tun wir das folgendermaßen:
#include <iostream> using namespace std; class bar { public: bar(); }; bar::bar() { cout << "ein Objekt der Klasse \"bar\" wurde angelegt" << endl; } int main() { bar a, b, c; }
Initialisierungslisten
Kommen wir also zurück auf unseren Monitor, wir könnten nun einfach den Membervariablen im Konstruktor false und 0 zuweisen um sicherzustellen, dass der Monitor von Beginn an aus und um 0° gedreht ist(wir könnten dann auch unsere Funktionen realistischer modellieren, wir schalten einen Monitor nicht nur an, wir drücken auf den Powerknopf, wenn er aus ist, geht er an und umgekehrt), das würde in dem Fall auch problemlos funktionieren, verschwendet aber Prozessorzeit und kann in gewissen Fällen zu Problemen führen. Wenn wir Membervariablen erst im Konstruktor belegen, werden sie zunächst defaultinitialisiert und dann weisen wir ihr noch einen Wert zu, es wäre als würden wir schreiben
int i; i = 4;
Wir wollen unsere Membervariablen aber initialisieren! Dazu gibt es die sogenannte "Initialisierungsliste". Diese befindet sich zwischen dem Konstruktorkopf und dem Konstruktorkörper und beginnt mit einem Doppelpunkt. Anschließend werden von Kommata getrennt alle Membervariablen initialisiert indem der Bezeichner gefolgt vom Wert in Klammern gelistet wird. Klartext, das ganze sieht so aus:
class monitor { public: monitor(); void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; }; monitor::monitor() : m_power(false), m_angle(0) {}
Parameter im Konstruktor
Okay, zurück zu unserem Wunsch string nachbauen zu können. Es fällt uns gleich ein signifikanter Unterschied auf: string nimmt einen Wert!
Wir weisen String eine Variable zu. Auch hier ist die Syntax des Konstruktors ähnlich der einer Funktion, der Konstruktor kann wie jede Funktion Parameter nehmen.
Wir könnten z.B. einen Konstruktor erstellen, der es erlaubt beim Erstellen unserer Monitorklasse anzugeben um wie viel Grad der Monitor gedreht sein soll und ob er an sein soll:
class monitor { public: monitor(bool is_on = false, double angle = 0); void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; }; monitor::monitor(bool is_on, double angle) : m_power(is_on), m_angle(angle) {}
Wie man sehen kann ist die Syntax wie bei einer Funktion. Wir können wie bei Funktionen Defaultparameter angeben etc.. nur wie sieht es mit der Instanzierung des Objekts aus?
monitor m; //Defaultparameter monitor m2(true, 90); //mit Parametern monitor m3 = true; //bei nur einem Parameter auch möglich
Sieht also schon sehr verständlich aus und ist identisch mit der Art und Weise wie wir jeder anderen Variable etwas zuweisen, neu ist nur m2, das wir auch mehrere Parameter in dieser Form angeben können.
Weiterhin interessant ist, dass man auch Konstruktoren überladen kann. Wenn wir mehrere Konstruktoren mit verschiedenen Parametern erstellen können wir das Objekt folglich auch auf verschiedene Art und Weisen instanzieren. Auch ist interessant, dass wir, wenn wir keinen Konstruktor haben unser Objekt dennoch parameterlos instanzieren können, wenn wir aber einen parametrisierten Konstruktor haben dies nicht mehr geht. Das liegt daran, dass C++ immer und nur wenn kein Konstruktor vorliegt einen Standardkonstruktor anlegt, der keine Parameter nimmt und nichts tut.
Kopierkonstruktor
Kommen wir zu einem weiteren Spezialfall eines Konstruktors: dem Kopierkonstruktor.
Es fällt auf, dass wir ein Objekt m jederzeit mit einem anderen Objekt der selben Klasse initialisieren können. Wir könnten also im obigen Beispiel ein m4 deklarieren und es mit m initialisieren, m4 wäre dann wie bei string und allen anderen Typen eine Kopie von m. Was passiert also?
C++ legt standardmäßig noch einen weiteren Konstruktor an: den Kopierkonstruktor! Dieser kopiert einfach alles aus einem Objekt in das neue. Es ist auch möglich den Kopierkonstruktor zu überschreiben und der Klasse dadurch ein eigenes Kopierverhalten zu nehmen. Der Kopierkonstruktor nimmt immer nur eine konstante Referenz der selben Klasse, in unserem Falle also
class monitor { public: monitor(bool is_on = false, double angle = 0); monitor(monitor const& that); void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; }; //... monitor::monitor(monitor const& that) : m_power(that.m_power), m_angle(that.m_angle) { cout << "Hilfe, ich bin eine Kopie" << endl; }
Ausgaben oder sonstiges Verhalten im Kopierkonstruktor sollte man höchstens zum Debuggen(finden von Fehlern) verwenden, ansonsten sollte sich der Kopierkonstruktor immer so verhalten, dass das Objekt kopiert wird. Nehmen wir Beispielsweise einen vector, hinter vector steckt, wie zu erwarten, ein dynamisches Array. Wie wir wissen sind dynamische Arrays durch Pointer realisiert. Man muss jetzt zwischen einer "Deep Copy" und einer "Shallow Copy" unterscheiden, letztere wird durch den Defaultkopierkonstruktor angelegt: der Pointer wird kopiert. Das heißt aber auch, wenn wir in einem vector ein Element ändern, verändert es sich auch im anderen vector(wir haben ja nur den Pointer kopiert, beide zeigen auf die gleiche Adresse). Eine tiefe Kopie wäre hier sinnvoller: wir wollen in dem neuen Objekt ein neues Array anlegen und dann jedes Element einzeln kopieren, das können wir im Kopierkonstruktor natürlich problemlos tun.(Ein Beispiel findet sich an späterer Stelle) Wann immer man den Kopierkonstruktor überschreibt sollte man auch den Zuweisungsoperator überschreiben und häufig auch den Destruktor(das ist eine Faustregel zur Unterstützung korrekt zu programmieren und hat keinen technischen Hintergrund), zu diesen beiden Konstrukten kommen wir jetzt.
Zuweisungsoperator
Eigentlich kommt das Thema über Operatorenüberladung erst später, aber für diesen Operator werde ich eine Ausnahme machen: Wann immer wir einer Variable später noch ein neues Objekt zuweisen, wird nicht der Kopierkonstruktor aufgerufen, sondern der Zuweisungsoperator. Dieser ist dem Kopierkonstruktor an sich recht ähnlich und deswegen kann man davon ausgehen, dass immer wenn der Kopierkonstruktor überschrieben wird auch der Zuweisungsoperator überschrieben werden muss. Den Zuweisungsoperator überschreibt man als ganz normale Funktion mit dem Namen operator=, die als Argument ein Objekt beliebigen Types nimmt und beliebigen Rückgabetyp hat. Sinnvollerweise nimmt die Funktion eine konstante Referenz auf ein Objekt des selben Types und gibt eine Referenz auf *this zurück(Diesem Verhalten folgen auch die Standardtypen. wenn wir ein int i haben können wir i einen Wert zuweisen i = 2 und es im selben Ausdruck noch als (neues) i verwenden):
class monitor { public: monitor(bool is_on = false, double angle = 0); monitor(monitor const& that); monitor& operator=(monitor const& that); void turn_power_on(); void pivot(int angle); void show(); private: bool m_power; int m_angle; }; monitor& monitor::operator=(monitor const& that) { m_power = that.m_power; m_angle = that.m_angle; cout << "Zuweisung erfolgt" << endl; return *this; }
Destruktoren
Und nun zum letzten der großen Drei: Der Destruktor auch "DTor" genannt.
Der Destruktor ist das Gegenstück zum Konstruktor, er wird ausgeführt, wenn das Objekt gelöscht wird, zum Beispiel weil der Scope des Objektes verlassen wird.
Im Gegensatz zum Konstruktor nimmt dieser natürlich keine Parameter, denn wir rufen ihn ja nicht direkt auf. Der Destruktor ähnelt dem parameterlosen Konstruktor syntaktisch, bis auf, dass ihm ein ~ vorangestellt wird. Der Destruktor ist dazu da Resourcen freizugeben, die noch freigegeben werden müssen, als Resource kennen wir bisher die meistverwewendete: dynamisch allokierte Objekte, diese werden im Destruktor mit delete wieder gelöscht(das muss man immer dann selbst tun, wenn man keine Smartpointer o.ä. verwendet).
Jetzt da wir die großen Drei kennen werde ich ein etwas sinnvolleres Beispiel anführen: Ein Objekt, das ein dynamisch alloziertes Objekt(nehmen wir, auch wenns sinnlos ist, einen int) hält, den Speicher selbst verwaltet(normalerweise würde ich hier Smartpointer nehmen, zur Demonstration aber rohe Zeiger), eine Wertesyntax hat(im Gegensatz zu Smartpointern tiefe Kopien erzeugt.) und auf Anfrage eine Referenz auf das Objekt zurück gibt, kurz: wir schreiben einen Wrapper um ein dynamisch allokiertes Objekt, zunächst nur um int:
class dyn_wrapper { public: dyn_wrapper(int val); dyn_wrapper(dyn_wrapper const& that); dyn_wrapper& operator=(dyn_wrapper const& that); ~dyn_wrapper(); int& ref(); private: int* m_dyn_object; }; //Im Konstruktor allokieren wir uns ein int dynamisch und weisen ihm den übergebenen Wert zu: dyn_wrapper::dyn_wrapper(int val) : m_dyn_object(new int(val)) {} //Im Kopierkonstruktor machen wir eine tiefe Kopie von dyn_object, wenn wir unser Objekt kopieren, wird eine richtige, tiefe Kopie erzeugt: //Wir legen ein neues int dynamisch an und weisen ihm den Inhalt des alten ints zu dyn_wrapper::dyn_wrapper(dyn_wrapper const& that) : m_dyn_object(new int(that.ref())) {} //Im Zuweisungsoperator passiert in etwa das Selbe dyn_wrapper& dyn_wrapper::operator=(dyn_wrapper const& that) { m_dyn_object = new int(that.ref()); } //Im Destruktor wird der Speicher des ints wieder freigegeben, somit muss sich der Benutzer unserer Klasse nicht mehr drum kümmern, alles wird von dyn_wrapper verwaltet dyn_wrapper::~dyn_wrapper() { delete m_dyn_object; m_dyn_object = NULL; } //ref gibt eine Referenz auf das int zurück, sodass der Benutzer unserer Klasse auch damit arbeiten kann int& dyn_wrapper::ref() { return *m_dyn_object; }
Nested Typed
Klassen müssen in C++ nicht an globaler Stelle definiert werden, sondern können unter anderem auch verschachtelt werden. Eine Klasse und jeder andere Typ auch kann in einer anderen Klasse definiert werden, zugegriffen wird dann über den Scope-Operator ::, natürlich nur sofern die Klasse nicht private: ist.
class A { public: class B { ... }; }; //... A::B b;
Noch ein Wort zur objektorientierten Programmierung
Und das wars auch schon zu den Grundlagen von Klassen. Es gibt noch viele weitere Konzepte und wir werden von nun an viel mit Klassen zu tun haben, denn sie sind die Grundlage objektorientierter Programmierung in C++. Objektorientierte Programmierung ist nicht nur die Nutzung von Klassen, sondern eine Denkweise, die einem das Programmieren insbesonders größerer Anwendungen erleichtert, ich empfehle einfach zu versuchen in Objekten zu denken. Wir schreiben nicht einen festen Programmablauf, wir schreiben Klassen und definieren deren Verhalten, unsere Welt besteht also aus Objekten.
Natürlich ist die objektorientierte Programmierung nicht für jedes Problem das richtige Mittel, bei kleinen Programmen ist prozedurale Programmierung wie bisher oftmals einfacher und objektorientierte Programmierung kommt mit parallelen Aufgaben, sogenanntem "Multithreading" nur sehr schlecht klar, was der Grund ist warum funktionale Sprachen(wie z.B. Haskell) wieder derart im Kommen sind, aber die objektorientierte Programmierung gehört zu den Grundlagen, die ein Programmierer heutzutage einfach können muss und es hilft wirklich!