Il meccanismo di dispatching

Questa breve nota intende illustrare, attraverso una serie di esempi commentati, il meccanismo di dispatching attraverso il quale viene dapprima selezionata la segnatura del metodo da invocare (durante la fase di compilazione) e quindi individuata l'implementazione da eseguire (durante la fase di esecuzione).

Il caso più elementare è quello di una singola classe in cui ci sia un solo metodo con un dato nome:

In [1]:
public class Simple {
  public void f() {
    System.out.println("Simple::f");
  }
}

In queste circostanze, è evidente che il tipo apparente e concerto non possono che coincidere e che l'invocazoine di f su un oggetto di tipo Simple non può che produrre l'invocazione dell'unica implementazione esistente.

In [2]:
Simple s = new Simple();
s.f();
Simple::f

Overloading

Se vengono definiti più metodi col medesimo nome, ossia c'è un di overloading, la selezione della segnatura del metodo da invocare segue la logica del minor numero di conversioni (il compilatore tenta di individuare la segnatura detta most specific).

In [3]:
public class Overload {
  public void f(int x) {
    System.out.println("Overload::f(int)");
  }
  public void f(double x) {
    System.out.println("Overload::f(double)");
  }
}

In alcuni casi il numero di conversioni è zero

In [4]:
Overload o = new Overload();
o.f(1);
o.f(1.0);  // 1.0 è un double, nessuna conversione
Overload::f(int)
Overload::f(double)

mentre in altri è sufficiente effettuare una conversione (da float a double) e non ci sono altre possibilità

In [5]:
o.f(1.0f); // 1.0f è un float, una conversione
Overload::f(double)

Le cose si complicano se, date le segnature dei metodi e i tipi dei parametri concreti nell'invocazione, esiste più di una segnatura che si adattarebbe alla chiamata a parità di numero di conversioni.

In [6]:
public class Overload {
  public void f(int x, double y) {
    System.out.println("Overload::f(int, double)");
  }
  public void f(double x, int y) {
    System.out.println("Overload::f(double, int)");
  }
}

Usando due int entrambe i metodi sono invocabili con una conversione a double (del primo, o del secondo argomento)

In [7]:
Overload o = new Overload();
o.f(1, 1);
|   o.f(1, 1);
reference to f is ambiguous
  both method f(int,double) in Overload and method f(double,int) in Overload match

In questo caso non è possibile individuare la segnatura most specific, il compialtore non può scegliere quale segnatura selezione!

È utile ribadire che ciò dipende dal tipo di invocazione: è evidente che le invocazioni che non causano conversioni, ad esempio, sono entrambe legittime.

In [8]:
o.f(1, 1.0);
o.f(1.0, 0);
Overload::f(int, double)
Overload::f(double, int)

Ereditarietà

L'introduzione di un sottotipo apre, tra le altre, la possibilità che su un oggetto di un sottotipo venga invocato un metodo definito nel supertipo.

In [9]:
public class Above {
  public void f() {
    System.out.println("Above::f");
  }
}

public class Below extends Above {
  public void g() {
    System.out.println("Below::f");
  }
}

Per prima cosa, osserviamo che, considerando anche i sottotipi, il tipo apparente e concreto non necessariamente coincidono: si aprono tre possibilità a seconda che il tipo apparente e concreto siano, rispettivamente

  • Above e Above,
  • Above e Below,
  • Below e Below

evidentemente il caso Below e Above non è possibile in quanto il secondo non è sottotipo del primo.

Nel primo caso è possibile solo l'invocazione di f

In [10]:
Above aa = new Above();
aa.f();
Above::f

ma non quella di g, dato non è definita in tale tipo

In [11]:
aa.g();
|   aa.g();
cannot find symbol
  symbol:   method g()

Nel secondo caso, è possibile invocare f perché è visibile (a partire dal tipo apparente) e la sua implementazione (nel tipo concreto) viene ereditata dal supertipo

In [12]:
Above ab = new Below();
ab.f();
Above::f

Sebbene nel tipo concreto sia definita g, il compilatore sceglie il metodo sulla base del tipo apparente, quindi la chiamata di g resta impossibile.

In [13]:
ab.g();
|   ab.g();
cannot find symbol
  symbol:   method g()

Nel caso del sottotipo, invece, g è visibile poiché è definito in tale tipo (e f lo è perché ereditata), quindi sono possibili entrambe le invocazioni.

In [14]:
Below bb = new Below();
bb.f();
bb.g();
Above::f
Below::f

Overriding

Un sottotipo può decidere di riscrivere l'implementazione di un metodo ereditato, ossia farne l'overriding.

In [15]:
public class Above {
  public void f(double x) {
    System.out.println("Above::f(double)");
  }
}

public class Below extends Above {
  @Override
  public void f(double x) {
    System.out.println("Below::f(double)");
  }
}

Affinché ciò avvenga, è però necessario che il metodo riscritto abbia la medesima segnatura di quello nel supertipo.

Usando l'annotazione @Override, che serve ad esprimere l'intenzione del programmatore, il compilatore può accorgersi e segnalare, nel caso in cui la segnatura fosse diversa, che la nuova implementazione non è davvero un override (ma solo un overload come sarà chiarito nella prossima sezione)!

In [16]:
public class BelowErr extends Above {
  @Override
  public void f(int x) {
    System.out.println("BelowErr::f(int)");
  }
}
|     @Override
method does not override or implement a method from a supertype

Tornando a considerare i tre possibili casi di combinazione tra tipo apparente e concreto

In [17]:
Above aa = new Above();
Above ab = new Below();
Below bb = new Below();

è ovvio che su aa e bb l'invocazione corrisponderà alle implementazioni definite nella classe del tipo (apparente e concreto):

In [18]:
aa.f(1.0);
bb.f(1.0);
Above::f(double)
Below::f(double)

Il caso interessante è quello in cui il tipo concreto è il sottotipo; in tal caso, una volta che il compilatore ha determinato che la segnatura da chiamare è f(double), l'inocazione riguarderà però il codice presente nell'implementazione del tipo apparente:

In [19]:
ab.f(1.0);
Below::f(double)

Overloading ed ereditarietà

Un caso più complesso (e interessante) è quando l'overloading è determinato dall'ereditarietà, ossia quando è un metodo del sottotipo a causare l'overloading di uno che è definito nel supertipo.

In [20]:
public class Above {
  public void f(double x) {
    System.out.println("Above::f(double)");
  }
}

public class Below extends Above {
  public void f(int x) {
    System.out.println("Below::f(int)");
  }
}

Come nel caso precedente, i casi in cui il tipo apparente coincide con quello concreto e non ci sono conversioni, sono banali:

In [21]:
Above aa = new Above();
Below bb = new Below();

aa.f(1.0);
bb.f(1);
Above::f(double)
Below::f(int)

Nel caso del supertipo, la chiamata con argomento int seleziona l'unico metodo preente (la cui segnatura è compatibile grazie ad una conversione)

In [22]:
aa.f(1);
Above::f(double)

In quello del sottotipo, la chiamata con argomento double seleziona il metodo ereditato (che non richiede conversioni)

In [23]:
bb.f(1.0);
Above::f(double)

La cosa si fa interessante se il tipo apparente non coincide con quello concreto. In tal caso, non sorprendentemente, la chiamata con argomento double seleziona il metodo del tipo apparente con tale segnatura:

In [24]:
ab.f(1.0);
Below::f(double)

Cosa succede però con argomento int? Dal momento che il sottotipo ha un metodo che non ricihede conversioni, ci si potrebbe attendere che sia esso a venir eseguito.

In [25]:
ab.f(1);
Below::f(double)

Questo però non avviene perché il compilatore seleziona la segnatura sulla base del tipo apparente: per Above la segnatura selezionata è f(double) che è compatibile grazie ad una conversione. Una volta selezionata la segnatura, l'invocazione utilizzerà l'implementazione di un metodo di tale segnatura nel sottotipo; tale metodo non è definito nel sottotipo, ma è ereditato dal supertipo.

Overriding e overloading (e ereditarità)

Avendo analizzato separatamente i vari meccanismi che regolano i casi precedenti, non è difficile comprendere un caso in cui si presentino assieme tutte le possibilità.

In [26]:
public class Above {
  public void f(double x) {
    System.out.println("Above::f(double)");
  }
}

public class Below extends Above {
  public void f(int x) {
    System.out.println("Below::f(int)");
  }
  @Override
  public void f(double x) {
    System.out.println("Below::f(double)");
  }
}

Restano come sempre i casi banali (tipo apparente coincidente con il concreto, nessuna conversione):

In [27]:
Above aa = new Above();
Below bb = new Below();

aa.f(1.0);
bb.f(1);
bb.f(1.0);
Above::f(double)
Below::f(int)
Below::f(double)

Nel caso del supertipo, l'invocazione col tipo int sarà soddisfatta tramite una conversione

In [28]:
aa.f(1);
Above::f(double)

Nel caso in cui il tipo apparente è diverso dal concreto, quale che sia il tipo dell'argomento, verrà selezionata l'unica segnatura possibile dato il tipo apparente che è f(double). Certamente, una volta selezionata la segnatura, l'invocazione si userà però l'implementazione del sottotipo che fa overriding di quella nel supertipo.

In [29]:
Above ab = new Below();

ab.f(1);
ab.f(1.0);
Below::f(double)
Below::f(double)

Su ab non c'è quindi verso di ottenere l'esecuzione del metodo definito in Below con segnatura f(int), o di alcun metodo di nome f definito in Above!