Salta al contenuto
Note per Studenti C++ - funzioni, reference e passaggio dei parametri

C++ - funzioni, reference e passaggio dei parametri

In questa pagina 6

Come si passano i dati a una funzione e al loro costo. Il meccanismo con cui la chiamata usa lo stack è in C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →; i puntatori come alternativa alle reference sono in 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 →. Chi viene da C conosce già il passaggio per valore (Funzioni in CDefinizione e prototipo di una funzione C, tipo di ritorno e void, passaggio dei parametri sempre per valore, variabili locali, globali e static, file header e compilazione separata, funzioni ricorsive.Funzioni in C →): la novità qui sono le reference. Per Python, dove "tutto è un riferimento", vedi Variabili, oggetti e riferimentiIn Python una variabile è un nome legato a un oggetto; tipo, identità e valore; assegnamento, aliasing, oggetti mutabili e immutabili, None.Variabili, oggetti e riferimenti →.

Funzioni: richiamo

Una funzione suddivide la computazione in blocchi logici. Ha input (argomenti, 0 o più) e output (valore di ritorno, 0 o 1):

cpp
double fct(int a, double d);                    // dichiarazione
double fct(int a, double d) { return a * d; }   // definizione: a e d sono i parametri formali
// ...
double d2 = fct(i, d1);                         // chiamata: i e d1 sono gli argomenti

Passare un argomento significa inizializzare il parametro formale con il valore dell'argomento. Una funzione senza valore di ritorno ha tipo void; chiamate e dichiarazioni sono quelle viste in C++ - tipi, variabili, espressioni e dichiarazioniun tipo definisce contenuto, intervallo di valori e operazioni; un oggetto è una regione di memoria con un tipo, il valore è la configurazione di bit letta in base al tipo, una variabile è un oggetto con un nome; inizializzazione (dare il primo valore) e assegnamento (cambiare il valore) sono diversi; le conversioni verso un tipo più capiente sono sicure, quelle verso un tipo meno capiente (narrowing, double->int, int->char) non lo sono e il compilatore le segnala di rado, ma la notazione con graffe {} le vieta; un'espressione calcola un valore da operandi e operatori, un lvalue è ciò che può stare a sinistra di =, constexpr vuole un valore noto in compilazione mentre const solo un valore che non cambia; gli operatori con side effect (++, --, +=) non vanno mischiati con altri usi della stessa variabile nella stessa espressione perché l'ordine di valutazione non è definito; una dichiarazione introduce un nome e un tipo, una definizione fornisce anche l'entità (memoria per una variabile, corpo per una funzione).C++ - tipi, variabili, espressioni e dichiarazioni →.

Passaggio per valore

Il passaggio in C++ è per valore per impostazione predefinita: il parametro è una variabile locale della funzione, inizializzata a ogni chiamata con una copia del valore. Cambiarla non tocca l'originale.

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

int f(int x)             // x e' una variabile LOCALE di f, inizializzata a ogni chiamata
{
    x = x + 1;           // modifica la copia, non l'originale
    return x;
}

int main()
{
    int xx = 0;
    cout << f(xx) << '\n';   // f riceve una copia di xx (0) e restituisce 1
    cout << xx << '\n';      // xx e' ancora 0
    int yy = 7;
    cout << f(yy) << '\n';   // 8
    cout << yy << '\n';      // 7
    return 0;
}
1
0
8
7

Passo per passo (dalla lezione): main ha xx = 0; la chiamata f(xx) crea nello stack un nuovo oggetto x con valore 0; la funzione fa x = x + 1 (la sua x diventa 1) e restituisce 1; tornati in main, xx vale ancora 0. Con yy = 7 la funzione lavora su x = 7, restituisce 8 e yy resta 7. Le due x sono due oggetti diversi: la prima nasce e muore a ogni chiamata.

main f (durante la chiamata) dopo il ritorno
xx = 0 x = 0, poi x = 1 xx = 0, stampato 1 e 0
yy = 7 x = 7, poi x = 8 yy = 7, stampato 8 e 7

Il passaggio per valore è comodo, efficace ed efficiente per pochi dati (i tipi standard) e per dati che non devono essere modificati. Ha un potenziale problema di efficienza quando i dati sono molti: vettori, immagini, testi, database.

Il problema della copia

vector<T> è la versione moderna dell'array C: gestisce vettori di ogni tipo (vector<int>, vector<double>, vector<vector<Record>>), conosce il proprio numero di elementi (v.size()) e gestisce da solo la memoria. Ma un vector può occupare molta memoria. Con la funzione

cpp
void print(vector<double> v)                 // v e' una COPIA di tutto il vettore
{
    cout << "{";
    for (int i = 0; i < v.size(); ++i) {
        cout << v[i];
        if (i != v.size() - 1) cout << ", ";
    }
    cout << "}\n";
}

chiamata con un vettore di un milione di elementi, si copia un milione di double solo per stamparli. Non bisogna ossessionarsi con l'efficienza, ma prestare attenzione ai dettagli. Il programma seguente conta le copie con un tipo che incrementa un contatore nel costruttore di copia:

copie_contate.cppcpp
#include <iostream>
#include <vector>
using namespace std;

// Un tipo "pesante" che conta quante volte viene copiato
struct Grande {
    vector<double> dati;
    static int copie;
    Grande(int n) : dati(n, 1.0) {}
    Grande(const Grande& g) : dati(g.dati) { ++copie; }   // costruttore di copia: costa O(n)
};
int Grande::copie = 0;

double somma_valore(Grande g)            // per VALORE: copia l'argomento
{
    double s = 0;
    for (double x : g.dati) s += x;
    return s;
}

double somma_ref(Grande& g)              // per REFERENCE: nessuna copia, ma potrebbe modificare
{
    double s = 0;
    for (double x : g.dati) s += x;
    return s;
}

double somma_cref(const Grande& g)       // per CONST REFERENCE: nessuna copia e sola lettura
{
    double s = 0;
    for (double x : g.dati) s += x;
    // g.dati.push_back(1);              // errore: g e' const
    return s;
}

int main()
{
    Grande g(1000000);
    cout << "all'inizio: copie = " << Grande::copie << '\n';
    somma_valore(g);
    cout << "dopo somma_valore: copie = " << Grande::copie << " (un milione di double copiati)\n";
    somma_ref(g);
    somma_cref(g);
    cout << "dopo somma_ref e somma_cref: copie = " << Grande::copie << '\n';
    return 0;
}
all'inizio: copie = 0
dopo somma_valore: copie = 1 (un milione di double copiati)
dopo somma_ref e somma_cref: copie = 1

somma_valore fa una copia (di un milione di double); somma_ref e somma_cref zero. Per evitare la copia serve accedere direttamente ai dati del chiamante: ci serve l'indirizzo del dato, cioè un puntatore o una reference.

Reference

Una reference (T&) è un alias: un secondo nome per un oggetto già esistente. Basta un & in più nella dichiarazione del parametro, e print(vector<double>& v) si riferisce (è un riferimento) all'oggetto definito nel chiamante: il main non cambia.

reference.cppcpp
#include <iostream>
#include <vector>
using namespace std;

void g(int a, int& r, const int& cr)
{
    ++a;            // incrementa la copia locale
    ++r;            // incrementa l'originale del chiamante
    int x = cr;     // si puo' solo leggere cr
    cout << "dentro g: a = " << a << ", r = " << r << ", cr = " << x << '\n';
    // ++cr;        // errore: cr e' const
}

int main()
{
    int i = 7;
    int& r = i;                   // r e i sono ALIAS dello stesso oggetto
    r = 9;                        // i ora contiene 9
    i = 10;                       // r vede 10
    cout << r << ' ' << i << '\n';

    vector<vector<double>> v(3, vector<double>(3, 1.0));
    double& var = v[1][2];        // alias di un elemento: si puo' anche scrivere
    var = var / 2 + 4;
    cout << v[1][2] << '\n';

    int x = 0, y = 0, z = 0;
    g(x, y, z);                   // x resta 0, y diventa 1, z resta 0
    cout << x << ' ' << y << ' ' << z << '\n';
    // g(1, 2, 3);                // errore: int& richiede un lvalue
    g(1, y, 3);                   // ok: const int& accetta un literal (oggetto temporaneo)
    cout << y << '\n';
    return 0;
}
10 10
4.5
dentro g: a = 1, r = 1, cr = 0
0 1 0
dentro g: a = 2, r = 2, cr = 3
2
  • r e i sono alias dello stesso oggetto: scrivere su uno cambia l'altro, r = 9 mette 9 in i. Una reference deve essere inizializzata alla nascita e non si può spostare su un altro oggetto.
  • Una reference ad un elemento (double& var = v[1][2];) evita di ripetere v[f(x)][g(y)] e permette di scrivere: var = var / 2 + sqrt(var);.
  • Il passaggio per reference dà accesso in lettura e scrittura. Se il dato non deve cambiare si usa il passaggio per const reference, che il compilatore verifica: tentare di modificare dà errore.

Tre modi di passare, a confronto

cpp
void g(int a, int& r, const int& cr)
{
    ++a;                // modifica la copia
    ++r;                // modifica l'originale
    int x = cr;         // sola lettura
}
int main() {
    int x = 0, y = 0, z = 0;
    g(x, y, z);         // x == 0, y == 1, z == 0
    g(1, 2, 3);         // ERRORE: int& richiede un lvalue
    g(1, y, 3);         // OK: const int& accetta un literal
}
parametro cosa riceve richiede può modificare l'originale
int a una copia niente di speciale no
int& r un alias un lvalue (un oggetto vero) sì
const int& cr un alias in sola lettura anche un literal o un'espressione no (errore di compilazione)

Una const reference non richiede un lvalue perché il compilatore crea un oggetto temporaneo: g(1, y, 3) diventa int __temp = 3; g(1, y, __temp);. Tutto bene perché la funzione non può scriverci.

Quale scegliere

Linee guida:

  1. Valore per gli oggetti piccoli (int, double, ...).
  2. const T& per gli oggetti grandi da non modificare.
  3. È preferibile restituire un risultato piuttosto che modificare un oggetto con una reference: è più chiaro e meno soggetto a errori. Va bene per oggetti piccoli e anche per grandi se hanno il costruttore di spostamento (C++ - costruttori, inizializzazione, copia e spostamentouna buona interfaccia è completa e minimale, ha i costruttori giusti, supporta o proibisce la copia, usa tipi adeguati, dichiara const ciò che non modifica e libera le risorse nel distruttore; il costruttore di default (senza argomenti) esiste quando c'è un valore iniziale sensato, si ottiene con valori di default nei membri (in-class initializer) o argomenti di default, e explicit vieta la conversione implicita da un argomento; initializer_list permette la lista {a, b, c} e con le graffe ha la precedenza sul costruttore a un intero (vector{3} è un elemento, vector(3) sono tre); la copia di default è membro a membro (shallow copy) e con un puntatore a un buffer duplica il puntatore invece del contenuto (due oggetti che condividono i dati e una doppia delete), quindi servono il costruttore di copia e l'assegnamento di copia che fanno la deep copy (l'assegnamento prima alloca e copia, poi libera i vecchi dati, e deve tollerare l'auto-assegnamento); this è il puntatore all'oggetto corrente, parametro implicito delle funzioni membro; il costruttore e l'assegnamento di spostamento (T&&, rvalue reference) rubano il buffer della sorgente lasciandola vuota ma valida, il compilatore li sceglie da solo (con la copy elision a volte non servono nemmeno); i cinque (distruttore, copia, spostamento) vanno definiti insieme.C++ - costruttori, inizializzazione, copia e spostamento →).
  4. T& solo se necessario: per manipolare contenitori e altri oggetti grandi, o per funzioni che devono produrre più di un output. Chi legge presume che una funzione che accetta una reference modifichi l'oggetto passato.
scelta_param.cppcpp
#include <iostream>
#include <vector>
using namespace std;

int  incr_v(int x)   { return x + 1; }          // restituisce il risultato
void incr_p(int* p)  { ++*p; }                  // puntatore: il chiamante scrive &x
void incr_r(int& r)  { ++r; }                   // reference: il chiamante scrive x e basta

// Una funzione con piu' di un output: si usano reference
void min_max(const vector<int>& v, int& min, int& max)
{
    min = max = v[0];
    for (int x : v) {
        if (x < min) min = x;
        if (x > max) max = x;
    }
}

int main()
{
    int x = 7;
    x = incr_v(x);                 // 8: esplicito, chiaro, ottimo per oggetti piccoli
    incr_p(&x);                    // 9: si vede il & nella chiamata
    incr_r(x);                     // 10: "sembra innocente"
    cout << x << '\n';

    vector<int> v {4, -2, 9, 3};
    int lo, hi;
    min_max(v, lo, hi);
    cout << lo << ' ' << hi << '\n';
    return 0;
}
10
-2 9

I tre incr_* modificano x in modi diversi: incr_v restituisce il nuovo valore, incr_p e incr_r lo modificano. La chiamata incr_p(&x) rende esplicito che x può cambiare; incr_r(x) "sembra innocente". Con un puntatore si può anche passare nullptr per dire "nessun oggetto" (e la funzione deve gestirlo); con la reference no. 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 no-object è un valore plausibile, puntatore; altrimenti reference o const reference.

Conversione degli argomenti

Un argomento passato a una funzione è convertito nel tipo del parametro, come in un'inizializzazione: f(y) è legale tutte le volte che T x = y; lo è. Con void f(double x) la chiamata f(n) con n intero converte n in double (conversione sicura). Il rischio sta nelle conversioni non sicure, accettate in silenzio: ff(d) con void ff(int) tronca. Un cast esplicito è più espressivo: un altro programmatore capisce che l'intenzione è questa.

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

void ff(int x) { cout << "ff(" << x << ")\n"; }
void gg(double y) { cout << "gg(" << y << ")\n"; }

int main()
{
    int n = 3;
    gg(n);                          // int -> double: conversione sicura, dentro la chiamata
    double d = 2.9;
    ff(d);                          // double -> int: tronca in silenzio, compila
    ff(static_cast<int>(d));        // lo stesso, ma esplicito: l'intenzione si legge
    ff(int(d));                     // forma equivalente
    return 0;
}
gg(3)
ff(2)
ff(2)
ff(2)

int x1 = x;, int(x) e static_cast<int>(x) fanno la stessa conversione; l'ultima è la più evidente e la più facile da cercare nel codice. Le regole sulle conversioni sono in C++ - tipi, variabili, espressioni e dichiarazioniun tipo definisce contenuto, intervallo di valori e operazioni; un oggetto è una regione di memoria con un tipo, il valore è la configurazione di bit letta in base al tipo, una variabile è un oggetto con un nome; inizializzazione (dare il primo valore) e assegnamento (cambiare il valore) sono diversi; le conversioni verso un tipo più capiente sono sicure, quelle verso un tipo meno capiente (narrowing, double->int, int->char) non lo sono e il compilatore le segnala di rado, ma la notazione con graffe {} le vieta; un'espressione calcola un valore da operandi e operatori, un lvalue è ciò che può stare a sinistra di =, constexpr vuole un valore noto in compilazione mentre const solo un valore che non cambia; gli operatori con side effect (++, --, +=) non vanno mischiati con altri usi della stessa variabile nella stessa espressione perché l'ordine di valutazione non è definito; una dichiarazione introduce un nome e un tipo, una definizione fornisce anche l'entità (memoria per una variabile, corpo per una funzione).C++ - tipi, variabili, espressioni e dichiarazioni →.

Domande d'esame

1. Differenza tra passaggio per copia e per riferimento: esempio di ciascuno. Traccia. Per copia il parametro è una variabile locale inizializzata col valore: la funzione modifica la copia (int f(int x) { x = x + 1; return x; }, il chiamante non vede modifiche), costo proporzionale alla dimensione. Per riferimento il parametro è un alias dell'oggetto del chiamante (void incr(int& r) { ++r; }): nessuna copia, la funzione può modificare l'originale. Con const T& si evita la copia e si impedisce la modifica.

2. Una variabile modificata da una funzione: il chiamante la vede modificata? (per reference, per puntatore, per const reference) Traccia. Per reference sì; tramite puntatore sì (si scrive sull'oggetto puntato, il puntatore stesso è copiato); per const reference non è possibile modificarla (errore di compilazione), quindi il chiamante la vede invariata.

3. Perché g(1, 2, 3) non compila con g(int a, int& r, const int& cr) ma g(1, y, 3) sì? Traccia. int& richiede un lvalue, 2 è solo un valore; const int& accetta un literal perché il compilatore crea un oggetto temporaneo.

Versione ripasso

cpp
int f(int x) { x = x + 1; return x; }
// xx = 0: f(xx) stampa 1, poi xx e' ancora 0;  yy = 7: f(yy) stampa 8, yy resta 7
  • Va bene per pochi dati e dati da non modificare. Problema: con molti dati (vector, immagini, testi) la copia costa: print(vector<double> v) su un milione di elementi li copia tutti (nel programma di prova: copie = 1 con il valore, 0 con & e const &).
  • Reference T& = alias di un oggetto esistente: int& r = i; r = 9; cambia i. Va inizializzata, non si sposta. Dà lettura e scrittura. Utile anche per double& var = v[f(x)][g(y)];.
  • Const reference const T&: nessuna copia, sola lettura verificata dal compilatore, accetta anche literal (il compilatore crea un temporaneo).
parametro riceve richiede modifica l'originale
int a copia niente no
int& r alias lvalue sì
const int& cr alias in lettura anche un literal no
cpp
g(x, y, z);   // x == 0, y == 1, z == 0
g(1, 2, 3);   // ERRORE: int& vuole un lvalue
g(1, y, 3);   // OK: const int& accetta il literal 3

Esercizi su questo argomento

Teoria collegata