C++ Tutorial

Klassen und Objekte

Einführung

Eine der Grundideen der OOP ist das Zusammenfassen von Daten und der sie bearbeitenden Funktionen in einem in der jeweiligen Programmiersprache geeigneten Konstrukt. Und unter C++ ist dieses Konstrukt die Klasse.

Eine Klasse ist ein anwenderdefinierter Datentyp, der (vereinfacht ausgedrückt) Daten und Funktionen vereint. Die Daten einer Klasse werden als deren Eigenschaften bezeichnet und die Funktionen als Methoden oder Memberfunktionen. Alle Eigenschaften und Methoden einer Klasse werden als deren Member bezeichnet. Somit besitzt eine Klasse Member, die sich aus Eigenschaften und Methoden zusammensetzen.

Ein Objekt ist die Instanziierung eines Datums vom Typ einer Klasse. Angenommen wir hätten eine Klasse Window definiert und folgende Anweisung geschrieben:

Window myWindow;

Dann ist myWindow eine Instanz der Klasse Window und man sagt: myWindow ist ein Objekt vom Typ Window. Von einer Klasse können beliebig viele Objekte instanziiert werden, welche alle die gleichen Eigenschaften und Methoden besitzen. Die Inhalte der Eigenschaften sind dabei in der Regel aber unterschiedlich.

Klasse

C++ kennt 3 Klassentypen: struct, class und union. Auf den Unterschied zwischen den Klassentypen struct und class kommen wir gleich zu sprechen und der union ist ein eigenes Kapitel gewidmet.

Zum Einstieg bilden wir Schritt für Schritt einmal ein Fenster einer fiktiven grafischen Oberfläche in einer Klasse ab.

Zunächst benötigt jede Klasse einen Rahmen, in dem die Eigenschaften und Methoden zusammengefasst werden. Dieser Rahmen besteht aus dem Schlüsselwort class oder struct, gefolgt einem eindeutigen Namen für die Klasse (im Beispiel Window). Anschließend folgt ein Block {...}, der mit einem Semikolon abgeschlossen wird.

class Window
{
   ... // Hier folgen gleich die
   ... // Eigenschaften und Methoden
};

Im nächsten Schritt werden der Klasse die Member innerhalb des Blocks {...} hinzugefügt. Sinnvollerweise wird mit den Eigenschaften begonnen. In der Praxis besitzt ein Fenster sicher eine Reihe von Eigenschaften, für das Beispiel soll das Fenster nur eine Position und Größe besitzen.

1: class Window
2: {
3:    int xPos, yPos;               // Position
4:    unsigned int width, height;   // Groesse
5: };

Für die Eigenschaften einer Klasse sind alle bisher bekannten Datentypen zulässig; so ist es zum Beispiel möglich, innerhalb einer Klasse als Eigenschaft eine weitere Klasse einzuschließen. Solche eingeschlossenen Klassen werden später gesondert behandelt, da sie bestimmte Anforderungen erfüllen müssen.

Ebenfalls können die Eigenschaften bei ihrer Definition mit einem Wert initialisiert werden.

1: class Window
2: {
3:    int xPos=0, yPos=0;                   // Position
4:    unsigned int width=640, height=480;   // Groesse
5:    // Konstante fuer Standard-Farbe
6:    const int DEFCOLOR = 0xFF0000;
7: };

Um die Eigenschaften ändern zu können, werden Methoden eingesetzt. Methoden werden innerhalb der Klasse definiert oder zumindest deklariert.

Welche Methoden notwendig sind, ergibt sich aufgrund der Eigenschaften fast von selbst. Zum einen wird eine Methode zum Verschieben des Fensters benötigt. Zum anderen soll das Fenster in der Größe verändert werden können.

1: class Window
2: {
3:    int xPos, yPos; // Position
4:    unsigned int width, height; // Grösse
5:    // Position verändern
6:    void Move(int x, int y);
7:    // Grösse verändern
8:    void Size(unsigned int w, unsigned int h);
9: };

Anstatt die Datentypen bei den Methoden direkt anzugeben, hätten sie auch mittels des decltype() Operators bestimmt werden können:

void Move (decltype(xPos) x, decltype(yPos) y);

Die Gesamtheit aller Methoden einer Klasse wird als deren Schnittstelle (Interface) bezeichnet. Bei einer 'sauberen' Implementierung der Klasse wird nur über diese Methoden auf die Eigenschaften zugegriffen.

Beachten Sie bitte, dass in der obigen Klassendefinition die Methoden nur deklariert sind. Wie Methoden definiert werden, sehen wir uns gleich an.

Objekte

Ein Objekt ist die Instanziierung einer Klasse. Dies erfolgt analog zur bisherigen Definition einer Variablen, d.h. zuerst folgt der Datentyp des Objekts, also die Klasse, und anschließend der Objektname.

Der vollständige Datentyp eines Objekts besteht aus dem Schlüsselwort class bzw. struct (je nach Klassentyp) und dem Klassennamen. Bei Eindeutigkeit kann das Schlüsselwort class bzw. struct weggelassen werden, so wie im Beispiel bei der zweiten Objektdefinition dargestellt. Diese zweite Form der Objektdefinition ist die in der Praxis gängigste.

1: // Klassendefinition
2: class Window
3: {
4:    ... // Member
5: };
6:
7: // Objekte vom Typ class Window definieren
8: class Window firstWin;
9: Window secondWin;

Beide Fensterobjekte besitzen die zwar die gleichen Eigenschaften, diese können (und werden in der Regel) unterschiedliche Inhalte haben.

Es können aber nicht nur einzelne Objekte definiert werden, sondern ebenso Objektfelder. Die Definition eines Objektfeldes erfolgt analog zur Definition eines Feldes mit intrinsischen Daten.

1: // Klassendefinition
2: class Window
3: {
4:    ... // Member
5: };
6:
7: // Objektfeld definieren
8: Window winArray[10];

Definition von Methoden

Innerhalb der Klasse

Wird eine Methode innerhalb der Klasse definiert, erfolgt die Definition prinzipiell gleich wie die Definition einer normalen Funktion.

1: class Window
2: {
3:     ... // Eigenschaften
4:     void Move(int x, int y)
5:     {
6:        ... // Fenster verschieben
7:     }
8:     void Size(unsigned int w, unsigned int h)
9:     {
10:       ... // Grösse verändern
11:    }
12: };

Erhält eine Methode einen Parameter vom Typ einer Eigenschaft, kann wieder der decltype() Spezifizierer eingesetzt werden. Dies bedeutet zwar etwas mehr Schreibarbeit, stellt aber sicher, dass der Datentyp des Parameters immer zum Datentyp der Eigenschaft passt.

1: class Window
2: {
3:    int xpos, ypos;
4:    ...
5:    // Definition der Methode
6:    void Move(decltype(xpos) x, decltype(ypos) y)
7:    {
8:       ...
9:    }
10: };

Außerhalb der Klasse

Innerhalb der Klasse wird die Methode nur deklariert. Bei der Definition außerhalb der Klasse ist die Methode der Klasse zuzuordnen. Dazu wird nach dem Returntyp und vor dem Namen der Methode der Klassenname gefolgt vom Gültigkeitsbereichsoperator :: angegeben.

1: class Window
2: {
3:    ... // Member
4:    // Deklaration der Methoden
5:    void Move(int x, int y);
6:    void Size(unsigned int w, unsigned int h);
7: };
8:
9: // Definition der Methode Move()
10: void Window::Move(int x, int y)
11: {
12:    ... // Fenster verschieben
13: }
14: // Definition der Methode Size()
15: void Window::Size(unsigned int w, unsigned int h)
16: {
17:    ... // Grösse verändern
18: }

Da bei der Definition der Methoden außerhalb der Klasse der Klassenname mit angegeben werden muss, können verschiedene Klassen Methoden mit gleichem Namen besitzen, ohne dass dies zu Namenskonflikten führt.

Die Praxis

Sehen wir uns an, wie Klassen und deren Methoden in der Praxis definiert werden.

Vor C++20 erfolgte in der Regel die Klassendefinition in einer Header-Datei und die Definitionen der Methoden in einer Quellcode-Datei.

1: // Datei window.h
2: // Klassendefinition
3: class Window
4: {
5:     int xPos, yPos;             // Position
6:     unsigned int width, height; // Grösse
7:     // Verschieben
8:     void Move(int x, int y);
9:     // Grösse ändern
10:    void Size(unsigned int w, unsigned int h);
11: };
1: // Datei window.cpp
2: // Einbinden der Header-Datei
3: #include "window.h"
4:
5: // Definition der Methode Move()
6: void Window::Move(int x, int y)
7: {
8:    ... // Fenster verschieben
9: }
10: // Definition der Methode Size()
11: void Window::Size(unsigned int w, unsigned int h)

12: {
13:    ... // Grösse verändern
14: }

Ab C++20 ist mit der Einführung von Modulen diese Aufteilung nicht mehr notwendig. Die Klassendefinition sowie die Definitionen der Methoden kann nun in einem Modul erfolgen.

1: // Modul window exportieren
2: export module Window;
3:
4: // Klassendefinition exportieren
5: export class Window
6: {
7:     int xPos, yPos;             // Position
8:     unsigned int width, height; // Grösse
9:     // Verschieben
10:    void Move(int x, int y);
11:    // Grösse ändern
12:    void Size(unsigned int w, unsigned int h);
13: };
14:
15: // Definition der Memberfunktion Move()
16: void Window::Move(int x, int y)

17: {
18:    ... // Fenster verschieben
19: }
20: // Definition der Memberfunktion Size()
21: void Window::Size(unsigned int w, unsigned int h)
22: {
23:    ... // Fenstergröße setzen;
24: }

Beachten Sie, dass die Klasse exportiert wird und nicht die einzelnen Methoden.

Zugriffsrechte in Klassen

Klassen besitzen ein Standard-Zugriffsrecht, das den Zugriff auf die Member regelt. Das heißt, je nach Zugriffsrecht kann der Zugriff auf ein Member über ein Objekt erlaubt sein oder nicht. Sinn und Zweck dieser Zugriffsbeschränkung liegt darin, den Zugriff auf die Member nur über eine definierte Schnittstelle freizugeben. So enthält die Klasse Window unter anderem die Eigenschaften width und height. Ohne Zugriffskontrolle könnte der Anwender z.B. die Eigenschaft width auf den nicht plausiblen Wert -1 setzen, was Probleme beim Darstellen des Fensters geben wird. Wird der Zugriff auf die Eigenschaften aber kontrolliert, können beim Setzen der Eigenschaften Plausibilitätsprüfungen durchgeführt werden und fehlerhafte Werte abgewiesen werden

Um den Zugriff auf Member einer Klasse zu verhindern, d.h. die Member zu schützen, wird innerhalb der Klasse vor die zu schützenden Member die Anweisung private: gestellt. Auf alle Member die nach dieser Anweisung folgen kann nicht über ein Objekt zugegriffen werden. Methoden der eigenen Klasse haben aber immer Zugriff auf alle Member der eigenen Klasse.

1: class Window
2: {
3: private:
4:    int xPos, yPos;             // Position
5:    unsigned int width, height; // Grösse
6:    // Verschieben
7:    void Move(int x, int y);
8:    // Grösse verändern
9:    void Size(unsigned int w, unsigned int h);
10: };

Beachten Sie den Doppelpunkt nach der Angabe des Zugriffsrechts!

Da eine Klasse mit nur geschützten Member in der Regel sinnlos ist, muss dieser Schutz wieder aufgehoben werden können. Dies erfolgt durch die Anweisung public:. Auf alle Member die nach dieser Anweisung folgen kann über ein Objekt der Klasse zugegriffen werden. Im nachfolgenden Beispiel sind damit die Eigenschaften der Klasse Window gegen den direkten Zugriff geschützt, während auf die Methoden zugegriffen werden kann.

1: class Window
2: {
3: private: // Ab hier alles geschuetzt
4:     int xPos, yPos;             // Position
5:     unsigned int width, height; // Grösse
6: public: // Ab hier alles oeffentlich
7:     // Verschieben
8:     void Move(int x, int y);
9:     // Grösse verändern
10:    void Size(unsigned int w, unsigned int h);
11: };

Soll zum Beispiel die Fenstergröße verändert werden, ist die Methode Size() aufzurufen, denn width und height sind für den direkten Zugriff geschützt. Und die Methode Size() kann eine Plausibilitätsprüfung der übergebenen Daten durchführen.

Wie erwähnt ist das Zugriffsrecht innerhalb einer Klasse so lange gültig, bis es durch ein anderes Zugriffsrecht überschrieben wird. Die Anzahl und Reihenfolge der private- und public-Anweisungen innerhalb einer Klasse ist beliebig.

In der Praxis hat es sich als sinnvoll erwiesen, die Eigenschaften einer Klasse, so weit wie möglich, innerhalb eines private-Bereichs zu definieren, um den Zugriff darauf nur über die Schnittstelle der Klasse (Methoden) zuzulassen. Des Weiteren sollte der Klassenaufbau strukturiert erfolgen. Geben Sie z.B. zuerst alle private-Eigenschaften, dann alle private-Methoden, dann alle public-Eigenschaften und zum Schluss die public-Methoden an.

Standard-Zugriffsrechte

Je nach Klassentyp (struct, union und class) besitzen Klassenmember vordefinierte Zugriffsrechte:

  • struct/union: Voreingestellt ist das Zugriffsrecht public.
  • class: Voreingestellt ist das Zugriffsrecht private.

Dieses voreingestellte Standard-Zugriffsrecht ist der einzige Unterschied zwischen den Klassentypen struct und class!

Der dritte Klassentyp union wird im nächsten Kapitel behandelt.

Zugriffsrechte zwischen Objekte der gleichen Klasse

Wird einer Methode ein Objekt ihrer Klasse übergeben, kann die Methode auch auf die geschützten Member des übergebenen Objekts zugreifen. Dieser Sachverhalt spielt bei dem später beschriebenen Kopierkonstruktor eine entscheidende Rolle.

Das nachfolgende Beispiel zeigt die u.a. um die Methode CopyData() erweiterte Klasse Window.

1: class Window
2: {
3:     // Geschuetzte Eigenschaften
4:     short width, height;
5:     std::string title;
6:     // Oeffentliche Methoden
7: public:
8:     void Size(short w, short h);
9:     void SetTitle(const char *const pT);
10:    void CopyData(const Window& source);
11: };
12: // Definition der Methode CopyData()
13: void Window::CopyData(const Window& source)
14: {
15:    width = source.width;    // Die Zugriffs-Syntax wird
16:    height = source.height;  // wird gleich erklaert
17:    title = source.title;
18: }

Obwohl die Eigenschaften des übergebenen Objekts gegen den direkten Zugriff geschützt sind (private), kann die Methode CopyData() auf die geschützten Eigenschaften des übergebenen Objekts zugreifen. Wie gesagt, dies funktioniert nur, wenn das übergebene Objekt derselben Klasse angehört wie die aufgerufene Methode.

Und das war's vorläufig zu den Zugriffsrechten. Das dritte (und letzte) Zugriffsrecht protected wird später behandelt, das es nur im Zusammenhang mit abgeleiteten Klassen eine Rolle spielt.

Zugriff auf Klassenmember

Zugriff aus Methoden

Innerhalb einer Methode kann auf alle Member der eigenen Klasse direkt zugegriffen werden, unabhängig davon, ob diese public oder private sind.

1: // Klassendefinition
2: class Window
3: {
4: private:
5:    int xPos, yPos;
6:    unsigned int width, height;
7: public:
8:    void Move(decltype(xPos) x, decltyp(yPos) y);
9:    void Size(decltype(width) w, decltype(height) h);
10: };
11: // Definition der Methoden
12: void Window::Move(decltype(xPos) x, decltype(yPos) y)
13: {
14:    // Position setzen
15:    xPos = x;
16:    yPos = y;
17: }
18: void Window::Size(decltype(width) w, decltype(height) h)
19: {
20:    // Grösse setzen
21:    width = w;
22:    height = h;
23: }

Mit obiger Klassendefinition könnte zum Beispiel eine weitere Methode WinPos() zum Verändern der Position und Größe eines Fensters wie folgt implementiert werden:

1: void Window::WinPos(decltype(xPos) x, decltype(yPos) y,
2:                     decltype(width) w, decltype(height) h)
3: {
4:    Move(x, y);    // Fenster verschieben
5:    Size(w, h);    // Fenster in der Größe ändern
6: }

Die Wiederverwendung von bestehendem Code, hier der Methoden Move() und Size(), ist eines der erklärten Ziele der OOP.

Zugriff nicht über Methoden

Hierbei ist zu beachten, dass außerhalb von Methoden nur der Zugriff auf die public-Member einer Klasse möglich ist.

Auf die Member einer Klasse kann nur über ein entsprechendes Objekt zugegriffen werden. Ausnahme: statische Member, die später in einem eigenen Kapitel beschrieben werden.

Der Zugriff auf ein Member erfolgt durch Angabe des Objekts, gefolgt vom Punktoperator '.' und dem Namen des jeweiligen Members.

1: // Klassendefinition
2: class Window
3: {
4:    int xPos, yPos;
5:    unsigned int width, height;
6: public:
7:    void Move(decltype(xPos) x, decltype(yPos) y);
8:    void Size(decltype(width) w, decltype(height) h);
9: };
10: ...
11: // Objektdefinitionen
12: Window myWin, yourWin;
13: // Zugriff auf public-Member
14: myWin.Move(10,200);
15: yourWin.Size(640,480);

Im Beispiel werden zwei Objekte myWin und yourWin definiert. Anschließend werden die Positionseigenschaften von myWin durch den Aufruf von Move() geändert. Diese Änderung der Positionseigenschaft von myWin hat keine Auswirkung auf die Positionseigenschaft von yourWin. Im Anschluss daran werden die Größeneigenschaften von yourWin durch den Aufruf von Size() geändert.

Zugriff über Objektzeiger

Ein Objektzeiger verweist, wie der Name sagt, auf ein Objekt. Um über einen Objektzeiger auf eine Eigenschaft zuzugreifen oder eine Methode aufzurufen, folgen nach dem Objektzeiger der Zeigeroperator -> und der Namen der Eigenschaft oder Methode. Wichtig dabei ist, dass der Zeiger dabei einen gültigen Verweis enthält. Dieser Punkt mag auf den ersten Blick trivial erscheinen, ist aber ein häufiger Fehlerfall.

1: // Klassendefinition
2: class Window
3: {
4:    int xPos, yPos;
5:    unsigned int width, height;
6: public:
7:    void Move(decltype(xPos) x, decltype(yPos) y);
8:    void Size(decltype(width) w, decltype(height) h);
9: };
10: // Objektdefinitionen
11: Window myWin, yourWin;
12: ...
13: // Objektzeigerdefinition und Initialisierung
14: auto *pWin = &myWin;
15: // Indirekter Zugriff auf myWin-Member
16: pWin->Move(10,200);
17: // Zeiger umsetzen auf yourWin-Objekt
18: pWin = &yourWin
19: // Indirekter Zugriff auf yourWin-Member
20: pWin->Move(100,0);

Einem definierten Objektzeiger können im weiteren Verlaufe des Programms beliebig oft Verweise auf Objekte desselben Typs zugewiesen werden.

Zugriff auf enum-Eigenschaften

Beim Zugriff auf enum-Eigenschaften in Klassen gilt es einiges zu beachten. Zum einen gehören sie zur Klasse. Werden die Enumeratoren außerhalb einer Methode verwendet (z.B. als Parameter beim Aufruf einer Methode), ist der Enumerator vollständig zu qualifizieren, d.h es sind der Klassenname, der enum-Datentyp und der Enumerator anzugeben.

Des Weiteren muss der enum-Datentyp public sein, da ansonsten kein Zugriff darauf möglich ist. Dabei sollte aber darauf geachtet werden, dass lediglich der enum-Datentyp als public deklariert ist und nicht die mit ihm verknüpfte enum-Eigenschaft.

1: // Klassendefinition
2: class Window
3: {
4: public:
5:     // enum-Datentyp definieren
6:     enum class Style {FRAME, CLOSEBOX, SYSMENU};
7: private:
8:     // enum-Eigenschaft definieren
9:     Style winStyle;
10: public:
11:    void DoAnything(decltype(winStyle) s);
12: };
13: // Objekt definieren
14: Window myWin;
15: ...
16: // Aufruf einer Methode mit einem enum-Parameter
17: myWin.DoAnything(Window::Style::FRAME);

const-Methoden und mutable Member

const-Methoden besitzen die Eigenheit, dass sie keine Eigenschaften ändern. Um eine Methode als const-Methode zu kennzeichnen, wird nach der Parameterklammer das Schlüsselwort const angegeben:

RTYP MName ([Parameter]) const;

Nachfolgend ein Beispiel für die Anwendung einer const-Methode. Die Methode GetXPos() liefert lediglich die X-Position des Fensters zurück und besitzt damit nicht die Notwendigkeit, Eigenschaften zu ändern.

1: // Klassendefinition
2: class Window
3: {
4:    int xPos, yPos;
5: public:
6:    auto GetXPos() const;
7:    // weitere Member der Klasse
8:    ...
9: };
10: // Definition der Memberfunmtion GetXPos()
11: auto Window::GetXPos() const
12: {
13:    return xPos;
14: }
15: int main()
16: {
17:    Window myWin;
18:    ...
19:    auto x = myWin.GetXPos();
20: }

Wie im Beispiel ersichtlich, kann auto auch direkt zur Bestimmung des Returntyps der Methode eingesetzt werden (Zeile 11). Alternativ wäre auch möglich gewesen:

auto GetXPos() const -> decltype(xPos); // oder
decltype(xPos) GetXPos() const;         // oder
int GetXPos() const;

Beachten Sie, dass sowohl bei der Deklaration wie auch bei der Definition der Methode jeweils const anzugeben ist.

Im Zusammenhang mit const-Methoden ist noch das Schlüsselwort mutable zu erwähnen. Mit mutable definierte Eigenschaften können auch in const-Methoden verändert werden, d.h. mutable überschreibt das const-Attribut der Methode für Eigenschaften. Außerdem erlaubt mutable selbst dann die Veränderung einer Eigenschaft, wenn von der Klasse ein const-Objekt definiert wurde.

1: // Klassendefinition
2: class Window
3: {
4:    int xPos, yPos;

5:    mutable long any;
6: public:
7:    void DoSomething() const;
8:    ...
9: };
10: // Definition const-Methode
11: void Window::DoSomething() const
12: {
13:    xPos = 10; // nicht erlaubt wegen const-Methode
14:    any = 10L; // das geht wegen mutable
15: }

mutable kann nicht mit den Speicherklassen const und static kombiniert werden, d.h. die Definition folgender Eigenschaften nicht zulässig:

mutable const int MAX=10;
mutable static short statVar;

Auf die Speicherklasse static im Zusammenhang mit Klassen kommen wir später zu sprechen.

Aufruf von Funktionen aus Methoden

Werden aus Methoden heraus 'normale' Funktionen aufgerufen, können drei Fälle auftreten:

1. Fall: Innerhalb der Klasse gibt es keine Methode mit der gleichen Signatur (Name und Parameter) wie die aufzurufende Funktion. Dann erfolgt der Aufruf der Funktion wie gewohnt, d.h., es reicht die alleinige Angabe des Funktionsnamens.

1: // Funktionsdeklaration
2: bool CheckIt();
3: // Klassendefinition
4: class CAny
5: {
6:     ... // Klasse enthält keine Methode CheckIt()
7:     void DoAny()
8:     {
9:        auto result = CheckIt();
10:       ...
11:    }
12: };

2. Fall: Innerhalb der Klasse gibt es eine Methode mit der gleichen Signatur wie die aufzurufende Funktion, jedoch liegt die aufzurufende Funktion in einem eigenen Namensraum. Standardmäßig wird beim Aufruf einer Funktion immer die Methode der eigenen Klasse aufgerufen. Um die Funktion aus dem anderen Namensraum aufzurufen, ist vor dem Funktionsnamen der Name des Namensraums (z.B. std), gefolgt von zwei Doppelpunkten, zu stellen.

1: #include <cmath> // Bindet u.a. sin() ein
2: // Klassendefinition
3: class CAny
4: {
5:     // sin() Methode
6:     double sin(double x)
7:     {
8:        ...
9:     }
10:    void DoAny()
11:    {
12:       // Aufruf der eigenen sin() Methode
13:       auto res1 = sin(1.2);
14:       // Aufruf der Funktion aus cmath
15:       auto res2 = std::sin(0.5);
16:    }
17: };

3. Fall: Innerhalb der Klasse gibt es eine Methode mit der gleichen Signatur wie die aufzurufende Funktion, die jedoch in keinem eigenen Namensraum liegt. Auch hier wird standardmäßig wieder die Methode der eigenen Klasse aufgerufen. Um die globale Funktion aufzurufen, ist vor dem Funktionsnamen der globale Zugriffsoperator :: anzugeben.

1: // Funktionsdeklaration
2: bool CheckIt();
3: // Klassendefinition
4: class CAny
5: {
6:     // CheckIt() Methode
7:     void CheckIt()
8:     {
9:        ...
10:    }
11:    void DoAny()
12:    {
13:       // Aufruf der CheckIt() Methode
14:       CheckIt();
15:       // Aufruf der globalen Funktion
16:       auto res2 = ::CheckIt();
17:    }
18: };

Beachten Sie im Beispiel, dass die globale Funktion einen anderen Returntyp besitzt als die Methode. Dies reicht für die Unterscheidung, welche Funktion aufgerufen werden soll, nicht aus, da nur die Signatur betrachtet wird.

Kopieren von Objekten

Um Objekte zu kopieren, könnten wir im ersten Ansatz eine Methode schreiben, welche Eigenschaft für Eigenschaft per Zuweisungen umkopiert. Doch es geht wesentlich einfacher, indem die Objekte einander zugewiesen werden. Durch die Zuweisungen werden alle Eigenschaften kopiert, unabhängig davon, wie viele es sind.

1: // Klassendefinition
2: class Window
3: {
4: ...
5: };
6: // main() Funktion
7: int main()
8: {
9:     // Zwei Window-Objekt definieren
10:    Window myWin, yourWin;
11:    ...
12:    // Eigenschaften von myWin nach yourWin übernehmen
13:    yourWin = myWin;
14:    ...
15: }

Eines ist dabei unbedingt zu beachten:

Objekte als Parameter

Werden Objekte an Funktionen oder Methoden übergeben, sollte dies in der Regel per Referenzparameter erfolgen. Eine Übergabe per call-by-value ist so weit wie möglich zu vermeiden, da die Funktion eine Kopie des Objekts erhält. D.h., alle Eigenschaften des zu übergebenden Objekts werden in ein temporäres Objekt umkopiert und dieses dann an die Funktion übergeben. Bei einer Übergabe per Referenz entfällt dieser Kopiervorgang, da ja lediglich ein Verweis auf das Objekt übergeben wird.

Innerhalb der aufgerufenen Funktion bzw. Methode kann dann über den Parameternamen auf alle public-Member des übergebenen Objekts zugegriffen werden. Gehört die aufgerufene Methode zur gleichen Klasse wie das übergebene Objekt, hat die Methode Zugriff auf alle Member des übergebenen Objekts.

1: class Window
2: {
3:    ...
4: };
5: // Funktionsdeklaration
6: void DoAny (Window& obj);
7:
8: // Objekt definieren
9: Window myWin;
10: int main()
11: {
12:    ...
13:    // Aufruf der Funktion
14:    DoAny(myWin);
15: }
16: // Funktionsdefinition
17: void DoAny (Window& obj)
18: {
19:    ...
20:    obj.Size (640,480); // Übergebenes
21:    obj.Move (0,0);     // Fenster verändern
22: }

Der Nachteil der Übergabe eines Objekts per Referenzparameter ist, dass die Funktion oder Methode das übergebene Objekt unbeabsichtigt ändern kann. Es kann aber durchaus sinnvoll sein, das übergebene Objekt gegen Veränderungen zu schützen. In diesem Fall ist das Objekt als const-Referenz zu übergeben, so wie nachfolgend bei der Funktion DoAny() dargestellt. Ein Versuch, das Objekt innerhalb der Funktion/Methode zu verändern, führt zu einem Fehler beim Übersetzen des Programms. Dies gilt auch, wenn die Funktion/Methode eine weitere Methode des übergebenen Objekts aufruft die nicht als const-Methode definiert ist. Der Aufruf einer Nicht-const-Methode würde ansonsten wieder eine Änderung des übergebenen Objekts zulassen.

1: class Window
2: {
3:    ...
4: public:
5:    void NonConstMeth();
6:    void ConstMeth(...) const;
7: };
8:
9:  // Normale Funktion mit const-Referenzparameter
10: void DoAny (const Window& obj)
11: {
12:    // Aufruf nicht-const Memberfunktion FEHLER!
13:    obj.NonConstMeth();
14:    // Aufruf const Methode, OK!
15:    obj.ConstMeth();
16: }
17:
18: // Objekt definieren
19: Window myWin;
20: // main() Funktion
21: int main()
22: {
23:    ...
24:    // Aufruf der Funktion
25:    DoAny(myWin);
26:    ...
27: }

Objekte als Rückgabewert

Wie erwähnt, können innerhalb von Methoden und Funktionen lokale Daten definiert werden. Und dies gilt auch für lokale Objekte. Vorsicht ist aber geboten, wenn ein Objekt als Returnwert zurückgegeben wird.

Die nachfolgende Funktion CreateWin() soll zur Erstellung eines Fensters dienen. Dazu wird in der Funktion zunächst ein Window-Objekt instanziiert und die Referenz darauf an den Aufrufer zurückgeliefert.

1: class Window
2: {
3:    ...
4: };
5: // So geht es nicht!!!
6: // Funktion liefert Referenz zurück
7: // was bis zum Programmabsturz führen kann
8: Window& CreateWin()
9: {
10:    Window myWin;
11:    ...     // irgendetwas mit Fenster tun
12:    // und dann Referenz zurückliefern
13:    return myWin;
14: }
15:
16: // main() Funktion
17: int main()
18: {
19:    ...
20:    // Fenster erstellen lassen
21:    auto newWin = CreateWin();
22:    ...
23: }

Am Ende von CreateWin() wird das lokale Window-Objekt aber gelöscht, genauso wie alle anderen lokalen Daten. Wenn jetzt eine Referenz auf dieses lokale Window-Objekt zurückgeliefert wird, verweist die Referenz auf ein nicht mehr existierendes Objekt. Je nach verwendetem Compiler erhalten Sie bei einer solchen Vorgehensweise entweder eine Warnung oder eine Fehlermeldung.

Um ein Objekt zurückzugeben, wird anstelle einer Referenz das Objekt selbst zurückgegeben. In diesem Fall führt der Compiler intern prinzipiell Folgendes durch: Da das erzeugte Window-Objekt nur bis zum Ende der Funktion existiert, wird am Ende der Funktion zunächst ein temporäres Window-Objekt erstellt. Dieses temporäre Window-Objekt wird mit dem lokalen Window-Objekt initialisiert. Anschließend wird das lokale Window-Objekt gelöscht. Das zurückgelieferte temporäre Window-Objekt wird nach der Rückkehr aus der Methode/Funktion dem Zielobjekt (im Beispiel ist dies newWin) zugewiesen. Und am Ende der Anweisung, in der die Funktion aufgerufen wurde, wird schließlich das temporäre Window-Objekt gelöscht. Unter bestimmten Umständen können Compiler diesen Vorgang optimieren. Mehr dazu ist im Internet unter dem Stichwort 'C++ copy elision' zu finden.

1: class Window
2: {
3:    ...
4: };
5: // So geht's richtig!!!
6: // Funktion liefert temporäres Window-Objekt zurück
7: Window CreateWin()
8: {
9:     Window myWin;
10:    ... // irgendetwas mit dem Fenster tun
11:    // und dann Referenz zurückliefern
12:    return myWin;
13: }
14:
15: // main() Funktion
16: int main()
17: {
18:    // Fenster erstellen lassen
19:    auto newWin = CreateWin();
20:    ...
21: }

Beachten Sie im obigen Beispiel, dass der Datentyp des Objekts newWin mittels auto definiert wird. auto kann nicht nur für intrinsische Daten, sondern auch für Objekte verwendet werden.

Default-Methoden und -Operatoren

Außer dass eine Klasse anwenderdefinierte Methoden enthalten kann, definiert C++ standardmäßig die folgenden Methoden, die später noch behandelt werden:

  • Standardkonstruktor
  • Kopierkonstruktor
  • Move-Konstruktor
  • Destruktor
  • Zuweisungsoperator
  • Move-Zuweisungsoperator

Diese Default-Methoden werden aber nur dann vom Compiler automatisch generiert, wenn keine dieser Methoden explizit definiert wird. Ist mindestens eine dieser Methoden definiert, sind die anderen bei Bedarf ebenfalls zu definieren.

Doch damit nicht genug. Zusätzlich zu diesen Methoden definiert der Compiler die folgenden Operatoren für eine Klasse:

  • Adressoperator &
  • Dereferenzierungsoperator *
  • Zugriffsoperator -> auf Member
  • indirekten Zugriffsoperator ->* auf Member
  • Operator new zur Speicherreservierung
  • Operator delete zur Freigabe des Speichers
  • Komma-Operator ,

Diese Default-Methoden und -Operatoren werden im weiteren Verlauf noch behandelt.

Structured Binding

Structured Binding bezeichnet die Möglichkeit, mehrere Daten in einer Anweisung zu definieren und dabei die public-Eigenschaften eines Objekts zu übernehmen.

auto [var1,var2,...] = obj;

Die Anweisung definiert die Variablen varx und übernimmt die public-Eigenschaften aus obj in die Variablen. Dabei ist darauf zu achten, dass genauso viele Variablen bzw. Objekte aufgeführt werden, wie das rechtes vom Zuweisungsoperator stehende Objekt public-Eigenschaften hat. Bei einer Struktur mit z.B. 4 public-Eigenschaften sind vier Variablen/Objekte anzugeben. Stimmt die Anzahl nicht mit der Anzahl der Eigenschaften überein, gibt der Compiler eine Fehlermeldung aus.

Außer bei Zuweisungen kann structured binding zum Durchlaufen von Objektfeldern in einer range-for Schleife eingesetzt werden.

for (auto [var1[,var2[,...]]]: myObjArray)
{...}

Hier werden alle Elemente des Feldes myObjArray durchlaufen und für jedes Feldelement werden dessen public-Eigenschaften in den Variablen varx abgelegt.

1: struct Depot
2: {
3:    std::string name;   // Name der AG
4:    float kurs;         // Aktienkurs
5: };
6:
7: Depot myDepot[]
8:      {{"Bayer"s,51.20f},{"BMW"s,89.62f},{"Lanxess",59.24f}};
9:
10: int main()
11: {
12:    // Ersten Eintrag ausgeben
13:    auto [ag, wert] = myDepot[0];
14:    std::println("{} AG: {}", ag, wert);
15:    // Alle Eintraege ausgeben
16:    for (auto [ag, wert]: myDepot)
17:       std::println("{} AG: {}\n", ag, wert);
18: }

Entwicklung einer Klasse

Da die Entwicklung von Klassen die Grundlage der OOP unter C++ ist, wird nachfolgend beispielhaft eine Klasse zur Darstellung und Manipulation eines Rechtecks auf einer fiktiven grafischen Oberfläche erstellt.

Wie vorhin ausgeführt, bietet es sich bei der Entwicklung einer Klasse an, zunächst deren Eigenschaften zu bestimmen. Sind alle Eigenschaften bekannt, ergibt sich daraus fast zwangsläufig die Schnittstelle der Klasse. Die Klasse wird in einem eigenen Modul rect.cxx definiert, so wie es ab C++20 in der Praxis sein sollte.

Das darzustellende Rechteck soll die Eigenschaften Position, Größe und Farbe besitzen. Zunächst wird der Klassenrahmen erstellt und diesem die Eigenschaften hinzugefügt. Für die Eigenschaften Position und Größe werden short-Daten verwendet. Da später vielleicht weitere Klassen definiert werden, die ebenfalls eine Farbinformation benötigen, wird die Farbinformation in eine eigene Klasse ohne Methoden ausgelagert. Diese Farbklasse enthält die Rot-, Grün- und Blauanteile der Farbe als unsigned char-Werte. Standardmäßig soll für die Farbe Schwarz eingestellt sein (RGB=0,0,0).

1: Modul-Datei rect.cxx
2: export module Rect;
3: // Für verkuerzte Schreibweise
4: using BYTE = unsigned char;
5:
6: // Klasse fuer Farbwerte
7: // Klasse wird nicht exportiert und nur intern verwendet
8: // Farbanteile werden mit Defaultwerten initialisiert
9: struct Color
10: {
11:    BYTE red = 0;
12:    BYTE green = 0;
13:    BYTE blue = 0;
14: };
15: // Klasse fuer Rechteck, wird exportiert
16: export class Rect
17: {
18:    short xPos, yPos;      // Position
19:    short width, height;   // Groesse
20:    Color rectColor;       // Farbe
21: ...
22: };

Sind die Eigenschaften definiert, geht es an die Definition der Schnittstelle. Dabei ist zu beachten, dass die Eigenschaften private sind und die Methoden public, so wie es sich gehört.

Zuerst wird eine Methode benötigt um die Eigenschaften des Rechtecks zu initialisieren. Diese Methode erhält den Namen Init() und als Parameter nur die Position und Größe des neuen Rechtecks. Die Farbe soll standardmäßig bei der Definition eines Rechteck-Objekts auf Schwarz eingestellt bleiben.

Nach der Initialisierungsfunktion Init() werden die restlichen Methoden deklariert, um die Eigenschaften des Rechtecks zu ändern. Da das Rechteck die Eigenschaften Position, Größe und Farbe besitzt, werden drei Methoden Move(), Resize() und SetColor() deklariert. Zum Schluss wird noch eine Methode benötigt, um das Rechteck darstellen zu können. Die hierfür verwendete Methode erhält den Namen DrawIt() und gibt die Eigenschaften des Rechtecks aus.

Die vollständige Implementierung der Klasse Rect in einem Modul könnte damit wie folgt aussehen:

1: // Modul-Datei rect.cxx
2: // Globales Modul zum Einbinden der Header-Dateien
3: module;
4: #include <print>
5: export module Rect;
6: // Für verkuerzte Schreibweise
7: using BYTE = unsigned char;
8:
9: // Klasse fuer Farbwerte
10: // Klasse wird nicht exportiert und nur intern verwendet
11: // Farbanteile werden mit Defaultwerten initialisiert
12: struct Color
13: {
14:    BYTE red = 0;
15:    BYTE green = 0;
16:    BYTE blue = 0;
17: };
18: // Klasse fuer Rechteck, wird exportiert
19: export class Rect
20: {
21:    short xPos, yPos;       // Position
22:    short width, height;    // Groesse
23:    Color rectColor;        // Farbe
24: public:
25:    // Rechteck initialisieren
26:    void Init(decltype(yPos) x, decltype(yPos) y,
27:             decltype(width) w, decltype(height) h);
28:    // Rechteck verschieben
29:    void Move(decltype(xPos) x, decltype(yPos) y);
30:    // Groesse aendern
31:    void Resize(decltype(width) w, decltype(height) h);
32:    // Farbwert setzen
33:    void SetColor(BYTE r, BYTE g, BYTE b);
34:    void DrawIt() const;
35: };
36: // Definition der Init() Memberfunktion
37: // Die Farbanteile enthalten Defaultwerte
38: void Rect::Init(decltype(yPos) x, decltype(yPos) y,
39:                 decltype(width) w, decltype(height) h)
40: {
41:    xPos = x; yPos = y;
42:    width = w; height = h;
43: }
44: // Definition der Move() Memberfunktion
45: void Rect::Move(decltype(xPos) x, decltype(yPos) y)
46: {
47:    xPos = x; yPos = y;
48: }
49: // Definition der Resize() Memberfunktion
50: void Rect::Resize(decltype(width) w, decltype(height) h)
51: {
52:    width = w; height = h;
53: }
54: // Definition der setColor() Memberfunktion
55: void Rect::SetColor(BYTE r, BYTE g, BYTE b)
56: {
57:    rectColor.red = r;
58:    rectColor.green = g;
59:    rectColor.blue = b;
60: }
61: // Definition der DrawIt() Memberfunktion
62: void Rect::DrawIt() const
63: {
64:    std::println("Position: {}, {}\tGroesse: {},{}\n"
65:                 "RGB: {:#x},{:#x},{:#x}",
66:                 xPos, yPos, width, height,
67:                 rectColor.red, rectColor.green,
68:                 rectColor.blue);
69: }

Eventuelle Plausibilitätsprüfungen der Parameter wurden nicht implementiert, damit das Beispiel überschaubar bleibt. In der Praxis sollten solche Plausibilitätsprüfungen aber stets durchgeführt werden.

Und damit sind die Arbeiten an der Klasse im Prinzip beendet. Selbstverständlich sollte die Klasse dokumentiert werden, damit sie später auch eingesetzt werden kann.

Nachfolgend eine kleine Anwendung, die den Einsatz der Klasse Rect demonstriert.

1: #include <iostream>
2: // Modul rect mit der Klasse Rect importieren
3: import Rect;
4:
5: // Zwei Rechteck definieren
6: Rect rect1, rect2;
7:
8: // main() Funktion
9: int main()
10: {
11:    // Beide Rechtecke initialisieren
12:    rect1.Init(10,10,640,480);
13:    rect2.Init(100,50,800,600);
14:    // Rechteckdaten ausgeben
15:    std::cout << "1. Rechteck:\n";
16:    rect1.DrawIt();
17:    std::cout << "2. Rechteck:\n";
18:    rect2.DrawIt();
19:    // 1. Rechteck verschieben
20:    rect1.Move(20,20);
21:    // 2. Rechteck vergrössern und Farbe abändern
22:    rect2.Resize(1024,786);
23:    rect2.SetColor(0xC0, 0xC0, 0xC0);
24:    // Rechteckdaten ausgeben
25:    std::cout << "1. Rechteck:\n";
26:    rect1.DrawIt();
27:    std::cout << "2. Rechteck:\n";
28:    rect2.DrawIt();
29: }

Damit sind wir fast am Ende der Einführung von Klassen und Objekten. Es wird noch reichlich Gelegenheit geben, das Erstellen von Klassen und Objekten zu üben.

Die objektorientierte Programmierung (OOP)

Sehen wir uns zum Schluss dieses Kapitels an, was eine objektorientierte Programmiersprache von einer prozeduralen Programmiersprache unterscheidet.

Kapselung von Member (Encapsulation).

Durch Kapselung verhindert eine Klasse den direkten Zugriff auf Member (private-Member). Somit kann eine Klasse als eine Art Blackbox betrachtet werden, die eine definierte Schnittstelle (Interface, public-Methoden) besitzt. Und nur über diese Schnittstelle kann der Anwender mit der Klasse agieren.

Vererbung (Inheritance)

Bei der Vererbung werden die Methoden und Eigenschaften einer Klasse (Basisklasse) an eine andere Klasse (abgeleitete Klasse) weitergegeben. Diese neue Klasse enthält alle Member der Basisklasse sowie ihre eigenen zusätzlichen Member. Um auf das vorherige Beispiel zurückzukommen, könnte die Klasse Rect als Basisklasse für eine neue Klasse Button (Schaltfläche) verwendet werden, da ein Button ebenfalls eine definierte Ausdehnung, Position und Farbe besitzt. Zusätzlich würde die neue Klasse Button die Eigenschaften für den Zustand des Buttons sowie für einen Text erhalten.

Polymorphie (Polymorphism)

Polymorphie kennzeichnet die Eigenschaft, dass Methoden in abgeleiteten Klassen zwar den gleichen Namen wie in der Basisklasse besitzen, in ihrer Implementierung abweichen. Um diesen etwas abstrakten Sachverhalt zu veranschaulichen, kehren wir zur Klasse Button zurück. Button enthält unter anderem die Member seiner Basisklasse Rect, d.h. sowohl Rect wie auch Button besitzen eine Methode DrawIt() für die Darstellung des Objekts. Mithilfe der Polymorphie kann nun sowohl für die Klasse Rect wie auch für die Klasse Button eine Methode mit dem Namen DrawIt() verwendet werden, die aber unterschiedlich implementiert sind. Wann welche Methode aufgerufen wird, hängt dann letztendlich vom Objekttyp ab. Im Kapitel über virtuelle Methoden spielt dieser Sachverhalt die entscheidende Rolle.


Copyright 2024 © Wolfgang Schröder
E-Mail mit Fragen oder Kommentaren zu dieser Website an: info@cpp-tutor.de
Impressum & Datenschutz