Incapsulamento, Getter/Setter e Costruttori
public e private, costruttoriLa divisione delle classi in header e file sorgente, con la compilazione separata, è l’argomento della Lezione 08, insieme a stack e heap.
public.private e si modificano con metodi pubblici che li verificano, come imposta() della classe Temperatura.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.
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:
Il codice che la usa:
int main() {
Neurone n1;
1 n1.peso = 1.12;
return 0;
}error: 'class Neurone' has no member named 'peso'.
Con l’attributo privato la modifica resta confinata nella classe: è l’incapsulamento.
L’accesso ai dati privati passa per metodi pubblici: un getter restituisce il valore di un attributo, un setter lo modifica.
_ (Lezione 06).
Come imposta() di Temperatura (Lezione 06), un setter può rifiutare i valori non validi.
std::cerr. Le eccezioni, che permettono al chiamante di gestire l’errore, sono l’argomento della Lezione 19.
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) { }
};: 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).
Come per le funzioni (Lezione 05), si può fare l’overloading dei costruttori: la scelta dipende dagli argomenti.
value_ vale 0.0.
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.
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).const e i reference devono stare nella lista: dopo la creazione non si possono assegnare (nota sullo scheduler, Lezione 06).Sensore(double o) { offset_ = o; }: error: assignment of read-only member.
Temperatura() e Temperatura(double): t_ è creato con il costruttore di default, poi riceve per assegnazione un temporaneo.
Temperatura(double): t_ è creato direttamente con il valore iniziale.
Dal C++11 un attributo può ricevere un valore iniziale direttamente nella dichiarazione:
peso_ vale 0 e lr_ vale 0.1.
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.
double, int, puntatori): il loro valore resta indeterminato.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.private: l’uso tipico, il Singleton, è nella Lezione 13.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_) { }
};altro. Un metodo accede ai membri privati di qualunque oggetto della propria classe, quindi può leggere altro.value_.
invalid constructor; you probably meant 'Datum (const Datum&)'.const perché l’originale viene soltanto letto (const reference, Lezione 05).La copia alla creazione e l’assegnazione successiva sono operazioni distinte:
d2 viene creato come copia di d1: si chiama il costruttore di copia.
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).
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.
Datum d1(2.0);.
double si costruisce un Datum temporaneo, che viene passato alla funzione.
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).
Neurone con due attributi privati di tipo double: peso_ e lr_ (il learning rate).double getPeso() e double getLr().void setLr(double nuovo): accetta solo valori in \((0, 1]\); altrimenti rifiuta il valore, stampa il motivo su std::cerr e mantiene quello precedente.Neurone(double peso, double lr), che nella lista inizializza peso_(peso) e lr_(0.1), poi nel corpo chiama setLr(lr).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().lr_(0.1) dal secondo costruttore e rieseguite: che valore ha lr_ di m, e perché?#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";
}
}
};lr_(0.1) inizializza lr_ prima della chiamata a setLr.
lr_ deve già contenere un valore valido.
mainint 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
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.
Senza portatile.
1. Quante righe vengono stampate, e quali?
2. La classe Persona della slide «Setter che verificano i dati» non ha costruttori.
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.
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:
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);SigmoidLayer: senza attributi né costruttori, con int numParametri() che restituisce \(0\).in entrambe, un metodo void descrivi() che stampa una riga nella forma
DenseLayer <ingressi> -> <neuroni>: <numero> parametri
SigmoidLayer: <numero> parametrinel 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().
DenseLayer_.
SigmoidLayer1class SigmoidLayer {
public:
int numParametri() { return 0; }
void descrivi() {
std::cout << "SigmoidLayer: " << numParametri() << " parametri\n";
}
};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à.
mainint 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