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.
Noch keine Stimmen abgegeben
Noch keine Kommentare

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>