Programmazione Scientifica++: Lezione 7

Incapsulamento, Getter/Setter e Costruttori

Cristiano De Michele

Argomenti di oggi

  • Richiamo della Lezione 06: classi, public e private, costruttori
  • Perché i dati privati: un’interfaccia stabile
  • Getter e setter
  • La lista di inizializzazione e il valore iniziale nella dichiarazione
  • Più costruttori nella stessa classe; il costruttore generato dal compilatore
  • Il costruttore di copia, l’assegnazione e la conversione
  • Esercizio finale: i layer del framework

La divisione delle classi in header e file sorgente, con la compilazione separata, è l’argomento della Lezione 08, insieme a stack e heap.

Richiamo della Lezione 06

  • Una classe riunisce attributi e metodi. I metodi accedono a tutti i membri; dall’esterno si accede solo a quelli public.
  • I dati si tengono private e si modificano con metodi pubblici che li verificano, come imposta() della classe Temperatura.
  • Il costruttore ha il nome della classe, non ha tipo di ritorno e viene eseguito quando l’oggetto viene creato; può ricevere parametri.
  • Se la classe ha solo un costruttore con parametri, Result r1; non compila: no matching function for call to 'Result::Result()'.

Oggi si vede perché conviene questa organizzazione e che cosa avviene esattamente quando un oggetto viene creato o copiato.

Perché i dati privati: un’interfaccia stabile

Chi usa una classe dovrebbe dipendere solo dalla sua interfaccia pubblica, non dai dettagli interni. Un attributo pubblico ne fa parte: se viene rinominato, il codice che lo usa smette di compilare.

La classe, prima e dopo la modifica:

class Neurone {   // prima
public:
    double peso;
};
class Neurone {   // dopo
public:
    double peso_sinaptico;
};

Il codice che la usa:

int main() {
    Neurone n1;
1    n1.peso = 1.12;
    return 0;
}
1
Compila con la prima versione della classe; con la seconda: error: 'class Neurone' has no member named 'peso'.

Con l’attributo privato la modifica resta confinata nella classe: è l’incapsulamento.

Getter e setter

L’accesso ai dati privati passa per metodi pubblici: un getter restituisce il valore di un attributo, un setter lo modifica.

#include <iostream>

class Neurone {
private:
1    double peso_;

public:
2    double getPeso() {
        return peso_;
    }
3    void setPeso(double p) {
        peso_ = p;
    }
};
1
Attributo privato, con il suffisso _ (Lezione 06).
2
Getter: restituisce il valore.
3
Setter: assegna il valore.
int main() {
    Neurone n1;
    n1.setPeso(1.12);
    std::cout << n1.getPeso();
    return 0;
}

Se peso_ cambia nome o rappresentazione, si aggiornano solo i due metodi: il main resta invariato.

I prefissi get e set sono una convenzione, non una regola del linguaggio.

Setter che verificano i dati

Come imposta() di Temperatura (Lezione 06), un setter può rifiutare i valori non validi.

#include <iostream>

class Persona {
private:
    int eta_;

public:
    void setEta(int nuova_eta) {
1        if (nuova_eta >= 0 && nuova_eta < 150) {
            eta_ = nuova_eta;
        } else {
2            std::cerr << "Errore: età non valida\n";
        }
    }
    int getEta() { return eta_; }
};
1
Controllo di validità: un valore fuori intervallo non viene assegnato.
2
Per ora un messaggio su std::cerr. Le eccezioni, che permettono al chiamante di gestire l’errore, sono l’argomento della Lezione 19.

La lista di inizializzazione

Il costruttore può inizializzare gli attributi prima di eseguire il proprio corpo, con la lista di inizializzazione:

class Neurone {
private:
    double peso_;
    double lr_;
public:
1    Neurone(double peso, double lr) : peso_(peso), lr_(lr) { }
};
1
Dopo i : ogni attributo riceve il valore iniziale, nella forma nome(valore); qui il corpo { } è vuoto.

Il suffisso _ distingue l’attributo dal parametro, peso_(peso): è una convenzione, non una regola. Il framework non la usa e sceglie nomi diversi: DenseLayer(int Nn, int Ni) : Nnodes(Nn), Nin(Ni).

Gli attributi vengono inizializzati nell’ordine in cui sono dichiarati nella classe, non in quello della lista; se i due ordini differiscono, -Wall lo segnala (-Wreorder).

Più costruttori nella stessa classe

Come per le funzioni (Lezione 05), si può fare l’overloading dei costruttori: la scelta dipende dagli argomenti.

class Datum {
private:
    double value_;
public:
    Datum() : value_(0.0) { }
    Datum(double v) : value_(v) { }
};
int main() {
1    Datum d1;
2    Datum d2(5.5);
}
1
Usa il costruttore di default, senza parametri (riga 5): value_ vale 0.0.
2
Usa il costruttore con un parametro (riga 6): value_ vale 5.5.

Il costruttore della riga 6, con un solo parametro, è anche un costruttore di conversione da double a Datum. La classe non dichiara invece un costruttore di copia, che crea un oggetto da un altro: lo genera il compilatore. Li vediamo più avanti in questa lezione.

Lista di inizializzazione o assegnazione nel corpo

  • Per un double è indifferente usare la lista o assegnare nel corpo (peso_ = peso;, Lezione 06). Per un attributo che è un oggetto no: nel corpo viene prima creato con il costruttore di default e poi riceve il valore (esempio nella slide successiva).
  • Gli attributi const e i reference devono stare nella lista: dopo la creazione non si possono assegnare (nota sullo scheduler, Lezione 06).
class Sensore {
private:
1    const double offset_;
public:
2    Sensore(double o) : offset_(o) { }
};
1
Attributo costante: il valore si fissa alla creazione.
2
Con Sensore(double o) { offset_ = o; }: error: assignment of read-only member.

Approfondimento: attributi che sono oggetti

#include <iostream>

class Temperatura {
private:
    double kelvin_;
public:
    Temperatura() : kelvin_(273.15) {
        std::cout << "Temperatura()\n";
    }
    Temperatura(double k) : kelvin_(k) {
        std::cout << "Temperatura(double)\n";
    }
};
class CellaA {
    Temperatura t_;   // privato
public:
1    CellaA(double k) { t_ = Temperatura(k); }
};
class CellaB {
    Temperatura t_;   // privato
public:
2    CellaB(double k) : t_(k) { }
};
int main() {
    CellaA a(300.0);
    CellaB b(300.0);
}
1
Stampa Temperatura() e Temperatura(double): t_ è creato con il costruttore di default, poi riceve per assegnazione un temporaneo.
2
Stampa solo Temperatura(double): t_ è creato direttamente con il valore iniziale.

Valore iniziale nella dichiarazione

Dal C++11 un attributo può ricevere un valore iniziale direttamente nella dichiarazione:

class Neurone {
private:
1    double peso_ = 0.0;
    double lr_ = 0.1;
public:
2    Neurone() { }
3    Neurone(double lr) : lr_(lr) { }
};
1
Vale per attributi di qualunque tipo, anche oggetti.
2
Usa i valori della dichiarazione: peso_ vale 0 e lr_ vale 0.1.
3
La lista ha la precedenza: con Neurone n(0.5); si ha lr_ uguale a 0.5 e peso_ uguale a 0.

Anche il costruttore generato dal compilatore usa questi valori: un attributo con un valore nella dichiarazione non resta mai indeterminato. Il framework usa questa forma per alcuni attributi dei layer: la si riprenderà nei laboratori in cui vanno scritti.

Il costruttore generato dal compilatore

  • Se una classe non dichiara alcun costruttore, il compilatore ne genera uno di default, che non inizializza gli attributi dei tipi fondamentali (double, int, puntatori): il loro valore resta indeterminato.
  • Se la classe dichiara almeno un costruttore, quello di default non viene più generato. Con il solo Datum(double v), la riga Datum d1; dà error: no matching function for call to 'Datum::Datum()', come per Result nella Lezione 06. Se serve anche Datum d1;, il costruttore di default va scritto esplicitamente.
  • Anche un costruttore può essere private: l’uso tipico, il Singleton, è nella Lezione 13.

Il costruttore di copia

Il costruttore di copia crea un oggetto come copia di un altro oggetto della stessa classe:

class Datum {
private:
    double value_;
public:
    Datum() : value_(0.0) { }
    Datum(double v) : value_(v) { }
1    Datum(const Datum& altro) : value_(altro.value_) { }
};
1
Copia gli attributi di altro. Un metodo accede ai membri privati di qualunque oggetto della propria classe, quindi può leggere altro.value_.
  • Il parametro è un reference: passato per valore richiederebbe a sua volta una copia, cioè di nuovo il costruttore di copia. Il compilatore lo rifiuta: invalid constructor; you probably meant 'Datum (const Datum&)'.
  • È const perché l’originale viene soltanto letto (const reference, Lezione 05).

Copia e assegnazione

La copia alla creazione e l’assegnazione successiva sono operazioni distinte:

int main() {
    Datum d1(5.5);
1    Datum d2 = d1;
2    Datum d3(d1);
    Datum d4;
3    d4 = d1;
    return 0;
}
1
d2 viene creato come copia di d1: si chiama il costruttore di copia.
2
La stessa operazione, scritta come chiamata esplicita.
3
d4 esiste già: non si costruisce nulla, si chiama l’operatore di assegnazione, operator= (Lezione 10).

Se la classe non li dichiara, il compilatore genera sia il costruttore di copia sia l’operatore di assegnazione. Il primo copia ogni attributo, privati compresi, nel nuovo oggetto; il secondo assegna ogni attributo all’oggetto già esistente. Di un puntatore copiano l’indirizzo, non i dati: con la memoria dinamica (Lezione 08) è un problema, risolto dalla deep copy (Lezione 10).

Approfondimento: le conversioni

Un costruttore che si può chiamare con un solo argomento definisce anche una conversione: con Datum(double v) il compilatore sa ottenere un Datum da un double, e lo fa anche dove non è scritto.

void stampa(Datum d) { }

int main() {
1    Datum d1 = 2.0;
2    stampa(4.0);
3    d1 = 3.0;
    return 0;
}
1
Equivale a Datum d1(2.0);.
2
Dal double si costruisce un Datum temporaneo, che viene passato alla funzione.
3
Si costruisce un temporaneo con Datum(double) e lo si assegna a d1 con operator=.

Le conversioni implicite possono nascondere errori. Con explicit Datum(double v) le tre righe sopra non compilano; restano ammessi Datum d1(2.0); e static_cast<Datum>(2.0).

Esercizio veloce: il setter

  1. Scrivete una classe Neurone con due attributi privati di tipo double: peso_ e lr_ (il learning rate).
  2. I due getter, double getPeso() e double getLr().
  3. void setLr(double nuovo): accetta solo valori in \((0, 1]\); altrimenti rifiuta il valore, stampa il motivo su std::cerr e mantiene quello precedente.
  4. Due costruttori, con la lista di inizializzazione: quello di default (peso \(0\), lr \(0{,}1\)) e Neurone(double peso, double lr), che nella lista inizializza peso_(peso) e lr_(0.1), poi nel corpo chiama setLr(lr).
  5. Nel main: create Neurone n;, provate n.lr_ = 5.0; e leggete l’errore; chiamate n.setLr(5.0), n.setLr(0.05) e stampate n.getLr(). Poi create Neurone m(0.7, 99.0); e stampate m.getPeso(), m.getLr().
  6. Togliete lr_(0.1) dal secondo costruttore e rieseguite: che valore ha lr_ di m, e perché?

Soluzione dell’esercizio: la classe

#include <iostream>

class Neurone {
private:
    double peso_;
    double lr_;

public:
    Neurone() : peso_(0.0), lr_(0.1) {}

1    Neurone(double peso, double lr) : peso_(peso), lr_(0.1) { setLr(lr); }

    double getPeso() { return peso_; }
    double getLr()   { return lr_;   }

    void setLr(double nuovo) {
        if (nuovo > 0.0 && nuovo <= 1.0) {
            lr_ = nuovo;
        } else {
            std::cerr << "[setLr] rifiutato " << nuovo
2                      << ": deve stare in (0, 1]. Tengo " << lr_ << "\n";
        }
    }
};
1
lr_(0.1) inizializza lr_ prima della chiamata a setLr.
2
Se il setter rifiuta il valore, lr_ deve già contenere un valore valido.

Soluzione dell’esercizio: il main

int main() {
    Neurone n;
    // n.lr_ = 5.0;         // <- il compilatore lo rifiuta
    n.setLr(5.0);           // rifiutato in esecuzione, con un messaggio
    n.setLr(0.05);          // accettato
    std::cout << "n: lr " << n.getLr() << "\n";

    Neurone m(0.7, 99.0);   // il costruttore passa dal setter
    std::cout << "m: peso " << m.getPeso() << ", lr " << m.getLr() << "\n";
}
[setLr] rifiutato 5: deve stare in (0, 1]. Tengo 0.1
n: lr 0.05
[setLr] rifiutato 99: deve stare in (0, 1]. Tengo 0.1
m: peso 0.7, lr 0.1

Togliendo il commento a n.lr_ = 5.0;:

error: 'double Neurone::lr_' is private within this context

Soluzione: il punto 6

Se dalla lista di inizializzazione si toglie lr_(0.1), lasciando : peso_(peso) { setLr(lr); }, l’uscita diventa per esempio:

[setLr] rifiutato 99: deve stare in (0, 1]. Tengo 6.94378e-310
m: peso 0.7, lr 6.94378e-310

Il setter rifiuta 99 e mantiene il valore di lr_, che nessuno ha inizializzato: il valore è indeterminato e cambia da un sistema all’altro. L’oggetto resta in uno stato non valido.

Il compilatore non è una garanzia

g++ -Wall          -> nessun avviso
g++ -O2 -Wall      -> warning: 'm.Neurone::lr_' is used uninitialized
clang++ -Wall      -> nessun avviso, con o senza ottimizzazioni

Accorgersene richiede un’analisi del flusso del programma, che g++ (GNU) esegue solo quando ottimizza e clang++ in questo caso non fa. Sul Mac il comando g++ è in realtà clang++; il GNU g++ di Homebrew si chiama g++-15, g++-16. La difesa è inizializzare sempre gli attributi, nella lista o nella dichiarazione.

Esercizi lampo: cosa stampa?

Senza portatile.

1. Quante righe vengono stampate, e quali?

#include <iostream>

class D {
public:
    D()          { std::cout << "default\n"; }
    D(double)    { std::cout << "param\n"; }
    D(const D&)  { std::cout << "copia\n"; }
};

int main() {
    D a;
    D b = a;
    D c;
    c = a;
    c = 3.0;
}

2. La classe Persona della slide «Setter che verificano i dati» non ha costruttori.

Persona p;
p.setEta(200);          // l'età non supera il controllo
std::cout << p.getEta();

Esercizi lampo: le risposte

1. Quattro righe: default, copia, default, param. D b = a; crea b come copia di a. c = a; non stampa nulla: c esiste già, quindi non si chiama alcun costruttore ma l’operatore di assegnazione generato dal compilatore. c = 3.0; costruisce un D temporaneo con D(double), la conversione della slide «Approfondimento: le conversioni», e poi lo assegna a c.

2. Il valore stampato è indeterminato. setEta(200) rifiuta il valore, ma eta_ non è mai stato inizializzato: leggerlo è undefined behaviour. Verificare i dati nei setter non basta se l’oggetto viene creato in uno stato non valido: per questo servono i costruttori. Incapsulamento e costruttori risolvono due parti dello stesso problema.

Esercizio finale: i layer del framework

Nel framework la rete è una sequenza di layer: un DenseLayer contiene i pesi da addestrare (è un layer parametrico), un SigmoidLayer applica la sigmoide e non ha parametri. Scrivete le due classi con i nomi del framework:

  1. DenseLayer: attributi privati int Nnodes (numero di neuroni) e int Nin (numero di ingressi); costruttore DenseLayer(int Nn, int Ni) che li inizializza nella lista; metodo int numParametri() che restituisce il numero dei pesi, \(N_\text{nodes} \cdot N_\text{in}\), uno per ogni coppia ingresso-neurone (il framework, come il libro di Rashid, non usa bias);
  2. SigmoidLayer: senza attributi né costruttori, con int numParametri() che restituisce \(0\).

Esercizio finale: i layer del framework (segue)

  1. in entrambe, un metodo void descrivi() che stampa una riga nella forma

    DenseLayer <ingressi> -> <neuroni>: <numero> parametri
    SigmoidLayer: <numero> parametri
  2. nel main, la rete che nel Laboratorio 12 si addestrerà su MNIST: DenseLayer(200, 784) (784 ingressi, 200 neuroni), un SigmoidLayer, DenseLayer(10, 200) (200 ingressi, 10 neuroni) e un altro SigmoidLayer. Chiamate descrivi() per ogni layer e stampate il numero totale dei parametri, sommando i valori di numParametri().

Soluzione dell’esercizio finale: DenseLayer

#include <iostream>

class DenseLayer {
private:
1    int Nnodes;
    int Nin;

public:
    DenseLayer(int Nn, int Ni) : Nnodes(Nn), Nin(Ni) { }

2    int numParametri() { return Nnodes * Nin; }

    void descrivi() {
        std::cout << "DenseLayer " << Nin << " -> " << Nnodes
                  << ": " << numParametri() << " parametri\n";
    }
};
1
Gli stessi nomi del framework, senza il suffisso _.
2
Un peso per ogni coppia ingresso-neurone.

Soluzione dell’esercizio finale: SigmoidLayer

1class SigmoidLayer {
public:
    int numParametri() { return 0; }

    void descrivi() {
        std::cout << "SigmoidLayer: " << numParametri() << " parametri\n";
    }
};
1
Nessun costruttore: si usa quello generato dal compilatore, che qui non ha attributi da inizializzare.

Nel framework le due classi hanno una base comune, layer, e DenseLayer deriva da ParametricLayer. Qui il totale dei parametri si somma a mano, layer per layer (slide successiva): per raccogliere layer di tipi diversi in un’unica sequenza, come fa Sequential nel framework, serve una classe base comune. È l’argomento della Lezione 12, l’ereditarietà.

Soluzione dell’esercizio finale: il main

int main() {
    DenseLayer   d1(200, 784);
    SigmoidLayer s1;
    DenseLayer   d2(10, 200);
    SigmoidLayer s2;

    d1.descrivi();
    s1.descrivi();
    d2.descrivi();
    s2.descrivi();

    int totale = d1.numParametri() + s1.numParametri()
               + d2.numParametri() + s2.numParametri();
    std::cout << "Totale: " << totale << " parametri\n";
    return 0;
}
DenseLayer 784 -> 200: 156800 parametri
SigmoidLayer: 0 parametri
DenseLayer 200 -> 10: 2000 parametri
SigmoidLayer: 0 parametri
Totale: 158800 parametri