Klassen

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
}

Konstruktor und Destruktor

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);
}

Konstante Instanzmethoden

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.

Statische Klassenelemente

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

Der this-Zeiger

Werden 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.