Method Invocation in java

In un linguaggio ad oggetti, come Java, è prassi comune utilizzare spesso senza quasi accorgersene meccanismi in realtà abbastanza complessi come ereditarietà, overriding, overloading, polimorfismo, ecc ecc. Questa volta vorrei approfondire il meccanismo di gestione delle chiamate dei metodi, per dimostrare come in realtà non sia una cosa così banale come apparare. Per semplicità inoltre non considererò metodi statici, abstract, i generics e i metodi con un numero di parametri variabili che complicano ancora la questione.

Overloading e Overriding

Per overloading si intende la ridefinizione di un metodo (quindi con lo stesso nome) ma con tipo e/o numero di parametri differenti, da notare che il tipo di ritorno non è considerato. Per overriding invece si intende la scrittura di un metodo in una sottoclasse con lo stesso numero e tipo dei parametri della superclasse. E’ importante notare che se si ridefinisce un metodo in cui varia unicamente il tipo di ritorno, il compilatore segnala un errore. Ecco un breve esempio per chiarire meglio quanto detto:

class A {
  private int m(int a) { ... }
  public void m(int a, double b) { ... }
  public int m(String  d) { ... }
  public int m(double a) { ... }
  public int n(String b) {... }
  public int n(int a) { ... }
}
class B extends A {
  public int n(String x) { ... }
  public int n(double y) { ...]
}

Come si vede il metodo m è overloaded in A in quanto ne sono presenti ben quattro versioni. Anche il metodo n ha più versioni (ancora overloading) ma presenta anche overriding, infatti B ridefinisce n(String). Supponete ora di avere un’altra classe che intende utilizzare i metodi messi a disposizione da A e B. Il compilatore dovrà essere in grado di determinare per ogni chiamata quale metodo debba essere invocato. Per realizzare questo obiettivo sono necessarie operazioni sia in fase di compilazione sia in fase di esecuzione.

Lista dei metodi applicabili

Nella fase di compilazione per ogni chiamata il compilatore deve prima di tutto determinare la lista dei metodi applicabili. Ovvero deve costruire una lista di tutti i metodi che possono essere invocati su un dato oggetto. In questa fase si analizza il tipo statico dell’oggetto considerato e tutte le sue superclassi. Inoltre bisogna anche controllare la visibilità di ciascun metodo e i relativi tipi. Consideriamo le seguenti invocazioni riferite all’esempio precedente:

A a=new A();
B b=new B();
a.m(3);
b.n(4);

Il compilatore esamina a.m(3) e quindi determina la lista dei metodi che:

Si chiamano come il metodo che si invoca (in questo caso m) Sono visibili da dove li si sta invocando Hanno arietà (numero di parametri) che coincide con quelli dell’invocazione (in questo caso 1) Hanno come tipo di ogni parametro un tipo che coincide o è supertipo di quello dell’invocazione Nell’esempio in questione solo m(double) è applicabile poiché m(int) è privato, m(int, double) ha due parametri, m(String) non ha un tipo compatibile. Nel comando b.n(4) invece i metodi possibili sono n(double) ed n(int), infatti double è un supertipo di int.

Scelta del “Metodo più specifico”

Se la lista dei metodi applicabili è vuota si ha un errore di compilazione mentre se contiene solo un metodo si passa alla fase successiva. Resta da considerare il caso in cui ci siano più metodi applicabili (come nell’esempio precedente). In questa situazione bisogna scegliere quello “più specifico”. Per la scelta di tale metodo si procede confrontando due metodi alla volta ed eliminando progressivamente quello strettamente meno specifico dell’altro. Un metodo U è strettamente più specifico di un metodo J se U è più specifico di J ma J non è più specifico di U. Le specifiche del linguaggio Java (Java JLS) sono mutate nel tempo e nella terza edizione (che corrisponde a Java 5) sono state introdotte delle modifiche su questo aspetto. In particolare si sono rese necessarie per gestire i generics e i metodi con un numero di parametri variabili. Tuttavia, ignorando questi aspetti, una modifica introdotta da java 1.4.2 (e presente in questa terza edizione) cambia il comportamento nella ricerca del “metodo più specifico” anche nei casi “tradizionali”. Pertanto per java<1.4.2 si ha che un metodo m dichiarato in A è più specifico di un metodo n dichiarato in B se:

Mentre per java>=1.4.2 si ha che un metodo m dichiarato in A è più specifico di un metodo n dichiarato in B se:

Sempre nell’esempio precedente nel secondo caso avevamo come lista dei metodi applicabili: in B: n(double) e in A: n(int) a fronte dell’invocazione n(int). n(int) risulta strettamente più specifico di n(double) per java>=1.4.2, quindi è il metodo scelto. Tuttavia per java <1.4.2 B:n(double) non è più specifico di A:n(int) infatti double non è sottotipo di int. Tuttavia non vale neanche il contrario perché A non è sottotipo di B. Pertanto il codice non compila.

Chiamata del metodo a run-time

Se il programma è stato compilato correttamente allora conosciamo, per tutte le chiamate, il prototipo del metodo che dobbiamo invocare, tuttavia non sappiamo ancora quale metodo invocare. In esecuzione dunque partiamo dalla classe che rappresenta il tipo dinamico dell’oggetto su cui invochiamo il metodo e controlliamo se esiste un metodo che è uguale al prototipo che abbiamo salvato dalla compilazione. Se esiste viene invocato altrimenti si ripete la stessa operazione sulla relativa superclasse e così via fino a che non si trova il metodo cercato. E’ da notatore che è garantito a fronte delle ricerce effettuate in compilazione che si troverà comunque un metodo. Sempre nell’esempio precedente A.m(3) ha tipo statico e dinamico A, quindi la ricerca di m(double) parte da A dove troviamo il metodo cercato. B.n(4) ha tipo dinamico e tipo statico B, quindi la ricerca parte da B dove cerchiamo n(int), tuttavia non esiste. Passiamo dunque alla superclasse A dove troviamo il metodo e lo eseguiamo.

Un ultimo esempio

class X {
  public void x(int a, double b) { ... }
  public void x(double a, int b) { ... }
}
class A {
  public void t(int a) { ... }
}
class B extends A {
  public void t(double a) { ... }
}
class C {
  public static void main(String args[]) {
    A a=new A();
    B b=new B();
    A c=new B();
    X d=new X();
    a.t(4.4);  // 1
    a.t(3);    // 2
    b.t(4.1);  // 3
    b.t(7);    // 4
    c.t(4);    // 5
    c.t(3.8);  // 6
    d.x(3,3);  // 7
  }
}

Considerando java>=1.4.2 risulta: