Salta al contenuto
Note per Studenti Esercizio - Puntatori, array e passaggio dei parametri (laboratorio 3)

Esercizio - Puntatori, array e passaggio dei parametri (laboratorio 3)

In questa pagina 4

Prima parte del terzo laboratorio del corso di Laboratorio di Programmazione, Ing. Informatica UniPD: domande di discussione e tre esercizi su puntatori, array in stile C e passaggio dei parametri. La seconda parte (la classe MyVector) è in Esercizio - MyVector con memoria dinamica (laboratorio 3). Teoria: C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →, C++ - funzioni, reference e passaggio dei parametriuna funzione ha parametri formali (0 o più, input) e un valore di ritorno (0 o 1, output); in C++ gli argomenti si passano per valore, cioè il parametro è una variabile locale inizializzata con una copia dell'argomento, e modificarla non cambia l'originale; per oggetti grandi (vector, immagini) la copia costa, e si passa per reference (T&), un alias dell'oggetto del chiamante che permette lettura e scrittura, oppure per const reference (const T&), alias in sola lettura verificata dal compilatore, che accetta anche literal (oggetto temporaneo); regola: valore per oggetti piccoli, const T& per grandi da non modificare, T& solo se la funzione deve davvero modificare o produrre più di un output; il passaggio converte l'argomento nel tipo del parametro come in un'inizializzazione e un cast esplicito (static_cast) rende visibile l'intenzione.C++ - funzioni, reference e passaggio dei parametri →.

Domande di discussione

Testo (dal libro di testo, cap. 17, domande di ripasso). 1. What is a dereference operator and why do we need one? 2. What is an address? How are memory addresses manipulated in C++? 3. What information about a pointed-to object does a pointer have? What useful information does it lack? 4. What can a pointer point to? 5. When do we need a pointer (instead of a reference or a named object)?

1. L'operatore di dereference (*). Dato un puntatore, *p dà l'oggetto puntato: serve perché un puntatore contiene un indirizzo, non il dato; senza dereference non si potrebbe leggere né scrivere ciò a cui punta. *p è un lvalue: *p = 7; scrive nell'oggetto puntato.

2. Indirizzo. Un numero che identifica un byte della memoria (la memoria è indirizzata a byte). In C++ si ottiene con & (&x), si conserva in una variabile puntatore, si sposta con l'aritmetica dei puntatori (p + 1), si confronta (==, <), si converte con i cast (void*, reinterpret_cast). Non si fanno somme fra due indirizzi.

3. Che informazioni ha un puntatore? L'indirizzo e il tipo dell'oggetto puntato (quindi quanti byte leggere e come interpretarli). Non ha: se l'oggetto esiste ancora (può essere pendente), se l'indirizzo è valido o inizializzato, quanti elementi ci sono (è il primo di un array? di quanti?), a chi appartiene la memoria (chi deve liberarla).

4. A che cosa può puntare? A una variabile (&x), a un elemento di un array, a un oggetto sul free store (new), a un altro puntatore, a una funzione, a nulla (nullptr); con void* a memoria grezza di tipo non specificato.

5. Quando serve un puntatore? Quando l'oggetto può non esserci (nullptr), quando deve cambiare oggetto durante la vita del puntatore (una reference non si sposta), quando si usa memoria dinamica (new restituisce un puntatore), per scorrere array e buffer, per interfacciarsi con codice C e per il polimorfismo con puntatori a classi base. In tutti gli altri casi: un oggetto con nome o una reference, più sicuri.

Esercizio 1: array, puntatore a un elemento e scritture fuori dai limiti

Testo. Creare un programma che: a. definisce una funzione f(), chiamata dal main, che definisce un array stile C di 10 int come variabile locale automatica; b. in f(), crea un puntatore a uno degli elementi dell'array a scelta (non il primo) e scrive su tutto l'array usando l'operatore [] applicato al puntatore; c. definisce una funzione f_illegal() che definisce un array analogo ma scrive fuori dai limiti; verificare se questo causa un errore in esecuzione o meno in funzione di quanto dista la memoria a cui si accede illegalmente dall'array.

Il puntatore a un elemento centrale, p = &a[4], raggiunge l'intero array con indici da −4-4 a +5+5: p[i] equivale a *(p + i), quindi p[-4] è a[0] e p[5] è a[9]. I limiti restano quelli dell'array: i puntatori non li conoscono.

Per f_illegal la distanza dall'ultimo elemento viene letta da cin, così si può provare con valori diversi senza ricompilare.

lab3_es1.cppcpp
#include <iostream>
using namespace std;

constexpr int N = 10;

void f()
{
    int a[N];                          // array stile C, variabile locale automatica (nello stack)
    int* p = &a[4];                    // puntatore a un elemento in mezzo (non il primo)
    // con l'operatore [] applicato al puntatore si raggiunge TUTTO l'array: indici da -4 a +5
    for (int i = -4; i <= 5; ++i)
        p[i] = (i + 4) * 10;           // p[-4] e' a[0], p[5] e' a[9]
    for (int i = 0; i < N; ++i)
        cout << a[i] << ' ';
    cout << '\n';
}

// Scrive FUORI dai limiti: a[N + distanza]. L'esito dipende da che cosa c'e' in quella zona di memoria.
void f_illegal(int distanza)
{
    int a[N];
    for (int i = 0; i < N; ++i) a[i] = i;
    a[N - 1 + distanza] = 12345;       // distanza = 0: ultimo elemento (lecito); 1: subito dopo la fine...
    cout << "scritto a[" << N - 1 + distanza << "] senza errori\n";
}

int main()
{
    f();
    int distanza;
    cin >> distanza;
    f_illegal(distanza);
    cout << "il programma e' arrivato alla fine\n";
    return 0;
}

Con l'ingresso 7:

0 10 20 30 40 50 60 70 80 90 
scritto a[16] senza errori
il programma e' arrivato alla fine

Esecuzioni al variare della distanza (a[9 + distanza]; il programma è compilato senza controlli sui limiti, come con g++):

distanza indice scritto esito
0 a[9] lecito: ultimo elemento
1 a[10] nessun errore visibile: si sovrascrive la memoria subito dopo l'array
5, 7 a[14], a[16] nessun errore visibile
100 a[109] nessun errore visibile
10610^6 a[1000009] errore a run-time (accesso fuori dalla memoria del programma: su Linux segmentation fault, qui memory access out of bounds)
10810^8 a[100000009] errore a run-time

Conclusioni:

  • Scrivere appena oltre la fine dell'array non dà errore: i byte sono dentro la memoria del programma (altre variabili locali, il record di attivazione del chiamante, altri dati). Il programma sembra funzionare ma ha corrotto altri dati; il sintomo può comparire molto dopo e altrove. Con altri compilatori o altre ottimizzazioni la stessa riga si comporta in modo diverso: è comportamento indefinito.
  • Scrivere molto lontano esce dalla memoria assegnata al processo: errore immediato (segmentation fault).
  • Il compilatore, se vede un indice costante, può avvertire (array index 10 is past the end of the array), ma con un indice letto da cin non può: il controllo non esiste.
  • È il motivo per cui std::vector con at() controlla i limiti e per cui MyVector ha safe_get/safe_set (Esercizio - MyVector con memoria dinamica (laboratorio 3)).

Esercizio 2: sizeof e decadimento dell'array

Testo. Creare un programma che: a. definisce nel main un array di double grande a piacere (la dimensione definita con constexpr o variabile costante); b. stampa la dimensione dell'array con sizeof; c. passa l'array come argomento a una funzione print_array_length() che stampa la dimensione dell'array con sizeof. La funzione conosce la dimensione dell'array? Riesce ad accedere ai dati? L'accesso è lecito? Commentare i risultati.

lab3_es2.cppcpp
#include <iostream>
using namespace std;

// "double a[]" e "double* a" sono la stessa cosa: l'array DECADE a puntatore al primo elemento
void print_array_length(double a[])
{
    cout << "dentro la funzione: sizeof(a) = " << sizeof(a) << " byte (un puntatore)\n";
    cout << "ma a[0] = " << a[0] << " e a[3] = " << a[3] << " sono raggiungibili\n";
}

// la soluzione corretta: passare ANCHE la lunghezza
double somma(const double* a, int n)
{
    double s = 0;
    for (int i = 0; i < n; ++i) s += a[i];
    return s;
}

int main()
{
    constexpr int n = 8;
    double v[n] = {1, 2, 3, 4, 5, 6, 7, 8};
    cout << "nel main: sizeof(v) = " << sizeof(v) << " byte, elementi = " << sizeof(v) / sizeof(v[0]) << '\n';
    print_array_length(v);
    cout << "somma = " << somma(v, n) << '\n';
    return 0;
}
nel main: sizeof(v) = 64 byte, elementi = 8
dentro la funzione: sizeof(a) = 4 byte (un puntatore)
ma a[0] = 1 e a[3] = 4 sono raggiungibili
somma = 36
  • Nel main, sizeof(v) =8⋅8=64= 8 \cdot 8 = 64 byte: l'array è un oggetto di tipo double[8]. Il numero di elementi si ottiene con sizeof(v) / sizeof(v[0]).
  • Nella funzione, sizeof(a) vale 4 (la dimensione di un puntatore; 8 sui PC a 64 bit): il parametro double a[] è in realtà double* a. Il compilatore avverte (sizeof on array function parameter will return size of 'double *' instead of 'double[]').
  • La funzione non conosce la dimensione: passando l'array se ne passa l'indirizzo del primo elemento (decadimento) e si perde il numero di elementi.
  • Riesce ad accedere ai dati e l'accesso è lecito: a[0], a[3] sono raggiungibili tramite l'indirizzo. È lecito se l'indice sta nei limiti, che la funzione però non può verificare da sola. La soluzione è passare anche la lunghezza (somma(const double* a, int n)), oppure usare vector/array, che la portano con sé.

Esercizio 3: stesso dato, per reference e per puntatore

Testo. Creare un programma che: a. definisce una variabile int e una double nel main; b. le stampa da una funzione print_reference() a cui sono passate per const reference; c. le stampa da una funzione print_pointer() a cui sono passate per puntatore.

lab3_es3.cppcpp
#include <iostream>
using namespace std;

// per const reference: nessuna copia, sola lettura, nessun valore "nullo" possibile
void print_reference(const int& i, const double& d)
{
    cout << "reference: i = " << i << ", d = " << d << '\n';
}

// per puntatore: il chiamante deve passare &, e la funzione deve gestire nullptr
void print_pointer(const int* i, const double* d)
{
    if (i == nullptr || d == nullptr) {
        cout << "pointer: argomento nullo\n";
        return;
    }
    cout << "pointer: i = " << *i << ", d = " << *d << '\n';
}

int main()
{
    int i = 42;
    double d = 2.5;
    print_reference(i, d);          // si passano gli oggetti
    print_pointer(&i, &d);          // si passano gli indirizzi
    print_pointer(nullptr, &d);     // solo il puntatore ammette "nessun oggetto"
    print_reference(7, 1.5);        // la const reference accetta anche i literal
    return 0;
}
reference: i = 42, d = 2.5
pointer: i = 42, d = 2.5
pointer: argomento nullo
reference: i = 7, d = 1.5

Le differenze nell'uso:

print_reference(const int&, const double&) print_pointer(const int*, const double*)
chiamata print_reference(i, d) print_pointer(&i, &d)
accesso direttamente i *i
argomenti nulli impossibili nullptr possibile: la funzione deve controllare
literal print_reference(7, 1.5) ok (oggetto temporaneo) no: &7 non esiste
lettura const impedisce di modificare const int* (puntatore a costante) impedisce di modificare il dato

Criterio (C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →): se nessun oggetto è un valore plausibile, puntatore; altrimenti reference o const reference. Qui l'input è sempre presente: print_reference è la scelta naturale.

Versione ripasso

  • Domande: (1) *p = oggetto puntato (lvalue), serve perché il puntatore contiene un indirizzo; (2) indirizzo = numero di un byte; &x, variabili puntatore, aritmetica, confronti, cast; (3) il puntatore ha indirizzo e tipo, non sa se l'oggetto esiste/è valido, quanti elementi ci sono, chi lo possiede; (4) può puntare a variabile, elemento di array, oggetto sul free store, puntatore, funzione, nullptr, void* per memoria grezza; (5) serve quando l'oggetto può mancare, deve cambiare, memoria dinamica, array/buffer, codice C, polimorfismo.
  • Esercizio 1: int a[10]; int* p = &a[4]; e p[i] per ii da −4-4 a 55 scrive tutto l'array (p[-4] è a[0], p[5] è a[9]).
cpp
void f() { int a[10]; int* p = &a[4];
           for (int i = -4; i <= 5; ++i) p[i] = (i + 4) * 10; }      // a = 0 10 20 ... 90
void f_illegal(int distanza) { int a[10]; a[9 + distanza] = 12345; }  // fuori dai limiti
  • Fuori dai limiti: distanza 1, 5, 100: nessun errore visibile (si corrompono altri dati, comportamento indefinito); distanza 10610^6 o 10810^8: errore a run-time (segmentation fault). Il compilatore avverte solo se l'indice è costante.
  • Esercizio 2: nel main sizeof(v) = 64 (8 double); nella funzione sizeof(a) = 4 (un puntatore): double a[] è double* a. La funzione non conosce la dimensione, accede ai dati (lecito se l'indice è nei limiti); si passa anche la lunghezza, somma(const double* a, int n), o si usa vector.
  • Esercizio 3: print_reference(const int&, const double&) chiamata con gli oggetti, nessun nullo, accetta literal; print_pointer(const int*, const double*) chiamata con &, accesso con *, controlla nullptr. Stampano i = 42, d = 2.5; print_pointer(nullptr, &d) →\to "argomento nullo".
  • Programmi, codice:
cpp
void print_reference(const int& i, const double& d) { cout << "reference: i = " << i << ", d = " << d << '\n'; }
void print_pointer(const int* i, const double* d)
{ if (i == nullptr || d == nullptr) { cout << "pointer: argomento nullo\n"; return; }
  cout << "pointer: i = " << *i << ", d = " << *d << '\n'; }
double somma(const double* a, int n) { double s = 0; for (int i = 0; i < n; ++i) s += a[i]; return s; }
// main: print_reference(i, d); print_pointer(&i, &d); print_pointer(nullptr, &d); print_reference(7, 1.5);
  • Esercizio 2, uscita: nel main: sizeof(v) = 64 byte, elementi = 8; dentro la funzione: sizeof(a) = 4 byte (un puntatore); somma = 36.
  • Errori tipici: credere che sizeof di un parametro array dia la dimensione dell'array; scrivere oltre la fine dell'array pensando che "non succeda niente"; dereferenziare nullptr nella versione a puntatore senza controllo.

Esercizi su questo argomento

Teoria collegata