I-PROGR2-HOME

  1. Einführung

  2. Vererbung - Inheritance

  3. Das Ideal und die Praxis

  4. Konstruktion eines Beispiels

  5. Klasse Bruch/Friend-Funktionen/ globale Funktionen

  6. Implementierung von Bruch/ Überladung von Input-Output

  7. Abgeleitete Klasse

  8. Testfragen und Aufgaben


I-PROGR2 SS03 - PROGRAMMIEREN2 - Vorlesung mit Übung
Vererbung - Inheritance

 Achtung : Skript gibt den mündlichen Vortrag nicht vollständig wieder !
 Achtung : Skript weiter ausgearbeitet aber noch nicht abgeschlossen !
                         

AUTHOR: Gerd Döben-Henisch
DATE OF FIRST GENERATION: May-12, 2003
DATE OF LAST CHANGE: May-19, 2003, 22:30h
EMAIL: Gerd Döben-Henisch



1. Einführung


In dieser Vorlesung steht das Thema Vererbung im Rahmen von C++ im Vordergrund. Im Zusammenhang damit werden auch die Themen friend-Funktionen, globale Operatoren sowie das Überladen der Ein-Ausgabe-Operatoren {<<, >>} angesprochen.

Die Kodierungsbeispiele stammen aus dem Buch von [JOSUTTIS 2001], wurden allerdings streckenweise modifiziert bzw. neu kombiniert, um die Fülle des Materials für eine Vorlesungseinheit zu komprimieren. Zur weiteren Vertiefung sei auf das Buch selbst verwiesen.


START



2. Vererbung - Inheritance


Vererbung gilt allgemein als eine der zentralen Eigenschaften, die eigentlich den objektorientierten Programmierstil charakterisieren. Im UML-Standard V 1.5 liest sich dies in Kurzform so:

Generalization: A taxonomic relationship between a more general element and a more specific element. The more specific element is fully consistent with the more general element and contains additional information. An instance of the more specific element may be used where the more general element is allowed. See: inheritance.(p.712)

Inheritance: The mechanism by which more specific elements incorporate structure and behavior of more general elements related by behavior. See generalization.(p.712)

Vererbung ist also das Gegenstück zur Generalisierung und erlaubt es, für eine neue Klasse Eigenschaften einer schon existierenden Klasse zu übernehmen, ohne alle die Eigenschaften der schon existierenden Klasse nochmals hinschreiben zu müssen. Grafisch wird eine Generalisierungs- bzw. Vererbungsbeziehung dargestellt durch einen durchgezogenen Pfeil mit einem offenen Dreieck, das beim allgemeineren Element endet (siehe Bild).



inheritance

Vererbung ('inheritance')



Notationell kann man die durch die Pfeile repräsentierte Beziehung noch durch zusätzliche textliche Erläuterungen kommentieren, z.B. {disjoint, incomplete}, um anzudeuten, daß die angeführten vererbten Elemente die Menge der möglichen vererbten Elemente nicht vollständig widergibt.

Bei der Frage, welche Eigenschaften der Elternklasse eine vererbte Klasse genau erbt, ist allerdings zu beachten, daß die Sichtbarkeit hier modifizierend wirksam wird! So heißt es im UML-Standard 1.5:

Nested Classifiers may be accessed by other Classifiers only if the nested Classifiers have adequate visibility. (p.86)

Und:

Visibility specifies whether the Feature can be used by other Classifiers. Visibilities of nested Classifiers combine so that the most restrictive visibility is the result. Possibilities:

(p.94)

Wechselt man nun den Kontext weg von der allgemeinen Modellierung im Rahmen daß UML hin zu einer konkreten Programmiersprache wie C++, dann zeigen sich allerdings gerade bei der Eigenschaft der Vererbung nicht geringe Probleme, die deren Einsatz in der Praxis auf vielfache Weise in Frage stellen und es sind nicht wenige Autoren, die auf diese Probleme hinweisen. Wir werden hier den Argumentationen von [JOSUTTIS 1999] folgen, der die Problematik klar und übersichtlich herausarbeitet.


START



3. Das Ideal und die Praxis


Das Ideal der Vererbung besteht darin, dass man schon definierte Eigenschaften einer Elternklasse in nachfolgenden abgeleiteten Klasse uneingeschränkt übernehmen kann, evtl. ergänzt durch zusätzliche Eigenschaften, die es so in der Elternklasse noch nicht gibt.

Übernimmt man dieses Ideal als Design-Prinzip, dann leiten sich daraus unterschiedliche theoretische Teilforderungen für die Programmierpraxis ab:

  1. Alles, was man mit einem Objekt der Basisklasse (Elternklasse) machen kann, soll man auch mit einem Objekt der abgeleiteten Klasse (Kindklasse) machen können.


  2. Umgekehrt: Objekte der abgeleiteten Klasse sollten jederzeit auch als Objekte der Basisklasse verwendet werden können.


  3. Der Zustand einer Komponente eines Objektes der Basisklasse für einen bestimmten Wert sollte nicht durch neue Komponenten von Objekten der abgeleiteten Klasse verändert werden.


  4. Die neuen Komponenten von Objekten der abgeleiteten Klasse sollten nicht die Bedeutung von Komponenten der Basisklasse verändern.


  5. Basisklassen, auf deren Veränderng man keinen Einfluss hat, sollten nach Möglichkeit nicht verändert werden.


Will man diese theoretische Forderungen bei der Implementierung in C++ umsetzen, dann ergeben sich folgende eher praktische Konsequenzen (siehe dazu [JOSUTTIS 1999:303]):

  1. Alle Funktionen, die von abgeleiteten Klassen überschrieben werden könnten, müssen als virtuelle Funktionen deklariert werden (andernfalls werden Funktionsaufrufe statisch eingebunden).


  2. Es muss ein virtueller Destruktor definiert werden (da ansonsten u.U. automatisch der Destruktor der Basisklasse aufgrufen wird).


  3. In der abgeleiteten Klasse sollen nur solche Funktionen der Basisklasse überschrieben werden, die dort als virtuell deklariert wurden.


  4. Funktionen der Basisklasse gelten nur dann als überschrieben, wenn die Typen bei der Deklaration gleich sind.


  5. Überschriebene Funktionen sollten auch die gleichen Default-Argumente besitzen.


  6. Auf als Parameter übergebene Objekte der Basisklasse besteht nur öffentlicher Zugriff


Versucht man anhand dieser Prinzipien zu implementieren, dann wird man feststellen, dass bei einer Vererbung mit der Sichtbarkeit 'public' trotzalledem Zugriffe auf die Elemente der Basisklasse möglich sind, die zu Inkonsistenzen führen können. Die verschiedenen Versuche, diese Inkonsistenzen auszuschalten, führen aber dann letztlich dazu, das Prinzip der Vererbung soweit einzuschränken, dass die Vererbung eigentlich nicht mehr existent ist. Damit entsteht dann die paradoxe Situation, dass eine zentrale Eigenschaft des objektorientierten Programmierparadigmas in der Praxis gerade nicht mehr gilt.

Eine ausführliche Darstellung dieser Problematik sprengt leider den verfügbaren Zeitrahmen. Im folgenden wird ein vollständiges Beispiel zur einfachen Vererbung vorgestellt dazu die wichtigsten technischen Aspekte.


START



4. Konstruktion eines Beispiels


Das folgende Beispiel ist aus verschiedenen Modulen zusammengebaut, die [JOSUTTIS 1999] als Quellcode zur Verfügung stellt und die ich für die Vorlesung z.T. vereinfacht habe.

Ausgangspunkt ist eine Klasse Bruch (:= bruch93b.hpp und .cpp) und eine davon abgeleitete Klasse kbruch (:= kbruch3b.hpp und .cpp).

Die wesentlichen Eigenschaften der Klasse Bruch sind die Variablen zaehler und nenner sowie die Operationen multiplikative Zuweisung '*=', Multiplikation '*' sowie Einlesen eines Bruchs von der Tastatur (mit '<<') und Ausgabe eines Bruchs auf den Bildschirm (mit '>>'). Dabei wird nur die Funktion '*=' als Elementfunktion von Bruch definiert; alle anderen Funktionen werden als globale eigenständige Funktionen deklariert.

Die abgeleitete Klasse kbruch (:= kürzbarer Bruch) überschreibt die multiplikative Zuweisungsfunktion '*=', besitzt zusätzlich eine Variable 'kuerzbar' und eine Hilfsfunktion ggt(), um die Kürzbarkeit eines Bruches feststellen zu können.

Bei der Vererbungsbeziehung wurde als Sichtbarkeitsmodus 'public' gewählt, das bedeutet, dass sowohl die abgeleitete Klasse als auch eine mögliches anwendendes Programm alle öffentlichen Elemente der Basisklasse weiterhin 'sehen' können. Eine Konsequenz dieses Sachverhaltess ist es, dass die Funktion '*=' der Basisklasse aufgerufen werden kann und zur anwendung auf ein Objekt kürzbarer Bruch kommt. So wird erst ein Objekt KBruch mit Namen x erzeugt:

  Bsp::KBruch x(7,3);

aber dann wird auf dieses KBruch-Objekt der Operator '*=' der Basisklasse angewendet:

  x.Bsp::Bruch::operator *= (3);

was zu einem fehlerhaften Ergebnis führt. Übergibt man z.B. (7,3), multipliziert dies mit 3, dann erhält man korekt 21/3, aber zugleich die Ausgabe, 21/3 sei nicht kürzbar, was falsch ist. Ruft man dann den überschriebenen Operator als Elementfunktion der abgeleiteten Klasse auf:

   x.Bsp::KBruch::operator *= (1);

dann wird der aktuelle Bruch 21/3 mit 3/3 multipliziert und man erhält als korrekte Ausgabe:

gerd@goedel:~/WEB-MATERIAL/fh/I-PROGR2/I-PROGR2-EX/EX9> ./kbruch
21/3 (unkuerzbar)
63/3 (kuerzbar)

So einfach dieses Beispiel vergleichsweise ist, so zeigt es odch das zugrundeliegende Problem.

Da in diesem Beispiel mehr als eine Datei benutzt wird, wurde eine kleine einfache make-Datei 'Makefile' zur Verwaltung benutzt.


START






objects = kbruch3b.o bruch93b.o kbruchtest4b.o

kbruch: $(objects)
	g++ -o kbruch $(objects)

kbruch3b.o: kbruch3b.cpp kbruch3b.hpp
	g++ -c kbruch3b.cpp

bruch93b.o: bruch93b.cpp bruch93b.hpp
	g++ -c bruch93b.cpp


kbruchtest4b.o: kbruchtest4b.cpp kbruch3b.hpp
	g++ -c kbruchtest4b.cpp

clean:
	rm kbruch $(objects)



Das Kompilieren wird dann mit einem Befehl 'make' erledigt:

gerd@goedel:~/WEB-MATERIAL/fh/I-PROGR2/I-PROGR2-EX/EX9> make
g++ -c kbruch3b.cpp
g++ -c bruch93b.cpp
g++ -c kbruchtest4b.cpp
g++ -o kbruch kbruch3b.o bruch93b.o kbruchtest4b.o
gerd@goedel:~/WEB-MATERIAL/fh/I-PROGR2/I-PROGR2-EX/EX9>

Es folgt jetzt eine kurze Kommentierung der verwendeten Dateien.

Die Usage-Datei kbruchtest4b.cpp wurde im Prinzip eben schon beschrieben (s.o.). Speziell sei hingewiesen auf die Verwendung der Input-Outputoperatoren, denen man aufgrund der überladenen Funktionen hier direkt kürzbare Brüche übergeben kann (Details dazu weiter unten).



Usage-Datei kbruchtest4b.cpp




#include "kbruch3b.hpp"


int main()
{
    // KBruch deklarieren
    Bsp::KBruch x(7,3);

    /* x mit 3 multiplizieren
     * ABER: Operator der Basisklasse Bruch verwenden
     */
    x.Bsp::Bruch::operator *= (3);

    // x ausgeben
    std::cout << x;
    std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)")
              << std::endl; x.Bsp::Bruch::operator *= (3);

    x.Bsp::KBruch::operator *= (1);


    // x ausgeben
    std::cout << x;
    std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)")
              << std::endl;





}


5. Klasse Bruch/Friend-Funktionen/ globale Funktionen


Wie man sofort erkennen kaann, gibt es in der Klasse Bruch mehrere Elementfunktionen, die als 'virtual' deklariert sind, diese sollen später von abgeleiteten Klassen überschrieben werden können. Speziell wurde auch ein virtueller Destruktor explizit angegeben, obgleich in diesem Fall keine individuellen Anweisungen vereinbart werden.

Hervorzuheben ist das Keyword 'friend' bei der Funktion '*':

    friend Bruch operator * (const Bruch&, const Bruch&);

Dadurch wird eine sogenannte 'friend'-Funktion definiert, d.h. die funktion '*' ist selbst keine Elementfunktion der Klasse Bruch, ist aber so konzipiert, dass sie auf interne Elemente der ihr als Parameter übergebenen Objekte zugreifen können muss; dies ist möglich, wenn Sie innerhalb einer bestimmten Klasse als 'friend' deklariert wird. Diese eigenschaft ist --wie sooft-- sehr 'praktisch', widerspricht aber auch dem 'Geist' des objektorientierten Ansatzes, ist doch gerade die sogenannte Datenkapselung ('information hiding') eine weitere zentrale Eigenschaft, über die sich das objektorientierte Paradigma definiert. Die Existenz von friend-Funktionen weicht dieses Prinzip auf. Andererseits können solche friend-Funktionen nicht 'aus dem Nichts' auftauchen und 'unvorhergesehen' auf irgendwelche Daten zugreifen, sie müssen explizit innerhalb der Klassen explizit deklariert werden, auf deren Elemente sie zugreifen wollen. Somit stellen sie mit dieser Verankerung in den Klassen gleichsam 'Grenzgänger' dar zwischen reinen Elementfunktionen und 'frei vagabundierenden' Funktionen.

Am Beispiel der Input-Output-Funktionen '<<' und '>>'

std::ostream& operator << (std::ostream& strm, const Bruch& b)
std::istream& operator >> (std::istream& strm, Bruch& b)

kann man allerdings auch studieren, wie man globale Funktionen definieren kann, ohne sie zu friend-Funktionen zu machen: zuerst wird eine Elementfunktion 'printOn()' der Klasse Bruch definiert, dann wird diese innerhalb der neuen globalen Funktion operator <<() benutzt, um die Funktion operator <<() neu zu definieren. Entsprechend mit der Funktion 'scanFrom()' der Klasse Bruch als Hilfsfunktion für den neu zu definierenden globale Funktion operator >>().


START





/* Die folgenden Code-Beispiele stammen aus dem Buch:
 *  Objektorientiertes Programmieren in C++
 *   Ein Tutorial für Ein- und Umsteiger
 * von Nicolai Josuttis, Addison-Wesley München, 2001
 *
 * (C) Copyright Nicolai Josuttis 2001.
 * Diese Software darf kopiert, verwendet, modifiziert und verteilt
 * werden, sofern diese Copyright-Angabe in allen Kopien vorhanden ist.
 * Diese Software wird "so wie sie ist" zur Verfügung gestellt.
 * Es gibt keine explizite oder implizite Garantie über ihren Nutzen.
 */
#ifndef BRUCH_HPP
#define BRUCH_HPP

#include <cstdlib>
#include <iostream>

namespace Bsp{

class Bruch {

  protected:
    int zaehler;
    int nenner;

  public:

    /* Default-Konstruktor, Konstruktor aus Zähler und
     * Konstruktor aus Zähler und Nenner
     */
    Bruch (int = 0, int = 1);



    /* multiplikative Zuweisung
    * - neu: virtuell
     */
    virtual const Bruch& operator *= (const Bruch&);

    /* Multiplikation
     * - globale Friend-Funktion, damit eine automatische
     *     Typumwandlung des ersten Operanden möglich ist
     */
    friend Bruch operator * (const Bruch&, const Bruch&);


    /* Ein- und Ausgabe mit Streams
     * - neu: virtuell
     */
    virtual void printOn (std::ostream&) const;
    virtual void scanFrom (std::istream&);

    // neu: virtueller Destruktor (ohne Anweisungen)
    virtual ~Bruch () {
    }
};

/* Operator *
 * - globale Friend-Funktion
 * - inline definiert
 */
inline Bruch operator * (const Bruch& a, const Bruch& b)
{
    /* Zähler und Nenner einfach multiplizieren
     * - das Kürzen sparen wir uns
     */
    return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
}


/* Standard-Ausgabeoperator
 * - global überladen und inline definiert
 */
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
    b.printOn(strm);    // Elementfunktion zur Ausgabe aufrufen
    return strm;        // Stream zur Verkettung zurückliefern
}

/* Standard-Eingabeoperator
 * - global überladen und inline definiert
 */
inline
std::istream& operator >> (std::istream& strm, Bruch& b)
{
    b.scanFrom(strm);   // Elementfunktion zur Eingabe aufrufen
    return strm;        // Stream zur Verkettung zurückliefern
}

}//End namespace Bsp

#endif  // BRUCH_HPP   



START



6.Imlementierung von Bruch/ Überladung von Input-Output


Die Implementierung der Klasse Bruch enthält keine besonders schwierigen Elemente, ausgenommen vielleicht die Implementierung der Funktion 'scanFrom()', die einige der in der letzten VL besprochenen Elementfunktionen und Eigenschaften der I/O-Klassen benutzt. Die Element-Funktion .peek() liest das nächste Zeichen aus dem Inputstream ohne es zu entnehmen. Die Elementfunktion .get() entnimmt das Zeichen dann tatsächlich. Der Operator '!' ist in diesem Fall nicht das logische NOT, sondern ein speziller Operator der I/O-Klassen, der 'true' ist, wenn ein I/O-Fehler passiert ist. Der Ausdruck

  strm.clear (strm.rdstate() | std::ios::failbit)

bedeutet, dass die Elementfunktion .clear() erst alle Flags klärt und dann mit .rdstate() alle aktuell gesetzten Flags setzt oder das failbit mittels 'std::ios::failbit'.





#include "bruch93b.hpp"

namespace Bsp{

Bruch::Bruch (int z, int n)
{
    if (n == 0) {
      std::cerr << "NENNER IST NULL" << std::endl;
      std::exit(EXIT_FAILURE);

    }
    if (n < 0) {
        zaehler = -z;
        nenner  = -n;
    }
    else {
        zaehler = z;
        nenner  = n;
    }
}


const Bruch& Bruch::operator *= (const Bruch& b)
{
    *this = *this * b;

    return *this;
}

/* printOn()
 * - Bruch auf Stream strm ausgeben
 */


void Bruch::printOn (std::ostream& strm) const
{
  strm << zaehler << '/' << nenner;
}

/* scanFrom()
 * - Bruch von Stream strm einlesen
 */
void Bruch::scanFrom (std::istream& strm)
{
    int z, n;

    // Zähler einlesen
    strm >> z;

    // optionales Trennzeichen '/' und Nenner einlesen
    if (strm.peek() == '/') {
        strm.get();
        strm >> n;
    }
    else {
        n = 1;
    }

    // Lesefehler?
    if (! strm) {
        return;
    }

    // Nenner == 0?
    if (n == 0) {
        // Fail-Bit setzen
        strm.clear (strm.rdstate() | std::ios::failbit);
        return;
    }

    /* OK, eingelesene Werte zuweisen
     * - ein negatives Vorzeichen des Nenners kommt in den Zähler
     */
    if (n < 0) {
        zaehler = -z;
        nenner  = -n;
    }
    else {
        zaehler = z;
        nenner  = n;
    }
}

}//End namespace Bsp



7. Abgeleitete Klasse






#ifndef KBRUCH_HPP
#define KBRUCH_HPP

// Headerdatei der Basisklasse

#include "bruch93b.hpp"

namespace Bsp{

/* Klasse KBruch
 * - abgeleitet von Bruch
 * - der Zugriff auf geerbte Komponenten wird nicht
 *     eingeschränkt (public bleibt public)
 * - zur Weiter-Vererbung geeignet
 */

class KBruch : public Bruch {
  protected:
    bool kuerzbar;        // true: Bruch ist kürzbar

    // Hilfsfunktion: größter gemeinsamer Teiler von Zähler und Nenner
    unsigned ggt() const;

  public:
    /* Default-Konstruktor, Konstruktor aus Zähler
     * und Konstruktor aus Zähler und Nenner
     * - Parameter werden an Bruch-Konstruktor durchgereicht
     */
    KBruch (int z = 0, int n = 1) : Bruch(z,n) {
        kuerzbar = (ggt() > 1);
    }

    // multiplikative Zuweisung (neu implementiert)
    const KBruch& operator*= (const Bruch&);


    // Eingabe mit Streams (neu implementiert)
    virtual void scanFrom (std::istream&);

    // Bruch kürzen
    void kuerzen();

    // Kürzbarkeit testen
    bool istKuerzbar() const {
        return kuerzbar;
    }
};

} //End namespace Bsp

#endif    // KBRUCH_HPP





// Headerdatei für min() und abs()
#include <algorithm>

#include <cstdlib>

// Headerdatei der eigenen Klasse einbinden

#include "kbruch3b.hpp"

namespace Bsp{

/* ggt()
 * - größter gemeinsamer Teiler von Zähler und Nenner
 */
unsigned KBruch::ggt () const
{
    if (zaehler == 0) {
        return nenner;
    }

    /* Größte Zahl ermitteln, die sowohl Zähler als auch
     * Nenner ohne Rest teilt
     */
    unsigned teiler = std::min(std::abs(zaehler),nenner);
    while (zaehler % teiler != 0  ||  nenner % teiler != 0) {
        teiler--;
    }
    return teiler;
}

/* kuerzen()
 */
void KBruch::kuerzen ()
{
    // falls kürzbar, Zähler und Nenner durch GGT teilen
    if (kuerzbar) {
        int teiler = ggt();

        zaehler /= teiler;
        nenner  /= teiler;

        kuerzbar = false;       // damit nicht mehr kürzbar
    }
}

/* Operator *=
 * - zum Überschreiben mit Typen der Basisklasse neu implementiert
 */
const KBruch& KBruch::operator*= (const Bruch& b)
{
    /* Implementierung der Basisklasse aufrufen
     * - auf nichtöffentliche Komponenten von b besteht kein Zugriff
     */
    Bruch::operator*= (b);

    // weiterhin gekürzt ?
    if (!kuerzbar) {
        kuerzbar = (ggt() > 1);
    }

    return  *this;
}

/* scanFrom()
 */
void KBruch::scanFrom (std::istream& strm)
{
    Bruch::scanFrom (strm);   // scanFrom() der Basisklasse aufrufen

    kuerzbar = (ggt() > 1);
}



}//End namespace Bsp


7. Aufgaben



START