Ein Problem bei der Entwicklung großer Programme besteht darin, dass
bei vielen Variablen die Abhängigkeiten nicht mehr überschaubar sind. In
C++ fasst man daher Daten zu Objekten zusammen, die
miteinander interagieren. Zusätzlich werden Methoden auf Objekten
definiert. Diesen Ansatz nennt man Objektorientierte Programmierung
(OOP).
Idealerweise erfüllt eine Klasse ein definiertes Interface. Die Details der Implementierung werden dann vor dem Anwender der Klasse versteckt. Bei der OOP können Funktionalitäten einer Klasse durch Vererbung in andere Klassen übernommen werden.
Der Datentyp eines Objektes ist eine Klasse. In einer Klasse werden die Datenelemente (Variablen) definiert, die ein Objekt hat. Zusätzlich können der Datenstruktur Methoden (Funktionen) zugeordnet werden. Diese dienen der Manipulation und Abfrage der in der Datenstruktur enthaltenen Datenelemente.
Auch wenn einfache Datenstrukturen (struct) in die
gleichen Funktionalitäten haben wie Klassen, gibt es Unterschiede darin,
wie sie benutzt werden: Datenstrukturen mit struct sind
Ansammlungen von Daten, bei denen alle Zustände valide sind und es gibt
keine dieser Datenstruktur zugeordneten Funktionen zum Zugriff und zur
Veränderung der Daten.
Klassen werden wie die aus C bekannten Datenstrukturen definiert,
aber mit dem Schlüsselwort class statt struct
eingeleitet. Für die Zugriffskontrolle gibt es die Zugriffsspezifizierer
public und private.
class Point
{
public:
double x, y;
};
class Date
{
private:
int year, month, day;
};Während auf öffentliche (public) Datenelemente von jeder
Stelle des Programms aus zugegriffen werden darf, dürfen auf die
privaten Datenelemente nur die Methoden der Klasse zugreifen.
Zugriffsspezifizierer können in beliebiger Reihenfolge und auch mehrfach
innerhalb der Klasse verwendet werden und sind bis zur Angabe eines
neuen Zugriffsspezifizierers gültig. Die Datenelemente einer Klasse
heißen auch Instanzvariablen, die Methoden Instanzmethoden. Zugriff auf
die Mitglieder (Datenelemente und Methoden) eines Objektes erhält man
über den Punkt-Operator (.) bei Referenzen bzw. den
Pfeil-Operator (->) bei Pointern.
Beispiel:
class Example
{
public:
int i;
};
int main()
{
Example obj;
obj.i = 1; // Zugriff mit operator .
Example* p = &obj;
int x = p->i; // Zugriff bei Pointer mit operator ->
}Die Instanzvariablen sollten normalerweise private sein.
Falls sie von außen geändert werden, dann sollten sogenannte Setter
programmiert werden, die die neuen Werte prüfen. Falls lesender Zugriff
auf private Datenelemente auch von außerhalb der Klasse möglich sein
soll, sollte eine Hilfsfunktion (auch Getter genannt) definiert werden.
Die Instanzmethoden sollten normalerweise public sein. Der
Name der Getter sollte mit get beginnen und dann eine
Beschreibung des Wertes enthalten, analog sollten die Setter mit
set beginnen.
class Date
{
private:
int p_year, p_month, p_day;
public:
void setMonth(int m)
{
if (1 <= m && m <= 12)
p_month = m;
else
; // hier z. B. Exception werfen
}
int getYear()
{
return p_year;
}
};Klassennamen sollten – wie alle selbst definierten Datentypen – zur
Unterscheidung von Variablen- und Funktionsnamen mit einem
Großbuchstaben beginnen. Instanzvariablen sollten zur Unterscheidung von
lokalen Variablen mit einem Präfix gekennzeichnet sein, z.B.
p_.
Die Erzeugung einer Variablen eines Klassentyps wird als
Instanziierung eines Objekts einer Klasse bezeichnet. Ein
Objekt (eine Instanz) ist also nichts anderes als eine Variable eines
Klassentyps. Die Instanziierung von Objekten einer Klasse erfolgt analog
zur Deklaration von Variablen mit fundamentalen Datentypen wie
int oder float durch Angabe des Klassennamens
gefolgt vom Variablennamen.
Beispiel:
class Example
{
private:
int p_i; // p_i ist komplett privat
int p_j; // p_j ist über den Getter und Setter lesbar und schreibbar
public:
int k; // k ist öffentlich les- und schreibbar
int getJ() const { return p_j; } // Getter für j
void setJ(int j) { p_j = j>0?j:0; } // Setter, nur positive Werte für j zulassen
};
int main()
{
Example b; // Instanziierung eines Objektes der Klasse Beispiel
b.k = 3; // erlaubt, da hier public
int x = b.getJ(); // Zugriff nur über öffentlichen getter
// b.p_j = 4; // Fehler: j nicht öffentlich schreibbar
b.setJ(4); // zulässig über Setter
// int y = b.p_i; // Fehler: i nicht öffentlich lesbar
// b.p_i = 4; // Fehler: i nicht öffentlich schreibbar
}Konstruktoren sind besondere Funktionen, die ein Objekt einer Klasse
erstellen. Bei allen Instanziierungen wird zwingend ein Konstruktor
aufgerufen. Im Sourcecode erkennt man die Konstruktoren daran, dass sie
denselben Namen wie die Klasse tragen. Bei Klassen sollen Konstruktoren
dafür sorgen, dass das Objekt einen definierten Zustand erhält, auf dem
alle Funktionen aufgerufen werden können. Dazu sollten in den
Konstruktoren alle Datenelemente initialisiert werden. Bei mehreren
Konstruktoren wird anhand von Anzahl und Typ der Parameter
unterschieden, welcher Konstruktor aufgerufen wird. Der Konstruktor ohne
Parameter heißt Default- oder Standardkonstruktor. Objekte werden durch
den Destruktor zerstört. Er trägt ebenfalls den gleichen Namen wie die
Klasse, hat aber zusätzlich eine Tilde "~" als Präfix. Der
Destruktor hat keine Parameter. Er sollte immer virtual
definiert werden, um ein korrektes Löschen bei Unterklassen
sicherzustellen. Konstruktoren und der Destruktor haben keine
Rückgabeparameter. Den Variablen kann bei der Definition direkt ein Wert
zugewiesen werden. Dies sollte genutzt werden, um sicherzustellen, dass
alle Variablen immer einen definierten Wert haben.
Beispiel:
// Klassenbeispiel mit Konstruktor und Destruktor
#include <iostream>
class Sum
{
public:
Sum(); // Standardkonstruktor
Sum(int i, int j); // Konstruktor mit Parameter
virtual ~Sum(); // Destruktor
int getJ(){return p_j;};
void setI(int i);
int getI(){return p_i;};
int getSum(){return p_i+p_j;};
private:
int p_j = 0; // Initialisierung mit 0
int p_i = 0;
};
Sum::Sum()
{
std::cout << "Standardkonstruktor" << std::endl;
}
Sum::Sum(int i, int j)
{
if (i > 0) // Abfrage zu Wertebereich möglich
{
p_i = i; // Instanzvariable setzen
}
if (j > 0) // Abfrage zu Wertebereich möglich
{
p_j = j; // Instanzvariable setzen
}
std::cout << "Nicht-Standardkonstruktor" << std::endl;
}
Sum::~Sum()
{
std::cout << "Aufruf des Destruktors" << std::endl;
}
void Sum::setI(int i)
{
if (i > 0) // Abfrage zu Wertebereich möglich
{
p_i = i; // Instanzvariable setzen
}
}
int main()
{
std::cout << "Anfang" << std::endl;
Sum tS1; // Statische Erzeugung mit Standardkonstruktor
tS1.setI(6); // Zugriff auf p_i nur über Funktion möglich.
Sum tS2(1,3); // Erzeugung mit Nicht-Standardkonstruktor
std::cout << tS1.getSum() << std::endl;
std::cout << tS2.getSum() << std::endl;
std::cout << "Ende" << std::endl;
}
Anhand der Ausgabe ist zu erkennen, wann Konstruktor und Destruktor aufgerufen werden und welche Wirkung die aufgerufenen Memberfunktionen haben:
main();
Der Default-Konstruktor sollte immer definiert werden. Er wird
automatisch generiert, wenn keine anderen Konstruktoren definiert sind,
ansonsten nicht. Automatisch generierte Konstruktoren und Destruktoren
kan man aktivieren (=default) und deaktivieren
(=delete). Als default definierte Funktionen
brauchen dann nicht implementiert werden, es entspricht der leeren
Implementierung durch {} .
class Date
{
// ...
public:
Date(int year, int month, int day);
Date() = default;
virtual ~Date() = default;
};Es gibt mehrere Möglichkeiten, die Datenelemente eines Objektes bei der Konstruktion zu initialisieren. Die einfachste ist es, wie oben in der Klassendefinition Default-Werte anzugeben. Bei der Konstruktion werden dann in der Definitionsreihenfolge die Werte konstruiert. Dabei ist es möglich, sich auf vorhergehende Elemente zu beziehen. Der Vorteil dieser Methode ist, dass die Vorbesetzungen in allen Konstruktoren gleich sind.
class Date
{
// valides Datum
private:
int p_year = 1970;
int p_month = 1;
int p_day = 1;
int p_day_after = p_day + 1;
};Für die nächste Variante muss ein Konstruktor definiert werden. In der sogenannten Initialisierungsliste werden Elemente mit Werten konstruiert. Falls ein Element nicht erwähnt wird, wird die Konstruktion aus der Klassendefinition verwendet. Damit können selektiv einzelne Elemente angegeben werden. Die anderen werden dann wie in der Klassendefinition initialisiert. Es wird jeweils zu dem Namen einer Instanzvariablen in Klammern die entsprechende Initialisierung gesetzt.
Date::Date(int year, int month, int day)
// Initialisierungsliste
: p_year(year),
p_month(month),
p_day(day)
// implizit: p_day_after = p_day + 1;
{}Datenelemente, die nur einmal bei der Initialisierung einen Wert
erhalten, können als const definiert werden. Sie können
dann nach der Initialisierung nicht mehr verändert werden. Dabei können
auch Operatoren eingesetzt und auf bereits initialisierte Variablen
zugegriffen werden.
class Date
{
// valides Datum
private:
const int p_year = 1970;
const int p_month = 1;
const int p_day = 1;
const int p_day_after = p_day + 1;
};
Date::Date(int year, int month, int day)
// Initialisierungsliste
: p_year((year>1970)?year:0), // Jahr frühestens 1970
p_month((month>0 && month<13)?month:0),
p_day((day>0 && day<32)?day:0),
p_day_after(p_day + 1)
{
}Die dritte Variante, Datenelemente zu initialisieren ist, ihnen im
Konstruktorrumpf Werte zuzuweisen. Das ist notwendig, wenn man etwa
aufwändigere Tests bzgl. der erlaubten Tage abhängig Monat oder vom Jahr
(Schaltjahr) durchführen möchte. Da dabei aber erst die Elemente wie in
der ersten Methode initialisiert werden, sind die Werte schon vor der
Zuweisung konstruiert. Daher ist die Zuweisung eine doppelte Operation
und sollte, wenn möglich, vermieden werden. Bei Datenelementen, die
const sind, ist eine Zuweisung im Konstruktorrumpf nicht
möglich, sondern nur die Konstruktion mit einer der ersten beiden
Methoden.
Neben dem Standardkonstruktor (ohne Parameter) gibt es noch einen anderen ausgezeichneten Typ eines Konstruktors: den Copykonstruktor. Dieser hat als Parameter eine (konstante) Referenz auf seine Klasse.
Beispiel:
class Class
{
Class(); // Standardkonstruktor
Class(const Class&); // Copykonstruktor
};Der Copykonstruktor existiert immer automatisch, wenn er nicht
definiert wurde. Er kopiert dann Byte für Byte. Der Copykonstruktor wird
automatisch immer dann aufgerufen, wenn eine Variable an eine
Funktion/Methode nicht als Referenz übergeben wird. Dies kann
unangenehme Folgen haben, wenn innerhalb der Klasse dynamische
Datenstrukturen oder bestimmte eindeutige Kennzeichnungen existieren.
Stellen Sie sich hierzu einen Zeiger auf einen String vor. Wenn das
Objekt byteweise kopiert wird, dann wird der Zeiger kopiert und nicht
der Inhalt. Es existieren dann zwei Zeiger auf den gleichen String. Das
Löschen eines der Objekte führt dann unter Umstände dazu, dass der
String gelöscht wird und das verbleibende Objekt weiter auf den nicht
mehr existierenden String verweist (wilder Zeiger). Dies ist
gefährlich und sollte vermieden werden. Daher sollte man den
Copykonstruktor immer dann selbst definieren, wenn die Klasse dynamische
Elemente hat, oder den Aufruf eines Copykonstruktors mit
=delete deaktivieren:
class Class
{
private:
Class(const Class&) = delete;
};Der Parameter des Copykonstruktors muss eine Referenz sein, da ansonsten bei seinem Aufruf wieder kopiert würde, was zu einer unendlichen Rekursion führen würde.
Als Beispiel soll in einer einfachen Stringklasse der Copykonstruktor definiert werden:
class HString
{
private:
char* p_pString = nullptr;
int p_iSize = 0;
public:
HString() = default;
HString(const HString&);
};
HString::HString(const HString& aHString)
{
p_iSize = aHString.p_iSize;
p_pString = new char [p_iSize + 1];
strcpy(p_pString, aHString.p_pString);
}Methoden, die die Instanzen ihrer Klasse nicht verändern, sollten zur
besseren Wartbarkeit als konstant markiert werden. Dazu wird hinter der
Funktionsdefinition das Schlüsselwort const
hinzugefügt.
Beispiel:
class Class
{
public:
int getElement() const;
private:
int iElement;
};
int Class::getElement() const
{
// iElement = 3; // Fehler, da Element verändert wird
return iElement;
}Konstante Methoden dürfen den Zustand des Objektes nicht verändern,
weswegen es im Beispiel an der auskommentierten Stelle zu einem Fehler
beim Compilieren kommen würde. Konstante Methoden dürfen nur ebensolche
Methoden aufrufen. Eine Markierung mit const ändert die
Signatur der Funktion, d.h. eine Definition von
int Klasse::getElement() const entspricht nicht einer
Deklaration int getElement() in der Klasse.
Datenelemente einer Klasse können mit Hilfe des Schlüsselworts
static als Klassenvariable deklariert werden. Von
Klassenvariablen wird nur ein Exemplar pro Klasse erzeugt,
unabhängig davon, wie viele Objekte der Klasse instanziiert werden. Eine
statische Variable ist also der Klasse selbst zugeordnet und nicht den
Objekten der Klasse. Statische Variablen können praktisch als globale
Variablen im Kontext einer Klasse betrachtet werden.
Funktionen können mit static als Klassenfunktion
deklariert werden, wenn sie Zugriff auf Elemente einer Klasse benötigen,
jedoch nicht für ein bestimmtes Objekt aufgerufen werden sollen. Sie
können über den vorangestellten Klassennamen aufgerufen werden.
Die Definition und Initialisierung statischer Datenelemente erfolgt
entweder in der Klasse mit dem zusätzlichen Schlüsselwort
inline
class S {
private:
static inline int p_max = 0;
};oder auf Datei-Ebene mit der Deklaration in der Header-Datei:
class S {
private:
static int p_max;
};Das nächste Programmbeispiel zeigt den Unterschied von statischen und nicht-statischen Variablen sowie die Benutzung einer statischen Funktion:
class Example
{
private:
static inline int p_iCounter = 0; // statische Variable
const int p_iNumber = p_iCounter++;
public:
static void printCounter() // statische Methode
{
std::cout << "# erzeugte Objekte: " << p_iCounter << std::endl;
}
void printNumber() const
{
std::cout << "Objekt#: " << p_iNumber << std::endl;
}
};
int main()
{
Example::printCounter();
Example a;
Example::printCounter();
a.printNumber();
Example b;
Example::printCounter();
b.printNumber();
}
Die Ausgabe des obigen Beispiels sieht wie folgt aus:
main();
Versuchen Sie an dieser Stelle, den Code-Block, der die
main()-Funktion aufruft, mehrmals hintereinander
auszuführen. Überlegen Sie sich vorher, welche Ausgabe Sie
erwarten
this-ZeigerWerden mehrere Objekte einer Klasse erzeugt, so sind nur die
Datenelemente in diesem Objekt vorhanden. Die Methoden hingegen werden
für jede Klasse nur einmal erzeugt, da ihr Code für jedes Objekt gleich
sind. Bei Aufruf einer Methode wird deswegen das konkrete Objekt
übergeben. Der Compiler übergibt bei jedem Methodenaufruf das jeweilige
Objekt als Pointer. Innerhalb der Funktion kann man durch
this auf diesen Pointer zugreifen. Diese Adresse wird auch
als this-Zeiger bezeichnet.
Übungsaufgaben zu diesem Kapitel finden Sie hier.