Salta ai contenuti

Lezione 8: I Puntatori e la Gestione Esplicita della Memoria

Copertina Lezione 8

Nelle lezioni precedenti abbiamo visto come le funzioni siano isolate e ricevano solo “fotocopie” dei dati passati dal chiamante (passaggio per valore). Sebbene questo garantisca sicurezza e pulizia del codice, introduce dei limiti evidenti quando vogliamo che una funzione modifichi direttamente una variabile del chiamante o quando dobbiamo evitare di copiare grandi quantità di dati in memoria. In questa lezione supereremo questo limite entrando nei dettagli fisici della memoria RAM. Introdurremo i puntatori, capiremo la reale natura degli array (il decadimento ad array decay), vedremo come eseguire calcoli complessi con l’aritmetica dei puntatori e scopriremo l’ottimizzazione tramite funzioni inline.


Quando scriviamo un programma in C, il compilatore ci offre l’astrazione comoda delle variabili nominate (es. int x). Tuttavia, a livello hardware, la CPU non ha idea di cosa sia “x”. La memoria RAM è vista dal processore come una sequenza lineare di celle microscopiche (byte), ciascuna identificata da un indirizzo fisico numerico univoco (solitamente espresso in notazione esadecimale, es. 0x7fffd9b3).

Durante l’esecuzione, lo Stack Pointer alloca i byte necessari nello stack per memorizzare le nostre variabili locali. Se conosciamo l’indirizzo esatto di una di queste celle, possiamo scavalcare l’isolamento dei record di attivazione e scrivervi o leggervi direttamente, a prescindere dal fatto che la variabile appartenga a un’altra funzione.

Il tipo di dato che ci permette di memorizzare e manipolare questi indirizzi di memoria è il puntatore.

Per comprendere meglio questo concetto, osserviamo cosa accade durante la traduzione del codice da C ad Assembly (x86-64).

Quello che scriviamo in C:

int x = 42;
x = 100;

Quello che vede la CPU (Assembly x86-64):

; La variabile "x" a livello macchina non esiste più. Esiste solo una
; coordinata (offset) calcolata a partire dalla base dello stack frame (rbp).
mov DWORD PTR [rbp-4], 42 ; Scrivi il valore 42 nella cella a indirizzo (RBP - 4)
mov DWORD PTR [rbp-4], 100 ; Sovrascrivi con 100 nella stessa identica cella

Come si può notare, l’identificatore x è stato rimpiazzato dall’indirizzo relativo [rbp-4].


Un puntatore è a tutti gli effetti una variabile che contiene un indirizzo di memoria. In un’architettura moderna a 64 bit, gli indirizzi sono lunghi 64 bit, quindi qualsiasi variabile puntatore occupa esattamente 8 byte in memoria.

La sintassi di dichiarazione prevede l’uso dell’asterisco *:

int *p; // p è un puntatore ad un intero
double *q; // q è un puntatore a un double

Si consiglia di leggere questa dichiarazione da destra a sinistra: “p è un puntatore (*) a un intero (int)”.

Se tutti gli indirizzi occupano 8 byte, perché dobbiamo dichiarare int *p invece di un ipotetico tipo generico pointer p?

La risposta risiede nell’interpretazione dei dati: il tipo indica al compilatore come deve essere letta e modificata la memoria una volta giunti a quell’indirizzo.

  • Con char *p, il compilatore leggerà o scriverà 1 byte a partire dall’indirizzo memorizzato.
  • Con int *p, leggerà o scriverà 4 byte consecutivi.
  • Con double *p, interpreterà 8 byte consecutivi secondo lo standard IEEE 754.

Per visualizzare questo comportamento in memoria, consideriamo il seguente codice:

// Variabili Standard
int x = 5;
double y = 8.2;
char z = 'a';
// Variabili Puntatore
int *px = &x;
double *py = &y;
char *pz = &z;

A livello di Stack Frame, le variabili occupano spazi differenti a seconda del tipo (1, 4 o 8 byte), mentre tutti i puntatori (px, py, pz) occupano esattamente 8 byte, indipendentemente dal tipo di dato a cui puntano:

Diagramma: L'Anatomia dei Puntatori nello Stack

Prestare attenzione a dichiarazioni multiple sulla stessa riga:

int* p, q; // Crea p come puntatore a int, ma q come normale intero!

L’asterisco si lega solo all’identificatore immediatamente successivo. La best practice raccomanda di dichiarare un solo puntatore per riga:

int *p = NULL;
int q = 0;

Dichiarare un puntatore senza inizializzarlo lo rende un dangling pointer (puntatore selvaggio), contenente un indirizzo spazzatura casuale. Tentarne l’accesso provocherà un crash improvviso (Segmentation Fault).

È regola aurea inizializzare sempre i puntatori non associati a NULL (o 0):

Inizializzare un puntatore a NULL (che sotto il cofano corrisponde all’indirizzo 0) è una fondamentale pratica di programmazione difensiva. L’indirizzo 0 è riservato dal sistema operativo e non è accessibile per la lettura o la scrittura diretta da parte del programma utente.

Impostando un puntatore a NULL dichiariamo esplicitamente: “questo puntatore al momento è vuoto, non punta a nulla di valido”. Questo ci permette di implementare controlli logici nel codice per verificare se il puntatore contiene o meno un indirizzo utilizzabile prima di effettuare accessi (evitando di andare incontro a crash del programma).

int *ptr = NULL; // ptr non punta a nessun indirizzo valido

1.3 Operatori & (Indirizzo) e * (Dereferenziazione)

Sezione intitolata “1.3 Operatori & (Indirizzo) e * (Dereferenziazione)”

Per lavorare con i puntatori utilizziamo due operatori unari fondamentali:

  1. Operatore Indirizzo (&): Restituisce la coordinata di memoria (l’indirizzo) di una variabile esistente.
  2. Operatore di Dereferenziazione (*): Consente di accedere al valore memorizzato all’indirizzo puntato, sia in lettura che in scrittura.
#include <stdio.h>
int main(void) {
int x = 42;
int *p = &x; // p memorizza l'indirizzo di x
printf("Valore di x: %d\n", x); // Stampa 42
printf("Indirizzo di x: %p\n", (void*)p); // Stampa es. 0x7ffd82
*p = 100; // Dereferenziazione: vai all'indirizzo in p e scrivi 100
printf("Nuovo valore di x: %d\n", x); // Stampa 100!
return 0;
}

A livello di Stack Frame, l’istruzione di dereferenziazione si traduce in un accesso diretto alle celle originali:

Diagramma: L'Operatore di Dereferenziazione

Prima di dereferenziare un puntatore, possiamo sfruttare l’inizializzazione a NULL per assicurarci che l’indirizzo sia valido:

if (ptr != NULL) {
*ptr = 10; // Sicuro
}

Esempio di Crash: Dimenticare il controllo di sicurezza

Sezione intitolata “Esempio di Crash: Dimenticare il controllo di sicurezza”

Cosa succede se omettiamo questo controllo in un programma più strutturato? Consideriamo questo esempio non banale in cui una funzione elabora i dati di un sensore passati tramite puntatore:

#include <stdio.h>
void elabora_sensore(const int *dati, int *risultato) {
// BUG: Mancano i controlli di sicurezza!
// Se dati o risultato sono NULL, avremo un crash.
*risultato = (*dati) * 2;
}
int main(void) {
int sensor_val = 25;
int output = 0;
// Caso d'uso corretto:
elabora_sensore(&sensor_val, &output);
printf("Risultato 1: %d\n", output);
// Caso d'uso errato (ad esempio, sensore scollegato o guasto):
int *sensore_guasto = NULL;
elabora_sensore(sensore_guasto, &output); // Qui il programma andrà in crash!
printf("Risultato 2: %d\n", output);
return 0;
}

Compilando ed eseguendo questo programma da terminale, l’esecuzione si interromperà bruscamente restituendo un errore del tipo Segmentation fault (o Errore di segmentazione), senza dare alcuna indicazione su quale riga di codice abbia causato il problema.

Per trovare la riga esatta del crash, possiamo utilizzare il debugger LLDB integrato. Ecco i passi da seguire da terminale:

  1. Compilare con i simboli di debug:
    Dobbiamo indicare al compilatore gcc (o clang) di includere i metadati di debug con l’opzione -g:
    Terminal window
    gcc -g sensore.c -o sensore
  2. Avviare LLDB:
    Lanciamo il debugger sul file eseguibile appena generato:
    Terminal window
    lldb ./sensore
  3. Eseguire il programma:
    All’interno dell’interfaccia interattiva di LLDB, digitiamo run (o semplicemente r) per avviare l’esecuzione:
    (lldb) run
  4. Analizzare il punto di arresto:
    Il programma verrà eseguito finché non incontrerà l’errore di memoria. LLDB intercetterà il crash e ci mostrerà l’esatta riga di codice che lo ha causato:
    Process 12345 stopped
    * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
    frame #0: 0x0000000100000f58 sensore`elabora_sensore(dati=0x0000000000000000, risultato=0x00007ff7bfefef08) at sensore.c:6:18
    3 void elabora_sensore(const int *dati, int *risultato) {
    4 // BUG: Mancano i controlli di sicurezza!
    5 // Se dati o risultato sono NULL, avremo un crash.
    -> 6 *risultato = (*dati) * 2;
    7 }
    L’output di LLDB ci dice che:
    • Lo stop è avvenuto alla riga 6 del file sensore.c all’interno della funzione elabora_sensore.
    • Il motivo dello stop è EXC_BAD_ACCESS (un tentativo di accedere a una zona di memoria non consentita).
    • Il parametro dati è impostato a 0x0000000000000000 (ovvero NULL).
  5. Esaminare lo Stack dei Frame:
    Digitando il comando backtrace (o bt), possiamo vedere l’intera catena di chiamate che ha portato a quella riga, capendo che la funzione elabora_sensore è stata invocata dal main alla riga 19:
    (lldb) backtrace
    * frame #0: 0x0000000100000f58 sensore`elabora_sensore(dati=0x0000000000000000, risultato=0x00007ff7bfefef08) at sensore.c:6:18
    frame #1: 0x0000000100000fc4 sensore`main at sensore.c:19:5
  6. Risolvere il Bug:
    Una volta individuata l’origine, possiamo correggere la funzione aggiungendo la guardia if:
    void elabora_sensore(const int *dati, int *risultato) {
    if (dati == NULL || risultato == NULL) {
    printf("Errore: puntatori non validi.\n");
    return;
    }
    *risultato = (*dati) * 2;
    }
    Infine usciamo dal debugger digitando quit o q.

Proviamo a scrivere un programma che inverte due variabili intere usando una funzione swap:

#include <stdio.h>
void swap_sbagliato(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int main(void) {
int x = 5;
int y = 10;
swap_sbagliato(x, y); // Passa i valori di x e y (fotocopie)
printf("x = %d, y = %d\n", x, y); // Stampa x = 5, y = 10!
return 0;
}

Se eseguiamo questo codice, il valore di x e y nel main rimarrà rispettivamente 5 e 10. Questo perché la funzione lavora su copie isolate collocate nel proprio Stack Frame, che vengono distrutte non appena la funzione termina.

Diagramma: Swap Pass-by-Value


Per consentire a una funzione di modificare le variabili del chiamante, dobbiamo passarle gli indirizzi delle variabili (riferimento simulato tramite puntatori):

#include <stdio.h>
void swap_corretto(int *a, int *b) {
int temp = *a; // Copia il valore puntato da a
*a = *b; // Scrive il valore puntato da b all'indirizzo a
*b = temp; // Scrive temp all'indirizzo b
}
int main(void) {
int x = 5, y = 10;
swap_corretto(&x, &y); // Passa gli indirizzi di x e y
printf("x = %d, y = %d\n", x, y); // Stampa x = 10, y = 5!
return 0;
}

Grazie ai puntatori, swap_corretto può raggiungere direttamente la memoria riservata al main e modificare fisicamente le variabili originali.

Diagramma: Swap Pass-by-Reference


In C esiste un legame indissolubile tra gli array e i puntatori. Quando dichiariamo un array statico:

int arr[5] = {10, 20, 30, 40, 50};

Il nome dell’array, arr, si comporta a tutti gli effetti come un puntatore costante al suo primo elemento. In altre parole, l’espressione arr è semanticamente equivalente a &arr[0].

Questa corrispondenza si riflette anche nella sintassi di accesso. Quando scriviamo arr[i], il compilatore traduce questa notazione in un’operazione di aritmetica dei puntatori seguita da dereferenziazione:

arr[i] <==> *(arr + i)

Ciò significa che possiamo scorrere un array sia utilizzando l’indice [i] sia manipolando direttamente l’indirizzo tramite l’aritmetica dei puntatori.

Per chiarire questo legame intimo tra array e puntatori, osserviamo il seguente codice d’esempio:

#include <stdio.h>
int main(void) {
// Allocazione contigua di 8 interi (32 Byte)
int array[8] = {10, 20, 30, 40, 50, 60, 70, 80};
// x memorizza l'indirizzo di partenza dell'array
int *x = array;
// Equivalenza fondamentale:
// array == &array[0]
printf("Indirizzo array: %p\n", (void*)array);
printf("Indirizzo primo elemento: %p\n", (void*)&array[0]);
printf("Valore puntato da x: %d\n", *x); // Stampa 10
return 0;
}

A livello dello Stack Frame, le celle dell’array sono allocate in posizioni consecutive di memoria, e il puntatore x punta all’inizio di questa sequenza:

Diagramma: Equivalenza Array e Puntatori


Quando passiamo un array come parametro a una funzione, il linguaggio C non copia l’intero array in memoria (operazione che richiederebbe molto tempo e memoria per array di grandi dimensioni). Invece, applica la regola del decadimento degli array (array decay): l’array decade a un semplice puntatore al suo primo elemento.

Di conseguenza, qualsiasi parametro formale dichiarato come int arr[] o int arr[100] viene interpretato dal compilatore come int *arr.

Questo ha un effetto collaterale importante: la funzione perde l’informazione sulla dimensione dell’array. Possiamo verificarlo empiricamente confrontando l’output dell’operatore sizeof all’interno del main e all’interno di una funzione:

#include <stdio.h>
void stampa_dimensione(int arr[], int dim) {
// All'interno della funzione, arr è diventato un semplice puntatore a int (int *).
// sizeof(arr) restituirà la dimensione di un puntatore (8 byte su sistemi a 64-bit).
printf("In funzione: sizeof(arr) = %lu byte\n", sizeof(arr));
}
int main(void) {
int voti[5] = {18, 22, 25, 28, 30};
// Nel main, voti è un array statico di 5 interi.
// sizeof(voti) restituisce la dimensione totale: 5 * sizeof(int) = 20 byte.
printf("Nel main: sizeof(voti) = %lu byte\n", sizeof(voti));
stampa_dimensione(voti, 5);
return 0;
}

Output del programma (su sistema a 64-bit):

Nel main: sizeof(voti) = 20 byte
In funzione: sizeof(arr) = 8 byte

Per questo motivo, in C è sempre necessario passare la dimensione dell’array come parametro aggiuntivo (int dim) a qualsiasi funzione debba elaborarlo.


3.3 Aritmetica dei puntatori e zucchero sintattico

Sezione intitolata “3.3 Aritmetica dei puntatori e zucchero sintattico”

Sui puntatori è possibile eseguire operazioni aritmetiche (come la somma e la sottrazione). L’aritmetica dei puntatori è tipizzata: aggiungere 1 a un puntatore non aumenta l’indirizzo di 1 byte, ma di una quantità pari a sizeof(tipo) associato al puntatore (ovvero la dimensione del tipo a cui punta).

Consideriamo il confronto nel codice seguente:

Aritmetica su Interi (sizeof(int) == 4):

int i[4] = {10, 20, 30, 40};
int *p = i; // p punta a i[0] (es. indirizzo 1000)
// p + 1 incrementa l'indirizzo di 1 * sizeof(int) = 4 byte
// p + 2 incrementa l'indirizzo di 2 * sizeof(int) = 8 byte
printf("p : %p\n", (void*)p); // 1000
printf("p+1 : %p\n", (void*)(p + 1)); // 1004

Aritmetica su Reali (sizeof(double) == 8):

double d[4] = {1.5, 2.5, 3.5, 4.5};
double *q = d; // q punta a d[0] (es. indirizzo 1000)
// q + 1 incrementa l'indirizzo di 1 * sizeof(double) = 8 byte
// q + 2 incrementa l'indirizzo di 2 * sizeof(double) = 16 byte
printf("q : %p\n", (void*)q); // 1000
printf("q+1 : %p\n", (void*)(q + 1)); // 1008

A livello fisico, la CPU calcola l’indirizzo sommando al valore del puntatore l’indice moltiplicato per la dimensione del tipo di dato. Il passo di incremento (passo dell’aritmetica) si adatta quindi automaticamente al tipo di puntatore:

Diagramma: Aritmetica dei Puntatori

Questo spiega perché in C l’accesso agli elementi sia estremamente veloce: si tratta di una semplice operazione aritmetica di offset seguita da dereferenziazione.


Perché non possiamo cambiare l’indirizzo di un array

Sezione intitolata “Perché non possiamo cambiare l’indirizzo di un array”

Come abbiamo detto, il nome dell’array arr è un puntatore al primo elemento, ma con una restrizione cruciale: è un puntatore costante. Questo significa che il compilatore fissa l’indirizzo base dell’array al momento dell’allocazione e non permette di cambiarlo. Un’istruzione come la seguente genererà un errore di compilazione:

int x = 42;
int arr[5];
arr = &x; // ERRORE DI COMPILAZIONE! arr equivale a un int * const, non può essere riassegnato

Poiché passare un array (ovvero un puntatore al suo primo elemento) a una funzione dà a quest’ultima il potere di modificarne il contenuto originale, dobbiamo poter proteggere i dati quando vogliamo che la funzione si limiti a leggerli.

Per farlo, dichiariamo il parametro formale come puntatore a costante utilizzando la parola chiave const:

void stampa_array(const int *arr, int dim) {
// arr[0] = 99; // ERRORE DI COMPILAZIONE! Il compilatore impedisce la scrittura
}
  • const int *p: Il puntatore può cambiare indirizzo, ma il dato puntato è costante (sola lettura).
  • int * const p: Il dato puntato può essere modificato, ma il puntatore non può cambiare indirizzo (comportamento nativo degli array).
  • const int * const p: Sia il puntatore che il dato puntato sono costanti.

Le chiamate a funzione richiedono un costo architetturale (overhead) per la creazione e distruzione dei record di attivazione nello stack. Per funzioni piccolissime invocate frequentemente (es. all’interno di cicli intensivi), questo overhead può impattare sulle prestazioni.

La keyword inline suggerisce al compilatore di sostituire la chiamata con il codice effettivo direttamente nel punto in cui è invocata:

static inline int quadrato(int x) {
return x * x;
}

Questo elimina totalmente l’overhead della chiamata a funzione senza perdere i vantaggi di modularità e leggibilità del codice.

Per comprendere concretamente l’effetto dell’inlining, osserviamo a confronto il codice C e l’Assembly generato da una funzione static inline (in cui il compilatore ha scelto di non espandere a causa di mancate ottimizzazioni) rispetto ad una funzione classica, notando la chiamata effettiva tramite call.

Caso 1: Utilizzo di static inline (senza ottimizzazioni attive)

Sezione intitolata “Caso 1: Utilizzo di static inline (senza ottimizzazioni attive)”

Se scriviamo una funzione dichiarata come static inline, in assenza di flag di ottimizzazione del compilatore, quest’ultimo potrebbe comunque generare una chiamata a funzione standard.

Codice C:

#include <stdio.h>
static inline int add(int x, int y) {
return x + y;
}
int main(void) {
int x = 3 + add(5, 5);
return x;
}

Assembly x86-64 generato per il main:

main:
push rbp
mov rbp, rsp
sub rsp, 16
mov dword ptr [rbp - 4], 0
mov esi, 5
mov edi, esi
call add ; Esegue comunque la chiamata alla funzione (locale) add
add eax, 3 ; Somma 3 al risultato restituito in eax
mov dword ptr [rbp - 8], eax
mov eax, dword ptr [rbp - 8]
add rsp, 16
pop rbp
ret

Nel caso di una funzione standard, il compilatore genera separatamente il codice della funzione add e lo invoca esplicitamente nel main.

Codice C:

#include <stdio.h>
int add(int x, int y) {
return x + y;
}
int main(void) {
int x = 3 + add(5, 5);
return x;
}

Assembly x86-64 generato:

add:
push rbp
mov rbp, rsp
mov dword ptr [rbp - 4], edi ; Primo parametro
mov dword ptr [rbp - 8], esi ; Secondo parametro
mov eax, dword ptr [rbp - 4]
add eax, dword ptr [rbp - 8] ; Esegue la somma
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov dword ptr [rbp - 4], 0
mov esi, 5
mov edi, esi
call add ; Chiama direttamente la funzione add
add eax, 3 ; Somma 3
mov dword ptr [rbp - 8], eax
mov eax, dword ptr [rbp - 8]
add rsp, 16
pop rbp
ret

Quando invece attiviamo le ottimizzazioni del compilatore (es. -O2), la keyword inline fa sì che il codice di add venga iniettato direttamente all’interno di main, traducendo l’intera espressione in un unico calcolo immediato (con -O2 il main diventa semplicemente mov eax, 13 seguito da ret) ed eliminando sia l’istruzione call sia lo stack frame associato alla funzione chiamata.


1. Qual è la dimensione in byte di una variabile puntatore su un calcolatore a 64 bit?

  • A) Dipende dal tipo a cui punta (es. 4 byte per int*, 1 byte per char*)
  • B) Sempre 8 byte
  • C) Sempre 4 byte
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: Un puntatore contiene un indirizzo di memoria. Nelle architetture a 64 bit, l’indirizzo di memoria ha sempre una lunghezza fissa di 64 bit (ovvero 8 byte), indipendentemente dal tipo di dato a cui punta.

2. Quale operatore restituisce l’indirizzo di memoria di una variabile esistente?

  • A) *
  • B) &
  • C) ->
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: L’operatore unario di indirizzo & (address-of) estrae le coordinate di memoria fisiche in cui è allocata la variabile.

3. Se ptr è un puntatore a intero inizializzato a NULL, cosa succede se proviamo a eseguire l’istruzione *ptr = 10;?

  • A) La variabile intera viene correttamente modificata
  • B) Il programma viene compilato correttamente ma va in crash (Segmentation Fault) all’esecuzione
  • C) Errore di compilazione immediato
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: Compilare la dereferenziazione di un puntatore è sintatticamente valido. All’atto pratico, tuttavia, tentare di accedere all’indirizzo 0x0 (NULL) genera una violazione di accesso di memoria (Segmentation Fault) gestita dal sistema operativo che termina forzatamente il processo.

4. Perché i puntatori richiedono una tipizzazione (es. int* o char*)?

  • A) Per definire lo spazio occupato dal puntatore stesso (8 byte vs 4 byte)
  • B) Per consentire al compilatore di sapere quanti byte leggere/scrivere e come interpretarli all’indirizzo puntato
  • C) È solo un vincolo estetico del linguaggio C
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: Sebbene l’indirizzo occupi sempre 8 byte, il tipo associato specifica la “lente” attraverso cui guardare la cella: definisce quanti byte consecutivi considerare a partire da quell’indirizzo e con quale schema interpretarli.

5. Cosa significa il termine “Array Decay” in C?

  • A) La progressiva perdita di precisione dei dati all’interno dell’array
  • B) Il fatto che il nome di un array decade implicitamente a un puntatore al suo primo elemento quando passato come parametro
  • C) La distruzione automatica dell’array alla fine del ciclo di vita
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: Quando un array viene passato come argomento a una funzione, decade in un semplice puntatore (&arr[0]), perdendo l’informazione sulla sua dimensione totale originale (sizeof).

6. Se un puntatore a intero p memorizza l’indirizzo decimale 2000, quale indirizzo conterrà l’espressione p + 2?

  • A) 2002
  • B) 2008
  • C) 2016
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: L’aritmetica dei puntatori scala l’addizione per la dimensione del tipo a cui punta. Poiché in genere un intero (int) occupa 4 byte, p + 2 equivale a 2000 + (2 * 4) = 2008.

7. Qual è l’equivalenza fondamentale che lega gli array e la dereferenziazione in C?

  • A) arr[i] equivale a *(arr + i)
  • B) arr[i] equivale a &arr + i
  • C) arr[i] equivale a *arr + i
▶ Mostra Risposta Corretta

Risposta corretta: A

Spiegazione: La notazione con parentesi quadre è solo zucchero sintattico per l’aritmetica dei puntatori. arr[i] esegue una somma sull’indirizzo base arr con offset i e dereferenzia il risultato.

8. Cosa dichiara l’istruzione const int *ptr = &x;?

  • A) Un puntatore costante: non possiamo spostare ptr su altri indirizzi
  • B) Un puntatore a un intero costante: ptr può puntare altrove, ma non possiamo modificare il valore di x tramite ptr
  • C) Tutto bloccato: sia ptr che x sono immutabili
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: La parola chiave const prima dell’asterisco protegge il valore puntato. Il puntatore stesso rimane modificabile (ptr = &y; è valido), ma il contenuto all’indirizzo puntato diventa in sola lettura.

9. A cosa serve la parola chiave inline davanti alla definizione di una funzione?

  • A) A dire al compilatore di inserire il corpo della funzione direttamente nel punto di chiamata, riducendo l’overhead dello stack frame
  • B) A consentire alla funzione di essere dichiarata su un’unica riga
  • C) A forzare la funzione a essere compilata solo per sistemi multi-thread
▶ Mostra Risposta Corretta

Risposta corretta: A

Spiegazione: La keyword inline suggerisce al compilatore di sostituire la chiamata con il codice vero e proprio, eliminando l’overhead computazionale dovuto a push e pop dei record di attivazione dello Stack.

10. Qual è il comportamento atteso dichiarando un parametro formale come int * const arr?

  • A) L’array è di sola lettura
  • B) L’indirizzo puntato da arr non può essere modificato (arr è costante), ma gli elementi all’interno possono essere cambiati
  • C) Il compilatore genera un errore di sintassi
▶ Mostra Risposta Corretta

Risposta corretta: B

Spiegazione: Collocare const dopo l’asterisco rende il puntatore stesso costante (sola scrittura dell’indirizzo). Gli elementi a cui punta restano accessibili in scrittura.


Scrivete una funzione void incrementa(int *x) che incrementi di una unità il valore contenuto all’indirizzo puntato da x. Nel main, dichiarate un intero, chiamate la funzione passandone l’indirizzo e verificate il cambiamento.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void incrementa(int *x) {
if (x != NULL) {
(*x)++; // Nota: le parentesi sono necessarie a causa delle precedenze
}
}
int main(void) {
int valore = 10;
printf("Valore originale: %d\n", valore);
incrementa(&valore);
printf("Valore incrementato: %d\n", valore); // Stamperà 11
return 0;
}

Scrivete una funzione double calcola_prodotto(const double *a, const double *b) che calcoli e restituisca il prodotto dei due valori reali puntati da a e b. I valori originali non devono essere modificabili all’interno della funzione.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
double calcola_prodotto(const double *a, const double *b) {
if (a != NULL && b != NULL) {
return (*a) * (*b);
}
return 0.0;
}
int main(void) {
double n1 = 3.5;
double n2 = 2.0;
double ris = calcola_prodotto(&n1, &n2);
printf("Prodotto: %.2f\n", ris); // Stamperà 7.00
return 0;
}

Scrivete una funzione void swap_double(double *x, double *y) che scambi i valori reali contenuti in due variabili reali del chiamante.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void swap_double(double *x, double *y) {
if (x != NULL && y != NULL) {
double temp = *x;
*x = *y;
*y = temp;
}
}
int main(void) {
double a = 1.23, b = 9.87;
printf("Prima: a=%.2f, b=%.2f\n", a, b);
swap_double(&a, &b);
printf("Dopo: a=%.2f, b=%.2f\n", a, b);
return 0;
}

Scrivete una funzione void azzera_array(int *arr, int dim) che azzeri tutti gli elementi di un array di interi. Utilizzate l’aritmetica dei puntatori per scorrere l’array invece degli indici quadrate (zucchero sintattico).

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void azzera_array(int *arr, int dim) {
if (arr == NULL) return;
for (int i = 0; i < dim; i++) {
*(arr + i) = 0; // Uso dell'aritmetica dei puntatori
}
}
int main(void) {
int numeri[5] = {1, 2, 3, 4, 5};
azzera_array(numeri, 5);
for (int i = 0; i < 5; i++) {
printf("%d ", numeri[i]); // Stamperà: 0 0 0 0 0
}
printf("\n");
return 0;
}

Esercizio 5: Riferimento a minimo (Doppio Puntatore)

Sezione intitolata “Esercizio 5: Riferimento a minimo (Doppio Puntatore)”

Scrivete una funzione void trova_min_ptr(const int *arr, int dim, const int **min_ptr) che trovi l’indirizzo dell’elemento minimo all’interno di un array e scriva tale indirizzo nella variabile puntata da min_ptr.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void trova_min_ptr(const int *arr, int dim, const int **min_ptr) {
if (arr == NULL || min_ptr == NULL || dim <= 0) return;
const int *min_corrente = arr; // Inizializza al primo elemento
for (int i = 1; i < dim; i++) {
if (*(arr + i) < *min_corrente) {
min_corrente = arr + i;
}
}
*min_ptr = min_corrente; // Scrive l'indirizzo trovato
}
int main(void) {
int dati[5] = {34, 12, 56, 8, 45};
const int *min_addr = NULL;
trova_min_ptr(dati, 5, &min_addr);
if (min_addr != NULL) {
printf("Il minimo e': %d, all'indirizzo %p\n", *min_addr, (void*)min_addr);
}
return 0;
}

Esercizio 6: Inversione di un array in-place con due puntatori

Sezione intitolata “Esercizio 6: Inversione di un array in-place con due puntatori”

Scrivete una funzione void inverti_array(int *arr, int dim) che inverta gli elementi di un array sul posto usando due puntatori di supporto: uno impostato al primo elemento, l’altro all’ultimo elemento. Si effettui lo swap degli elementi e si muovano i puntatori l’uno verso l’altro finché non si incontrano.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void inverti_array(int *arr, int dim) {
if (arr == NULL || dim <= 1) return;
int *sinistra = arr;
int *destra = arr + dim - 1;
while (sinistra < destra) {
// Swap degli elementi puntati
int temp = *sinistra;
*sinistra = *destra;
*destra = temp;
// Spostamento dei puntatori
sinistra++;
destra--;
}
}
int main(void) {
int v[6] = {10, 20, 30, 40, 50, 60};
inverti_array(v, 6);
for (int i = 0; i < 6; i++) {
printf("%d ", v[i]); // Stamperà: 60 50 40 30 20 10
}
printf("\n");
return 0;
}

Scrivete una funzione int confronta_puntatori(const int *p1, const int *p2) che verifichi i valori puntati. Restituisca 1 se il valore puntato da p1 è maggiore di quello di p2, -1 se è minore, e 0 se sono uguali. Se uno dei due puntatori è nullo, la funzione deve restituire -999.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
int confronta_puntatori(const int *p1, const int *p2) {
if (p1 == NULL || p2 == NULL) {
return -999;
}
if (*p1 > *p2) return 1;
if (*p1 < *p2) return -1;
return 0;
}
int main(void) {
int a = 15, b = 20;
printf("Risultato: %d\n", confronta_puntatori(&a, &b)); // Stamperà -1
printf("Risultato NULL: %d\n", confronta_puntatori(&a, NULL)); // Stamperà -999
return 0;
}

Esercizio 8: Lunghezza stringa con Aritmetica dei Puntatori

Sezione intitolata “Esercizio 8: Lunghezza stringa con Aritmetica dei Puntatori”

Scrivete una funzione int calcola_lunghezza_stringa(const char *s) che calcoli la lunghezza di una stringa in C (terminata dal carattere \0) incrementando un puntatore da inizio stringa e calcolando la differenza di indirizzi alla fine. Non utilizzate indici o variabili di conteggio.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
int calcola_lunghezza_stringa(const char *s) {
if (s == NULL) return 0;
const char *p = s;
while (*p != '\0') {
p++; // Avanza il puntatore
}
return p - s; // Differenza tra puntatori: restituisce il numero di char
}
int main(void) {
char testo[] = "Puntatori";
printf("Lunghezza di '%s': %d\n", testo, calcola_lunghezza_stringa(testo)); // Stamperà 9
return 0;
}

Scrivete una funzione void copia_array_const(const int *sorgente, int *destinazione, int dim) che copi tutti gli elementi da un array sorgente (in sola lettura) a un array di destinazione usando puntatori.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void copia_array_const(const int *sorgente, int *destinazione, int dim) {
if (sorgente == NULL || destinazione == NULL) return;
for (int i = 0; i < dim; i++) {
*(destinazione + i) = *(sorgente + i);
}
}
int main(void) {
int src[4] = {5, 10, 15, 20};
int dst[4];
copia_array_const(src, dst, 4);
for (int i = 0; i < 4; i++) {
printf("%d ", dst[i]); // Stamperà: 5 10 15 20
}
printf("\n");
return 0;
}

Scrivete una funzione void swap_puntatori(int **p1, int **p2) che scambi gli indirizzi di memoria memorizzati all’interno di due variabili puntatore. Nel main, verificate che dopo lo swap, i due puntatori puntino a variabili inverse.

💻 Mostra Soluzione e Codice C
#include <stdio.h>
void swap_puntatori(int **p1, int **p2) {
if (p1 != NULL && p2 != NULL) {
int *temp = *p1;
*p1 = *p2;
*p2 = temp;
}
}
int main(void) {
int x = 100, y = 200;
int *ptr1 = &x;
int *ptr2 = &y;
printf("ptr1 punta a valore: %d, ptr2 punta a valore: %d\n", *ptr1, *ptr2);
swap_puntatori(&ptr1, &ptr2); // Scambia gli indirizzi contenuti in ptr1 e ptr2
printf("Dopo swap:\n");
printf("ptr1 punta a valore: %d, ptr2 punta a valore: %d\n", *ptr1, *ptr2);
// Stamperà ptr1 punta a valore 200 e ptr2 punta a valore 100
return 0;
}