Salta al contenuto
Note per Studenti Esercizio - Distruttore, copia e spostamento della classe Elements (secondo appello 2021-22, programmazione)

Esercizio - Distruttore, copia e spostamento della classe Elements (secondo appello 2021-22, programmazione)

Esame
In questa pagina 3

Prova di programmazione (60 minuti) del secondo appello 2021-22 (11 febbraio 2022) del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: 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 →, 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 →.

Testo. Si consideri la classe:

#include <vector>
class Elements {
public:
    Elements() : buffer(new double[kLength]) {}
private:
    int i = 0;
    double* buffer = nullptr;
    std::vector<int> values;
    static constexpr int kLength = 300;
};

Implementare: a) il distruttore; b) il costruttore di copia e quello di spostamento, l'assegnamento di copia e quello di spostamento. In relazione ad essi: c) motivare le differenze di implementazione tra i due costruttori; d) mostrare un esempio di codice che causa la chiamata dell'assegnamento di copia, e un altro esempio che causa la chiamata dell'assegnamento di spostamento, spiegando il perché.

Analisi dei membri

Per ogni membro bisogna dire che cosa fa la copia (o lo spostamento) di default:

membro tipo copia di default spostamento di default basta?
i int copia il valore copia il valore sì
values std::vector<int> copia gli elementi (profonda) sposta il buffer interno sì
buffer double* copia l'indirizzo (shallow) copia l'indirizzo, la sorgente continua a puntare no
kLength static constexpr non è un dato dell'oggetto idem non conta

Il problema è buffer: l'unico puntatore nudo, che possiede memoria dinamica (300 double). Per questo la classe ha bisogno del distruttore e delle quattro operazioni, la regola del cinque (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 →).

Soluzione

elements.cppcpp
#include <algorithm>
#include <iostream>
#include <utility>
#include <vector>
using namespace std;

class Elements {
public:
    Elements() : buffer(new double[kLength]) {}     // costruttore dato dal testo (il buffer non e' inizializzato)

    // a) distruttore: libera l'unica risorsa "nuda", il buffer; il vector libera da solo i suoi dati
    ~Elements() { delete[] buffer; }

    // b1) costruttore di copia: deep copy
    Elements(const Elements& other)
        : i(other.i),
          buffer(new double[kLength]),               // NUOVO buffer: non si copia il puntatore
          values(other.values)                       // vector: la sua copia e' gia' profonda
    {
        copy(other.buffer, other.buffer + kLength, buffer);
    }

    // b2) costruttore di spostamento: ruba il buffer
    Elements(Elements&& other) noexcept
        : i(other.i),
          buffer(other.buffer),                      // si prende il puntatore...
          values(std::move(other.values))            // ...e i dati del vector
    {
        other.buffer = nullptr;                      // la sorgente non possiede piu' nulla (delete[] nullptr e' innocuo)
        other.i = 0;
    }

    // b3) assegnamento di copia
    Elements& operator=(const Elements& other)
    {
        if (this != &other) {                        // auto-assegnamento: niente da fare
            double* nuovo = new double[kLength];     // 1. alloca
            copy(other.buffer, other.buffer + kLength, nuovo);   // 2. copia
            delete[] buffer;                         // 3. libera il vecchio
            buffer = nuovo;                          // 4. sostituisce
            i = other.i;
            values = other.values;
        }
        return *this;
    }

    // b4) assegnamento di spostamento
    Elements& operator=(Elements&& other) noexcept
    {
        if (this != &other) {
            delete[] buffer;                         // libera il proprio buffer
            buffer = other.buffer;                   // ruba quello della sorgente
            other.buffer = nullptr;
            i = other.i;
            other.i = 0;
            values = std::move(other.values);
        }
        return *this;
    }

    // --- funzioni aggiunte soltanto per provare la classe ---
    void set(int k, double x) { buffer[k] = x; }
    double get(int k) const { return buffer[k]; }
    void add(int v) { values.push_back(v); }
    size_t n_values() const { return values.size(); }
    bool has_buffer() const { return buffer != nullptr; }

private:
    int i = 0;
    double* buffer = nullptr;
    std::vector<int> values;
    static constexpr int kLength = 300;
};

int main()
{
    Elements a;
    a.set(0, 1.5);
    a.add(10); a.add(20);

    Elements b = a;                                  // costruttore di copia
    b.set(0, 9.9);
    cout << "dopo la copia:   a.get(0) = " << a.get(0) << ", b.get(0) = " << b.get(0)
         << ", a.n_values = " << a.n_values() << ", b.n_values = " << b.n_values() << '\n';

    Elements c = std::move(a);                       // costruttore di spostamento
    cout << "dopo lo spostamento: a.has_buffer = " << a.has_buffer() << ", a.n_values = " << a.n_values()
         << ", c.get(0) = " << c.get(0) << ", c.n_values = " << c.n_values() << '\n';

    Elements d;
    d = b;                                           // assegnamento di copia (d esiste gia')
    d.set(0, 7.7);
    cout << "dopo assegnamento di copia: b.get(0) = " << b.get(0) << ", d.get(0) = " << d.get(0) << '\n';

    Elements e;
    e = Elements();                                  // assegnamento di spostamento (la sorgente e' un temporaneo)
    e = std::move(d);                                // assegnamento di spostamento (esplicito)
    cout << "dopo assegnamento di spostamento: d.has_buffer = " << d.has_buffer() << ", e.get(0) = " << e.get(0) << '\n';
    return 0;
}
dopo la copia:   a.get(0) = 1.5, b.get(0) = 9.9, a.n_values = 2, b.n_values = 2
dopo lo spostamento: a.has_buffer = 0, a.n_values = 0, c.get(0) = 1.5, c.n_values = 2
dopo assegnamento di copia: b.get(0) = 9.9, d.get(0) = 7.7
dopo assegnamento di spostamento: d.has_buffer = 0, e.get(0) = 7.7

a) Distruttore. delete[] buffer;: è new[], quindi delete[]. Non si tocca values: il suo distruttore è chiamato dal compilatore. Funziona anche con buffer == nullptr (oggetto spostato): delete[] nullptr non fa nulla.

b) Le quattro operazioni. Il costruttore e l'assegnamento di copia fanno una deep copy: nuovo buffer di kLength double, copia degli elementi con std::copy, e values si copia con la sua copia. L'assegnamento in più deve liberare il vecchio buffer e gestire l'auto-assegnamento; alloca e copia prima di liberare, così se new fallisce l'oggetto resta intatto. Il costruttore e l'assegnamento di spostamento rubano: copiano il puntatore, mettono other.buffer = nullptr e spostano values con std::move. Sono noexcept (permettono ai contenitori di preferire lo spostamento).

c) Differenze fra i due costruttori.

costruttore di copia costruttore di spostamento
parametro const Elements& Elements&& (rvalue reference, non const: la sorgente si modifica)
buffer alloca un nuovo buffer e copia i 300 double prende il puntatore della sorgente, senza allocare né copiare
sorgente dopo invariata svuotata: buffer = nullptr, i = 0, values vuoto
costo allocazione + copia di 300 double + copia di values pochi assegnamenti di puntatori, O(1)O(1)
values values(other.values) values(std::move(other.values))
noexcept non necessario sì

Se lo spostamento copiasse soltanto il puntatore senza azzerare la sorgente, due oggetti avrebbero lo stesso buffer e il distruttore farebbe la delete[] due volte.

d) Chiamate dell'assegnamento. L'assegnamento si usa quando l'oggetto a sinistra esiste già (altrimenti è un costruttore).

cpp
Elements d;
d = b;                // b è un lvalue (ha un nome e sopravvive): ASSEGNAMENTO DI COPIA
Elements e;
e = Elements();       // il sorgente è un temporaneo (rvalue): ASSEGNAMENTO DI SPOSTAMENTO
e = std::move(d);     // std::move trasforma d in rvalue: ASSEGNAMENTO DI SPOSTAMENTO

Perché: il compilatore sceglie l'overload in base alla categoria di valore dell'operando destro: un lvalue (b) si lega a const Elements&, quindi copia; un rvalue (un temporaneo, o un lvalue dichiarato "spostabile" con std::move) si lega a Elements&&, quindi sposta. Con std::move(d) il programmatore promette di non usare più il contenuto di d: nel test d.has_buffer = 0 dopo lo spostamento.

Osservazioni

  • Il costruttore dato lascia i 300 double non inizializzati (new double[kLength] senza ()): la copia legge valori indeterminati, formalmente comportamento indefinito per i double; nella pratica non ha effetti. Basterebbe new double[kLength]() per azzerarli.
  • Con il costruttore di spostamento, la sorgente resta in uno stato valido ma vuoto: si può distruggere o riassegnare, non usarne i dati.
  • Le funzioni set, get, add, n_values, has_buffer sono aggiunte soltanto per provare la classe: l'esame richiede solo le cinque funzioni.

Versione ripasso

  • Analisi dei membri: i (int) e values (vector<int>) vanno bene di default (il vector copia profondo / sposta il buffer); buffer (double*) copiato di default come indirizzo: shallow ⇒\Rightarrow servono le cinque funzioni. kLength è static.
  • a) Distruttore: ~Elements() { delete[] buffer; } (new[] →\to delete[]; nullptr innocuo).
  • b) Copia (deep):
cpp
Elements(const Elements& o) : i(o.i), buffer(new double[kLength]), values(o.values)
{ copy(o.buffer, o.buffer + kLength, buffer); }
Elements& operator=(const Elements& o)
{ if (this != &o) { double* n = new double[kLength]; copy(o.buffer, o.buffer + kLength, n);
                    delete[] buffer; buffer = n; i = o.i; values = o.values; }
  return *this; }

Spostamento (rubare):

cpp
Elements(Elements&& o) noexcept : i(o.i), buffer(o.buffer), values(std::move(o.values))
{ o.buffer = nullptr; o.i = 0; }
Elements& operator=(Elements&& o) noexcept
{ if (this != &o) { delete[] buffer; buffer = o.buffer; o.buffer = nullptr;
                    i = o.i; o.i = 0; values = std::move(o.values); }
  return *this; }
  • c) Differenze: copia = const Elements&, alloca e copia 300 double, sorgente invariata, costo alto; spostamento = Elements&& (non const), prende il puntatore e annulla la sorgente (nullptr), costo O(1)O(1), noexcept. Senza o.buffer = nullptr doppia delete[].
  • d) Esempi: Elements d; d = b; (lvalue →\to copia); e = Elements(); (temporaneo →\to spostamento); e = std::move(d); (move rende d un rvalue →\to spostamento). Il compilatore sceglie l'overload dalla categoria di valore dell'operando destro.
  • Prova: dopo la copia a.get(0) = 1.5, b.get(0) = 9.9; dopo lo spostamento a.has_buffer = 0, c.get(0) = 1.5, c.n_values = 2; d.has_buffer = 0 dopo std::move(d).
  • Errori tipici: delete al posto di delete[]; non azzerare other.buffer nello spostamento; copiare il puntatore nella copia; assegnamento che libera prima di copiare; dimenticare values e i.
  • Osservazione sul testo: Elements() : buffer(new double[kLength]) {} lascia i 300 double non inizializzati (formalmente la copia legge valori indeterminati): new double[kLength]() li azzererebbe. Dopo lo spostamento la sorgente è valida ma vuota: buffer == nullptr, values vuoto in pratica.
  • Perché noexcept: std::vector<Elements> sposta gli elementi in riallocazione solo se lo spostamento non può lanciare.

Esercizi su questo argomento

Teoria collegata