Auflösung von Überladung, Überschreiben, dynamisches Binden

Was ist eigentlich dieses „Binden“?

„Binden“ bezeichnet den Vorgang, durch den einem im Quellcode stehenden Methodenaufruf die Methode zugeordnet wird, welche dann tatsächlich ausgeführt wird.

Wie läuft das „Binden“ bei Java ab? Was macht der Compiler und was die VM?

Dieser Vorgang erfolgt bei Java in zwei Stufen.

Zunächst erfolgt die Auflösung von Überladung durch den Compiler ausschließlich aufgrund der Deklarationstypen (statischen Typen) des Ausdrucks, auf dem der Methodenaufruf erfolgt sowie der Parameter des Aufrufs. Als Ergebnis der Tätigkeit des Compilers wird ein Aufruf einer ganz bestimmten Methode in den Bytecode geschrieben, oder aber es kommt zu einem Compilerfehler, nämlich dann, wenn der Compiler unter mehreren zum Aufruf passenden Methoden keine Entscheidung treffen kann.

Zur Laufzeit ordnet die Laufzeitumgebung („Virtual Machine“, VM) dem im Bytecode stehenden Methodenaufruf anhand des tatsächlichen (dynamischen) Typs des Objekts, an das sich der Methodenaufruf richtet die letztlich auszuführende Methode zu (dynamische Methodenwahl).

Wie funktioniert die Auflösung der Überladung durch den Compiler genau?

Vorbemerkung: Bitte beachten Sie im Folgenden, dass die Subtypbeziehung definitionsgemäß reflexiv ist, d.h. jeder Typ ist Subtyp (und Supertyp) seiner selbst!

Die Auflösung von Überladung durch den Compiler erfolgt nach dem Most-Specific-Algorithmus, der selbst wieder in mehreren Schritten abläuft. Dabei werden prinzipiell nur die Deklarationstypen (= statischen Typen) des Empfängers des Methodenaufrufs sowie der Parameter berücksichtigt, denn nur diese sind dem Compiler bekannt.

Im ersten Schritt wird eine Liste mit zum Aufruf passenden Methoden angelegt, das sind alle, für die gilt, dass die Deklarationstypen der Aufrufparameter Subtypen der Deklarationstypen der formalen Parameter der Methode sind. Besteht diese Liste nur aus einem Element, wird direkt der betreffende Methodenaufruf in den Bytecode geschrieben.

Wenn die Liste der zum Aufruf passenden Methoden mehrere Elemente enthält, ist ein weiterer Schritt erfoderlich, die Auswahl der speziellsten Methode. Dazu wird aus der Liste jede Methode gestrichen, für die es in der Liste eine speziellere gibt. Der Vergleich, welche von zwei Methoden spezieller ist, ist dabei völlig unabhängig von einem konkreten Methodenaufruf: Eine Methode M2 ist genau dann spezieller als eine Methode M1, wenn für jeden formalen Parameter von M2 gilt, dass sein Typ Subtyp des Typs des entsprechenden formalen Parameters von M1 ist. Bleibt nach dem Streichen genau eine Methode übrig, ist diese die speziellste zum Aufruf passende und ein entsprechender Aufruf wird in den Bytecode geschrieben. Bleiben mehrere Methoden übrig, dann sind diese offenbar gleich speziell, und es ist nicht möglich, den Methodenaufruf eindeutig aufzulösen. → Compilerfehler.

Wie funktioniert das dynamischen Binden durch die Laufzeitumgebung genau?

Als Ergebnis der Tätigkeit des Compilers steht im Bytecode ein konkreter Methodenaufruf. Zur Laufzeit muss nun die Laufzeitumgebung („Virtual Machine“, VM) diesem Methodenaufruf die tatsächlich auszuführende Methode zuordnen.

Diese (dynamische) Zuordnung ist notwendig, weil der tatsächliche Typ (dynamische Typ) des Objekts, an das sich der Methodenaufruf richtet, ein beliebiger Subtyp des Deklarationstyps des Ausdrucks sein kann, auf dem der Methodenaufruf erfolgte. Im dynamischen Typ könnte also die vom Compiler ausgewählte Methode überschrieben worden sein, und dann soll ja die Methode des tatsächlichen Typs des Aufrufempfängers ausgeführt werden. Anders gesagt: Dynamisches Binden ist ein technisches Mittel, um eins der wesentlichen Grundprinzipien des objektorientierten Paradigmas umzusetzen: "Das Objekt entscheidet, wie es auf eine Nachricht reagiert."

Da in Java eine Methode nur dann eine gleichnamige Methode eines Supertyps überschreibt, wenn die Deklarationstypen der Parameter beider Methoden exakt übereinstimmen (Invarianz), sucht die VM beginnend im tatsächlichen Typ des Aufrufempfängers nach einer Methode, deren Parametertypen genau denen des im Bytecode stehenden Methodenaufrufs entsprechen. Findet sie diese Methode dort nicht, schaut sie in der Superklasse nach, ggf. in deren Superklasse und so weiter. Dass sich die Methode finden muss, ist klar, sonst hätte der Compiler den entsprechenden Methodenaufruf ja gar nicht erst zugelassen.

Warum ist es falsch, zu sagen, im zweiten Schritt des Most-Specific-Algorithmus würde die „am besten zum Aufruf passende“ Methode ausgewählt?

Weil aus der Liste der prinzipiell zum Aufruf passenden Methoden die speziellste Methode ausgewählt wird. Dazu wird aus der Liste jede Methode gestrichen, zu der es eine speziellere gibt. Die Frage, welche von zwei Methoden spezieller ist, ist aber völlig unabhängig vom einem konkreten Methodenaufruf: Eine Methode M2 ist genau dann spezieller als eine Methode M1, wenn für jeden formalen Parameter von M2 gilt, dass sein Typ Subtyp des Typs des entsprechenden formalen Parameters von M1 ist.

Ein Vergleich zwischen den aktuellen Parametern eines tatsächlichen Methodenaufrufs und den formalen Parametern der verschiedenen prinzipiell passenden Methoden („passendste Methode“) kann zu einem anderen Ergebnis führen als die oben beschriebene tatsächliche Vorgehensweise („speziellste passende Methode“) und ist daher falsch.

Wieso sind im folgenden Beispiel die beiden Methoden „gleich speziell“, obwohl doch der „Abstand“ zwischen Karpfen und Fisch viel geringer ist als der zwischen Tier und Object?

Gegeben war die folgende Typhierarchie:

Karpfen SUB→ Fisch SUB→ Tier SUB→ Lebewesen SUB→ Object.

Es ging um diese beiden Methoden:

    void m1(Karpfen k, Object o) { … }

    void m2(Fisch f, Tier t) { … }

Antwort:     In der Tat… zwischen Karpfen und Fisch liegt ein „Typschritt“, zwischen Tier und Object liegen zwei. Man könnte daher annehmen, dass die obere Methode spezieller sei als die untere. Das ist aber nicht der Fall: Bei der Frage, ob eine Methode spezieller ist, als eine andere spielt nur die Existenz von Subtypbeziehungen zwischen den Deklarationstypen der formalen Parameter eine Rolle. Ob und wie viele „Schritte“ zwischen zwei Typen liegen, ist dabei irrelevant.

Für keine der beiden obigen Methoden kann man sagen, dass die Deklarationstypen sämtlicher formaler Parameter Subtypen des entsprechenden Parametertyps der anderen Methode sind: Es gilt ja Karpfen SUB→ Fisch, aber nicht Object SUB→ Tier und es gilt Tier SUB→ Object aber nicht Fisch → Karpfen. Die beiden Methoden sind also gleich speziell.

Kämen also nur diese beiden Methoden für einen bestimmten Aufruf als passende Methoden in Frage oder gäbe es unter den anderen in Frage kommenden Methoden keine, die spezieller ist als beide, wäre der Aufruf nicht auflösbar.

Warum wird im folgenden Beispiel nicht die Methode ausgeführt, die „3“ ausgibt? Schließlich passt diese doch am besten zum Aufruf?

public class Test {
    public static void main(String[] args) {
        Vogel v = new Vogel();
        Tier t = new Tier();
        Super sup = new Sub();
        sup.m(v, t);
    }
}

class Super {
    public void m(Tier t1, Tier t2) {
        System.out.println("1");
    }
    
    public void m(Vogel v1, Vogel v2) {
        System.out.println("2");
    }
}

class Sub extends Super {
    public void m(Vogel v, Tier t) {
        System.out.println("3");
    }
}

class Tier { }

class Vogel extends Tier { }

Antwort:    Die Auflösung von Überladung findet ausschließlich und abschließend durch den Compiler statt. Und der kennt nur die statischen Typen (= Deklarationstypen) von Variablen und Ausdrücken. Der Deklarationstyp der Variablen sup ist aber der Typ Super. Ob dieser Variablen zur Laufzeit eine Referenz auf ein Sub-Exemplar zugewiesen werden wird, weiß der Compiler nicht: Selbst die Zulässigkeit der Zuweisung überprüft er nur anhand der Deklarationstypen (das Resultat eines Konstruktoraufrufs hat ja definitionsgemäß den Deklarationstyp der Klasse, deren Konstruktor aufgerufen wurde). Die Zuweisung ist zulässig, denn es gilt Sub SUB→ Super. Anschließend spielt der Typ Sub für den Compiler keine Rolle mehr, ihn interessieren ja nur Deklarationstypen!

Einschub: Der Grund für diese auf den ersten Blick willkürlich erscheinende Bechränktheit des Compiler ist, dass es für den Compiler (der ja nur einzelne Klassen compiliert) gar nicht möglich ist, lokal zu analysieren, was einer Variable im Laufe ihrer Existenz so zugewiesen wird. So könnte eine Variable z.B. mit dem Resultat des Aufrufs einer dynamisch gebundenen Methode eines Exemplars einer anderen Klasse belegt werden. Dazu kommt bei einem Attribut noch die Möglichkeit, dass mehrere Threads dieses in nicht vorhersagbarer Reihenfolge ändern, womit es ebenfalls unmöglich wird, vorherzusagen, welches Objekt es zu einem bestimmten Zeitpunkt referenzieren wird.

Und das heißt, dass für den Compiler bei Methodenaufrufen auf der Variablen sup nur die Methoden eine Rolle spielen, welche für deren Deklarationstyp Super deklariert sind (in diesem selbst oder von einer seiner Supertypen geerbt). Der Compiler wählt unter diesen Methoden diejenigen aus, welche zum Methodenaufruf passen. Das sind genau die Methoden, für die gilt, dass die Deklarationstypen aller Aufrufparameter Subtypen der entsprechenden formalen Parameter in der Methodendeklaration sind. In unserem Beispiel ist das nur bei der Methode „1“ der Fall. In den Bytecode wird also ein Methodenaufruf von m(Tier, Tier) geschrieben.

Zur Laufzeit könnte nun doch noch eine im Typ Sub deklarierte Methode eine Rolle spielen: Da die Variable sup zur Laufzeit ein Sub-Exemplar referenziert, beginnt die VM mit ihrer Suche nach der Methode, deren Aufruf im Bytecode steht, in der Klasse Sub. Wenn also in Sub eine Methode existierte, welche die Methode m(Tier, Tier) aus Super überschriebe, dann (und nur dann) würde diese überschreibende Methode zur Ausführung gelangen (dynamische Methodenwahl). Das ist aber in unserem Beispiel nicht der Fall, da die Methode m(Vogel, Tier) aus Sub die Methode m(Tier, Tier) aus Super ja nicht überschreibt.

Noch einmal ganz deutlich: Das Thema „Überladung“ ist mit der Tätigkeit des Compilers abgeschlossen. Eine Suche im Subtyp nach einer Methode, die „besser zum Aufruf passen würde“ als die, deren Aufruf der Compiler in den Bytecode geschrieben hat, findet zur Laufzeit also nicht statt.

Bisher war bzgl. des „Bindens“ nur die Rede von Methoden. Man sagt aber auch, in Java würden „Attribute statisch gebunden“. Was heißt das?

Richtig, der Begriff wird auch bei Attributen verwendet. Hier meint er – analog zur Begriffsverwendung bei Methoden – den Vorgang, bei dem einer Attributselektion im Quellcode das Attribut zugeordnet wird, das tatsächlich angesprochen wird. Die Aussage, dass in Java Attribute statisch gebunden werden, bedeutet, dass bei diesem Vorgang nur der Deklarationstyp (= statischer Typ) des Ausdrucks, über den die Attributselektion erfolgt, eine Rolle spielt. Hier ein Beispiel, das die statische Bindung von Attributen demonstriert und dieser die dynamische Bindung bei (nichtstatischen) Methoden gegenüberstellt:

public class Test {
    public static void main(String[] args) {
        Super sup = new Sub();
        System.out.println(sup.a);
        System.out.println(sup.m());
    }
}

class Super { 
    String a = "Super-Attribut";

    String m() {
        return "Super-Methode";
    }
}

class Sub extends Super {
    String a = "Sub-Attribut";
    
    String m() {
        return "Sub-Methode";
    }
}

Die Ausgabe dieses Programms lautet:

    Super-Attribut
    Sub-Methode

Denn der Deklarationstyp der Variablen sup ist Super, also wird die Variable a des Typs Super an die Attributselektion „gebunden“. Hingegen wird bei der Bindung der Methode durch die VM der Laufzeittyp (= dynamischer Typ) des von sup referenzierten Objekts berücksichtigt. Dies ist der Typ Sub. Und da die Methode m() aus Super in Sub überschrieben wurde, wird die Methode m() von Sub ausgeführt („dynamisches Binden“).