Scrivere funzioni di libreria

La scrittura di un programma C implica sempre la necessità di scrivere funzioni, in quanto almeno main() deve essere definita. Spesso, però, le funzioni che fanno parte di uno specifico programma sono scritte avendo quali linee guida la struttura e gli obiettivi di quello. Leggermente diverso è il comportamento da tenere quando si scrivano funzioni destinate a far parte di una libreria e, come tali, utilizzabili almeno in teoria da qualsiasi programma: in questo caso è opportuno osservare alcune regole, parte delle quali derivano dal buon senso e dalla necessità di scrivere codice qualitativamente valido; parte, invece, dettate dalle esigenze tecniche del linguaggio e dei compilatori.

Accorgimenti generali

Nello scrivere funzioni di libreria va innanzitutto ricordato che il codice scritto può essere utilizzato nelle situazioni più disparate: è pertanto indispensabile evitare, per quanto possibile, qualsiasi assunzione circa le condizioni operative a runtime.

Supponiamo, ad esempio, di scrivere una funzione in grado di copiare in un buffer il contenuto della memoria video: se il codice deve far parte di un programma, magari preparato per una specifica macchina, è possibile che le modalità operative (tipo di monitor, pagina video attiva) siano note al momento della compilazione e non pongano dunque problemi di sorta. Ma se la funzione deve essere inserita in una libreria, non può ipotizzare nulla circa tali condizioni: è opportuno, allora, che esse siano richieste quali parametri. In alternativa la funzione stessa può incorporare alcune routine atte a conoscere tutti i parametri operativi necessari mediante le opportune chiamate al BIOS. Oppure, ancora, possono essere predisposte una o più funzioni "complementari", da chiamare prima di quella in questione, che memorizzino i dati necessari in variabili globali.

L'indipendenza del codice dalle condizioni operative del programma è anche detta parametricità, e rappresenta un requisito essenziale delle funzioni di libreria.

Un'altra importante osservazione riguarda la coerenza delle regole di interfacciamento funzioni/programma. Accade spesso di scrivere gruppi di funzioni le quali, nel loro insieme, permettono di gestire in modo più o meno completo determinate situazioni o caratteristiche del sistema in cui opera il programma che le utilizza. E' bene che le funzioni inserite in una libreria, ed in particolare quelle che implementano funzionalità tra loro correlate, siano simili quanto a parametri, valori restituiti e modalità di gestione degli errori. In altre parole, esse dovrebbero, per quanto possibile, somigliarsi reciprocamente. Con riferimento ad un gruppo di funzioni che utilizzino servizi DOS per realizzare particolari funzionalità, si può pensare ad una modalità standard di gestione degli errori, nella quale il valore restituito è sempre il codice di stato a sua volta restituito dal DOS. In alternativa ci si può uniformare alla modalità implementata da gran parte delle funzioni della libreria standard, che prevedono la restituzione del valore ­1 in caso di errore e la memorizzazione del codice di errore nella variabile globale errno: più avanti sono forniti la descrizione di come tale algoritmo sia realizzato nella libreria C ed un esempio di utilizzo della funzione (non documentata) ___IOerror().

Ancora, i nomi di variabili e funzioni dovrebbero essere il più possibile autoesplicativi: dalla loro lettura dovrebbe cioè risultare evidente il significato della variabile o il compito della funzione. Al proposito sono state sviluppate specifiche formali[1] che descrivono un possibile metodo per uniformare i nomi C basato, tra l'altro, sulle modalità di allocazione delle variabili e su un insieme di suffissi standard per le funzioni. Se non si scrive codice a livello professionale, tali formalismi possono forse risultare eccessivi; è bene comunque ricordarsi che le funzioni di libreria sono spesso utilizzate da terzi, i quali è bene possano concentrarsi sul programma che stanno implementando piuttosto che essere costretti a sforzarsi di decifrare significati e modalità di utilizzo di una interfaccia software criptica, disordinata e disomogenea.

Analoghe considerazioni valgono per la documentazione delle funzioni. E' indispensabile che le librerie siano accompagnate da una chiara e dettagliata descrizione, per ciascuna funzione, del tipo e del significato di tutti i parametri richiesti e del valore eventualmente restituito. Del pari, è opportuno fornire esaustiva documentazione delle strutture ed unioni definite, delle variabili globali e delle costanti manifeste.

Esigenze tecniche

Alcune regole derivano invece dalle caratteristiche proprie del linguaggio C e dei compilatori. Per verificare la correttezza sintattica della chiamata a funzione e gestirla nel modo opportuno, il compilatore deve conoscere le regole di interfacciamento tra la stessa funzione e quella chiamante (cioè la coerenza tra i parametri formali e quelli attuali). Dal momento che una funzione di libreria non è mai definita nel sorgente del programma che ne fa uso, è necessario fornirne il prototipo. Allo scopo si rivelano particolarmente adatti gli header file (.H): è bene, pertanto, che una libreria di funzioni sia sempre accompagnata da uno o più file .H contenenti tutti i prototipi delle funzioni, la dichiarazione (come variabili external) di tutte le variabili globali, i template delle strutture ed unioni, nonché le costanti manifeste eventualmente definite per comodità del programmatore.

Si ricordi, poi, che una libreria di funzioni non è che un file contenente uno o più object file (.OBJ), generati dalla compilazione dei rispettivi sorgenti. Detti moduli oggetto possono derivare da sorgenti scritti in linguaggi diversi dal C: è frequente, soprattutto per l'implementazione di rouine dei basso livello, il ricorso al linguaggio Assembler. E' evidente che negli include file devono essere dati anche i prototipi delle funzioni facenti parti di moduli assembler. Inoltre, dal momento che, per default, il compilatore genera un underscore (il carattere "_") in testa ai nomi delle funzioni C, mentre ciò non viene fatto dall'assemblatore, i nomi di tutte le funzioni definite in moduli assembler devono inziare con un underscore, che viene ignorato nelle chiamate nel sorgente C. Se, ad esempio, la libreria contiene il modulo oggetto relativo alla funzione assembler definita come segue:

....
_machineType proc near
    ....
_machineType endp
....

il prototipo fornito nello header file è:

int machineType(void);  // senza undescore iniziale!!

e le chiamate nel sorgente C saranno analoghe alla seguente:

    ....
    int cpu;
    ....
    cpu = machineType();   // niente underscore neppure qui!
    ....

Va ancora sottolineato che, essendo il C un linguaggio case­sensitive, anche la compilazione dei sorgenti assembler mediante l'assemblatore deve essere effettuata in modo che le maiuscole siano distinte dalle minuscole, attivando le opportune opzioni[2].

Se le funzioni sono scritte in C ma incorporano parti di codice in assembler, è opportuno prestare particolare attenzione alle istruzioni che referenziano i parametri formali (e soprattutto i puntatori): per un esempio vedere le funzioni realizzate per la gestione della memoria convenzionale. Maggiori dettagli sull'interazione tra C ed assembler si trovano nel capitolo dedicato.

Qualche raccomandazione in tema di variabili globali. Quando si scrive un gruppo di funzioni che per lo scambio reciproco di informazioni utilizzano anche variabili globali, è opportuno che queste siano dichiarate static se non devono essere referenziate dal programma che utilizza quelle funzioni: in tal modo si accentua la coerenza logica del codice, impedendo la visibilità delle variabili "ad uso riservato" all'esterno del modulo oggetto che contiene le funzioni. Esempio:

static int commonInfo;  // visibile solo in f_a(), f_b() e f_c()
....
void f_a(int iParm)
{
    extern int commonInfo;
    ....
}

int f_b(char *sParm)
{
    ....   // non referenzia commonInfo (ma potrebbe farlo)
}

int f_c(char *sParm,int iParm)
{
    extern int commonInfo;
    ....
}

E' però indispensabile che tutte le funzioni che referenziano dette variabili siano definite nel medesimo file sorgente in cui quelle sono dichiarate.

Considerazioni analoghe valgono anche per le funzioni: una funzione implementata unicamente come subroutine di servizio per un'altra può essere dichiarata static (e resa invisibile all'esterno del modulo oggetto) purché definita nel medesimo sorgente di questa.

Attenzione, però: se una variabile globale (o una funzione) deve essere referenziabile dal programma che utilizza la libreria, essa non deve assolutamente essere dichiarata static.

Qualche precauzione è richiesta anche nella gestione dei puntatori. Non va dimenticato che i puntatori non dichiarati esplicitamente near, far o huge sono implementati dal compilatore con  16 o32 bit a seconda del modello di memoria utilizzato; analoga regola si applica inoltre alle funzioni. Ne segue che solo le funzioni dichiarate far, che accettano quali parametri e restituiscono puntatori esplicitamente far possono essere utilizzate senza problemi in ogni programma, indipendentemente dal modello di memoria con il quale esso è compilato.

Al proposito, è regola generale scrivere le funzioni senza tenere conto del modello di memoria[3] e generare diversi file di libreria, uno per ogni modello di memoria a cui si intende fornire supporto (è necessario, come si vedrà tra breve, compilare più volte i sorgenti). Si noti che i compilatori C sono accompagnati da una dotazione completa di librerie per ogni modello di memoria gestito.

Si è detto, poco fa, che una libreria è, dal punto di vista tecnico, un file contenente più moduli oggetto, ciascuno originato dalla compilazione di un file sorgente. Durante la fase di linking vengono individuati, all'interno della libreria, i moduli oggetto in cui si trovano le funzioni chiamate nel programma e nell'eseguibile in fase di creazione è importata una copia di ciascuno di essi. Ciò significa che se una funzione è chiamata più volte, il suo codice compilato compare una volta sola nel programma eseguibile; tuttavia, se un modulo oggetto implementa più funzioni, queste sono importate in blocco nell'eseguibile anche qualora una sola di esse sia effettivamente utilizzata nel programma. Appare pertanto conveniente, a scopo di efficienza, definire in un unico sorgente più funzioni solo se, per le loro caratteristiche strutturali, è molto probabile (se non certo) che esse siano sempre utilizzate tutte insieme. Ad esempio, tutte le subroutine di servizio di una funzione dovrebbero essere definite nel medesimo sorgente di questa: ciò minimizza il tempo di linking senza nulla sottrarre all'efficienza del programma in termini di spazio occupato.

La realizzazione pratica

A complemento delle considerazioni teoriche sin qui esposte, vediamo quali sono le operazioni necessarie per la costruzione di una libreria di funzioni.

In primo luogo occorre scrivere il codice ed effettuare il necessario debugging, ad esempio aggiungendo al sorgente una main() che richiami le funzioni in modo da testare, nel modo più completo possibile, tutte le caratteristiche implementate. Al termine della fase di prova bisogna assolutamente ricordarsi di eliminare la main(), in quanto nessuna libreria C può includere una funzione con tale nome.

Nell'ipotesi di avere realizzato un gruppo di sorgenti chiamati, rispettivamente, MYLIB_A.C, MYLIB_B.C, MYLIB_C.C e MYLIB_D.C, accompagnati dallo header file MYLIB.H, si può procedere, a questo punto, alla generazione dei moduli oggetto e della libreria; le operazioni descritte di seguito dovranno essere ripetute per ogni modello di memoria (eccetto il modello tiny, che utilizza le medesime librerie del modello small). Negli esempi che seguono si propone la costruzione della libreria per il modello large.

Si parte sempre dalla compilazione dei sorgenti: dal momento che non si vuole generare un programma eseguibile, ma solamente i moduli oggetto, è necessaria l'opzione ­c sulla riga di comando del compilatore[4]:

bcc -c -ml mylib_a.c mylib_b.c myliv_c.c mylib_d.c

L'opzione ­ml richiede che la compilazione sia effettuata per il large memory model; l'operazione produce, in assenza di errori, i moduli oggetto MYLIB_A.OBJ, MYLIB_B.OBJ, MYLIB_C.OBJ e MYLIB_D.OBJ.

E' ora possibile generare il file di libreria mediante la utility TLIB (o LIB, a seconda del compilatore utilizzato):

tlib mylibl /C +mylib_a +mylib_b +mylib_c +mylib_d

L'opzione /C richiede che la generazione della libreria avvenga in modalità case­sensitive. Il nome file libreria è MYLIBL.LIB; se non esiste esso è creato e vi sono inseriti i quattro moduli oggetto preceduto dall'operatore "+". Si noti che il nome del file deve essere differenziato per ogni modello di memoria; è pratica comune indicare il modello supportato mediante una lettera aggiunta in coda al nome (S per small e tiny, M per medium, C per compact, L per large, H per huge). L'estensione è, per default, .LIB.

Il pacchetto di libreria completo è perciò costituito, in definitiva, dal file MYLIB.H, unico per tutti i modelli di memoria, e dai file MYLIBS.LIB, MYLIBM.LIB, MYLIBC.LIB, MYLIBL.LIB e MYLIBH.LIB. E' ovvio che la libreria può essere pienamente utilizzata da chi entri in possesso dei file appena elencati (e della documentazione!), senza necessità alcuna di disporre anche dei file sorgenti.

Va infine sottolineato che la utility TLIB permette anche di effettuare operazioni di manutenzione: se, ad esempio, a seguito di modifiche si rendesse necessario sostituire all'interno della libreria il modulo mylib_a con una differente versione, il comando

tlib mylibl +- mylib_a

raggiunge lo scopo. Si noti che, nonostante l'operatore "+" sia specificato prima dell'operatore "­", l'operazione di eliminazione è eseguita sempre prima di quella di inserimento. L'operatore "*" consente di estrarre dalla libreria una copia di un modulo oggetto: il comando

tlib mylibl *mylib_a

genera il file MYLIB_A.OBJ, sovrascrivendo quello che eventualmente preesiste nella directory.

Per una descrizione completa della utility di manutenzione delle librerie si rimanda comunque alla documentazione fornita con il compilatore.


OK, andiamo avanti a leggere il libro...

Non ci ho capito niente! Ricominciamo...