LEZIONE:Appendice B: struttura di grossi programmi
Adesso abbiamo spiegato le basi della programmazione in C, ma sempre limitandoci a programmi di dimensioni medio/piccole; è utile, quindi, affrontare gli aspetti teorici e pratici dello sviluppo di programmi di una certa dimensione, questo perché un progetto con applicazioni reali usualmente raggiunge grandi dimensioni.
In questi casi è consigliabile dividere i programmi in moduli, che dovrebbero stare in file sorgenti separati (quindi ogni file conterrà uno o più funzioni), mentre l'istruzione main dovrà essere in un solo file (generalmente main.c). Alla fine questi moduli interagiranno tra loro semplicemente includendoli in fase di compilazione, con l'ovvio vantaggio anche di riusabilità per altri programmi.
Tutto ciò viene fatto, con gli strumenti adeguati, per ovvie ragioni:
I moduli verranno divisi in gruppi di funzioni comuni
È possibile compilare separatamente ogni modulo e poi linkarlo nei moduli compilati
Usando le utility di Linux, come make, si possono mantenere abbastanza facilmente grossi sistemi
Utilizzo dei file header
Utilizzando un approccio modulare, dovremo includere in ogni modulo la definizione delle variabili, i prototipi di funzioni ed i comandi per il preprocessore C, ecc. Questo potrebbe causare problemi di mantenimento del software, così è consigliabile centralizzare queste definizioni all'interno di un file (o più di uno) e condividere, poi, le informazioni con gli altri file. Tali file vengono generalmente definiti "header file" (file di intestazione) e devono avere un suffiso ".h".
Se avete notato, generalmente, includiamo i file header delle librerie standard, come ad esempio <stdio.h> o <stdlib.h>, mettendoli, appunto tra il simbolo di minore (<) e di maggiore (>), questa è la convenzione usata per includere gli header di sistema (di cui il compilatore conosce il percorso completo);
#include <stdio.h>
per includere invece, all'interno di un file ".c", un file header che risiede nella medesima directory, dobbiamo usare le virgolette ("), quindi per includere, ad esempio, un file di nome mia_intestazione.h, basta scrivere, all'interno del file che vuole richiamarlo,
#include "mia_intestazione.h"
Per i programmi di moderate dimensioni è meglio avere due o più file header che condividono le definizioni di più di un modulo ciascuno. Il problema sorge quando abbiamo delle variabili globali in un modulo e vogliamo che vengano riconosciute anche negli altri moduli, generalmente, infatti, le variabili esterne e le funzioni devono avere visibilità globale. Quindi per le funzioni vengono usati i prototipi di funzioni, mentre possiamo usare il prefisso "extern" per le variabili globali che, in questo modo, vengono dichiarate ma non definite (cioè non viene allocata memoria per la variabile), saranno definite, poi, una ed una sola volta all'interno di un modulo che comporrà il programma; per questo possiamo avere tantissime dichiarazioni esterne, ma una sola definizione della variabile.
L'utility Make ed i makefile
L'utility Make è un programma che di per se non fa parte del famoso GCC, perché non è altro che un program manager molto usato, soprattutto in Ingegneria del Software, per gestire un gruppo di moduli di un programma, una raccolta di programmi o un sistema completo. Sviluppata originariamente per UNIX, questa utility per la programmazione, ha subito il porting su altre piattaforme, tra cui, appunto, Linux. Se dovessimo mantenere molti file sorgenti, come ad esempio
main.c funzione1.c funzione2.c ... funzionen.c
potremmo compilare questi file utilizzando il gcc, ma rimane comunque un problema di fondo anche quando abbiamo già creato qualche file oggetto (.o) che vogliamo linkare durante la compilazione globale del programma:
È tempo sprecato ricompilare un modulo per il quale abbiamo già il file oggetto, dovremmo compilare solamente quei file modificati dopo una determinata data, e facendo degli errori (cosa che aumenta con l'aumentare delle dimensioni del progetto) potremmo avere il nostro programma finale sostanzialmente errato o non funzionante.
Per linkare un oggetto, spesso, è necessario scrivere grossi comandi da riga di comando. Scrivere tanti comandi lunghi e diversi per svariati file può indurre facilmente all'errore.
L'utility make viene in aiuto in questi scenari, fornendo automaticamente gli strumenti per fare questi controlli e quindi, garantisce una compilazione esente da errori e solo di quei moduli di cui non esiste già il file oggetto aggiornato.
L'utility make utilizza, a tal fine un semplice file di testo di nome "makefile", con all'interno regole di dipendenza e regole di interpretazione.
Una regola di dipendenza ha due parti, una sinistra ed una destra, separate dai due punti (:).
lato_sinistro : lato_destro
La parte sinistra contiene il nome di un target (nome del programma o del file di sistema) che deve essere creato, mentre quella destra definisce i nomi dei file da cui dipende il file target (file sorgenti, header o dati); se il file target (destinatario) risulta non aggiornato rispetto ai file che lo compongono, allora si devono seguire le regole di interpretazione che seguono quelle di dipendenza, infatti quando si esegue un makefile vengono seguite queste regole:
Viene letto il makefile e si estrapola quali file hanno bisogno di essere solamente linkati e quali, invece, hanno bisogno di essere ricompilati
Come detto prima, si controlla la data e l'ora di un file oggetto e se esso risulta "antecedente" rispetto ai file che lo compongono, si ricompila il file, altrimenti si linka solamente
Nella fase finale si controlla data e ora di tutti i file oggetto e se anche uno solo risulta più recente del file eseguibile, allora si ricompilano tutti i file oggetto
Naturalmente possiamo estendere l'utility di make a qualsiasi ambito, perché possiamo usare qualsiasi comando dalla riga di comando, in questo modo risulterà intuitiva qualsiasi operazione di manutenzione, come fare il backup.
Ma come si crea un makefile? Ammettiamo di avere i file simili all'esempio precedente,
programma.c funzione1.c funzione2.c header.h
e di voler far si che ci siano delle regole che impongano di ricompilare se cambia qualche file, ecco come, ad esempio, potrebbe essere strutturato un makefile (editabile con un qualsiasi editor di testo):
Questo programma può essere interpretato in questo modo:
"programma" dipende da tre file oggetto, "programma.o", "funzione1.o" e "funzione2.o"; se anche uno solo di questi tre file risulta cambiato, i file devono essere linkati nuovamente
"programma.o" dipende da due file, "header.h" e "programma.c", se uno di questi due file è stato modificato, allora bisogna ricompilare il file oggeto. Questo vale anche per ultimi due comandi
Gli ultimi tre comandi vengono chiamati regole esplicite perché si usano i nomi dei file in modo esplicito, mentre volendo si possono usare regole implicite come la seguente:
.c.o : gcc -c $<
che può sembrare apparentemente incomprensibile, ma che si traduce facilmente in "prendi tutti i file .c e trasformali in file con estensione .o eseguendo il comando gcc su tutti i file .c (espresso con il simbolo $<).
Per quanto riguarda i commenti, si usa il carattere cancelletto (#), così tutto ciò che è sulla medesima riga viene ignorato; questo tipo di commento è lo stesso disponibile in python ed in perl.
Per eseguire un makefile è sufficiente digitare dalla linea di comando, il comando "make", il quale andrà automaticamente a cercare un file di nome makefile da eseguire. Se però abbiamo utilizzato un nome diverso (ad esempio abbiamo fatto un makefile per fare il backup, di nome backup_all), possiamo dire a make di interpretare tale file come il makefile corrente, digitando,
# make -f backup_all
Ovviamente esistono altre opzioni per il comando make, che possono essere scoperte digitando da console, il comando,
# make --help
# man make
Abbiamo cercato di spiegare in modo semplicistico l'uso del Make e dei makefile, questo per farne comprendere le possibilità, senza addentrarci troppo nella programmazione di un makefile complesso, comprensivo di comandi più avanzati o dell'uso delle macro. Tutto ciò spero possa servire come punto di partenza per uno studio più approfondito di tale strumento che, comunque, viene utilizzato quando si inizia a lavorare su progetti di una certa dimensione e che, quindi, non è utile trattare ulteriormente in questo ambito.