Laboratorio di Calcolo 2

Lezione 2

Scopo di questa sessione di laboratorio è implementare alcuni algoritmi per la ricerca di zeri e di massimi e minimi di una funzione di una sola variabile.

Nel fare questo si simulerà una situazione in cui si partecipa allo sviluppo di un pacchetto di software con altri persone e bisogna integrare il proprio lavoro (componente numerica del progetto) con quello di altri.

Dal punto di vista tecnico, si svilupperanno i seguenti punti:

  1. utilizzo di puntatori a funzione
  2. gestione di file di archivio e compressione in Linux
  3. utilizzo di variabili predefinite nel makefile
  4. passaggio di argomenti da linea di comando

Indice

  1. Implementazione degli algoritmi
    1. Le specifiche
    2. Puntatori a funzione
    3. Un programma di prova
    4. Esercizi di prova
  2. Il programma studio
    1. Gestione di file di archivio
    2. Inserimento degli algoritmi matematici
    3. Esercizi
  3. Appendice
    1. Un'occhiata al makefile
    2. Miglioramento dell'interfaccia utente

Implementazione degli algoritmi

Scopo della prima parte dell'esercizio è proprio di fornirne una implementazione corretta utilizzando uno o più degli algoritmi di ricerca di zeri, massimi e minimi fatti a lezione.

Inizialmente ci sarà da implementare la ricerca di zeri e solo successivamente si passerà a quella dei massimi e minimi.

Gli algoritmi così realizzati, verranno inseriti in un progetto più completo, che si occupa di fornire anche una visualizzazione della funzione e dei punti osservati.

Le specifiche

contratto con gli sviluppatori del progetto (i vostri professori di laboratorio), in modo che le interfacce con il resto del programma siano rispettate. Il contratto è il seguente:
  1. le funzioni devono essere realizzate in un file chiamato algoritmi.cxx
  2. i prototipi delle funzioni sono definiti dall'header algoritmi.h, che quindi non può venire cambiato:
    #include <cfloat>
    /*
     * Tutte le funzioni restituiscono l'ascissa del punto trovato.
     * Se nell'intervallo non viene trovato il punto cercato, il valore di 
     * ritorno della funzione e' DBL_MAX (costante contenente il massimo valore
     * che una variabile di tipo double puo' contenere: e' definito in <cfloat>)
     */
    
    double FindZero   (double lowerlimit, double upperlimit, double precision, 
                       double (*function)(double));
    double FindMaximum(double lowerlimit, double upperlimit, double precision, 
                       double (*function)(double));
    double FindMinimum(double lowerlimit, double upperlimit, double precision, 
                       double (*function)(double));
    

    un altro sviluppatore del progetto dovrebbe poter lavorare basandosi esclusivamente su queste informazioni, senza dover conoscere i dettagli dell'implementazione dei vari algoritmi.

    Quando si implementeranno le funzioni nel file algoritmi.cxx, bisognerà seguire queste specifiche. In particolare:

    Nota bene: includendo <cfloat> oltre che la definizione di DBL_MAX si ottiene anche una serie di costanti utile legate alla precisione numerica della macchine: i valori massimi e minimi rappresentabili con float, double e long double, i valori massimi e minimi degli esponenti e della precisione sulla mantissa. Queste provengono dal file float.h delle librerie standard del C.

    Puntatori a funzione

    I metodi che vogliamo usare vogliono studiare funzioni di una variabile, ovvero funzioni che hanno un prototipo del tipo:
    double laMiaFunzione( double );
    buona parte delle funzione delle librerie matematiche, come sin, cos, exp, log soddisfano questo prototipo.

    In caso servissero funzioni non elementari, se ne possono costruire di apposite, ad esempio possiamo definire:

    double sinxsux(double x) {
      if ( x==0 ) return 1.;
      else return sin(x)/x;
    }
    

    Le funzioni di ricerca da usare in studio sono funzioni generiche che devono operare su tutte le funzioni di una variabile, e quindi la funzione da studiare deve essere di volta in volta passata come argomento. Per fare questo il C++ utilizza i puntatori a fuzione.

    Nella dichiarazione delle funzioni FindZero e simili, troviano nella lista di argomenti:

    double (*function)(double)
    che si legge:
    function è un puntatore ad una funzione che ha per argomento un double e valore di ritorno di tipo double
    Il fatto che function sia un puntatore si evince dalla presenza dell'asterisco davanti al nome, mentre che sia una funzione si deduce dal fatto che il nome è seguito da parentesi tonde.

    Ogni volta che si costruisce una funzione, si costruisce automaticamente un puntatore alla funzione stessa, che viene acceduto usando il nome della funzione. Per esempio, se volessimo usare FindZero per cercare uno zero della funzione seno nell'intervallo [2.,4.], con precisione 10-4, potremmo invocarla semplicemente con:

    FindZero(2.,4.,1.E-4,sin)
    e lo stesso per le ulteriori funzioni da noi definite:
    FindZero(2.,4.,1.E-4,sinxsux)

    All'interno di FindZero la funzione passata come argomento può venire valutata con la sintassi:

    (*function)(a)
    dove per prima cosa si applica a function l'operatore di dereferenziazione per accedere alla funzione puntata da function e poi si valuta la funzione ottenuta nel punto a.

    Un programma di prova

    Per verificare che gli algoritmi numerici funzionano, si può usare il seguente programma di test che, dato un intervallo xmin-xmax, cerca uno zero della funzione sin nell'intervallo:
    #include <iostream>
    #include <cstdlib>
    #include <cmath>
    #include "algoritmi.h"
    
    
    using namespace std;
    
    int main ( int argc, char* argv[]) {
      double xmin, xmax;
      double precisione=1.E-6;
      if ( argc!=3 ) {
        cout << "Usage: " << argv[0] << " x_minimo x_massimo" << endl;
        return -1;
      }
      xmin = atof(argv[1]);
      xmax = atof(argv[2]);
      cout.precision(10);
      cout << FindZero(xmin,xmax,precisione,sin) << endl;
      return 0;
    }
    
    Analogamente si possono fare programmi per provare FindMinimum e FindMaximum.

    Alcuni punti caratteristici di questo programma:

    1. il programma viene invocato dando da linea di comando i valori degli estremi dell'intervallo, ad esempio:
      ./nomeprogramma 5. 7.
    2. il programma controlla che il numero di argomenti passato da linea di comando sia 3:
      • argv[0] è il nome del programma invocato
      • argv[1] è il valore dell'estremo inferiore
      • argv[2] è il valore dell'estremo superiore
      in caso positivo, procede alla determinazione dello zero, altrimenti stampa un messaggio esplicativo sulle modalità di utilizzo.
    3. le stringhe argv[1] e argv[2] vengono convertite nei rispettivi valori numerici usando la funzione atof che fa parte delle librerie standard incluse con #include <cstdlib>. Per le conversioni a intero esiste l'analoga atoi (vedi appendice).

    Esercizi di prova

    1. Realizzate una versione di algoritmi.cxx in cui avete un'implemetazione funzionane di FindZero ed una fittizia, ma che segue le specifiche per le funzioni di ricerca di massimi e minimi (un semplice
      return DBL_MAX;
      dovrebbe bastare).
    2. Verificate che funziona con il programmino di prova di cui sopra. Provate a cambiare gli estremi per vedere come si comporta se scegliete un intervallo troppo grande o addirittura sbagliato.
    3. Adattare il programma per usare la funzione sinxsux.
    A questo punto, proseguite con la seconda parte del programma di questa sessione, usando il programma studio.

    Una volta che siete riusciti a compilare ed eseguire il programma, tornate a questo punto, implementate le funzioni di ricerca di massimi e minimi ed adattate il programmino di prova per controllarne la funionalità.

    Il programma studio

    Il programma studio consiste di un'interfaccia da linea di comando a degli algorimi numerici che si occupa di disegnare su schermo un grafico della funzione studiata, indicando i punti notevoli trovati e poi stampando le coordinate degli stessi.

    Uno dei vantaggi di questo programma è che, essendo interfacciato con ROOT permette di specificare l'espressione della funzione come una stringa da riga di comando.

    Gestione di file di archivio

    In ambiente UNIX, la procedura standard per distribuire pacchetti di file (per esempio i sorgenti di programmi) è di utilizzare file di archivio

    I file contenenti i programmi necessari per questa lezione si trovano nel file di archivio compresso /home/comune/lab2/datafiles/studio.tar.gz: copiatelo nella vostra cartella di lavoro.

    Questo file è stato compresso, per ridurre le dimensioni dei file di archivio, lo strumento più comune in ambiente Linux è il programma di compressione gzip, che si utilizza nel modo seguente:
    gzip NomeFile per comprimere un file
    gunzip NomeFile per decomprimere un file.

    Dopo la decompressione, il file studio.tar.gz è stato rinominato in studio.tar, dove il suffiso tar sta per Tape ARchive.

    Essi sono gestiti con il comando tar.


    Per completezza, sono allegate le pagine del man di tar e gzip.

    Decomprimere e estrarre tutti i file dell'esercizio da studio.tar.gz: dopo questa operazione dovreste avere una sottodirectory studiocon tutti i file necessari.

    Leggere attentamente il file README con le istruzioni per compilare ed eseguire il programma.

    Inserimento degli algoritmi matematici

    Per provare il programma, si può tentare con la funzione sin(x)/x):

    ./studio "sin(x)/x" -10. 10.
    e si ottiene il seguente grafico:
    Grafico senza implementazioni della ricerca di punti notevoli

    Guardando il grafico ci si rende conto che qualcosa non va: in effetti il programma ha tutti gli algoritmi numerici encapsulati nel file algoritmi.cxx e nel relativo header algoritmi.h, ma questi non sono stati implementati in maniera accettabile.

    Se avete svolto correttamente la prima parte della lezione, dovrebbe essere sufficiente copiare i files algoritmi.cxx e algoritmi.h che avete scritto precedentemente nella cartella in cui si trova studio e ricompilare.

    Si noti che in studio, la stringa passata come argomento viene usata per creare un oggetto TF1 di RooT, che poi viene incapsulato in una funzione che soddisfa il prototipo richiesto da FindZero, FindMaximum e FindMinimum. In questo modo combiniamo la flessibilità di avere una libreria di algoritmi numerici indipendente da RooT, e quindi riutilizzabili in qualunque ambiente, ma di sfruttare le librerie di RooT per la grafica ed il passaggio della funzione da riga di comando.

    Esercizi

    Dopo aver inserito degli algoritmi numerici corretti, ripetendo lo studio di sinx/x, si dovrebbe ottenere il grafico corretto ed una stampa a video dei valori dei punti trovati (salvo ci potrebbero essere dei problemi nello zero):
    Grafico con implementazioni della ricerca di punti notevoli
    Zero:          (-9.4247779608,3.8980430911e-17)
    Zero:          (-6.2831853072,-3.8980430911e-17)
    Zero:          (-3.1415926536,3.8980430911e-17)
    Zero:          (3.1415926536,3.8980430911e-17)
    Zero:          (6.2831853072,-3.8980430911e-17)
    Zero:          (9.4247779608,3.8980430911e-17)
    Massimo locale: (-7.725251837,0.12837455353)
    Minimo locale: (-4.493409458,-0.21723362821)
    Massimo locale: (-0.00036488121489,0.99999997781)
    Massimo locale: (0.00036376988551,0.99999997795)
    Minimo locale: (4.493409458,-0.21723362821)
    Massimo locale: (7.725251837,0.12837455353)
    Massimo assoluto: (0.00036376988551,0.99999997795)
    Minimo assoluto: (-4.4934094546,-0.21723362821)
    

    Risolvere i seguenti problemi:

    Appendice

    Un'occhiata al makefile

    Il makefile di studio illustra alcune tecniche utili per semplificare e ridurre errori di battitura nella creazione dello stesso, attraverso l'utilizzio di alcune variabili predefinite.
    LIBS:=`root-config --libs`
    INCS:=`root-config --cflags`
    
    %.o : %.cxx
            g++ -Wall -c $< ${INCS}
    
    studio: studio.o interfaccia.o algoritmi.o
            g++ -Wall -o $@ $^ ${LIBS}
    

    Le prime righe definiscono le variabili INCS e LIBS, rispettivamente per utilizzare i file header di RooT in compilazione e le librerie di RooT al momento del link.

    Poi viene definita una regola generale per creare un qualsiasi file del tipo nome.o a partire dal corrispondende file nome.cxx (il carattere % sta per qualsiasi file). La variable predefinita $< indica la prima dipendenza nella linea. In questo modo, non siamo obbligati, ogni volta che aggiungiamo un file sorgente nel nostro progetto, ad aggiungere nel makefile la riga necessaria per compilarlo.

    Per linkare insieme diversi file oggetto e costruire un eseguibile, possiamo usare le variabili predefinite $@, nome del target ,e $^, lista completa delle dipendenze. Questo evita di trascrivere i nomi dalla riga con target e dipendenze alla riga con le istruzioni di compilazione: statisticamente molti errori avvengono durante questa trascrizione.

    Miglioramento dell'interfaccia utente

    Il programma studio è molto poco flessibile: accetta solo la definizione della funzione e gli estremi dell'intervallo da studiare.

    Sarebbe molto utile provare a cambiare la precisione, oppure il numero di sottointervalli in cui l'intervallo iniziale viene diviso prima di procedere alla ricerca dei massimi e dei minimi in un sottointervallo. Per funzioni che oscillano molto, potrebbe essere necessario suddividere l'intervallo iniziale in sottointervalli piu' piccoli. Si chiede di modificare studio.cxx in modo che il programma possa essere invocato con cinque parametri:
    ./studio funzione xmin xmax precisione numero_sottointervalli
    di cui gli ultimi due opzionali, oppure con la forma
    ./studio
    nel qual caso il programma richiede all'utente di fornire tutti i parametri interattivamente. Per svolgere questo punto, bisogna sapere che la definizione completa della funzione main è:
    int main( int argc, char* argv[] )
    dove argc è il numero di argomenti sulla riga di comando (il nome del programma e argc-1 parametri). Il vettore di stringhe argv contiene tali argomenti. Nel nostro caso:

    Per convertire una stringa nel suo valore numerico, si possono usare le funzioni:
    int atoi(const char*) per interi,
    double atof(cont char*) per valori in virgola mobile,
    entrambe dichiarate in cstdlib.

    La caratteristica principale di questo punto è che il numero di sottointervalli non è noto al momento della compilazione, ma solo al momento dell'esecuzione. D'altra parte è necessario dimensionare un vettore di lunghezza pari a numero_sottointervalli+1.

    Una volta realizzata questa modifica, provate a determinare numericamente π utilizzando i due metodi:

    aumentando di volta in volta la precisione e vedere quando il programma inizia a dare risultati numericamente instabili perchè si è raggiunta la precisione della macchina.

    Considerare la funzione x2sin(1/√x) e vedere quanti minimi si trovano nell'intervallo [0,1] scomponendo lo stesso di volta in volta in 10, 100 e 1000 sottointervalli.