Salta al contenuto
Note per Studenti Esercizio - Quiz e domande su polimorfismo, allocazione dinamica e garanzie sulle eccezioni (secondo appello 2023-24, teoria)

Esercizio - Quiz e domande su polimorfismo, allocazione dinamica e garanzie sulle eccezioni (secondo appello 2023-24, teoria)

Esame
In questa pagina 4

Testo. Prova scritta di Laboratorio di Programmazione, secondo appello 2023-24, parte di teoria (90 minuti): un quiz di 10 domande da 4 affermazioni vere/false (corretta +1, sbagliata -0,25, non data 0) e tre domande aperte (5, 4 e 4 punti).

Teoria: C++ - polimorfismoPolimorfismo = abilità di associare comportamenti diversi a un'unica notazione generica. In C++ ci sono due forme principali: dinamico (ereditarietà più funzioni virtuali: vincolato dall'interfaccia della classe base, binding a run-time, permette collezioni eterogenee) e statico (template: svincolato, basta che i tipi abbiano la stessa sintassi, binding a tempo di compilazione, nessun costo a run-time ma una funzione per ogni tipo); il polimorfismo statico è lo strumento della programmazione generica. Anche l'overloading di funzioni e di operatori può essere visto come polimorfismo. Nella gerarchia si naviga con l'upcasting (implicito, sempre lecito) e il downcasting con dynamic_cast, l'unico cast con verifica a run-time: su un puntatore dà nullptr se non lecito, su un riferimento lancia bad_cast.C++ - polimorfismo →, C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →, C++ - eccezioni e gestione delle risorse (RAII)Un errore che non si può gestire dove avviene si segnala con throw e si gestisce con try/catch più in alto; la libreria standard ha la gerarchia std::exception (logic_error e runtime_error con le loro derivate) e le eccezioni proprie si derivano pubblicamente da una di esse; si cattura per riferimento. Le eccezioni mettono in crisi il codice che acquisisce risorse (memoria con new, file, lock, socket): se una chiamata lancia prima della delete, la risorsa si perde. La soluzione è RAII (Resource Acquisition Is Initialization): acquisire la risorsa nel costruttore di un oggetto locale e rilasciarla nel distruttore, perché all'uscita da uno scope, anche per eccezione (stack unwinding), i distruttori degli oggetti locali vengono chiamati. Garanzie di una funzione: basic (nessuna perdita, stato valido), strong (commit or rollback: se fallisce lo stato osservabile è quello di prima) e no-throw (non lancia).C++ - eccezioni e gestione delle risorse (RAII) →. La parte di programmazione dello stesso appello è Esercizio - ConvertToArray da un container STL (secondo appello 2023-24, programmazione).


Quiz

1. Codice: float* ptr1; float* ptr2; for (int i = 0; i < 4; ++i) { ptr1 = new float[5]; ptr2 = ptr1; }

affermazione risposta motivo
dà errore in fase di compilazione F il codice è corretto sintatticamente
causa memory leak V si fanno 4 new[] e nessuna delete[]: dei 4 blocchi restano raggiungibili solo l'ultimo (da ptr1 e ptr2) e, a fine programma, nessuno viene liberato; i primi tre sono persi a ogni iterazione
produce un problema di dangling pointer F nessun blocco è stato liberato: nessun puntatore punta a memoria già rilasciata
utilizza l'allocazione dinamica V new[]

2. I tipi che implementano una deep copy:

affermazione risposta motivo
non possono essere implementati tramite classi derivate F una derivata può benissimo definire copia profonda
permettono la copia solo dei membri pubblici F la copia (che è una funzione membro) vede anche i membri privati
hanno un tempo di esecuzione più lungo rispetto a tipi equivalenti con shallow copy V la copia profonda alloca e copia i dati, la superficiale solo i puntatori
devono definire esplicitamente costruttore e assegnamento di copia se usano allocazione dinamica V quelli generati copiano i puntatori (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 →)

3. Una variabile locale automatica definita in una funzione f:

affermazione risposta motivo
ha scope globale F scope locale (il blocco)
ha durata di memorizzazione pari alla durata di esecuzione di f V nasce nel record di attivazione e muore quando f ritorna
deve essere deallocata in uscita da f per non causare memory leak F la deallocazione è automatica
è definita dalla keyword auto F in C++11 auto indica la deduzione del tipo (la "storage class" auto è di un C/C++ antico e mai usata)

4. Il record di attivazione di una funzione:

5. Un errore di linking può essere causato da:

affermazione risposta motivo
una funzione dichiarata, chiamata, ma non definita né nel progetto né nelle librerie linkate V undefined reference
una variabile non inizializzata F è un problema di esecuzione (valore indeterminato)
un puntatore usato senza essere inizializzato F come sopra
una libreria usata ma il cui header non è incluso in modo appropriato F senza header il compilatore non conosce il nome: errore di compilazione

6. L'overloading degli operatori:

affermazione risposta motivo
è possibile anche per i tipi built-in F almeno un operando deve essere di un tipo definito dall'utente
permette di trasformare operatori unari in binari F non si cambia l'arità dell'operatore
per operator+ si può usare funzione libera o membro, ma il numero di argomenti cambia V libera: due argomenti; membro: uno (il primo operando è *this)
per operator<< si può usare funzione libera o membro, ma il numero di argomenti non cambia F cambia come per +: libera operator<<(ostream&, const T&) con due argomenti, membro con uno

7. Gli iteratori dei container STL:

affermazione risposta motivo
si usano tramite operator[] F si usano con *, ++, == (solo i random access hanno anche [])
possono essere dereferenziati V *it
quando sono distrutti deallocano l'oggetto puntato F non possiedono l'elemento
sono implementati con classi, ciascuna definita nel relativo container V vector<T>::iterator, list<T>::iterator

8. Il distruttore di una classe:

affermazione risposta motivo
è chiamato quando un oggetto locale automatico esce dallo scope V
è chiamato quando un oggetto statico esce dallo scope e il programma non termina F gli oggetti statici sono distrutti alla fine del programma
può avere un argomento di tipo int F il distruttore non ha parametri
è chiamato effettuando una delete su un oggetto allocato dinamicamente V

9. Puntatori e riferimenti:

affermazione risposta motivo
un riferimento può essere spostato da un oggetto a un altro F si lega una volta sola, in inizializzazione
un puntatore può essere inizializzato in modo da non puntare a nessun elemento V nullptr
un riferimento può essere inizializzato in modo da non essere collegato a niente F non esiste il riferimento nullo
la copia tra due riferimenti equivale alla copia tra gli elementi a cui sono collegati V r1 = r2 assegna il valore riferito, non ricollega

10. L'istruzione std::cout << "Hello, world!\n";:

affermazione risposta motivo
contiene un literal V la stringa
contiene una definizione F è un'istruzione-espressione, nessuna dichiarazione
contiene due operandi V std::cout e la stringa
contiene un operatore V <<

Domanda aperta 1 (5 punti): polimorfismo

Testo. Descrivere il concetto di polimorfismo in C++ e le due tipologie di polimorfismo viste durante il corso, evidenziandone le differenze. Descrivere gli strumenti che le implementano. Per ogni tipologia riportare un esempio di codice in cui sia chiara la sua applicazione.

Risposta. Polimorfismo = capacità di associare comportamenti diversi a un'unica notazione generica. Le due forme (terminologia di Vandevoorde e Josuttis):

dinamico statico
strumenti ereditarietà + funzioni virtuali template
quando si sceglie la funzione a run-time (tramite vptr e vtbl) a compile-time (si genera una funzione per ogni tipo)
vincolo vincolato: l'interfaccia delle derivate è fissata dalla classe base svincolato: non c'è base, basta che i tipi abbiano la stessa sintassi
collezione di tipi diversi sì (vector<Base*>) no
costo indirezione per chiamata e un puntatore per oggetto nessun costo a run-time, ma codice generato per ogni tipo; errori in compilazione se un tipo non ha l'operazione
a2t/polimorfismo_due.cppcpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;

// ---------- POLIMORFISMO DINAMICO: ereditarieta' + funzioni virtuali ----------
class Animale {
public:
    virtual string verso() const = 0;                 // interfaccia comune
    virtual ~Animale() {}
};
class Cane : public Animale { public: string verso() const override { return "bau"; } };
class Gatto : public Animale { public: string verso() const override { return "miao"; } };

void fai_parlare(const Animale& a) { cout << a.verso() << ' '; }       // una sola funzione, decisione a RUN-TIME

// ---------- POLIMORFISMO STATICO: template ----------
struct Auto { string verso() const { return "brum"; } };            // nessuna base comune
struct Barca { string verso() const { return "tut"; } };

template <typename T>
void fai_rumore(const T& x) { cout << x.verso() << ' '; }               // una funzione per tipo, decisione a COMPILE-TIME

int main()
{
    Cane c;
    Gatto g;
    vector<const Animale*> zoo{&c, &g};                // collezione eterogenea: possibile solo con il dinamico
    for (const Animale* a : zoo) fai_parlare(*a);      // bau miao
    cout << '\n';

    Auto a;
    Barca b;
    fai_rumore(a);                                     // fai_rumore<Auto>
    fai_rumore(b);                                     // fai_rumore<Barca>
    cout << '\n';
}

Uscita eseguita: bau miao (le due chiamate passano per Animale, scelta a run-time) e brum tut (due istanze fai_rumore<Auto> e fai_rumore<Barca>, scelta a compile-time). Nel primo caso Cane e Gatto devono derivare da Animale e ridefinire verso; nel secondo Auto e Barca non hanno parentela, basta che abbiano verso(). Altre forme di polimorfismo: overloading di funzioni e operatori. Dettagli in C++ - polimorfismoPolimorfismo = abilità di associare comportamenti diversi a un'unica notazione generica. In C++ ci sono due forme principali: dinamico (ereditarietà più funzioni virtuali: vincolato dall'interfaccia della classe base, binding a run-time, permette collezioni eterogenee) e statico (template: svincolato, basta che i tipi abbiano la stessa sintassi, binding a tempo di compilazione, nessun costo a run-time ma una funzione per ogni tipo); il polimorfismo statico è lo strumento della programmazione generica. Anche l'overloading di funzioni e di operatori può essere visto come polimorfismo. Nella gerarchia si naviga con l'upcasting (implicito, sempre lecito) e il downcasting con dynamic_cast, l'unico cast con verifica a run-time: su un puntatore dà nullptr se non lecito, su un riferimento lancia bad_cast.C++ - polimorfismo →.

Domanda aperta 2 (4 punti): allocazione dinamica

Testo. Descrivere come avviene l'allocazione dinamica della memoria in C++, indicando quale zona di memoria è usata e quali sono i vantaggi/svantaggi rispetto alle variabili locali automatiche. Specificare gli elementi (istruzioni, variabili) usati per allocare e deallocare la memoria, corredando la discussione con un esempio di codice. Descrivere le problematiche di cui il programmatore deve tenere conto.

Risposta.

Dove e come. L'allocazione dinamica usa il free store (heap): una zona di memoria gestita a run-time, separata dallo stack. new T(args) alloca lo spazio per un T, chiama il costruttore e restituisce un puntatore T*; new T[n] alloca un array di n elementi; delete p chiama il distruttore e libera lo spazio; delete[] p libera un array. Le istruzioni sono new, new[], delete, delete[]; la variabile che tiene l'indirizzo è un puntatore (che vive, per esempio, nello stack).

variabile locale automatica (stack) oggetto dinamico (free store)
durata quella dello scope: automatica scelta dal programmatore: fino alla delete
dimensione nota a compile-time decisa a run-time (new int[n])
costo allocazione quasi gratuita più lenta (gestione dello heap)
rilascio automatico manuale
rischi pochi (dangling se si restituisce l'indirizzo) leak, dangling, doppia delete
spazio limitato (stack overflow) grande

Esempio verificato:

a2t/allocazione.cppcpp
#include <iostream>
using namespace std;

struct Punto {
    int x, y;
    Punto(int xx, int yy) : x{xx}, y{yy} { cout << "  Punto(" << x << "," << y << ")\n"; }
    ~Punto() { cout << "  ~Punto(" << x << "," << y << ")\n"; }
};

int* crea_dinamico()
{
    return new int{5};                            // vive nel free store: sopravvive alla funzione
}

int main()
{
    cout << "1) variabile automatica (stack): nasce e muore con lo scope\n";
    {
        Punto p{1, 2};
    }                                             // ~Punto automatico

    cout << "2) new / delete (free store)\n";
    Punto* q = new Punto{3, 4};                   // allocazione + costruttore
    delete q;                                     // distruttore + deallocazione
    q = nullptr;                                  // niente dangling pointer

    cout << "3) new[] / delete[]\n";
    int* v = new int[5]{};                        // 5 int inizializzati a 0
    v[4] = 9;
    cout << "  v[4] = " << v[4] << '\n';
    delete[] v;                                   // con le quadre, perche' e' stato fatto new[]

    cout << "4) il dato dinamico sopravvive alla funzione\n";
    int* d = crea_dinamico();
    cout << "  *d = " << *d << '\n';
    delete d;

    cout << "5) memory leak, dangling pointer e doppia delete (descritti, non eseguiti)\n";
    // int* p = new int{1}; p = new int{2};       // LEAK: il primo blocco non e' piu' raggiungibile
    // int* a = new int{1}; delete a; *a = 3;      // DANGLING: uso dopo la delete
    // int* b = new int{1}; delete b; delete b;    // DOPPIA delete: comportamento indefinito
    // int* f() { int x = 5; return &x; }         // DANGLING: indirizzo di una variabile automatica restituito
}

Uscita eseguita: Punto(1,2)/~Punto(1,2) (automatico: il distruttore è chiamato alla chiusura delle graffe), Punto(3,4)/~Punto(3,4) (con new/delete il costruttore e il distruttore sono chiamati dalle due istruzioni), v[4] = 9, *d = 5.

Problematiche.

Domanda aperta 3 (4 punti): eccezioni e memoria dinamica

Testo. Illustrare un caso in cui chiamare una funzione che può lanciare un'eccezione può creare problemi alla gestione della memoria allocata dinamicamente. Spiegare che cosa si intende per strong guarantee, basic guarantee, no-throw guarantee, e come queste permettono di risolvere tale problema.

Risposta.

Il caso. La funzione alloca un blocco, poi chiama qualcosa che può lanciare (qui vector::at()), e rilascia in fondo: se l'eccezione parte, la funzione è abbandonata e la delete[] non viene eseguita: memory leak.

a2t/eccezione_memoria.cppcpp
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

// 1) PROBLEMA: se v.at(x) lancia, la delete[] non viene eseguita e la memoria e' persa
void con_problema(int s, int x)
{
    int* p = new int[s];
    vector<int> v;
    p[0] = v.at(x);                    // lancia out_of_range (v e' vuoto)
    delete[] p;                        // non raggiunta
}

// 2) BASIC GUARANTEE con try/catch: libera e rilancia
void con_basic(int s, int x)
{
    int* p = new int[s];
    vector<int> v;
    try {
        p[0] = v.at(x);
    } catch (...) {
        delete[] p;                    // nessuna perdita
        throw;                         // l'eccezione continua verso chi sa gestirla
    }
    delete[] p;
}

// 3) RAII: nessun try/catch e nessuna delete, ci pensa il distruttore di vector
void con_raii(int s, int x)
{
    vector<int> p(static_cast<size_t>(s));
    vector<int> v;
    p[0] = v.at(x);
}

// 4) STRONG GUARANTEE: lavoro su una copia, poi commit con operazioni che non lanciano
void aggiungi_due(vector<int>& dati, int a, int b)
{
    vector<int> copia = dati;          // se lancia, dati e' intatto
    copia.push_back(a);                // puo' lanciare (bad_alloc): sempre sulla copia
    copia.push_back(b);
    dati.swap(copia);                  // swap non lancia: commit
}

int main()
{
    for (auto f : {con_problema, con_basic, con_raii}) {
        try { f(10, 3); } catch (const exception& e) { cout << "catch: " << e.what() << '\n'; }
    }
    vector<int> d{1, 2};
    aggiungi_due(d, 3, 4);
    cout << d.size() << '\n';
}

(Il programma compila per Linux senza avvisi e non è stato eseguito: le eccezioni non funzionano in WebAssembly. Il comportamento è determinato dal codice: con v vuoto at(3) lancia out_of_range; le tre funzioni terminano tutte con eccezione, ma solo con_problema lascia 40 byte non liberati; il testo di what() dipende dalla libreria. aggiungi_due lascia d con 4 elementi.)

Le garanzie.

garanzia che cosa promette nell'esempio
basic se lancia non ci sono perdite di risorse e lo stato resta valido (non necessariamente quello di prima) con_basic: try/catch (...) che fa delete[] e rilancia (throw;); le STL offrono almeno questa
strong in più, in caso di fallimento lo stato osservabile è identico a quello precedente alla chiamata (commit or rollback) aggiungi_due: lavora su una copia e poi swap
no-throw la funzione non lancia mai (noexcept) swap, il distruttore: sono i mattoni per costruire il commit della strong

Come risolvono il problema. La basic impone che l'eventuale eccezione trovi la funzione in grado di rilasciare tutto (catch (...) { delete[] p; throw; }), quindi niente leak, ma il codice si duplica e diventa pesante a ogni punto che può lanciare. La soluzione generale è RAII: l'acquisizione nel costruttore e il rilascio nel distruttore di un oggetto locale (vector, unique_ptr) fanno sì che lo stack unwinding chiami da solo i distruttori all'uscita dallo scope, anche per eccezione (con_raii): la basic guarantee senza try/catch. La strong si ottiene separando la fase che può fallire (su una copia) dal commit (operazioni no-throw come swap).

Versione ripasso

  • Quiz (risposte V/F): 1) errore di compilazione F; leak V; dangling F; allocazione dinamica V. 2) deep copy non con derivate F; solo membri pubblici F; più lenta V; definire copia ed assegnamento V. 3) scope globale F; durata = esecuzione di f V; va deallocata F; keyword auto F. 4) creato in compilazione F; impilato V; una sola copia per processo F; dimensione dipende dalla funzione V. 5) funzione non definita V; variabile non inizializzata F; puntatore non inizializzato F; header non incluso F (errore di compilazione). 6) built-in F; unari in binari F; operator+ cambia numero di argomenti V; operator<< non cambia F. 7) [] F; dereferenziabili V; deallocano F; classi nei container V. 8) locale automatico V; statico a fine scope F; argomento int F; delete V. 9) riferimento spostabile F; puntatore a niente V; riferimento a niente F; copia tra riferimenti = copia dei riferiti V. 10) literal V; definizione F; due operandi V; un operatore V.
  • Polimorfismo: associa comportamenti diversi a una notazione. Dinamico = ereditarietà + virtuali, vincolato dalla base, run-time, collezioni eterogenee. Statico = template, svincolato (stessa sintassi), compile-time, una funzione per tipo. Esempi: fai_parlare(const Animale&) con Cane/Gatto → bau miao; fai_rumore<T> con Auto/Barca → brum tut.
a2t/polimorfismo_due.cppcpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;

// ---------- POLIMORFISMO DINAMICO: ereditarieta' + funzioni virtuali ----------
class Animale {
public:
    virtual string verso() const = 0;                 // interfaccia comune
    virtual ~Animale() {}
};
class Cane : public Animale { public: string verso() const override { return "bau"; } };
class Gatto : public Animale { public: string verso() const override { return "miao"; } };

void fai_parlare(const Animale& a) { cout << a.verso() << ' '; }       // una sola funzione, decisione a RUN-TIME

// ---------- POLIMORFISMO STATICO: template ----------
struct Auto { string verso() const { return "brum"; } };            // nessuna base comune
struct Barca { string verso() const { return "tut"; } };

template <typename T>
void fai_rumore(const T& x) { cout << x.verso() << ' '; }               // una funzione per tipo, decisione a COMPILE-TIME

int main()
{
    Cane c;
    Gatto g;
    vector<const Animale*> zoo{&c, &g};                // collezione eterogenea: possibile solo con il dinamico
    for (const Animale* a : zoo) fai_parlare(*a);      // bau miao
    cout << '\n';

    Auto a;
    Barca b;
    fai_rumore(a);                                     // fai_rumore<Auto>
    fai_rumore(b);                                     // fai_rumore<Barca>
    cout << '\n';
}
a2t/eccezione_memoria.cppcpp
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace std;

// 1) PROBLEMA: se v.at(x) lancia, la delete[] non viene eseguita e la memoria e' persa
void con_problema(int s, int x)
{
    int* p = new int[s];
    vector<int> v;
    p[0] = v.at(x);                    // lancia out_of_range (v e' vuoto)
    delete[] p;                        // non raggiunta
}

// 2) BASIC GUARANTEE con try/catch: libera e rilancia
void con_basic(int s, int x)
{
    int* p = new int[s];
    vector<int> v;
    try {
        p[0] = v.at(x);
    } catch (...) {
        delete[] p;                    // nessuna perdita
        throw;                         // l'eccezione continua verso chi sa gestirla
    }
    delete[] p;
}

// 3) RAII: nessun try/catch e nessuna delete, ci pensa il distruttore di vector
void con_raii(int s, int x)
{
    vector<int> p(static_cast<size_t>(s));
    vector<int> v;
    p[0] = v.at(x);
}

// 4) STRONG GUARANTEE: lavoro su una copia, poi commit con operazioni che non lanciano
void aggiungi_due(vector<int>& dati, int a, int b)
{
    vector<int> copia = dati;          // se lancia, dati e' intatto
    copia.push_back(a);                // puo' lanciare (bad_alloc): sempre sulla copia
    copia.push_back(b);
    dati.swap(copia);                  // swap non lancia: commit
}

int main()
{
    for (auto f : {con_problema, con_basic, con_raii}) {
        try { f(10, 3); } catch (const exception& e) { cout << "catch: " << e.what() << '\n'; }
    }
    vector<int> d{1, 2};
    aggiungi_due(d, 3, 4);
    cout << d.size() << '\n';
}

Teoria collegata