Klassen und Objekte
Einführung
Eine der Grundideen der objektorientierten Programmierung (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.
Und 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 von Objekt zu Objekt unterschiedlich.
Streng genommen definiert die Anweisung
int var;
ebenfalls ein Objekt von Typ int. In C++ ist ein Objekt alles, was u.a. eine definierte Größe hat, Speicher belegt und einen Datentyp sowie eine definierte Lebensdauer besitzt
Die 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 das nächste 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.
// Klassenrahmen anlegen
class Window
{
// ... hier stehen dann die Member der Klasse
};
Vergessen Sie das Semikolon am Ende der Klassendefinition, meldet der Compiler beim Übersetzen des Programms eine Reihe von Fehler.
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.
// Klassenrahmen anlegen
class Window
{
// Eigenschaften
int xPos, yPos; // Position
unsigned int width, height; // Ausdehnung
};
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.
// Klassenrahmen anlegen
class Window
{
// Eigenschaften
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
};
Eine Definition von z.B. xPos oder yPos als auto Eigenschaft würde hier fehlschlagen. auto Eigenschaften sind nur für static const Eigenschaften zugelassen, auf die gleich eingegangen wird.
Bis jetzt kann die Größe eines Eigenschaftsfeldes nicht mithilfe einer Klassenkonstante definiert werden, d.h., die folgenden Anweisungen liefern einen Fehler:
class Any
{
// Das geht nicht!
const unsigned int SIZE{10};
unsigned short field[SIZE];
...
};
Wenn die Feldgröße über eine Konstante definiert werden soll, muss die Konstante bis auf Weiteres global definiert sein.
Um auf die Eigenschaften zuzugreifen, 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.
// Klassenrahmen anlegen
class Window
{
// Eigenschaften
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Methoden
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
};
Die Gesamtheit aller Methoden einer Klasse wird als deren Schnittstelle (Interface) bezeichnet. Bei einer 'sauberen' Implementierung einer 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.
Nicht alle Klassen benötigen Methoden für den Zugriff auf ihre Eigenschaften. Im Anhang L: Klassen ohne Methoden können Sie sich einmal ein Beispiel für eine Klasse ansehen, die keine Methoden enthält.
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.
#include <print>
// Klassenrahmen anlegen
class Window
{
// Eigenschaften
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Methoden
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
};
// Objekte vom Tyo Window definieren
class Window firstWin;
Window secondWin;
int main ()
{
// ... Objekt bearbeiten
}
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.
Window winArray[10];
Prüfen Sie beim Einsatz eines Objektfeldes stets, ob sich statt des Feldes nicht einer der später erwähnten Container aus der Standardbibliothek besser eignet. Dies gilt insbesondere, wenn die Anzahl der Objekte im Feld nicht von vornherein feststeht oder Objekte gesucht oder sortiert werden sollen.
Definition der Methoden
Definition innerhalb der Klasse
Wird eine Methode innerhalb der Klasse definiert, erfolgt die Definition prinzipiell gleich wie die Definition einer normalen Funktion.
#include <print>
// Klassenrahmen anlegen
class Window
{
// Eigenschaften
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Definition der Methoden
void Move(int x, int y)
{
// ... Fenster verschieben
}
void Size(unsigned int w, unsigned int h)
{
// ... Fenstergroesse aendern
}
};
// Objekte vom Tyo Window definieren
class Window firstWin;
Window secondWin;
int main ()
{
// ... Objekte bearbeiten
}
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.
class Window
{
int xpos, ypos;
...
// Definition der Methode
void Move(decltype(xpos) x, decltype(ypos) y)
{ ... }
};
Definition 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.
#include <print>
// Klassenrahmen anlegen
class Window
{
// Eigenschaften
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Deklarion der Methoden
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
};
// Definition der Methoden
void Window::Move(int x, int y)
{
// ... Fenster verschieben
}
void Window::Size(unsigned int w, unsigned int h)
{
// ... Fenstergroesse aendern
}
// Objekte vom Tyo Window definieren
class Window firstWin;
Window secondWin;
int main ()
{
// ... Objekte bearbeiten
}
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.
// Datei window.h
// Klassendefinition
class Window
{
int xPos, yPos; // Position
unsigned int width, height; // Grösse
// Verschieben
void Move(int x, int y);
// Grösse ändern
void Size(unsigned int w, unsigned int h);
};
// Datei window.cpp
// Einbinden der Header-Datei
#include "window.h"
// Definition der Methode Move()
void Window::Move(int x, int y)
{
... // Fenster verschieben
}
// Definition der Methode Size()
void Window::Size(unsigned int w, unsigned int h)
{
... // Grösse verändern
}
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.
// Moduldatei window.cxx
// Definition der Klasse Window
export module Window;
// Klassenrahmen anlegen
export class Window
{
// Eigenschaften
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Deklarion der Methoden
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
};
// Definition der Methoden
void Window::Move(int x, int y)
{
// ... Fenster verschieben
}
void Window::Size(unsigned int w, unsigned int h)
{
// ... Fenstergroesse aendern
}
// Datei main.cpp
// Verwendet Klasse Window
#include <print>
import Window;
// Objekte vom Tyo Window definieren
class Window firstWin;
Window secondWin;
int main ()
{
// ... Objekte bearbeiten
}
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 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.
// Moduldatei window.cxx
// Definition der Klasse Window
export module Window;
// Klassenrahmen anlegen
export class Window
{
// Eigenschaften
private:
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Deklarion der Methoden
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
};
// Definition der Methoden
void Window::Move(int x, int y)
{
// ... Fenster verschieben
}
void Window::Size(unsigned int w, unsigned int h)
{
// ... Fenstergroesse aendern
}
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.
// Moduldatei window.cxx
// Definition der Klasse Window
export module Window;
// Klassenrahmen anlegen
export class Window
{
// Eigenschaften
private:
int xPos=0, yPos=0; // Position
unsigned int width=320, height=240; // Ausdehnung
const int DEFCOLOR = 0xffff; // Farbkonstante
// Deklarion der Methoden
public:
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
};
// Definition der Methoden
void Window::Move(int x, int y)
{
// ... Fenster verschieben
}
void Window::Size(unsigned int w, unsigned int h)
{
// ... Fenstergroesse aendern
}
Soll zum Beispiel die Fenstergröße verändert werden, muss die Methode Size() aufgerufen werden, denn width und height sind für den direkten Zugriff geschützt. Und die Methode Size() könnte dann 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.
Geben Sie allen nicht-konstanten Eigenschaften einer Klasse die gleichen Zugriffsrechte. Unterschiedliche Zugriffsrechte führen leicht zu Verwirrung, da nicht eindeutig geregelt ist, ob zum Setzen/Lesen einer Eigenschaft eine Methode aufzurufen ist oder die Eigenschaft direkt bearbeitet werden kann.
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 um die Methode CopyData() erweiterte Klasse Window.
// Moduldatei window.cxx
// Definition der Klasse Window
export module Window;
// Klassenrahmen anlegen
export class Window
{
// Eigenschaften
private:
int xPos, yPos; // Position
unsigned int width, height; // Ausdehnung
// Deklaration der Methoden
public:
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
void CopyData(const Window& source);
};
// Definition der Methoden
void Window::Move(int x, int y)
{
// ... Fenster verschieben
}
void Window::Size(unsigned int w, unsigned int h)
{
// ... Fenstergroesse aendern
}
void Window::CopyData(const Window& source)
{
// ... Fensterdaten von source kopieren
}
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.
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.
Objekte, deren Klasse nur public-Eigenschaften enthält, können bei ihrer Definition initialisiert werden. In einem der nachfolgenden Kapitel werden wir uns das allgemeine Verfahren zur Initialisierung von Objekten ansehen. Wenn Sie trotzdem mehr über diese Initialisierungsart erfahren wollen, sehen Sie bitte im Anhang M: Initialisierung Objekte mit nur public-Member nach.
Zugriff auf Klassenmember
Zugriff innerhalb von Methoden
Innerhalb einer Methode kann auf alle Member der eigenen Klasse direkt zugegriffen werden, unabhängig davon, ob diese public oder private sind.
// Moduldatei window.cxx
// Definition der Klasse Window
export module Window;
// Klassenrahmen anlegen
export class Window
{
// Eigenschaften
private:
int xPos, yPos; // Position
unsigned int width, height; // Ausdehnung
// Deklaration der Methoden
public:
void Move(int x, int y);
void Size(unsigned int w, unsigned int h);
void CopyData(const Window& source);
};
// Definition der Methoden
void Window::Move(int x, int y)
{
xPos = x; yPos = y;
}
void Window::Size(unsigned int w, unsigned int h)
{
width = w; height = h;
}
void Window::CopyData(const Window& source)
{
xPos = source.xPos; yPos = source.yPos;
width = source.width; height = source.height;
}
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:
void Window::WinPos(decltype(xPos) x, decltype(yPos) y,
decltype(width) w, decltype(height) h)
{
Move(x, y); // Fenster verschieben
Size(w, h); // Fenster in der Größe ändern
}
Die Wiederverwendung von bestehendem Code, hier der Methoden Move() und Size(), ist eines der erklärten Ziele der OOP.
Zugriff außerhalb von 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 im Kapitel Statische Member beschrieben werden.
Der Zugriff auf ein Member erfolgt durch Angabe des Objekts, gefolgt vom Punktoperator '.' und dem Namen des jeweiligen Members.
// Datei main.cpp
// Verwendet Klasse Window
#include <print>
import Window;
// Objekte vom Tyo Window definieren
class Window firstWin;
Window secondWin;
int main ()
{
firstWin.Move(10,200);
secondWin.Size(640,480);
}
Im Beispiel werden zwei Objekte firstWin und secondWin definiert. Anschließend werden die Positionseigenschaften von firstWin durch den Aufruf von Move() geändert. Diese Änderung der Positionseigenschaft von firstWin hat keine Auswirkung auf die Positionseigenschaft von secondWin. Im Anschluss daran werden die Größeneigenschaften von secondWin durch den Aufruf von Size() geändert.
Wäre xPos eine public-Eigenschaft, könnte auf diese ebenfalls zugegriffen werden. Die Anweisung dazu würde wie folgt aussehen:
firstWin.xPos = 100;
Da es in der OOP aber fast ein Vergehen ist, Eigenschaften direkt zu ändern, sollte die Änderung von Eigenschaften stets über Methoden erfolgen.
Zugriff über Objektzeiger
Ein Objektzeiger verweist, wie der Name sagt, auf ein Objekt. Um über einen Objektzeiger auf ein Member zuzugreifen, 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.
// Datei main.cpp
// Verwendet Klasse Window
#include <print>
import Window;
// Objekte vom Tyo Window definieren
class Window firstWin;
Window secondWin;
int main ()
{
firstWin.Move(10,200);
secondWin.Size(640,480);
// Objektzeiger definieren/initialisieren
Window *pWin = &firstWin;
// Aufruf der Methode Size von firstWin
pWin->Size(1024,800);
}
Außer Objektzeiger gibt es Memberzeiger, die auf eine Eigenschaft oder Methode verweisen. Wie solche Memberzeiger definiert und wofür sie eingesetzt werden, wird im Anhang I: Memberzeiger erläutert.
Zugriff auf enum-Eigenschaften
Beim Zugriff auf enum-Eigenschaften gilt es einiges zu beachten. Zum einen gehören sie natürlich 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.
// Klassendefinition
class Window
{
public:
// enum-Datentyp definieren
enum class Style {FRAME, CLOSEBOX, SYSMENU};
private:
// enum-Eigenschaft definieren
Style winStyle;
public:
void DoAnything (decltype(winStyle) newStyle)
{
winStyle = newStyle;
}
};
// Objekt definieren
Window myWin;
int main()
{
// Aufruf einer Methode mit einem enum-Parameter
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.
#include <print>
#include <string>
// Klassendefinition
class Window
{
std::string title {"Ein Fenster"};
public:
// const-Methode, liefert nur Wert zurueck
auto GetTitle() const;
};
// Methoden-Definitionen
auto Window::GetTitle() const
{
return title;
}
int main()
{
Window myWin;
std::println("Fenstertitel: {}",myWin.GetTitle());
}
Fenstertitel: Ein Fenster
Wie im Beispiel ersichtlich, kann auto auch direkt zur Bestimmung des Returntyps der Methode eingesetzt werden. Und beachten Sie, dass sowohl bei der Deklaration wie auch bei der Definition der Methode jeweils const anzugeben ist.
Da laut Definition der Aufruf einer const-Methode keine Eigenschaften ändern darf, dürfen const-Methoden wiederum nur const-Methoden aufrufen. Der Aufruf von nicht-const-Methoden führt zu einer Fehlermeldung beim Übersetzen.
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.
// Klassendefinition
class Any
{
int value1;
mutable long value2;
public:
// const-Methode
void DoAnything() const;
};
// Methoden-Definitionen
void Any::DoAnything() const
{
// Das geht nicht wegen const-Methode
// value1 = 10;
// Aber das geht da value2 mutable ist
value2 = 100L;
}
int main()
{
Any obj1;
obj1.DoAnything();
}
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.
#include <print>
double DoAnything(double p)
{
return p*2;
}
// Klassendefinition
// Klasse enthaelt keine gleichnamige Methode
class Any
{
public:
// Methode
double Calc(double param)
{
// Any enthaelt keine Methode DoAnything()
auto res = DoAnything(param);
return res;
}
};
int main()
{
Any obj1;
std::println("Returnwert: {}",obj1.Calc(2.0));
}
Returnwert: 4
2. Fall: Innerhalb der Klasse gibt es eine Methode mit der gleichen Signatur wie die aufzurufende Funktion, die jedoch in keinem eigenen Namensraum liegt. Hier wird standardmäßig die Methode der eigenen Klasse aufgerufen. Um die globale Funktion aufzurufen, ist vor dem Funktionsnamen der globale Zugriffsoperator :: anzugeben.
#include <print>
double DoAnything(double p)
{
return p*2;
}
// Klassendefinition
// Klasse enthaelt gleichnamige Methode
class Any
{
public:
// Methoden
double DoAnything(double p)
{
return p*10;
}
void Calc(double param)
{
// Ruft Any::DoAnything() auf
std::println("Any::DoAnything -> {}",DoAnything(param));
// Ruft globale Fkt. DoAnything() auf
std::println("::DoAnything -> {}",::DoAnything(param));
}
};
int main()
{
Any obj1;
obj1.Calc(2);
}
Any::DoAnything -> 20
::DoAnything -> 4
3. 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.
#include <print>
#include <cmath>
// Klassendefinition
// Klasse enthaelt gleichnamige Methode
class Any
{
public:
// Methoden
// Berechnet Sinus nach Reihenentwicklung
double sin(double p)
{
return 0.9; // Fixwert, nur zur Demo
}
void Calc(double param)
{
// Ruft Any::sin() auf
std::println("Any::sin -> {:.2}",sin(param));
// Ruft std::sin() auf
std::println("std::sin -> {:.2}",std::sin(param));
}
};
int main()
{
Any obj1;
obj1.Calc(2);
}
Any::sin -> 0.9
std::sin -> 0.91
Wie sich das Ganze mit globalen Variablen anstelle von Funktionen verhält, das können Sie sich im Anhang P: Überschreiben von Variablen ansehen.
Kopieren von Objekten
Objekte der selben Klasse können einander zugewiesen werden. Durch die Zuweisungen werden alle Eigenschaften vom rechten Operanden in den linken Operanden kopiert, unabhängig davon, wie viele es sind.
#include <print>
#include <string>
#include <string_view>
using namespace std::string_literals;
// Klassendefinition
// Klasse enthaelt kein gleichnamiges Datum
class Any
{
int value;
std::string title;
public:
void SetValue(int val, std::string_view text)
{
value = val;
title = text;
}
void PrintValue()
{
std::println("value = {}, title = {}",value,title);
}
};
int main()
{
// Zwei Objekte definieren
Any obj1, obj2;
// Daten obj1 setzen und ausgeben
obj1.SetValue(10,"Ein Titel"s);
std::print("obj1: ");
obj1.PrintValue();
// Objekte zuweisen
obj2 = obj1;
// Daten obj2 ausgeben
std::print("obj2: ");
obj2.PrintValue();
}
obj1: value = 10, title = Ein Titel
obj2: value = 10, title = Ein Titel
Eines ist dabei unbedingt zu beachten:
Enthält eine Klasse dynamische Eigenschaften (das sind Eigenschaften die per Zeiger referenziert werden), kann dieses Standardverhalten unter Umständen zu fatalen Fehlern führen. Mehr zu Zeiger und dynamischen Eigenschaften später im Kapitel Dynamische Eigenschaften und Objekte.
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 dann eine Kopie des Objekts erhält. D.h., alle Eigenschaften des zu übergebenden Objekts werden in ein temporäres Objekt kopiert 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 eine aufgerufene Methode zur gleichen Klasse wie das übergebene Objekt, hat die Methode Zugriff auf alle Member des übergebenen Objekts.
#include <print>
#include <string>
#include <string_view>
using namespace std::string_literals;
// Klassendefinition
class Any
{
std::string title; // beliebige Eigenschaft
public:
// Eigenschaften setzen
void SetValue(std::string_view text)
{
title = text;
}
// Eigenschaften ausgeben
void PrintValue()
{
std::println("title = {}",title);
}
};
// 'Normale' Funktion
// Erhaelt Objekte vom Typ Any
void DoAnything(Any& obj)
{
obj.PrintValue();
}
int main()
{
// Zwei Objekte definieren
Any obj1, obj2;
// Daten objx setzen
obj1.SetValue("Ein Titel"s);
obj2.SetValue("A Title");
// Funktion mit Any-Objekt aufrufen
DoAnything(obj1);
DoAnything(obj2);
}
title = Ein Titel
title = A Title
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 als nicht-const-Methode definiert ist. Der Aufruf einer nicht-const-Methode würde ansonsten wieder eine Änderung des übergebenen Objekts zulassen.
// Nur geaenderte Anweisungen gegenueber
// dem vorherigen Beispiel
// Klassendefinition
class Any
{
// ... Member wie im vorherigen Beispiel
// Eigenschaften ausgeben
// PrintValue muss nun eine const-Methode sein
void PrintValue() const
{
std::println("title = {}",title);
}
};
// 'Normale' Funktion
// Erhaelt Objekte vom Typ Any
void DoAnything(const Any& obj)
{
obj.PrintValue();
}
int main()
{
// ... Definitionen wie im vorherigen Beispiel
// und keine Aenderungen am Fkt-Aufruf notwendig
DoAnything(obj2);
DoAnything(obj2);
}
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.
Geben Sie niemals eine Referenz auf das lokale Objekt zurück, da dieses nach dem Verlassen der Funktion/Methode nicht mehr exisitiert. Geben Sie stattdessen das Objekt selbst zurück.
#include <print>
// Klassendefinition
class Window
{
std::string title; // beliebige Eigenschaft
public:
// Eigenschaften setzen
void SetValue(std::string_view text)
{
title = text;
}
// Eigenschaften ausgeben
// PrintValue muss nun eine const-Methode sein
void PrintValue() const
{
std::println("title = {}",title);
}
};
// 'Normale' Funktion
// Liefert ein Objekt vom Typ Window zurueck
Window CreateWindow()
{
Window myWin;
myWin.SetValue("Ein Fenster");
return myWin;
}
int main()
{
auto winObj = CreateWindow();
winObj.PrintValue();
}
title = Ein Fenster
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 winObj) 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 gesamten Vorgang optimieren. Mehr dazu ist im Internet unter dem Stichwort 'C++ copy elision' zu finden.
Enthält das zurückzugebende Objekt dynamische Daten (Zeiger!), muss die Klasse des Objekts in der Regel einen Kopierkonstruktor und einen überladenen Zuweisungsoperator besitzen! Mehr dazu später.
Default-Methoden und -Operatoren
Außer dass eine Klasse anwenderdefinierte Methoden enthalten kann, werden folgende Methoden, die später noch behandelt werden, automatisch durch den Compiler definiert:
- 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. Wird eine dieser Methoden definiert, sind die anderen bei Bedarf ebenfalls explizit zu definieren.
Außer 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
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 weist die public-Eigenschaften von obj den Variablen zu. 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 beim Durchlaufen von Objektfeldern in einer range-for-Schleife eingesetzt werden.
for (auto [var1,...,varx]: objArray)
{...}
Hier werden alle Elemente des Feldes objArray durchlaufen und für jedes Feldelement werden dessen public-Eigenschaften in den Variablen varx abgelegt.
#include <print>
#include <string>
using namespace std::string_literals;
struct Depot
{
std::string name; // Name der AG
float kurs; // Aktienkurs
};
Depot myDepot[] {
{"Bayer"s,51.20f},
{"BMW"s,89.62f},
{"Lanxess",59.24f} };
int main()
{
// Ersten Eintrag ausgeben
auto [ag, wert] = myDepot[0];
std::println("{}: {}\n", ag, wert);
// Alle Eintraege ausgeben
for (auto [ag, wert]: myDepot)
std::println("{}: {}", ag, wert);
}
Bayer: 51.2
Bayer: 51.2
BMW: 89.62
Lanxess: 59.24
Damit sind wir fast am Ende dieses Kapitels angekommen. 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 ihre 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 Member einer Klasse (Basisklasse) an eine andere Klasse (abgeleitete Klasse) weitergegeben. Diese neue Klasse enthält dann alle Member der Basisklasse sowie ihre eigenen zusätzlichen Member. So könnte eine Klasse Rect die allgemeinen Eigenschaften eines darzustellenden Rechtecks enthalten (z.B. die Position, Farbe usw.) und eine davon abgeleitete Klasse Button zusätzlich die Beschriftung für den Button.
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, sehen wir uns die Klasse Button genauer an. Button enthält unter anderem die Member seiner Basisklasse Rect, d.h. sowohl Rect wie auch Button besitzen jeweils 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.
Übungen
Versuchen Sie in den Übungen jede Klasse in ein eigenes Modul zu legen.
In den Lösungen zu den Übungen besitzen Modul-Dateien den Namen aaaa_bbZ.cxx, wobei Z ein Buchstabe ist und zur Unterscheidung bei mehreren Moduldateien dient. Für die nächstes Übung ist z.B. der Name der Moduldatei klasse_01a.cxx.
klasse_01:
Es ist eine Klasse zum Rechnen mit komplexen Zahlen zu entwickeln.
Eine komplexe Zahl besitzt zwei Eigenschaften: einen Realanteil und einen Imaginäranteil. Für beide Eigenschaften sind Gleitkomma-Datentypen zu verwenden. Damit der Anwender die Eigenschaften (Real- und Imaginäranteil) nicht direkt ändern kann, sind sie gegen den direkten Zugriff zu schützen.
Zum Setzen einer komplexen Zahl sowie für deren Ausgabe sind entsprechende Methoden zu implementieren. Die Ausgabe der Daten soll mit vier Nachkommastellen erfolgen.
Zusätzlich sind zwei Methoden zu schreiben, um komplexe Zahlen addieren und subtrahieren zu können. Werden zwei komplexe Zahlen addiert bzw. subtrahiert, werden deren Real- und Imaginäranteile jeweils getrennt addiert bzw. subtrahiert (siehe Programmausgabe).
Definieren Sie zwei Zahlen, initialisieren diese und geben sie aus. Danach ist die zweite Zahl zur ersten zu addieren und das Ergebnis auszugeben. Das so erhaltene Ergebnis ist anschließend von der zweiten Zahl zu subtrahieren und erneut auszugeben.
1. Komplexe Zahl = (R:1.10 / I:2.20)
2. Komplexe Zahl = (R:3.30 / I:4.40)
Nach Addition 2. Zahl zur 1. Zahl
Neue 1. Zahl = (R:4.40 / I:6.60)
Nach Subtraktion der 1. Zahl von der 2. Zahl
Neue 2. Zahl = (R:-1.10 / I:-2.20)
klasse_02:
Es ist eine Klasse zur Klassifizierung von Daten zu erstellen. Bei der Klassifizierung von Daten wird der Gesamtwertebereich der Daten in kleinere Wertebereiche, den Klassen, unterteilt und die Häufigkeit des Auftretens von Werten, die in einen dieser Wertebereiche fallen, gezählt.
Beispiel:
Der Wertebereich der Daten beträgt 0...99 und soll in 20 Klassen unterteilt werden.
Daraus folgt:
Wertebereich der 1. Klasse : 0...4
Wertebereich der 2. Klasse : 5...9
...
Wertebereich der 20. Klasse: 95...99
Der zu klassifizierende Wert ist als unsigned short-Wert an eine Methode der Klasse zur Klassifizierung zu übergeben.
Des Weiteren soll die Klasse eine Methode enthalten, die die Anzahl der Werte pro Klasse ausgibt.
Für eine eventuell notwendige Initialisierung der Eigenschaften der Klasse ist ebenfalls eine Methode zu verwenden.
Verwenden Sie in der Übung keine Literale, sondern Konstanten. Legen Sie mithilfe dieser Konstanten die Anzahl der Klassen (Bereiche), die Obergrenze des Wertebereichs der Daten (Untergrenze soll stets 0 sein) und die Anzahl der zu klassifizierenden Daten fest.
Erzeugen Sie in der main() Funktion entsprechend viele Zufallszahlen und übergeben diese nacheinander an die Klasse zur Klassifizierung. Zum Schluss ist die Häufigkeit der Werte in den Klassen auszugeben.
Die nachfolgende Ausgabe geht von 10 Klassen, einem Wertebereich von 0...99 und 10000 Werten aus.
Verteilung:
0... 9: 969
10...19: 947
20...29: 1054
30...39: 978
40...49: 1027
50...59: 989
60...69: 1020
70...79: 955
80...89: 1021
90...99: 1040
klasse_03:
Es ist eine Klasse für die Realisierung eines Stacks zu erstellen. Ein Stack ist ein Bereich zum temporären Sichern von Daten, d.h. auf einem Stack können Daten abgelegt und später wieder ausgelesen werden. Hierbei gilt, dass das zuletzt abgelegte Datum als Erstes wieder ausgelesen wird.
Der in dieser Übung zu realisierende Stack soll zur Ablage von short-Werten dienen. Damit die Übung zunächst einfach bleibt, sollen maximal 10 short-Werte auf dem Stack zwischengespeichert werden können. Verwenden Sie zur Definition der maximal abzulegenden Daten eine Konstante und kein Literal!
Für die Ablage der short-Werte auf dem Stack ist eine Methode Push() zu erstellen. Die Methode liefert true zurück, wenn der übergebene Wert abgelegt werden konnte. Ist der Stack belegt, liefert sie false zurück.
Zum Auslesen der auf dem Stack abgelegten Werte ist eine weitere Methode Pop() zu erstellen. Enthält der Stack noch Werte, soll die Methode als Returnwert true zurückgeben und in einem Parameter den ausgelesenen Wert. Sind keine weiteren Daten auf dem Stack, soll die Methode false zurückgeben; der Inhalt des Parameters ist dann nicht relevant.
Für eine eventuelle Initialisierung des Stacks ist bei Bedarf eine gesonderte Methode zu erstellen.
Definieren Sie ein globales Stack-Objekt, welches in der main() Funktion so lange mit Zufallszahlen gefüllt wird bis der Stack belegt ist. Die abzulegenden Werte sind in main() zur Kontrolle auszugeben.
Nachdem der Stack vollständig gefüllt ist, sind alle Werte wieder vom Stack auszulesen und erneut auszugeben.
Schiebe Werte auf Stack:
41 67 34 0 69 24 78 58 62 64
Lese Werte vom Stack:
64 62 58 78 24 69 0 34 67 41
klasse_04:
Die Datei swr1_hitparade.csv enthält (fast) alle Titel ab der Position 1000, die in der SWR1-Hitparade seit 1989 vertreten waren.
Die Datei hat folgenden prinzipiellen Aufbau:
Titel;Interpret;1989;1990;...;2025
dreadlock holiday;10cc;419;896;...;0
Eagle;abba;0;0;...;436
Die erste Zeile enthält einen Kopf mit der Beschreibung der Spalten. Die erste Spalte enthält den Titel, die zweite den Interpreten und anschließend folgen die Spalten mit den Jahreszahlen.
Danach folgen die Zeilen mit den Titeln, Interpreten und den Platzierungen in den entsprechenden Jahren. Beachten Sie, dass der letzte Eintrag in einer Zeile nicht mit einem Semikolon abgeschlossen ist.
Ihre Aufgabe ist es, die ersten 5 Titel sowie deren höchste Position in den ersten 10 Jahren auszugeben. Ist der Titel nicht in den ersten 10 Jahren nicht platziert, ist die Position 9999 auszugeben. Beachten Sie, dass die Position 1 die höchste Position in der Hitparade ist!
Definieren Sie zwei Klassen: eine welche die Daten für die Titel in der Hitparade enthält und eine für die Hitparade selbst.
Die Klasse für die Hitparade soll folgende Methoden enthalten:
- ReadChart(): liest die ersten 5 Titel mit deren Positionen aus der Datei ein und speichert sie ab
- PrintChart(): gibt die 5 Titel und deren max. Position aus
Die Klasse für die Titel soll folgende Methoden enthalten:
- ReadData(): liest einen Titel und dessen Platzierungen ein
- GetTitle() und GetArtist(): liefert den Titel bzw. Interpreten zurück
- GetMaxPos(): liefert die höchste Position des Titels zurück
Tipp: Definieren Sie in der Hitparaden-Klasse ein entsprechend großes Objektfeld vom Typ der Titel-Klasse.
Diese Übung sollte Ihnen bekannt vorkommen, sie wurde so ähnlich so schon im Kapitel String-Objekte I gestellt.
fantastischen vier mit '25', Pos:9999
lo & leduc mit '79', Pos:9999
james blunt mit '1973', Pos:132
prince mit '1999', Pos:394
bap mit '10. Jun', Pos:686