Laboratorio di Calcolo 2

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

Università degli Studi di Milano

Anno Accademico 2004/2005

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. librerie matematiche;
  6. 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 è stato scritto e compilato con successo, le sue funzionalità dovranno essere controllate con funzioni il cui integrale è noto.

Qui di seguito ci sono dei suggerimenti su come impostare la struttura del programma e le differenze tra il FORTRAN che avete studiato nel Laboratorio di Calcolo 1 ed il C.

main

In C non esistono programmi o subroutine, ma solo funzioni. Il programma eseguibile è in effetti una funzione speciale che si chiama main ed ha un valore di ritorni 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 più semplice di invocare il compilatore C è con:
gcc -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 gcc -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 perché 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 stdio.h 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 può 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 è una direttiva molto utile, ma anche subdola: FUNC(x+y) dà 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 può essere utile terminare la compilazione dopo questa fase, utilizzando l'opzione -c del compilatore:
gcc -c -o nomefileoggetto nomefilesorgente
Se il compilatore non riesce a capire che istruzioni generare perché 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 è molto esplicativo. In più il compilatore dice sempre a quale riga dell file sorgente è presente un errore. Tranne nel caso in cui ci si sia dimenticati un ; al termine di una riga, nel qual caso l'informazione può 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 gcc senza l'opzione -c:
gcc -o nomeeseguibile [lista file sorgente] [lista file oggetto] -lfilelibreria1 -lfilelibreria2...

Il comando gcc prevede molte opzioni, che si possono mettere tutte prima del -o. Una particolarmente consigliata è 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, le funzioni da usare sono scanf e printf. Il loro utilizzo è stato introdotto a lezione. Però se avete dubbi, potete sembre guardare la descrizione dettagliata di queste funzioni con il comando man scanf o man printf. Ricordatevi che scanf ha bisogno dell'indirizzo della variabile per potervi immagazzinare il valore letto dal terminale, quindi attenti ad usare correttamente l'operatore & (=indirizzo di). Per utilizzare queste funzioni.

Librerie matematiche

Per valutare la vostra funzione, probabilmente avrete bisogno di funzioni matematiche elementari. In FORTRAN molte funzioni matematiche e l'elevamento a potenza sono funzioni intrinseche del linguaggio. In C non è cosí semplice. Le funzioni matematiche hanno quasi tutte lo stesso nome delle analoghe in FORTRAN (la radice quadrata sarà sqrt, per esempio), ma sono contenute in una libreria separata. Quindi per poterle utilizzare è necessario fare due cose:
  1. a livello di scrittura del codice, ricordarsi di includere il file header contenente la dichiarazione delle librerie matematiche, di modo che il compilatore sappia di che cosa stiamo parlando. Questo si fa, inserendo all'inizio del file la direttiva:
  2. #include <math.h>
    che viene interpretata dal preprocessore, che inserisce nel programma il contenuto del file math.h, che si trova tra i file di sistema;
  3. al momento della compilazione, bisogna dire al linker che le funzioni matematiche si trovano precompilate in un opportuno file di libreria. Poiché le librerie matematiche sono una delle componenti maggiormente usate del C, l'opzione da fornire al compilatore è molto semplice: basta aggiungere -lm all'istruzione di compilazione.
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 è abs per le espressioni integer e fabs per le espressioni float (ed è importantissimo usare le espressioni giuste al posto giusto!)

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 è dato dalla formula:
Formula dei trapezoidi
dove
h=(xN-x0)/N

Quindi un approccio per il programma potrebbe essere:
#include <stdio.h> /* per avere le definizioni di scanf e printf */
#include <math.h> /* per avere le definizioni delle funzioni matematiche */

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

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

h=(xN-x0);
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 può semplificare il tutto usando un'opporuna 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). È 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 può 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 granularità 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 convessità della funzione integranda. La sua applicazione richiede che N sia pari e la formula da usare è:
Formula di Simpson

La complicazione rispetto al programma precedente è 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.

Il C fornisce un utile operatore a proposito: l'operatore % "resto della divisione per". Ad esempio il valore dell'espressione N%2 sarà 0 se N è pari o 1 se N è dispari.

Si verifichi su alcune funzioni di prova che la formula di Simpson è infatti più precisa di quella dei trapezoidi.

Integrazione a precisione prefissata

Per migliorare la precisione dell'integrazione, una possibile soluzione è 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 è necessario ricalcolare tutto l'integrale, ma basta aggiungere alla sommatoria dei valori della funzione la valutazione nei nuovi punti aggiunti a metà tra i punti precedentemente calcolati.

Questa possibilità 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 è noto a priori, concettualmente il processo di raddoppio degli intervalli può 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 è 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 è preferito separarla dalla  variabile contentente l'integrale perché 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, è 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.