conclusioni sul corso di tnds -...
TRANSCRIPT
Laboratorio di trattamento numerico deidati sperimentali
Maurizio Tomasi ﴾turno A2﴿
Giovedì 11 Gennaio 2018
Conclusioni sul corso di TNDS
Come usare quanto imparato nel corso?Presento ora una prospettiva personale di quanto abbiamoimparato nel corso, e di come muoversi quando vi troverete adovere scrivere codici per un laboratorio/tesi/dottorato/etc.
Questo corso vi ha insegnato il linguaggio C++, ma è unapreoccupazione mia e degli altri docenti del corso che non vifermiate ad usare solo esso nella vostra vita accademica elavorativa! Nella maggior parte delle situazioni concrete, esistonoalternative più semplici ed efficienti del C++ ﴾es., Python, R, Julia,Mathematica﴿.
OOP: una retrospettivaIn questo corso abbiamo visto come sviluppare programmi usandoi concetti della programmazione orientata agli oggetti ﴾OOP,object‐oriented programming﴿, ossia:
1. Incapsulamento ﴾funzioni e variabili stanno insieme in una class ﴿;
2. Ereditarietà ﴾classe derivata da un'altra﴿;3. Polimorfismo ﴾ridefinizione di funzioni virtual ﴿.
Vediamo in cosa ci sono servite, e quali possibili alternative sonodisponibili alla OOP.
Questo vi sarà utile non solo per preparare l'esame, ma anche perdecidere come scrivere i vostri futuri codici.
Ereditarietà e polimorfismoÈ frequente la necessità di scrivere algoritmi che operino su unafunzione matematica f non nota a priori:
1. Calcolo degli zeri di una funzione;2. Calcolo di derivate;
3. Calcolo di integrali.4. Etc.
La OOP ci ha fornito gli strumenti per fare in modo che questicodici accettassero qualsiasi funzione, definendo una classe FunzioneBase con un metodo Eval virtuale, e poi implementandoclassi derivate.
Vediamo come era possibile fare ciò prima che esistessero letecniche OOP.
Esempio: calcolo di integrali senza OOPdouble integrate(double fn(double), double a, double b) { // To evaluate `fn` in the body of "integrate", use // fn(x)}
È possibile scrivere una funzione come quella sopra, che accetta ininput una funzione qualsiasi e ne calcola l'integrale tra a e b .
Esempio d'uso:
#include <iostream>#include <cmath>
int main() { std::cout << integrate(std::sin, 0.0, 1.0) << "\n";}
Esempio: calcolo di integrali con OOPCon la OOP le cose si fanno più complicate:
#include <cmath>#include <iostream>
class FunzioneBase {public: FunzioneBase() {} virtual double eval(double x) = 0;};
class Sin : public FunzioneBase {public: Sin() {} double eval(double x) { return std::sin(x); }};
double integrate(FunzioneBase & fn, double a, double b) { // To evaluate "fn" in the body of "integrate", use // fn.eval(x)}
Parentesi: uso di operator() Se dà fastidio ricordarsi di chiamare sempre il metodo eval , il C++ci viene incontro con operator() ﴾che è molto elegante!﴿:
class FunzioneBase {public: FunzioneBase() {} virtual double operator()(double x) = 0;};
class Sin : public FunzioneBase {public: Sin() {} double operator()(double x) { return std::sin(x); }};
double integrate(FunzioneBase fn, double a, double b) { // To evaluate "fn" in the body of "integrate", use // fn(x)}
Esempio: calcolo di integrali con OOPPer giunta, nell'invocare integrate dobbiamo prima creare unavariabile di tipo Sin :
int main() { Sin funzione; std::cout << integrate(funzione, 0.0, 1.0) << "\n";}
A prima vista la OOP non sembra un gran guadagno:
1. Ci vogliono più linee di codice per fare la stessa cosa;
2. Dobbiamo creare una variabile funzione apposta per invocare integrate .
Qual è il vantaggio di usare la OOP qui?
Vantaggi dell'approccio OOPSupponiamo che ciò che vogliamo integrare non sia la funzione
f(x) = sinx,
ma la funzione
f (x) = sin kx,
per qualche k fissato, e che il nostro programma debba calcolarepiù volte questo integrale, ogni volta con un valore diverso di k.
k
Vantaggi di OOPPossiamo passare il parametro addizionale k nel costruttore, inmodo da lasciare eval così com'è.
class Sin : public FunzioneBase { double k; public: Sin(double a_k) : k(a_k) {} double eval(double x) { return std::sin(k * x);}
Fare in modo che eval continui ad accettare il solo parametro x èfondamentale: integrate non saprebbe più come funzionare se il eval accettasse ora due argomenti:
double Sin::eval(double x, double k) { /* ? */ }
Vantaggi di OOPDiventa a questo punto molto facile calcolare integrali con valoridiversi di k, perché k viene passato al costruttore e integrate nonsa neppure che il parametro k esista!
int main() { Sin sin1(1.0); Sin sin2(2.0); std::cout << integrate(sin1, 0.0, 1.0) << " " << integrate(sin2, 0.0, 1.0) << "\n";}
Questo chiarifica anche perché si debba definire una variabile sin nell'esempio visto prima: perché in essa sono conservati i valori dieventuali parametri ﴾ k , appunto﴿ passati tramite il costruttore.
Ci sono tanti trucchi possibili per ottenere ciò con l'approccio non‐OOP mostrato prima. Però raramente questi trucchi sono eleganti!
Problemi con l'approccio OOP: verbositàLa OOP risolve il problema del passaggio di parametri arbitraricome nel caso di f (x) = sin(k ⋅ x). Questo approccio peròcausa nuovi problemi.
Supponiamo che un collega mi passi il codice di una funzione checalcola lo spettro di brillanza della radiazione fossile:
double planck_law_3K(double frequency) { // …}
Se volessi usare la mia funzione integrate per stimarne l'integralesu ν ﴾ossia σT , la legge di Stefan‐Boltzmann﴿, non potrei passarledirettamente planck_law : dovrei prima creare una classe PlanckLaw derivata da FunzioneBase e definire in essa un metodo eval che chiama planck_law ﴾rallentando il codice﴿.
k
4
Problemi con l'approccio OOP: gerarchiecomplesseLa tendenza del codice OOP è quella di generare strutturegerarchiche di classi che hanno tutte il medesimo oggetto diorigine ﴾solitamente chiamato Object , TObject o CObject ﴿.
Se volessi usare in un mio codice una classe Pippo proveniente daun'altra libreria, questa non potrebbe essere facilmente integratanel codice esistente perché ne sfrutti le funzionalità: bisognerebbeprima creare una nuova classe TPippo che derivi sia da Pippo cheda TObject , e ricordarsi di usare sempre TPippo anziché Pippo .Non sempre ciò è possibile, o comunque conveniente!
Vediamo storicamente quando è emerso questo problema.
Borland C++ 3.1 ﴾1992﴿
Includeva una libreria OOP in C++ ﴾«Turbo Vision»﴿ per creareinterfacce di testo come quella dell'ambiente di sviluppo. ﴾Ilsottoscritto l'ha usata tantissimo!﴿
Funzionamento di Turbo VisionUn programma Turbo Vision poteva salvare su disco il suo stato. Inquesto modo, quando si faceva ripartire il programma, l'utenteritrovava tutto nello stesso stato ﴾posizione finestre, file aperti…﴿ incui l'aveva lasciato.
Questo era possibile perché ogni classe che definiva gli oggetti delprogramma ﴾finestre di testo, bottoni, dialoghi…﴿ derivava dallaclasse TObject , che aveva un metodo save e un metodo load .Per salvare lo stato del programma bastava un codice simile aquesto:
TObject **objects; // Array of pointers to TObjectfor(int i = 0; i < num_of_objects; ++i) { objects[i]‐>save();}
Gerarchia di classi di Turbo Vision
Borland C++ 3.1 per Windows 3.1 ﴾1992﴿
La versione per Windows del Borland C++ includeva una libreria diclassi ﴾OWL, Object Windows Library﴿ per scrivere programmiWindows. Il sottoscritto ha usato moltissimo anche questa, sia nellasua versione C++ che nella versione inclusa nel Borland Pascal 7.0.
Gerarchia di classi di OWLA differenza di Turbo Vision, con OWL non aveva senso salvaretutte le classi: quelle che lo permettevano erano quindi derivate da TStreamable , che implementava i metodi virtuali load e save .
﴾Borland ObjectWindows for C++ 2.0: Programmer's manual, pag. 7﴿
ROOT
Anche ROOT ﴾che nasce nel 1994﴿ usa l'approccio di una classe, TObject , alla base della gerarchia di classi.
Microsoft Foundation Classes ﴾1998﴿
Il problema delle gerarchie di classiNei progetti che usano OOP, c'è una enorme proliferazione diclassi, legate tra loro da relazioni spesso complesse. Questo rende ilcodice difficile da comprendere.
Considerate la slide appena vista sulle Microsoft FoundationClasses: è difficile comprenderne la struttura intricata, eppure laslide presenta solo le relazioni tra i tipi di dati! L'implementazionedegli algoritmi ﴾es., come disegnare a video una finestra, cosasuccede quando si preme un bottone a video…﴿ può essereaffrontata solo successivamente alla definizione di questagerarchia!
Alexander StepanovUno dei curatori della libreria C++ STL ﴾Standard Template Library﴿,Alexander Stepanov, si era reso conto che la OOP portava allacreazione di sistemi ingestibili, e pensò a come usare unmeccanismo alternativo, chiamato programmazione generica ﴾v. AnInterview with A. Stepanov﴿.
Stepanov riteneva che nel mondo della programmazione ci sidovesse concentrare innanzitutto sugli algoritmi, e che soloquando questi fossero stati definiti avesse senso pensare al tipo didato su cui essi potessero essere applicati.
L'OOP, al contrario, obbliga a partire dal tipo di dato ﴾ossia, lagerarchia di classi﴿ per definire poi gli algoritmi.
OOP e C++Bjarne Stroustrup, il creatore del C++, sposò le idee di Stepanov﴾nonostante avesse creato il C++ sull'onda della «moda» dell'OOP﴿:lo standard C++98 include una versione della STL. È la libreria checontiene i file vector , map , etc… Nessuna di queste librerie usa iconcetti OOP di ereditarietà e polimorfismo.
Di fatto, la tendenza del linguaggio C++ negli standard successivial C++98 ﴾ossia, quelli rilasciati nel 2003, 2011, 2014 e 2017﴿ è stataquella di potenziare gli aspetti del linguaggio più legati allaprogrammazione generica e alla metaprogrammazione: poche dellepotenzialità delle versioni più recenti sono legate alla OOP ﴾es., lekeyword final e override ﴿.
Piccolo esperimentoA dimostrazione di ciò, il seguente comando mostra tutti i file dellalibreria standard C++ sui computer del laboratorio che nonimplementano metodi virtuali ﴾che sono il chiaro sintomo dell'usodel polimorfismo﴿:
grep ‐L virtual $(find /usr/include/c++/4.4.7/* ‐type f)
Usando ‐l invece di ‐L , si ottiene la lista complementare.
Il confronto è schiacciante: 631 file contro 54: nella libreria C++fornita da GCC 4.4, le classi che usano il polimorfismo sono menodel 10%!
Integrali senza OOPVediamo come Stepanov suggerirebbe di implementare la funzione integrate :
template<typename Function>double integrate(Function fn, double a, double b) { // To evaluate `fn` in the body of "integrate", use // fn(x)}
Questo è simile al nostro primo tentativo:
double integrate(double fn(double), double a, double b) { // To evaluate `fn` in the body of "integrate", use // fn(x)}
ma usa i template: questo garantisce una versatilità moltomaggiore.
Uso dei templateUna funzione introdotta da
template<typename Function>
dice che tutte le volte che compare Function , esso è daconsiderarsi un tipo di dato non definito al momento: esso saràprecisato solo nel momento in cui integrate verrà effettivamenteusata.
Quando poi, nel main , il compilatore trova la scrittura
integrate(std::sin, 0.0, 1.0);
allora prova a sostituire a Function il tipo di std::sin , che è unafunzione che accetta un double e ritorna un double , e controlla seil corpo della funzione integrate ha senso o no in questo caso.
Uso dei templateConsideriamo per esempio il calcolo dell'integrale col metodo dellamedia:
template<typename Function>double integrate(Function fn, double a, double b) { double sum = 0.0; const int N = 1000; for (int i = 0; i < N; ++i) { sum += fn(random_uniform(a, b)); } return sum * (b ‐ a) / N;}
Se si passa come argomento fn la funzione std::sin , ilcompilatore trova che questo è accettabile: sostituendo in fn(random_uniform(a, b)) l'espressione fn con std::sin , siottiene std::sin(random_uniform(a, b)) , che è un'istruzione lecita.
ParametriVediamo ora come procedere se, come in precedenza, la funzioneda integrare ha un parametro:
f(x) = sin kx.
In questo caso basta definire una classe con un metodo operator() :
class Sin { double k;public: Sin(double m_k) : k(m_k) {} double operator()(double x) { return std::sin(k * x); }};
come nel caso visto sopra, ma ora Sin non è più obbligata adiscendere da FunzioneBase .
ParametriSe ora chiamiamo integrate in questo modo ﴾C++11﴿:
Sin sin1(1.0);std::cout << integrate(sin1, 1.0, 2.0) << "\n";
il compilatore guarda com'è definita integrate e trova che fn èusata nella riga
sum += fn(random_uniform(a, b));
Opera allora la sostituzione fn → sin1 ed ottiene
sum += sin1(random_uniform(a, b));
e siccome sin1 implementa operator() , è usabile come se fosseuna funzione. Quindi il compilatore accetta di compilare il codice.
L'idea della programmazione genericaCon l'approccio OOP, siamo noi, creatori della funzione integrate ,a decidere quali funzioni possano essere passate a integrate equali no: solo le classi derivate da FunzioneBase sono accettabili. Diconseguenza, è necessaria una grande cura nel definire bene lagerarchia di classi. ﴾L'articolo «The problem with ROOT» descrivesupposti errori fatti nel definire la gerarchia di classi della primaversione di ROOT, e poi inevitabilmente perpetuatisi in quellesuccessive﴿.
Nella programmazione generica, è invece l'utilizzatore a deciderecosa passare a integrate : il garante della coerenza è ilcompilatore, che verifica di volta in volta se il costrutto usato sialecito o no. Il programmatore deve quindi pensare a meno dettagli.
Funzioni lambdaIl C++11 ha introdotto le funzioni lambda, che risparmiano lanecessità di implementare una classe Sin quando si voglionopassare i parametri:
double k = 2.0;integrate([double x]{return std::sin(k * x);}, 1, 2);
Senza le funzioni lambda, saremmo costretti a definire una classeseparata, come abbiamo visto in precedenza.
Vantaggi e svantaggiRispetto alla OOP, la programmazione generica ha questi vantaggi:
1. Si scrive meno codice;2. Il compilatore è in grado di creare codice più veloce
﴾nell'esempio dell'integrale, circa il 10 %﴿;3. Si scarica sul compilatore l'onere di decidere se si può passare
un certo tipo a una funzione oppure no;
4. Non è necessario definire gerarchie complesse di classi.
Porta però con sè alcuni svantaggi:
1. Solitamente i messaggi di errore del compilatore sono piùcriptici ﴾è sperabile che nel C++20 questo problema siamitigato dall'introduzione dei concepts﴿;
2. Il tempo necessario alla compilazione aumenta.
Altri strumentiNel caso in cui dobbiate scrivere codice di analisi per un'attività dilaboratorio o per la vostra tesi, vi consiglio di puntare su uno diquesti strumenti:
1. Python: di questo abbiamo parlato già abbastanza;2. Julia: secondo il sottoscritto, è il cavallo vincente su cui
puntare!
3. GNU R: perfetto per analisi statistiche;4. Mathematica: a pagamento, nel laboratorio di calcolo è
disponibile la licenza. È molto potente nel calcolo simbolico.
La caratteristica che accomuna questi linguaggi è la semplicitàd'uso: sono tutti interattivi, e non richiedono di usare Make né dicompilare il codice prima di eseguirlo. Inoltre, ciascuno di essiconsente di creare plot usando una sola istruzione.