C++: i puntatori in C++ spiegati semplici

I puntatori sono lo spauracchio di chiunque cominci a studiare il C++. Oggi tento un’impresa titanica: renderli semplici e comprensibili. Il trucco, secondo me, è non partire dalla sintassi ma da come un programma compilato usa la memoria mentre gira. Capito dove finiscono i dati, l’asterisco e la e commerciale diventano quasi ovvi.

Come un programma usa la memoria

Quando un programma C++ è in esecuzione, la memoria che il sistema gli assegna è divisa in zone, ognuna con il suo scopo:

  • il codice, che può essere solo letto ed eseguito;
  • i valori letterali, cioè le costanti che non cambiano mai, in sola lettura;
  • le zone per i dati inizializzati e per quelli non inizializzati;
  • le due memorie di lavoro, quelle che ci interessano davvero: lo stack e lo heap.

Stack e heap sono le aree coinvolte quando diamo un valore a una variabile e quando creiamo un puntatore. Vediamo come funzionano.

Lo stack

Prendiamo un programma che usa soltanto lo stack, con tre blocchi di codice uno dentro l’altro:

int main() {
    int a = 1;
    {
        int b = 2;
        {
            int c = 3;
        }
    }
}

Quando viene creata a, il suo valore finisce nello stack. Apriamo un blocco e creiamo b: anche b va nello stack, sopra ad a. Apriamo un altro blocco e creiamo c, che va in cima: a questo punto lo stack contiene c, b e a.

Lo stack funziona con una logica LIFO, last in first out: l’ultimo arrivato è il primo a uscire. Questo lo rende velocissimo ed efficiente, ma non è il posto giusto per archiviare grandi quantità di dati.

Ecco, questo è molto importante: la vita di una variabile nello stack è limitata al blocco di codice in cui viene creata. Quando la prima graffa si chiude, c viene distrutta e nello stack restano b e a. Alla seconda graffa sparisce b, all’ultima anche a, e lo stack resta vuoto. Ma a quel punto è finito anche il programma.

Lo heap E I puntatori

Cosa succede se invece di una variabile a dichiariamo un puntatore? Con l’asterisco diciamo che a non è più una semplice variabile:

int *a = new int(1);

Il valore stavolta va nello heap, mentre nello stack finisce soltanto il suo indirizzo. È questo che fa la dichiarazione di un puntatore: memorizza nello stack l’indirizzo che serve per trovare nello heap il valore.

Il resto del programma procede uguale, con un’attenzione: le variabili dello stack vengono distrutte alla chiusura dei blocchi. Quando sparisce a, cioè l’indirizzo, il valore a cui puntava resta nello heap, e nessuno sa più dove sia. È quello che i programmatori chiamano memory leak: un dato nello heap che non è più referenziato da nessun puntatore nello stack.

Per evitarlo, prima che il puntatore sparisca, dobbiamo cancellare esplicitamente anche il valore a cui punta, con delete:

delete a;

È una tecnica comodissima: nello stack, che è una memoria veloce e snella, teniamo solo l’indirizzo, mentre il dato, che può essere anche molto grande e ingombrante, lo mettiamo nello heap e ci arriviamo tramite il suo puntatore.

Valore E indirizzo di una variabile

Passiamo alla pratica. Apriamo l’editor e scriviamo un programmino che ci aiuti a capire cosa viene memorizzato e dove. Includiamo il solito iostream, dichiariamo una variabile e ne stampiamo il valore come già sappiamo fare:

#include <iostream>

int main() {

    int a = 7;

    std::cout << "a: " << a << std::endl;

}

Compilando e lanciando, il programma stampa 7. Ma a non è soltanto un valore: come ogni variabile ha anche un indirizzo in memoria. Per leggerlo c’è l’operatore e commerciale, che in un certo senso è la controparte dell’asterisco:

std::cout << "&a: " << &a << std::endl;

Stavolta otteniamo qualcosa del genere:

a: 7
&a: 0x7fff88a85820

Il primo è il valore di a, il secondo il suo indirizzo. Siccome a è dichiarata in modo statico, il suo valore sta nello stack, e quello è un indirizzo dello stack ( gli indirizzi che vedete voi saranno diversi, cambiano a ogni esecuzione ).

Un puntatore A una variabile

Dichiariamo adesso una variabile puntatore, la chiamiamo ptr, e ci mettiamo dentro l’indirizzo di a, letto proprio con la e commerciale. Poi stampiamo sia ptr sia il valore a cui punta, che si legge con l’asterisco:

int *ptr = &a;

std::cout << "ptr: " << ptr << std::endl;
std::cout << "*ptr: " << *ptr << std::endl;

L’output:

ptr: 0x7fff88a85820
*ptr: 7

ptr contiene lo stesso indirizzo di a, perché è esattamente quello che gli abbiamo detto di fare, mentre il valore puntato da ptr è 7. Notate che il 7 è sempre lui, sempre nello stack: ci stiamo solo accedendo in due modi diversi, attraverso a e attraverso l’indirizzo contenuto in ptr.

Con l’asterisco il valore puntato si usa come qualunque altro valore. Possiamo per esempio metterlo in una nuova variabile b:

int b = *ptr;

std::cout << "b: " << b << std::endl;

E b contiene 7. Ma attenzione: b è allocata staticamente, quindi anche il suo 7 sta nello stack, ed è una copia. Adesso nello stack ci sono due sette: quello di a e quello di b.

Un valore nello heap

Ultimo caso: come si mette un valore nello heap? Molto semplicemente dichiarando direttamente una variabile puntatore, e creando il valore con new:

int *c = new int(5);

std::cout << "c: " << c << std::endl;
std::cout << "*c: " << *c << std::endl;

Cosa ci aspettiamo? Che c contenga un indirizzo e stia nello stack, mentre *c, il valore a cui punta, stia nello heap e valga 5:

c: 0x5a32171812c0
*c: 5

Guardate l’indirizzo: è molto diverso dai primi due, perché stavolta punta nello heap. A differenza dei sette di a e b, il 5 non sta nello stack.

Il programma completo

Ecco il main.cpp intero:

#include <iostream>

int main() {

    int a = 7;

    std::cout << "a: " << a << std::endl;
    std::cout << "&a: " << &a << std::endl;

    int *ptr = &a;

    std::cout << "ptr: " << ptr << std::endl;
    std::cout << "*ptr: " << *ptr << std::endl;

    int b = *ptr;

    std::cout << "b: " << b << std::endl;

    int *c = new int(5);

    std::cout << "c: " << c << std::endl;
    std::cout << "*c: " << *c << std::endl;

}

Per compilarlo uso il solito Makefile che conoscete dalle puntate precedenti:

# nome dell'eseguibile da generare
TARGET = main

# lista dei file sorgente
SRCS = main.cpp

# flag del compilatore
CXXFLAGS = -g

# nome del compilatore
CXX = g++

# regola di default
all: $(TARGET)

# regola per generare l'eseguibile
$(TARGET): $(SRCS)
	$(CXX) $(CXXFLAGS) -o $(TARGET) $(SRCS)

clean:
	rm -f $(TARGET)

( attenzione: le righe dei comandi nel Makefile cominciano con un tab, non con degli spazi ). Poi si compila e si lancia:

make
./main

Se non volete usare make, va benissimo anche g++ a mano:

g++ -g -o main main.cpp

Il programma lascia il 5 nello heap senza un delete: qui finisce subito e il sistema si riprende tutto, ma in un programma vero andrebbe liberato con delete c prima che c sparisca.

Per chiudere

Questo breve video non esaurisce il mondo vastissimo dell’algebra dei puntatori in C++, ma spero di avervi chiarito le basi: come è fatta la memoria di un programma in esecuzione, la differenza fra stack e heap, l’operatore asterisco e l’operatore e commerciale. La cosa migliore da fare adesso la sapete: aprite la vostra Linux box e cominciate a giocare con i puntatori, perché sono cose che si imparano solo con la pratica.

Link e risorse

Sostieni il canale

PER SOSTENERE IL CANALE BASTANO UN LIKE E UN’ISCRIZIONE!
ma se vuoi offrirmi un caffè, mi aiuterà a rimanere sveglio mentre registro i video 😁☕
clicca qui per scoprire come https://buymeacoffee.com/thelinuxnerd

questi link sono affiliati Amazon, acquistando i libri che ti consiglio sostieni il canale!