Objekt-orientiertes Design in C++: Komposition und Vererbung

Vorgestellt werden die Design-Prinzipien Komposition und Vererbung, die immer dann eingesetzt werden, wenn Klassen als Datenelemente nicht nur fundamentale Datentypen sondern ihrerseits Klassen enthalten. Dabei werden weitere anspruchsvolle Konzepte von C++ erläutert: Der Polymorphismus (= Vielgestaltigkeit), Zeiger, das Überschreiben von Funktionen (function overriding) und Operatoren, Implementierung einer Baumstruktur.

Einordnung des Artikels

Einführung

Verschachtelung selbstdefinierter Datentypen

Bisher wurde mehrfach gesagt, dass Klassen aus Datenelementen und Methoden bestehen, wobei beide entweder als public oder private markiert werden, was den Zugriff spezifiziert (access modifier). Die Beispiel-Klassen, die bisher vorgestellt wurden, hatten als Datenelemente aber stets fundamentale Datentypen. Möchte man aber komplexe Datenstrukturen aufbauen, wird man dazu übergehen, in selbstdefinierte Datentypen wiederum selbstdefinierte Datentypen (oder Klassen aus der Standard-Bibliothek) einzubauen.

Generell gibt es zwei Möglichkeiten, wie man dies realisiert:

  1. Komposition (has-a-relationship)
  2. Vererbung (is-a-relationship).

Was hiermit gemeint ist, soll an einem einfachen Beispiel demonstriert werden. Im Zusammenhang mit UML-Diagrammen wird der Begriff Komposition schärfer definiert (eine Komposition ist dann ein Spezialfall einer Aggregation); diese schärfere Bedeutung wird hier nicht erklärt und verwendet.

Beispiel: Die Klasse Gamble (Glücksspiel)

Die Klasse RandomGenerator wurde bisher entwickelt, um für ein kleines Statistik-Projekt Testdaten zu erzeugen wie sie in einem Zufallsexperiment entstehen könnten (siehe Objekt-orientiertes Design in C++: Datenkapselung und UML-Diagramme. Das UML-Klassendiagramm ist unten nochmals abgebildet:

Abbildung 1: UML-Klassendiagramm für RandomGenerator.Abbildung 1: UML-Klassendiagramm für RandomGenerator.
Angenommen es soll ein Glücksspiel entwickelt werden mit folgenden Regeln:

  1. Ein Würfel wird n mal geworfen wird und der Spieler kann darauf setzen, dass die Summe der geworfenen Augenzahlen größer ist als S.
  2. Der Spieler zahlt vor dem Spiel einen Einsatz E und erhält im Falle, dass er richtig liegt, den Gewinn G.
  3. Wie groß der Gewinn ist, wird später in einer Übungsaufgabe berechnet und implementiert.

Für das Glücksspiel kann die Klasse RandomGenerator aus dem Statistik-Projekt verwendet werden; es müssen jetzt noch zusätzliche Funktionen angeboten werden, die das Spiel simulieren. Dies könnte durch stake(), win() und pay() geschehen:

  1. Die Funktion stake() steht dafür, dass bei n Würfen ein gewisser Betrag (stake) auf das Ereignis die Augensumme ist größer als S gesetzt wird; sie benötigt keinen Rückgabewert.
  2. Die Funktion win() stellt anhand einer Zufallsfolge fest, ob die Augensumme größer ist als S; der Rückgabewert ist ein Wahrheitswert (true oder false).
  3. Die Funktion pay() beschreibt die Auszahlung im Fall eines Gewinnes (im Fall eines Verlustes gibt es keine Auszahlung); ihr Rückgabewert ist eine ganze Zahl.

Die Deklarationen der Funktionen lauten also:

void stake(int number, int stake, int sum);

bool win(std::vector<int> randomSequence, int sum);

int pay(int number, int stake, int sum);

Durch eine geschickte Festlegung der Datenelemente der zu entwickelnden Klasse Gamble lassen sich die Eingabewerte später noch verringern.

Aber wie werden jetzt die Klasse RandomGenerator und die neuen Funktionen zur Klasse Gamble zusammmengefügt?

1. Komposition (has-a-relationship):

Die Klasse Gamble enthält als Datenelement ein Objekt vom Typ RandomGenerator (Gamble has a RandomGenerator); das UML-Klassendiagramm für Gamble würde dann etwa wie folgt aussehen:

Abbildung 2: UML-Klassendiagramm für Gamble für den Fall, dass Gamble aus RandomGenerator und anderen Elementen zusammengesetzt wird (Komposition).Abbildung 2: UML-Klassendiagramm für Gamble für den Fall, dass Gamble aus RandomGenerator und anderen Elementen zusammengesetzt wird (Komposition).

Wenn im Konstruktor von Gamble ein Objekt der Klasse RandomGenerator erzeugt wird, kann die Klasse Gamble alle Funktionalitäten der Klasse RandomGenerator nutzen.

2. Vererbung (is-a-relationship).

Die Klasse Gamble ist eine Erweiterung der Klasse RandomGenerator (Gamble is a RandomGenerator) in dem Sinn, dass Gamble alle Datenelemente und Methoden von RandomGenerator erbt, aber noch zusätzliche Elemente enthält, die in RandomGenerator nicht enthalten sind (wie die oben beschriebenen Methoden).

Das UML-Klassendiagramm sieht jetzt wie folgt aus:

Abbildung 3: UML-Klassendiagramm für Gamble für den Fall, dass Gamble alle Eigenschaften von RandomGenerator erbt. Die Datenelemente  und Methoden von RandomGenerator sind nicht nochmal dargestellt (siehe oben). Der Pfeil von Gamble zu RandomGenerator steht für die Vererbung.Abbildung 3: UML-Klassendiagramm für Gamble für den Fall, dass Gamble alle Eigenschaften von RandomGenerator erbt. Die Datenelemente und Methoden von RandomGenerator sind nicht nochmal dargestellt (siehe oben). Der Pfeil von Gamble zu RandomGenerator steht für die Vererbung.

Aufgaben:

  1. Überlegen Sie sich geeignete Anwendungsfälle (use cases) für das Würfelspiel, das zur Klasse Gamble geführt hat: Wie wird man aus einer main()-Methode heraus ein Würfelspiel ausführen und dabei die Klasse Gamble und ihre Methoden nutzen?
  2. Überarbeiten Sie das UML-Klassendiagramm (Abbildung 2) für die Klasse Gamble:
    • Wird die Implementierung erleichtert, wenn weitere Datenelemente hinzugefügt werden?
    • Sind weitere Methoden sinnvoll?
    • Welche Konstruktoren wird die Klasse anbieten?
    • Ändern sich dadurch die Eingabewerte der drei Methoden, die im Klassendiagramm aufgeführt sind?
  3. Implementieren Sie die Klasse Gamble! Die Methode pay() soll dabei den Gewinn so berechnen, dass bei einem Laplace-Würfel (alle 6 Ergebnisse erscheinen mit Wahrscheinlichkeit 1/6) ein faires Spiel entsteht, das heißt der Erwartungswert für den Spieler ist gleich null.
  4. Ist dies — für den Fall, dass der Einsatz E ganzzahlig ist, — mit dem Typ des Rückgabewertes int von pay() verträglich oder muss er in double abgändert werden?

Ausblick

In den folgenden Abschnitten werden zunächst Komposition und Vererbung näher erläutert. Dazu wird ein relevantes Beispiel für eine selbstdefinierte Datenstruktur vorgestellt, das selbstdefinierte Datentypen ineinander verschachtelt.

Es simuliert einen Ordner, der — vorerst — nur Dateien enthalten kann. Später wird die Klasse Ordner so erweitert, dass ein Ordner sowohl Unterordner als auch Dateien enthalten kann; und ein Unterordner hat wiederum die Eigenschaften eines Ordners — es ensteht eine Baumstruktur. Dabei wird auch gezeigt, wie man ganz einfach Operationen auf dieser Datenstruktur ausführen kann, wie:

  • die Berechnung des Speicherplatzes eines Ordners oder
  • die Angabe des Pfades eines Ordners.

Komposition

Die Problemstellung: Simulation eines Ordners, der Dateien enthält

Als ein Beispiel für eine selbstdefinierte Datenstruktur, die nicht nur fundamentale Datentypen enthält, wird folgendes Beispiel besprochen, das die beiden Klassen Folder und File (Ordner und Datei) enthält. Sie sollen die Eigenschaften besitzen:

  1. Ein Ordner besitzt als Datenelemente:
    • den Namen des Ordners
    • den Pfad des Ordners
    • einen Vektor mit den enthaltenen Dateien.
  2. Ein Ordner besitzt Methoden,
    • um eine Datei hinzuzufügen
    • um eine Datei zu löschen.
  3. Ein Ordner kann keine Unterordner enthalten.
  4. Eine Datei hat die Datenelemente:
    • der Name der Datei
    • der Pfad der Datei
    • der von der Datei belegte Speicherplatz.
  5. Addiert man den Speicherplatz, den die Dateien belegen, erhält man den Speicherplatz, den ein Ordner belegt (dass der Ordner selbst auch Speicherplatz beansprucht — auch wenn er leer ist —, wird hier vernachlässigt).

Das UML-Klassendiagramm für Folder und File könnte dann wie folgt aussehen:

Abbildung 4: UML-Klassendiagramm für File und Folder.Abbildung 4: UML-Klassendiagramm für File und Folder.

Die Verbindungslinie zwischen den beiden Klassen deutet deren Assoziation an, die angefügten Zahlen die Multiplizitäten der Assoziation. Dabei bedeutet Assoziation lediglich, dass die Klassen in einer Verbindung zueinander stehen und die Multiplizitäten geben an, mit welchen Anzahlen die beiden Klassen in dieser Verbindung vorkommen.

In diesem speziellen Fall kann ein Ordner beliebig viele Dateien enthalten (daher 0 .. ∗) und umgekehrt ist eine Datei eindeutig einem Ordner zugeordnet (daher die 1 auf der anderen Seite der Assoziation). Die Raute steht für eine Aggregation, also der Tatsache, dass der Ordner die Dateien enthält (und nicht umgekehrt).

In UML wird zwischen Aggregation und Komposition unterschieden:

  • Aggregation heißt, dass das Ganze aus Teilen zusammengesetzt ist, wobei die Teile auch ohne das Ganze existieren können (ein Computer kann mehrere Festplatten enthalten; und die Festplatten können aus dem Computer ausgebaut und woanders verwendet werden). In UML-Diagrammen wird eine Assoziation durch eine nicht gefüllte Raute wie in Abbildung 4 dargestellt.
  • Komposition heißt, dass die Teile ohne das Ganze nicht existieren können (ein Speichermodul ist aus mehreren Speicherzellen aufgebaut, aber eine Speicherzelle verliert ihre Funktion, wenn sie nicht mehr im Speichermodul enthalten ist, da die gesamte Infrastruktur wie die Adressierung vom Speichermodul zur Verfügung gestellt wird). Eine Komposition wird im UML-Diagramm als gefüllte Raute dargestellt.

Im Folgenden wird nicht mehr zwischen Aggregation und Komposition unterschieden; beide werden salopp als Komposition bezeichnet, um sie von der Vererbung abzugrenzen.

Die Implementierung der Klassen Folder und File

Die Deklarationen der Klassen Folder und File werden nicht angegeben: sie ergeben sich sofort aus den UML-Diagrammen.

Bei den Implementierungen gibt es noch einige Unklarheiten:

  1. Was passiert wenn eine Datei einem Ordner hinzugefügt werden soll, wenn der Ordner bereits eine Datei mit dem selben Namen enthält? Hier wird die einfachste Lösung gewählt: Gibt es bereits eine Datei mit dem entsprechenden Namen (zwischen Groß- und Kleinschreibung wird unterschieden), wird sie nicht hinzugefügt.
  2. Beim Löschen einer Datei muss nur deren Name angegeben werden und es wird nicht nachgefragt, ob sie tatsächlich gelöscht werden soll.
  3. Der Speicherplatz, den der Ordner belegt, ist einfach die Summe der Speicherplätze aller enthaltenen Dateien.

Im Folgenden sind nur die relevanten Teile der Dateien Folder.cpp und File.cpp gezeigt:

// Folder.cpp
// Implementierungen der Klasse Folder

#include "Folder.h"
#include<vector>
#include<iostream>
using namespace std;

/* Konstruktor, der die zwei Datenelemente 
name
path 
setzt; keine weiteren Aktionen
*/
Folder::Folder(std::string folderName, std::string folderPath) : name {folderName}, path {folderPath}
{
}

// ============= Setter / Getter ===========
// ...

// ============= Methoden ===========

// Achtung: wenn schon ein File mit dem Namen des neuen Files existiert, passiert NICHTS 
// (keine Rückfrage an denn Nutzer; File wird nicht hinzugefügt)
void Folder::addFile(File file)
{
    if (files.size() == 0)          // noch kein File im Folder
    {
        files.push_back(file);
        return;
    }
    for (File f : files)
    {
        if (f.getName() == file.getName())
            return;         // file wird nicht hinzugefügt, da schon eines mit dem Namen existiert
    }

    files.push_back(file);          // file wird am Ende von files angehängt
}

// Falls eine Datei mit dem Namen file.getName() in files enthalten ist, wird es aus files gelöscht (ohne Nachfrage)
void Folder::removeFile(File file)
{
    int number = files.size();
    
    if (number == 0)            // file kann nicht in files enthalten sein
        return;
      
    for (int i = 0; i < number; i++)
    {
        if (file.getName() == files[i].getName())
        {
            cout << "gelöscht wird: " << files[i].getName() << endl;
            files.erase(files.begin() + i);
            return;         // file wurde aus files gelöscht -> es gibt nichts mehr zu tun         
                            // Achtung: Fehler ohne return: durch das Löschen wird der Vektor files verändert 
                            // und beim nächsten Durchlauf hat i eine andere Bedeutung
        }
    }
}

// Berechnet die Summe der von den Dateien in files belegten Speicherplätze
int Folder::getDiskSpace()
{
    int sum = 0;
    cout << "size of files: " << files.size() << endl;
    for (File f: files)
    {
        cout << "in getDiskSpace(): " << f.getDiskSpace() << endl;
        sum += f.getDiskSpace();
    }
    return sum;
}
// File.cpp
// Implementierungen der Klasse File

/* Konstruktor, der die drei Datenelemente 
name
path 
diskSpace
setzt; keine weiteren Aktionen
*/
File::File(std::string fileName, std::string filePath, int diskSp) : name {fileName}, path {filePath}, diskSpace {diskSp}
{
}

// weitere Konstruktoren, Setter und Getter
// ...

Zur Erklärung:

  1. Die Dateien sind nicht vollständig wiedergegeben: an den Kommentaren ist zu ersehen, dass nur die relevanten Konstruktoren, aber keine Setter und Getter gezeigt sind.
  2. Man beachte, dass dadurch nicht klar ist, ob und wie Kopier-Konstruktoren implementiert sind. Dies ist aber hier entscheidend, da die Methoden addFile() und removeFile() durch call by value realisiert sind und dabei wird der Kopier-Konstruktor von File aufgerufen. So wie das File-Objekt nach der Übergabe benötigt wird, muss der Kopier-Konstruktor als tiefe Kopie realisiert sein. Oder noch besser: er wird überhaupt nicht implementiert — denn der Standard-Kopier-Konstruktor besitzt genau das erwünschte Verhalten.
  3. Die Methode removeFile() in Folder.cpp ist sehr fehleranfällig (Zeile 42 bis 59). Einfach ist noch der Fall, wenn der Ordner keine Dateien enthält: denn dann gibt es auch nichts zu löschen und die Methode kann sofort beendet werden (Zeile 47). Andernfalls muss aber eine Schleife durchlaufen werden, die alle Dateien abfragt und diejenige entfernt, die den gesuchten Namen hat. Warum soll dies fehleranfällig sein? In der gezeigten Implementierung werden die Schleife und die Methode verlassen, sobald die Datei gelöscht wurde (Zeile 55). Man muss sich über folgendes im Klaren sein: Das Löschen verändert den Vektor, dessen Einträge die Schleife durchläuft. Welcher Eintrag wird abgearbeitet, nachdem ein Eintrag gelöscht wurde und der nächste Eintrag aufgerufen wird? Es kann hier zu schwer vorhersehbarem Verhalten der Schleife kommen.

Aufgaben:

1. Testen Sie die Klassen Folder und File, indem Sie aus einer main()-Methode einen Ordner mit mehreren Dateien erzeugen und wieder löschen. Es empfiehlt sich dazu eine Methode Folder::printFiles() zu implementieren, die alle Dateien eines Ordners ausgibt.

2. Im Konstruktor von File wird der Pfad einer Datei gesetzt.

Begründen Sie, welche der beiden Aussagen richtig ist:

  • Man kann auf das Setzen des Pfades verzichten, da man den Pfad aus dem Ordnernamen und dem Dateinamen zusammensetzen kann. Das heißt man kann dies in der Methode setPath() erledigen.
  • Man kann sich den Pfad einer Datei nicht wie soeben beschrieben zusammensetzen, da ein File-Objekt nicht weiß, welchem Folder-Objekt es zugeordnet ist.

Überladen von Operatoren

In der Implementierung sowohl von addFile(File file) als auch removeFile(File file) wird abgefragt, ob der Name des übergebenen Objektes file mit einem der Dateinamen im Ordner übereinstimmt (Zeile 34 und 51 in Folder.cpp). Man stellt sich hier die Frage, warum man stattdessen nicht einfach die Gleichheit der File-Objekte prüft, also

if (f == file)

in Zeile 34 beziehungsweise

if (file == files[i])

in Zeile 51? Der Grund ist einfach: Der Operator == ist für die Klasse File nicht definiert; obige if-Abfragen führen zu einem Compiler-Fehler. Der Operator == ist zunächst nur für fundamentale Datentypen definiert.

Aber C++ bietet die Möglichkeit, nahezu alle Operatoren zu überladen, also auch File-Objekte mittels == zu vergleichen und dabei zu definieren, was es heißt, dass zwei Objekte gleich sind.

Um die Prüfung auf Gleichheit hier sinnvoll einzusetzen, bietet es sich an zu vergleichen, ob zwei Dateien identische Namen haben. (Man könnte auch anspruchsvoller sein und tatsächlich die Inhalte der Dateien vergleichen — allerdings haben die Dateien in diesem einfachen Beispiel noch keinen Inhalt.)

Das Überladen von Operatoren ist eigentlich nichts anderes als eine Funktion zu definieren, mit dem Unterschied, dass anstelle eines Funktionsnamen ein Operator eingesetzt wird. Wenn Sie also nicht sicher sind, ob es sinnvoll ist einen Operator zu überladen (da dies auch zu Verwirrung führen kann), können Sie auch eine entsprechende Funktion definieren; hier würde sich etwa anbieten in der Klasse File die Methode zu definieren:

bool equals(File file);

Sollen zwei Dateien als gleich gelten, wenn sie identische Namen besitzen, würde die Implementierung von equals() lauten:

bool File::equals(File file)
{
    if (name == file.getName())
        return true;
    else
    return false;
}

Aufgerufen wird die Methode equals() dann mit einem File-Objekt, etwa f1:

f1.equals(f2);

Soll dies nun mit dem Operator == ausgedrückt werden, lauten Deklaration und Implementierung:

bool operator==(File file);
bool File::operator==(File file)
{
    if (name == file.getName())
        return true;
    else
    return false;
}

Dabei ist name in Zeile 3 der Name der Datei, mit der die Methode operator== aufgerufen wird (name ist ja ein Datenelement von File).

Aufgabe:

Implementieren Sie eine Klasse ComplexNumber die zwei Datenelemente re und im vom Datentyp double besitzt (Real- und Imaginärteil einer komplexen Zahl) und die die Operatoren + und * überlädt (Addition und Multiplikation komplexer Zahlen). Weiter sollen zwei komplexe Zahlen gleich sein, wenn sie in Real- und Imaginärteil übereinstimmen (Überladen von == ).

Vererbung

Einführung

Oben wurde schon gesagt, dass Vererbung (inheritance) eine weitere Möglichkeit ist, wie bereits entwickelte Software wiederverwendet werden kann: Erbt eine Klasse — die Unterklasse — von einer anderen Klasse — der Oberklasse —, so erhält die Unterklasse die Datenelemente und Methoden der Oberklasse. Im Detail ist diese Aussage etwas komplizierter, da man zwischen privaten und öffentlichen Datenelementen und Methoden unterschieden muss; es gibt sogar noch eine dritte Zugriffs-Spezifikation: protected.

In den folgenden Abschnitten wird zunächst — an einem abstrakten Beispiel untersucht -, welche Eigenschaften bei Vererbung weitergegeben werden und welche nicht. Anschließend wird das Beispiel der Klasse Gamble nochmal aufgegriffen: diesmal soll sie als Unterklasse von RandomGenerator definiert werden.

In der Literatur sind die Begriffe Oberklasse und Unterklasse nicht die einzigen, die die Vererbungs-Beziehung beschreiben. Üblich sind auch:

Oberklasse =

  • Superklasse (super class)
  • Basisklasse (base class)
  • Elternklasse (parent class)

Unterklasse =

  • Subklasse (sub class)
  • abgeleitete Klasse (derived class)
  • Kindklasse (child class)

Syntax: Deklaration einer Klasse als Unterklasse

Im Folgenden wird ein Beispiel entwickelt, das inhaltlich nichtssagend ist, aber an dem gezeigt werden soll, wie Vererbung definiert wird und welche Datenelemente und Methoden an die Unterklasse weitergegeben werden.

Die Oberklasse wird mit SuperClass bezeichnet, die Unterklasse mit SubClass. Im UML-Klassendiagramm sehen sie wie folgt aus:

Abbildung 5: UML-Klassendiagramm für SuperClass und SubClass.Abbildung 5: UML-Klassendiagramm für SuperClass und SubClass.

Bei der Deklaration der Klasse SuperClass gibt es nichts Neues zu tun: die Klasse wird so deklariert (und implementiert) wie es bisher für Klassen geschehen ist. Man kann dies auch so sehen: Die Oberklasse weiß nichts davon, dass es Unterklassen gibt. Im Sinne der Wiederverwendbarkeit von Software ist dies nur konsequent.

Es gehört aber zum guten Programmier-Stil bei geplanten, komplexen Vererbungshierarchien in der Oberklasse — als Kommentar — anzugeben, welche Klassen Unterklassen sind.

Dagegen muss in der Deklaration der Unterklasse der Verweis auf die Oberklasse erfolgen, wie dies geschieht, zeigt der folgende Quelltext für SubClass:

#ifndef SUBCLASS_H
#define SUBCLASS_H

#include <SuperClass.h>

class SubClass : public SuperClass
{
// weitere Deklarationen
};

#endif // SUBCLASS_H

Zur Erklärung:

  1. Klar ist, dass die Oberklasse inkludiert werden muss (Zeile 4).
  2. Mit dem Doppelpunkt-Operator wird auf die Oberklasse SuperClass verwiesen (Zeile 6).
  3. Vor SuperClass steht die Zugriffs-Spezifikation public (ebenfalls Zeile 6). Hier könnte auch protected oder private stehen: im Folgenden wird aber nur public ausführlich erläutert.

Vererbung und Zugriffs-Spezifikation: public, protected, private

Einfaches Beispiel einer Vererbung

Die oben eingeführten Klassen SuperClass und SubClass sollen nun ein wenig mit Inhalt gefüllt werden, um die Vererbung besser zu verstehen. Dazu werden in der Oberklasse drei Datenelemente definiert mit den drei Zugriffs-Spezifikationen public, protected und private:

// SuperClass.h
#ifndef SUPERCLASS_H
#define SUPERCLASS_H


class SuperClass
{
    public:
        SuperClass();
        SuperClass(int a, int b, int c);

        // Ausgabe der Datenelemente a, b, c
        void print();

        int a;

    protected:
        int b;

    private:
        int c;
};

#endif // SUPERCLASS_H

Die Klasse besitzt also:

  • einen Default-Konstruktor (der nicht benötigt wird, Zeile 9)
  • einen Konstruktor, der die drei Datenelemente a, b, c setzt (Zeile 10)
  • eine Methode, die a, b, c ausgibt (Zeile 13)
  • drei int-Datenelemente a, b, c, die public, protected beziehungsweise private sind. Die Bedeutung der Zugriffs-Spezifikation wird schnell klar werden (Zeile 15, 18, 21).

Die Implementierung der Klasse SuperClass könnte wie folgt aussehen:

// SuperClass.cpp
#include "SuperClass.h"

#include <iostream>
using namespace std;

SuperClass::SuperClass()
{
}

SuperClass::SuperClass(int a, int b, int c) : a {a}, b {b}, c {c}
{
    cout << "\nAufruf des Konstruktors SuperClass mit 3 Argumenten"  << endl;
}

// Ausgabe der Datenelemente a, b, c
void SuperClass::print()
{
    cout << "\nAusgabe der Datenelemente: " << endl;
    cout << "public a = " << a << endl;
    cout << "protected b = " << b << endl;
    cout << "private c = " << c << endl;
    cout << "================" << endl;
}
  1. Der Default-Konstruktor macht nichts.
  2. Der Konstruktor mit drei Argumenten setzt die drei Datenelemente a, b, c (Initialisierungs-Liste) und macht eine Konsolen-Ausgabe.
  3. Die öffentliche Methode print() gibt die drei Datenelemente aus.

Die Unterklasse SubClass soll vorerst keine eigene Implementierungen besitzen, mit einer Ausnahme: sie soll ebenfalls den Konstruktor mit drei Argumenten haben, aber dieser soll das Setzen der Datenelemente an den Konstruktor der Oberklasse SuperClass weiterreichen.

Die Deklaration und Implementierung lauten dann:

// SubClass.h
#ifndef SUBCLASS_H
#define SUBCLASS_H

#include <SuperClass.h>

class SubClass : public SuperClass
{
    public:
        SubClass();
        SubClass(int a, int b, int c);
};

#endif // SUBCLASS_H
// SubClass.cpp
#include "SubClass.h"

#include <iostream>
using namespace std;

SubClass::SubClass()
{
}

SubClass::SubClass(int a, int b, int c) : SuperClass(a, b, c)           // Delegation an den Konstruktor der Oberklasse
{
    cout << "\nAufruf des Konstruktors SubClass mit 3 Argumenten"  << endl;
}

Aufgabe:

Skizzieren Sie das UML-Klassendiagramm für SuperClass und SubClass gemäß obigen Deklarationen.

Hinweis: Geschützte Datenelemente und Methoden (protected) werden mit # gekennzeichnet.

Zugriff auf Datenelemente

An den beiden Beispiel-Klassen soll nun der Zugriff auf die Datenelemente getestet werden. In einer main()-Methode (wieder in Client.cpp) werden nun je ein Objekte der Ober- und Unterklasse erzeugt, die Datenelemente mit Hilfe des Konstruktors gesetzt und ausgegeben:

// Client.cpp

#include "SuperClass.h"
#include "SubClass.h"

#include <iostream>
using namespace std;

int main()
{
    SuperClass superClass(1, 2, 3);
    superClass.print();
    cout << "sizeof(SuperClass): " << sizeof(SuperClass) << endl;

    SubClass subClass(4, 5, 6);
    subClass.print();
    cout << "sizeof(SubClass): " << sizeof(SubClass) << endl;

    return 0;
}

Die Konsolen-Ausgabe lautet:

Aufruf des Konstruktors SuperClass mit 3 Argumenten

Ausgabe der Datenelemente:
public a = 1
protected b = 2
private c = 3
================
sizeof(SuperClass): 12

Aufruf des Konstruktors SuperClass mit 3 Argumenten

Aufruf des Konstruktors SubClass mit 3 Argumenten

Ausgabe der Datenelemente:
public a = 4
protected b = 5
private c = 6
================
sizeof(SubClass): 12

Zur Erklärung:

  1. In der main()-Methode wird zuerst ein Objekt von SuperClass erzeugt, was zu einem Konstruktor-Aufruf (mit drei Argumenten) von SuperClass führt (erkennbar an der Konsolen-Ausgabe in Zeile 2).
  2. Der Aufruf der print()-Methode mit dem SuperClass-Objekt liefert die erwarteten Zahlenwerte für a, b, c (Zeilen 5 bis 7 der Konsolen-Ausgabe).
  3. Auch nicht schwer vorherzusehen ist der Speicherplatz, den ein Objekt der Klasse SuperClass belegt (Berechnung mit Hilfe von sizeof() in Zeile 13 der main()-Methode): 12 Byte, da drei Datenelemente mit je 4 Byte im SuperClass-Objekt vorhanden sind (Zeile 9 der Konsolen-Ausgabe).
  4. Anschließend wird ein Objekt der Unterklasse SubClass erzeugt (Zeile 15 in der main()-Methode). Man sieht an der Konsolen-Ausgabe, dass zuerst der Konstruktor von SuperClass und dann der Konstruktor von SubClass aufgerufen wird (Zeile 11 und 13 der Konsolen-Ausgabe). Auch dies ist mit dem Delegations-Prinzip verständlich, denn das Delegations-Prinzip reicht zuerst an den Konstruktor der Oberklasse weiter und dann erst wird der Körper des Unterklassen-Konstruktors ausgeführt (der die Anweisung für die Konsolen-Ausgabe enthält, siehe Zeile 11 und 13 in SubClass.cpp).
  5. Der Aufruf der print()-Methode liefert kein überraschendes Ergebnis: die Zahlen a, b, c werden so ausgegeben wie sie beim Erzeugen des Objektes gesetzt wurden. Überraschend ist vielmehr, dass print() aufgerufen werden kann. Denn die print()-Methode ist nicht in SubClass definiert, vielmehr wird sie durch den Vererbungs-Mechanismus der Unterklasse zur Verfügung gestellt.
  6. Zum Schluss lässt man in der main()-Methode den Speicherplatz des SubClass-Objektes berechnen: es belegt wieder 12 Byte (Zeile 20 der Konsolen-Ausgabe). Auch das ist nicht verwunderlich, da kein neues Datenelement hinzugekommen ist.

Der letzte Punkt zeigt, dass hier eigentlich nichts geschehen ist: Die Klasse SubClass ist identisch mit der Klasse SuperClass. Hier ist also noch kein sinnvoller Einsatz der Vererbung zu sehen; es sollte nur die Syntax der Vererbung und der Mechanismus gezeigt werden, wie Daten und Funktionalitäten an die Unterklasse weitergegeben werden (Setzen von Datenelementen, die eigentlich in der Oberklasse definiert sind; Aufruf der Methode print() aus der Oberklasse).

Eine sinnvolle Erweiterung der Unterklasse ergibt sich, indem eine weitere Methode definiert wird. Man deklariert zusätzlich in SubClass.h eine öffentliche Methode sum(), die die Summe der drei Zahlen a, b, c berechnen soll:

int sum();

Ihre Implementierung in SubClass.cpp soll lauten:

// Summe der 3 Zahlen a, b, c
int SubClass::sum()
{
    //return (a + b + c);         // Fehler: "c is private"
    //return a;                 // ok
    return (a + b);             // ok
}

Mit dem return-statement aus Zeile 4 erhält man einen Compiler-Fehler: Das Datenelement c ist private und daher kann man in einer Methode aus der Unterklasse nicht darauf zugreifen. Dagegen sind die return-statements aus Zeile 5 und 6 erlaubt: auf Datenelemente, die als public oder protected spezifiziert sind, kann man innerhalb der Methode der Unterklasse zugreifen.

Und es sollte auch klar sein, dass mit einem SuperClass-Objekt die Methode sum() nicht aufgerufen werden kann (die Oberklasse weiß nichts von ihren Unterklassen).

Betrachtet man die Zugriffs-Spezifikationen public, protected und private aus der Sicht der Oberklasse, ergibt sich:

  1. public wird verwendet für Datenelemente, auf die aus allen anderen Klassen heraus zugegriffen werden kann,
  2. protected für Datenelemente, auf die aus Unterklassen aber nicht aus anderen Klassen zugegriffen werden kann und
  3. private für Datenelemente, auf die nur die eigene Klasse zugreifen kann.

Analoges gilt für Methoden.

Die Eigenschaften für den Zugriff auf Datenelemente, die hier vorgestellt wurden gelten, falls in der Deklaration der Unterklasse public angegeben war, also in diesem Beispiel:

class SubClass : public SuperClass

Hätte man hier protected oder private angegeben, ergeben sich andere Zugriffs-Regeln. Am häufigsten wird public eingesetzt und daher sollen die anderen Fälle nicht diskutiert werden.

Überschreiben von Methoden (function overriding)

Betrachtet man obiges Beispiel etwas näher, sollte der Zugriff auf das private Datenelement c stutzig machen:

  • Einerseits ist es möglich mit Hilfe der Methode print() das Datenelement c auszugeben (siehe Client.cpp, Zeile 16).
  • Andererseits ist der Zugriff auf c in der Methode sum() nicht möglich (siehe Implementierung von sum(), Zeile 4).

Der Widerspruch kann folgendermaßen aufgelöst werden:

  • Die Methode print() ist in der Oberklasse SuperClass definiert. Auch wenn diese Methode mit einem Objekt der Unterklasse aufgerufen wird (siehe Client.cpp, Zeile 16), wird die print()-Methode aus SuperClass aufgerufen und diese hat Zugriff auf das private Datenelement c.
  • Die Methode sum() ist dagegen in der Unterklasse SubClass definiert und hat daher keinen Zugriff auf c.

Beim Befehl (Zeile 16 aus Client.cpp)

subClass.print();

sucht der Compiler zuerst, ob es eine Deklaration der Funktion print() in der Klasse SubClass gibt. Falls ja, wird diese Funktion ausgeführt; falls nein, wird in der Oberklasse nach einer Deklaration von print() gesucht und dann diese Funktion ausgeführt (beziehungsweise wird die Vererbungshierarchie nach oben weiterverfolgt).

Daher kann man jetzt die Funktion print() in der Unterklasse überschreiben, das heißt die Methode print() wird jetzt in SubClass.h deklariert und in SubClass.cpp implementiert, wobei man natürlich eine abweichende Implementierung schreiben kann. Wird jetzt die print()-Methode mit einem SubClass-Objekt aufgerufen (Zeile 16 aus Client.cpp), wird die neu implementierte Methode ausgeführt. Im Folgenden werden die Quelltexte gezeigt.

Ergänzungen zu SubClass.h:

// Ausgabe der Datenelemente
// Überschreiben der Methode aus der Oberklasse
void print();

Ergänzungen zu SubClass.cpp:

void SubClass::print()
{
    cout << "\nNeue Version: Ueberschreiben von print() in SubClass" << endl;
    cout << "\nAusgabe der Datenelemente: " << endl;
    cout << "public a = " << a << endl;
    cout << "protected b = " << b << endl;
    //cout << "private c = " << c << endl;          // Compiler-Fehler: "c is private"
    cout << "================" << endl;
}

Die Ausgabe des privaten Datenelementes c ist mit der neuen print()-Methode nicht mehr möglich.

Aber man kann mit dem Objekt der Unterklasse die print()-Methode der Oberklasse aufrufen, siehe Zeile 3 in folgendem Quelltext (hier ist nur der entsprechende Inhalt der main()-Methode aus Client.cpp gezeigt):

SubClass subClass(4, 5, 6);
subClass.print();
subClass.SuperClass::print();

Die Konsolen-Ausgabe lautet jetzt:

Neue Version:Ueberschreiben von print() in SubClass

Ausgabe der Datenelemente:
public a = 4
protected b = 5
================

Ausgabe der Datenelemente:
public a = 4
protected b = 5
private c = 6
================

Umgekehrt ist es natürlich nicht möglich, von der Oberklasse aus eine von der Unterklasse überschriebene Methode aufzurufen (wiederum: die Oberklasse weiß nichts von ihren Unterklassen).

Man sieht, dass das Überschreiben von Methoden gerade im Sinne der Wiederverwendbarkeit von Software realisiert ist: man kann entweder die Methode aus der Oberklasse nutzen oder man kann sie in der Unterklasse geeignet abändern.

Aufgaben:

  1. Skizzieren Sie das UML-Klassendiagramm für die beiden Klassen SuperClass und SubClass, das jetzt auch die überschriebene print()-Methode enthält.
  2. Testen Sie das Erzeugen eines Objektes einer Unterklasse in folgendem Szenario:
  • Die Unterklasse besitzt einen Konstruktor, der nicht (wie im Beispiel oben) an einen Konstruktor der Oberklasse delegiert.
  • Die Oberklasse besitzt einen Default-Konstruktor.

Wird der Default-Konstruktor der Oberklasse aufgerufen oder nicht?

Wenn ja: wann geschieht dies?

Bevor Sie testen: Welche Antworten erscheinen Ihnen sinnvoll?

Realisierung des Glücksspiels Gamble mit Hilfe der Vererbung

Einführung

Mit dem Beispiel SuperClass und SubClass wurde lediglich die Syntax der Vererbung vorgestellt und es wurden einige Regeln erläutert, wie man auf Datenelemente und Methoden zugreifen kann oder wann dieser Zugriff verboten ist. Wie man Vererbung sinnvoll einsetzen kann, ist aus diesem — inhaltsleeren — Beispiel schwer ablesbar.

Oben wurde gezeigt, wie man eine Klasse Gamble definieren kann, die die Komposition nutzt, um die bereits vorhandene Klasse RandomGenerator einzusetzen. In diesem Abschnitt wird gezeigt, wie man dies mit Hilfe der Vererbung realisieren kann.

Die Klasse Gamble als Unterklasse von RandomGenerator

In Abbildung 3 wurde bereits ein Vorschlag gemacht, wie die Klasse Gamble als Unterklasse von RandomGenerator realisiert werden könnte. Dort wurde auch angedeutet, dass sich die Implementierung deutlich vereinfachen lässt, wenn man die Prototypen der Methoden anders wählt und weitere Datenelemente einführt. Ein Lösungsvorschlagen — von vielen möglichen — für diese Vererbung könnte aussehen wie in Abbildung 6.

Abbildung 6: UML-Klassendiagramm für Gamble als Unterklasse von RandomGenerator.Abbildung 6: UML-Klassendiagramm für Gamble als Unterklasse von RandomGenerator.

Die Bedeutung der Datenelemente und Methoden lässt sich leicht erklären, wenn man sich überlegt, wie aus einer main()-Methode heraus ein Objekt der Klasse Gamble erzeugt und ein Spiel ausgeführt wird.

Gamble gamble(1, 6);
gamble.play(4, 1, 14);          // 4 mal Würfeln, Einsatz 1, Gewinn bei "Augensumme größer als 14"
cout << "gewonnen: " << gamble.win() << endl;
cout << "Betrag: " << gamble.pay() << endl;

Zur Erklärung:

1. Zeile 1: Der Konstruktor setzt die Datenelemente min und max der Oberklasse RandomGenerator, um ein Würfelspiel mit Ergebnissen von 1 bis 6 zu erzeugen.

2. Ein anderer als dieser Konstruktor ist also nicht nötig.

3. Zeile 2: Die öffentliche Methode play() erhält die drei Eingabewerte:

  • number: die Zahl der Würfe
  • stake: den Einsatz,
  • sum: die Augensumme; ist die tatsächlich erreichte Augensumme größer als _sum_, gewinnt der Spieler.

4. Die Methode setzt diese drei privaten Datenelemente, damit sie bei späteren Methoden-Aufrufen nicht übergeben werden müssen.

5. Weiter ruft sie aus der Oberklasse RandomGenerator die Methode createRandomSequence() auf und setzt damit das Datenelement randomSequence.

6. Zeile 3: Die Methode win() entscheidet, ob der Spieler gewonnen hat. Dabei ruft sie die private Methode getSumOfRandomSequence() auf, die das Datenelement randomSequence auswertet und die Augensumme berechnet.

7. Zeile 4: Die Methode pay() berechnet, wie hoch der Gewinn ist, der an den Spieler ausbezahlt wird. Falls er nicht gewonnen hat, ist der Betrag gleich null. Andernfalls muss das Würfelspiel genauer untersucht werden: Um zu berechnen, wie hoch die Wahrscheinlichkeit ist, dass bei number Würfen eine Augensumme größer als sum entsteht, wird auf die private Methode

numberOfPossibilities(int number, int totalValue)

zurückgegriffen; sie gibt an, wieviele Möglichkeiten es gibt, dass bei number Würfen eine Augensumme von genau totalValue entsteht. Hier und in der anschließenden Berechnung des Auszahlungsbetrages (so dass für einen Laplace-Würfel ein faires Spiel entsteht) sind also die mathematisch schwierigsten Teile der Implementierung enthalten.

Diskussion des Klassen-Designs

Datenelemente, Methoden und Konstruktoren

Aufgabe: So wie die Klasse Gamble aktuell deklariert ist (siehe Abbildung 6), enthält sie einen schweren Design-Fehler.

  • Identifizieren Sie diesen Fehler!
  • Welche Gefahr birgt ein derartiger Fehler?
  • Diskutieren Sie: Wie kann man den Fehler korrigieren?

Oben wurde gesagt, dass es eine Vielzahl von Möglichkeiten gibt, wie die Datenelemente und Methoden der Klasse Gamble definiert werden können. Man sieht dies insbesondere an der Methode pay(), die den Gewinn des Spielers (das Datenelement payout) berechnet und diesen als Rückgabewert besitzt. Dies sollte man nicht so belassen, denn die Getter-Methode getPayout() gibt ebenfalls den Gewinn des Spielers zurück. Für eine Aufgabe sollen niemals zwei Methoden existieren, denn es besteht die Gefahr, dass die Methoden inkonsistent implementiert werden (also unterschiedliche Ergebnisse liefern, obwohl sie identisch sein müssen).

Es gibt mehrere Möglichkeiten, wie man dieses ungeschickte Design der Klasse korrigiert:

  1. Enteweder: Die Methode pay() erhält als Typ des Rückgabewertes void und sie dient nur dazu den Gewinn zu berechnen, der dann mit Hilfe von setPayout() gesetzt wird. Von außen abgefragt werden kann der Gewinn dann nur durch den Getter getPayout().
  2. Oder: Man verzichtet auf das Datenelement payout; der Gewinn wird wie bisher mit der Methode pay() berechnet und der Gewinn kann nur über diese Methode abgefragt werden.

Ähnlich kann man auch die Berechtigung der Methoden play() und win() hinterfragen: Dass ein Spiel startet, anschließend festgestellt wird, ob der Spieler gewonnen hat und sein Gewinn berechnet wird, könnte auch schon durch einen geeigneten Konstruktor-Aufruf angestoßen werden. Die Methoden play() und win() könnten dann als private Methoden realisiert werden. Nötig ist dann der Konstruktor mit folgender Parameterliste:

Gamble(int minimum, int maximum, int number, int stake, int sum)

Jetzt konfiguriert der Konstruktor den Würfel (durch Setzen von minimum und maximum) und nimmt die Eingaben des Spielers entgegen.

Man kann hier keine generelle Regel angeben, wie die Aufgaben zwischen den Konstruktoren und den Methoden zu verteilen sind. Was in diesem Beispiel nicht geschehen ist, was aber in einem realen Projekt ausführlich geschehen muss, ist die genaue Festlegung der Anwendungsfälle (use cases). Bevor man die Deklarationen festlegt, muss klar sein, wie das Programm später vom Nutzer verwendet wird und daraus lassen sich oft derartige Design-Entscheidungen ableiten.

Komposition oder Vererbung?

Ebenso kann man jetzt die Diskussion aufnehmen, ob es besser ist, die Klasse Gamble mit Hilfe der Komposition (Abbildung 2) oder der Vererbung (Abbildung 3 beziehungsweise 6 ) zu realisieren. Eindeutig lässt sich die Frage nicht beantworten, hier einige Anhaltspunkte:

  • Möglich sind beide Lösungen und es ist weder die eine noch die andere Lösung klar zu bevorzugen.
  • Sind — so wie hier — nur zwei Klassen betroffen, wird meist die Komposition eingesetzt.
  • Erst bei komplexeren Vererbungshierarchien bevorzugt man die Vererbung.
  • Dies ist auch dann der Fall, wenn absehbar ist, dass weitere Klassen hinzukommen werden, die sich sinnvoll in einer Vererbungshierarchie anordnen lassen.

Angenommen die Klasse Gamble soll nicht nur für ein Würfelspiel verwendet werden, sondern für weitere Glücksspiele wie Werfen einer Münze oder Roulette, so ist eine Vererbungshierarchie der Komposition vorzuziehen; man könnte die zugehörigen Klassen wie in folgender Abbildung 7 anordnen.

Abbildung 7: UML-Klassendiagramm für Gamble als Unterklasse von RandomGenerator, wobei Gamble als Oberklasse für weitere Glücksspiele fungiert.Abbildung 7: UML-Klassendiagramm für Gamble als Unterklasse von RandomGenerator, wobei Gamble als Oberklasse für weitere Glücksspiele fungiert.

Man kann sich jetzt überlegen:

  • Welche Datenelemente und Methoden sind für alle Glücksspiele identisch und werden somit in der Klasse Gamble definiert?
  • Müssen Methoden in den Unterklassen überschrieben werden?
  • Welche Datenelemente und Methoden müssen für die speziellen Glücksspiele eigens definiert werden?

Kombination von Komposition und Vererbung: Implementierung einer Baumstruktur, polymorphe Funktionen

Einführung

Oben wurde die Komposition ausführlich am Beispiel von Ordner und Datei erklärt, wobei das dortige Modell noch weit von einer echten Ordnerstruktur entfernt war: Bisher konnte ein Ordner nur Dateien, aber keine Unterordner enthalten.

Es gibt zahllose Ansätze, wie man die Baumstruktur eines Dateisystems realisieren kann; dabei ist mit Baumstruktur gemeint:

  1. Es gibt ein eindeutiges Wurzelelement (root) des Dateisystems.
  2. Jeder Ordner kann beliebig viele Dateien oder Unterordner enthalten.
  3. Ein Unterordner hat die selben Eigenschaften wie ein Ordner.
  4. Dass man ein Dateisystem nicht beliebig tief verschachteln kann, soll hier nicht berücksichtigt werden.
  5. Es soll eine einfache Navigation innerhalb des Baumes möglich sein, so dass zum Beispiel der gesamte Speicherplatz des Ordners berechnet werden kann, wenn man annimmt, dass der Speicherplatz jeder Datei bekannt ist und nur diese zum gesamten Speicherplatz beitragen (klar ist, dass auch die Ordner und Unterordner — selbst wenn sie keine Dateien enthalten — einen gewissen Speicherplatz einnehmen).
  6. Die Navigation kann in zwei Richtungen erfolgen, sie hat aber jeweils eine andere Qualität:
    • von oben nach unten: hier gibt es wiederum zwei Möglichkeiten, nämlich
      • ein Ordner hat beliebig viele Elemente, die entweder Ordner oder Dateien sind,
      • erreicht man eine Datei, kann man nicht mehr tiefer absteigen.
    • von unten nach oben: sowohl ein Ordner als auch eie Datei haben ein eindeutiges übergeordnetes Element.

Hier soll ein Ansatz vorgestellt werden, der Komposition und Vererbung geschickt vereinigt, so dass man die Baumstruktur leicht implementieren kann. In der Literatur ist der Ansatz bekannt als das Design-Pattern Composite.

Aufbau der Baumstruktur

Erster falscher Ansatz

Achtung: Zunächst wird ein zu einfacher und sogar falscher Ansatz vorgestellt, um die Baumstruktur eines Dateisystems zu realisieren.

Dieser falsche Ansatz ist aber sehr instruktiv und wird daher ausführlich besprochen — er führt sehr gut das wichtige Konzept des Polymorphismus ein. Und es ist — nachdem man verstanden hat, warum der Ansatz falsch ist — nicht schwer, durch kleine Veränderungen einen richtigen Ansatz zu finden.

Wie kann man die oben beschriebene Baumstruktur implementieren? Man definiert dazu eine Oberklasse Component, die sowohl für einen Ordner (Folder) als auch eine Datei (File) stehen kann. Allerdings ist diese Klasse nicht dafür vorgesehen, dass von ihr Objekte erzeugt werden. Man nennt derartige Klassen abstrakte Klassen.

Component dient dann als Oberklasse für Folder und File und deklariert somit alle Datenelemente und Methoden, die in den beiden Unterklassen vorkommen werden. Die Klasse Folder wird dann ähnlich wie im bereits besprochenen Beispiel (siehe Abschnitt Komposition implementiert: Der große Unterschied ist, dass ein Folder -Objekt nicht aus mehreren File-Objekten zusammengesetzt ist, sondern aus Component-Objekten. Dabei darf man Component-Objekte nicht falsch verstehen: es gibt ja keine Component-Objekte; diese Sprechweise soll dafür stehen, dass ein Folder-Objekt aus Objekten zusammengesetzt ist, die entweder selber Folder- oder File-Objekte sind.

Ein Beispiel für ein gemeinsames Datenelement ist

string name;

also der Name der Datei beziehungsweise des Ordners. Da der Name in der Oberklasse Component deklariert wird und an die Unterklassen vererbt werden soll, muss er als protected gekennzeichnet werden.

Ein Beispiel für eine Methode, die sowohl in Folder als auch in File vorkommt, und somit in der Oberklasse Component definiert ist:

double getDiskSpace();

Für eine Datei gibt sie einfach den von der Datei belegten Speicherplatz an, für einen Ordner muss sie aus allen enthaltenen Elementen (Unterordner und Dateien) deren Speicherplatz aufaddieren.

Dagegen gibt es Methoden wie

void addChild(Component component);

void removeChild(Component component);

nur in der Klasse Folder, aber nicht in der Klasse File.

Man beachte den Unterschied zur früher besprochenen einfachen Realisierung eines Ordners: Damals wurde einem Ordner eine Datei hinzugefügt oder eine Datei konnte gelöscht werden. Jetzt ist der Typ des Eingabewertes Component, das heißt es kann sich jeweils um einen Ordner oder um eine Datei handeln, die hinzugefügt oder gelöscht wird.

Das UML-Klassendiagramm für die drei Klassen Component, Folder und File ist mit den wichtigsten Datenelementen und Methoden in Abbildung 8 dargestellt. Die Klassen Folder und File sind Unterklassen der abstrakten Klasse Component, aber gleichzeitig kann ein Folder-Objekt beliebig viele Component-Objekte enthalten. Letzeres ist aber so zu verstehen, dass die Component-Objekte entweder Objekte der Klassen Folder und File sind.

Das Datenelement name in Component soll an alle Unterklassen vererbt werden und ist daher als protected gekennzeichnet. Das Datenelement diskSpace gibt es nur für eine Datei und kann dort als private gekennzeichnet werden.

Die Methode getDiskSpace() ist in File eine echte Getter-Methode, dagegen in Folder eine komplizierte Methode, deren Implementierung die Baumstruktur wiedergeben wird: Um den gesamten Speicherplatz eines Ordners zu bestimmen, müssen alle Unterordner und darin enthaltene Dateien aufgesucht und deren Speicherplatz aufaddiert werden. Daher kann getDiskSpace() in Component noch nicht implementiert sondern nur deklariert werden; und die beiden Implementierungen in den Unterklassen werden sehr unterschiedlich ausfallen. Aber genau hier wird sich zeigen, warum dieser Ansatz falsch ist. Geeignete Konstruktoren sind im UML-Diagramm nicht dargestellt — man kann sich aber leicht überlegen, welche Konstruktoren ein Nutzer des Programmes erwartet.

Abbildung 8: UML-Klassendiagramm für Component, Folder und File.Abbildung 8: UML-Klassendiagramm für Component, Folder und File.

Die Deklarationen für die drei Klassen Component, Folder und File lauten:

// Component.h
// abstrakte Klasse (entspricht File oder Folder)

#include <string>
#include <vector>

#ifndef COMPONENT_H_INCLUDED
#define COMPONENT_H_INCLUDED

class Component
{
public:

    // getName() und setName() werden hier schon implementiert, obwohl es kein Objekt geben kann
    // Implementierungen sind aber in allen Unterklassen identisch

    std::string getName()
    {
        return name;
    }

    void setName(std::string value)
    {
        name = value;
    }

    int getDiskSpace();

protected:

    std::string name;

};

#endif // COMPONENT_H_INCLUDED
// Folder.h

#include <vector>
#include <string>
#include "Component.h"

#ifndef FOLDER_H_INCLUDED
#define FOLDER_H_INCLUDED

class Folder : public Component         // Folder ist Unterklasse von Component
{
public:

    // Konstruktor
    Folder(std::string newName);

    void addChild(Component component);

    void removeChild(Component component);

    std::vector<Component> getChildren()
    {
        return children;
    }

    int getDiskSpace();

private:

    std::vector<Component> children;

};
#endif // FOLDER_H_INCLUDED
// File.h

#include <vector>
#include <string>
#include "Component.h"

#ifndef FILE_H_INCLUDED
#define FILE_H_INCLUDED

class File : public Component           // File ist Unterklasse von Component
{
public:

    // Konstruktor
    File(std::string newName, int diskSp);

    int getDiskSpace();

private:

    int diskSpace;

};
#endif // FILE_H_INCLUDED

Die Implementierung wird nicht vollständig angegeben:

  1. Die Konstruktoren setzen nur die genannten Datenelemente und erfüllen sonst keine Aufgabe.
  2. Die Methoden addChild() und removeChild() werden völlig analog zum einfacheren Projekt für die Ordnerstruktur definiert: Dort konnte nur eine Datei hinzugefügt oder gelöscht werden. Jetzt ist neu, dass entweder ein Ordner oder eine Datei Eingabewert sind und daher steht die abstrakte Klasse Component als Datentyp für den Eingabewert.
  3. Einzig die Methode getDiskSpace() soll hier näher betrachtet werden, denn an ihr sieht man, warum der Ansatz falsch ist.

In der Klasse File ist getDiskSpace() eine simple Getter-Methode, die keiner näheren Erklärung bedarf:

// File.cpp

// ...

int File::getDiskSpace()
{
    return diskSpace;
}

Dagegen ist in der Klasse Folder ein rekursiver Aufruf der Methode getDiskSpace() nötig, um für alle Dateien und Unterordner eines Ordners die belegten Speicherplätze aufzusammeln. Die Implementierung könnte wie folgt aussehen:

// Folder.cpp

// ...

int Folder::getDiskSpace()
{
    int sum = 0;
    for (Component c : children)
    {
        sum += c.getDiskSpace();            // Compiler-Fehler
    }
    return sum;
}

Das Datenelement children ist ein Vektor, der alle File- und Folder-Objekte enthält, die in einem Folder-Objekt enthalten sind. Der Datentyp von children ist:

std::vector<Component> children;

Daher wird eine Schleife gebildet, die über alle children-Objekte iteriert und man versucht für jedes children-Objekt die Methode getDiskSpace() aufzurufen (siehe Zeile 10 in Folder.cpp). Aber genau hier liegt der Fehler dieses Ansatzes: Der Compiler weiß in Zeile 10 nicht, um welches Objekt es sich bei c handelt; zur Compilierungs-Zeit ist nur bekannt, dass c vom (abstrakten!) Datentyp Component ist. Erst wenn das Programm ausgeführt wird, entscheidet sich, ob c ein File- oder ein Folder-Objekt ist. Und daher kann erst bei der Ausführung des Programmes entschieden werden, welche Methode mit getDiskSpace() angesprochen wird, die aus File oder die aus Folder.

Das heißt Zeile 10 führt zu einem Compiler-Fehler und es ist nicht klar, wie man diesen Fehler vermeiden soll. Die nicht ganz einfache Lösung zeigt der folgende Unterabschnitt.

Zweiter richtiger Ansatz

Den Hinweis, wie ein richtiger Ansatz aussehen könnte, gibt die Schleife aus Folder.cpp in Zeile 8 bis 11:

for (Component c : children)
    {
        sum += c.getDiskSpace();
    }

Es muss erst während der Programm-Ausführung (und nicht schon beim Compilieren) entschieden werden, von welchem Datentyp c ist: entweder Folder oder File und je nachdem wird die entsprechende Methode getDiskSpace() ausgeführt. Dass c vom Datentyp Component ist, ist irrelevant, da in der Klasse Component noch keine Implementierung von getDiskSpace() existiert.

Dieses hier geforderte Verhalten — also die Entscheidung darüber, von welchem Datentyp c ist und der Aufruf der richtigen Implementierung von getDiskSpace() — wird als Polymorphismus bezeichnet. Der Name rührt daher, dass die in Component deklarierte Methode getDiskSpace() vielgestaltig ist und je nachdem mit welchem Objekt sie aufgerufen wird eine andere Erscheinung (eine andere Implementierung) zeigt.

Um einen richtigen Ansatz zu finden, muss man also überdenken, ob man nicht das Datenelement children in einer anderen Form abspeichern kann, die den Polymorphismus ermöglicht. Bisher war children vom Datentyp eines Vektors (definiert in Folder.h, Zeile 30):

std::vector<Component> children;

Da die Einträge im Vektor von abstrakten Datentyp Component sind, versteht der Compiler den Zugriff c.getDiskSpace() nicht. Wie kann man anstelle des abstrakten Datentyps hier mit den konkreten Datentypen Folder und File arbeiten? Mit den Konzepten, die bisher besprochen wurden, ist dies nicht möglich; man muss um dieses Problem zu lösen die sogenannten Zeiger (pointer) einführen.

Das Konzept des Zeigers ist verwandt (aber nicht identisch) mit dem Konzept der Referenz auf eine Variable; um Zeiger zu verstehen, sollte man sich nochmals vergegenwärtigen:

1. Eine Variable hat stets einen eindeutigen Datentyp; der Wert einer Variable wird in einer Speicherzelle abgelegt, deren Größe sich nach dem Datentyp richtet. Der Name der Variable dient dazu, auf den Inhalt der Speicherzelle zuzugreifen. Ein einfaches Beispiel, an dem man sich dies klarmachen kann:

int x {17};
cout << "x = " << x << endl;            // x = 17

2. Eine Referenz auf eine Variable erlaubt auf eine Variable unter einem anderen Namen zuzugreifen. Ein Beispiel:

int x {17};                 // Variable
int & y {x};                // Referenz auf die Variable x
cout << "x = " << y << endl;            // x = 17, obwohl y ausgegeben wird

In dieser Form werden Referenzen eigentlich nicht verwendet. Aber es wurde diskutiert, dass Referenzen eingesetzt werden sollen, wenn bei Funktionsaufrufen mit call by value Objekte kopiert werden müssen, die sehr viel Speicherplatz belegen.

Ein Zeiger (pointer) ist eine Variable, die eine Adresse speichert; dies kann die Adresse einer Variable (oder einer Funktion, dies wird hier aber nicht behandelt) sein. Die Adresse der Variable alleine reicht aber nicht, um die Variable eindeutig zu identifizieren, da je nach Datentyp der Variable der Inhalt der Speicherzelle anders interpretiert werden muss. Daher muss der Zeiger auch beinhalten, von welchem Datentyp die Variable ist, deren Adresse er speichert.

Beispiel: Definition eines Zeigers

Folder img("img");                      // Erzeugen eines Folder-Objektes img mit dem Konstruktor, der den Namen setzt
Folder * ptr_img = & img;               // Zeiger auf das Folder-Objekt img ; Initialisierung  mit der Adresse von img
cout << "Adresse von img mit Zeiger: " << ptr_img << endl;                  // Adresse von img mit Zeiger: 0x64fea0
cout << "Adresse von img mit Adress-Operator: " << & img << endl;           // Adresse von img mit Adress-Operator: 0x64fea0

In diesem Beispiel wird:

1. Zuerst ein Objekt namens img der Klasse Folder erzeugt (Zeile 1).

2. Anschließend wird ein Zeiger ptr_img auf dieses Objekt erzeugt. Der Datentyp eines Zeigers auf ein Folder-Objekt ist (siehe Zeile 2):

Folder * ptr_img;           // lies: ptr_img ist von Datentyp "Zeiger auf Folder"

Und dieser Zeiger wird mit der Adresse des Objektes img initialisiert. Der Operator & ist der sogenannte Adress-Operator.

3. Will man sich die Adresse des Ordners img ausgeben lassen, so kann dies entweder mit Hilfe des Zeigers (Zeile 3) oder mit Hilfe des Adress-Operators geschehen (Zeile 4). Die Adresse wird jeweils als Hexadizamalzahl ausgegeben.

Tip: Verwenden Sie eine leicht eingängige Namenskonvention, um Variablen von Zeigern zu unterscheiden. Es bieten sich an:

  • jeder Name eines Zeigers beginnt mit p, oder noch besser
  • jeder Name eines Zeigers beginnt mit ptr.

Neheliegend ist es sich zu fragen, warum zusätzlich zu den Variablen, die den Zugriff auf den Inhalt einer Speicherzelle erleichtern, eine weitere Variable (der Zeiger) definiert wird, die die Adresse der Speicherzelle beinhaltet? Der Grund ist einfach:

Zeiger erlauben den polymorphen Zugriff auf Funktionen, die in einer Vererbungshierarchie überschrieben wurden.

Wie man diesen polymorphen Zugriff realisiert, soll nun an dem Beispiel der Baumstruktur des Dateisystems erläutert werden.

Der Ausgangspunkt ist, dass das Datenelement children von Folder nicht wie bisher als Vektor von Component-Objekten

std::vector<Component> children;

definiert wird, sondern als Vektor von Zeigern auf Component-Objekte:

std::vector<Component *> ptr_children;

Und alle weiteren Quelltexte werden lediglich an diese neue Definition angepasst.

Interessant ist insbesondere, wie die Schleife verändert werden muss, die die polymorphe Funktion getDiskSpace() aufruft; bisher war sie folgendermaßen realisiert, was aber den Polymorphismus nicht unterstützt hat:

for (Component c : children)
    {
        sum += c.getDiskSpace();
    }

Die entsprechende Schleife über ptr_children sieht dann so aus:

for (Component * c : ptr_children)          // c ist Zeiger auf Component
    {
        sum += c->getDiskSpace();           // Zugriff auf eine Funktion mit dem Pfeil-Operator
    }

Neu sind hier zwei Dinge:

  1. Die Elemente von ptr_children sind Zeiger auf Component, also muss das Element c, das die Iteration ausführt, entsprechend definiert werden.
  2. Der Zugriff eines Zeigers auf eine Funktion erfolgt nicht mit dem Operator . , sondern mit dem Pfeil-Operator -> (siehe Zeile 3).

Die vorerst wichtigsten Teile der Quelltexte der drei Klassen Component, Folder und File lauten:

// Component.h
// abstrakte Klasse (entspricht File oder Folder)

#include <string>
#include <vector>

#ifndef COMPONENT_H_INCLUDED
#define COMPONENT_H_INCLUDED


class Component
{
public:

    //Component getParent();

    // getName() und setName() werden hier schon implementiert, obwohl es kein Objekt geben kann
    // Implementierungen sind aber in allen Unterklassen identisch

    std::string getName()
    {
        return name;
    }

    void setName(std::string value)
    {
        name = value;
    }

    virtual int getDiskSpace() = 0;

protected:

    std::string name;

};

#endif // COMPONENT_H_INCLUDED
// Folder.h

#include <vector>
#include <string>
#include "Component.h"

#ifndef FOLDER_H_INCLUDED
#define FOLDER_H_INCLUDED

class Folder : public Component
{
public:

    Folder(std::string newName);

    void addChild(Component * component);

    void removeChild(Component * component);

    std::vector<Component *> getChildren()
    {
        return ptr_children;
    }

    virtual int getDiskSpace();

private:

    std::vector<Component *> ptr_children;

};
#endif // FOLDER_H_INCLUDED
// File.h

#include <vector>
#include <string>
#include "Component.h"

#ifndef FILE_H_INCLUDED
#define FILE_H_INCLUDED

class File : public Component
{
public:

    File(std::string newName, int diskSp);

    virtual int getDiskSpace();

private:

    int diskSpace;

};
#endif // FILE_H_INCLUDED
// Folder.cpp

#include <vector>
#include <string>
#include "Folder.h"

// Konstruktor

// addChild(), removeChild()

int Folder::getDiskSpace()
{
    int sum = 0;
    for (Component * c : ptr_children)
    {
        sum += c->getDiskSpace();
    }
    return sum;
}

Erklärungsbedürftig ist hier lediglich das Schlüsselwort virtual , das mehrfach verwendet wird (immer bei der Deklaration und den Implementierungen von getDiskSpace()): Es kennzeichnet eine Funktion als sogenannte virtuelle Funktion, womit man im Quelltext andeuten kann, dass diese Funktion in den Unterklassen überschrieben wird. Zur besseren Lesbarkeit empfiehlt es sich auch die überschriebenen Funktionen in den Unterklassen nochmals mit virtual zu kennzeichnen.

Ein Test der hier vorgestellten Klasse in einer main()-Methode könnte wie folgt aussehen:

Folder img("img");

File residence("residence.jpg", 105);
img.addChild(& residence);
    
Folder house("house");

File door ("door.jpg", 100);
File window ("window.jpg", 200);

house.addChild(& door);
house.addChild(& window);

Folder garden("garden");

File cat ("cat.jpg", 120);
File tree ("tree.jpg", 250);

garden.addChild(& cat);
garden.addChild(& tree);

img.addChild(& house);
img.addChild(& garden);

cout << "img diskSpace: " << img.getDiskSpace() << endl;                // img diskSpace: 775		// 775 = 300 + 370 + 105
cout << "house diskSpace: " << house.getDiskSpace() << endl;            // house diskSpace: 300
cout << "garden diskSpace: " << garden.getDiskSpace() << endl;          // garden diskSpace: 370

Es wird ein Ordner (img, Zeile 1) mit zwei Unterordnern (house und garden, Zeile 6 und 14) und einer Datei (residence.jpg, Zeile 3) erzeugt; in jedem Unterordner liegen jeweils zwei Dateien. Der rekursive Aufruf der Funktion getDiskSpace() (Zeile 25) bildet die Summe aller belegten Speicherplätze.

Aufgabe:

Legen Sie das Projekt an, das die Baumstruktur eines Ordnersystems mit Hilfe der Vererbung und des Polymorphismus realisiert (der Großteil der Quelltexte ist oben wiedergegeben).

Implementieren Sie die noch fehlenden Methoden addChild() und removeChild().

Rekursionen

Die polymorphe Methode getDiskSpace() kann dazu verwendet werden, in der Ordnerstruktur von oben nach unten zu navigieren. Realisiert wurde dies durch den rekursiven Aufruf:

  • für einen Ordner wird die Funktion selbst aufgerufen,
  • für eine Datei wird einfach der Wert des Datenelementes diskSpace zurückgegeben.

Für manche Anwendungen benötigt man, dass man sich in der Baumstruktur von unten nach oben bewegen muss. Soll zum Beispiel zu einer Datei der vollständige Pfad angegeben werden, muss man wissen, in welchem Ordner sich die Datei befindet, in welchem Ordner dieser Ordner ist und so weiter bis man beim Wurzelelement des Dateisystems angelangt ist.

Mit den hier vorgestellten Quelltexten ist diese Navigation nicht möglich, da zum Beispiel eine Datei keinen Verweis auf den Ordner hat, in dem sie sich befindet. Man kann die Quelltexte leicht erweitern, indem man in Component ein neues Datenelement einführt:

Component * ptr_parent;

Für das Wurzelelement des Dateisystems definiert man als Eltern-Element den sogenannten nullpointer:

img.ptr_parent = nullptr;

Tip: Variablen sollen möglichst sofort initialisiert werden. Ebenso sollen Zeiger sofort mit einer Adresse initialisiert werden, oder wenn dies nicht möglich ist, mit dem Null-Pointer nullptr (das ist der Zeiger, der auf nichts zeigt).

Aufgaben:

1. Erweitern Sie Ihr Projekt um des Datenelement, das den Zeiger auf die übergeordnete Komponente bildet (siehe oben), sowie die zugehörigen Setter und Getter.

2. Realisieren Sie damit eine Methode, die zu jeder Komponente (Datei oder Ordner) den Pfad angibt, also eine Methode in Component.h, die von den Unterklassen implementiert wird:

virtual std::string getPath();

Das Wurzelelement (im Beispiel oben) besitzt den Pfad /img ; es bietet sich dazu an eine Konstante zu definieren:

const static char SEPARATOR = '/';

Für das File cat würde sich dann der Pfad /img/garden/cat.jpg ergeben. </div>

Durch die Nutzung dieser Website erklären Sie sich mit der Verwendung von Cookies einverstanden. Außerdem werden teilweise auch Cookies von Diensten Dritter gesetzt. Genauere Informationen finden Sie in unserer Datenschutzerklärung sowie im Impressum.