Kovarianz und Kontravarianz

(Weitergeleitet von Invarianz (Informatik))

In der objektorientierten Programmierung unterscheidet Kovarianz und Kontravarianz, ob ein Aspekt (d. h. eine Typdeklaration) gleichartig der Vererbungsrichtung (kovariant) oder entgegengesetzt zu dieser (kontravariant) ist. Liegt in der Unterklasse keine Änderung gegenüber der Oberklasse vor, wird das als Invarianz bezeichnet.

Den Begriffen liegen die Überlegungen des Ersetzbarkeitsprinzips zugrunde: Objekte der Oberklasse müssen durch Objekte einer ihrer Unterklassen ersetzbar sein. Das bedeutet zum Beispiel, dass die Methoden der Unterklasse mindestens die Parameter akzeptieren müssen, die die Oberklasse auch akzeptieren würde (Kontravarianz). Die Methoden der Unterklasse müssen ebenfalls Werte zurückliefern, die mit der Oberklasse vereinbar sind, also nie allgemeineren Typs sind, als der Rückgabetyp der Oberklasse (Kovarianz).

Begriffsherkunft

Bearbeiten

Die Begriffe Kontravarianz und Kovarianz leiten sich in der Objektorientierung davon ab, dass sich die Typen der betrachteten Parameter mit der Vererbungshierarchie der Ersetzung (kovariant) bzw. entgegengesetzt zur Vererbungshierarchie (kontravariant) verhalten.

Auftreten von Varianzen

Bearbeiten

Man kann zwischen Ko-, Kontra- und Invarianz bei

  • Methoden
    • Argumenttypen (die Typen der übergebenen Parameter)
    • Ergebnistypen (die Typen des Rückgabewertes)
    • sonstige Signaturerweiterungen (z. B. Exceptiontypen in der throws-Klausel in Java)
  • generischen Klassenparametern

unterscheiden.

Durch das Substitutionsprinzip ergeben sich in der Vererbungshierarchie der objektorientierten Programmierung folgende Auftrittsmöglichkeiten für Varianzen:

Kontravarianz Eingabeparameter
Kovarianz Rückgabewert und Ausnahmen
Invarianz Ein- und Ausgabeparameter

Kovarianz, Kontravarianz und Invarianz

Bearbeiten

Kovarianz bedeutet, dass die Typhierarchie mit der Vererbungshierarchie der zu betrachtenden Klassen die gleiche Richtung hat. Wenn man also eine vererbte Methode anpassen will, so ist die Anpassung kovariant, wenn der Typ eines Methodenparameters in der Oberklasse ein Obertyp des Parametertyps dieser Methode in der Unterklasse ist.

Wenn die Typhierarchie entgegengesetzt zur Vererbungshierarchie der zu betrachtenden Klassen läuft, so spricht man von Kontravarianz. Wenn die Typen in der Ober- und Unterklasse nicht geändert werden dürfen, spricht man von Invarianz.

In der Objektorientierten Modellierung ist es oft wünschenswert, dass auch die Eingabeparameter von Methoden kovariant sind. Dadurch wird allerdings das Substitutionsprinzip verletzt. Das Überladen wird in diesem Fall von den verschiedenen Programmiersprachen unterschiedlich gehandhabt.

Beispiel anhand von Programmcode

Bearbeiten

Grundsätzlich gilt in Programmiersprachen wie C++ und C#, dass Variablen und Parameter kontravariant sind, während Methodenrückgaben kovariant sind. Java verlangt hingegen die Kovarianz der Methodenparameter und Variablen, wobei der Rückgabeparameter kovariant sein muss:

Beispiel in C#
Kontravarianz Kovarianz Invarianz
public abstract class Animal
{
   public abstract string Name { get; }
}

public class Giraffe : Animal
{
   public Giraffe(string name)
   {
      Name = name;
   }
   public override string Name { get; }
}

public string GetNameFromAnimal(Animal animal)
{
   return animal.Name;
}

[Test]
public void Contravariance()
{
    var herby = new Giraffe("Herby");
    // kontravariante Umwandlung von Giraffe nach Animal
    var name = GetNameFromAnimal(herby);
    Assert.AreEqual("Herby", name);
}
public abstract class Animal
{
   public abstract string Name { get; }
}

public class Giraffe : Animal
{
   public Giraffe(string name)
   {
      Name = name;
   }
   public override string Name { get; }
}

public string GetNameFromGiraffe(Giraffe animal)
{
   return animal.Name;
}

[Test]
public void Covariance()
{
    var herby = new Giraffe("Herby");
    // kovariante Umwandlung des Rückgabewerts von String nach Object
    object name = GetNameFromGiraffe(herby);
    Assert.AreEqual((object)"Herby", name);
}
public abstract class Animal
{
   public abstract string Name { get; }
}

public class Giraffe : Animal
{
   public Giraffe(string name)
   {
      Name = name;
   }
   public override string Name { get; }
}

public string GetNameFromGiraffe(Giraffe animal)
{
   return animal.Name;
}

[Test]
public void Invariance()
{
    var herby = new Giraffe("Herby");
    // keine Umwandlung der Datentypen
    string name = GetNameFromGiraffe(herby);
    Assert.AreEqual("Herby", name);
}

Beispiel anhand von Abbildungen

Bearbeiten

Im Folgenden wird verdeutlicht, wann die Typsicherheit gewährleistet bleibt, wenn man eine Funktion durch eine andere ersetzen will. Dies lässt sich im Weiteren dann auf Methoden in der Objektorientierung übertragen, wenn nach dem Liskovschen Substitutionsprinzip Methoden von Objekten ersetzt werden.

Seien   und   Funktionen, die beispielsweise folgende Signatur haben:

 , wobei   und  , und
 , wobei   und  .

Wie man sieht, ist   eine Obermenge von  , jedoch   eine Untermenge von  . Wenn man die Funktion   anstelle von   einsetzt, dann nennt man den Eingabetyp C kontravariant, den Ausgabetyp D kovariant. Im Beispiel kann die Ersetzung ohne Typverletzung geschehen, da die Eingabe von   den gesamten Bereich der Eingabe von   abdeckt. Außerdem liefert   Ergebnisse, die den Wertebereich von   nicht überschreiten.

Korrektheit von Kontra- und Kovarianz

Bearbeiten

Als Modell soll die UML-Schreibweise zur Darstellung der Vererbungshierarchie dienen:

                       Kontravarianz           Kovarianz             Invarianz
 ┌─────────┐         ┌───────────────┐     ┌───────────────┐     ┌───────────────┐
 │    T    │         │ ClassA        │     │ ClassA        │     │ ClassA        │
 ├─────────┤         ├───────────────┤     ├───────────────┤     ├───────────────┤
 │         │         │               │     │               │     │               │
 ├─────────┤         ├───────────────┤     ├───────────────┤     ├───────────────┤
 │         │         │ method(t':T') │     │ method():T    │     │ method(t :T&) │
 └─────────┘         └───────────────┘     └───────────────┘     └───────────────┘
      ↑                      ↑                     ↑                     ↑
 ┌─────────┐         ┌───────────────┐     ┌───────────────┐     ┌───────────────┐
 │    T'   │         │ ClassB        │     │ ClassB        │     │ ClassB        │
 ├─────────┤         ├───────────────┤     ├───────────────┤     ├───────────────┤
 │         │         │               │     │               │     │               │
 ├─────────┤         ├───────────────┤     ├───────────────┤     ├───────────────┤
 │         │         │ method(t :T ) │     │ method():T'   │     │ method(t :T&) │
 └─────────┘         └───────────────┘     └───────────────┘     └───────────────┘

Kontravarianz: Das Substitutionsprinzip wird eingehalten, denn man kann method(t : T) der Unterklasse ClassB so verwenden, als wäre es die Methode der Oberklasse ClassA.
Prüfen: Man kann der method(t : T) eine Variable eines spezielleren Typs T' übergeben, da aufgrund der Vererbung T' alle Informationen enthält, die sich auch in T befinden.

Kovarianz: Das Substitutionsprinzip wird eingehalten, denn man kann method():T' der Unterklasse ClassB so verwenden, als wäre es die Methode der Oberklasse ClassA.
Prüfen: Der Rückgabewert der Methode aus ClassB ist T'. Man darf diesen Wert einer vom Typ T deklarierten Variable übergeben, da T' aufgrund der Vererbung über alle Informationen verfügt, die sich auch in T befinden.

Typsicherheit bei Methoden

Bearbeiten

Auf Grund der Eigenschaften des Substitutionsprinzipes ist statische Typsicherheit dann gewährleistet, wenn die Argumenttypen kontravariant und die Ergebnistypen kovariant sind.

Typunsichere Kovarianz

Bearbeiten

Die in der Objektorientierten Modellierung oft wünschenswerte Kovarianz der Methodenparameter wird trotz resultierender Typunsicherheit in vielen Programmiersprachen unterstützt.

Ein Beispiel für die Typunsicherheit kovarianter Methodenparameter findet sich in den folgenden Klassen Person und Arzt, und deren Spezialisierungen Kind und Kinderarzt. Der Parameter der Methode untersuche in der Klasse Kinderarzt ist eine Spezialisierung des Parameters derselben Methode von Arzt und demnach kovariant.

Typunsichere Kovarianz - allgemein
┌─────────┐         ┌───────────────┐
│    T    │         │ ClassA        │
├─────────┤         ├───────────────┤
│         │         │               │
├─────────┤         ├───────────────┤
│         │         │ method(t :T ) │
└─────────┘         └───────────────┘
     ↑                      ↑
┌─────────┐         ┌───────────────┐
│    T'   │         │ ClassB        │
├─────────┤         ├───────────────┤
│         │         │               │
├─────────┤         ├───────────────┤
│         │         │ method(t':T') │
└─────────┘         └───────────────┘
   Beispiel für typunsichere Kovarianz
┌────────────────┐         ┌───────────────────────┐
│ Person         │         │ Arzt                  │
├────────────────┤         ├───────────────────────┤
│                │         │                       │
├────────────────┤         ├───────────────────────┤
│ stillHalten()  │         │ untersuche(p: Person) │
└────────────────┘         └───────────────────────┘
         ↑                             ↑
┌────────────────┐         ┌───────────────────────┐
│ Kind           │         │ Kinderarzt            │
├────────────────┤         ├───────────────────────┤
│                │         │                       │
├────────────────┤         ├───────────────────────┤
│ tapferSein()   │         │ untersuche(k: Kind)   │
└────────────────┘         └───────────────────────┘
Die Implementierung des Beispiels in Java sieht folgendermaßen aus: Ein Programm unter Verwendung der Klassen könnte so aussehen: Die Ausgabe lautet dann:
   public class Person {
       protected String name;
       public String getName() { return name; }
       public Person(final String n) { name = n; }
       public void stillHalten() {
           System.out.println(name + " hält still");
       }
   }

   public class Kind extends Person {
       boolean tapfer = false;
       public Kind(final String n) {super(n); }
       public void stillHalten() {
           if(tapfer)
               System.out.println(name + " hält still");
           else
               System.out.println(name + " sagt AUA und wehrt sich");
       }
       public void tapferSein() {
           tapfer = true;
           System.out.println(name + " ist tapfer");
       }
   }

   public class Arzt extends Person {
       public Arzt(final String n) { super(n); }
       public void untersuche(Person person) {
           System.out.println(name + " untersucht " + person.getName());
           person.stillHalten();
       }
   }

   public class Kinderarzt extends Arzt {
       public Kinderarzt(final String n) { super(n); }
       public void untersuche(Kind kind) {
           System.out.println(name + " untersucht Kind " + kind.getName());
           kind.tapferSein();
           kind.stillHalten();
       }
   }
public class Main {
    public static void main(String[] args) {
       Arzt arzt = new Kinderarzt("Dr. Meier");
       Person person = new Person("Frau Müller");
       arzt.untersuche(person);
       Kind kind = new Kind("kleine Susi");
       arzt.untersuche(kind);
       // und jetzt RICHTIG
       Kinderarzt kinderarzt = new Kinderarzt("Dr. Schulze");
       kinderarzt.untersuche(person);
       kinderarzt.untersuche(kind);
    }
}
Dr. Meier untersucht Frau Müller
Frau Müller hält still
Dr. Meier untersucht kleine Susi
kleine Susi sagt AUA und wehrt sich
Dr. Schulze untersucht Frau Müller
Frau Müller hält still
Dr. Schulze untersucht Kind kleine Susi
kleine Susi ist tapfer
kleine Susi hält still

Wichtig ist, dass das Objekt arzt richtig deklariert werden muss, weil hier eine Methode nicht überschrieben, sondern überladen wird, und der Vorgang des Überladens an den statischen Typ des Objekts gebunden ist. Die Folge sieht man beim Vergleich der Ausgaben: Dr. Meier kann keine Kinder untersuchen, Dr. Schulze hingegen schon.

In Java funktioniert das Beispiel korrekt: Die Methode untersuche von Arzt wird in Kinderarzt nicht überschrieben, sondern aufgrund der unterschiedlichen Parameter lediglich überladen, dadurch wird jeweils die richtige Methode aufgerufen. Wenn Arzt untersuche aufgerufen wird, wird die Methode auch immer dort aufgerufen; wenn jedoch Kinderarzt untersuche aufgerufen wird, wird je nach Typ einmal untersuche bei Arzt und einmal bei Kinderarzt aufgerufen. Laut der Sprachdefinition von Java muss eine Methode, die überschrieben werden soll, die gleiche Signatur (in Java bestehend aus Parameter + evtl. Exceptions) besitzen.

Das gleiche Beispiel kann man auch in Python codieren, allerdings ist zu beachten, dass Parameter nicht typisiert werden. Der Code würde so aussehen:


#!/usr/bin/env python

class Person:
    def __init__(self,name):
        self.name = name
    def stillHalten(self):
        print(self.name, " hält still")

class Arzt(Person):
    def __init__(self,name):
        super().__init__(name)
    def untersuche(self,person):
        print(self.name, " untersucht ", person.name)
        person.stillHalten()

class Kind(Person):
    def __init__(self,name):
        super().__init__(name)
        self.tapfer = False
    def tapferSein(self):
        self.tapfer = True
        print(self.name, " ist jetzt tapfer")
    def stillHalten(self):
        if self.tapfer:
            print(self.name, " hält still")
        else:
            print(self.name, " sagt AUA und wehrt sich")

class Kinderarzt(Arzt):
    def __init__(self,name):
        super().__init__(name)
    def untersuche(self,person):
        print(self.name, " untersucht ", person.name)
        if isinstance(person,Kind):
            person.tapferSein()
        person.stillHalten()


if __name__ == "__main__":
    frMüller = Person("Frau Müller")
    drMeier = Arzt("Dr. Meier")
    drMeier.untersuche(frMüller)
    kleineSusi = Kind("kleine Susi")
    drMeier.untersuche(kleineSusi)
    drSchulze = Kinderarzt("Dr. Schulze")
    drSchulze.untersuche(frMüller)
    drSchulze.untersuche(kleineSusi)

Kovarianz auf Arrays

Bearbeiten

Bei Array-Datentypen kann Kovarianz bei Sprachen wie C++, Java und C# zu einem Problem führen, da diese intern den Datentyp auch nach der Umwandlung beibehalten:

Java C#
@Test (expected = ArrayStoreException.class)
public void ArrayCovariance()
{
    Giraffe[] giraffen = new Giraffe[10];
    Schlange alice = new Schlange("Alice");

    // Kovarianz (Typumwandlung in Vererbungsrichtung)
    Tier[] tiere = giraffen;

    // führt zur Laufzeit zu einer Ausnahme,
    // da das Array intern vom Typ Giraffe ist
    tiere[0] = alice;
}
[Test, ExpectedException(typeof(ArrayTypeMismatchException))]
public void ArrayCovariance()
{
    var giraffen = new Giraffe[10];
    var alice = new Schlange("Alice");

    // Kovarianz
    Tier[] tiere = giraffen;

    // Ausnahme zur Laufzeit
    tiere[0] = alice;
}

Um derartige Laufzeitfehler zu vermeiden, können generische Datentypen genutzt werden, die keine modifizierenden Methoden anbieten. In C# wird häufig das Interface IEnumerable<T> verwendet, das unter anderem vom Array-Datentyp implementiert wird. Da ein IEnumerable<Tier> nicht verändert werden kann, muss z. B. über Erweiterungsmethoden aus LINQ eine neue Instanz erzeugt werden, um Element alice aufzunehmen.

[Test]
public void ArrayCovariance()
{
    var giraffen = new Giraffe[10];
    var alice = new Schlange("Alice");

    IEnumerable<Tier> tiere = new Tier[]{ alice }
       .Concat(giraffen.Skip(1).Take(9));

    Assert.Contains(alice, tiere);
}

Siehe auch

Bearbeiten