Vererbung: für Objekte nützlich, für Werte gefährlich

In der Praxis der Objektorientierung wirft Vererbung oft Probleme auf. Es spricht vieles dafür, dass das nicht an der Unfähigkeit der Programmierer liegt, sondern dass sie in einigen Fällen gar nicht anwendbar ist und darum zu Fehlern und Widersprüchen führt.

Sprachen  –  40 Kommentare

Objektorientierung ist das in der Praxis vorherrschende Programmiersprachenparadigma. Vererbung gilt als einer ihrer wichtigsten Grundpfeiler – vielfach sogar als das Merkmal der Objektorientierung schlechthin. Sie verspricht Wiederverwendbarkeit, indem sie die Klassen eines objektorientierten Systems hierarchisch strukturiert, vom Allgemeinen zum Speziellen: Button erbt von Widget, Fahrzeug von Fortbewegungsmittel, Motorrad von Fahrzeug et cetera.

Problembeispiel: Kreis-Ellipsen-Paradoxon

Es gibt Situationen, in denen der Einsatz von Vererbung scheinbar unlösbare Schwierigkeiten nach sich zieht. Eine davon ist das sogenannte Kreis-Ellipsen-Dilemma (beschrieben u.a. von Robert C. Martin [1], Kazimir Majorinc [2] und Kevlin Henney [3]): Einerseits ist ein Kreis eine spezielle Ellipse, was dafür spricht, eine Klasse Circle von einer Klasse Ellipse erben zu lassen (s. Listing 1).

Listing 1: Circle erbt von Ellipse

public class Ellipse {

private float minorAxis;
private float majorAxis;

public Ellipse(float minorAxis, float majorAxis) {
this.minorAxis = minorAxis;
this.majorAxis = majorAxis;
}

public float getMajorAxis() {
return majorAxis;

}
public float getMinorAxis() {
return minorAxis;
}

public void stretchMajorAxis(float factor) {
this.majorAxis *= factor;
}

// ... more methods ...
}


public class Circle extends Ellipse {

public Circle(float radius) {
super(radius, radius);
}

public float getRadius() {
return getMajorAxis();
}

@Override
public void stretchMajorAxis(float factor) {
this.majorAxis *= factor;
this.minorAxis *= factor;
}

// ... more methods ...
}

Andererseits benötigt eine Ellipse mehr Attribute als ein Kreis, nämlich eine Haupt- und eine Nebenachse statt nur einen Radius. Das wiederum deutet darauf hin, dass Ellipse von Circle erben sollte (s. Listing 2).

Listing 2: Ellipse erbt von Circle

public class Circle {

private float radius;

public Circle(float radius) {
this.radius = radius;
}

public float getRadius() {
return radius;
}

public void setRadius(float radius) {
this.radius = radius;
}

// ... more methods ...
}


public class Ellipse extends Circle {

private float minorAxis;

public Ellipse(float majorAxis, float minorAxis) {
super(majorAxis);
this.minorAxis = minorAxis;
}

public float getMajorAxis() {
return this.radius;
}

public void setMajor(float majorAxis) {
this.radius = majorAxis;
}

public float getMinorAxis() {
return minorAxis;
}

public void setMinorAxis(float minorAxis) {
this.minorAxis = minorAxis;
}

// ... more methods ...
}

Diese Mehrdeutigkeit ist nicht das einzige Problem. Im ersten Fall erbt die Klasse Circle von der Klasse Ellipse nicht benötigte Member-Variablen und Methoden. Zum Beispiel sind die beiden unabhängig voneinander änderbaren Member-Variablen majorAxis und minorAxis für Circle nicht sinnvoll, er benötigt nur einen Radius.

Eine Operation, die eine Achse der Ellipse streckt, würde in der Subklasse Circle dazu führen, dass der Kreis nach Anwendung dieser Methode kein Kreis mehr ist. Überschreibt man sie in der Subklasse so, dass der Kreis seine Form behält, werden Invarianten der Klasse Ellipse verletzt: Bei einer Ellipse ändert die Methode stretchMajorAxis die Fläche proportional zu factor, bei einem Kreis dagegen quadratisch mit factor. Davon abgesehen wäre der Methodenname stretchMajorAxis in der Subklasse Circle missverständlich, wenn hier beide Achsen gestreckt werden.

Auch im zweiten Fall erbt die Subklasse Methoden, die sie nicht benötigt: getRadius wirft bei einer Ellipse Probleme auf, da für Benutzer nicht klar ist, ob damit die Haupt- oder die Nebenachse gemeint ist. Ellipse ist als Subklasse von Circle semantisch unsinnig, da im Allgemeinen eine Ellipse kein Kreis ist, vielmehr ist umgekehrt ein Kreis eine spezielle Ellipse. Egal, wie man es dreht: An irgendeiner Stelle "passt" es nicht – darum die Bezeichnung Dilemma.

Ein ähnlich gelagerter Fall ist die Programmierung einer Klasse Complex für die Abbildung der komplexen Zahlen als Subklasse einer Klasse Float (s. Listing 3).

Listing 3: Complex als Subklasse einer Klasse Float

public class Float {

private float value;

public Float(float value) {
this.value = value;
}

public float getValue() {
return value;
}
// ... more methods ...
}


public class Complex extends Float {

private float img;

public Complex(float real, float img) {
super(real);
this.img = img;
}

public float getReal() {
return value;
}

public float getImg() {
return img;
}

public Complex add(Complex other) {
return
new Complex(this.getReal() + other.getReal(),
this.getImg() + other.getImg());
}
// ... more methods ...
}

Complex erbt hier den Realteil von der Oberklasse, und für den Imaginärteil fügt sie eine weitere Member-Variable, ebenfalls vom Typ float, hinzu.

Auch hier ist die Klasse Float in der Vererbungshierarchie über Complex angesiedelt. Float-Zahlen lassen sich aber als spezielle komplexe Zahlen ansehen, nämlich solche mit Imaginärteil 0. Von daher wäre Float in der Typhierarchie unterhalb von Complex zu erwarten. Der umgekehrte Ansatz, die Programmierung von Float als Subklasse von Complex, würde allerdings ähnliche Schwierigkeiten aufwerfen wie im vorhergehenden Beispiel die Programmierung von Circle als Subklasse von Ellipse.