C++/Tutorial: 11. Vererbung und Laufzeitpolymorphie

Aus Scientia
Version vom 6. April 2011, 07:32 Uhr von Alexis Hiemis (Diskussion | Beiträge)

(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Wechseln zu: Navigation, Suche

Im letzten Kapitel haben wir eine neue Art zu Programmieren(ein Programmierparadigma) kennen gelernt: die objektorientierte Programmierung.
Wir erreichen damit ein höheres Level von Abstraktion und können effizienter Programme bauen, wir erzählen dem Programm jetzt quasi nicht mehr Schritt für Schritt was zu tun ist, sondern unsere Modellvorstellung hat sich zu einem System gewandelt, in dem Objekte existieren, die sich gegenseitig Nachrichten schicken(Methoden aufrufen). Natürlich kennen wir noch nicht alle Aspekte dieser neuen Art zu programmieren und erst recht nicht alles was uns C++ noch mit Klassen anstellen lässt, in diesem Kapitel soll es um Vererbung und Laufzeitpolymorphie gehen.

Vererbung

Objektorientierung lässt sich gut an Modellen erklären, deswegen gehe ich in folgendem von dem Modell eines Strategiespiels aus. In einem Strategiespiel haben wir normalerweise Einheiten, diese können angreifen, sich bewegen etc. Es bietet sich also ein eine Klasse für diese Einheiten zu modellieren, nennen wir sie unit. Geben wir unit eine Methode "attack" die unit ganz neutral eine Attacke ausführen lässt, geben wir unit außerdem eine Methode "move" mit der sich die Einheit bewegen kann.

#include <iostream>
 
using namespace std;
 
class unit
  {
  public:
    void attack();
    void move(int x, int y);
  };
 
void unit::attack()
  {
  cout << "huh! ich greife an" << endl;
  }
 
void unit::move(int x, int y)
  {
  cout << "hah! ich bewege mich um " << x << " nach rechts und um " << y << " nach unten!" << endl;
  }
 
int main()
  {
  unit u;
  u.move(2, 2);
  u.attack();
  }

Wenn wir uns nun auf ein richtiges Spiel beziehen haben wir üblicherweise aber nicht nur eine Art von Einheit, sondern mehrere. Bewegen sich alle gleich und haben den selben Angriff ist es noch empfehlenswert Angriffsstärke und Geschwindigkeit nur als Variablen festzulegen, aber wenn sich der Angriff unterscheidet(ein Bogenschütze greift ganz anders an, als ein Schwertkämpfer und das muss auch anders programmiert werden), würden wir wieder bei ewig vielen switch case Anweisungen landen und wären unübersichtlich und unflexibel. Sinnvoller wäre es dann dem Bogenschützen und dem Schwertkämpfer separate Klassen zu geben.
Einen Nachteil hätte das ganze wiederum, wir müssten die Funktion move für Schwertkämpfer und Bogenschütze separat programmieren und da sich beide gleich bewegen hätten wir eine Codedopplung, einen Ausweg bietet hier unter anderem die Vererbung. Wir lassen sword_fighter und archer von unit erben, stark vereinfacht bedeuted das, dass sowohl sword_fighter, als auch unit alle Funktionen und Eigenschaften von unit "erben" also ebenfalls haben. Wir können move und attack von unit erben und attack dann "überschreiben", sodass sowohl sword_fighter als auch archer eine eigene attack-Methode haben, aber keine eigene move-Methode. Syntaktisch schreiben wir hinter den Bezeichner in der Klassendefinition von einem : getrennt public super_class, wobei super_class für die Klasse steht, von der wir erben wollen, in dem Fall also unit.

class sword_fighter : public unit
  {
  public:
    void attack();
  };
 
void sword_fighter::attack()
  {
  cout << "huh! ich zücke mein Schwert" << endl;
  }
 
class archer : public unit
  {
  public:
    void attack();
  };
 
void archer::attack()
  {
  cout << "hehe, ich schieß dich jetzt ab" << endl;
  }
 
[...]
 
  archer a;
  a.move(2, 2);
  a.attack();
 
  sword_fighter sf;
  sf.move(2, 2);
  sf.attack();

Wenn wir diesen Code zu unserem bisherigen Beispiel hinzufügen merken wir, dass sowohl der archer, als auch der sword_fighter beim Aufruf von move den selben Text sagen, nämlich den, den sie von unit geerbt haben. Beim Aufruf von attack, sagen aber beide ihren eigenen Text, die Funktion attack in unit könnten wir an dieser Stelle eigentlich löschen, da wir sie nicht benötigen.
Vererbung ist ein mächtiges Werkzeug, aber mit Vorsicht zu genießen, denn die Klassen archer und unit sind unmittelbar verbunden, archer veröffentlicht alles, was auch unit veröffentlicht, wir wollen aber in der OOP erreichen, dass die Objekte möglichst unabhängig voneinander sind. Wir sollten also wenn möglich Komposition verwenden(ein Objekt hat ein anderes als Membervariable) und auch unser Beispiel kann man besser designen, darauf werde ich am Ende des Kapitels nochmal eingehen.

Zugriffsspezifizierer "protected"

Das Konzept der Datenkapselung ist uns bereits bekannt, wir kennen bereits zwei Zugriffsspezifizierer: private und public, public Methoden und Variablen eines Objektes sind für alle anderen Objekte aufrufbar, private nur von den eigenen Methoden, aber C++ kennt noch einen dritten Zugriffsspezifizierer "protected". protected erlaubt den Zugriff den Methoden der eigenen Klassen und den Methoden aller erbenden Klassen.

class bar
  {
  protected:
    int a;
  };
 
class sub_bar : public bar
  {
  public:
    void foo();
  };
 
void sub_bar::foo()
  {
  cout << a << endl; //nur möglich, wenn a public oder protected ist
  }
 
int main()
  {
  bar b;
  cout << b.a << endl; //nicht möglich, weil a nicht public ist
  }

private Vererbung

Dem aufmerksamen Leser mag das "public" vor der Basisklasse auffallen und natürlich ist es nicht nur Dekoration, sondern hat auch eine Bedeutung.
Die normale Vererbung ist eine public Vererbung, das bedeutet alle Methoden und Eigenschaften, die wir erben behalten ihren Zugriffsspezifizierer. Was in der Basisklasse public war, bleibt auch in der erbenden Klasse public, was in der Basisklasse protected war, bleibt auch in der erbenden Klasse protected und was in der Basisklasse private war haben wir in der erbenden Klasse sowieso nicht mehr. Darüber hinaus gibt es aber auch noch die private und die protected Vererbung. Ersetzen wir das public durch private handelt es sich fast schon nicht mehr um eine richtige Vererbung, alles was die erbende Klasse von der Basisklasse erbt ist von außen nicht mehr verfügbar, es ist nun private. Die private Vererbung ist daher eher mit der Komposition, als mit der public Vererbung zu vergleichen. Noch seltener angewand wird die protected Vererbung, bei ihr werden alle public Member, die geerbt werden in der erbenden Klasse "protected".

class bar
  {
  public:
    int a;
  };
 
class sub_bar : private bar
  {
  public:
    void foo();
  };
 
void sub_bar::foo()
  {
  cout << a << endl; //nur möglich, wenn a public oder protected ist
  }
 
int main()
  {
  sub_bar b;
  cout << b.a << endl; //nicht möglich, a ist in bar zwar public, in sub_bar aber private
  }

Konstruktoraufruf der Basisklasse

Bevor die erbende Klasse konstruiert werden kann muss der Konstruktor der Basisklasse aufgerufen werden. Bei einem Parameterlosen Konstruktor ist dies kein Problem, da C++ ihn einfach automatisch aufrufen kann, aber was wenn der Konstruktor der Basisklasse Parameter hat? Oder wenn es einen parameterlosen gibt, wir aber einen mit Parametern aufrufen wollen? Wir können den Konstruktor der Basisklasse in der Initialisierungsliste aufrufen!

class baseclass
  {
  public:
    baseclass(int x);
  };
 
baseclass::baseclass(int x)
  {
  cout << "constructed baseclass with " << x << endl;
  }
 
class subclass : public baseclass
  {
  public:
    subclass();
  };
 
subclass::subclass() :
  baseclass(4)
  {
  cout << "constructed subclass" << endl;
  }

In diesem Beispiel wird die Basisklasse mit 4 konstruiert, die Ausgabe lautet also
constructed baseclass with 4
constructed subclass

Mehrfachvererbung

Bisher haben wir nur Klassen gehabt, die von einer anderen Klasse geerbt haben, in manchen Fällen ist es jedoch sinnvoll von mehr als einer anderen Klasse zu erben und auch das ist in C++ möglich, die Superklassen werden dabei durch Kommata getrennt

class base_a
  {
  public:
    void base_a_foo();
  };
 
class base_b
  {
  public:
    void base_b_foo();
  };
 
class subclass : public base_a, public base_b
  {
  public:
    void subclass_foo();
  };
 
void base_a::base_a_foo()
  {
  cout << "inherited from base_a" << endl;
  }
 
void base_b::base_b_foo()
  {
  cout << "inherited from base_b" << endl;
  }
 
void subclass::subclass_foo()
  {
  cout << "subclass_foo" << endl;
  }
 
int main()
  {
  subclass sc;
  sc.base_a_foo(); //inherited from a
  sc.base_b_foo(); //inherited from b
  sc.subclass_foo(); //subclass_foo
  }

Aufruf von Methoden einer speziellen Basisklasse

Manchmal ist es nötig den Namen der Basisklasse beim Methodenaufruf explizit anzugeben, zum Beispiel hatten wir bereits den Fall, dass die erbende Klasse Methoden der Basisklasse verdeckt, wir können die Methode der Basisklasse dann dennoch aufrufen, indem wir den Namen der Basisklasse spezifizieren. Dies können wir tun, indem wir vor den Methodennamen den Namen der Basisklasse und den Scopeoperator :: schreiben:

class baseclass
  {
  public:
    void foo();
  };
 
void baseclass::foo()
  {
  cout << "baseclass foo" << endl;
  }
 
class subclass : public baseclass
  {
  public:
    void foo();
  };
 
void subclass::foo()
  {
  cout << "subclass foo" << endl;
  }
 
int main()
  {
  subclass sc;
  sc.foo(); //subclass foo
  sc.baseclass::foo(); //baseclass foo
  }

Ein ähnliches Problem haben wir, wenn wir von zwei verschiedenen Klassen erben, die beide eine Methode "foo" besitzen. C++ kann dann unmöglich wissen, welches foo aufgerufen werden muss und dann müssen wir die Basisklasse zwangsläufig angeben.

Diamondproblem und virtuelle Vererbung

Ein bekanntes Problem bei Mehrfachvererbung ist das Diamondproblem. Nehmen wir an Klasse A erbt von Klasse B und Klasse C. Klasse B erbt von Klasse D und Klasse C erbt ebenfalls von Klasse D. Wenn D nun eine Methode foo hat erben B und C dieses foo von D. Normalerweise folgt C++ jedem Vererbungspfad seperat, sodass A quasi zwei mal von D erbt. Alle Methoden und Variablen werden doppelt geerbt, was problematisch ist. Die Lösung ist die virtuelle Vererbung. Wenn B und C "virtual" von D erben, achtet C++ darauf nur ein D anzulegen, A erbt also nur einmal von D. virtual wird dabei dem public/protected/private vorangestellt. Zunächst einmal verdeutlichen wir das Problem:

class D
  {
  public:
    int a;
  };
 
class C : public D
  {
  };
 
class B : public D
  {
  };
 
class A : public B, public C
  {
  };
 
int main()
  {
  A a;
  a.a = 2;
  }

verursacht beim GCC folgenden Fehler:
vererbung.cpp: In function ‘int main()’:
vererbung.cpp:67: error: request for member ‘a’ is ambiguous
vererbung.cpp:49: error: candidates are: int D::a
vererbung.cpp:49: error: int D::a
Dieses Problem tritt nicht auf, wenn wir die Vererbungen "virtual" markieren:

class D
  {
  public:
    int a;
  };
 
class C : virtual public D
  {
  };
 
class B : virtual public D
  {
  };
 
class A : public B, public C
  {
  };
 
int main()
  {
  A a;
  a.a = 2;
  }

Laufzeitpolymorphie

Richtig sinnvoll wird Vererbung in Verbindung mit Laufzeitpolymorphie.
Bisher haben wir Vererbung vereinfacht als Übernahme aller Funktionen und Eigenschaften dargestellt, aber Vererbung ist mehr als das. Zur Vererbung gehört auch das Subtyping, es handelt sich wirklich um eine "ist ein" Beziehung, denn wenn die Klasse A von der Klasse B erbt, sind alle Objekte der Klasse A zugleich auch Objekte der Klasse B und Referenzen und Zeiger vom Typ B können auf Objekte der Klasse A zeigen.
Die Frage wozu das gut sein soll ist hier natürlich durchaus berechtigt, der Sinn liegt in der sogenannten Laufzeitpolymorphie. Polymorphie bedeuted im Grunde, dass eine Variable mehrere Typen haben kann und sich abhängig vom Typ unterschiedlich verhält, was das bedeuted kann man am besten an unserem Modell erklären.
Nehmen wir an, wir sind wieder bei dem Bogenschützen(archer) und dem Schwertkämpfer(sword_fighter), die beide von der allgemeinen Klasse Einheit(unit) erben. Üblicherweise haben Spieler in einem Strategiespiel von jeder Klasse mehrere Einheiten, mit unseren bisherigen Kenntnissen müsste nun für jeden Typ ein Array anlegen. Schlimmer noch, wann immer wir Funktionen schreiben wollen, die alle Arten von Einheiten nehmen können und dann z.B. die attack Funktion von ihnen verwendet, müssten wir diese Funktion für jeden Einheitentyp überladen nur um dann doch immer den gleichen Code auszuführen, diese Codedopplung ist ganz klar nicht wünschenswert. Die Lösung ist Laufzeitpolymorphie, da wir wissen, dass jedes Klasse, die vom Typ unit erbt auch zwangsweise eine move und eine attack Funktion hat, die sie entweder von unit erbt, oder die sie selbst überschreibt.
Laufzeitpolymorphie ermöglicht es uns also Funktionen aufzurufen, die alle Klassen zwingend gemeinsam haben müssen. Wir können in unserem Strategiespiel zum Beispiel ein Squad als Container von Zeigern auf unit anlegen. Wir können dem Squad befehlen anzugreifen, indem wir attack auf jedes Element aus diesem Container aufrufen, dabei kann es uns völlig egal sein, dass Bogenschützen anders angreifen als Schwertkämpfer, C++ ruft für uns immer die richtige Methode aus, wenn das Objekt auf das der unit* zeigt ein Schwertkämpfer ist, wird die attackmethode von sword_fighter aufgerufen, wenn das Objekt auf das der unit* zeigt ein Bogenschütze ist wird die attackmethode von archer aufgerufen.
Soviel zur Theorie, setzen wir unser Beispiel nun einmal in die Praxis um:

#include <iostream>
#include <vector>
#include <tr1/memory>
 
using namespace std;
using namespace std::tr1;
 
class unit
  {
  public:
    virtual void attack();
    virtual void move(int x, int y);
    virtual ~unit(){}
  };
 
void unit::attack()
  {
  cout << "huh! ich greife an" << endl;
  }
 
void unit::move(int x, int y)
  {
  cout << "hah! ich bewege mich um " << x << " nach rechts und um " << y << " nach unten!" << endl;
  }
 
class sword_fighter : public unit
  {
  public:
    void attack();
  };
 
void sword_fighter::attack()
  {
  cout << "huh! ich zücke mein Schwert" << endl;
  }
 
class archer : public unit
  {
  public:
    void attack();
  };
 
void archer::attack()
  {
  cout << "hehe, ich schieß dich jetzt ab" << endl;
  }
 
int main()
  {
  vector<shared_ptr<unit> > squad(3);
  squad[0] = shared_ptr<unit>(new sword_fighter());
  squad[1] = shared_ptr<unit>(new archer());
  squad[2] = shared_ptr<unit>(new sword_fighter());
  for(int i(0); i < 3; i++)
    squad[i]->attack();
  }

Wenn wir dieses Beispiel ausführen, werden wir sehen, dass immer die richtige attackmethode aufgerufen wird, die Ausgabe lautet:
huh! ich zücke mein Schwert
hehe, ich schieß dich jetzt ab
huh! ich zücke mein Schwert
würden wir move aufrufen, würde weiterhin die movemethode von unit aufgerufen werden.

virtual?

Nun, etwas habe ich noch unterschlagen, wer sich das Beispiel oben angeschaut hat, wird feststellen, dass der Klasse unit einige Veränderungen wiederfahren sind:

  • attack und move sind nun virtual
  • es gibt nun einen ebenfalls virtuellen leeren Destruktor

Wieso das?
Im Gegensatz zu anderen Sprachen, wie zum Beispiel Java ist Polymorphie in C++ nicht für alle Methoden möglich, sondern nur für Methoden, die in der Basisklasse als virtual deklariert wurden. virtual legt eine Vererbungstabelle an, sodass sich die Klasse erst merkt, welchen Typ sie eigentlich hat und welche Methoden aufgerufen werden sollen, das ganze hat Performancegründe. Polymorphie kommt nicht ohne Kosten, die Klasse muss sich die Vererbungstabelle irgendwie merken und hat somit normalerweise mindestens einen versteckten pointer, der auf die Informationen zeigt. Natürlich ist ein weiterer Pointer nicht allzuviel, aber bei kleinen Klassen kann das durchaus ins Gewicht fallen. Nehmen wir zum Beispiel eine Klasse eines mathematischen zweidimensionalen Vektors, die Klasse merkt sich also die X und Y Koordinate, üblicherweise als int. Auf einem 64 Bitsystem mit dem GCC haben die beiden ints zusammen 8 Byte, ein Pointer hat ebenfalls 8 Byte. Nicht nur, dass die Klasse jetzt doppelt soviel Speicher belegt und dementsprechend lange zum kopieren braucht, nun ist die Klasse auch noch zu groß um in einem Prozessorregister zwischengespeichert zu werden. Es ist also ungünstig alle Methoden virtual zu machen, deswegen ist es in C++ erforderlich virtual explizit anzugeben.
Wenn wir diesem Gedanken folgen, wird klar, dass ohne virtual auch keine Vererbungstabelle angelegt wird, sonst wäre das ganze ziemlich sinnlos, also stellt sich die Frage woher delete wissen soll, welches Objekt gelöscht werden soll. Die Antwort ist einfach: gar nicht, deswegen der virtuelle Destruktor. Die Sache ist einfach, wann immer eine Klasse zur Laufzeitpolymorphie gedacht ist, braucht sie auch einen virtuellen Destruktor und wenn dieser leer ist.

Rein virtuelle Funktionen und abstrakte Klassen

Wenn wir uns unit anschauen, kommen wir zu dem Schluss, dass die attackmethode keinen Sinn macht. Es gibt im Strategiespiel keine Einheit "Einheit" und dementsprechend wird es auch nicht möglich sein eine attackmethode zu implementieren. Aber wenn es keine attackmethode in unit gibt, können wir sie auch nicht virtual deklarieren, oder? Die Lösung ist eine rein virtuelle Funktion. Eine rein virtuelle Funktion besteht nur aus einer Deklaration gefolgt von einem = 0 und gibt an, dass diese Funktion nicht implementiert wird, sondern nur dazu da ist um von erbenden Klassen überschrieben zu werden. Die Klasse in der die rein virtuelle Funktion sich befindet wird dadurch zu einer sogenannten "abstrakten Klasse", das ist eine Klasse, die nicht instanziert werden kann, sondern nur als Basisklasse für andere Klassen dient. Ersetzen wir also attack in unit durch eine rein virtuelle Funktion:

class unit
  {
  public:
    virtual void attack() = 0; //Wird nicht mehr implementiert
    virtual void move(int x, int y);
  };

dynamic_cast

Kommen wir zum letzten Cast in C++: dynamic_cast. dynamic_cast ist der einzige Cast, der zur Laufzeit ausgeführt wird und dadurch auch der einzige Cast, der nicht durch C-Style Casts dargestellt werden kann. Bei dynamic_cast handelt es sich um einen sogenannten "downcast". Wie wir wissen können Pointer auf Objekte implizit in Pointer mit dem Typ der Basisklasse gecastet werden, aber was wenn wir es einmal andersrum wollen?
Nun, das sollten wir normalerweise nicht wollen, die sauberere Variante ist die Polymorphie, aber manchmal ist es eben doch besser, einen Pointer zurückzucasten, dynamic_cast macht dies. Sollte das Objekt, auf das der Pointer zeigt, nicht von dem angegebenen Typ sein, so gibt dynamic_cast NULL zurück.
Schreiben wir also eine Funktion, die eine Einheit bewegt, wenn sie ein Bogenschütze ist:

int move_archer(unit* u)
  {
  archer* a(dynamic_cast<archer*>(u));
  if(a)
    a->move(2, 2);
  }

static_pointer_cast und dynamic_pointer_cast

An dem Beispiel fällt auf, dass es einen rohen Pointer nimmt, was aber problematisch ist, wenn wir mit Smartpointern arbeiten. Aus diesem Grund gibt es in tr1/memory Funktionen, die die Casts für Smartpointer zur Verfügung stellen(es handelt sich hierbei nicht um Sprachmittel, wie die 4 C++ Casts), neben dynamic_pointer_cast, das die Funktionalität von dynamic_cast für Smartpointer zur Verfügung stellt gibt es auch noch static_pointer_cast, das das selbe mit static_cast tut. Im Gegensatz zu den original Casts muss hier nur der Typ angegeben werden, auf den der Smartpointer zeigt.
Das obige Beispiel sähe für Smartpointer dann so aus:

int move_archer(shared_ptr<unit> const& u)
  {
  shared_ptr<archer> a(dynamic_pointer_cast<archer>(u));
  if(a)
    a->move(2, 2);
  }

Und könnte dann auch aus unserem ersten Beispiel aufgerufen werden:

int main()
  {
  vector<shared_ptr<unit> > squad(3);
  squad[0] = shared_ptr<unit>(new sword_fighter());
  squad[1] = shared_ptr<unit>(new archer());
  squad[2] = shared_ptr<unit>(new sword_fighter());
  for(int i(0); i < 3; i++)
    {
    squad[i]->attack();
    move_archer(squad[i]);
    }
  }

Klassenkopplung gering halten

So schön Vererbung auch ist, es hat seine Nachteile. Klassen sollten selbstständig sein, nur dann sind sie flexibel, in tiefen Vererbungshierarchien sind die Klassen jedoch alle miteinander verbunden, was schnell zu Fehlern führen kann, die man nicht mehr so einfach lokalisieren kann(in welcher der Dutzend Basisklassen ist der Fehler nun?)
Wenn wir uns unser Beispiel anschauen hätten wir das auch besser machen können, anstatt die ganze Klasse des jeweiligen Einheitentypes erben zu lassen, hätten wir der unitklasse auch 2 Polymorphe Objekte geben können "weapon" und "vehicle", ersteres würde in unserem Beispiel eine Klasse "sword" und eine Klasse "bow" haben, letzteres könnte neben unseren "feet"s auch "horse" sein und schon könnten wir die Klassen beliebig kombinieren und hätten berittene Bogenschützen und solche die zu Fuß gehen, sowie Schwertkämpfer und Ritter und das ohne repetive attackmethoden.