Definizione del problema
In questo articolo vedremo cosa si intende e che differenza c’è, nell’ambito dei linguaggi di programmazione, per pass by value e pass by reference.
A seconda del linguaggio di programmazione lo stesso pezzo di codice potrebbe avere comportamenti molto differenti.
Prendiamo ad esempio il seguente pseudocodice:
class Cup {
filled_percentage = 0;
}
fn fill_cup(c: Cup){
c.filled_percentage = 100;
}
c = Cup();
fill_cup(c);
print(c);
Cosa dovrebbe stampare l’ultima riga?
- Alcune persone potrebbero rispondere con
100
: la chiamata a funzione difill_cup
infatti, usando la referenza alla Cupc
, ne aggiorna il parametro filled_percentage. - Alcune persone potrebbero rispondere con
0
: alla funzione fill_cup viene infatti passato come argomento una copia dell’oggetto cup. All’interno di questa funzione quindi non stiamo modificandoc
, ma una copia identica di c.
La risposta corretta è dipende. Partendo da un bias basato su quale linguaggio di programmazione siamo più abituati a utilizzare, ci aspetteremo un comportamento diverso.
Il design del linguaggio di programmazione infatti, influenzerà l’output del codice sopra. Vediamo quindi questi diversi metodi impiegati dai linguaggi di programmazione per il passaggio di parametri ad una funzione.
Pass by value (passaggio del valore)
Il metodo più semplice per il passaggio di un parametro ad una funzione è probabilmente il pass by value.
Dato un parametro a
salvato in posizione di memoria p1
, viene eseguita una copia di a
in una posizione in memoria p2
e questa nuova locazione viene passata alla chiamata alla funzione.
class Cup {
filled_percentage = 0;
}
fn fill_cup(c: Cup){
c.filled_percentage = 100;
}
// Con la seguente istruzione viene istanziato un oggetto Cup in posizione p1. La posizione p1 viene associata alla variabile c:
c = Cup();
// c viene copiato in p2, e la copia di c viene passata a fill_cup
fill_cup(c);
print(c);
Vediamo quindi che succede:
- Prima di chiamare fill_cup,
c
viene copiato in una nuova posizione in memoria, che viene passata come input afill_cup
. - fill_cup riempie la copia di c.
- Ritornando al main, fill_cup non ha modificato
c
. Per questo motivo, l’ultima riga stamperà 0.
Un esempio di linguaggio pass-by-value è il C:
typedef struct book
{
int year;
} t_book;
void update(t_book book) {
book.year = 20;
}
void main() {
t_book l = { 10 };
update(l);
// prints 10:
printf("%d", l.year);
}
Pass by reference
Il pass by value non sembra molto utile: in generale infatti, vorremmo modificare gli oggetti che passiamo in input alle nostri funzioni. E’ qui che entra in gioco il pass by reference.
Come nel paragrafo precedente: assumendo c
una istanza di Cup memorizzato in posizione p1
, passeremo all’invocazione della funzione fill_cup
la posizione p1
. In parole povere, tutto ciò che facciamo dentro a fill_cup
, modificherà anche l’oggetto originale:
class Cup {
filled_percentage = 0;
}
fn fill_cup(c: Cup){
c.filled_percentage = 100;
}
c = Cup();
fill_cup(c);
// Stampa 100!
print(c);
In questo caso quindi fill_cup riceve c
che è una variable che punta proprio a p1
.
Nel caso in cui fill_cup facesse:
fn fill_cup(c: Cup){
c = Cup();
c.filled_percentage = 100;
}
Questo farebbe sì che in posizione p1 venga creata una nuova istanza di Cup.
Chiamate a funzione in Java
Java è un linguaggio di programmazione che segue la convenzione del pass by value. Vediamo quindi che succede se riscriviamo lo pseudocodice presentanto all’inizio e riscritto in Java (10):
class Main {
static class Cup{
int filledCup;
Cup(){}
}
static void fillCup(Cup myCup){
myCup.filledCup = 100;
}
public static void main(String args[]){
var c = new Cup();
System.out.println(c.filledCup); // 0
fillCup(c);
System.out.println(c.filledCup); // 100
}
}
Secondo come abbiamo definito i passaggi a valore poco fa’, uno si dovrebbe aspettare che fill_cup abbia ricevuto la referenza ad un nuovo oggetto Cup.
Il caveau è nella definizione della variabile c. Che cos’è c
nel contesto di Java? E’ una puntatore ad una oggetto. Vediamo in maniera schematica una rappresentazione della memoria dopo aver assegnato una instanza di Cup a c:
- Cup: Si trova in posizione 10.
- c: E’ un puntatore ad oggetto, e si trova in posizione 5. Se leggiamo il valore di questo puntatore, è 10.
Quando invochiamo la funzione fillCup viene quindi passato una copia del valore del puntatore (10, ovvero l’indirizzo di Cup definiton nel main).
Per questo motivo, la variabile myCup
è un puntatore a un oggetto Cup, che si trova in posizione 10. Et voilà! Usando un metodo di chiamata pass by value, siamo riusciti a modificare lo stesso il nostro input!
Ora un altro esempio interessante:
class Main {
static class Cup{
int filledCup;
Cup(){}
}
static void fillCup(Cup myCup){
myCup = new Cup();
myCup.filledCup = 100;
}
public static void main(String args[]){
var c = new Cup();
System.out.println(c.filledCup); // 0
fillCup(c);
System.out.println(c.filledCup); // Ancora 0!
}
}
Ci saremmo forse aspettati un output differente? Riflettendoci un po’ è possibile capire perché questo codice si comporta in questo modo.
new Cup()
richiede l’allocazione di un nuovo oggetto Cup. Tenendo a mente lo schema della memoria mostrato sopra, assumiamo che all’oggetto creato all’interno di fillCup
venga assegnata la posizione di memoria 15.
Prima dell’assegnamento, myCup
è una variabile che punta a 10, ovvero alla Cup definita nel main. Dopo l’assegnamento, myCup è una variabile che punta a 15. Il parametro filled_cup si riferisce a quest’ultimo oggetto. Una volta terminata la funzione e tornati al main, c
non è stato modificato (come sarebbe magari accaduto nel caso di una call-by-reference!) e punta ancora alla posizione 10.
Conclusione
Pass by value e pass by reference sono probabilmente i metodi più comuni per il passaggio di valori a funzioni. Un altro metodo non trattato qui è il pass by name, ma meno comune nei linguaggi più recenti.
Leggendo codice in un linguaggio di programmazione che non conosciamo, facciamo spesso assunzioni possibilmente errate sul comportamento del programma perchè assumiamo che si comporti “come ci aspettiamo”.
Conoscere questi due metodi di chiamate a funzione conferisce più consapevolezza e dominanza degli strumenti che utilizziamo ogni giorno.