Erasure e Metodi Bridge in Java
- La cancellazione (Erasure) è il processo attraverso il quale le informazioni sui tipi generici vengono rimosse durante la compilazione in Java.
- I metodi bridge sono generati dal compilatore per gestire situazioni in cui la cancellazione del tipo di un metodo sovrascritto non produce la stessa firma del metodo nella superclasse.
- I metodi bridge non sono visibili agli sviluppatori e sono utilizzati internamente dal compilatore per garantire la compatibilità tra le classi generiche e le loro sottoclassi.
Erasure o Cancellazione
Solitamente, non è necessario conoscere i dettagli su come il compilatore Java trasforma il codice sorgente in codice oggetto.
Tuttavia, nel caso dei generics, una comprensione generale del processo è importante perché spiega perché le caratteristiche generiche funzionano nel modo in cui lo fanno e perché il loro comportamento è talvolta un po' sorprendente. Per questa ragione, è opportuna una breve discussione su come i generics sono implementati in Java.
Un vincolo importante che ha governato il modo in cui i generics sono stati aggiunti a Java era la necessità di compatibilità con le versioni precedenti di Java.
In poche parole, il codice generico doveva essere compatibile con il codice preesistente, non generico. Pertanto, qualsiasi cambiamento alla sintassi del linguaggio Java, o alla JVM, doveva evitare di compromettere il codice più vecchio. Il modo in cui Java implementa i generics soddisfacendo questo vincolo è attraverso l'uso della cancellazione o Erasure.
In generale, ecco come funziona la cancellazione:
Quando il codice Java viene compilato, tutte le informazioni sui tipi generici vengono rimosse (cancellate). Questo significa sostituire i parametri di tipo con il loro tipo limite, che è Object
se non viene specificato un limite esplicito, e poi applicare i cast appropriati (come determinato dagli argomenti di tipo) per mantenere la compatibilità di tipo con i tipi specificati dagli argomenti di tipo. Il compilatore impone anche questa compatibilità di tipo.
Questo approccio ai generics significa che nessun parametro di tipo esiste a tempo di esecuzione. Sono semplicemente un meccanismo che lavora a livello del codice sorgente.
All'atto pratico, per comprendere appieno, consideriamo il seguente esempio. Supponiamo di avere la seguente classe generica:
// Una classe generica.
class Gen<T> {
// Dichiarazione di un oggetto di tipo T.
T oggetto;
// Passa al costruttore un riferimento a un oggetto di tipo T.
Gen(T o) {
oggetto = o;
}
// Restituisce oggetto.
T getOgg() {
return oggetto;
}
}
Supponiamo di creare un'istanza di Gen come segue:
// Crea un'istanza di Gen per Integer.
Gen<Integer> mioOggetto = new Gen<Integer>(98);
// Ottiene il valore di oggetto.
Integer valore = mioOggetto.getOgg();
System.out.println("Valore: " + valore);
In questo caso, per prima cosa, il compilatore Java rimuoverà le informazioni sui tipi generici e convertirà il codice in qualcosa di simile a questo:
// Una classe generica.
class Gen {
// Dichiarazione di un oggetto di tipo Object.
Object oggetto;
// Passa al costruttore un riferimento a un oggetto di tipo Object.
Gen(Object o) {
oggetto = o;
}
// Restituisce oggetto.
Object getOgg() {
return oggetto;
}
}
Come si può osservare, il compilatore ha rimosso il parametro di tipo T
e ha sostituito tutti i riferimenti a T
con Object
.
Adesso, il codice che utilizza Gen<Integer>
diventa:
// Crea un'istanza di Gen per Integer.
Gen mioOggetto = new Gen(new Integer(98));
// Ottiene il valore di oggetto.
Integer valore = (Integer) mioOggetto.getOgg();
System.out.println("Valore: " + valore);
Qui il compilatore ha semplicemente aggiunto un cast a Integer
per mantenere la compatibilità di tipo. Quindi, quando il codice viene eseguito, il cast viene applicato e il programma funziona come previsto.
In parole povere, il compilatore Java ha cancellato le informazioni sui tipi generici e ha sostituito i riferimenti ai parametri di tipo con il loro tipo limite, che è Object
in questo caso.
La funzionalità dei Generics, pertanto, riguarda strettamente il codice sorgente e non il bytecode. Questo significa che, a livello di bytecode, i generics non esistono. Il bytecode risultante non contiene alcuna informazione sui tipi generici, e le classi generiche vengono trattate come se fossero classi normali con riferimenti a Object
.
Metodi Bridge
Occasionalmente, il compilatore dovrà aggiungere un metodo bridge (o metodo ponte) a una classe per gestire situazioni in cui la cancellazione del tipo di un metodo che sovrascrive in una sottoclasse non produce la stessa cancellazione del metodo nella superclasse.
In questo caso, viene generato un metodo che usa la cancellazione del tipo della superclasse, e questo metodo chiama il metodo che ha la cancellazione del tipo specificata dalla sottoclasse. Naturalmente, i metodi bridge si verificano solo a livello di bytecode, non vengono visti dal programmatore, e non sono disponibili per gli sviluppatori.
Anche se i metodi bridge non sono qualcosa di cui normalmente bisogna preoccuparsi, è comunque istruttivo vedere una situazione in cui ne viene generato uno.
Consideriamo il seguente programma:
// Una situazione che crea un metodo bridge.
// Una classe generica.
// Una classe generica.
class Gen {
// Dichiarazione di un oggetto di tipo Object.
Object oggetto;
// Passa al costruttore un riferimento a un oggetto di tipo Object.
Gen(Object o) {
oggetto = o;
}
// Restituisce oggetto.
Object getOgg() {
return oggetto;
}
}
// Una sottoclasse di Gen.
class Gen2 extends Gen {
Gen2(String o) {
super(o);
}
// Un override specifico per String di getOgg().
String getOgg() {
System.out.print("Hai chiamato String getOgg(): ");
return (String) oggetto;
}
}
// Una classe demo per mostrare l'uso di Gen2.
// Dimostra una situazione che richiede un metodo bridge.
class BridgeDemo {
public static void main(String[] args) {
// Crea un oggetto Gen2 per String.
Gen2 strOb2 = new Gen2("Test Generics");
System.out.println(strOb2.getOgg());
}
}
Nel programma, la sottoclasse Gen2
estende Gen
, ma lo fa utilizzando una versione specializzata per String
di Gen
, come mostra la sua dichiarazione:
class Gen2 extends Gen<String> {
/* ... */
}
Inoltre, all'interno di Gen2
, getOgg()
viene sovrascritto con String
specificato come tipo di ritorno:
// Un override specifico per String di getOgg().
String getOgg() {
System.out.print("Hai chiamato String getOgg(): ");
return oggetto;;
}
Tutto questo è perfettamente accettabile. L'unico problema è che a causa della cancellazione del tipo, la forma attesa di getOb() sarà
Object getOb() { // ...
return oggetto;
}
In altre parole, il metodo getOb() nella superclasse Gen e il metodo sovrascritto getOb() nella sottoclasse Gen2, dopo che viene applicata la cancellazione del tipo, hanno la stessa firma, ma i loro tipi di ritorno sono diversi.
Per gestire questo problema, il compilatore genera un metodo bridge con la firma precedente che chiama la versione String
. Quindi, se si esamina il file di classe per Gen2
utilizzando javap
, vedrete i seguenti metodi:
class Gen2 extends Gen<java.lang.String> {
Gen2(java.lang.String);
java.lang.String getOb();
java.lang.Object getOb(); // metodo bridge
}
Come si può vedere, il metodo bridge è stato incluso. Il commento è stato aggiunto dall'autore e non da javap
, e l'output preciso che si osserva può variare in base alla versione di Java che si sta utilizzando.
C'è un ultimo punto da sottolineare riguardo a questo esempio. Notate che l'unica differenza tra i due metodi getOb()
è il loro tipo di ritorno. Normalmente, questo causerebbe un errore, ma si tratta di un caso speciale in cui il compilatore Java consente questa situazione. In questo caso, il compilatore sa che i due metodi sono equivalenti e quindi non genera un errore.