Maurizio Tomasi
Martedì 1 Ottobre 2024
La spiegazione dettagliata degli esercizi si trova qui: carminati-esercizi-02.html.
Come al solito, queste slides, che forniscono suggerimenti addizionali rispetto alla lezione di teoria, sono disponibili all’indirizzo ziotom78.github.io/tnds-tomasi-notebooks.
Ricordo a quanti usano i computer del laboratorio di configurare il compilatore. Seguite le istruzioni che avevamo fornito settimana scorsa (link).
Lo scopo della lezione di oggi è introdurre il concetto di
«classe», che è un tipo di dato complesso del linguaggio C++, creando
una classe Vettore
che implementa un array «intelligente»
di valori double
.
Esercizio
2.0: creazione della classe Vettore
.
Esercizio 2.1: completamento dell’esercizio 2.0.
Esercizio 2.2 (da consegnare per l’esame scritto): è lo stesso tipo di esercizio della scorsa lezione, ma ora occorre usare quanto si è scritto per gli esercizi 2.0 e 2.1.
Attenzione a non far fare al vostro codice calcoli inutili!
Un errore molto diffuso è quello di implementare il calcolo della varianza così:
Il codice invoca calcola_media
per N
volte! Molto meglio scrivere così:
N = 365:
- Mean : -1.0813335533916488
- Variance : 6.67466568793561 (corrected: 6.693002681583785)
- Standard deviation : 2.5835374369138933 (corrected: 2.587083818043742)
- Median : -0.9156626506024095
N = 10:
- Mean : -1.1889156626506023
- Variance : 4.270508578893889 (corrected: 4.745009532104321)
- Standard deviation : 2.0665208876016448 (corrected: 2.1783042790446703)
- Median : -1.3186746987951807
N = 9:
- Mean : -0.9204819277108434
- Variance : 4.024442831567232 (corrected: 4.527498185513136)
- Standard deviation : 2.0061014011179075 (corrected: 2.12779185671746)
- Median : -0.9
Quando si scrive un programma, è indispensabile verificare che funzioni.
La scorsa settimana vi avevo fornito i risultati attesi, che avete (spero!) confrontato con l’output dei vostri programmi.
Ma se nelle prossime settimane deciderete di mettere mano ai vecchi esercizi per migliorarli, dovrete ricontrollare i numeri dopo ogni modifica del codice!
Non sarebbe meglio se fosse il computer a fare questi controlli per voi?
Per verificare la correttezza del codice, si può eseguire alcune volte il programma.
$ g++ test1.cpp -o test1
$ ./test1
Insert two numbers: 4 6
The result is 10
$ ./test1
Insert two numbers: -1 3
The result is 2
$
Il conto però, come abbiamo visto, va verificato a mano ogni volta.
Si può verificare automaticamente la correttezza del codice con
assert
:
Una chiamata ad assert
viene tradotta più o meno
così:
Il resto del codice resta uguale, ma nel main
si
deve invocare test_sum()
prima di ogni altra cosa:
Potete implementare più funzioni test_*()
, che poi
chiamerete una di seguito all’altra nel main
.
Se c’è un errore, il programma si blocca con un messaggio:
$ ./test2
test2.cpp:4:void test_sum():
Assertion `sum(-1,3) == 2' failed
Aborted (core dumped)
$
Avvertenza: questo output si ottiene solo se
avete implementato il suggerimento
della scorsa lezione e usate il flag -g3
nel
Makefile
.
Inserite sempre all’inizio del main
una serie di
test.
Il main
dei vostri prossimi esercizi sembrerà
questo:
In questa e nelle lezioni successive vi fornirò una serie di
comandi assert
da inserire nei vostri codici: vi aiuteranno
a verificare che l’esercizio sia corretto.
Se provate a scrivere dei test per gli esercizi di queste prime lezioni, vi imbatterete però in un problema legato ai numeri floating-point.
Si può vedere facilmente che i numeri floating-point
sono solo un’approssimazione dei numeri reali. Ad esempio, il numero 0.1
non è rappresentabile esattamente con un float
o un
double
.
Occorre fissare una tolleranza \epsilon e verificare che il risultato del calcolo x_\text{calc} differisca dal valore atteso x_\text{exp} per meno di \epsilon, ossia
\left|x_\text{calc} - x_\text{exp}\right| < \epsilon.
// Return true if `calculated` and `expected` differ by less than `epsilon`
bool are_close(double calculated, double expected, double epsilon = 1e-7) {
return fabs(calculated - expected) < epsilon;
}
void test_statistical_functions(void) {
double mydata[] = {1, 2, 3, 4}; // Use these instead of data.dat
assert(are_close(CalcolaMedia(mydata, 4), 2.5));
assert(are_close(CalcolaVarianza(mydata, 4), 1.25));
assert(are_close(CalcolaMediana(mydata, 4), 2.5)); // Even
assert(are_close(CalcolaMediana(mydata, 3), 2)); // Odd
// Continue from here …
// At the end, be sure to print a message stating that everything was ok
cerr << "All the statistical tests have passed! 🥳\n";
}
Questi assert
vanno bene anche per gli esercizi di oggi,
con opportuni aggiustamenti (es., usare Vettore
anziché
double *
).
Se gli assert()
ricevono un valore
true
, non stampano nulla.
È meglio però avere un feedback a video, altrimenti potreste non
essere sicuri che i test siano effettivamente stati eseguiti. Ad
esempio, potreste dimenticarvi di chiamare
test_statistical_functions()
nel
main()
!
Il codice della slide precedente produce questo messaggio:
All the statistical tests have passed! 🥳
Abituatevi ad aspettarvi questo genere di messaggio in cima ad ogni esercizio che scriverete d’ora in poi.
Se avete sbagliato ad implementare una delle funzioni, questo è quello che accade quando eseguite il programma:
$ make
esercizio01.1: esercizio01.1.cpp:53: int main(): ↲
Assertion `are_close(CalcolaMediana(mydata, num), 2.5)' failed.
Aborted (core dumped)
$
Anche quando avete verificato che gli assert
passano
con successo, lasciateli al loro posto: nel caso in cui in futuro
dobbiate modificare l’implementazione delle funzioni (ad esempio per
renderla più veloce), continueranno a fungere da controllo. (Ed è
appagante vedere la faccina che festeggia 🥳!)
Le liste di assert
che vi fornisco sono state
costruite anno dopo anno, alla luce degli errori che solitamente hanno
fatto i vostri precedenti colleghi nei loro esercizi.
Non presentatevi all’esame finché non riuscite a far passare
tutti gli assert
di tutti
gli esercizi!
Molte volte degli studenti hanno presentato uno scritto in cui avevano usato librerie con errori! E quasi sempre ritrovavo commenti del genere negli esercizi che consegnavano:
Vettore
Il testo dell’esercizio richiede di implementare i metodi
GetComponent
e SetComponent
per leggere e
scrivere valori nell’array:
Questo è però più scomodo rispetto ai semplici array:
operator[]
Si può rendere valida la scrittura miovett[3]
anche
con oggetti di tipo Vettore
implementando il metodo
operator[]
:
In questo modo la linea di codice
std::cout << miovett[5] << "\n"
sarà
equivalente a
operator[]
Se si ritorna un reference, è possibile anche fare assegnamenti:
Così il programma seguente diventa legale:
Avete visto a lezione l’utilità degli header files,
ossia dei file con estensione .h
, .hh
o
.hpp
.
Capita spesso che un file sia incluso più volte nel corso di una stessa compilazione.
Consideriamo questo esempio:
Nel main
si usano sia le funzioni dichiarate in
vettore.h
che quelle dichiarate in
statistiche.h
, quindi ci vogliono entrambi gli
#include
.
Supponiamo che questo sia il contenuto di
vettore.h
:
e questo sia il contenuto di statistiche.h
:
Compilare il programma main.cpp
provocherebbe un errore
di compilazione:
#include
definisce la classe
Vettore
;#include
carica
statistiche.h
…Vettore
:
ma il C++ non ammette di definire due classi con lo stesso nome (neppure
se sono identiche!).In pratica, g++
è come se vedesse questo codice:
Questo è un problema che risale ai primordi del linguaggio C (fine anni ’60), ed è stato storicamente risolto con l’uso di header guards:
In questo modo, la seconda volta che il file viene incluso verrà
saltato. L’identificatore __VETTORE_H__
è
arbitrario.
#pragma once
I recenti compilatori C++, incluso il g++
,
permettono un’alternativa più semplice alle header
guards.
La seguente scrittura è più agile e mette al riparo da errori:
È più comoda perché si deve aggiungere una sola riga senza dover
inventare un identificatore (__VETTORE_H__
).
L’uso di #pragma once
mette al riparo anche da una serie
di errori che gli studenti di questo corso fanno spesso.
Ci sono due passaggi che il compilatore C++ compie quando si introduce una variabile nel codice:
Esempio:
Vettore
Le classi, a differenza di int
, richiedono
l’invocazione di un costruttore.
Il costruttore va invocato quando si dichiara la variabile: non è possibile «differire» l’inizializzazione:
I costruttori possono richiedere molto tempo per essere eseguiti,
ad esempio se al loro interno invocano new
(com’è il caso
di Vettore
).
Immaginiamo che una variabile sia come un appartamento. Una volta allocata, è come quando gli imbianchini e i piastrellisti hanno appena terminato il lavoro. Quando poi viene chiamato il costruttore della variabile, è come se la casa venisse arredata.
Un copy constructor corrisponde all’azione di costruire una
stanza vuota identica alla prima (ossia, di tipo Vettore
),
e riempirla con gli stessi identici mobili (ossia, assegnando lo stesso
valore a m_N
e agli elementi di m_v
): è come
se volessi arredare due camere di un albergo in modo che siano
identiche.
Con una operazione di assegnamento (operator=
), abbiamo
due stanze già arredate (variabili già inizializzate), che vogliamo
rendere identiche. Dobbiamo quindi prima svuotare la stanza di
destinazione perché è piena, e solo dopo arredarla allo stesso modo
dell’altra.
Il move constructor è stato introdotto nel C++11, e corrisponde a un trasloco: ho una stanza già arredata e una vuota, e voglio spostare i mobili dalla prima alla seconda, senza comprarne di nuovi: così non spreco nulla!