Come è noto, in Java è possibile utilizzare due tipologie differenti di tipi di dati:
- I tipi primitivi
- I riferimenti ad oggetti (object reference)
Spesso, però, soprattutto quando non si ha molta esperienza, non si ha ben chiara la differenza che esiste tra le due tipologie e ciò, talvolta, può essere la causa di errori o comportamenti totalmente inaspettati. Vediamo, allora, di chiarire per bene le caratteristiche e la semantica associate ad ognuno dei due tipi di dati.
Per rendere il confronto più semplice, paragoneremo la rappresentazione di un tipo primitivo con quella di un object reference che punti ad un'istanza di Wrapper Class. Per chi non sapesse cosa siano, le Wrapper Class (Classi "contenitori") sono delle classi che inglobano al loro interno le caratteristiche dei tipi primitivi, fornendo dei metodi di utilità che consentono di manipolarne i valori. Spesso, inoltre, si ricorre alle Wrapper Class quando è necessario passare degli oggetti a dei metodi che non accettano tipi primitivi in input (ad esempio, se si vogliono aggiungere valori interi ad una variabile vect di tipo Vector
si utilizzerà un'istruzione del tipo: vect.add(new Integer(4))
).
La tabella seguente mostra, per ogni tipo primitivo definito in Java, la corrispondente Wrapper Class:
Tipo Primitivo | Wrapper class |
---|---|
boolean | Boolean |
char | Character |
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
La Rappresentazione sullo Stack
Supponiamo di definire le seguenti due variabili:
int i = 3;
Integer j = new Integer(5);
La prima è un tipo primitivo intero mentre la seconda è un riferimento ad un oggetto di classe Integer (sempre contenente un valore intero). Nello stack degli operandi la rappresentazione delle due variabili è molto differente anche se, in questo caso, sia il riferimento all'oggetto che il tipo primitivo int occuperanno 4 byte. Quello che cambia è il modo in cui viene conservato il valore della variabile.
Mentre la variabile i
viene memorizzata (con il valore intero assegnatogli) direttamente sullo stack, la variabile j
mantiene sullo stack soltanto un riferimento ad una locazione di memoria dell'heap, dove sarà contenuto il valore intero vero e proprio che è stato attribuito con l'istruzione precedente.
Questo è il motivo per cui le variabili assegnate ad oggetti vengono definite riferimenti (reference). Quando si dice che j contiene un riferimento ad una locazione di memoria dell'heap, si vuole dire che j
contiene, ad esempio, un valore del tipo: 0B345
che corrisponde ad una locazione ben precisa dell'heap.
Le differenze nell'utilizzo
Questa difformità di rappresentazione, in generale, incide non poco sull'utilizzo delle variabili da parte del programmatore. Infatti, gestire una variabile di tipo primitivo è abbastanza diverso dal gestire un object reference. In generale, vi sono diversità nella inizializzazione, nel modo di creare le istanze, nei valori di default e nel fatto che un tipo primitivo non ha metodi associati alle istanze.
Ad esempio, per istanziare un nuovo oggetto referenziato da un object reference sarà necessario ricorrere all'utilizzo della parola riservata new, mentre un tipo primitivo viene creato sullo stack direttamente in fase di dichiarazione di una variabile.
Il valore di default di una variabile di tipo riferimento (non ancora istanziata) è null
; quello di una variabile associata ad un tipo primitivo varia in dipendenza del tipo stesso. Nel caso di un int
, come nel precedente esempio, è 0
.
I rischi di un errato utilizzo
Qualcuno, arrivati a questo punto, si potrebbe domandare: «Ok, interessanti queste cose….ma sono davvero importanti?». La risposta è si.
Infatti, senza conoscere bene le modalità di rappresentazione delle variabili in memoria in base al tipo di dato utilizzato, i rischi più comuni sono due:
- Errata impostazione di nuove variabili
- Errata comparazione tra variabili
Il primo caso si evidenzia quando, per assegnare ad una variabile un valore già contenuto in un'altra, si utilizza l'operatore di assegnamento. Vediamo un esempio, sia per i tipi primitivi che per gli object reference, in modo da evidenziare le differenze:
Listato 1. Esempio di assegnamento per tipi primitivi
class Primitive_Assignment
{
public static void main(String args[])
{
int x = 4;
int y;
y = x ;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
y = 7 ;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
}
}
Output del codice precedente
x vale : 4 y vale : 4 x vale : 4 y vale : 7
Il risultato è semplice da interpretare. Abbiamo assegnato un valore ad x
e ad y
, stampato, variato il valore di y
e stampato di nuovo.
Vediamo ora un esempio analogo per gli object rerefence, dove abbiamo utilizzato la classe java.awt.Dimension
:
Listato 2. Assegnamento per riferimenti ad oggetto
import java.awt.Dimension;
class Reference_Assignment
{
public static void main(String args[])
{
Dimension x = new Dimension(4, 4);
Dimension y;
y = x;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
y.setSize(2, 2); // Modifica le coordinate
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
}
}
Output del codice precedente
x vale : java.awt.Dimension[width=4,height=4]
y vale : java.awt.Dimension[width=4,height=4]
x vale : java.awt.Dimension[width=2,height=2]
y vale : java.awt.Dimension[width=2,height=2]
A primo acchitto, forse, ci saremmo aspettati che l'output della riga evidenziata in rosso fosse diverso.
x vale : java.awt.Dimension[width=4,height=4]
Ma anche in questo caso ci rendiamo conto della logica del risultato se teniamo presente quanto detto sulla rappresentazione delle variabili sullo Stack.
L'assegnamento y = x
, per gli object reference, assegna al riferimento y
il valore del riferimento x
, ovvero un indirizzo dell'heap che punta alla locazione di memoria contenente il valore dell'oggetto referenziato da x
!
Pertanto, quando si cambia il valore contenuto nell'heap, con l'istruzione y = setSize(2, 2);
tale modifica andrà ad influenzare entrambi i riferimenti (x
e y
).
Vogliamo complicare un po' il tutto (ma solo apparentemente) e confonderci le idee? Utilizziamo il seguente codice:
Listato 3. Assegnamento di object reference con overload degli operatori
class Reference_Assignment
{
public static void main(String args[])
{
Integer x = new Integer(4);
Integer y;
y = x;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
y = 2 ;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
}
}
Output del codice precedente
x vale: 4
y vale: 4
x vale: 4
y vale: 2
Dopo ciò che abbiamo visto nell'esempio con la classe Dimension e visto che anche qui stiamo utilizzando degli object reference, avremmo potuto aspettarci un risultato del tipo:
Risultato atteso
x vale: 4
y vale: 4
x vale: 2
y vale: 2
Tutto da rivedere, dunque? Niente affatto. La classe Integer, come detto, è una classe Wrapper di un tipo primitivo. Per tale ragione, essa è stata implementata in modo tale che fosse possibile assegnare direttamente ad un oggetto il valore di un intero, proprio come abbiamo fatto con l'istruzione y = 2;
. Questa istruzione, in maniera trasparente al programmatore, crea un nuovo oggetto di tipo Integer
e passa il valore 2
al costruttore. In altre parole, è come se avessimo scritto:
y = new Integer (2);
che, come sappiamo, crea un nuovo oggetto, gli assegna una nuova locazione di memoria e scrive il valore 2
in tale locazione. Questo spiega, dunque, il risultato ottenuto.
Vediamo, adesso, quali problemi possono scaturire dalla comparazione tra object reference. Modifichiamo il codice dell'ultimo esempio, in questo modo:
Listato 4. Comparazione tra riferimenti
class Reference_Comparison
{
public static void main(String args[])
{
Integer x = new Integer(4);
Integer y;
y = x;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
y = 4 ;
System.out.println("x vale : " + x) ;
System.out.println("y vale : " + y) ;
if (x == y)
System.out.println("x e y sono uguali");
else
System.out.println("x e y sono diversi");
}
}
Output del codice precedente
x vale: 4
y vale: 4
x vale: 4
y vale: 4
x e y sono diversi
Forse rimaniamo ancora un po' stupiti dal risultato. Stavolta, però, se abbiamo capito il modo in cui ragionare, dovremmo cominciare a meravigliarci un po' meno. Infatti, abbiamo detto che per una Wrapper Class, un'istruzione come y = 4;
equivale a scrivere: y = new Integer(4);
. Pertanto, il risultato è che avremo due riferimenti ad oggetti che puntano a locazioni differenti, in ognuna delle quali è contenuto il valore 4.
In generale, quando si esegue una comparazione tramite l'operatore '==
' vengono confrontati gli indirizzi di memoria referenziati dalle variabili (x
e y
, nel nostro caso) e non i valori contenuti in tali indirizzi. Ne consegue, allora, che x
è giustamente diversa da y
in quanto punta ad una locazione dell'heap differente.
Per confrontare i valori contenuti nell'heap da entrambe le variabili avremmo dovuto utilizzare il metodo equals()
nel seguente modo:
Confronto del contenuto degli oggetti
if (x.equals(y))
ed avremmo ottenuto l'output:
x e y sono uguali
Conclusioni
Quando si lavora con variabili di tipo reference è molto importante aver chiaro il meccanismo di rappresentazione in memoria di tali variabili e la differenza che esiste rispetto alla rappresentazione dei tipi di dati primitivi. Una padronanza di tali nozioni è utile e serve a prevenire risultati inattesi o i classici "errori inspiegabili".