Il linguaggio C21.1 Linux il linguaggio C e la programmazioneNel sistema operativo Linux sono presenti vari linguaggi di programmazione, come il C, il Perl, il PHP etc, ma qui ci occuperemo del linguaggio C, in quanto Linux e' stato scritto usando il linguaggio C. Una differenza peculiare tra i sistemi Linux e Windows e' la disponibilita' dei sorgenti dei vari progammi del sistema operativo stesso. Un sorgente e' la traduzione del linguaggio comprensibile dalla macchina in linguaggio comprensibile dagli umani. Per capire cosa e' un sorgente, occorre partire dall'inizio, cioe' dal microprocessore. Ogni PC contiene al suo interno una CPU (Central Processing Unit) o microprocessore. Ogni processore puo' eseguire un numero di istruzioni prestabilito. Si dice cioe' che ogni microprocessore possiede un determinato set di istruzioni. Ad esempio, per spostare un valore da una parte all'altra della memoria RAM, esiste una istruzione MOV (da MOVe, cioe' sposta), per saltare da una istruzione all'altra viene usata una istruzione JMP (da JuMP, cioe' salto) e via dicendo. Il microprocessore Intel Pentium riconosce un insieme di istruzioni che sono diverse da quelle riconosciute da un microprocessore AMD Athlon le quali a loro volta sono diverse da un microprocessore Motorola 68000 e cosi' via. Ogni tipo marca e modello di microprocessore cioe', riconosce esclusivamente le istruzioni per le quali e' stato costruito. Ad ogni modo queste istruzioni alla fine sono sempre codificate in numeri binari. Ad esempio un ipotetica istruzione 'Mov reg2,reg1' potrebbe corrispondere alla serie 0100010001000100. Un programma e' un insieme di istruzioni (o comandi) impartiti al microprocessore e puo' essere considerato in definitiva come un file contenente una lunghissima serie di 0 e di 1. Questa serie interminabile di zeri e di 1 viene letta e capita perfettamente dalla macchina ma non si puo' dire altrettando degli umani... ;o) Il microprocessore legge le istruzioni e le esegue. I primissimi programmi erano di dimensioni modeste, percio' con non poca difficolta' si potevano scrivere direttamente in binario. Successivamente con l'aumentare delle dimensioni dei programmi questo tipo di gestione divenne impraticabile e si passo' dal sistema binario al sistema esadecimale perche' piu' compatto. Ma in fondo anche il sistema esadecimale non e' un linguaggio da 'umani'...leggere una serie di lettere e cifre come AF01D2C43AC012FC43D non e' proprio il massimo. Ecco che allora venne creato un linguaggio mnemonico piu' vicino agli umani: ogni istruzione che originariamente era una riga di 0 e 1 venne tradotta in istruzioni piu' leggibili come 'mov reg2,reg1'. Il rapporto era 1 a 1, cioe' ad ogni istruzione composta da una serie di 0 e 1 corrispondeva ora una istruzione in linguaggio mnemonico. Questo linguaggio mnemonico si chiama assembler. Mediante tale linguaggio la creazione dei programmi divenne un'attivita' alla portata degli umani, ma si trattava pur sempre di una attivita' abbastanza complessa. Le cose rimasero cosi' fino a quando divenne chiaro che alcuni insiemi di istruzioni potevano essere raggruppati per formare delle macroistruzioni. Per eseguire una determinata azione infatti, si scriveva sempre lo stesso insieme di istruzioni. Ad esempio per leggere un carattere da un file, il microprocessore deve eseguire sempre determinate operazioni (scritte in assembler dai programmatori). Ma allora perche' non creare delle macroistruzioni composte da piu' istruzioni elementari assembler? Ad esempio una macroistruzione 'leggi-file', una 'scrivi-file', una 'verifica-condizione' e via dicendo. Vennero cosi' alla luce i linguaggi di programmazione di seconda generazione, cioe' quei linguaggi composti da macroistruzioni decisamente piu' leggibili dagli umani. Ora questi linguaggi evoluti, erano dotati di macroistruzioni come read, write, move, if, tutte istruzioni intelligibili per gli umani. Read significava leggi un file, write scrivilo etc. In questi tipi di linguaggi il rapporto non e' piu' di 1 a 1 come con l'assembler ma di 1 a N. Cioe', ad ogni macroistruzione corrispondono piu' istruzioni elementari assembler. Tali linguaggi vengono definiti linguaggi di alto livello. Successivamente vennero creati dei linguaggi di terza generazione: in sostanza piu' passa il tempo e piu' i nuovi linguaggi si discostano dalla lingua della macchina e si avvicinano alla lingua degli uomini. Il C e' un linguaggio di alto livello, cioe' ad ogni istruzione scritta in C corrispondono N istruzioni elementari. Un programma scritto mediante un linguaggio di programmazione viene definito sorgente. Un sorgente quindi e' un insieme di istruzioni impartite al microprocessore e scritte in un file di testo. Un file contenente istruzioni in cifre binarie interpretabili dalla macchina viene detto eseguibile o file binario. Ma come fare per tradurre questo file sorgente leggibile dagli umani in istruzioni in linguaggio binario? Utilizzando i compilatori. Un compilatore non e' altro che un programma che legge un sorgente e lo traduce nella corrispondente serie interminabile di cifre binarie leggibili dal microprocessore e quindi eseguibili. In sostanza quindi, un compilatore traduce un file sorgente in un file eseguibile. Questa affermazione evidenzia lo scopo di un compilatore ma nella realta', come al solito, le cose sono un po' piu' complesse. Infatti il compilatore non crea un eseguibile direttamente, ma crea un file oggetto. Il file oggetto e' un file parzialmente eseguibile. Per poter arrivare all'eseguibile vero e proprio occorre ancora l'intervento di un altro programma: il linker (o linkage editor). I programmatori nel tempo si resero conto che alcune funzioni generali venivano ripetute spesso all'interno dei loro programmi. Programmi che fanno cose diverse contengono in realta' al loro interno alcune istruzioni uguali. Ad esempio un programma che visualizza a video il risultato di una somma di due numeri ed un programma che visualizza a video il risultato di una radice quadrata sono due programmi diversi. Producono due risultati diversi, hanno istruzioni diverse al loro interno ma nonostante cio' hanno un punto in comune: visualizzano a video un risultato. Perche' scrivere ogni volta le istruzioni necessarie per visualizzare a video un risultato? Perche' non creare un piccolo programmino generico che effettua questo lavoro? Un programma che prende dei dati in input e li visualizza a video. Si potrebbe creare un programma che legge un carattere da un file, un programma che scrive un carattere in un file e via dicendo. Si potrebbero cioe' creare dei piccoli programmini che effettuano una operazione generica ed utilizzarli tutte le volte che occorre effettuare quell'operazione all'interno di un qualsiasi programma. Infatti e' stato fatto cosi': sono stati creati parecchi programmini generici utilizzabili da qualsiasi programmatore. Questi programmini chiamati moduli (o anche membri) sono stati inseriti all'interno di contenitori chiamati librerie. Sono anche loro dei programmini parzialmente eseguibili, cioe' dei file oggetto. I programmi scritti dai programmatori hanno bisogno di questi programmini per poter eseguire delle operazioni generiche (come la stampa a video) e questi programmini a loro volta hanno bisogno di un programma che gli fornisca dei dati per svolgere il loro lavoro. Come collegare i programmi ai moduli di libreria? Questa operazione di collegamento (link) viene effettuata dal linker. Il linker unisce, cuce, collega dei moduli oggetto tra loro. Ogni file oggetto (il programma appena scritto ed i vari moduli di libreria) rappresenta un tassello del puzzle. Il linker unisce i vari tasselli e produce il puzzle, cioe' il file eseguibile finale. Studiando il linguaggio C si scoprira' che esistono una moltitudine di programmini gia' scritti e perfettamente funzionanti posti all'interno di librerie ed utilizzabili da chiunque. Esistono svariate librerie ed ogni libreria si occupa di un argomento particolare. Esistono librerie generiche, librerie dedicate alle stringhe, librerie dedicate alle operazioni matematiche, librerie dedicate all'accesso ai file, librerie dedicate alla grafica e via dicendo. Quando utilizziamo uno di questi programmini gia' scritti, diciamo che utilizziamo una funzione di libreria. Ad esempio la funzione 'printf' e' un programmino che permette di visualizzare a video i dati che gli forniamo in input. E' una funzione usata all'interno di parecchi programmi. Ma torniamo ai sorgenti: dovrebbe ormai essere chiaro che per ogni file esguibile deve esistere da qualche parte il relativo file sorgente. Qui entra in ballo Linux. Infatti Linux insieme ai programmi eseguibili fornisce anche i relativi sorgenti. Qualsiasi programmatore che conosca il linguaggio C puo' esaminare Linux in profondita' per capire quali sono le operazioni che eseguono determinati programmi. Ma un programmatore che conosce il linguaggio C, puo' anche modificare i programmi o addirittura riscriverli! Certo, occorre essere un programmatore esperto, ma e' possibile. Di quali strumenti occorre disporre per scrivere un programma? Un editor di testi per scrivere il sorgente nel linguaggio di programmazione che si conosce ed il relativo compilatore per tradurlo in formato eseguibile. Per ogni linguaggio di programmazione esiste il relativo compilatore. In ogni distribuzione Linux viene fornito anche un compilatore per il linguaggio C. Il compilatore C fornito nelle varie distribuzioni Linux e' il compilatore GCC (GNU Compiler Collection). Quando parliamo genericamente di compilatore normalmente intendiamo sia il compilatore che il linker. 21.2 Un approccio insolito al C
Sin dai tempi della prima versione del libro 'Linguaggio C' di Brian W. Kerninghan e
Dennis M. Ritchie(creatore del C), la tradizione vuole che per trattare un linguaggio di programmazione
si debba partire dal famoso programmino Hello World, cioe' il
piu' semplice programma che visualizza a video la frase 'Hello World'. Qui pero' vorrei tentare un approccio diverso: comincero' ad affrontare i tipi di dati previsti nel C evidenziando tutti quegli aspetti che spesso sono fonte di confusione se non chiariti adeguatamente. Il testo guida adottato qui e' il famoso libro: 'linguaggio C' di Brian W. Kerninghan e Dennis M. Ritchie (la cosidetta bibbia del C, versione in lingua originale: "The C Programing Language 2nd Edition (ANSI C)", B.W. Kernighan, D.M. Ritchie (1988); Prentice Hall; ISBN 0-13-110362-8. Versione tradotta in italiano: Linguaggio C, Gruppo Editoriale Jackson, ISBN 88-7056-443-6). Cerchero' di rimanere fedele alle raccomandazioni del C ANSI/ISO. ANSI e ISO sono due organizzazioni che si occupano di standard, ANSI sta per American National Standard Institute mentre ISO sta per International Standard Organization. Lo standard ANSI C venne definito nel 1989 dall' American National Standard Institute e venne successivamente adottato dall' International Standard Organization come standard internazionale. Lo standard ANSI originale e' definito nel documento ANSI X3.159-1989 (chiamato anche C89). Tale standard venne adottato dall'ISO nel documento: ISO/IEC 9899:1990 (chiamato anche C90). Per avere maggiori informazioni sullo stato dello standard si possono consultare i siti www.iso.ch e http://std.dkuug.dk/JTC1/SC22/WG14/ oppure cercare su google la stringa 'ISO/IEC 9899: 1999'. Ma perche' e' importante uno standard? Quando sviluppiamo dei programmi in C possiamo scrivere del codice:
Un codice conforme allo standard verra' compilato da qualsiasi compilatore aderente allo standard ANSI e, cosa piu' importante, sara' portabile, nel senso che, una volta ricompilato, girera' ('girare' e' un termine gergale che significa 'essere eseguibile'. Quindi quando si dice che un programma 'gira su Linux' significa che tale programma e' eseguibile sui sistemi Linux) su qualsiasi sistema operativo senza apporre modifiche alcune o con poche modifiche. Viceversa, un programma non aderente allo standard ANSI non sara' portabile, nel senso che potra' girare solo sul sistema operativo per il quale e' stato pensato. In particolare, del codice non aderente allo standard, potra' avere un comportamento definito dall'implementazione oppure indefinito. Scrivere del codice con comportamento definito dall'implementazione significa scrivere programmi che possono comportarsi in un modo piuttosto che in un altro a seconda del compilatore usato. Poiche' i compilatori sono ottimizzati per il sistema operativo per i quali sono stati scritti, ogni compilatore e' diverso da un altro. Un compilatore ottimizzato per Linux e' scritto (implementato) in modo da produrre codice piu' efficente per i sistemi Linux, mentre un compilatore Windows e' implementato in modo da sfruttare al massimo le caratteristiche del sistema operativo Windows. Ne consegue che le stesse istruzioni scritte in linguaggio C vengono tradotte in modo diverso a seconda del compilatore usato. Se si scrive del codice che produce determinati risultati con un compilatore Linux e se tale codice non e' conforme allo standard ma e' implementation defined, i risultati che si avranno ricompilando lo stesso codice con un compilatore Windows potrebbero essere diversi. Infine, scrivere del codice dal comportamento indefinito, significa scrivere del codice sintatticamente corretto ma semanticamente errato. Del codice con queste caratteristiche sara' scritto seguendo le regole della grammatica del linguaggio (ed il compilatore non produrra' errori) ma conterra' delle istruzioni logicamente insensate. Di fronte a queste istruzioni il compilatore potra' generare un eseguibile funzionante, un eseguibile che va in crash (si pianta), un eseguibile che spegne il PC e chissa' che altro! Da tutto questo ne segue che sarebbe meglio scrivere del codice conforme allo standard ANSI. I compilatori aderenti allo standard ANSI sono tenuti a documentare punto per punto tutte le parti implementation defined del linguaggio. Piu' avanti verrano evidenziate quelle parti di codice che potrebbero avere un comportamento implementation defined o undefined (indefinito). 21.3 Variabili e costantiUn programma e' un file di testo contenente una parte di istruzioni (la parte definita codice) ed una parte dati. Ad esempio per effettuare una divisione ci occorre un foglio di carta ed una penna per annotare tutti i passi ed i valori intermedi prima di ottenere il risultato finale (oppure usare una calcolatrice! ;o). La stessa cosa e' valida per i programmi. Un programma, eseguendo determinate operazioni, puo' avere la necessita' di memorizzare dei valori temporanei da qualche parte. Questi valori vengono memorizzati nella memoria RAM all'interno delle variabili. Una variabile puo' essere paragonata ad una scatola che puo' contenere dei valori che variano nel tempo. Tale scatola puo' contenere delle penne, dei colori, delle puntine da disegno, dei fiori etc. Quindi una variabile e' una zona della memoria RAM riservata al programma che puo' contenere dei valori. Supponendo di voler memorizzare il valore 2 da qualche parte nella memoria, potremmo usare una variabile 'x' ed assegnargli il valore 2. A questa variabile potremmo dare un nome a piacere del tipo 'mia-variabile'. Successivamente potremmo assegnare a tale variabile un altro valore se vogliamo. Ma come viene creata una variabile nella memoria RAM? La RAM e' una matrice di celle di memoria, dove ogni cella puo' contenere un valore. Puo essere paragonata ad un quaderno a quadretti dove all'interno di ogni quadretto si puo' inserire un valore. Il microprocessore individua ogni cella di memoria (il quadretto) in base al suo indirizzo all'interno della RAM (il foglio) usando dei sistemi di indirizzamento particolari. Ad esempio potrebbe individuare un quadretto tramite le coordinate riga/colonna: la riga 37 e colonna 44 identificano insieme un preciso quadretto all'interno del foglio. Un qualsiasi sistema di individuazione del quadretto viene definito indirizzamento. In realta' all'interno della RAM tutto e' molto piu' complesso ed ogni cella di memoria ha un indirizzo ben preciso. Poiche' gli indirizzi di memoria sono difficili da ricordare per gli umani, nei linguaggi di programmazione sono state inventate le variabili. Una variabile nasce quando viene eseguito un programma e muore quando tale programma termina l'esecuzione. Una variabile allora non e' altro che un nome simbolico al quale e' associato un indirizzo di memoria ben preciso. Piu' precisamente il nome di una variabile viene detto identificatore. E' il compilatore che, nella fase di compilazione (cioe quando traduce un sorgente in file oggetto) associa al nostro nome a piacere un preciso indirizzo di memoria. Tutte le variabili all'interno del nostro programma avranno cosi' nomi di fantasia: pippo, pluto, topolino, mia-variabile, variabileX etc, ma ad ogni nome il compilatore associera' un indirizzo di memoria ben preciso (il compilatore compila delle tabelle di simboli appunto, dove ad ogni nome o simbolo viene associato un indirizzo di una zona di memoria RAM). Tutte le volte che faremo riferimento alla variabile 'mia-variabile', il compilatore la cerchera' nelle sue tabelle dei simboli dalla quale leggera' l'indirizzo di memoria associato. Come al solito, nella realta' le cose non sono cosi' semplici, perche' con un nome di variabile possiamo identificare una o piu' celle in base al tipo di variabile. Il contrario di una variabile e' una costante, cioe' un valore che non varia nel tempo ma anzi rimane fisso (costante) per tutta la durata dell'esecuzione del programma. Percio' una variabile puo' variare mentre una costante non puo' variare. Occorre aver ben chiaro questo concetto in modo da evitare confusioni quando parleremo dei puntatori e delle loro relazioni con gli array. 21.4 I tipi di dati
Abbiamo detto che un valore o meglio un oggetto occupa una o piu' celle di memoria a seconda del tipo di oggetto.
Una variabile puo' contenere solo oggetti di un determinato tipo. E' un po' come dire che la scatola delle scarpe puo' contenere solo scarpe, la scatola dei fiammiferi puo' contenere solo fiammiferi, la scatola delle caramelle solo caramelle e cosi' via. Insomma, ogni scatola puo' contenere solo oggetti di un determinato tipo. Non esistono quindi delle variabili generiche che possono contenere qualsiasi tipo di oggetto (in realta' nel C e' stato introdotto il tipo void che fa riferimento ad un oggetto inesistente e quindi non associato ad alcun tipo in particolare). Ma vediamo quali erano i tipi di dati ammessi in C inizialmente: Le dimensioni dei tipi fissate dallo standard ANSI non sono rigide in quanto possono variare a seconda dell'implementazione, cioe' ogni implementazione deve attenersi ai limiti minimi ma puo' aumentarli se lo ritiene necessario. Ad esempio il tipo int puo' avere una dimensione che varia da 2 byte (16 bit) a 4 byte (32 bit) in base alla macchina. Per conoscere la dimensione di un tipo nella macchina che si sta usando, e' possibile usare l'operatore sizeof messo a disposizione dal C. In seguito vedremo l'uso di tale operatore. Ad ogni modo esistono alcune regole fissate dallo standard ANSI che devono essere rispettate da tutti i compilatori conformi a tale standard. In particolare gli short e gli int devono essere di almeno 2 byte ed i long di 4. Inoltre uno short non puo' essere piu' grande di un int ed un int non puo' essere piu' grande di un long. I valori minimi e massimi consentiti e legati al compilatore sono definiti all'interno di 2 file: limits.h e float.h. In generale si ha: char ≤ short ≤ int ≤ long
In ogni caso i tipi numerici char, short, int, long possono essere signed o unsigned, cioe' con o senza segno. I tipi senza
segno, in base alla loro ampiezza possono assumere i seguenti valori:
-2n-1...2n-1-1
Dove n rappresenta il numero di bit utilizzati. Ad esempio un tipo di dato con ampiezza di 2 byte utilizzera' 16 bit (2*8) pertanto il valore di n sara' 16. In realta' lo standard ISO/IEC 9899: 1999 pone dei limiti inferiori, infatti un char ad esempio varia da -127 a 127 ed uno short da -32767 a 32767. E' bene far riferimento allo standard se l'obiettivo e' la portabilita' ed ai file limits.h e float.h per conoscere il range di valori esatto consentito dal compilatore se non si e' interessati alla portabilita'. I tipi char, a dispetto del nome sono in realta' dei piccoli interi e possono contenere un intervallo di numeri da -128 a 127 se segnati o da 0 a 255 se senza segno. Cio' puo' essere dimostrato dal codice seguente perfettamente legale:
Inizializzare una variabile di tipo char con la costante carattere 'A' infatti, equivale inizializzarla con il numero 65 se stiamo lavorando su un sistema che utilizza la codifica ASCII (ma puo' equivalere ad un altro numero, come ad esempio 193 se stiamo lavorando su un sistema che utilizza la codifica EBCDIC). Occorre far attenzione agli apici: una costante carattere e' delimitata dagli apici singoli mentre gli apici doppi delimitano una stringa costante. Una curiosita': se stiamo lavorando su un sistema Unix/Linux abbiamo 3 tipi di apici a disposizione: gli apici singoli, gli apici doppi e gli apici inversi. Ognuno ha un significato specifico. I tipi float double e long double, sono tipi di dato in virgola mobile (floating point, cioe' punto fluttuante, data la notazione inglese inversa alla nostra secondo la quale la virgola decimale e' rappresentata dal punto, mentre per rappresentare le decine, le centinaia, le migliaia etc si usa la virgola) sono un po' diversi e meritano una trattazione separata. Quando trattiamo numeri con decimali (cioe' non interi), numeri molto grandi o numeri molto piccoli, possiamo utilizzare la rappresentazione in virgola mobile. Ad esempio se trattiamo dati come i dati finanziari di una azienda, la distanza tra la luna e la terra, la massa di un elettrone, gli abitanti di una nazione e cosi' via. I calcoli in virgola mobile vengono eseguiti dalla FPU (Floating Point Unit) una sorta di calcolatore specializzato per questi calcoli ed integrato nella CPU. Inizialmente i calcolatori possedevano la CPU ed un coprocessore matematico utilizzato per questo genere di calcoli. Attualmente il coprocessore e' integrato nella CPU (FPU) (le applicazioni 3D sono un esempio di applicazioni che fanno intenso uso di calcoli in virgola mobile). Secondo tale rappresentazione un numero puo' essere rappresentato in questi modi:
0,001 oppure 1*10-3 oppure 1E-3
Queste 3 notazioni si equivalgono. Percio' 1E-38 equivale a 1*10-38 (notazione scientifica), cioe': 0,00000000000000000000000000000000000001 (spero di aver contato correttamente 38 zeri ;o).
I numeri in virgola mobile possono essere a precisione singola oppure a precisione doppia. I float sono
numeri in virgola mobile a singola precisione, mentre i double sono a doppia precisione. Nella precisione singola vengono
usati 32 bit (4 byte) mentre nella precisione doppia 64 (8 byte). Esiste poi la precisione doppia estesa che usa 80 bit (10 byte) e la precisione quadrupla che usa 128 bit. Nella notazione a virgola mobile il numero viene scomposto in 2 parti: la mantissa (o significante) e l'esponente. La mantissa contiene le cifre piu' significative del numero, mentre l'esponente indica la posizione della virgola all'interno del numero stesso (da qui la denominazione di virgola 'mobile'). Nella precisione singola si hanno a disposizione 32 bit di cui 23 dedicati alla mantissa 1 al segno ed i rimanenti 8 bit sono dedicati all'esponente. Nella precisione doppia si hanno a disposizione 64 bit di cui 52 per la mantissa, 1 per il segno ed i rimanenti 11 per l'esponente. Questo come stabilito dallo standard IEEE 754 (Institute of Electrical and Electronics Engineers, una associazione senza scopo di lucro formata da piu' di 380.000 membri appartenenti a 150 paesi che si occupano di elettronica, telecomunicazioni, computer etc). E' importante saper gestire i tipi di dato a virgola mobile per evitare di commettere errori. Nel passato alcuni errori sui tipi a virgola mobile sono stati fatali. 4 giugno 1996: viene lanciato Arianne 5 a Kourou. Dopo 36 secondi il razzo cambio' rotta e si autodistrusse. Cosa era accaduto? Un sistema di riferimento inerziale cerco' di convertire un numero a 64 bit in virgola mobile in un numero a 16 bit in virgola fissa. Poiche' il numero di partenza era troppo grande per essere contenuto nel numero di arrivo, venne generato un errore di overflow che provoco' l'invio di un messaggio diagnostico al computer di bordo. Questo messaggio di errore venne interpretato erroneamente come un dato di volo corretto. Tornando alla notazione scientifica, abbiamo visto che un numero puo' essere espresso nella forma: 1*10n. Se cio' e' vero allora le forme:
-∞oooo-m eoooooooooooooooooooo-m-eoooooZEROooooo m-eoooooooooooooooooooo m eoooo ∞ L' intervallo dei valori rappresentabili nella modalita' in virgola mobile e' evidenziato in rosso i rimanenti intervalli non sono rappresentabili. In particolare gli intervalli evidenziati in giallo rappresentano l'overflow, mentre gli intervalli evidenziati in verde rappresentano l'underflow. La differenza tra underflow ed overflow e' importante in quanto in caso di errori di underflow il calcolatore puo' porre rimedio esprimendo con lo zero il valore infinitesimale che e' uscito dal range disponibile (e si ottiene un errore di approssimazione) mentre in caso di errori di overflow il calcolatore non e' in grado di porre rimedio (non e' possibile approssimare introducendo un valore infinito) percio' si blocca il calcolo ed il programma termina in errore. E' da notare che la distanza tra due numeri in virgola mobile vicini tra loro tende ad aumentare con l'aumentare del valore del numero e tende a diminuire verso lo zero. Cioe' la distribuzione dei numeri non e' costante. In altre parole si ha una rarefazione all'aumentare del valore del numero e viceversa un addensamento con il diminuire del valore. Diminuendo il valore la distanze si accorciano e i numeri si avvicinano tra loro mentre aumentando il valore le distanze si allungano ed i numeri si allontanano tra loro: -∞---|-|-|-|-|-ZERO-|-|-|-|--|---|-----|------|---------|----------|---------------|--------------------> ∞ Ad ogni modo la percentuale di errore e' costante nei vari intervalli. La mantissa rappresenta la precisione del valore rappresentato, cioe' il numero di cifre significative del valore rappresentato: maggiore e' il numero delle cifre della mantissa maggiore sara' la precisione ottenuta. Poiche' un numero binario equivale in decimale al numero moltiplicato per 0,301, avremo che 2127 equivale circa a 1038 in quanto 127 * 0,301 = 38,227. Leggendo i valori contenuti nel file float.h l'intervallo di valori ammissibile dai tipi in virgola mobile della mia implementazione e':
float = da -2 128 a -2-125 e da 2-125 a 2 128 Oppure in decimale:
float = da -10 38 a -10-37 e da 10-37 a 10 38
Il massimo valore della parte intera esprimibile, e' in realta la precisione massima ottenibile, mentre il numero massimo della parte frazionaria (esponente) indica l'intervallo massimo di numeri rappresentabili. Togliendo un bit alla mantissa lo si puo' aggiungere all'esponente e viceversa. Per conoscere l'intervallo di valori interi ammissibili o, piu' esattamente il numero di cifre della parte intera esprimibile, occorre far riferimento ancora una volta allo standard IEEE 754. Secondo tale standard i tipi float (precisione singola) dispongono di 32 bit di cui 24 sono dedicati alla parte intera (in realta' 23 perche' uno e' dedicato al segno). Cio' significa che il valore massimo esprimibile (limitatamente alla parte intera) e' di circa 223, ossia 8388608, quindi 6 cifre 'piene' e circa 7 cifre 'quasi' piene (esattamente fino a 8388608) a disposizione. Cio' significa che per elaborare un numero di 8 cifre una variabile di tipo float non e' sufficiente. Lo stesso ragionamento e' applicabile ai double (precisione doppia) ed ai long double (precisione estesa). Per i double, sempre secondo lo standard IEEE 754 i bit disponibili per la parte intera sono 52 (53 - 1 bit dedicato al segno) mentre per i long double sono 63 (64 - 1). Eseguendo lo stesso calcolo visto per i float sara' evidente che con i double saranno disponibili 15 cifre per la parte intera (fino a 252 = 4503599627370496) e con i long double 18 cifre (fino a 252 = 9223372036854775808). Comunque questi valori sono indicativi, in quanto l'esatto intervallo e la precisione dipende dall'implementazione ed e' ricavabile esclusivamente dai file limits.h, float.h e values.h. Un compilatore diverso su una macchina diversa, potrebbe fornire valori diversi. Ecco la tabella dei tipi a virgola mobile secondo lo standard IEEE 754:
Per verificare i limiti delle variabili in virgola mobile supportate dal compilatore e' possibile utilizzare il seguente programma:
Lo standard ISO/IEC 9899:1990 in realta' pone dei limiti minimi (in valore assoluto) che ogni implementazione deve rispettare, ma ogni implementazione e' libera di aumentare tali valori (sempre in valore assoluto):
float = da 1E-37 a 1E 37 Attenzione a non confondere il long double con il nuovo long long. Il long double e' un tipo a virgola mobile a doppia precisione estesa di 80 bit (10 byte di cui 64 per la mantissa, 15 per l'esponente ed 1 per il segno) mentre il long long e' un intero a 64 bit (8 byte). Il long long ammette un intervallo di valori da -263 a 263-1. Anche per questi tipi vale il discorso dell'implementazione: lo standard definisce i limiti minimi che devono essere rispettati, ma ogni implementazione e' libera di aumentarli. Infatti su alcuni sistemi esistono dei char da 64 bit (cioe' lunghi 8 byte) oppure dei long double lunghi 128 bit. La verita' in un ipotetico sistema X, con un sistema operativo Y ed un compilatore Z e' data solo dai file limits.h, float.h e values.h. Lo standard ad ogni modo definisce i limiti minimi ai quali qualsiasi compilatore deve attenersi: occorre seguire le direttive dello standard se l'obiettivo e' la portabilita'. Secondo lo standard IEEE 754 esistono 4 tipi di dato in virgola mobile: a precisione singola (4 byte), a doppia precisione (8 byte), a doppia precisione estesa (10 byte) e a quadrupla precisione (16 byte). Lo standard definisce la precisione quadrupla un tipo non specificato dallo IEEE 754 ma lo riconosce come uno standard de facto. Gli altri due tipi di oggetti fondamentali sono il tipo void ed il tipo enum. Il tipo void (che significa vuoto) significa nessun tipo. Attenzione a non confondere NULL con il tipo void: il primo e' un valore nullo il secondo e' un tipo di oggetto. Il tipo void e' utile per vari scopi che vedremo in seguito. Il tipo enum e' un tipo di costante enumerativa. Un'enumerazione e' un elenco di costanti intere come ad esempio: enum giorni { LUN = 1, MAR = 2, MER = 3, GIO = 4, VEN = 5, SAB = 6, DOM = 7 ) oppure: enum boolean { TRUE, FALSE ) oppure: enum giorni { LUN = 1, MAR, MER, GIO, VEN, SAB, DOM ) Nel primo caso ogni costante ha un valore specificato esplicitamente, nel secondo caso implicitamente la prima costanta ha valore 0 e la seconda 1 (una terza costante avrebbe valore 2, una quarta 3, una quinta 4 e cosi' via) e nell'ultimo caso i valori non specificati continuano la progressione a partire dall'ultimo specificato (nell'esempio sopra MAR vale 2, MER vale 3 e cosi' via). Una alternativa al tipo enum e la direttiva #define che vedremo in seguito. I tipi derivati (vettori, strutture, puntatori e union) verranno illustrati in seguito. Alcuni testi annoverano tra i tipi anche le funzioni in quanto sono pur sempre oggetti in memoria e possono anche essere indirizzati attraverso i puntatori ma personalmente preferisco considerarle uno strumento per scrivere dei piccoli blocchi di istruzioni richiamabili in vari punti del programma e che possono ritornare dei valori; un po' come le procedure del Pascal, le perform del Cobol o le subroutines del Fortran o del Basic (le gosub). 21.5 Ancora sulle costantiAbbiamo visto che il contrario di una variabile e' una costante, cioe' un valore che non varia nel tempo ma anzi rimane fisso (costante) per tutta la durata dell'esecuzione del programma. Le costanti possono essere intere, carattere o stringa. Una costante intera come 123 e' una costante di tipo int. Una costante intera di tipo long e' seguita dal carattere 'l' o 'L' come ad esempio 123456789L. Le costanti senza segno sono seguita da una 'u' o 'U' come ad esempio 123456789UL (che corrisonde ad un tipo unsigned long). Una costante a virgola mobile termina con il carattere 'f' o 'F' come ad esempio 123.4 (ricordiamo che il punto corrisponde alla nostra virgola decimale) e corrisponde al tipo float. Una costante a virgola mobile di tipo long double termina con una 'l' o 'L'. Una costante intera puo essere espressa in decimale, in ottale oppure in esadecimale. Un prefisso '0x' o '0X' indica la notazione esadecimale come ad esempio 0X1F (che corrisponde al numero decimale 31). Uno zero prefisso ad un intero indica la notazione ottale, ad esempio il numero di prima puo' essere scritto come 037. Percio' 0X1F, 037 e 31 si equivalgono e corrispondono al numero decimale 31. Una costante carattere puo' rappresentare dei caratteri di controllo particolari come carattere backspace, il carattere di tabulazione od il carattere new line attraverso la cosidetta sequenza di escape. Una sequenza di escape e' un sistema per indicare dei caratteri speciali utilizzando come prefisso il carattere '\' (backslash o barra inversa). Ecco l'elenco delle sequenze di escape:
\a (campanello) La costante carattere '\0' rappresenta il carattere nullo cioe' con valore zero, da non confondere con NULL. La stringa costante e' una serie do zero o piu' caratteri racchiusi tra doppi apici. Internamente ogni stringa e' terminata dal carattere nullo '\0'. Attenzione ancora una volta che '\0' e "\0" non sono la stessa cosa, in quanto la prima espressione identifica un carattere mentre la seconda una stringa. Un esempio di stringa puo' essere "pippo" oppure "paperino pippo e pluto". Una stringa costante in memoria viene rappresentata come un vettore di caratteri. Poiche' ogni stringa costante termina con il carattere '\0', lo spazio occupato in memoria e' maggiore di un carattere. Ad esempio la stringa "ciao" occupa 5 caratteri in quanto in memoria viene rappresentata cosi': "ciao\0". '\0' e' un carattere e non due in quanto il carattere backslash (barra inversa) in realta' e' un metacarattere, cioe' rappresenta un informazione relativamente al carattere vero e proprio. Ad esempio 'b' e '\b' sono due cose diverse in quanto il primo rappresenta il carattere 'b' mentre il secondo rappresenta il carattere di controllo backspace (cioe' il tasto che permette di tornare indietro nella digitazione del testo cancellando). Infine esistono le espressioni costanti. Un esempio di espressione costante e' la seguente: #define MASSIMO 1000. Questa e' una direttiva per il precompilatore che all'atto della compilazione sostituira' la parola MASSIMO con il valore 1000 in qualsiasi punto del programma venga trovata. L'utilita' di queste espressioni constanti e' chiara: impostato un valore costante poniamo a 1000, se successivamente si rendesse necessario portare tale valore a 2000, sarebbe sufficiente modificare una sola riga (la riga #define MASSIMO 1000 diventerebbe #define MASSIMO 2000). Viceversa, senza l'uso delle espressioni costanti, occorrerebbe modificare tutte le righe che presentino il valore costante 1000 per portarlo a 2000. Inizio della guida Precedente Indice Il primo programma in C Copyright (c) 2002-2003 Maurizio Silvestri |