Kovarianz / Kontravarianz

Versuch einer ausführlicheren Erläuterung

Die Frage, bei der die Begriffe Ko-/Kontravarianz eine Rolle spielen, ist die, inwieweit eine Methode eines Subtyps, welche eine in einem Supertyp bereits deklarierte Methode überschreibt (bzw. implementiert) sich von der des Supertyps bzgl. bestimmter Aspekte unterscheiden darf, nämlich in Bezug auf den Rückgabetyp, die Typen der formalen Parameter und ggf. die Typen deklarierter Checked Exceptions.

Dabei bedeutet Kovarianz in etwa „gleichgerichtete Abweichung“, Kontravarianz „entgegengerichtete Abweichung“. Der Bezug ist dabei immer das Typverhältnis zwischen Supertyp und Subtyp, d.h. Kovarianz bzgl. irgendeines Faktors liegt vor, wenn die Typbeziehung zwischen den Ausprägungen dieses Faktors in den beiden Typen gleichgerichtet zu der Typbeziehung der Typen selbst verläuft, Kontravarianz, wenn sie entgegengerichtet verläuft.

Beispiel: Überschreibt eine Methode im Subtyp eine Methode des Supertyps so, dass der Rückgabetyp der Subtypmethode Subtyp des Rückgabetyps der Supertypmethode ist, dann sagt man: Im Subtyp wurde die Methode mit kovariantem Rückgabetyp überschrieben. Überschreibt eine Methode im Subtyp eine Methode des Supertyps so, dass der Parametertyp der Subtypmethode Supertyp des Parametertyps der Supertypmethode ist, dann sagt man: Im Subtyp wurde die Methode mit kontravariantem Parametertyp überschrieben.

Ganz wichtig also: Wenn man von Ko- oder Kontravarianz spricht, muss man immer dazusagen, auf welchen Faktor man sich überhaupt bezieht. Aussagen wie „Java erlaubt keine Kontravarianz“ oder „Kovarianz wird durch das LSP verboten“ sind daher völlig sinnlos.

So... nach dieser einleitenden Begriffsklärung nun zum eigentlichen Thema.

Anmerkung: Zu beachten ist, dass die folgenden Ausführungen sich nicht auf Java beziehen, sondern auf das Konzept Subtyping als solches. Java ist hier restriktiver, als es theoretisch nötig wäre. Der Kurstext hierzu:

„Java lässt Kontravarianz bei den Parametertypen nicht zu, da dies zu Problemen im Zusammenhang mit überladenen Methodennamen führen würde (vgl. Abschn. 2.1.4, S. 89, vgl. Abschn. 3.3.5, S. 245). Ab der Version 5.0 lässt Java aber Kovarianz bei den Ergebnistypen zu, was in früheren Versionen noch nicht erlaubt war.“

Die konzeptionelle Grundlage, von der wir ausgehen, ist die Definition der Subtypbeziehung, wonach gewährleistet sein muss, dass ein Exemplar eines Subtyps, da es ein Exemplar des Supertyps ist, überall dort stehen kann, wo ein Exemplar des Supertyps erlaubt ist (Liskovsches Substitutionsprinzip, im Folgenden abgekürzt LSP, Barbara Liskov, 1988).

Aus dieser Ersetzbarkeitsforderung ergibt sich, dass ein Subtyp die Fähigkeiten des Supertyps, bestimmte Nachrichten zu verarbeiten, nicht einschränken darf. Also darf schon rein konzeptionell eine im Supertyp vorhandene Methode im Subtyp nie durch eine ersetzt werden, welche

  • einen Parameter nicht „verträgt“, den die Supertypmethode verträgt,
  • deklariert, abrupt mit einer Ausnahme enden zu können, mit der nicht auch die Supertyp-Methode hätte terminieren können, oder
  • einen Rückgabewert liefert, den nicht auch die Supertyp-Methode hätte liefern können.

Eine überschreibende Methode darf also die möglichen Parametertypen nicht einschränken, indem sie nur bestimmte Subtypen der in der von ihr überschriebenen Supertypmethode als Parameter erlaubt (das wäre Kovarianz bzgl. der Parametertypen). Hingegen ist es konzeptionell unproblematisch, wenn sie auch Supertypen der Parametertypen der überschriebenen Methode zulässt (Kontravarianz bzgl. der Parametertypen), denn damit verträgt sie immer noch alle Parameter, welche die überschriebene Methode vertragen hat.

Eine überschreibende Methode darf auch nicht deklarieren, einen Supertyp des Rückgabewerts der überschriebenen Methode zu liefern (das wäre Kontravarianz bzgl. des Rückgabewerts). Denn dann könnte sie auch solche Typen zurückgeben, mit denen der Aufrufer der Methode nicht rechnen musste. Hingegen spricht nichts dagegen, wenn sie deklariert, nur bestimmte Subtypen zu liefern (Kovarianz bzgl. des Rückgabewerts). Denn damit liefert sie ggf. nichts, was nicht auch die überschriebene Methode hätte liefern können.

Wichtig ist, zu verstehen, dass und warum Überschreiben einer Methode mit kontravariantem Rückgabetyp oder mit kovariantem Parametertyp prinzipiell nicht möglich ist, wenn man die Forderung aufrechterhält, dass Objekte eines Subtyps überall stehen können müssen, wo Objekte des Supertyps erlaubt sind, wohingegen Überschreiben einer Methode mit kovariantem Rückgabetyp und/oder kontravariantem Parametertyp mit dieser Forderung durchaus verträglich ist. Das ist also keine Eigenschaft einer bestimmten Programmiersprache, sondern eine logische Folgerung einer bestimmten – der gängigen – Definition der Subtypbeziehung.

Und nun das Ganze noch mal an zwei halbwegs konkreten Beispielen...

WARNUNG: Die Beispiele dienen nur der Erläuterung des Konzepts. Es handelt sich also trotz der Java-Syntax nicht um sinnvollen Java-Code! Insbesondere ist zu beachten, dass Beispiel 1 in Java zwar kompilierbar wäre, allerdings würde durch die Methode

    Waerme verbrenne(Brennstoff b)

in KohleUndOelOfen die Methode

    Waerme verbrenne(Kohle k)

aus KohleOfen nicht ersetzt (= überschrieben), sondern beide Methoden, die geerbte und die neue, würden in der Klasse KohleUndOelOfen nebeneinander existieren (= Überladung), denn Java erlaubt aus den o.g. logischen Gründen kein Überschreiben einer Methode mit kontravarianten Parametertypen.

Beispiel 2 ist in Java erst ab 1.5 / 5.0 kompilierbar, da Java Überschreiben einer Methode mit kovariantem Rückgabetyp erst ab dieser Version unterstützt.

Beispiel 1 (Kontravarianz bzgl. Parametertyp):

class Brennstoff {
}

class Waerme {
}

class Kohle extends Brennstoff {
}

class Oel extends Brennstoff {
}

class KohleOfen {
    Waerme verbrenne(Kohle k) {
        // verheize Kohle auf korrekte Art und Weise erzeuge ein Waerme-Exemplar
        return new Waerme();
    }
}

class KohleUndOelOfen extends KohleOfen {
    Waerme verbrenne(Brennstoff b) {
        if (b instanceof Kohle) 
            // verheizeKohle wie bisher erzeuge ein Waerme-Exemplar
            return new Waerme();
        else
            // verheize Oel auf korrekte Art und Weise erzeuge ein Waerme-Exemplar
            return new Waerme();
    }
}

Hier überschreibt (noch einmal: nicht in Java!) die Methode

   Waerme verbrenne(Brennstoff b) { ... }

der Subklasse die Methode

   Waerme verbrenne(Kohle k) { ... }

der Superklasse. Dies ist konzeptionell möglich, da die überschreibende Methode nach wie vor auch Kohle (als Subtyp von Brennstoff) verarbeiten kann, denn Kohle ist ein Brennstoff.

Wenn A SUB→ B bedeutet, A ist Subtyp von B, und ich unter jede Klasse den Parameter ihrer verbrenne()-Methode schreibe, dann sieht die „ist Subtyp von“-Beziehung hier so aus:

    Klasse:         KohleOfen       ←SUB    KohleUndOelOfen
    Parameter:      Kohle           SUB→    Brennstoff

Diese Gegenläufigkeit der Supertyp-Subtyp-Beziehungen ist ein Beispiel für Kontravarianz.

Hingegen wäre es nicht möglich, dass eine fiktive Subklasse SteinkohleOfen die Superklassenmethode verbrenne() mit kovariantem Parametertyp überschreibt, etwa so:

   Waerme verbrenne(Steinkohle k) { ... }

Denn dann würde die neue Methode im Gegensatz zu der, welche sie überschreibt, z.B. keine Braunkohle mehr vertragen, womit ein solcher SteinkohleOfen nicht mehr überall eingesetzt werden könnte, wo ein Ofen eingesetzt werden kann → Widerspruch zum LSP. Beispiel 2 (Kovarianz bzgl. Rückgabetyp):

class Mehl {}

class Eier {}

class Milch {}

class Gebaeck {}

class SuessGebaeck extends Gebaeck {}

class Kuchen extends SuessGebaeck {}

class Baecker {
    SuessGebaeck backe(Mehl me, Eier e, Milch mi) {
        // tu irgendwas was ein SuessGebaeck erzeugt
        return new SuessGebaeck();
    }
}

class KuchenBaecker extends Baecker {
    Kuchen backe(Mehl me, Eier e, Milch mi) {
        // tu irgendwas was einen Kuchen erzeugt
        return new Kuchen();
    }
}

Hier handelt es sich um Überschreiben mit Kovarianz bzgl. des Rückgabetyps: Die Methode

   Kuchen backe(Mehl m, Eier e, Milch m) { ... }

der Subklasse überschreibt die Methode

   SuessGebaeck backe(Mehl m, Eier e, Milch m) { ... }

der Superklasse. Dies ist konzeptionell möglich, da sie nach wie vor ein SuessGebaeck zurückliefert, denn ein Kuchen ist ein SuessGebaeck.

Wenn A SUB→ B bedeutet, A ist Subtyp von B, und ich unter jede Klasse den Rückgabetyp ihrer backe()-Methode schreibe, dann sieht die „ist Subtyp von“-Beziehung hier so aus:

    Klasse:         Baecker         ←SUB    KuchenBaecker
    Rückgabetyp:    SuessGebaeck    ←SUB    Kuchen

Diese Gleichläufigkeit der Supertyp-Subtyp-Beziehungen ist ein Beispiel für Kovarianz.

Hingegen wäre es nicht möglich, dass eine fiktive Subklasse AllesMoeglicheBaecker von Baecker die Superklassenmethode backe() mit kontravariantem Rückgabetyp überschreibt, etwa so:

   Gebaeck backe(Mehl m, Eier e, Milch m) { ... }

Denn dann könnte die neue Methode im Gegensatz zu der, welche sie überschreibt, z.B. auch Brot zurückliefern, welches ein Gebaeck, aber kein SuessGebaeck ist. Ein Verwender der Methode rechnet aber mit SuessGebaeck. Damit könnte ein AllesMoeglicheBaecker nicht mehr überall eingesetzt werden, wo ein Baecker eingesetzt werden kann → Widerspruch zum LSP.