DI RECENTE ACCOMAZZI...
CERCA
» Ricerca avanzata
MAILING LIST

Se vuoi iscriverti alla mailing list di Luca Accomazzi inserisci qui la tua mail:

Vuoi ricevere i messaggi immediatamente (50 invii / giorno) o in differita e in gruppo
(due invii / giorno)?

» Vuoi saperne di più?

Linguaggio C: il pre processore

Come abbiamo visto nelle precedenti puntate, il linguaggio C consente la scrittura di programmi veloci e compatti, quasi al livello di programmi scritti in Assembler: con Assembler ha in comune anche il funzionamento in più passi del compilatore.
Quando si scrive un programma in Pascal, un linguaggio che come il C utilizza le funzioni come mattoni sui quali basare il programma "main", tutte le funzioni debbono trovarsi disposte nel listato sorgente in scala gerarchica: per prime quelle di livello più basso, che debbono usare solo i comandi del linguaggio, poi altre che possono usare solo le prime funzioni e così via sino al corpo main del programma che può usare tutte le funzioni.
Nel C questa richiesta piuttosto innaturale non esiste: siamo liberi di disporre le funzioni nel programma secondo l'ordine che più ci è comodo. Personalmente, trovo utile raggruppare insieme le funzioni di input, quelle di output, quelle che eseguono solo calcoli, e così via.
Questa libertà in più concessaci dal C dipende dal fatto che, mentre il compilatore Pascal legge il programma una sola volta durante la compilazione, e quindi non può ammettere di ritrovarsi a compilare per prime funzioni che dipendono da altre non ancora esaminate, il compilatore C legge più di una volta (due o tre) il programma.


Quando nasce il pre processore

I signori Kernighan e Ritchie, i "papà" del C, hanno pensato bene di prendere due piccioni con una fava. La facilità nella scrittura dei programmi conseguente alla doppia o tripla lettura del programma sorgente da parte del compilatore ha un prezzo da pagare in termini di velocità di compilazione (ed in effetti normalmente un compilatore C è più lento di un compilatore Pascal). Così, hanno approfittato delle multiple letture per metterci a disposizione un pre processore.
Si tratta, per dirla in termini semplici, di un programma che legge il nostro listato prima del compilatore C, ed esegue certe trasformazioni al nostro servizio, permettendoci di scrivere del codice più vicino al nostro modo di pensare che a quello del compilatore, e più leggibile.

Possiamo mandare dei comandi al pre processore iniziando una riga con il segno diesis (o cancelletto, se preferite: il simbolo #). Quando il pre processore vede quel simbolo intercetta la riga ed esegue il comando: il compilatore non vedrà mai quel comando, che per lui non significherebbe nulla.

Per un primo esempio dell'utilità del pre processore, pensiamo ad un esempio nato nella scorsa puntata. Avevamo pensato a come rappresentare una carta da gioco in forma di variabile, giungendo alla definizione

typedef struct {
int valore;
int seme;
} CARTA;

Quella definizione ci permette di scrivere l'asso di cuori come

CARTA assocuori = {1, 1}; /* 1= asso, 1=cuori */

In effetti la leggibilità di quella variabile non è perfetta. Anche se siamo disposti a pensare al numero uno come ad un asso, troviamo meno naturale identificare il secondo uno con il seme di cuori. Ci viene in aiuto il pre processore: è legale scrivere:

#define CUORI 1
#define QUADRI 2 /* Notate la mancanza del punto e virgola finale */
#define FIORI 3 /* e l'assenza di uno spazio bianco tra il diesis */
#define PICCHE 4 /* e la parola "define". Sono importanti. */

CARTA assocuori = { 1, CUORI };


Define il raffinato

L'istruzione #define è la più usata tra quelle che ci mette a disposizione il pre processore: vedremo le altre tra poco.
Dal punto di vista del pre processore, l'idea è che ogni #define è seguita da due valori che d'ora in poi vanno considerati analoghi: il primo (nel nostro caso CUORI, PICCHE, FIORI, DENARI) quando venga trovato nel programma va sostituito con il secondo (i valori numerici dall'uno al quattro).
Questo ci permette di sostituire dei valori numerici, che per noi non hanno un chiaro significato, con dei simboli che troviamo più naturali quando stiamo trattando con variabili che rappresentano oggetti reali.
Possiamo scrivere ovunque CUORI: il pre processore lo sostituirà per noi con il suo valore numerico che è irrilevante concettualmente.

Una nota per i pascalisti: questo uso di #define è analogo alla creazione di tipi astratti. Quello che stiamo per introdurre è l'analogo C della creazione di CONST.

Ecco un altro uso per #define: ammettiamo di star scrivendo un programma che faccia uso dell'alta risoluzione del nostro personal computer. Poiché il linguaggio C è tanto facile da trasportare da una macchina all'altra sarà semplicissimo adattarlo al personal di un'altra marca di qualche amico.
Ma c'è un problema: la capacità dell'alta risoluzione cambia da modello a modello. Un Commodore 64 è capace di 320 x 200 punti, un Apple // di 580 x 192, un Macintosh di 512 x 320, un IBM con scheda grafica di 640 x 400... come possiamo fare?

Ammettiamo di star sviluppando il nostro programma sul Commodore. Sempre per continuare nel nostro esempio, immaginiamo di avere su tutte le macchine una funzione "hplot", che richiede quattro parametri, e cioè le coordinate x ed y del punto di partenza e poi x ed y del punto di arrivo. La funzione hplot traccia una riga sullo schermo di alta risoluzione. Questo significa che con "hplot (0, 0, 50, 50);" tracceremmo una riga dal punto 0, 0 al punto 50, 50.
Scriveremmo nel nostro programma, versione C64:

#define XMASSIMO 320
#define YMASSIMO 200

Per tracciare una riga dall'angolo in alto a sinistra a quello in basso a destra, utilizzando la "hplot", scriveremo:

hplot (0, 0, XMASSIMO, YMASSIMO);

Grazie all'uso di #define, questa riga non ha bisogno di essere modificata quando trasferiremo il programma dal 64 ad un altro computer. Sarà sufficiente modificare i valori numerici dei due #define e ricompilare il programma sull'altra macchina. Anche se avremo fatto uso di centinaia di istruzioni hplot, due modifiche saranno tutto quanto ci vuole per convertire il programma.
è sempre utile definire con #define le costanti, ed utilizzare poi i termini nel corpo del programma. Questo permette una maggiore leggibilità del programma, ed è più facile modificarlo dopo qualche tempo. è più semplice distinguere a vista d'occhio i valori numerici utilizzati una sola volta dai valori ricorrenti.


Convenzioni

Quando si fa uso del pre processore, i termini introdotti a suo uso e consumo (come CUORI, PICCHE, XMASSIMO dei nostri esempi), vanno scritti in lettere maiuscole. Questa regola non è rigida o imposta dal compilatore, ma è una convenzione universale dei programmatori di linguaggio C, e va osservata.
Come ho già accennato, non bisogna mai mettere spazi bianchi tra il segno # e le parole chiave del pre processore (come "define"). Non serve nè va messo un punto e virgola dopo le linee destinate al pre processore. Queste ultime due regole sono vincolanti e vanno osservate, o il programma non funzionerà come desiderate.


Spogliando il linguaggio C

Tenetevi forte alla sedia: sto per fare una rivelazione.
La parola "printf" non è un comando del linguaggio C. Anzi, il linguaggio C non ha nessuna istruzione nè per l'input nè per l'output.

Passato lo shock iniziale, vediamo di spiegare quella che sembra una affermazione senza senso. Il linguaggio C vero e proprio è costituito unicamente da quella trentina di istruzioni e parole chiave che abbiamo pubblicato nella seconda puntata: tutto il resto, ivi comprese le istruzioni di I/O come printf, le funzioni matematiche, le funzioni per l'uso di file, sono funzioni scritte in linguaggio C od in Assembler che ogni compilatore ha a disposizione già pronte per l'inclusione.
Forse il concetto di "modulo funzionale" non è chiaro a tutti voi, perlomeno esplicitamente; spendiamoci qualche parola.

I lettori di Bit conoscono quella rubrica che una volta si chiamava "Il ricettario" e che ora, trasformata, va sotto il nome di "Bitricks". Essa raccoglie routine che noi redattori abbiamo creato per i nostri scopi e che potrebbero venirvi utili nei vostri programmi; non si tratta di programmi, ma di piccole subroutine Basic (od altro linguaggio) che, battute nei vostri programmi, vi permettono di svolgere professionalmente certi compiti comuni.
Il linguaggio C prevede esplicitamente e legittimamente una simile possibilità: è possibile creare una volta per tutte delle funzioni che verranno usate spesso e compilarle o mantenerle comunque a disposizione, per poi includerle nei propri programmi che ne hanno bisogno. Lo stesso avviene programmando in Modula 2 e lo stesso fanno i migliori programmatori Assembler, per non dovere reinventare la ruota ad ogni programma.

Così, esiste una libreria di funzioni "precotte", chiamata stdio.h (contrazione di "libreria standard input - output") che viene inclusa ogni qual volta che creiamo un programma C, con l'istruzione:

#include "stdio.h"

L'istruzione #include chiama in gioco il pre processore e gli chiede di presentare al compilatore non solo il nostro programma, ma anche la libreria il cui nome segue tra virgolette doppie (o tra i segni di minore e maggiore, come segue: #include <stdio.h>).
Il nome della libreria, che termina per .h, ne indica la natura. Nalla tabellina 1 sono elencati i suffissi dei nomi di file in C

Il suffisso... Indica un file...
.c sorgente di linguaggio C
.h libreria di funzioni da #includere
.o oggetto rilocabile, pezzo di programma compilato
(nessuno) programma compilato assoluto eseguibile

Torneremo sul tema dei programmi a pezzi nella prossima puntata, dove tratteremo i file nel linguaggio C. Per il momento aggiungiamo che oltre alla stdio.h ogni linguaggio C a a disposizione la libreria math.h, per le fuinzioni matematiche. I computer grafici (e quindi tutti i nostri personal) dovrebbero avere anche una libreria di funzioni grafiche per la alta risoluzione.
Questo meccanismo permette al linguaggio C di fornire funzioni che utilizzano primitive a disposizione del computer specifico: menù a discesa e finestre sul Mac, chiamate a System Monitor sull'Apple //, uso degli sprite sul Commodore e chi più ne ha più ne metta. Osservate però che se utilizzate queste funzioni tipiche del vostro computer metterete in discussione la portabilità dei vostri programmi in linguaggio C.


Altri comandi al pre-processore

#undef IDENTIFICATORE

è il comando, in genere non molto usato, per disfare un #define. Il comando

#line 120 trucco.c

fa credere al compilatore di essere arrivato alla compilazione della centoventesima riga, e che il file sorgente si chiami "trucco. c". Non l'ho mai usato in vita mia e credo che farete altrettanto. Più interessante è:

#if ... (#ifdef ... - #ifndef)
#else ...
#endif

Viene usato per ottenere la compilazione condizionale di pezzi di programma; anche in questo caso, si tratta di una opzione familiare a quanti programmano in Assembler. L'idea è che ogni tanto è necessario scrivere programmi in più versioni leggermente diverse tra di loro, e sarebbe alquanto scomodo dover mantenere più sorgenti quasi identici su disco.
Così, i pezzi di codice che differiscono da una versione all'altra vengono inclusi tra gli operatori #if-#else-#endif: il blocco che segue un #if viene compilato solo se l'espressione che segue è vera. Se abbiamo usato #ifdef, solo se l'identificatore è stato #definito, e se il condizionale è #ifndef solo se l'identificatore non è #definito.
Sarà sufficiente intervenire su un solo #define e compilare per ottenere le versioni differenti dall'unico listato sorgente. (Ovviamente, il blocco #else è facoltativo).


Qualche trucco in più con #define

Il libro di Kernighan e Ritchie presenta un esempio di uso del pre processore che, seppure vanesio, ne illustra bene la potenza. Il suo scopo è di trasformare il linguaggio C in un linguaggio simile a Pascal:

#define THEN
#define BEGIN {
#define END ;}

Ed eccovi così una funzione C perfettamente legittima:

if (a > b) THEN BEGIN
a = b;
b = 1
END;

Ho fatto anch'io uso del #define THEN nelle prime due puntate del corso. In quel modo, THEN è definito uguale ad uno spazio bianco, e quindi la sua presenza è ignorata: speravo in quel modo di evitarvi il trauma di vedere un "if" senza il corrispondente "then" sin quando non avessi introdotto le istruzioni di controllo nella terza puntata.
Comunque, questo tipo di trucchetti non è molto consigliabile, perché tende a ridurre la leggibilità dei programmi agli altri programmatori C.

Veniamo ora ad una possibilità molto più concreta ed utile: la creazione di macro istruzioni, ennesimo concetto comune al C ed all'Assembler.
Una macro istruzione è la concatenazione di più istruzioni semplici, applicabili ad un argomento a piacere e trattata come un unica istruzione.
Una volta digerita la definizione, i più solitamente si chiedono che differenza possa esserci rispetto alle funzioni. Facciamo un esempio: abbiamo bisogno di disporre della operazione di elevamento al cubo di un numero (cioè una operazione che moltiplica il numero x per x per x).
Se scrivessimo una funzione, ci cacceremmo nei pasticci: di che tipo dovremmo scriverla? Int, long, short, unsigned, float, double? E se poi ci troviamo a dover chiedere il cubo di una variabile di un altro tipo?
Con le macro del pre processore, scriviamo:

#define cubo(X) (X) * (X) * (X)

Per ottenere il cubo di 3, scriveremo semplicemente cubo(3): il pre processore lo sostituirà con (3) * (3) * (3) ed il compilatore sostituirà 27. Possiamo anche usarla come una funzione, cioè applicare l'operatore ad una variabile:

printf ("%f", cubo(my_variable + 7));

Poche parole di commento: la macro è preferibile alla funzione perché non ha un tipo: cubo funziona perfettamente con ogni tipo di variabile numerica.
Vi sono considerazioni piuttosto complesse da fare per giudicare se sia preferibile una funzione o una macro per uno scopo: gli interessati possono trovarle a pagina 81 del libro "Linguaggio C": a noi basta sapere che, più o meno, conviene usare una macro per le operazioni ripetitive ed estremamente semplici, che causerebbero la scrittura di una funzione di una o due righe.
Attenzione a qualche errore subdolo: le parentesi sono necessarie nella nostra "cubo"! Cosa accadrebbe se avessi scritto:

#define cubo(X) X * X * X

ed avessi poi richiesto il cubo(a + 2)?
è assolutamente indispensabile, nel definire una macro e poi nell'invocarla, che non vi sia nessun spazio bianco tra il nome della macro e la parentesi aperta della lista di argomenti: in quel caso il pre processore vedrebbe la riga come la #definizione di una costante. Le pseudo variabili della macro sono indicate in maiuscolo ed il suo nome va in minuscolo.
Con questo esauriamo la nostra trattazione del pre processore.


Compilatore, gioia e dolore

Val più la pratica della grammatica, sostiene sempre nonna Clementina. Fedele ai suoi insegnamenti, per aiutarvi a superare felicemente i primi impatti col compilatore C, approfitto della paginetta rimasta per parlarvi del funzionamento pratico del compilatore C.
Probabilmente il mio lettore tipico non ha esperienza di compilazioni, e quindi potrebbe trovarsi a disagio di fronte ai messaggi d'errore del compilatore.

Quando compilate un programma C ed il compilatore trova un errore, si blocca e stampa un messaggio diagnostico, più o meno fatto così:

ERROR: case value must be an integer constant IN LINE 37

A meno che voi non lo blocchiate, procederà comunque a far passare il resto del programma alla ricerca di eventuali altri errori. Vi ritroverete così, alla fine della prima compilazione di un programma, con una paginetta di messaggi simili.
Consiglio numero uno: sinché non sarete sicuri di voi, non tenete in nessuna considerazione i messaggi d'errore dopo il primo. Correggete il primo errore e poi ricompilate. Questo è indispensabile, perché il primo errore potrebbe far sbagliare il compilatore e fargli sputare gli altri messaggi a sproposito.
Un esempio semplice: voi definite una costante come # define PIPPO 12, inserendo per distrazione uno spazio tra il # e la parola define. Il compilatore segnala errore correttamente su quella riga, ma poi indica come errate tutte le altre righe seguenti che contengono la parola PIPPO perché ha scartato la definizione errata, mentre sono corrette.
Mi è capitato che un singolo errore in un programma abbia prodotto a cascata un centinaio di messaggi d'errore, sino al finale "COMPILER ERROR: TOO MANY ERRORS" col quale il compilatore indica che non ce la fa più a tenere dietro a tutti gli errori...

Ci sono anche errori fatali, che bloccano completamente la compilazione: ad esempio, se indicate al compilatore di compilare un programma inesistente...
In genere, però, il compilatore procede a rullo compressore, in modo poco affidabile dopo il primo errore.
Il numero di riga indica la linea dove il compilatore ha trovato errore. Poiché le righe del linguaggio C non sono numerate come quelle di Basic, sarà l'editore di sistema a permettervi di identificare la riga colpevole: ha certamente un comando del tipo "porta il cursore alla riga X". Alcuni editori sono chiamati automaticamente dal compilatore quando si trova un errore, e posizionati automaticamente sulla riga accusata.

Qualche volta la riga dove si indica un errore non ha nulla di male, e l'errore è in qualche altro punto. Dunque, consiglio numero due: ricordate che l'errore si trova in una riga precedente od al massimo uguale a quella indicata dal compilatore.
Ad esempio, guardate questa breve funzione:

minuscolo (parola)
char parola [];
{
int i = 0;

while (parola[i]) {
*(parola + i) = lower (* (parola + i));
i++;
/* QUI MANCA UNA GRAFFA CHIUSA!!!!! */
printf ("Ho ottenuto %s", parola);
printf ("che contiene %d lettere", i);
/* Omissis: qui ci sono altre righe di codice! */
} /* Fine del programma */

Il compilatore segnalerà errore sull'ultima riga del programma, indicando che manca una parentesi graffa chiusa. Infatti, se contate le parentesi graffe, ce ne sono due aperte e solo una chiusa, perché ho "dimenticato" di chiudere il blocco "while". Il compilatore, che come ricorderete non può nè deve considerare le indentazioni delle righe, considera la prima graffa chiusa che incontra, quella finale, come fine del blocco while, e poi si rende conto che manca una seconda graffa chiusa e segnala errore a quel punto.
Se voi, colti da un attacco di fiducia, metteste in quel punto una graffa chiusa il compilatore non darebbe più errore: ma il programma non funzionerebbe come volevate. Controllate sempre a monte!

Un terzo problema comune col compilatore C sono i messaggi criptici. In certi casi se ne esce con segnalazioni assolutamente incomprensibili. Ad esempio, credo di aver rischiato l'infarto la prima volta che mi sono visto arrivare un "ERROR: lvalue required".
Un "lvalue" è una variabile di qualche tipo e deve il suo nome alla contrazione di "left value", cioè "valore che deve trovarsi solo sulla sinistra nelle istruzioni di assegnamento". Se voi scrivete:

3.14 = my_variable;

il compilatore se ne esce con le sue considerazioni sugli lvalue per spiegarvi che avete invertito valore e variabile.
Per mettere una pietra sopra ai messaggi d'errore incomprensibili basta un buon manuale d'uso: il manuale dell'aztec C, il linguaggio C usato sull'Apple // e sul Commodore 64 e diffuso anche su PC IBM e Mac, contiene nel manuale diverse pagine dedicate a spiegare cosa può causare ciascun messaggio. Dunque, consiglio numero tre: andate a leggervi sul manuale la sezione dedicata ai messaggi d'errore.

In assoluto, comunque, l'errore più comune e più maledetto per i principianti sta nello scrivere

if (a = b) /* Sbagliato (versione A) */

al posto di

if (a == b) /* Corretto (versione B) */

per controllare l'eguaglianza di due variabili. Il brutto è che la versione A è perfettamente accettabile per il compilatore, e significa:

a = b;
if (a == 0)

Quest'ultima è una operazione degnissima e rispettabile, ma be diversa dalla versione B. Occhio alla penna, dunque. La morale di questo punto è il consiglio numero quattro: quando il compilatore non da più messaggi d'errore non esultate, gli errori peggiori sono ancora da individuare.
In un programma che si comporta stranamente, l'inserimento di moltissime printf che stampano il valore delle variabili nei momenti chiave dell'elaborazione è il trucco più comune usato dai programmatori C per il debugging. Consiglio numero cinque: usate molte printf nei programmi in fase di sviluppo.


Questo articolo fa parte di uno dei miei percorsi. Se vuoi saperne di più su questo argomento, visita il resto del percorso cliccando qui.