Puntatori

Indirizzi di memoria delle variabili

Ogni variabile memorizzata nella memoria di un computer occupa una specifica posizione chiamata indirizzo di memoria. Questo indirizzo è rappresentato come un numero intero che identifica univocamente la locazione nella RAM in cui è memorizzata la variabile.

Cosa significa indirizzo di memoria

  • Quando dichiariamo una variabile in C++, ad esempio:
    int numero = 42;
    il compilatore riserva una zona di memoria per la variabile numero. Questa zona è identificata da un indirizzo univoco, che possiamo ottenere utilizzando l’operatore di indirizzo &:
    cout << "Indirizzo di numero: " << &numero << endl;
    Il risultato sarà un valore esadecimale che rappresenta l’indirizzo.

Indirizzo e dimensione delle variabili

La dimensione di memoria occupata da una variabile dipende dal suo tipo di dato. Ad esempio:

  • Un int occupa generalmente 4 byte.
  • Un char occupa 1 byte.
  • Un double occupa 8 byte.

L’indirizzo rappresenta il primo byte della memoria occupata dalla variabile.


Cosa sono i puntatori

Un puntatore è una variabile speciale che contiene l’indirizzo di memoria di un’altra variabile. In altre parole, mentre le normali variabili memorizzano valori, i puntatori memorizzano indirizzi (che sono anche loro a tutti gli effetti dei valori).

Tipo e indirizzo dei puntatori

Stack Variabile Puntatore ++>

Anche un puntatore è una variabile, quindi:

  • Un puntatore ha un indirizzo proprio, che può essere ottenuto con &.
  • Un puntatore ha un tipo di dato, che dipende dal tipo della variabile a cui punta:
    int numero = 42;
    int *ptr = &numero; // 'ptr' è un puntatore a int

Nel codice sopra:

  • ptr memorizza l’indirizzo di numero.
  • Il tipo int * specifica che ptr punta a una variabile di tipo int.

Dimensione dei puntatori

La dimensione di un puntatore dipende dall’architettura del sistema:

  • Su sistemi a 32 bit, un puntatore occupa 4 byte.
  • Su sistemi a 64 bit, un puntatore occupa 8 byte. Indipendentemente dal tipo di variabile a cui punta, la dimensione del puntatore è sempre la stessa, perché rappresenta un indirizzo nella memoria e non il valore contnuto a quell’indirizzo.

Operazioni principali sui puntatori

Dichiarazione dei puntatori

Essendo una variabile, la dichiarazione di un puntatore segue le regole generali delle altre variabili, con l’aggiunta del simbolo * accanto al tipo:

int *ptr; // Dichiarazione di un puntatore a int

Il tipo della variabile include il simbolo * perché il puntatore “punta” a una variabile di un certo tipo. Ad esempio:

  • int * indica un puntatore a un intero (int).
  • double * indica un puntatore a un numero in virgola mobile (double). È importante ricordare che un puntatore, essendo una variabile, ha un suo indirizzo di memoria e una sua dimensione, che dipende dall’architettura del sistema (es. 4 byte su sistemi a 32 bit, 8 byte su sistemi a 64 bit).

Assegnamento

Quando assegniamo un valore a un puntatore, gli stiamo dicendo quale indirizzo deve contenere, ovvero dove “puntare”. Per farlo, utilizziamo l’operatore di indirizzo &, che restituisce l’indirizzo di una variabile.

int numero = 42;     // Dichiarazione di una variabile
int *ptr = &numero;  // Assegniamo a 'ptr' l'indirizzo di 'numero'

Qui, il puntatore ptr ora contiene l’indirizzo di memoria di numero. Questo significa che ptr sa dove trovare numero nella RAM. Possiamo verificare l’indirizzo:

cout << "Indirizzo di numero: " << &numero << endl;
cout << "Valore di ptr: " << ptr << endl; // Stampa lo stesso indirizzo
Metafora dell’assegnamento

Immagina di avere una casa (la variabile numero) e una mappa (il puntatore ptr):

  • La casa ha un indirizzo specifico.
  • Scrivendo l’indirizzo sulla mappa (ptr = &numero), possiamo usarla per trovare la casa.

Dereferenziazione

La dereferenziazione è l’operazione che consente di accedere al valore memorizzato all’indirizzo contenuto nel puntatore. Per farlo, si utilizza l’operatore * davanti al puntatore.

int numero = 42;
int *ptr = &numero;

cout << "Valore puntato da ptr: " << *ptr << endl; // Stampa 42

In questo esempio:

  • ptr contiene l’indirizzo di numero.
  • Usando *ptr, seguiamo l’indirizzo per accedere al valore di numero.

La dereferenziazione consente non solo di leggere, ma anche di modificare il valore della variabile puntata:

*ptr = 10; // Modifica il valore di 'numero' tramite il puntatore
cout << "Nuovo valore di numero: " << numero << endl; // Stampa 10
Metafora della dereferenziazione

Ritorniamo alla metafora della casa e della mappa:

  • Se hai una mappa (ptr) che punta all’indirizzo della casa (numero), puoi usarla per arrivare alla casa.
  • Una volta arrivato, puoi vedere cosa c’è dentro (leggere il valore con *ptr) o sostituire l’arredamento (modificare il valore con *ptr = ...).

Una volta che dereferenziaziamo un puntatore, esso può funzionare esattamente come la variabile originale alla quale indica. Questo significa che possiamo leggere e modificare il valore della variabile originale tramite il puntatore dereferenziato esattamente come se stessimo lavorando nella variabile originale.


Esempio completo

Esempio di Assegnamento e Dereferenziazione

#include <iostream>
using namespace std;

int main() {
    int numero = 5;         // Variabile normale
    int *ptr = &numero;     // Puntatore a 'numero'

    cout << "Indirizzo di numero: " << &numero << endl;
    cout << "Valore di numero: " << numero << endl;

    // Usare il puntatore per accedere al valore di 'numero'
    cout << "Valore puntato da ptr: " << *ptr << endl;

    // Modificare 'numero' tramite il puntatore
    *ptr = 20; // Cambia il valore di 'numero'
    cout << "Nuovo valore di numero: " << numero << endl;

    return 0;
}

Risultato atteso:

Indirizzo di numero: 0x7ffee4a2c4a8 (esempio, varia tra esecuzioni)
Valore di numero: 5
Valore puntato da ptr: 5
Nuovo valore di numero: 20

Errori comuni nell’uso dei puntatori

  1. Puntatori non inizializzati: Se un puntatore non è inizializzato (contiene “schifo”), punta a un indirizzo casuale:
    int *ptr; // Non inizializzato
    *ptr = 10; // Errore: accesso a memoria non valida
  2. Dereferenziare puntatori nulli: Un puntatore nullo (nullptr) non può essere dereferenziato:
    int *ptr = nullptr;
    cout << *ptr; // Errore: crash dell'applicazione

Best practice:

  • Inizializzare i puntatori a nullptr se non si sa subito cosa devono puntare.
  • Controllare sempre se un puntatore è nullo prima di dereferenziarlo:
    if (ptr != nullptr) {
        cout << *ptr << endl;
    }

Creare un albero genealogico in c++

Supponiamo di voler rappresentare un albero genealogico in un programma. Di ogni membro vogliamo memorizzare:

  • Nome
  • Cognome
  • Data di nascita
  • Genitori (padre e madre) Un’idea naturale sarebbe usare una struct per aggregare questi dati.
#include <iostream>
using namespace std;

struct Persona {
    string nome;
    string cognome;
    string data_nascita;
};

int main() {
    Persona persona = {"Mario", "Rossi", "01/01/1980"};

    cout << "Nome: " << persona.nome << endl;
    cout << "Cognome: " << persona.cognome << endl;
    cout << "Data di nascita: " << persona.data_nascita << endl;

    return 0;
}

Qui, la struct Persona funziona bene per aggregare nome, cognome e data di nascita. Tuttavia, se vogliamo aggiungere i genitori (anche loro di tipo Persona), non possiamo scrivere:

struct Persona {
    string nome;
    string cognome;
    string data_nascita;
    Persona padre;  // Errore! Definizione ricorsiva della struct Persona
    Persona madre;  // Errore! Definizione ricorsiva
};

Non possiamo scrivere così perché lo scopo della dichiarazione di una struct Persona è quello di dire al compilatore quanto è “grande” un valore di questo tipo. Ma se ogni Persona contiene al suo interno altre 2 Persona la sua dimensione sarà infinita.

Per risolvere il problema, usiamo dei puntatori per i genitori; invece di includere direttamente le struct padre e madre, memorizziamo i loro indirizzi (sappiamo che l’indirizzo di una variabile ha una dimensione definita e fissa).

Ecco come si modifica la definizione della struct:

struct Persona {
    string nome;
    string cognome;
    string data_nascita;
    Persona *padre;  // Puntatore alla struct del padre
    Persona *madre;  // Puntatore alla struct della madre
};

Ora, possiamo rappresentare i genitori puntando ad altre istanze di Persona.

Ecco l’esempio completo:

#include <iostream>
using namespace std;

struct Persona {
    string nome;
    string cognome;
    string data_nascita;
    Persona *padre;  // Puntatore al padre
    Persona *madre;  // Puntatore alla madre
};

int main() {
    // Creazione dei genitori
    Persona padre = {"Luigi", "Rossi", "15/03/1955", nullptr, nullptr};
    Persona madre = {"Anna", "Bianchi", "20/07/1960", nullptr, nullptr};

    // Creazione del figlio
    Persona figlio = {"Mario", "Rossi", "01/01/1980", &padre, &madre};

    // Stampa dei dati del figlio
    cout << "Figlio: " << figlio.nome << " " << figlio.cognome << endl;
    cout << "Nato il: " << figlio.data_nascita << endl;

    // Accesso ai genitori tramite i puntatori
    if (figlio.padre) {
        cout << "Padre: " << figlio.padre->nome << " " << figlio.padre->cognome << endl;
    }
    if (figlio.madre) {
        cout << "Madre: " << figlio.madre->nome << " " << figlio.madre->cognome << endl;
    }

    return 0;
}

Cosa succede nel programma:

  • Creiamo il padre (padre) e la madre (madre) come variabili normali.
  • Creiamo il figlio (figlio) e usiamo i puntatori &padre e &madre per collegarlo ai genitori.
  • Per accedere ai dati dei genitori tramite i puntatori, usiamo l’operatore -> (es. figlio.padre->nome).

Per accedere ad un attributo di una struct alla quale accediamo da puntatore (come in questo caso per accedere al padre o alla madre di una Persona) si utilizza l’operatore -> al posto dell’operatore ..

Quindi al posto di scrivere il seguente codice:

*(figlio.padre).nome // Mi posiziono dal padre (utilizzando il puntatore) e accedo all'attributo nome

si utilizza il seguente codice:

figlio.padre->nome // Mi posiziono dal padre e accedo all'attributo nome tutto in una operazione