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 hardwaredipendente 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
kinsegna.
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 (sideeffect) 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...