Operatori di referenziazione e dereferenziazione

25 novembre 2016

Nelle lezioni precedenti abbiamo introdotto il concetto di variabile a abbiamo imparato come definirne il valore mediante l’operatore di assegnamento =.

Abbiamo inoltre parlato della gestione della memoria e abbiamo visto la differenza tra le due modalità di allocazione, statica (stack) e dinamica (heap). Per affrondare quest’ultimo passo è stato necessario introdurre un nuovo concetto: i puntatori.

Tuttavia, quando usiamo i puntatori, come facciamo ad assegnare o leggere il valore puntato? Possiamo usare la stessa sintassi che abbiamo visto nel caso delle variabili automatiche?

La risposta è: no, dobbiamo usare regole sintattiche differenti.

Consideriamo ad esempio il caso in cui si voglia scrivere un certo valore nella locazione di memoria corrispondente all’indirizzo contenuto in un puntatore.

Se usassimo l’operatore di assegnamento per assegnare ad un puntatore direttamente il valore di una variabile, otterremmo sicuramente un errore di compilazione, dovuto alla differenza tra i tipi della variabile e del puntatore. Ricordiamo infatti che tipo e tipo* (puntatore a tipo) sono due tipi differenti.

Inoltre, commetteremmo un errore concettuale nel voler assegnare ad un puntatore un valore, anzichè un indirizzo.

La soluzione consiste nell’uso dell’operatore unario di referenziazione &, che restituisce l’indirizzo dell’operando cui viene applicato.

L’operazione complementare alla referenziazione è la dereferenziazione o indirezione, per cui è definito l’operatore unario *, che restituisce la locazione di memoria corrispondente all’indirizzo contenuto nel suo operando.

Diamo uno sguardo al codice seguente:

int a = 1;	
int *ptr = &a;  // si legge: "assegna a ptr l'indirizzo di a"
int b = *ptr; 	// si legge: "assegna a b il valore puntato da ptr"

std::cout << "a: " << a << std::endl;		  	// 1	
std::cout << "b: " << b << std::endl;			// 1
std::cout << "*ptr: " << *ptr << std::endl; 	// 1

Nell’esempio precedente, tre variabili, a e b di tipo intero, e ptr di tipo puntatore a intero, sono allocate nello stack.

L’operatore & è usato per assegnare a ptr l’indirizzo di a, e l’operatore * è usato per assegnare a b il valore puntato da ptr, che è contenuto in a. Quest’ultima istruzione ha lo stesso effetto che produrrebbe l’istruzione:

int b = a;

La figura successiva è un esempio di quello che potrabbe essere il contenuto dello stack frame relativo a questo frammento di codice, riportato in forma tabulare elencando indirizzi, identificatori e valori corrispondenti. Ovviamente, gli indirizzi riportati sono soltanto indicativi e, essendo determinati a tempo di esecuzione, saranno diversi ogni volta che viene eseguito il programma.

Esempio di referenziazione e dereferenziazione (click per ingrandire)

Esempio di referenziazione e dereferenziazione

Si noti che stampare i valori delle tre variabili produce lo stesso output, cioè 1. Tuttavia, nessuna locazione di memoria è allocata nello heap per il nostro puntatore ptr. Infatti, esso è stato inizializzato con l’indirizzo di a, anzicchè con quello restituito da un’allocazione dinamica. L’allocazione statica di b, infine, determina la creazione di un’altra variabile nello stack alla quale è assegnato il valore puntato da ptr, che viene quindi copiato. Nello stack quindi esistono effettivamente soltanto due ‘1’. Il terzo valore ‘1’ che vediamo stampato a schermo è il frutto di una indirezione.

Attenzione, infine, a non confondere l’operazione di indirezione con la dichiarazione di un puntatore. Il simbolo usato è sempre *, ma nel secondo caso esso deve essere preceduto da un tipo.

Applicabilità di & e *: lvalue e rvalue

L’uso di referenziazione e indirezione è spesso causa di confusione, soprattutto perchè i messaggi che il compilatore emette in caso di un uso scorretto possono risultare criptici.

Ciò induce molti programmatori, anche navigati, a tentare di correggerli senza troppi indugi, basandosi sulla massima “se non è &, sarà * e viceversa“. Purtroppo questo atteggiamento non aiuta a riflettere sulla causa del problema, ed in ultima analisi non emancipa dal rischio di commettere sempre gli stessi errori.

Possiamo spingere oltre la padronanza di questo aspetto del linguaggio introducendo i concetti di lvalue e rvalue, che molto spesso appaiono nei suddetti errori di compilazione.

In maniera non rigorosa, diciamo pure che un lvalue è qualsiasi entità che può trovarsi a sinistra (left value) dell’operatore di assegnamento, mentre un rvalue è per esclusione ciò che si trova alla sua destra (right value).

Adesso raffiniamo ulteriormente questo concetto, dicendo che un lvalue rappresenta un’entità che occupa una locazione di memoria identificabile tramite un indirizzo.

Un rvalue è, per esclusione, tutto il resto, come ad esempio:

  • i valori temporanei, che sono caricati nei registri della CPU come operandi o risultati intermedi delle istruzioni;
  • i valori immediati, cioè tutti i valori numerici incapsulati direttamente nel flusso di istruzioni.

In ragione di ciò, un rvalue è un’entità che non sopravvive oltre l’esecuzione dell’istruzione in cui è contenuta.

Nell’esempio seguente, a è una variabile residente nello stack, quindi ha un indirizzo, mentre ‘1’ è un valore immediato.

int a;	

a = 1; // ok 
1 = a; // errore!

In ragione di ciò l’ultima espressione non è lecita e genera un errore di compilazione simile al seguente:

error: lvalue required as left operand of assignment 

Ne consegue che l’operatore & può essere applicato solo ad un lvalue, perchè per ottenere l’indirizzo di un’entità essa deve risiedere in memoria. Pertanto, variabili e puntatori possono essere referenziati, mentre i valori immediati, i temporanei e i letterali di tipo stringa, che risiedono nel segmento read-only della memoria del programma, non sono invece idonei.

Il risultato di una referenziazione, invece, è sempre un rvalue. Infatti, un’operazione di referenziazione produce un valore temporaneo che contiene un indirizzo di memoria, presente soltanto nei registri della CPU fin tanto che l’istruzione è in esecuzione.

Un caso particolare è quello della referenziazione di un puntatore, che produce una entità di tipo puntatore a puntatore, cioè una variabile che contiene l’indirizzo di un puntatore. Il frammento seguente mostra un esempio della definizione di questa entità.

int a = 1;
int* ptr = &a;		  // dereferenziazione di una variabile		
int** ptr2ptr = &ptr; // dereferenziazione di un puntatore; Attenzione int** e int* sono tipi differenti	

L’operatore di referenziazione quindi non può apparire a sinistra dell’operatore di assegnamento. Inoltre, non può essere applicato due volte di seguito. Ad esempio, data la definizione di puntatore a puntatore, potrebbe sembrare lecito semplificare il codice precedente come appare di seguito:

int a = 1;
int** ptr2ptr = &(&a); // errore!	

Ma ciò produrrebbe un errore del tipo:

	
error: lvalue required as unary ‘&’ operand

Infatti &a è un rvalue, che per definizione non può essere referenziato direttamente.

L’operatore di indirezione ‘*’, invece, per definizione può essere applicato soltanto ad un indirizzo di memoria: siano essi puntatori (lvalue) o indirizzi derivati dall’algebra dei puntatori (rvalue), sono le uniche entità ammissibili come operandi. Osserviamo il seguente frammento di codice:

int a = 1;	
int b = *a;

Esso produrrebbe un errore di compilazione come il seguente:

	
error: invalid type argument of unary ‘*’ (have ‘int’)

oppure, in maniera più esplicita:

error: indirection requires pointer operand

Al contrario della referenziazione, il risultato di una indirirezione è sempre un lvalue: ricordiamo, infatti, che un puntatore contiene un indirizzo della memoria centrale. Nel caso in cui la memoria non sia allocata, otteremo una violazione di accesso, ma nessun errore verrà segnalato a tempo di compilazione.

Inoltre, non vi è un limite teorico alla indirezione.È infatti possibile dichiarare variabili di tipo “puntatore a puntatore … a puntatore”, e concatenare l’operatore di indirezione molteplici volte. Ogni compilatore ha un limite di indirezione dettato da esigenze prettamente implementative.

Infine, è possibile alternare e concatenare più operazioni di referenziazione e dereferenziazione per ottenere il risultato desiderato. Tuttavia, un’operazione di referenziazione seguita da una indirezione o viceversa è un’operazione superflua, come mostrato nell’esempio seguente:

	
#include <iostream>

int main()
{	
												
	int *a = new int;
	int b = **&a; 	// equivalente a 'int b = *a;'

	int c = 1;
	int d = *&c;	// equivalente a 'int d = c;'

	int *e = new int;
	int **f = &e;
	int g = ***&f; // equivalente a int g = **f;

	return 0;					
}

In conclusione, possiamo dire che, più che la sintassi, è la semantica dei due operatori & e * che suggerisce il modo in cui essi devono essere applicati, e laddove ciò non sia sufficiente, una attenta interpretazione degli errori riportati dal compilatore è la chiave per la risoluzione di ogni ambiguità nel loro uso.

Tutte le lezioni

1 ... 10 11 12 ... 61

Se vuoi aggiornamenti su Operatori di referenziazione e dereferenziazione inserisci la tua e-mail nel box qui sotto:
Tags:
 
X
Se vuoi aggiornamenti su Operatori di referenziazione e dereferenziazione

inserisci la tua e-mail nel box qui sotto:

Ho letto e acconsento l'informativa sulla privacy

Acconsento al trattamento di cui al punto 3 dell'informativa sulla privacy