Laboratorio di Calcolo 2

proff. A. Andreazza, D. Galli, E. Spoletini, G. Tiana, A.Vairo

Universita' degli Studi di Milano

Anno Accademico 2005/2006

Lezione 1

Introduzione

Scopo della prima sessione di laboratorio e' di impratichirsi con i comandi necessari per la compilazione e lo sviluppo di programmi in C++ in ambiente Linux.
Questa sessione prevede:
  1. ripasso dei comandi fondamentali del sistema operativo;
  2. include files;
  3. input/output da terminale;
  4. cicli for;
  5. cicli while.

L'esercizio

L'esercizio consiste nello scrivere un programma per l'integrazione di una funzione con il metodo dei trapezoidi. Il programma richiede come input da terminale gli estremi a e b dell'intervallo di integrazione ed il numero n di intervalli in cui questo viene diviso per effettuare l'integrazione numerica. Come output, il programma stampa il valore stimato dell'integrale. Una volta che il programma e' stato scritto e compilato con successo, le sue funzionalita' dovranno essere controllate con funzioni il cui integrale e' noto.

Introduzione al compilatore

main

In C++ non esistono programmi o subroutine, ma solo funzioni. Il programma eseguibile e' in effetti una funzione speciale che si chiama main ed ha un valore di ritorno di tipo int. Quindi scrivere un programma di fatto significa scrivere una funzione:
int main() {
/* All'interno della funzione deve essere inserito il codice appropriato */
return 0; /* Bisogna fornire un valore di ritorno */
}

La compilazione

Il compilatore C++ di fatto consta di tre parti fondamentali:
  1. il preprocessore,
  2. il compilatore vero e proprio,
  3. il linker.
I tre passi possono venire effettuati con un comando solo. La maniera piu' semplice di invocare il compilatore C++ e' con:
g++ -o nomeeseguibile nomefilesorgente
Se non viene indicato il nome dell'eseguibile, questo viene creato con il nome a.out.
IMPORTANTE: ricordarsi di far sempre seguire -o dal nome del file di uscita, dimenticarselo e scrivere g++ -o nomefilesorgente, risulta nella cancellazione del file sorgente!

Il preprocessore di fatto effettua delle sostituzioni letterali all'interno del programma, definite da alcune direttive, riconoscibili perche' iniziano con un #.
La direttiva #include inserisce nel file in corso di compilazione il testo di un altro file, di solito un file che contiene le dichiarazioni delle funzioni da usare (come iostream per esempio). Invece una direttiva
#define PIGRECO 3.1415196
sostituisce tutte le sequenze di caratteri P,I,G,R,E,C,O incontrate nel testo con la sequenza di caratteri 3,.,1,4,1,5,1,9,6, e risulta un modo pratico di inserire delle costanti.
Il #define puo' anche venire usato per definire delle sostituzioni contenti delle parti variabili, ad esempio
#define FUNC(A) sin(A)/A
sostituisce espressioni come FUNC(xyx) con sin(xyz)/xyz.  Questa e' una direttiva molto utile, ma anche subdola: FUNC(x+y) da' il risultato che vi aspettate? Come fare per avere il risultato corretto?

Il compilatore trasforma il codice che abbiamo scritto in una sequenza di istruzioni comprensibili per la macchina (file oggetto). A volte puo' essere utile terminare la compilazione dopo questa fase, utilizzando l'opzione -c del compilatore:
g++ -c -o nomefileoggetto nomefilesorgente
Se il compilatore non riesce a capire che istruzioni generare perche' il file sorgente non rispetta le regole del C++, esso fornisce un errore di sintassi e non crea il file oggetto. Il testo degli errori di sintassi e' molto esplicativo. In piu' il compilatore dice sempre a quale riga dell file sorgente e' presente un errore. Tranne nel caso in cui ci si sia dimenticati un ; al termine di una riga, nel qual caso l'informazione puo' anche risultare piuttosto criptica.

Il linker si preoccupa di aggiungere l'informazione di tutte le funzioni utilizzate dal file oggetto e non implementate esplicitamente al suo interno. Il linker viene chiamato se si invoca il g++ senza l'opzione -c:
g++ -o nomeeseguibile [lista file sorgente] [lista file oggetto] -lfilelibreria1 -lfilelibreria2...

Il comando g++ prevede molte opzioni, che si possono mettere tutte prima del -o. Una particolarmente consigliata e' l'opzione -Wall che scrive un sacco di messaggi su tutte le cose che, sebbene perfettamente legali in C++, suonano un po' strane al compilatore. Questi messaggi chiamati warnings sono molto utili per mettere in evidenza al momento della compilazione molti errori di logica.
 

Funzioni di input/output

Per leggere e scrivere dati attraverso la finestra terminale, si utilizzano gli oggetti cin e cout. Questi oggetti sono dichiarati nel file iostream che quindi dovrà essere incluso nel file sorgente del programma.

Se a fosse una variabile del programma, per stampare il suo valore su terminale si usa l'istruzione:

cout << a ;
se invece vogliamo leggere il suo valore da terminale, usiamo l'istruzione analoga
cin >> a ;

Il C++ non inserisce automaticamente spazi tra due oggetti che vengono stampati, e neppure un carattere di a capo al termine di una riga di stampa: questi devono essere forniti esplicitamente dal programmatore. In particolare, quando si terminano le operazioni di stampa, e' utile inserire sempre il carattere di fine riga. Questo carattere in C++ e' automaticamente definito nella costante endl.

Il primo programma

Ora siamo in grado di scrivere quello che in quasi tutti i libri di programmazione e' il primo programma: "HelloWorld".

Questo programma semplicemente stampa le parole "Hello World!" sul terminale. Aprite con nedit un nuovo file HelloWorld.cxx:

nedit HelloWorld.cxx
ed inserite il testo seguente:
#include <iostream>

using namespace std;

int main() {
  cout << "Hello world!" << endl ;
  return 0;
}

Tutto dovrebbe essere chiaro, eccetto l'istruzione using namespace std. Questa viene utilizzata perche' molti oggetti e definizioni standard del C++ hanno il loro vero nome preceduto da std::, per cui, ad esempio, il nome completo di cin e` in realta` std::cin. In termini del linguaggio questo fatto si esprime dicendo che cin si trova nel namespace std. L'istruzione cha abbiamo aggiunto informa il compilatore che intendiamo usare oggetti definiti nel namespace std attraverso il loro nome abbreviato. Se non avessimo messo questa istruzione, il programma avrebbe dovuto esplicitare i nomi completi degli oggetti, con conseguente perdita di leggibilita`:

#include <iostream>

int main() {
  std::cout << "Hello world!" << std::endl ;
  return 0;
}

A questo punto siamo pronti per compilare il programma:

g++ -o HelloWorld HelloWorld.cxx
ed eseguirlo:
./HelloWorld
Come piccolo esercizio finale, potete provare a compilare ed eseguire il programma senza includere il carattere di fine riga, e vedere cosa succede.

Librerie matematiche

Per valutare espressioni matematiche ci sarà bisogno almeno delle funzioni matematiche elementari. In FORTRAN molte funzioni matematiche e l'elevamento a potenza sono funzioni intrinseche del linguaggio. In C++ le funzioni matematiche hanno quasi tutte lo stesso nome delle analoghe in FORTRAN (la radice quadrata sara' sqrt, per esempio), ma sono considerate come facenti parte di una libreria separata. Quindi per poterle utilizzare a stretto rigore sarebbe necessario includere il file header <cmath> contenente la dichiarazione delle funzioni.

Fortunatamente la maggior parte dei compilatori ora effettuano automaticamente questa inclusione e non e' necessaria nessuna azione esplicita da parte dell'utente.

L'elenco delle funzioni delle librerie matematiche si trova nelle pagine 54-57 del libro di testo. In particolare, vale la pena di ricordare due funzioni di largo uso, ma dal nome diverso dal FORTRAN, sono l'elevamento a potenza, pow(x,a) (=xa ), ed il valore assoluto che e' abs per le espressioni integer e fabs per le espressioni float (ed e' importantissimo usare le espressioni giuste al posto giusto!)

Esercizio

Scrivere un programma che chieda all'utente di immettere da terminale un numero reale x e restituisca il valore |x1/3|.

Il programma di integrazione numerica

Schema del programma

Data una funzione f(x), definita su un certo intervallo [x0,xN], suddiviso in N sottointervalli di uguale dimensione, il suo integrale numerico con il metodo dei trapezoidi e' dato dalla formula:
Formula dei trapezoidi
dove
h=(xN-x0)/N

Quindi un approccio per il programma potrebbe essere:
#include <iostream> /* per avere le definizioni di cin e cout */


int main() {
/* inserire le dichiarazioni delle variabili da usare */

/* leggere da terminale x0, xN e N */

h=(xN-x0)/N;
integrale = 0.5*h*(f(x0)+f(xN));
for ( i=1; i<N; i++ ) {
x = /* calcolare la coordinata x del punto i-esimo */
integrale += h*f(x);
}

/* stampa dell'integrale */

return 0; /* bisogna fornire un valore di ritorno */
}
dove spetta a voi completare questo schema introducendo il codice appropriato al posto dei commenti e trasformando lo pseudocodice in un effettivo programma.

Si noti che l'espressione esplicita della funzione f(x) viene ripetuta almeno tre volte e, se vogliamo cambiare la funzione integranda, dobbiamo cambiare il codice in tre punti diversi. Non si puo' semplificare il tutto usando un'opportuna direttiva #define?

Dopo che siete riusciti ad ottenere un programma compilabile, provate a vedere se fornisce il risultato corretto su alcune funzioni di prova (ad esempio sin(x) o la radice quadrata). e' istruttivo prendere un intervallo in cui la funzione varia molto e, partendo con pochi intervalli (N=2,4...), vedere quando si ottiene un risultato a vostro avviso accettabile.

La realizzazione del programma ed il suo controllo costituiscono la parte obbligatoria dell'esercizio di oggi. Chi ha tempo puo' proseguire con due sviluppi del programma: la scrittura di un programma che utilizza la formula di Simpson per la stima dell'integrale o la scrittura di un programma che, invece di calcolare l'integrale con un numero fisso di passi, aumenta gradualmente la granularita' dell'intervallo fino ad ottenere una precisione prefissata.

Integrazione alla Simpson

Mantenendo lo stesso numero di valutazioni della funzione sull'intervallo, la formula di Simpson migliora la precisione dell'integrale tenendo in conto anche la convessita' della funzione integranda. La sua applicazione richiede che N sia pari e la formula da usare e':
Formula di Simpson

La complicazione rispetto al programma precedente e' che nella lettura da terminale di N, bisogna controllare che questo sia pari. Inoltre all'interno del ciclo for bisogna usare pesi differenti per i termini pari (2/3) e quelli dispari (4/3) della sommatoria.

A questo proposito, il C++ fornisce un utile operatore: l'operatore % "resto della divisione per". Ad esempio il valore dell'espressione N%2 sara' 0 se N e' pari o 1 se N e' dispari.

Si verifichi su alcune funzioni di prova che la formula di Simpson e' infatti piu' precisa di quella dei trapezoidi.

Integrazione a precisione prefissata

Per migliorare la precisione dell'integrazione, una possibile soluzione e' aumentare il numero di punti di integrazione. La formula dei trapezoidi ha una caratteristica attraente per fare questa operazione: se si raddoppia il numero dei sottointervalli, non e' necessario ricalcolare tutto l'integrale, ma basta aggiungere alla sommatoria dei valori della funzione la valutazione nei nuovi punti aggiunti a meta' tra i punti precedentemente calcolati.

Questa possibilita' offre lo spunto per un programma che abbia in input solo gli estremi dell'intervallo e poi proceda a valutare l'integrale raddoppiando ogni volta il numero di intervalli fin tanto che l'errore relativo sull'integrale non raggiunge la precisione desiderata. Per valutare l'errore, possiamo considerare la differenza tra due valutazioni successive dell'integrale.

Siccome il numero di passi non e' noto a priori, concettualmente il processo di raddoppio degli intervalli puo' stare bene all'interno di un ciclo while:

while ( fabs( (newint-oldint)/oldint ) > EPS ) {

oldint=newint;
N*=2;
h/=2;
for ( /* costruite il for in modo che si cicli solo sui nuovi punti */ ) {
x=...
sum+=f(x);
}
newint = h*sum;
}
Nello scrivere questo frammento del codice, si e' supposto che EPS sia una costante definita mediante una direttiva #define. La variabile sum contiene la somma delle valutazioni delle funzioni (la parte tra parentesi nella formula dei trapezoidi) e si e' preferito separarla dalla  variabile contentente l'integrale perche' le due sono legate dal fattore h che varia variando il numero di intervalli.

IMPORTANTE: in questo programma bisogna fare molta attenzione all'inizializzazione corretta di tutte le variabili!

Al termine del programma, oltre a farsi stampare il valore dell'integrale, e' opportuno far stampare il numero di passi  necessari per raggungere la precisione voluta.

Relazione

Prima di lasciare l'aula, siete pregati di riempire un piccolo formulario con domande relative allo svolgimento dell'esercizio.