Salta al contenuto
Note per Studenti Esercizio - Classe Image con buffer dinamico (terzo appello 2023-24, programmazione)

Esercizio - Classe Image con buffer dinamico (terzo appello 2023-24, programmazione)

Esame
In questa pagina 3

Testo (60 minuti, su carta). Progettare e implementare la classe Image che ha l'obiettivo di gestire immagini. Ciascun oggetto gestisce una sola immagine, la cui larghezza e altezza sono salvate nei dati membro w e h. Ciascun pixel è rappresentato da un intero, e il buffer di pixel è allocato dinamicamente e gestito tramite il puntatore data, anch'esso membro della classe. Un invariante della classe è che la dimensione del buffer è sempre uguale a w*h. Image ha un costruttore con gli argomenti width (larghezza, valore di default 100) e height (altezza, valore di default 100). Funzioni membro: set (scrive il valore passato per argomento in tutti i pixel dell'immagine); resize (cambia le dimensioni w e h aggiornando la dimensione del buffer; se la dimensione aumenta, i nuovi pixel hanno lo stesso valore di quelli esistenti). Image possiede inoltre costruttore e assegnamento di copia e spostamento, e tutte le funzioni necessarie per il suo buon funzionamento.

Teoria: C++ - classi, membri e invariantiuna classe è un tipo definito dal programmatore che rappresenta un concetto con una rappresentazione (dati membro) e delle operazioni (funzioni membro); l'interfaccia pubblica separa chi usa la classe da chi la implementa e i dati privati sono modificabili solo tramite l'interfaccia; struct è una class con membri pubblici di default; l'invariante è la regola che definisce gli stati validi, e il costruttore deve stabilirlo e ogni funzione membro conservarlo; il costruttore (stesso nome della classe) inizializza con una lista di inizializzazione e, se i dati non sono validi, lancia un'eccezione, l'unico modo per far fallire un costruttore; le funzioni membro si definiscono fuori dalla classe (solo le brevissime dentro, dove sono inline); le funzioni che non modificano l'oggetto sono dichiarate const; le funzioni che si possono scrivere con la sola interfaccia pubblica (operator==, next_sunday) sono helper function esterne, spesso nello stesso namespace.C++ - classi, membri e invarianti →, 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 →.


1. Lettura del testo

frase del testo conseguenza
"data puntatore a un buffer allocato dinamicamente" la classe possiede memoria: servono le cinque operazioni (distruttore, copia e spostamento, C++ - buona interfaccia e progetti su più fileUna classe C++ ben progettata decide esplicitamente le sette operazioni essenziali (costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore e assegnamento di spostamento, distruttore): se serve il distruttore perché si possiede una risorsa, servono quasi sempre anche copia e spostamento; un costruttore a un argomento definisce una conversione implicita, che si blocca con explicit; per capire quando i costruttori e il distruttore vengono chiamati si traccia una struct che stampa ogni chiamata. Un progetto su più file separa interfaccia (header .h con definizioni di classe e dichiarazioni, protetto da include guard) e implementazione (.cpp, compilati uno per uno, mai inclusi); al compilatore si passano solo i .cpp, il main sta in un file a sé; gli errori di compilazione (sintassi, tipi, dichiarazioni mancanti) vengono dal compilatore, quelli di linking (definizione mancante, due main, nessun main) dal linker.C++ - buona interfaccia e progetti su più file →)
invariante: "buffer di w*h elementi" ogni funzione che cambia w, h o il buffer deve lasciare vera questa uguaglianza: resize va scritta con cura
costruttore con argomenti di default 100 e 100 un solo costruttore Image(int width = 100, int height = 100) copre i tre usi Image(), Image(50), Image(50, 80); i valori di default stanno nella dichiarazione (nell'header)
set(value) std::fill su tutto il buffer
resize: "i nuovi pixel hanno lo stesso valore di quelli esistenti" frase ambigua

Come interpretare resize

La frase "se la dimensione aumenta, i nuovi pixel hanno lo stesso valore di quelli esistenti" ha almeno due letture ragionevoli: (a) i pixel nuovi copiano il valore dei pixel esistenti, in particolare quelli di bordo (la lettura più naturale per un'immagine, ed è quella adottata); (b) in un'immagine uniforme (dopo set) i nuovi pixel valgono lo stesso valore. La scelta (a) soddisfa anche (b). Dettagli scelti e dichiarati nel commento dell'header:

  • le coordinate dei pixel si conservano (il pixel (x, y) resta (x, y)): per allargare o allungare l'immagine non basta ricopiare il buffer lineare, perché un cambio di larghezza sposta le righe;
  • un pixel nuovo (x, y) copia il pixel vecchio più vicino: coordinate bloccate al bordo (min(x, w-1), min(y, h-1));
  • se le dimensioni diminuiscono si tiene l'angolo in alto a sinistra;
  • dimensioni non positive: eccezione std::invalid_argument (nessun'immagine ha lato nullo); l'unico stato 0 x 0 è quello di un oggetto spostato, valido e distruggibile.

Le cinque operazioni

  • Costruttore: controlla le dimensioni, poi new int[w*h]{} (le {} azzerano i pixel).
  • Copia: nuovo buffer della stessa dimensione e std::copy (copia profonda); un oggetto spostato (data_ == nullptr) si copia come nullptr.
  • Assegnamento di copia: copy-and-swap (copia locale, poi swap che non lancia): corretto anche per l'auto-assegnamento e con garanzia forte.
  • Spostamento: costruttore e assegnamento fanno swap con la sorgente, che resta 0 x 0.
  • Distruttore: delete[] data_.

resize usa lo stesso schema: crea un'immagine temporanea con le nuove dimensioni (se new fallisce l'oggetto non cambia), la riempie leggendo da *this, poi swap; il vecchio buffer muore con il temporaneo. Il prodotto w * h è calcolato come size_t per non andare in overflow con int grandi.

2. Il codice

image/include/image.hcpp
#ifndef IMAGE_H
#define IMAGE_H

// Immagine di w x h pixel, ciascuno un int, memorizzati per righe in un buffer allocato dinamicamente.
// Invarianti: il buffer ha esattamente w * h elementi; data_ e' nullptr se e solo se w == 0 (e h == 0),
// stato che si ha solo in un oggetto spostato (ogni costruzione normale richiede dimensioni positive).
class Image {
public:
    Image(int width = 100, int height = 100);          // dimensioni di default 100 x 100; lancia se <= 0
    Image(const Image& other);                         // copia profonda
    Image& operator=(const Image& other);
    Image(Image&& other) noexcept;                     // spostamento: other diventa 0 x 0
    Image& operator=(Image&& other) noexcept;
    ~Image();

    void set(int value);                               // scrive value in tutti i pixel
    // Cambia le dimensioni. Se aumentano, i pixel nuovi copiano il pixel esistente piu' vicino
    // (le coordinate fuori dall'immagine vecchia sono "bloccate" al bordo): in un'immagine uniforme
    // restano tutti uguali. Se diminuiscono, si tiene l'angolo in alto a sinistra.
    void resize(int new_width, int new_height);

    int width() const { return w_; }
    int height() const { return h_; }
    int& at(int x, int y) { return data_[y * w_ + x]; }              // x = colonna, y = riga
    int at(int x, int y) const { return data_[y * w_ + x]; }

private:
    void swap(Image& other) noexcept;
    int w_;
    int h_;
    int* data_;
};

#endif // IMAGE_H
image/src/image.cppcpp
#include "image.h"
#include <algorithm>
#include <stdexcept>
#include <utility>

Image::Image(int width, int height) : w_{width}, h_{height}, data_{nullptr}
{
    if (width <= 0 || height <= 0) throw std::invalid_argument("Image: dimensioni non positive");
    data_ = new int[static_cast<std::size_t>(w_) * h_]{};         // pixel inizializzati a 0
}

Image::Image(const Image& other)
    : w_{other.w_}, h_{other.h_}, data_{other.data_ ? new int[static_cast<std::size_t>(other.w_) * other.h_] : nullptr}
{
    std::copy(other.data_, other.data_ + static_cast<std::size_t>(w_) * h_, data_);
}

Image& Image::operator=(const Image& other)
{
    Image copy{other};                  // puo' lanciare: *this e' ancora intatto
    swap(copy);                         // commit senza eccezioni; la vecchia memoria la libera copy
    return *this;
}

Image::Image(Image&& other) noexcept : w_{0}, h_{0}, data_{nullptr}
{
    swap(other);                        // other resta 0 x 0, valido e distruggibile
}

Image& Image::operator=(Image&& other) noexcept
{
    swap(other);
    return *this;
}

Image::~Image() { delete[] data_; }

void Image::set(int value)
{
    std::fill(data_, data_ + static_cast<std::size_t>(w_) * h_, value);
}

void Image::resize(int new_width, int new_height)
{
    if (new_width <= 0 || new_height <= 0) throw std::invalid_argument("Image::resize: dimensioni non positive");
    Image next{new_width, new_height};                              // buffer nuovo: se new fallisce, nulla cambia
    for (int y = 0; y < new_height; ++y)
        for (int x = 0; x < new_width; ++x)
            next.at(x, y) = at(std::min(x, w_ - 1), std::min(y, h_ - 1));   // coordinate bloccate al bordo
    swap(next);                                                    // il vecchio buffer passa a next e muore con lui
}

void Image::swap(Image& other) noexcept
{
    std::swap(w_, other.w_);
    std::swap(h_, other.h_);
    std::swap(data_, other.data_);
}
image/src/main.cppcpp
#include <iostream>
#include <utility>
#include "image.h"

namespace {
void print(const char* title, const Image& im)
{
    std::cout << title << " (" << im.width() << "x" << im.height() << ")\n";
    for (int y = 0; y < im.height(); ++y) {
        std::cout << "  ";
        for (int x = 0; x < im.width(); ++x) std::cout << im.at(x, y) << (x + 1 < im.width() ? ' ' : '\n');
    }
}
}

int main()
{
    Image def;                                              // costruttore con argomenti di default
    std::cout << "default: " << def.width() << "x" << def.height() << '\n';
    def.set(7);
    def.resize(150, 120);                                   // immagine uniforme: i pixel nuovi valgono ancora 7
    std::cout << "dopo resize: " << def.width() << "x" << def.height()
              << ", pixel(149,119) = " << def.at(149, 119) << ", pixel(0,0) = " << def.at(0, 0) << '\n';

    Image im{3, 2};
    for (int y = 0; y < 2; ++y)
        for (int x = 0; x < 3; ++x) im.at(x, y) = x + 10 * y;
    print("3x2", im);
    im.resize(5, 3);                                        // allarga: i pixel nuovi copiano il bordo
    print("resize 5x3", im);
    im.resize(2, 1);                                        // restringe: resta l'angolo in alto a sinistra
    print("resize 2x1", im);

    Image a{2, 2};
    a.set(1);
    Image b = a;                                            // copia: buffer separati
    a.set(9);
    std::cout << "copia: a(0,0) = " << a.at(0, 0) << ", b(0,0) = " << b.at(0, 0) << '\n';
    b = a;                                                  // assegnamento di copia
    std::cout << "dopo b = a: b(1,1) = " << b.at(1, 1) << '\n';
    Image& same = b;
    b = same;                                               // auto-assegnamento innocuo
    Image c = std::move(a);                                 // spostamento: a diventa 0x0
    std::cout << "spostata: c = " << c.width() << "x" << c.height() << ", a = " << a.width() << "x" << a.height() << '\n';
    a = std::move(c);                                       // assegnamento di spostamento
    std::cout << "a = " << a.width() << "x" << a.height() << ", c = " << c.width() << "x" << c.height() << '\n';
}

Uscita eseguita (con la throw sostituita da un abort solo per la prova in WebAssembly, che non supporta le eccezioni; i percorsi di errore non vengono mai percorsi; compilato anche per Linux con -Wall -Wextra senza avvisi):

default: 100x100
dopo resize: 150x120, pixel(149,119) = 7, pixel(0,0) = 7
3x2 (3x2)
  0 1 2
  10 11 12
resize 5x3 (5x3)
  0 1 2 2 2
  10 11 12 12 12
  10 11 12 12 12
resize 2x1 (2x1)
  0 1
copia: a(0,0) = 9, b(0,0) = 1
dopo b = a: b(1,1) = 9
spostata: c = 2x2, a = 0x0
a = 2x2, c = 0x0

Lettura: dopo set(7) e resize(150, 120) i pixel nuovi valgono ancora 7 (immagine uniforme). L'immagine 3x2 con pixel x + 10y allargata a 5x3 ripete l'ultima colonna (2 e 12) e l'ultima riga: è il blocco al bordo; ridotta a 2x1 tiene 0 1. La copia ha un buffer proprio (a cambia, b no); dopo lo spostamento a è 0x0 e c ha le dimensioni di prima; l'assegnamento di spostamento restituisce i dati ad a.

3. Errori da evitare

  • Rompere l'invariante: cambiare w e h prima di avere allocato il nuovo buffer (se new fallisce l'oggetto resta incoerente).
  • Copia superficiale (puntatore copiato): doppia delete[].
  • delete data_ senza [].
  • Argomenti di default nella definizione del .cpp invece che nella dichiarazione.
  • resize che ricopia il buffer lineare: le immagini si deformano quando cambia la larghezza.
  • Spostare lasciando la sorgente con lo stesso puntatore (due proprietari, doppia delete[]): la sorgente va azzerata, qui lo fa lo swap con un oggetto vuoto.
  • w * h calcolato in int.

Versione ripasso

image/src/image.cppcpp
#include "image.h"
#include <algorithm>
#include <stdexcept>
#include <utility>

Image::Image(int width, int height) : w_{width}, h_{height}, data_{nullptr}
{
    if (width <= 0 || height <= 0) throw std::invalid_argument("Image: dimensioni non positive");
    data_ = new int[static_cast<std::size_t>(w_) * h_]{};         // pixel inizializzati a 0
}

Image::Image(const Image& other)
    : w_{other.w_}, h_{other.h_}, data_{other.data_ ? new int[static_cast<std::size_t>(other.w_) * other.h_] : nullptr}
{
    std::copy(other.data_, other.data_ + static_cast<std::size_t>(w_) * h_, data_);
}

Image& Image::operator=(const Image& other)
{
    Image copy{other};                  // puo' lanciare: *this e' ancora intatto
    swap(copy);                         // commit senza eccezioni; la vecchia memoria la libera copy
    return *this;
}

Image::Image(Image&& other) noexcept : w_{0}, h_{0}, data_{nullptr}
{
    swap(other);                        // other resta 0 x 0, valido e distruggibile
}

Image& Image::operator=(Image&& other) noexcept
{
    swap(other);
    return *this;
}

Image::~Image() { delete[] data_; }

void Image::set(int value)
{
    std::fill(data_, data_ + static_cast<std::size_t>(w_) * h_, value);
}

void Image::resize(int new_width, int new_height)
{
    if (new_width <= 0 || new_height <= 0) throw std::invalid_argument("Image::resize: dimensioni non positive");
    Image next{new_width, new_height};                              // buffer nuovo: se new fallisce, nulla cambia
    for (int y = 0; y < new_height; ++y)
        for (int x = 0; x < new_width; ++x)
            next.at(x, y) = at(std::min(x, w_ - 1), std::min(y, h_ - 1));   // coordinate bloccate al bordo
    swap(next);                                                    // il vecchio buffer passa a next e muore con lui
}

void Image::swap(Image& other) noexcept
{
    std::swap(w_, other.w_);
    std::swap(h_, other.h_);
    std::swap(data_, other.data_);
}
  • Uscita: default: 100x100; dopo set(7) e resize(150, 120) pixel (149,119) = 7; 3x2 (0 1 2 / 10 11 12) allargata a 5x3 ripete l'ultima colonna e riga; ridotta a 2x1 resta 0 1; copia con buffer separato; spostata a = 0x0.
  • Errori tipici: invariante rotto da resize, copia superficiale, delete senza [], default nel .cpp, buffer lineare ricopiato, w * h in int.

Esercizi su questo argomento

Teoria collegata