Linguaggio C e portabilità 

Una caratteristica di rilievo del linguaggio C consiste nella portabilità. In generale, si dice portabile un linguaggio che consente di scrivere programmi in grado di funzionare correttamente su piattaforme hardware diverse e sotto differenti sistemi operativi, richiedendo semplicemente la ricompilazione dei sorgenti nel nuovo ambiente (e dunque, implicitamente, con una differente implementazione del compilatore). 

Tutto ciò è reso possibile, nel caso del C, dalla standardizzazione del medesimo operata dall'ANSI e dal fatto che si tratta di un linguaggio basato in massima parte su routine (funzioni) implementate in librerie esterne al compilatore, e dunque sempre disponibili con caratteristiche coerenti a quelle dei differenti ambienti in cui il compilatore deve operare. 

Tali caratteristiche non sono però, da sole, sufficienti a rendere portabile qualsiasi programma C: molto dipende dalla cura spesa nella realizzazione del medesimo nonché, in ultima analisi, dagli scopi che il programmatore si prefigge. In molti casi è impossibile, o sostanzialmente inutile, eliminare quelle caratteristiche del programma che lo rendono più o meno dipendente dallo hardware, dal compilatore e dal sistema operativo, e ciò soprattutto quando si desideri controllare e sfruttare a fondo le prestazioni dell'ambiente in cui il programma deve operare[1]

Dipendenze dallo hardware 

Un programma può risultare hardware­dipendente per molte cause, dalle più scontate a quelle di più difficile individuazione. 

Esempio di banalità mostruosa: un programma che assuma aprioristicamente la presenza di un disco rigido non è portabile (fatta salva la possibilità di modificare il sorgente) su macchine che non ne siano dotate. 

E ancora: l'accesso diretto al buffer video è un ottimo metodo per rendere molto efficienti le operazioni di output, ma comporta la necessità di conoscerne l'indirizzo fisico, che può variare a seconda dello hardware installato. 

Più sottili considerazioni si possono fare sulle relazioni intercorrenti tra i tipi di dati gestiti dal programma e il microprocessore installato sulla macchina. Il tipo di dato forse più "gettonato" nei programmi C è l'integer, e proprio l'integer può essere fonte di fastidiosi grattacapi. Il C consente tre modi di dichiarare integer una variabile: 
    short s;
    long l;
    int i;
Va osservato che non è possibile specificarne la dimensione in bit; il compilatore garantisce soltanto che la variabile dichiarata short integer ha dimensione minore o uguale a quella long, mentre la variabile dichiarata semplicemente int viene gestita in modo da ottimizzarne la manipolazione da parte del processore. Se la compilazione avviene su una macchina a 16 bit essa risulta equivalente a quella short, ma la compilazione su macchine a 32 bit la rende equivalente a quella long. Non è difficile immaginare i problemi che potrebbero manifestarsi portando ad una macchina a 16 bit un programma compilato su una a 32 bit. E' dunque opportuno utilizzare (quando le dimensioni in bit o byte delle variabili assumano rilevanza) l'operatore sizeof() e le costanti manifeste definite in base allo standard ANSI negli header file LIMITS.H e FLOAT.H

In LIMITS.H troviamo, ad esempio, CHAR_BIT (numero di bit in un char); INT_MIN (minimo valore per un int); INT_MAX (massimo valore per un int); UINT_MAX (massimo valore per un unsigned int). A queste si aggiungono minimi e massimi per gli altri tipi: SCHAR_MIN, SCHAR_MAX e UCHAR_MAX per signed char e unsigned char; CHAR_MIN e CHAR_MAX per i char; SHRT_MIN, SHRT_MAX e USHRT_MAX per gli short; LONG_MIN, LONG_MAX e ULONG_MAX per i long

In STDDEF.H è definito un gruppo di costanti manifeste il cui simbolo è costituito da un prefisso indicante il tipo di dato (FLT_ per float; DBL_ per double; LDBL_ per long double) e da un suffisso al quale è associato il significato della costante medesima. Presentiamo un elenco dei soli suffissi (per brevità) precisando che l'elenco completo delle costanti manifeste si ottiene unendo ogni prefisso ad ogni suffisso (ad es.: FLT_DIG; DBL_DIG; LDBL_DIG; etc.): 

SUFFISSI PER LE COSTANTI MANIFESTE DEFINITE IN STDDEF.H 
SUFFISSI
SIGNIFICATI
SUFFISSI
SIGNIFICATI
MAX Massimo valore  MIN Minimo valore positivo 
MAX_10_EXP Max.esponente decimale  MIN_10_EXP Min.esponente decimale 
MAX_EXP Max.esponente binario  MIN_EXP Min.esponente binario 
DIG N.cifre di precisione  MANT_DIG N.di bit in mantissa 
EPSILON Min.valore di macchina  RADIX Radice dell'esponente 
Con riferimento ai tipi in virgola mobile, va ancora ricordato che lo standard ANSI ha definito il tipo long double, stabilendo che esso deve essere di dimensione maggiore o uguale al double, senza fissare altri vincoli all'implementazione. Sulle macchine 80x86 esso occupa 80 bit[2], ma su altre piattaforme hardware i compilatori possono gestirlo diversamente. 

Le difficoltà non si limitano alla dimensione dei tipi di dato. Appare rilevante, ai nostri fini, anche il modo in cui il processore ne gestisce l'aritmetica. Esistono infatti processori basati sull'aritmetica in complemento a due (come quelli appartenenti alla famiglia Intel 80x86) ed altri basati sull'aritmetica in complemento a uno. Le differenze sono notevoli: questi ultimi, a differenza dei primi, gestiscono due rappresentazioni dello zero (una negativa e una positiva). Inoltre varia la rappresentazione binaria interna dei numeri negativi: ad esempio, ­1 ad otto bit è 11111111 in complemento a due e 11111110 in complemento a uno[3]

Infine, alcune considerazioni sul metodo utilizzato dal processore per ordinare i byte in memoria. Gli Intel 80x86 lavorano nella modalità cosiddetta backwords, cioè a parole rovesciate: ogni coppia di byte (detta word, o parola) è memorizzata in modo che al byte meno significativo corrisponda la locazione di memoria di indirizzo inferiore; quando un dato è formato da due parole (ad esempio un long a 32 bit), un analogo criterio è applicato ad ognuna di esse (la word meno significativa precede in memoria quella più significativa). La conseguenza è che i dati sono memorizzati, in sostanza, a rovescio. Altri processori si comportano in modo differente, e ciò può rendere problematico portare da una macchina all'altra programmi che assumano come scontata una certa modalità di memorizzazione dei byte[4]

Dipendenze dai compilatori 

Anche le differenze esistenti tra le molteplici implementazioni di compilatori hanno rilevanti riflessi sulla portabilità. E' del tutto palese che costrutti sintattici ammessi da un compilatore ma non rientranti negli standard (ancora una volta il punto di riferimento è lo standard ANSI) possono non essere ammessi da altri, con ovvie conseguenze. Un macroscopico esempio è rappresentato dallo inline assembly, cioè dalle istruzioni di linguaggio assembler direttamente inserite nel codice C. 

Talvolta, problemi di portabilità possono sorgere tra successive release della medesima implementazione di compilatore: la famigerata opzione ­k­ insegna

Il numero dei caratteri significativi nei simboli (nomi di variabili, di funzioni, di etichette) non è fissato da alcuno standard. Ad esempio, alcuni compilatori sono in grado di distinguere tra loro nomi (ipotetici) di funzioni quali ConvertToUpper() e ConvertToLower(), mentre altri non lo sono. Inoltre, nonostante il C sia un linguaggio case sensitive (che distingue, cioè, le lettere minuscole da quelle maiuscole), e dunque lo siano tutti i compilatori, possono non esserlo alcuni linker (quantomeno per default): pericoloso, perciò, includere nei sorgenti simboli che differiscono tra loro solo per maiuscole e minuscole (es.: DOSversion e DosVersion). 

Anche in tema di compilatori vi è spazio, comunque, per considerazioni più particolari. Il C riconosce gli operatori unari di incremento ++ e decremento ­­, che possono essere anteposti o, in alternativa, posposti alla variabile che si intende incrementare (decrementare): vale la regola generale che quando esso precede la variabile, questa è incrementata (decrementata) prima di valutare l'espressione di cui fa parte; quando esso la segue, l'incremento (il decremento) avviene dopo la valutazione dell'intera espressione. Consideriamo, però, le due seguenti chiamate a funzione: 
    funz(++a);
    funz(a++);
Contrariamente a quanto si potrebbe supporre, non è garantito che nella prima la variabile a sia incrementata prima di passarne il valore alla funzione e, viceversa, nella seconda essa sia incrementata successivamente. Lo standard del linguaggio non prevede, al riguardo, vincoli particolari per l'implementazione, coerentemente con la generale libertà che ogni compilatore C può concedersi nella valutazione delle espressioni[5]

A tutti i programmatori C è noto, inoltre, il problema degli effetti collaterali (side­effect) che possono verificarsi quando una variabile in autoincremento (o decremento) costituisce il parametro di una macro, piuttosto che di una funzione. Al riguardo precisiamo che, a seconda dell'implementazione del compilatore, una funzione C può benissimo essere in realtà... una macro: è meglio dunque documentarsi a fondo[6], al minimo curiosando nei file .H

Ancora sulle funzioni: le implementazioni recenti di compilatori ammettono (ed è lo stile di programmazione consigliato) che nelle dichiarazioni di funzione siano specificati tipo e nome dei parametri formali: 
int funzione(int *ptr,long parm);
Ciò consente controlli precisi sul tipo dei parametri attuali e limita le possibilità di errore. I compilatori obsoleti individuano in tali dichiarazioni un errore di sintassi e precisano che i parametri possono essere specificati solo nelle definizioni di funzione (non nelle dichiarazioni). 

E' ora il momento di affrontare i puntatori. In C è del tutto lecito e normale calcolare la differenza tra due puntatori: ci si potrebbe aspettare che il risultato sia un integer (o unsigned integer). In realtà esso può essere un long: dipende, ancora una volta, dall'implementazione del compilatore. Lo standard ANSI definisce il tipo ptrdiff_t [7], il quale garantisce la coerenza della dimensione delle variabili ad esso appartenenti con il comportamento del compilatore nel calcolo di differenze tra puntatori. 

Aggiungiamo, per rimanere in tema, che il puntatore nullo NULL non è necessariamente uno zero binario in tutte le implementazioni. 

Per quanto riguarda i tipi di dato, occorre prestare attenzione al trattamento riservato dal compilatore ai char, i quali possono essere considerati signed o unsigned char per default. Ignorare le convenzioni in base alle quali il compilatore utilizzato si comporta può essere fonte di bug inaspettati e molto difficili da scovare: ci si ricordi che se, per esempio, i char sono trattati come segnati, un'espressione del tipo (a < 0x90), dove a è dichiarata semplicemente char, è sempre vera; analogamente, se i char sono considerati privi di segno, (a < 0x00) è sempre e comunque falsa. Le costanti esadecimali, infatti, sono sempre considerate prive di segno. Altre "stranezze" si verificano nel caso di assegnamento del valore (negativo) di una variabile char ad una variabile int, come risulta dal seguente esempio: 
    ....
    char c;
    int i;
    ....
    c = -1;
    i = c;
    ....
Quanto vale i? Se il compilatore considera i char grandezze senza segno, allora i vale 255; in caso contrario assume il valore ­1

Dipendenze dal sistema operativo 

Il sistema operativo mette a disposizione del programmatore un insieme di servizi che possono costituire una comoda interfaccia tra il software applicativo e il ROM BIOS o lo hardware. Sistemi operativi progettati per fornire ambienti differenti sono (è ovvio) interinsecamente diversi; ciononostante è spesso possibile portare programmi dall'uno all'altro senza particolari difficoltà. E' il caso, ad esempio, di Unix e DOS, quando si conoscano entrambi i sistemi appena un poco in profondità e si rinunci ad ottimizzazioni a basso livello del codice. Al riguardo intendiamo ricordare solo alcune delle differenze più notevoli tra DOS e Unix, quale spunto per meditazioni più approfondite. 

In primo luogo Unix è, contrariamente al DOS, un sistema multiuser/multitask; un programma scritto sotto DOS con "ambizioni" di portabilità deve essere in grado di gestire la condivisione delle risorse con altri processi. 

Un'altra differenza di rilievo consiste nell'assenza, in Unix, del concetto di volume (cioè di disco logico): pertanto un pathname, sotto Unix, non può includere un identificativo di drive. Vi sono poi sistemi operativi (CP/M, antesignano dello stesso DOS) che non gestiscono file systems gerarchici (in altre parole: non consentono l'uso delle directory). Inoltre la backslash ('\') che in DOS separa i nomi di directory e identifica la root è una slash ('/') in Unix, il quale considera il punto ('.'), nei nomi di file, alla stregua di un carattere qualsiasi, mentre in DOS esso ha la funzione di separare nome ed estensione. In ambiente DOS, infine, i nomi di file possono contare un massimo di undici caratteri (otto per il nome e tre per l'estensione); Unix ammette fino a quattordici caratteri; OS/2 (se installato con HPFS[8]) fino a 256. 

Unix gestisce le unità periferiche come device[9]: tale caratteristica è stata in parte ripresa dal DOS ed il linguaggio C consente di sfruttarla proficuamente attraverso le funzioni basate su stream. Particolarmente interessanti sono gli stream standard, resi disponibili dal sistema, ed usati da funzioni e macro come printf(), puts(), gets(), etc.: si tratta di stdin (standard input), stdout (standard output), stderr (standard error), stdaux (standard auxiliary) e stdprn (standard printer). La portabilità tra Unix e DOS del codice che ne fa uso è quasi totale[10], ma vi sono sistemi operativi che gestiscono le periferiche in modo assai differente. 

Approfondimenti circa problemi di portabilità tra Unix e DOS di sorgenti che implementano controllo e gestione di processi sono disponibili nel paragrafo dedicato.

Se, da una parte, è prevedibile incontrare problemi di portabilità tra sistemi di differente concezione tecnica, dall'altra sarebbe un errore ritenere che lo scrivere codice portabile tra sistemi fortemente analoghi sia privo di ogni difficoltà: si pensi, ad esempio, alle differenze esistenti tra versioni successive del DOS. Il codice che utilizzi servizi DOS non presenti in tutte le versioni non può dirsi completamente portabile neppure nell'ambito del medesimo ambiente operativo. La realizzazione di codice portabile tra ogni release di DOS implica la rinuncia alle funzionalità introdotte via via con le nuove versioni[11]: si tratta, con ogni probabilità, di un prezzo troppo alto e spetta pertanto al programmatore scegliere un opportuno compromesso tra il grado di portabilità da un lato e il livello di efficienza, unitamente al contenuto innovativo del codice[12], dall'altro. Le funzioni implementate dalle librerie standard dei recenti compilatori C si basano su servizi DOS disponibili a partire dalla versione 2.0 o 3.0. 

Va aggiunto che esistono versioni di DOS modificate da produttori di hardware per migliorarne la compatibilità con le macchine da essi commercializzate; possono così riscontrarsi diversità non solo tra release successive, ma anche tra differenti "marchi" nell'ambito della medesima release. Quasi sempre si tratta, in questi casi, di differenze nelle modalità con le quali il DOS interagisce con BIOS e hardware; pertanto difficilmente esse hanno reale influenza sulla portabilità del codice, eccetto i casi in cui questo incorpori o utilizzi servizi implementati a basso livello. Con un approccio forse un po' grossolano si può inoltre osservare che il DOS è costituito da un insieme di routine, ciascuna in grado di svolgere un compito piuttosto elementare (aprire un file, visualizzare un carattere...). Alcune di esse formano l'insieme dei servizi resi disponibili da quella particolare release: sono, pertanto descritte nella documentazione tecnica e la loro permanenza in future versioni è garantita. Altre, realizzate quali routine di supporto alle precedenti, sono riservate ad uso "interno" da parte del sistema operativo stesso: i manuali tecnici non vi fanno cenno e non è possibile contare sulla loro presenza nelle versioni future[13]

OK, andiamo avanti a leggere il libro... 

Non ci ho capito niente! Ricominciamo...