Der Deadly Diamond of Death - Das Diamantenproblem

Die Tatsache, dass viele Programmiersprachen keine Mehrfachvererbung anbieten deutet es schon an: die Fähigkeit ist nicht ganz ohne Preis. Bei der Verwendung von Mehrfachvererbung kann man über ein Problem stolpern, welches den schönen Namen “Deadly diamond of death” – oder etwas weniger martialisch: Diamantenproblem – trägt. Konkret passiert das immer dann, wenn eine Klasse direkt oder indirekt über mehrere Pfade im Vererbungsbaum von derselben Basisklasse erbt.

Video

Code

Achtung: das ist jetzt hier die Variante des Quellcodes mit der virtuellen Vererbung. Im Video ist anfangs eine andere Variante mit klassischer Vererbung zu sehen. Wer das ausprobieren will, muss also in den Zeilen 18 und 22 das Schlüsselwort virtual löschen. Dann kann allerdings in der Zeile 62 die Funktion basePrinter(...) nicht mehr aufgerufen werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <iostream>
 
class Base
{
  int x;
public:
  void setX(int v)
  {
    x = v;
  }
 
  int getX() const
  {
    return x;
  }
};
 
class Left : virtual public Base
{
};
 
class Right : virtual public Base
{
};
 
class Join : public Left, public Right
{
};
 
void leftSetter(Left &l, int v)
{
  l.setX(v);
}
 
void rightSetter(Right &r, int v)
{
  r.setX(v);
}
 
void leftPrinter(Left const &l)
{
  std::cout << "Left.x=" << l.getX() << std::endl;
}
 
void rightPrinter(Right const &r)
{
  std::cout << "Right.x=" << r.getX() << std::endl;
}
 
void basePrinter(Base const &b)
{
  std::cout << "Base.x=" << b.getX() << std::endl;
}
 
int main()
{
  Join j;
  leftSetter(j, 10);
  rightSetter(j, 5);
  leftPrinter(j);
  rightPrinter(j);
  basePrinter(j);
}

Erklärung

Das Diamantenproblem ist einfach erklärt: wenn man über mehrere mögliche Pfade von einer Basisklasse erbt und dabei auf diesen Pfaden möglicherweise Funktionen der Basisklasse unterschiedlich überschrieben wurde, welche Variante ist dann in der zuletzt abgeleiteten Klasse zu wählen? Der englische Wikipediaartikel zur Mehrfachvererbung listet verschiedene Lösungsansätze in verschiedenen Programmiersprachen auf. Der einfachste: Mehrfachvererbung verbieten. Diesen Weg gehen bspw. Java oder C#. Bei den Programmiersprachen, die trotzdem daran festhalten, sehen die Ansätze teilweise auch stark unterschiedlich aus und C++ bietet (mal wieder) mehr Flexibilität, als vielleicht gut wäre. Grundsätzlich gibt es in C++ zwei Arten von Vererbung: die klassische mit class B : public A und die virtuelle Vererbung mittels class B : virtual public A. Der Unterschied kommt zum Tragen, wenn man das Diamantenproblem betrachtet: im Falle der klassischen Vererbung sieht ein Objekt einer abgeleiteten Klasse wie folgt aus:

Objektstruktur bei klassischer Vererbung ohne virtuelle Basisklassen

Jeder Teilpfad der Vererbungshierarchie wird getrennt betrachtet und man erhält am Ende zwei Subobjekte der Basisklasse. Das braucht nicht nur doppelt soviel Platz im Speicher, sondern kann auch zu interessante Inkonsistenzen in den Daten in der Basisklasse führen. Wie im Beispiel im Video demonstriert, kann es durchaus dazu kommen, dass die – scheinbar – gleiche Variable in der Basisklasse gleichzeitig zwei verschiedene Werte annimmt und man je nach Verwendung mal den einen und mal den anderen sieht.

Um dieses Problem zu vermeiden bietet C++ die virtuelle Vererbung. Ähnliche wie bei virtual Funktionen vom Compiler sichergestellt wird, dass immer die korrekte Implementierung aufgerufen wird, stellt im Falle der virtual-Vererbung der Compiler sicher, dass nur ein Subobjekt der entsprechenden Basisklasse angelegt wird. Die Objektstruktur sieht dann genauso aus, wie die Klassenhierarchie (und damit zumindest etwas mehr, wie erwartet):

Objektstruktur bei Vererbung mit virtuellen Basisklassen

Löst das das Diamantenproblem? Leider nicht. Jetzt haben wir zwar nur noch ein Basisobjekt, was schonmal (je nach Anwendungsfall) besser ist, als mehrere zu haben. Ein Problem, welches aber immernoch bestehen bleibt, ist das unterschiedliche Überschreiben von Funktionen der Basisklasse in den beiden Teilen des Diamanten (sprich: in den Klassen Left und Right). Dazu aber mehr im nächsten Video.

Qualifizierung der Basisklasse

Im Video habe ich ja versprochen, dass ich nochmal kurz vorführen würde, wie man beim direkten Aufruf von Funktionen der Basisklasse über Join den Teilpfad des Vererbungsbaumes qualifizieren kann.

Zuersteinmal gehen wir vom Quelltext mit klassischer Vererbung aus (also nicht der oben, sondern die Variante ohne virtual). Versuche ich nun dort in der Funktion main(...) folgenden Code aufzurufen, so werde ich an der Uneindeutigkeit von Base::setX(...) scheitern.

1
2
3
4
5
int main()
{
  Join j;
  j.setX(9);
}

Der Compiler wird die schon im Video zu sehende Fehlermeldung werfen. Mit einem leicht veränderten Aufruf ist es allerdings möglich, den betreffenden Teilzweig (und damit auch das betreffende Basisobjekt) auszuwählen:

1
2
3
4
5
int main()
{
  Join j;
  j.Left::setX(9);
}

Mit dieser Aufrufsyntax teile ich dem Compiler mit, dass ich explizit die Left-Seite des Vererbungsbaumes absteigen möchte und damit auch dort auf dem entsprechenden Basisobjekt lande. Wer sich schon ein wenig weiter mit C++ auskennt, dem wird diese Syntax bekannt vorkommen: mit dem ::-Operator kann man auf den Inhalt von Namensräumen (siehe std::cout, das Objekt cout im Namensraum std) und Klassenmember (zur Definition von Funktionen außerhalb des Klassenbodys) zugreifen. Für alle anderen gibt’s dazu später mal ein Video.