Come funzionano effettivamente i puntatori C: spiegazione degli indirizzi di memoria

12

La maggior parte degli sviluppatori tratta la memoria come una scatola nera. Presumono che le variabili siano solo nomi associati a valori. Funziona finché non è necessario scrivere codice che manipoli i dati direttamente a livello della macchina. Quindi premi i puntatori.

Una variabile normale è semplice. È un pezzo di memoria con un nome che usi nel tuo codice. Dichiara un intero i e il compilatore riserva quattro byte. Vedi i. La macchina vede un indirizzo. Il valore si trova in quei quattro byte.

I puntatori rompono quel modello semplice.

Un puntatore non contiene un valore. Contiene un indirizzo. Indica un’altra variabile. Nello specifico, memorizza la posizione di memoria di quella variabile. Questa distinzione è fondamentale. Se confondi i due, il tuo programma andrà in crash. Difficile.

La struttura è a doppio strato. Innanzitutto c’è la variabile puntatore stessa. Contiene un indirizzo numerico. In secondo luogo, c’è la variabile target. L’indirizzo nel puntatore porta ai dati effettivi. Una volta compresa questa separazione, si sblocca un potere significativo nella programmazione dei sistemi.

Considera questo esempio C standard:

La prima riga dichiara due numeri interi standard, i e j. La riga successiva, “int p”, dichiara un puntatore. L’asterisco è la chiave. Dice al compilatore che p * non è un numero intero. È un puntatore a un numero intero. Puoi farlo con float, strutture, personaggi o qualsiasi altra cosa. La sintassi rimane la stessa.

Poi arriva la parte che fa inciampare i principianti: p = &i;.

In C, la e commerciale & è l’operatore dell’indirizzo. “&i” non restituisce il valore di i. Restituisce l’indirizzo di memoria in cui risiede i. L’assegnazione p = &i copia quell’indirizzo nel puntatore p. Ora p indica i.

Se salti questo passaggio, p contiene spazzatura. Contiene un indirizzo casuale e indefinito. Dereferenziarlo in un secondo momento causerà probabilmente un errore di segmentazione. Il programma si blocca perché gli hai detto di accedere alla memoria che non possiede.

La visualizzazione aiuta. Immagina la memoria come una griglia.

Prima dell’inizializzazione, i, j e p esistono ma contengono valori sconosciuti. Gli interi sono caselle con punti interrogativi. Potrebbero contenere qualsiasi cosa. Il puntatore p è un cerchio. Ha frecce che puntano in direzioni casuali. Non sa ancora dove andare.

Dopo aver eseguito p = &i, le frecce cambiano. Puntano tutti direttamente a i. p ora sa esattamente dove vive i.

La relazione è chiara. p contiene la posizione. i contiene i dati. Utilizzi p per raggiungere i.

Questo meccanismo è il modo in cui C gestisce la memoria in modo efficiente. Consente alle funzioni di modificare le variabili passate per riferimento. Abilita l’allocazione dinamica della memoria. Ti consente di creare strutture dati complesse come elenchi e alberi collegati.

Ma introduce anche dei rischi. Un indirizzo sbagliato e stai leggendo o scrivendo nel vuoto.

L’asterisco in *p = 5 fa qualcosa di diverso da quello in int *p. Il secondo asterisco faceva parte della definizione del tipo. Ha dichiarato la variabile come puntatore. Il primo asterisco è l’operatore di dereferenziazione. Significa “vai all’indirizzo memorizzato in p e modifica lì il valore”.

Quindi *p = 5 inserisce il numero 5 nella posizione di memoria indicata da p. Poiché p punta a i, i diventa 5. Alla variabile j viene quindi assegnato il valore i. L’output stampa 5, 5 e 5. Tutti e tre riflettono la stessa modifica sottostante.

Comprendere come funzionano i puntatori C richiede un cambiamento di mentalità. Smetti di pensare alle variabili come contenitori. Inizia a pensarli come luoghi. Il puntatore è la mappa. La variabile è la destinazione. Se la mappa è sbagliata, ti perderai. E nella programmazione di basso livello, perdersi è costoso.

Spiegazione dell’operatore indiretto

Quando assegni l’indirizzo di “i” a “p”, stai essenzialmente dando a quel singolo blocco di memoria due nomi diversi. La variabile “i” si riferisce ancora ad esso direttamente, ma “p” ora funge da handle, punto di riferimento per raggiungere gli stessi dati.

In C, questa dualità definisce il funzionamento delle variabili puntatore. Il puntatore stesso, “p”, è semplicemente un contenitore che contiene un indirizzo. Non contiene il valore effettivo che ti interessa. Contiene invece il luogo in cui risiede quel valore. È qui che entra in gioco l’operatore indiretto “*”.

L’uso di “*p” consente di accedere ai dati all’indirizzo memorizzato in “p”. Consideratelo come un dereferenziamento. Se scrivi *p = 5, non stai cambiando l’indirizzo all’interno di p. Stai andando nella posizione a cui punta “p” e sovrascriverai qualsiasi valore presente con il numero 5.

Questa distinzione è fondamentale per comprendere la gestione della memoria. “p” ti dice dove cercare. *p ti dice cosa guardare. Confondere i due porta a bug difficili da tracciare, perché potresti finire per cambiare un indirizzo invece dei dati, o provare a scrivere in una posizione di memoria casuale perché “p” non è stato inizializzato correttamente.

Una volta che ti senti a tuo agio con questo modello mentale, i puntatori smettono di essere una sintassi astratta e diventano una linea diretta con la memoria. È possibile modificare variabili in ambiti diversi, passare strutture di grandi dimensioni per riferimento senza copiarle e creare strutture di dati dinamiche che crescono e si restringono in base alle esigenze. È roba potente, una volta che smetti di combattere la sintassi.

Il frammento di codice riportato di seguito illustra esattamente questo comportamento, mostrando come il valore di “i” cambia solo perché viene utilizzato “p” per raggiungerlo.

Il valore di “i” si aggiorna istantaneamente. L’indirizzo non è stato spostato. Solo il contenuto ha.

Come i puntatori memorizzano valori e indirizzi

Sembra abbastanza semplice, ma i meccanici possono farti inciampare. Poiché alla variabile “i” viene assegnato il valore 5 e il puntatore “p” contiene l’indirizzo di “i”, il dereferenziamento di “p” (*p ) restituisce 5. Quando esegui “j = i;”, stai copiando quel valore. Quindi “j” diventa 5.

Esegui ora l’istruzione printf. Ottieni “5 5 5”.

Ciò accade a causa della duplice natura dei puntatori. Pensa a loro come se avessero due ruoli distinti. Innanzitutto, la variabile puntatore stessa memorizza un indirizzo di memoria. In secondo luogo, punta a un tipo di dati specifico situato in quell’indirizzo.

Nel nostro esempio:
p è il puntatore. Contiene la posizione della memoria.
*p è il valore. Sono i dati effettivi memorizzati a quell’indirizzo.

Confondere i due porta a bug. Se tratti l’indirizzo come dati, ottieni valori spazzatura. Se tratti i dati come indirizzo, manderai in crash il tuo programma. Sapere con quale hai a che fare cambia tutto.

Perché questa distinzione è importante per la codifica quotidiana? Perché comprendere la differenza tra dove risiedono i dati e cosa sono i dati consente di manipolare direttamente la memoria. Ciò è utile per le strutture dinamiche, ma pericoloso se si salta il passaggio. Una mossa sbagliata e il sistema tenta di leggere da una posizione inesistente.

Inizi a capire perché C rimane potente e pericoloso in egual misura.