Per poter parlare di come si gestiscono i dati, occorre prima
precisare che cosa essi siano, o meglio che cosa si intenda con
il termine "dati", non tanto dal punto di vista della
logica informatica, quanto piuttosto da quello strettamente tecnico
ed operativo.
In tal senso, va innanzitutto osservato che tutto quanto viene
elaborato dal microprocessore di un computer deve risiedere nella
memoria di questo, la cosiddetta RAM[1],
che, al di là della sua implementazione hardware, è
una sequenza di bit, ciascuno dei quali, ovviamente, può
assumere valore 1 oppure 0. Nella RAM si trova anche il codice
macchina eseguibile del programma: semplificando un poco, possiamo
dire che tutta la parte di RAM non occupata da quello può
rappresentare "dati".
E' evidente che nella maggior parte dei casi un programma non
controlla tutta la memoria, ma solo una porzione più o
meno ampia di essa; inoltre le regole in base alle quali esso
ne effettua la gestione sono codificate all'interno del programma
stesso e dipendono, almeno in parte, dal linguaggio utilizzato
per scriverlo.
Sintetizzando quanto affermato sin qui, i dati gestiti da un programma
sono sequenze di bit situate nella parte di RAM che esso controlla:
se il programma vi può accedere in lettura e scrittura,
dette sequenze rappresentano le cosiddette "variabili";
se l'accesso può avvenire in sola lettura si parla, invece,
di "costanti".
Dal punto di vista del loro significato si apre invece il discorso
dei tipi di dato.
Al fine di attribuire significato ad una sequenza di bit occorre
sapere quanti bit la compongono, e, come vedremo, qual è
la loro organizzazione al suo interno. La più ristretta
sequenza di bit significativa per le macchine è il byte,
che si compone di 8 bit[2].
In C, al byte corrisponde il tipo di dato character, cioè
carattere. Esso può assumere 256 valori diversi (28
= 256). Si distinguono due tipi di character: il signed character,
in cui l'ottavo bit funge da indicatore di segno (se è
1 il valore è negativo), e l'unsigned character,
che utilizza invece tutti gli 8 bit per esprimere il valore, e
può dunque esclusivamente assumere valori positivi. Un
signed char può variare tra 128 e 127,
mentre un unsigned char può esprimere valori tra 0
e 255.
La sequenza di bit di ampiezza immediatamente superiore al byte
è detta word. Qui il discorso si complica leggermente,
perché mentre il byte si compone di 8 bit su quasi tutte
le macchine, la dimensione della word dipende dal microprocessore
che questa utilizza, e può essere, generalmente, di16 o32 bit.
Nelle pagine che seguono faremo riferimento alla word come ad
una sequenza di 16 bit, in quanto è tale la sua dimensione
su tutte le macchine che utilizzano i processori Intel 8086 o
8088, o i chips 80286, 80386 e 80486 in modalità reale
(cioè compatibile con l'Intel 8086).
Il tipo di dato C corrispondente alla word è l'integer,
cioè intero. Anche l'integer può essere signed
o unsigned. Dando per scontato, come appena detto,
che un integer (cioè una word) occupi 16 bit, i valori
estremi del signed integer sono 32768 e 32767,
mentre quelli dell'unsigned integer sono 0 e 65535.
Tra il character e l'integer si colloca lo short integer,
che può essere, manco a dirlo, signed o unsigned.
Lo short integer occupa 16 bit, perciò stanti le assunzioni
sulla dimensione della word, ai nostri fini short integer e integer
sono equivalenti.
Per esprimere valori interi di notevole entità il C definisce
il long integer, che occupa 32 bit. Anche il long integer
può essere signed o unsigned. Nelle macchine
in cui la word è di 32 bit, integer e long integer coincidono.
Tutti i tipi sin qui descritti possono rappresentare solo valori
interi, e sono perciò detti integral types.
In C è naturalmente possibile gestire anche numeri in virgola
mobile, mediante appositi tipi di dato[3]:
il floating point, il double precision e il long
double precision. Il floating point occupa 32 bit ed offre
7 cifre significative di precisione, il double precision occupa
64 bit con 15 cifre di precisione e il long double precision 80 bit[4]
con 19 cifre di precisione. Tutti i tipi in virgola mobile sono
dotati di segno.
La tabella che segue riassume le caratteristiche dei tipi di dato
sin qui descritti.
TIPI DI DATO IN C
character | da -128 a 127 | ||
unsigned character | da 0 a 255 | ||
short integer | da -32768 a 32767 | ||
unsigned short integer | da 0 a 65535 | ||
integer | da -32768 a 32767 | ||
unsigned integer | da 0 a 65535 | ||
long integer | da -2147483648 a 2147483647 | ||
unsigned long integer | da 0 a 4294967295 | ||
floating point | da 3.4*10-38 a 3.4*1038 | ||
double precision | da 1.7*10-308 a 1.7*10308 | ||
long double precision | da 3.4*10-4932 a 1.1*104932 |
Il C non contempla un tipo di dato "stringa". Le stringhe
di caratteri (come "Ciao Ciao!\n") sono gestite
come array di character, cioè
come sequenze di caratteri che occupano posizioni contigue in
memoria ed ai quali è possibile accedere mediante l'indice
della loro posizione. Le stringhe possono
anche essere gestite mediante i puntatori.
Vi è, infine, un tipo di dato particolare, utilizzabile
per esprimere l'assenza di dati o per evitare di specificare a
quale tipo, tra quelli appena descritti, appartenga il dato: si
tratta del void type. Esso può essere utilizzato
esclusivamente per dichiarare puntatori void
e funzioni.
E' il momento di ripescare CIAO.C e complicarlo un poco.
#include <stdio.h> void main(void); void main(void) { unsigned int anni; float numero; anni = 31; numero = 15.66; printf("Ciao Ciao! Io ho %u anni\n",anni); printf("e questo è un float: %f\n",numero); }
Nella nuova versione, CIAO2.C abbiamo introdotto qualcosa
di molto importante: l'uso delle variabili. Il C consente di individuare
una certa area di memoria mediante un nome arbitrario che le viene
attribuito con un'operazione detta definizione della variabile;
la variabile è ovviamente l'area di RAM così identificata.
Ogni riferimento al nome della variabile è in realtà
un riferimento al valore in essa contenuto; si noti, inoltre,
che nella definizione della variabile viene specificato il tipo
di dato associato a quel nome (e dunque contenuto nella variabile).
In tal modo il programmatore può gestire i dati in RAM
senza conoscerne la posizione e senza preoccuparsi (entro certi
limiti) della loro dimensione in bit e dell'organizzazione interna
dei bit, cioè del significato che ciascuno di essi assume
nell'area di memoria assegnata alla variabile.
Con la riga
unsigned int anni;
viene definita una variabile di nome anni e di tipo unsigned integer (intero senza segno): essa occupa perciò una word nella memoria dell'elaboratore e può assumere valori da 0 a 65535. Va osservato che alla variabile non è associato, per il momento, alcun valore: essa viene inizializzata con la riga
anni = 31;
che costituisce un'operazione di assegnazione: il valore 31
è assegnato alla variabile anni; l'operatore "=",
in C, è utilizzato solo per le assegnazioni (che sono sempre
effettuate da destra a sinistra), in quanto per il controllo di una condizione di uguaglianza
si utilizza un operatore apposito ("==").
Tuttavia è possibile inizializzare una variabile contestualmente
alla sua dichiarazione:
unsigned int anni = 31;
è, in C, un costrutto valido.
Nella definizione di variabili di tipo integral, la parola int
può essere sempre omessa, eccetto il caso in cui sia "sola":
unsigned anni = 31; // OK! sinonimo di unsigned int long abitanti; // OK! sinonimo di long int valore; // ERRORE! il solo nome della variabile NON basta!
Dal momento che ci siamo, anche se non c'entra nulla con le variabili, tanto vale chiarire che le due barre "//" introducono un commento, come si deduce dalle dichiarazioni appena viste. Viene considerato commento tutto ciò che segue le due barre, fino al termine della riga. Si possono avere anche commenti multiriga, aperti da "/*" e chiusi da "*/". Ad esempio:
/* abbiamo esaminato alcuni esempi di dichiarazioni di variabili */
Tutto il testo che fa parte di un commento viene ignorato dal compilatore e non influisce sulle dimensioni del programma eseguibile; perciò è bene inserire con una certa generosità commenti chiarificatori nei propri sorgenti. Non è infrequente che un listato, "dimenticato" per qualche tempo, risulti di difficile lettura anche all'autore, soprattutto se questi non ha seguito la regola... KISS, già menzionata. I commenti tra "/*" e "*/" non possono essere nidificati, cioè non si può fare qualcosa come:
/* abbiamo esaminato alcuni esempi /* alcuni validi e altri no */ di dichiarazioni di variabili */
Il compilatore segnalerebbe strani errori, in quanto il commento
è chiuso dal primo "*/" incontrato.
Tornando all'argomento del paragrafo, va ancora precisato che
in una riga logica possono essere definite (e, volendo, inizializzate)
più variabili, purché tutte dello stesso tipo, separandone
i nomi con una virgola:
int var1, var2; // due variabili int, nessuna delle quali inizializzata char ch1 = 'A', ch2; // due variabili char, di cui solo la prima inizializ. 5 float num, // dichiarazione di 3 float ripartita su 3 v1, // righe fisiche; solo l'ultima variabile terzaVar = 12.4; // e' inizializzata
Soffermiamoci un istante sulla dichiarazione dei 3 float:
la suddivisione in più righe non è obbligatoria,
ed ha esclusivamente finalità di chiarezza (dove avremmo
sistemato i commenti?). Inoltre, e questo è utile sottolinearlo,
l'inizializzazione ha effetto esclusivamente sull'ultima variabile
dichiarata, terzaVar. Un errore commesso frequentemente
dai principianti (e dai distratti) è assegnare un valore
ad una sola delle variabili dichiarate, nella convinzione che
esso venga assegnato anche a tutte quelle dichiarate "prima".
Ebbene, non è così. Ogni variabile deve essere inizializzata
esplicitamente, altrimenti essa contiene... già... che
cosa? Cosa contiene una variabile non inizializzata? Ai paragrafi successivi
l'ardua sentenza... per il momento, provate a pensarci su.
Inoltre, attenzione alle maiuscole. La variabile terzaVar
deve essere sempre indicata con la "V" maiuscola:
int terzavar; //OK! char TerzaVar; //OK! double terzaVar; //ERRORE! terzaVar esiste gia'!
Non è possibile dichiarare più variabili con lo
stesso nome in una medesima funzione (ma in funzioni diverse sì).
A rendere differente il nome è sufficiente una diversa
disposizione di maiuscole e minuscole.
I nomi delle variabili devono cominciare con una lettera dell'alfabeto
o con l'underscore ("_") e possono contenere
numeri, lettere e underscore. La loro lunghezza massima varia
a seconda del compilatore; le implementazioni commerciali più
diffuse ammettono nomi composti di oltre32 caratteri.
double _numVar; int Variabile_Intera_1; char 1_carattere; //ERRORE! il nome inizia con un numero
Anche il void type può essere incontrato nelle dichiarazioni:
esso può però essere utilizzato solo per dichiarare
funzioni o puntatori, ma non comuni variabili; la parola chiave
da utilizzare nelle dichiarazioni è void.
Per riassumere, ecco l'elenco dei tipi di dato e delle parole
chiave da utilizzare per dichiarare le variabili.
TIPI DI DATO E DICHIARATORI
character | char |
unsigned character | unsigned char |
short integer | short int, short |
unsigned short integer | unsigned short int, unsigned short |
integer | int |
unsigned integer | unsigned int, unsigned |
long integer | long int, long |
unsigned long integer | unsigned long int, unsigned long |
floating point | float |
double precision floating point | double |
long double precision floating point | long double |
void type | void |
Un'ultima osservazione: avete notato che nelle stringhe passate
a printf() sono comparsi strani simboli ("%u",
"%f")? Si tratta di formattatori di campo e
indicano a printf() come interpretare (e quindi visualizzare)
le variabili elencate dopo la stringa stessa. La sequenza "%u"
indica un intero senza segno, mentre "%f" indica
un dato di tipo float. Un intero con segno si indica
con "%d", una stringa con "%s",
un carattere con "%c".
Dalle pagine che precedono appare chiaro che la dimensione dell'area
di memoria assegnata dal compilatore ad una variabile dipende
dall'ingombro in byte del tipo di dato dichiarato. In molti casi
può tornare utile sapere quanti byte sono allocati (cioè
assegnati) ad una variabile, o a un tipo di dato. Allo scopo è
possibile servirsi dell'operatore sizeof(),
che restituisce come int il numero di byte occupato dal
tipo di dato o dalla variabile indicati tra le parentesi.
Una variabile è un'area di memoria alla quale è associato un nome simbolico, scelto dal programmatore. Detta area di memoria è grande quanto basta per contenere il tipo di dato indicato nella dichiarazione della variabile stessa, ed è collocata dal compilatore, automaticamente, in una parte di RAM non ancora occupata da altri dati. La posizione di una variabile in RAM è detta indirizzo, o address. Possiamo allora dire che, in pratica, ad ogni variabile il compilatore associa sempre due valori: il dato in essa contenuto e il suo indirizzo, cioè la sua posizione in memoria.
Proviamo ad immaginare la memoria come una sequenza di piccoli
contenitori, ciascuno dei quali rappresenta un byte: ad ogni "contenitore",
talvolta detto "locazione", potremo attribuire un numero
d'ordine, che lo identifica univocamente. Se il primo byte ha
numero d'ordine 0, allora il numero assegnato ad un generico byte
ne individua la posizione in termini di spostamento (offset)
rispetto al primo byte, cioè rispetto all'inizio della
memoria. Così, il byte numero 12445 dista proprio 12445
byte dal primo, il quale, potremmo dire, dista 0 byte da se stesso.
L'indirizzamento (cioè l'accesso alla memoria mediante
indirizzi) avviene proprio come appena descritto: ogni byte è
accessibile attraverso il suo offset rispetto ad un certo punto
di partenza, il quale, però, non necessariamente è
costituito dal primo byte di memoria in assoluto. Vediamo perché.
Nella CPU del PC sono disponibili alcuni byte, organizzati come
vere e proprie variabili, dette registri (register). La
CPU è in grado di effettuare elaborazioni unicamente sui
valori contenuti nei propri registri (che si trovano fisicamente
al suo interno e non nella RAM); pertanto qualunque valore oggetto
di elaborazione deve essere "caricato", cioè
scritto, negli opportuni registri. Il risultato delle operazioni
compiute dalla CPU deve essere conservato, se necessario, altrove
(tipicamente nella RAM), al fine di lasciare i registri disponibili
per altre elaborazioni.
Anche gli indirizzi di memoria sono soggetti a questa regola.
I registri del processore Intel 8086 si compongono di 16 bit ciascuno,
pertanto il valore massimo che essi possono esprimere è
quello dell'integer, cioè 65535 (esadecimale FFFF):
il massimo offset gestibile dalla CPU permette dunque di indirizzare
una sequenza di 65536 byte (compreso il primo, che ha offset pari
a 0), corrispondenti a 64Kb.
Configurazioni di RAM superiori (praticamente tutte) devono perciò
essere indirizzate con uno stratagemma: in pratica si utilizzano
due registri, rispettivamente detti registro di segmento (segment
register) e registro di offset (offset register). Segmento
e offset vengono solitamente indicati in notazione esadecimale,
utilizzando i due punti (":") come separatore,
ad esempio 045A:10BF. Ma non è tutto.
Se segmento e offset venissero semplicemente affiancati, si potrebbero
indirizzare al massimo 128Kb di RAM: infatti si potrebbe avere
un offset massimo di 65535 byte a partire dal byte numero 65535.
Quello che occorre è invece un valore in grado di numerare,
o meglio di indirizzare, almeno 1Mb: i fatidici 640Kb, ormai presenti
su tutte le macchine in circolazione, più gli indirizzi
riservati al BIOS e alle schede adattatrici[5].
Occorre, in altre parole, un indirizzamento a20 bit[6].
Questo si ottiene sommando al segmento i 12 bit più significativi
dell'offset, ed accodando i 4 bit rimanenti dell'offset stesso:
tale tecnica consente di trasformare un indirizzo segmento:offset
in un indirizzo lineare[7].
L'indirizzo seg:off di poco fa (045A:10BF) corrisponde
all'indirizzo lineare 0565F, infatti 045A+10B = 565
(le prime 3 cifre di un valore esadecimale di 4 cifre, cioè
a 16 bit, corrispondono ai 12 bit più significativi).
Complicato? Effettivamente... Ma dal momento che le cose stanno
proprio così, tanto vale adeguarsi e cercare di padroneggiare
al meglio la situazione. In fondo è anche questione di
abitudine.
Il C consente di pasticciare a volontà, ed anche... troppo,
con gli indirizzi di memoria mediante particolari strumenti, detti
puntatori, o pointers.
Un puntatore non è altro che una normalissima variabile
contenente un indirizzo di memoria. I puntatori non rappresentano
un tipo di dato in sé, ma piuttosto sono tipizzati in base
al tipo di dato a cui... puntano, cioè di cui esprimono
l'indirizzo. Perciò essi sono dichiarati in modo del tutto
analogo ad una variabile di quel tipo, anteponendo però
al nome del puntatore stesso l'operatore "*",
detto operatore di indirezione (dereference operator).
Così, la riga
int unIntero;
dichiara una variabile di tipo int avente nome unIntero, mentre la riga
int *puntaIntero;
dichiara un puntatore a int avente nome puntaIntero
(il puntatore ha nome puntaIntero, non l'int...
ovvio!). E' importante sottolineare che si tratta di un puntatore
a integer: il compilatore C effettua alcune operazioni sui puntatori
in modo automaticamente differenziato a seconda del tipo che il
puntatore indirizza[8], ma è
altrettanto importante non dimenticare mai che un puntatore contiene
semplicemente un indirizzo (o meglio un valore che viene gestito
dal compilatore come un indirizzo). Esso indirizza, in altre parole,
un certo byte nella RAM; la dichiarazione del tipo "puntato"
permette al compilatore di "capire" di quanti byte si
compone l'area che inizia a quell'indirizzo e come è organizzata al proprio interno,
cioè quale significato attribuire ai singoli bit.
Si possono dichiarare più puntatori in un'unica riga logica,
come del resto avviene per le variabili: la riga seguente dichiare
tre puntatori ad intero.
int *ptrA, *ptrB, *ptrC;
Si noti che l'asterisco, o meglio, l'operatore di indirezione, è ripetuto davanti al nome di ogni puntatore. Se non lo fosse, tutti i puntatori dichiarati senza di esso sarebbero in realtà... normalissime variabili di tipo int. Ad esempio, la riga che segue dichiara due puntatori ad intero, una variabile intera, e poi ancora un puntatore ad intero.
int *ptrA, *ptrB, unIntero, *intPtr;
Come si vede, la dichiarazione mista di puntatori e variabili
è un costrutto sintatticamente valido; occorre, come al
solito, prestare attenzione a ciò che si scrive se si vogliono
evitare errori logici piuttosto insidiosi. Detto tra noi, principianti
e distratti sono i più propensi a dichiarare correttamente
il primo puntatore e privare tutti gli altri dell'asterisco nella
convinzione che il tipo dichiarato sia int*. In realtà,
una riga di codice come quella appena riportata dichiara una serie
di oggetti di tipo int; è la presenza o l'assenza
dell'operatore di indirezione a stabilire, singolarmente per ciascuno
di essi, se si tratti di una variabile o di un puntatore.
Mediante l'operatore & (detto "indirizzo di",
o address of) è possibile, inoltre, conoscere l'indirizzo
di una variabile:
float numero; // dichiara una variabile float float *numPtr; // dichiara un puntatore ad una variabile float numero = 12.5; // assegna un valore alla variabile numPtr = № // assegna al puntatore l'indirizzo della variabile
E' chiaro il rapporto tra puntatori e variabili? Una variabile
contiene un valore del tipo della dichiarazione, mentre un puntatore
contiene l'indirizzo, cioè la posizione in memoria, di
una variabile che a sua volta contiene un dato del tipo della
dichiarazione. Dopo le operazioni dell'esempio appena visto, numPtr
non contiene 12.5, ma l'indirizzo di memoria al quale
12.5 si trova.
Anche un puntatore è una variabile, ma contiene un valore
che non rappresenta un dato di un particolare tipo, bensì
un indirizzo. Anche un puntatore ha il suo bravo indirizzo, ovviamente.
Riferendosi ancora all'esempio precedente, l'indirizzo di numPtr
può essere conosciuto con l'espressione &numPtr
e risulta sicuramente diverso da quello di numero, cioè
dal valore contenuto in numPtr. Sembra di giocare a rimpiattino...
Proviamo a confrontare le due dichiarazioni dell'esempio:
float numero; float *numPtr;
Esse sono fortemente analoghe; del resto abbiamo appena detto che la dichiarazione di un puntatore è identica a quella di una comune variabile, ad eccezione dell'asterisco che precede il nome del puntatore stesso. Sappiamo inoltre che il nome attribuito alla variabile identifica un'area di memoria che contiene un valore del tipo dichiarato: ad esso si accede mediante il nome stesso della variabile, cioè il simbolo che, nella dichiarazione, si trova a destra della parola chiave che indica il tipo, come si vede chiaramente nell'esempio che segue.
printf("%f\n",numero);
L'accesso al valore della variabile avviene nella modalità appena descritta non solo in lettura, ma anche in scrittura:
numero = 12.5;
Cosa troviamo a destra dell'identificativo di tipo in una dichiarazione di puntatore? Il nome preceduto dall'asterisco. Ma allora anche il nome del puntatore con l'asterisco rappresenta un valore del tipo dichiarato... Provate ad immaginare cosa avviene se scriviamo:
printf("%f\n",*numPtr);
La risposta è: printf() stampa il valore di numero[9]. In altre parole, l'operatore di indirezione non solo differenzia la dichiarazione di un puntatore da quella di una variabile, ma consente anche di accedere al contenuto della variabile (o, più in generale, della locazione di memoria) indirizzata dal puntatore. Forse è opportuno, a questo punto, riassumere il tutto con qualche altro esempio.
float numero = 12.5; float *numPtr = №
Sin qui nulla di nuovo[10]. Supponiamo ora che l'indirizzo di numero sia, in esadecimale, FFE6 e che quello di numPtr sia FFE4: non ci resta che giocherellare un po' con gli operatori address of ("&") e dereference ("*")...
printf("numero = %f\n",numero); printf("numero = %f\n",*numPtr); printf("l'indirizzo di numero e' %X\n",&numero); printf("l'indirizzo di numero e' %X\n",numPtr); printf("l'indirizzo di numPtr e' %X\n",&numPtr);
L'output prodotto è il seguente:
numero = 12.5 numero = 12.5 l'indirizzo di numero è FFE6 l'indirizzo di numero è FFE6 l'indirizzo di numPtr è FFE4
Le differenza tra le varie modalità di accesso al contenuto
e all'indirizzo delle veriabili dovrebbe ora essere chiarita.
Almeno, questa è la speranza. Tra l'altro abbiamo imparato
qualcosa di nuovo su printf(): per stampare un intero
in formato esadecimale si deve inserire nella stringa, invece
di %d, %X se si desidera che le cifre AF
siano visualizzate con caratteri maiuscoli, %x se si
preferiscono i caratteri minuscoli.
Va osservato che è prassi usuale esprimere gli indirizzi
in notazione esadecimale. A prima vista può risultare un
po' scomodo, ma, operando in tal modo, la logica di alcune operazioni
sugli indirizzi stessi (e sui puntatori) risulta sicuramente più
chiara. Ad esempio, ogni cifra di un numero esadecimale rappresenta
quattro bit in memoria: si è già visto
come ciò permetta di trasformare un indirizzo segmentato
nel suo equivalente lineare con grande facilità. Per la
cronaca, tale operazione è detta anche "normalizzazione"
dell'indirizzo (o del puntatore).
Vogliamo complicarci un poco la vita? Eccovi alcune
interessanti domandine, qualora non ve le foste ancora posti...
Provate a trovare da soli le soluzioni, prima di andare a leggere le risposte!
I puntatori sono, dunque, strumenti appropriati alla manipolazione
ad alto livello degli indirizzi delle variabili. C'è proprio
bisogno di preoccuparsi dei registri della CPU e di tutte le contorsioni
possibili tra indirizzi seg:off e indirizzi lineari? Eh, sì...
un poco è necessario; ora si tratta di capire il perché.
Poco fa abbiamo ipotizzato che l'indirizzo di numero
e di numPtr fossero, rispettivamente, FFE6 e
FFE4. A prescindere dai valori, realisitci ma puramente
ipotetici, è interessante notare che si tratta di due unsigned
int. In effetti, per visualizzarli correttamente, abbiamo
passato a printf() stringhe contenenti %X, lo
specificatore di formato per gli interi in formato esadecimale.
Che significa tutto ciò?
Significa che il valore memorizzato in numPtr (e in
qualsiasi altro puntatore[11]) è
una word, occupa 16 bit e si differenzia da un generico intero
senza segno per il solo fatto che esprime un indirizzo di memoria.
E' evidente, alla luce di quanto appena affermato, che l'indirizzo
memorizzato in numPtr è un offset: come tutti
i valori a 16 bit esso è gestito dalla CPU in uno dei suoi
registri e può variare tra 0 e 65535. Un puntatore come
numPtr esprime allora, in byte, la distanza di una variabile
da... che cosa? Dall'indirizzo contenuto in un altro registro
della CPU, gestito automaticamente dal compilatore.
Con qualche semplificazione possiamo dire che il compilatore,
durante la traduzione del sorgente in linguaggio macchina, stabilisce
quanto spazio il programma ha a disposizione per gestire i propri
dati e a quale distanza dall'inizio del codice eseguibile deve
avere inizio l'area riservata ai dati. Dette informazioni sono
memorizzate in una tabella, collocata in testa al file eseguibile,
che il sistema operativo utilizza per caricare l'opportuno valore
in un apposito registro della CPU. Questo registro contiene la
parte segmento dell'indirizzo espresso da ogni puntatore dichiarato
come numPtr.
Nella maggior parte dei casi l'esistenza dei registri di segmento
è del tutto trasparente al programmatore, il quale non
ha alcun bisogno di proccuparsene, in quanto compilatore, linker
e sistema operativo svolgono automaticamente tutte le operazioni
necessarie alla loro gestione. Nello scrivere un programma è
di solito sufficiente lavorare con i puntatori proprio come abbiamo
visto negli esempi che coinvolgono numero e numPtr:
gli operatori "*" e "&"
sono caratterizzati da una notevole potenza operativa.
Le considerazioni sin qui espresse, però, aprono la via ad alcuni approfondimenti. In primo luogo, va sottolineato ancora una volta che numPtr occupa 16 bit di memoria, cioè 2 byte, proprio come qualsiasi unsigned int. E ciò è valido anche se il tipo di numero, la variabile puntata, è il float, che ne occupa 4. In altre parole, un puntatore occupa sempre lo spazio necessario a contenere l'indirizzo del dato puntato, e non il tipo di dato; tutti i puntatori come numPtr, dunque, occupano 2 byte, indipendentemente che il tipo di dato puntato sia un int, piuttosto che un float, o un double... Una semplice verifica empirica può essere effettuata con l'aiuto dell'operatore sizeof().
int unIntero; long unLongInt; float unFloating; double unDoublePrec; int *intPtr; long *longPtr; float *floatPtr; double *doublePtr; printf("intPtr: %d bytes (%d)\n",sizeof(intPtr),sizeof(int *)); printf("longPtr: %d bytes (%d)\n",sizeof(longPtr),sizeof(long *)); printf("floatPtr: %d bytes (%d)\n",sizeof(floatPtr),sizeof(float *)); printf("doublePtr: %d bytes (%d)\n",sizeof(doublePtr),sizeof(double *));
Tutte le printf() visualizzano due volte il valore 2,
che è appunto la dimensione in byte di un generico puntatore.
L'esempio mostra, tra l'altro, come sizeof() possa essere
applicato sia al tipo di dato che al nome di una variabile (in
questo caso dei puntatori); se ne trae, infine, che il tipo di
un puntatore è dato dal tipo di dato puntato, seguito dall'asterisco.
Tutti i puntatori come numPtr, dunque, gestiscono un
offset da un punto di partenza automaticamente fissato dal sistema
operativo in base alle caratteristiche del file eseguibile. E'
possibile in C, allora, gestire indirizzi lineari, o quanto meno
comprensivi di segmento ed offset? La risposta è sì.
Esistono due parole chiave, dette modificatori di tipo, che consentono
di dichiarare puntatori speciali, in grado di gestire sia la parte
segmento che la parte offset di un indirizzo di memoria: si tratta
di far e huge.
double far *numFarPtr;
La riga di esempio dichiara un puntatore far a un dato
di tipo double. Per effetto del modificatore far,
numFarPtr è un puntatore assai differente dal
numPtr degli esempi precedenti: esso occupa 32 bit di
memoria, cioè 2 word, ed è pertanto equivalente
ad un long int. Di conseguenza numFarPtr è
in grado di esprimere tanto la parte offset di un indirizzo (nei
2 byte meno significativi), quanto la parte segmento (nei
2 byte più significativi[12]).
La parte segmento è utilizzata dalla CPU per caricare l'opportuno
registro di segmento, mentre la parte offset è gestita
come al solito: in tal modo un puntatore far può
esprimere un indirizzo completo del tipo segmento:offset e indirizzare
dati che si trovano al di fuori dell'area dati assegnata dal sistema
operativo al programma.
Ad esempio, se si desidera che un puntatore referenzi l'indirizzo
596A:074B, lo si può dichiarare ed inizializzare
come segue:
double far *numFarPtr = 0x596A074B;
Per visualizzare il contenuto di un puntatore far con printf() si può utilizzare un formattatore speciale:
printf("numFarPtr = %Fp\n",numFarPtr);
Il formattatore %Fp forza printf() a visualizzare il contenuto di un puntatore far proprio come segmento ed offset, separati dai due punti:
numFarPtr = 596A:074B
è l'output prodotto dalla riga di codice appena riportata.
Abbiamo appena detto che un puntatore far rappresenta
un indirizzo seg:off. E' bene... ripeterlo qui, sottolineando
che quell'indirizzo, in quanto seg:off, non è un indirizzo
lineare. Parte segmento e parte offset sono, per così dire,
indipendenti, nel senso che la prima è considerata costante,
e la seconda variabile. Che significa? la riga
char far *vPtr = 0xB8000000;
dichiara un puntatore far a carattere e lo inizializza
all'indirizzo B800:0000; la parte offset è nulla,
perciò il puntatore indirizza il primo byte dell'area che
ha inizio all'indirizzo lineare B8000 (a 20 bit). Il
secondo byte ha offset pari a 1, perciò può
essere indirizzato incrementando di 1 il puntatore, portandolo
al valore 0xB8000001. Incrementando ancora il puntatore,
esso assume valore 0xB8000002 e punta al terzo byte.
Sommando ancora 1 al puntatore, e poi ancora 1,
e poi ancora... si giunge ad un valore particolare, 0xB800FFFF,
corrispondente all'indirizzo B800:FFFF, che è
proprio quello del byte avente offset 65535 rispetto
all'inizio dell'area. Esso è l'ultimo byte indirizzabile
mediante un comune puntatore near[13].
Che accade se si incrementa ancora vPtr? Contrariamente
a quanto ci si potrebbe attendere, la parte offset si riazzera
senza che alcun "riporto" venga sommato alla parte segmento.
Insomma, il puntatore si "riavvolge" all'inizio dell'area
individuata dall'indirizzo lineare rappresentato dalla parte segmento
con uno 0 alla propria destra (che serve a costruire
l'indirizzo a 20 bit). Ora si comprende meglio (speriamo!) che
cosa si intende per parte segmento e parte offset separate: esse
sono utilizzate proprio per caricare due distinti registri della
CPU e pertanto sono considerate indipendenti l'una dall'altra,
così come lo sono tra loro tutti i registri del microprocessore.
Tutto ciò ha un'implicazione estremamente importante: con
un puntatore far è possibile indirizzare un dato
situato ad un qualunque indirizzo nella memoria disponibile entro
il primo Mb, ma non è possibile "scostarsi" dall'indirizzo
lineare espresso dalla parte segmento oltre i 64Kb. Per fare un
esempio pratico, se si intende utilizzare un puntatore far
per gestire una tabella, la dimensione complessiva di questa non
deve eccedere i 64Kb.
Tale limitazione è superata tramite il modificatore huge,
che consente di avere puntatori in grado di indirizzare linearmente
tutta la memoria disponibile (sempre entro il primo Mb). La dichiarazione
di un puntatore huge non presenta particolarità:
int huge *iHptr;
Il segreto dei puntatori huge consiste in alcune istruzioni assembler che il compilatore introduce di soppiatto nei programmi tutte le volte che il valore del puntatore viene modificato o utilizzato, e che ne effettuano la normalizzazione. Con tale termine si indica un semplice calcolo che consente di esprimere l'indirizzo seg:off come rappresentazione di un indirizzo lineare: in modo, cioè, che la parte offset sia variabile unicamente da 0 a 15 (F esadecimale) ed i riporti siano sommati alla parte segmento. In pratica si tratta di sommare alla parte segmento i 12 bit più significativi della parte offset. Riprendiamo l'esempio precedente, utilizzando questa volta un puntatore huge.
char huge *vhugePtr = 0xB8000000;
L'inizializzazione del puntatore huge, come si vede,
è identica a quella del puntatore far. Incrementando
di 1 il puntatore si ottiene il valore 0xB8000001,
come nel caso precedente. Sommando ancora 1 si ha 0xB8000002,
e poi 0xB8000003, e così via. Sin qui, nulla di
nuovo. Al quindicesimo incremento il puntatore vale 0xB800000F,
come nel caso del puntatore far.
Ma al sedicesimo incremento si manifesta la differenza: il puntatore
far assume valore 0xB8000010, mentre il puntatore
huge vale 0xB8010000: la parte segmento si è
azzerata ed il 16 sottratto ad essa ha prodotto
un riporto[14] che è andato
ad incrementare di 1 la parte segmento. Al trentunesimo
incremento il puntatore far vale 0xB800001F,
mentre quello huge è 0xB801000F. Al trentaduesimo
incremento il puntatore far diventa 0xB8000020,
mentre quello huge vale 0xB8020000.
Il meccanismo dovrebbe essere ormai chiaro, così come il
fatto che le prime 3 cifre della parte offset di un puntatore
huge sono sempre 3 zeri. Fingiamo per un attimo di non
vederli: la parte segmento e la quarta cifra della parte offset
rappresentano proprio un indirizzo lineare a 20 bit.
La normalizzazione effettuata dal compilatore consente di gestire
indirizzi lineari pur caricando in modo indipendente parte segmento
e parte offset in registri di segmento e, rispettivamente, di
offset della CPU; in tal modo, con un puntatore huge
non vi sono limiti né all'indirizzo di partenza, né
alla quantità di memoria indirizzabile a partire da quell'indirizzo.
Naturalmente ciò ha un prezzo: una piccola perdita di efficienza
del codice eseguibile, introdotta dalla necessità di eseguire
la routine di normalizzazione prima di utilizzare il valore del
puntatore.
Ancora una precisazione: nelle dichiarazioni multiple di puntatori
far e huge, il modificatore deve essere ripetuto
per ogni puntatore dichiarato, analogamente a quanto occorre per
l'operatore di indirezione. L'omissione del modificatore determina
la dichiarazione di un puntatore "offset" a 16 bit.
long *lptr, far *lFptr, lvar, huge *lHptr;
Nell'esempio sono dichiarati, nell'ordine, il puntatore a long
a 16 bit lptr, il puntatore far a long
lFptr, la variabile long lvar e il
puntatore huge a long lHptr.
E' forse il caso di sottolineare ancora che la dichiarazione di
un puntatore riserva spazio in memoria esclusivamente per il puntatore
stesso, e non per una variabile del tipo di dato indirizzato.
Ad esempio, la dichiarazione
long double far *dFptr;
alloca, cioè riserva, 32 bit di RAM che potranno essere
utilizzate per contenere l'indirizzo di un long double,
i cui 80 bit dovranno essere allocati con un'operazione a parte[15].
Tanto per confondere un poco le idee, occorre precisare un ultimo
particolare. I sorgenti C possono essere compilati, tramite particolari
opzioni riconosciute dal compilatore, in modo da applicare differenti
criteri di default alla gestione dei puntatori. In particolare,
vi sono modalità di compilazione che trattano tutti i puntatori
come variabili a 32 bit, eccetto quelli esplicitamente dichiarati
near. Ne riparleremo descrivendo i modelli di memoria.
Per il momento è il caso di accennare a tre macro, definite
in DOS.H, che agevolano in molti casi la manipolazione
dei puntatori a 32 bit, siano essi far o huge:
si tratta di MK_FP(), che "costruisce" un puntatore
a 32 bit dati un segmento ed un offset entrambi a 16 bit, di FP_SEG(),
che estrae da un puntatore a 32 bit i 16 bit esprimenti la parte
segmento e di FP_OFF(), che estrae i 16 bit esprimenti
l'offset. Vediamole al lavoro:
#include <dos.h> .... unsigned farPtrSeg; unsigned farPtrOff; char far *farPtr; .... farPtr = (char far *)MK_FP(0xB800,0); // farPtr punta a B800:0000 farPtrSeg = FP_SEG(farPtr); // farPtrSeg contiene 0xB800 farPtrOff = FP_OFF(farPtr); // farPtrOff contiene 0
Le macro testè descritte consentono di effettuare facilmente la normalizzzione di un puntatore, cioè trasformare l'indirizzo in esso contenuto in modo tale che la parte offset non sia superiore a 0Fh:
char far *cfPtr; char huge *chPtr; .... chPtr = (char huge *)(((long)FP_SEG(cfPtr)) << 16)+ (((long)(FP_OFF(cfPtr) >> 4)) << 16)+(FP_OFF(cfPtr) & 0xF);
Come si vede, dalla parte offset sono scartati i 4 bit meno significativi:
i 12 bit più significativi sono sommati al segmento; dalla
parte offset sono poi scartati i 12 bit più significativi
e i 4 bit restanti sono sommati al puntatore. Il significato degli
operatori di shift <<
e >> e dell'operatore & (che in questo
caso non ha il significato di address of,
ma di and su bit)
è descritto più avanti.
L'indirizzo lineare corrispondente all'indirizzo segmentato espresso
da un puntatore huge può essere ricavato come
segue:
char huge *chPtr; long linAddr; .... linAddr = ((((((long)FP_SEG(chPtr)) << 16)+(FP_OFF(chPtr) << 12)) >> 12) & 0xFFFFFL);
Per applicare tale algoritmo ad un puntatore far è
necessario che questo sia dapprima normalizzato come descritto
in precedenza.
E' facile notare che due puntatori far possono referenziare
il medesimo indirizzo pur contenendo valori a 32 bit differenti,
mentre ciò non si verifica con i puntatori normalizzati,
nei quali segmento e offset sono sempre gestiti in modo univoco:
ne segue che solamente i confronti tra puntatori huge
(o normalizzati) garantiscono risultati corretti.
La dichiarazione
static float *ptr;
dichiara un puntatore static a un dato di tipo float. In realtà non è possibile, nel dichiarare un puntatore, indicare che esso indirizza un dato static essendo questo un modificatore della visibilità delle variabili, e non già del loro tipo. Si veda anche quanto detto circa i puntatori a funzione.
Abbiamo anticipato che non esiste, in C, il tipo di dato "stringa". Queste sono gestite dal compilatore come sequenze di caratteri, cioè di dati di tipo char. Un metodo comunemente utilizzato per dichiarare e manipolare stringhe nei programmi è offerto proprio dai puntatori, come si vede nel programma dell'esempio seguente, che visualizza "Ciao Ciao!" e porta a capo il cursore.
#include <stdio.h> char *string = "Ciao"; void main(void) { printf(string); printf(" %s!\n",string); }
La dichiarazione di string può apparire, a prima
vista, anomala. Si tratta infatti, a tutti gli effetti, della
dichiarazione di un puntatore e la stranezza consiste nel fatto
che a questo non è assegnato un indirizzo di memoria, come
ci si potrebbe aspettare, bensì una costante stringa. Ma
è proprio questo l'artificio che consente di gestire le
stringhe con normali puntatori a carattere: il compilatore, in
realtà, assegna a string, puntatore a 16 bit,
l'indirizzo della costante "Ciao". Dunque la
word occupata da string non contiene la parola "Ciao",
ma i 16 bit che esprimono la parte offset del suo indirizzo. A
sua volta, "Ciao" occupa 5 byte di memoria.
Proprio 5, non si tratta di un errore di stampa: i 4 byte necessari
a memorizzare i 4 caratteri che compongono la parola, più
un byte, nel quale il compilatore memorizza il valore binario
0, detto terminatore di stringa o null terminator.
In C, tutte le stringhe sono chiuse da un null terminator, ed
occupano perciò un byte in più del numero di caratteri
"stampabili" che le compongono.
La prima chiamata a printf() passa quale argomento proprio
string: dunque la stringa parametro indispensabile di
printf() non deve essere necessariamente una stringa
di formato quando l'unica cosa da visualizzare sia proprio una
stringa. Lo è, però, quando devono essere visualizzati
caratteri o numeri, o stringhe formattate in un modo particolare,
come avviene nella seconda chiamata.
Qui va sottolineato che per visualizzare una stringa con printf()
occore fornirne l'indirizzo, che nel nostro caso è il contenuto
del puntatore string. Se string punta alla stringa
"Ciao", che cosa restituisce l'espressione
*string? La tentazione di rispondere "Ciao"
è forte, ma se così fosse perché per visualizzare
la parola occorre passare a printf() string
e non *string? Il problema non si poneva con gli esempi
precedenti, perché tutti i puntatori esaminati indirizzavano
un unico dato di un certo tipo. Con le dichiarazioni
float numero = 12.5; float *numPtr = №
si definisce il puntatore numPtr e lo si inizializza in modo che contenga l'indirizzo della variabile numero, la quale, in fondo proprio come string, occupa più di un byte. In questo caso, però, i 4 byte di numero contengono un dato unitariamente considerato. In altre parole, nessuno dei 4 byte che la compongono ha significato in sé e per sé. Con riferimento a string, al contrario, ogni byte è un dato a sé stante, cioè un dato di tipo char: bisogna allora precisare che un puntatore indirizza sempre il primo byte di tutti quelli che compongono il tipo di dato considerato, se questi sono più d'uno. Se ne ricava che string contiene, in realtà, l'indirizzo del primo carattere di "Ciao", cioè la 'C'. Allora *string non può che restituire proprio quella, come si può facilmente verificare con la seguente chiamata a printf():
printf("%c è il primo carattere...\n",*string);
Non dimentichiamo che le stringhe sono, per il compilatore C,
semplici sequenze di char: la stringa del nostro esempio
inizia con il char che si trova all'indirizzo contenuto
in string (la 'C') e termina con il primo byte
nullo incontrato ad un indirizzo uguale o superiore a quello (in
questo caso il byte che segue immediatamente la 'o').
Per accedere ai caratteri che seguono il primo è sufficiente
incrementare il puntatore o, comunque, sommare ad esso una opportuna
quantità (che rappresenta l'offset, cioè lo spostamento,
dall'inizo della stringa stessa). Vediamo, come al solito, un
esempio:
int i = 0; while(*(string+i) != 0) { printf("%c\n",*(string+i)); ++i; }
L'esempio si basa sull'aritmetica dei puntatori, cioè sulla possibilità di accedere ai dati memorizzati ad un certo offset rispetto ad un indirizzo sommandovi algebricamente numeri interi. Il ciclo visualizza la stringa "Ciao" in senso verticale. Infatti l'istruzione while (finalmente una "vera" istruzione C!) esegue le istruzioni comprese tra le parentesi graffe finché la condizione espressa tra le parentesi tonde è vera (se questa è falsa la prima volta, il ciclo non viene mai eseguito): in questo caso la printf() è eseguita finché il byte che si trova all'indirizzo contenuto in string aumentato di i unità è diverso da 0, cioè finché non viene incontrato il null terminator. La printf() visualizza il byte a quello stesso indirizzo e va a capo. Il valore di i è inizialmente 0, pertanto nella prima iterazione l'indirizzo espresso da string non è modificato, ma ad ogni loop i è incrementato di 1 (tale è il significato dell'operatore ++, pertanto ad ogni successiva iterazione l'espressione string+i restituisce l'indirizzo del byte successivo a quello appena visualizzato. Al termine, i contiene il valore 4, che è anche la lunghezza della stringa: questa è infatti convenzionalmente pari al numero dei caratteri stampabili che compongono la stringa stessa; il null terminator non viene considerato. In altre parole la lunghezza di una stringa è inferiore di 1 al numero di byte che essa occupa effettivamente in memoria. La lunghezza di una stringa può quindi essere calcolata così:
unsigned i = 0; while(*(string+i)) ++i;
La condizione tra parentesi è implicita: non viene specificato
alcun confronto. In casi come questo il compilatore assume che
il confronto vada effettuato con il valore 0, che è
proprio quel che fa al nostro caso. Inoltre, dato che il ciclo
si compone di una sola riga (l'autoincremento di i),
le graffe non sono necessarie (ma potrebbero essere utilizzate ugualmente[16]).
Tutta questa chiacchierata dovrebbe avere reso evidente una cosa:
quando ad una funzione viene passata una costante stringa, come
in
printf("Ciao!\n");
il compilatore, astutamente, memorizza la costante da qualche
parte (non preoccupiamoci del "dove", per il momento)
e ne passa l'indirizzo.
Inoltre, il metodo visto poco fa per "prelevare" uno
ad uno i caratteri che compongono una stringa vale anche nel caso
li si voglia modificare:
char *string = "Rosso\n"; void main(void) { printf(string); *(string+3) = 'p'; printf(string); }
Il programma dell'esempio visualizza dapprima la parola "Rosso"
e poi "Rospo". Si noti che il valore di string
non è mutato: esso continua a puntare alla medesima locazione
di memoria, ma è mutato il contenuto del byte che si trova
ad un offset di 3 rispetto a quell'indirizzo. Dal momento che
l'indirezione di un puntatore a carattere restituisce un carattere,
nell'assegnazione della lettera 'p' è necessario
esprimere quest'ultima come un char, e pertanto tra apici
(e non tra virgolette). La variabile string non a caso
è dichiarata all'esterno di main().
E' possibile troncare una stringa? Sì, basta inserire un
NULL dove occorre:
*(string+2) = NULL;
A questo punto una chiamata a printf() visualizzerebbe la parola "Ro". NULL è una costante manifesta definita in STDIO.H, e rappresenta lo zero binario; infatti la riga di codice precedente potrebbe essere scritta così:
*(string+2) = 0;
E' possibile allungare una stringa? Sì, basta... essere
sicuri di avere spazio a disposizione. Se si sovrascrive il NULL
con un carattere, la stringa si allunga sino al successivo NULL.
Occorre fare alcune considerazioni: in primo luogo, tale operazione
ha senso, di solito, solo nel caso di concatenamento di stringhe
(quando cioè si desidera accodare una stringa ad un'altra
per produrne una sola, più lunga). In secondo luogo, se
i byte successivi al NULL sono occupati da altri dati,
questi vengono perduti, sovrascritti dai caratteri concatenati
alla stringa: l'effetto può essere disastroso. In effetti
esiste una funzione di libreria concepita appositamente per concatenare
le stringhe: la strcat(), che richiede due stringhe quali
parametri. L'azione da essa svolta consiste nel copiare i byte
che compongono la seconda stringa, NULL terminale compreso,
in coda alla prima stringa, sovrascrivendone il NULL
terminale.
In una dichiarazione come quella di string, il compilatore
riserva alla stringa lo spazio strettamente necessario a contenere
i caratteri che la compongono, più il NULL. E'
evidente che concatenare a string un'altra stringa sarebbe
un grave errore (peraltro non segnalato dal compilatore, perché
esso lascia il programmatore libero di gestire la memoria come
crede: se sbaglia, peggio per lui). Allora, per potere concatenare
due stringhe senza pericoli occorre riservare in anticipo lo spazio
necessario a contenere la prima stringa e la seconda... una in
fila all'altra. Affronteremo il problema parlando di array
e di allocazione dinamica della memoria.
Avvertenza: una dichiarazione del tipo:
char *sPtr;
riserva in memoria lo spazio sufficiente a memorizzare il puntatore
alla stringa, e non una (ipotetica) stringa. I byte allocati sono
2 se il puntatore è, come nell'esempio, near;
mentre sono 4 se è far o huge. In ogni
caso va ricordato che prima di copiare una stringa a quell'indirizzo
bisogna assolutamente allocare lo spazio necessario a contenerla
e assegnarne l'indirizzo a sPtr. Anche a questo proposito
occorre rimandare gli approfondimenti alle pagine in cui esamineremo
l'allocazione dinamica della memoria.
E' meglio sottolineare che le librerie standard del C comprendono
un gran numero di funzioni (dichiarate in STRING.H) per
la manipolazione delle stringhe, che effettuano le più
svariate operazioni: copiare stringhe o parte di esse (strcpy(),
strncpy()), concatenare stringhe (strcat(),
strncat()), confrontare stringhe (strcmp(),
stricmp()), ricercare sottostringhe o caratteri all'interno
di stringhe (strstr(), strchr(), strtok())...
insomma, quando si deve trafficare con le stringhe vale la pena
di consultare il manuale delle librerie e cercare tra le funzioni
il cui nome inizia con "str": forse la soluzione
al problema è già pronta.
Un array (o vettore) è una sequenza di dati dello stesso tipo, sistemati in memoria... in fila indiana. Una stringa è, per definizione, un array di char. Si possono dichiarare array di int, di double, o di qualsiasi altro tipo. Il risultato è, in pratica, riservare in memoria lo spazio necessario a contenere un certo numero di variabili di quel tipo. In effetti, si può pensare ad un array anche come ad un gruppo di variabili, aventi tutte identico nome ed accessibili, quindi, referenziandole attraverso un indice. Il numero di "variabili" componenti l'array è indicato nella dichiarazione:
int iArr[15];
La dichiarazione di un array è analoga a quella di una variabile, ad eccezione del fatto che il nome dell'array è seguito dal numero di elementi che lo compongono, racchiuso tra parentesi quadre. Quella dell'esempio forza il compilatore a riservare lo spazio necessario a memorizzare 15 interi, dunque 30 byte. Per accedere a ciascuno di essi occorre sempre fare riferimento al nome dell'array, iArr: il singolo int desiderato è individuato da un indice tra parentesi quadre, che ne indica la posizione.
iArr[0] = 12; iArr[1] = 25; for(i = 2; i < 15; i++) iArr[i] = i; for(i = 0; i < 15;) { printf("iArr[%d] = %d\n",i,iArr[i]); i++; }
Nell'esempio i primi due elementi dell'array sono inizializzati
a 12 e 25, rispettivamente. Il primo ciclo for
inizializza i successivi elementi (dal numero 2 al numero
14) al valore che i assume ad ogni iterazione.
Il secondo ciclo for
visualizza tutti gli elementi dell'array. Preme sottolineare che
gli elementi di un array sono numerati a partire da 0 (e non da
1), come ci si potrebbe attendere. Dunque, l'ultimo elemento di
un array ha indice inferiore di 1 rispetto al numero di elementi
in esso presenti. Si vede chiaramente che gli elementi di iArr,
dichiarato come array di 15 interi, sono referenziati con indice
che va da 0 a 14.
Che accade se si tenta di referenziare un elemento che non fa
parte dell'array, ad esempio iArr[15]? Il compilatore
non fa una grinza: iArr[15] può essere letto e
scritto tranquillamente... E' ovvio che nel primo caso (lettura)
il valore letto non ha alcun significato logico ai fini del programma,
mentre nel secondo caso (scrittura) si rischia di perdere (sovrascrivendolo)
qualche altro dato importante. Anche questa volta il compilatore
si limita a mettere a disposizione del programmatore gli strumenti
per gestire la memoria, senza preoccuparsi di controllarne più
di tanto l'operato. Per il compilatore, iArr[15] è
semplicemente la word che si trova a 30 byte dall'indirizzo al
quale l'array è memorizzato. Che farne, è affare del programmatore[17].
Un array, come qualsiasi altro oggetto in memoria, ha un indirizzo.
Questo è individuato e scelto dal compilatore. Il programmatore
non può modificarlo, ma può conoscerlo attraverso
il nome dell'array stesso, usandolo come un puntatore. In C, il
nome di un array equivale, a tutti gli effetti, ad un puntatore
all'area di memoria assegnata all'array. Pertanto, le righe di
codice che seguono sono tutte lecite:
int *iPtr; printf("indirizzo di iArr: %X\n",iArr); iPtr = iArr; printf("indirizzo di iArr: %X\n",iPtr); printf("primo elemento di iArr: %d\n",*iArr); printf("secondo elemento di iArr: %d\n",*(iArr+1)); ++iPtr; printf("secondo elemento di iArr: %d\n",*iPtr);
mentre non sono lecite le seguenti:
++iArr; // l'indirizzo di un array non puo' essere modificato iArr = iPtr; // idem
ed è lecita, ma inutilmente complessa, la seguente:
iPtr = &iArr;
in quanto il nome dell'array ne restituisce, di per se stesso,
l'indirizzo, rendendo inutile l'uso dell'operatore &
(address of).
Il lettore attento dovrebbe avere notato che l'indice di un elemento
di un array ne esprime l'offset, in termini di numero di elementi,
dal primo elemento dell'array stesso. In altre parole, il primo
elemento di un array ha offset 0 rispetto a se stesso;
il secondo ha offset 1 rispetto al primo; il terzo ha
offset 2, cioè dista 2 elementi dal primo...
Banale? Mica tanto. Il compilatore "ragiona" sugli arrays
in termini di elementi, e non di byte.
Ripensando alle stringhe, appare ora evidente che esse non sono
altro che array di char. Si differenziano solo per l'uso
delle virgolette; allora il problema del concatenamento di stringhe
può essere risolto con un array:
char string[100];
Nell'esempio abbiamo così a disposizione 100 byte in cui
copiare e concatenare le nostre stringhe.
Puntatori ed array hanno caratteristiche fortemente simili. Si
differenziano perché ad un array non può essere
assegnato un valore[18], e perché
un array riserva direttamente, come si è visto, lo spazio
necessario a contenere i suoi elementi. Il numero di elementi
deve essere specificato con una costante. Non è mai possibile
utilizzare una variabile. Con una variabile, utilizzata come indice,
si può solo accedere agli elementi dell'array dopo che
questo è stato dichiarato.
Gli array, se dichiarati al di fuori di qualsiasi funzione[19],
possono essere inizializzati:
int iArr[] = {12,25,66,0,144,-2,26733}; char string[100] = {'C','i','a','o'}; float fArr[] = {1.44,,0.3};
Per inizializzare un array contestualmente alla dichiarazione
bisogna specificare i suoi elementi, separati da virgole e compresi
tra parentesi graffe aperta e chiusa. Se non si indica tra le
parentesi quadre il numero di elementi, il compilatore lo desume
dal numero di elementi inizializzati tra le parentesi graffe.
Se il numero di elementi è specificato, e ne viene inizializzato
un numero inferiore, tutti quelli "mancanti" verranno
inizializzati a 0 dal compilatore. Analoga regola vale
per gli elementi "saltati" nella lista di inizializzazione:
l'array fArr contiene 3 elementi, aventi valore 1.44,
0.0 e 0.3 rispettivamente.
Su string si può effettuare una concatenazione
come la seguente senza rischi:
strcat(string," Pippo");
La stringa risultante, infatti, è "Ciao Pippo",
che occupa 11 byte compreso il NULL terminale: sappiamo
però di averne a disposizione 100.
Sin qui si è parlato di array monodimensionali, cioè
di array ogni elemento dei quali è referenziabile mediante
un solo indice. In realtà, il C consente di gestire array
multidimensionali, nei quali per accedere ad un elemento occorre
specificarne più "coordinate". Ad esempio:
int iTab[3][6];
dichiara un array a 2 dimensioni, rispettivamente di 3 e 6 elementi. Per accedere ad un singolo elemento bisogna, allo stesso modo, utilizzare due indici:
int i, j, iTab[3][6]; for(i = 0; i < 3; ++i) for(j = 0; j < 6; ++j) iTab[i][j] = 0;
Il frammento di codice riportato dichiara l'array bidimensionale
iTab e ne inizializza a 0 tutti gli elementi.
I due cicli for sono nidificati, il che significa che
le iterazioni previste dal secondo vengono compiute tutte una
volta per ogni iterazione prevista dal primo. In tal modo vengono
"percorsi" tutti gli elementi di iTab. Infatti
il modo in cui il compilatore C alloca lo spazio di memoria per
gli array multidimensionali garantisce che per accedere a tutti
gli elementi nella stessa sequenza in cui essi si trovano in memoria,
è l'indice più a destra quello che deve variare
più frequentemente.
E' evidente, d'altra parte, che la memoria è una sequenza
di byte: ciò implica che pur essendo iTab uno
strumento che consente di rappresentare molto bene una tabella
di 3 righe e 6 colonne, tutti i suoi elementi stanno comunque
"in fila indiana". Pertanto, l'inizializzazione di un
array multidimensionale contestuale alla sua dichiarazione può
essere effettuata come segue:
int *tabella[2][5] = {{3, 2, 0, 2, 1},{3, 0, 0, 1, 0}};
Gli elementi sono elencati proprio nell'ordine in cui si trovano in memoria; dal punto di vista logico, però, ogni gruppo di elementi nelle coppie di graffe più interne rappresenta una riga. Dal momento che, come già sappiamo, il C è molto elastico nelle regole che disciplinano la stesura delle righe di codice, la dichiarazione appena vista può essere spezzata su due righe, al fine di rendere ancora più evidente il parallelismo concettuale tra un array bidimensionale ed una tabella a doppia entrata:
int *tabella[2][5] = {{3, 2, 0, 2, 1}, {3, 0, 0, 1, 0}};
Si noti che tra le parentesi quadre, inizializzando l'array contestualmente
alla dichiarazione, non è necessario specificare entrambe
le dimensioni, perché il compilatore può desumere
quella mancante dal computo degli elementi inizialiazzati: nella
dichiarazione dell'esempio sarebbe stato lecito scrivere tabella[][5]
o tabella[2][].
Dalle affermazioni fatte discende infoltre che gli elementi di
un array bidimensionale possono essere referenziati anche facendo
uso di un solo indice:
int *iPtr; iPtr = tabella; for(i = 0; i < 2*5; i++) printf("%d\n",iPtr[i];
In genere i compilatori C sono in grado di gestire array multidimensionali senza un limite teorico (a parte la disponibilità di memoria) al numero di dimensioni. E' tuttavia infrequente, per gli utilizzi più comuni, andare oltre la terza dimensione.
Quanti byte di memoria occupa un array? La risposta dipende, ovviamente,
dal numero degli elementi e dal tipo di dato dichiarato. Un array
di 20 interi occupa 40 byte, dal momento che ogni int
ne occupa 2. Un array di 20 long ne occupa, dunque, 80.
Calcoli analoghi occorrono per accedere ad uno qualsiasi degli
elementi di un array: il terzo elemento di un array di long
ha indice 2 e dista 8 byte (2*4) dall'inizio dell'area di RAM
riservata all'array stesso. Il quarto elemento di un array di
int dista 3*2 = 6 byte dall'inizio dell'array. Generalizzando,
possiamo affermare che un generico elemento di un array di un
qualsiasi tipo dista dall'indirizzo base dell'array stesso un
numero di byte pari al prodotto tra il proprio indice e la dimensione
del tipo di dato.
Fortunatamente il compilatore C consente di accedere agli elementi
di un array in funzione di un unico parametro: il loro indice[20].
Per questo sono lecite e significative istruzioni come quelle
già viste:
iArr[1] = 12; printf("%X\n",iArr[j]);
E' il compilatore ad occuparsi di effettuare i calcoli sopra descritti
per ricavare il giusto offset in termini di byte di ogni elemento,
e lo fa in modo trasparente al programmatore per qualsiasi tipo
di dato.
Ciò vale anche per le stringhe (o array di caratteri).
Il fatto che ogni char occupi un byte semplifica i calcoli
ma non modifica i termini del problema[21].
E' importante sottolineare che quanto affermato vale non solo
nei confronti degli array, bensì di qualsiasi puntatore,
come può chiarire l'esempio che segue.
#include <stdio.h> int iArr[]= {12,99,27,0}; void main(void) { int *iPtr; iPtr = iArr; // punta al primo elemento di iArr[] while(*iPtr) { // finche' l'int puntato da iPtr non e' 0 printf("%X -> %d\n",iPtr,*iPtr); // stampa iPtr e l'intero puntato ++iPtr; // incrementa iPtr } }
Il trucco sta tutto nell'espressione ++iPtr: l'incremento
del puntatore è automaticamente effettuato dal compilatore
sommando 2 al valore contenuto in iPtr, proprio perché
esso è un puntatore ad int, e l'int occupa
2 byte. In altre parole, iPtr è incrementato,
ad ogni iterazione, in modo da puntare all'intero successivo.
Si noti che l'aritmetica dei puntatori è applicata dal
compilatore ogni volta che una grandezza intera è sommata
a (o sottratta da) un puntatore, moltiplicando tale grandezza
per il numero di byte occupati dal tipo di dato puntato.
Questo modo di gestire i puntatori ha due pregi: da un lato evita
al programmatore lo sforzo di pensare ai dati in memoria in termini
di numero di byte; dall'altro consente la portabilità dei
programmi che fanno uso di puntatori anche su macchine che codificano
gli stessi tipi di dato con un diverso numero di bit.
Un'ultima precisazione: ai putatori possono essere sommate o sottratte
solo grandezze intere (int o long, a seconda
che si tratti di puntatori near o no).
Un puntatore è una variabile che contiene un indirizzo. Perciò è lecito (e, tutto sommato, abbastanza normale) fare uso di puntatori che puntano ad altri puntatori. La dichiarazione di un puntatore a puntatore si effettua così:
char **pPtr;
In pratica occorre aggiungere un asterisco, in quanto siamo ad
un secondo livello di indirezione: pPtr non punta direttamente
ad un char; la sua indirezione *pPtr restituisce
un altro puntatore a char, la cui indirezione, finalmente,
restituisce il char agognato. Presentando i puntatori
è stato analizzato il significato
di alcune espressioni; in particolare si è detto che in
**numPtr, ove numPtr è un puntatore a
float, il primo "*" è ignorato:
l'affermazione è corretta, perché pur essendo numPtr
e pPtr entrambi puntatori, il secondo punta ad un altro
puntatore, al quale può essere validamente applicato il
primo dereference operator ("*").
L'ambito di utilizzo più frequente dei puntatori a puntatori
è forse quello degli array di stringhe: dal momento che
in C una stringa è di per sé un array (di char),
gli array di stringhe sono gestiti come array di puntatori a char.
A questo punto è chiaro che il nome dell'array (in C il
nome di un array è anche puntatore all'array stesso) è
un puntatore a puntatori a char[22].
Pertanto, ad esempio,
printf(pPtr[2]);
visualizza la stringa puntata dal terzo elemento di pPtr (con una semplificazione "umana" ma un po' pericolosa potremmo dire che viene visualizzata la terza stringa dell'array).
Un puntatore può essere dichiarato di tipo void. Si tratta di una pratica poco diffusa, avente lo scopo di lasciare indeterminato il tipo di dato che il puntatore indirizza, sino al momento dell'inizializzazione del puntatore stesso. La forma della dichiarazione è intuibile:
void *ptr, far *fvptr;
Ad un puntatore void può essere assegnato l'indirizzo di qualsiasi tipo di dato.
In C le variabili possono essere classificate, oltre che secondo
il tipo di dato, in base alla loro accessibilità e alla
loro durata. In particolare, a seconda del contesto in cui sono
dichiarate, le variabili di un programma C assumono per default
determinate caratteristiche di accessibilità e durata;
in molti casi, però, queste possono essere modificate mediante
l'utilizzo di apposite parole chiave applicabili alla dichiarazione
delle variabili stesse.
Per comprendere i concetti di accessibilità (o visibilità)
e durata, va ricordato che una variabile altro non è che
un'area di memoria, grande quanto basta per contenere un dato
del tipo indicato nella dichiarazione, alla quale il compilatore
associa, per comodità del programmatore, il nome simbolico
da questi scelto.
In termini generali, possiamo dire che la durata di una variabile
si estende dal momento in cui le viene effettivamente assegnata
un'area di memoria fino a quello in cui quell'area è riutilizzata
per altri scopi.
Dal punto di vista dell'accessibilità ha invece rilevanza
se sia o no possibile leggere o modificare, da parti del programma
diverse da quella in cui la variabile è stata dichiarata,
il contenuto dell'area di RAM riservata alla variabile stessa
Cerchiamo di mettere un po' d'ordine...
Qualsiasi variabile dichiarata all'interno di un blocco di codice
racchiuso tra parentesi graffe (generalmente all'inizio di una
funzione) appartiene per default alla classe automatic.
Non è dunque necessario, anche se è possibile farlo,
utilizzare la parola chiave auto. La durata e la visibilità
della variabile sono entrambe limitate al blocco di codice in
cui essa è dichiarata. Se una variabile è dichiarata
in testa ad una funzione, essa esiste (cioè occupa memoria)
dal momento in cui la funzione inizia ad essere eseguita, sino
al momento in cui la sua esecuzione termina.
Le variabili automatic, dunque, non occupano spazio di memoria
se non quando effettivamente servono; inoltre, essendo accessibili
esclusivamente dall'interno di quella funzione, non vi è
il rischio che possano essere modificate accidentalmente da operazioni
svolte in funzioni diverse su variabili aventi medesimo nome:
in un programma C, infatti, più variabili automatic possono
avere lo stesso nome, purché dichiarate in blocchi di codice
diversi. Se i blocchi sono nidificati (cioè uno è
interamente all'interno di un altro) ciò è ancora
vero, ma la variabile dichiarata nel blocco interno "nasconde"
quella dichiarata con identico nome nel blocco esterno (quando,
ovviamente, viene eseguito il blocco interno).
Vediamo un esempio:
#include <stdio.h> void main(void) { int x = 1; int y = 10; { int x = 2; printf("%d, %d\n",x,y); } printf("%d, %d\n",x,y); }
La variabile x dichiarata in testa alla funzione main() è inizializzata a 1, mentre la x dichiarata nel blocco interno è inizializzata a 2. L'output del programma é:
2, 10 1, 10
Ciò prova che la "prima" x esiste in
tutta la funzione main(), mentre la "seconda"
esiste ed è visibile solo nel blocco più interno;
inoltre, dal momento che le due variabili hanno lo stesso nome,
nel blocco interno la prima x è resa non visibile
dalla seconda. La y, invece, è visibile anche
nel blocco interno.
Se si modifica il programma dell'esempio come segue:
#include <stdio.h> void main(void) { int x = 1; int y = 10; { int x = 2; int z = 20; printf("%d, %d\n",x,y); } printf("%d, %d\n",x,y,z); }
il compilatore non porta a termine la compilazione e segnala l'errore
con un messaggio analogo a "undefined symbol z in function
main()" a significare che la seconda printf()
non può referenziare la variabile z, poiche questa
cessa di esistere al termine del blocco interno di codice.
La gestione delle variabili automatic è dinamica. La memoria
necessaria è allocata alla variabile esclusivamente quando
viene eseguito il blocco di codice (tipicamente una funzione)
in cui essa è dichiarata, e le viene "sottratta"
non appena il blocco termina. Ciò implica che non è
possibile conoscere il contenuto di una variabile automatic prima
che le venga esplicitamente assegnato un valore da una istruzione facente parte del blocco:
a beneficio dei distratti, vale la pena di evidenziare che una
variabile automatica può essere utilizzata in lettura prima
di essere inizializzata[23],
ma il valore in essa contenuto è casuale e, pertanto, inutilizzabile
nella quasi totalità dei casi.
E' opportuno sottolineare che mentre le variabili dichiarate nel
blocco più esterno di una funzione (cioè in testa
alla stessa) esistono e sono visibili (salvo il caso di variabili
con lo stesso nome) in tutti i blocchi interni di quella funzione,
nel caso di funzioni diverse nessuna di esse può accedere
alle variabili automatiche delle altre.
Dal momento che il compilatore colloca le variabili automatic
nella RAM del calcolatore, i valori in esse contenuti devono spesso
essere copiati nei registri della CPU per poter essere elaborati
e, se modificati dall'elaborazione subita, copiati nuovamente
nelle locazioni di memoria di provenienza. Tali operazioni sono
svolte in modo trasparente per il programmatore, ma possono deteriorare
notevolmente la performance di un programma, soprattutto se ripetute
più e più volte (ad esempio all'interno di un ciclo
con molte iterazioni).
Dichiarando una variabile automatic con la parola chiave register
si forza il compilatore ad allocarla direttamente in un registro
della CPU, con notevole incremento di efficienza nell'elaborazione
del valore in essa contenuto. Ecco un esempio:
register int i = 10; do { printf("%2d\n",i); } while(i--);
Il ciclo visualizza, incolonnati[24],
i numeri da 10 a 0; la variabile i si
comporta come una qualsiasi variabile automatic, ma essendo probabilmente
gestita in un registro consente un'elaborazione più veloce.
E' d'obbligo scrivere "probabilmente gestita" in quanto
non si può essere assolutamente certi che il compilatore
collochi una variabile dichiarata con register proprio
in un registro della CPU: in alcune situazioni potrebbe gestirla
come una variabile automatic qualsiasi, allocandola in memoria.
I principali motivi sono due: la variabile potrebbe occupare più
byte di quanti compongono un registro della CPU[25],
o potrebbero non esserci registri disponibili allo scopo[26].
Già che ci siamo, diamo un'occhiata più approfondita
all'esempio di poco fa. Innanzitutto va rilevato che nella dichiarazione
di i potrebbe essere omessa la parola
chiave int:
register i = 10;
Abbiamo poi utilizzato un costrutto nuovo: il ciclo do...while.
Esso consente di identificare un blocco di codice (quello compreso
tra le graffe) che viene eseguito finché la condizione
specificata tra parentesi dopo la parola chiave while
continua ad essere vera. Il ciclo viene sempre eseguito almeno
una volta, perché il test è effettuato al termine
del medesimo. Nel nostro caso, quale test viene effettuato? Dal
momento che non è utilizzato alcun operatore di confronto
esplicito, viene controllato se il risultato dell'espressione
nelle tonde è diverso da 0. L'operatore --,
detto di autodecremento, è
specificato dopo la variabile a cui è applicato. Ciò
assicura che i sia decrementata dopo l'effettuazione
del test. Perciò il ciclo è eseguito 11 volte, con
i che varia da 10 a 0 inclusi. Se l'espressione
fosse --i, il decremento sarebbe eseguito prima del test,
con la conseguenza che per i pari a 0 il ciclo
non verrebbe più eseguito.
Come per le variabili automatic, non è possibile conoscere
il contenuto di una variabile register prima della sua
esplicita inizializzazione mediante un operazione di assegnamento.
In questo caso non si tratta di utilizzo e riutilizzo di un'area
di memoria, ma di un registro macchina: non possiamo conoscerne
a priori il contenuto nel momento in cui esso è destinato
alla gestione della variabile (dichiarazione della variabile).
Inoltre, analogamente alle variabili automatic, anche quelle register
cessano di esistere all'uscita del blocco di codice (solitamente
una funzione) nel quale sono dichiarate e il registro macchina
viene utilizzato per altri scopi.
Le variabili register, a differenza delle automatic,
non hanno indirizzo: ciò appare ovvio se si pensa che i
registri macchina si trovano nella CPU e non nella RAM. La conseguenza
immediata è che una variabile register non può
mai essere referenziata tramite un puntatore. Nel nostro esempio,
il tentativo di assegnare ad un puntatore l'indirizzo di i
provocherebbe accorate proteste da parte del compilatore.
register i; int *iPtr = &i; // errore! i non ha indirizzo
Pur non avendo indirizzo, le variabili register possono contenere un indirizzo, cioè un puntatore: la dichiarazione
register char *ptr_1, char *ptr_2;
non solo è perfettamente lecita, ma anzi genera, se possibile, due puntatori (a carattere) particolarmente efficienti.
Una variabile è static se dichiarata utilizzando, appunto, la parola chiave static:
static float sF, *sFptr;
Nell'esempio sono dichiarate due variabili static: una
di tipo float e un puntatore (static anch'esso)
ad un float.
Come nel caso delle variabili automatic, quelle static
sono locali al blocco di codice in cui sono dichiarate (e dunque
sono accessibili solo all'interno di esso). La differenza consiste
nel fatto che le variabili static hanno durata estesa
a tutto il tempo di esecuzione del programma. Esse, pertanto,
esistono già prima che il blocco in cui sono dichiarate
sia eseguito e continuano ad esistere anche dopo il termine dell'esecuzione
del medesimo.
Ne segue che i valori in esse contenuti sono persistenti; quindi
se il blocco di codice viene nuovamente eseguito esse si presentano
con il valore posseduto al termine dell'esecuzione precedente.
In altre parole, il compilatore alloca in modo permanente alle
variabili static la memoria loro necessaria.
Il tutto può essere chiarito con un paio di esempi:
#include <stdio.h> void incrementa(void) { int x = 0; ++x; printf("%d\n",x); } void main(void) { incrementa(); incrementa(); incrementa(); }
Il programma chiama la funzione incrementa() 3 volte; ad ogni chiamata la variabile x, automatic, è dichiarata ed inizializzata a 0. Essa è poi incrementata e visualizzata. L'output del programma è il seguente:
1 1 1
Infatti x, essendo una variabile automatic, "sparisce"
al termine dell'esecuzione della funzione in cui è dichiarata.
Ad ogni chiamata essa è nuovamente allocata, inizializzata
a 0, incrementata, visualizzata e... buttata alle ortiche.
Indipendentemente dal numero di chiamate, incrementa()
visualizza sempre il valore 1.
Riprendiamo ora la funzione incrementa(), modificando
però la dichiarazione di x:
void incrementa(void) { static int x = 0; ++x; printf("%d\n",x); }
Questa volta x è dichiarata static. Vediamo l'output del programma:
1 2 3
La x è inizializzata a 0 solo una volta,
al momento della compilazione. Durante la prima chiamata
ad incrementa(), essa assume pertanto valore 1.
Poiché x è static, il suo valore
è persistente e non viene perso in uscita dalla funzione.
Ne deriva che alla seconda chiamata di incrementa() essa
assume valore 2 e, infine, 3 alla terza chiamata.
Quando si specifica un valore iniziale per una variabile automatic,
detto valore è assegnato alla variabile ogni volta che
viene eseguito il blocco in cui la variabile stessa è dichiarata.
Una inizializzazione come:
{ int x = 1; ....
non è che una forma abbreviata della seguente:
{ int x; x = 1; ....
Quanto detto non è vero per le variabili static. Il valore iniziale di 1 nella seguente riga di codice:
static int x = 1;
viene assegnato alla variabile x una sola volta, in fase
di compilazione: il compilatore riserva spazio per la variabile
e vi memorizza il valore iniziale. Quando il programma è
eseguito, il valore iniziale della variabile è già
presente in essa.
Se il programmatore non inizializza esplicitamente una variabile
static, il compilatore le assegna automaticamente il
valore NULL, cioè lo zero.
Va poi sottolineato che l'accessibilità di una variabile
static è comunque limitata (come per le varibili
automatic) al blocco di codice in cui è dichiarata. Nel
programma riportato per esempio, la variabile x non è
accessibile né in main() né in qualunque
altra funzione eventualmente definita, ma solamente all'interno
di incrementa().
Infine, è opportuno ricordare che un array dichiarato in
una funzione deve necessariamente essere dichiarato static
se inizializzato contestualmente alla dichiarazione.
Sono variabili external tutte quelle dichiarate al di fuori delle funzioni. Esse hanno durata estesa a tutto il tempo di esecuzione del programma, ed in ciò appaiono analoghe alle variabili static, ma differiscono da queste ultime in quanto la loro accessibilità è globale a tutto il codice del programma. In altre parole, è possibile leggere o modificare il contenuto di una variabile external in qualsiasi funzione. Vediamo, come sempre, un esempio:
#include <stdio.h> int x = 123; void incrementa(void) { ++x; printf("%d\n",x); } void main(void) { printf("%d\n",x); incrementa(); printf("%d\n",x); }
L'output del programma è il seguente:
123 124 124
Infatti la variabile x, essendo definita al di fuori
di qualunque funzione, è accessibile sia in main()
che in incrementa() e il suo valore è conservato
per tutta la durata dell'esecuzione.
Se una variabile external (o globale) ha nome identico a quello
di una variabile automatic (o locale), quest'ultima "nasconde"
la prima. Il codice che segue:
#include <stdio.h> int x = 123; void main(void) { printf("%d\n",x); { int x = 321; printf("%d\n",x); } printf("%d\n",x); }
produce il seguente output:
123 321 123
Infatti la x locale dichiarata nel blocco di codice interno
a main() nasconde la x globale, dichiarata fuori
dalla funzione; tuttavia la variabile locale cessa di esistere
alla fine del blocco, pertanto quella globale è nuovamente
accessibile.
Anche le variabili external, come quelle static, sono
inizializzate dal compilatore al momento della compilazione, ed
è loro attribuito valore 0 se il programmatore
non indica un valore iniziale contestualmente alla dichiarazione.
Come abbiamo visto, le variabili external devono essere dichiarate
al di fuori delle funzioni, senza necessità di specificare
alcuna particolare parola chiave. Tuttavia, esse possono (ma non
è obbligatorio) essere dichiarate anche all'interno delle
funzioni che le referenziano, questa volta necessariamente precedute
dalla parola chiave extern:
#include <stdio.h> int x = 123; void main(void) { extern int x; // riga facoltativa; se c'e' non puo' reinizializzare x printf("%d\n",x); }
In effetti il compilatore non richiede che le variabili external vengano dichiarate all'interno delle funzioni, ma in questo caso è necessario che tali variabili siano state dichiarate al di fuori della funzione e in linee di codice precedenti quelle della funzione stessa, come negli esempi precedenti. Se tali condizioni non sono rispettate il compilatore segnala un errore di simbolo non definito:
#include <stdio.h> int x = 123; void main(void) { printf("%d\n",x,y); // errore! y non e' stata ancora dichiarata } int y = 321;
Il codice dell'esempio è compilato correttamente se si dichiara extern la y in main():
#include <stdio.h> int x = 123; void main(void) { extern int x; // facoltativa extern int y; // obbligatoria! printf("%d\n",x,y); } int y = 321;
Il problema può essere evitato dichiarando tutte le variabili
globali in testa al sorgente, ma se una variabile external e una
funzione che la referenzia sono definite in due file sorgenti diversi[27],
è necessario comunque dichiarare la variabile nella funzione.
E' opportuno limitare al massimo l'uso delle funzioni external:
il loro utilizzo indiscriminato, infatti, può generare
risultati catastrofici. In un programma qualsiasi è infatti
piuttosto facile perdere traccia del significato delle variabili,
soprattutto quando esse siano numerose. Inoltre le variabili globali
sono generate al momento della compilazione ed esistono durante
tutta l'esecuzione, incrementando così lo spazio occupato
dal file eseguibile e la quantità memoria utilizzata dallo
stesso. Infine, con esse non è possibile utilizzare nomi
localmente significativi (cioè significativi per la funzione
nella quale vengono di volta in volta utilizzate) e si perde la
possibilità di mantenere ogni funzione una entità
a se stante, indipendente da tutte le altre.
Va infine osservato che una variabile external può essere
anche static:
#include <stdio.h> static int x = 123; void main(void) { printf("%d\n",x); }
Dichiarando static una variabile globale se ne limita la visibilità al solo file in cui essa è dichiarata: nel caso di un codice suddiviso in più sorgenti, le funzioni definite in altri file non saranno in grado di accedere alla x neppure qualora essa venga dichiarata con extern al loro interno. Naturalmente è ancora possibile dichiarare extern la variabile nelle funzioni definite nel medesimo file:
#include <stdio.h> static int x = 123; void main(void) { extern int x; printf("%d\n",x); }
Come facilmente desumibile dall'esempio, la parola chiave static non deve essere ripetuta.
Le costanti, in senso lato, sono dati che il programma non può
modificare. Una costante è, ad esempio, la sequenza di
caratteri "Ciao Ciao!\n" vista in precedenza:
per la precisione, si tratta di una costante stringa. Essa non
può essere modificata perché non le è associato
alcun nome simbolico a cui fare riferimento in un'operazione di
assegnazione. Una costante è un valore esplicito, che può
essere assegnato ad una variabile, ma al quale non può
essere mai assegnato un valore diverso da quello iniziale.
Ad esempio, una costante di tipo character (carattere) è
un singolo carattere racchiuso tra apici.
char c1, c2 = 'A'; c1 = 'b'; c2 = c1; 'c' = c2; //ERRORE! impossibile assegnare un valore a una costante
Una costante intera con segno è un numero intero:
int unIntero = 245, interoNegativo = -44;
Una costante intera senza segno è un numero intero seguito dalla lettera U, maisucola o minuscola, come ci insegna il nostro CIAO2.C:
unsigned int anni = 31U;
Per esprimere una costante di tipo long occorre posporle la lettera L, maiuscola o minuscola[28].
long abitanti = 987553L;
Omettere la L non è un reato grave... il compilatore
segnala con un warning che la costante è long
e procede tranquillamente. In effetti, questo è l'atteggiamento
tipico del compilatore C: quando qualcosa non è chiaro
tenta di risolvere da sé l'ambiguità, e si limita
a segnalare al programmatore di avere incontrato qualcosa di...
poco convincente. Il compilatore C "presume" che il
programmatore sappia quel che sta facendo e non si immischia nelle
ambiguità logiche più di quanto sia strettamente
indispensabile.
Una U (o u) individua una costante unsigned;
le costanti unsigned long sono identificate, ovviamente,
da entrambe le lettere U e L, maiuscole o minuscole,
in qualsivoglia ordine. Le costanti appartenenti ai tipi integral
possono essere espresse sia in notazione decimale (come in tutti
gli esempi visti finora), sia in notazione esadecimale (anteponendo
i caratteri 0x o 0X al valore) sia in notazione
ottale (anteponendo uno 0 al valore).
char beep = 07; // ottale; 7 unsigned long uLong = 12UL; // decimale; 12 unsigned long unsigned maxUInt = 0xFFFFU; // esadecimale; 65535 unsigned
Una costante di tipo floating point in doppia precisione (double) può essere espressa sia in notazione decimale che in notazione esponenziale: in questo caso si scrive la mantissa seguita dalla lettera E maiuscola o minuscola, a sua volta seguita dall'esponente. Per indicare che la costante è in singola precisione (float), occorre posporle la lettera F, maiuscola o minuscola. Per specificare una costante long double occorre la lettera L.
float varF = 1.0F; double varD = 1.0; double varD_2 = 1.; // lo 0 dopo il punto decimale puo' essere omesso long double varLD = 1.0L; // non e' un long int! C'e' il punto decimale! double varD_3 = 2.34E-2; // 0.0234
Dagli esempi si deduce immediatamente che la virgola è
espressa, secondo la convenzione anglosassone, con il punto (".").
Il C non riconosce le stringhe come tipo di dato, ma ammette l'utilizzo
di costanti stringa (seppure con qualche limite, di cui si dirà):
esse sono sequenze di caratteri racchiuse tra virgolette, come
si è visto in più occasioni. Quanti byte occupa
una stringa? Il numero dei caratteri che
la compongono... più uno. In effetti le stringhe sono sempre
chiuse da un byte avente valore zero binario[29],
detto terminatore di stringa. Il NULL finale è
generato automaticamente dal compilatore, non deve essere specificato
esplicitamente.
Attenzione: le sequenze di caratteri particolari, come "\n",
sono considerate un solo carattere (ed occupano un solo byte).
I caratteri che non rientrano tra quelli presenti sulla tastiera
possono essere rappresentati con una backslash (barra inversa)
seguita da una "x" e dal codice ASCII esadecimale
a due cifre del carattere stesso. Ad esempio, la stringa "\x07\x0D\x0A"
contiene un "beep" (il carattere ASCII 7) e un ritorno
a capo (i caratteri ASCII 13 e 10, questi ultimi equivalenti alla
sequenza ANSI "\n"[30].
I codici ASCII possono essere utilizzati anche per esprimere un
singolo carattere:
char beep = '\x07';
E' del tutto equivalente assegnare ad una variabile char un valore
decimale, ottale o esadecimale o, ancora, il valore espresso con
\x tra apici. Attenzione, però: la rappresentazione
ASCII di un carattere è cosa ben diversa dal suo valore
ASCII; 7, 07, 0x07 e '\x07'
sono tra loro equivalenti, ma diversi da '7'.
La differenza tra un singolo carattere rispetto ad una stringa
di un solo carattere sta negli apici, che sostistuiscono le virgolette.
Inoltre, '\x07' occupa un solo byte, mentre "\x07"
ne occupa due, uno per il carattere ASCII 7 e uno per il NULL
che chiude ogni stringa.
Non esistono costanti di tipo void.
Supponiamo di scrivere un programma per la gestione dei conti correnti bancari. E' noto (e se non lo era ve lo dico io) che nei calcoli finanziari la durata dell'anno è assunta pari a 360 giorni. Nel sorgente del programma si potrebbero perciò incontrare calcoli come il seguente:
interesse = importo * giorniDeposito * tassoUnitario / 360;
il quale impiega, quale divisore, la costante intera 360.
E' verosimile che nel programma la costante 360 compaia
più volte, in diversi contesti (principalmente in formule
di calcolo finanziario). Se in futuro fosse necessario modificare
il valore della costante (una nuova normativa legale potrebbe
imporre di assumere la durata dell'anno finanziario pari a 365
giorni) dovremmo ricercare tutte le occorrenze della costante
360 ed effettuare la sostituzione con 365. In
un sorgente di poche righe tutto ciò non rappresenterebbe
certo un guaio, ma immaginando un codice di diverse migliaia di
righe suddivise in un certo numero di file sorgenti, con qualche
centinaio di occorrenze della costante, è facile prevedere
quanto gravoso potrebbe rivelarsi il compito, e quanto grande
sarebbe la possibilità di non riuscire a portarlo a termine
senza errori.
Il preprocessore C consente di aggirare l'ostacolo mediante la
direttiva #define, che associa tra loro due sequenze
di caratteri in modo tale che, prima della compilazione ed in
modo del tutto automatico, ad ogni occorrenza della prima (detta
manifest constant) è sostituita la seconda.
Il nome della costante manifesta ha inizio col primo carattere non blank[31]
che segue la direttiva #define e termina con il carattere
che precede il primo successivo nonspazio; tutto quanto
segue quest'ultimo è considerato stringa di sostituzione.
Complicato? Solo in apparenza...
#define GG_ANNO_FIN 360 //durata in giorni dell'anno finanziario .... interesse = importo * giorniDeposito * tassoUnitario / GG_ANNO_FIN;
L'esempio appena visto risolve il nostro problema: modificando
la direttiva #define in modo che al posto del 360
compaia il 365 e ricompilando il programma, la sostituzione
viene effettuata automaticamente in tutte le righe in cui compare
GG_ANNO_FIN.
Va sottolineato che la direttiva #define non crea una
variabile, né è associata ad un tipo di dato particolare:
essa informa semplicemente il preprocessore che la costante manifesta,
ogniqualvolta compaia nel sorgente in fase di compilazione, deve
essere rimpiazzata con la stringa di sostituzione. Gli esempi
che seguono forniscono ulteriori chiarimenti: in essi sono definite
costanti manifeste che rappresentano, rispettivamente, una costante
stringa, una costante in virgola mobile, un carattere esadecimale
e una costante long integer, ancora esadecimale.
#define NOME_PROG "Conto 1.0" //nome del programma #define PI_GRECO 3.14 //pi greco arrotondato #define RETURN 0x0D //ritorno a capo #define VIDEO_ADDRESS 0xB8000000L //indirizzo del buffer video
Le costanti manifeste possono essere definite utilizzando altre costanti manifeste, purché definite in precedenza:
#define N_PAG_VIDEO 8 //numero di pagine video disponibili #define DIM_PAG_VIDEO 4000 //4000 bytes in ogni pagina video #define VIDEO_MEMORY (N_PAG_VIDEO * DIM_PAG_VIDEO) //spazio memoria video
Una direttiva #define può essere suddivisa in più righe fisiche mediante l'uso della backslash:
#define VIDEO_MEMORY \ (N_PAG_VIDEO * DIM_PAG_VIDEO)
L'uso delle maiuscole nelle costanti manifeste non è obbligatorio;
esso tuttavia è assai diffuso in quanto consente di individuarle
più facilmente nella lettura dei sorgenti.
Come tutte le direttive al preprocessore, anche la #define
non si chiude mai con il punto e virgola (un eventuale punto e
virgola verrebbe inesorabilmente considerato parte della stringa
di sostituzione); inoltre il crosshatch ("#",
cancelletto) deve trovarsi in prima colonna.
La direttiva #define, implementando una vera e propria
tecnica di sostituzione degli argomenti, consente di definire,
quali costanti manifeste, vere e proprie formule, dette macro,
indipendenti dai tipi di dato coinvolti:
#define min(a,b) ((a < b) ? a : b) // macro per il calcolo del minimo tra due
Come si vede, nella macro min(a,b) non è data
alcuna indicazione circa il tipo di a e b: essa
utilizza l'operatore ? :, che può essere
applicato ad ogni tipo di dato[32].
Il programmatore è perciò libero di utilizzarla
in qualunque contesto.
Le macro costituiscono dunque uno strumento molto potente, ma
anche pericoloso: in primo luogo, la mancanza di controlli (da
parte del compilatore) sui tipi di dato può impedire che
siano segnalate incongruenze logiche di un certo rilievo (sommare
le pere alle mele, come si dice...). In secondo luogo, le macro
prestano il fianco ai cosiddetti sideeffect, o effetti
collaterali. Il C implementa un particolare operatore, detto di
autoincremento[33], che accresce di
una unità il valore della variabile a cui è anteposto:
se applicato a uno dei parametri coinvolti nella macro, esso viene
applicato più volte al parametro, producendo risultati
indesiderati:
int var1, var2; int minimo; .... minimo = min(++var1, var2);
La macrosotituzione effettuata dal preprocessore trasforma l'ultima riga dell'esempio nella seguente:
minimo = ((++var1 < var2) ? ++var1 : var2);
E' facile vedere che esso si limita a sostituire alla macro min
la definizione data con la #define, sostituendo altresì
i parametri a e b con i simboli utilizzati al
loro posto nella riga di codice, cioè ++var1 e
var2. In tal modo var1 è incrementata
due volte se dopo il primo incremento essa risulta ancora minore
di var2, una sola volta nel caso opposto. Se min()
fosse una funzione il problema non potrebbe verificarsi (una chiamata
a funzione non è una semplice sostituzione di stringhe,
ma un'operazione tradotta in linguaggio macchina dal compilatore
seguendo precise regole); tuttavia una funzione non accetterebbe
indifferentemente argomenti di vario tipo, e occorrerebbe definire
funzioni diverse per effettuare confronti, di volta in volta,
tra integer, tra floating point, e così via. Un altro esempio
di effetto collaterale riguarda più da vicino il comportamento del compilatore.
Diamo un'occhiata all'esempio che segue:
#define PROG_NAME "PROVA" .... printf(PROG_NAME); .... #undef PROG_NAME .... printf(PROG_NAME); // Errore! PROG_NAME non esiste piu'...
Quando una definizione generata con una #define non serve
più, la si può annullare con la direttiva #undefine.
Ogni riferimento alla definizione annullata, successivamente inserito
nel programma, dà luogo ad una segnalazione di errore da
parte del compilatore.
Da notare che PROG_NAME è passata a printf()
senza porla tra virgolette, in quanto esse sono già parte
della stringa di sostituzione, come si può vedere nell'esempio.
Se si fossero utilizzate le virgolette, printf() avrebbe
scritto PROG_NAME e non PROVA: il preprocessore,
infatti, ignora tutto quanto è racchiuso tra virgolette
o apici. In altre parole, esso non ficca il naso nelle costanti
stringa e in quelle di tipo carattere.
Vale la pena di citare anche la direttiva #ifdef...#else...#endif,
che consente di includere o escludere dalla compilazione un parte
di codice, a seconda che sia, o meno, definita una determinata
costante manifesta:
#define DEBUG .... #ifdef DEBUG .... // questa parte del sorgente e' compilata #else .... // questa no (lo sarebbe se NON fosse definita DEBUG) #endif
La direttiva #ifndef e' analoga alla #ifdef, ma lavora con logica inversa:
#define DEBUG .... #ifndef DEBUG .... // questa parte del sorgente NON e' compilata #else .... // questa si (NON lo sarebbe se NON fosse definita DEBUG) #endif
Le direttive #ifdef e #ifndef risultano particolarmente utili per scrivere codice portabile: le parti di sorgente differenti in dipendenza dal compilatore, dal sistema o dalla macchina possono essere escluse o incluse nella compilazione con la semplice definizione di una costante manifesta in testa al sorgente.
E' di recente diffusione, tra i programmatori C, la tendenza a
limitare quanto più possibile l'uso delle costanti manifeste,
in parte proprio per evitare la possibilità di effetti
collaterali, ma anche per considerazioni relative alla logica
della programmazione: le costanti manifeste creano problemi in
fase di debugging[34],
poiché non è possibile sapere dove esse si trovino
nella memoria dell'elaboratore (come tutte le costanti, non hanno
indirizzo conoscibile); inoltre non sempre è possibile
distinguere a prima vista una costante manifesta da una variabile,
se non rintracciando la #define (l'uso delle maiuscole
e minuscole è libero tanto nelle costanti manifeste quanto
nei nomi di variabili, pertanto nulla garantisce che un simbolo
espresso interamente con caratteri maiuscoli sia effettivamente
una costante manifesta).
Il C consente di definire delle costanti simboliche dichiarandole
come vere e proprie variabili, ma anteponendo al dichiaratore
di tipo la parola chiave const. Ecco un paio di esempi:
const int ggAnnoFin = 360; const char return = 0x0D;
E' facile vedere che si tratta di dichiarazioni del tutto analoghe a quelle di variabili; tuttavia la presenza di const forza il compilatore a considerare costante il valore contenuto nell'area di memoria associata al nome simbolico. Il compilatore segnala come illegale qualsiasi tentativo di modificare il valore di una costante, pertanto ogni costante dichiarata mediante const deve essere inizializzata contestualmente alla dichiarazione stessa.
const int unIntCostante = 14; .... unIntCostante = 26; //errore: non si puo' modificare il valore di una costante
Il principale vantaggio offerto da const è che
risulta possibile accedere (in sola lettura) al valore delle costanti
così dichiarate mediante l'indirizzo delle medesime (come
accade per tutte le aree di memoria associate a nomi simbolici):
ancora una volta rimandiamo gli approfondimenti alla trattazione
dei puntatori.
Infine, le costanti simboliche possono essere gestite dai debugger
proprio come se fossero variabili.
I tipi di dato discussi in precedenza sono intrinseci al compilatore:
quelli, cioè, che esso è in grado di gestire senza
ulteriori costruzioni logiche da parte del programmatore; possiamo
indicarli come tipi elementari.
Spesso, però, essi non sono sufficienti a rappresentare
in modo esauriente le realtà oggetto di elaborazione: In
un semplice programma che gestisca in modo grafico il monitor
del computer può essere comodo rappresentare un generico
punto luminoso (pixel) del monitor stesso come un'entità
unica, individuata mediante parametri che consentano, attraverso
il loro valore, di distinguerla dalle altre dello stesso tipo:
si tratta di un'entità complessa.
Infatti ogni pixel può essere descritto, semplificando
un po', mediante tre parametri caratteristici: le coordinate (che
sono due, ascissa e ordinata, trattandosi di uno spazio bidimensionale)
e il colore.
Il C mette a disposizione del programmatore alcuni strumenti atti
a rappresentare entità complesse in modo più prossimo
alla percezione che l'uomo ne ha, di quanto consentano i tipi
di dato finora visti. Non si tratta ancora della possibilità
di definire veri e propri tipi di dato "nuovi" e di
gestirli come se fossero intrinseci al linguaggio[35],
ma è comunque un passo avanti...
Tra gli strumenti cui si è fatto cenno appare fondamentale
la struttura (structure), mediante la quale si definisce
un modello (template) che individua un'aggregazione di
tipi di dato fondamentali.
Ecco come potremmo descrivere un pixel con l'aiuto di una struttura:
struct pixel { int x; int y; int colour; };
Quello dell'esempio è una dichiarazione di template di
struttura: si apre con la parola chiave struct seguita
dal nome (tag) che intendiamo dare al nostro modello; questo
è a sua volta seguito da una graffa aperta. Le righe che
seguono, vere e proprie dichiarazioni di variabili, individuano
il contenuto della struttura e si concludono con una graffa chiusa
seguita dal punto e virgola.
Ecco un altro esempio di dichiarazione di template, dal quale
risulta chiaro che una struttura può comprendere differenti
tipi di dato:
struct ContoCorrente { char intestatario[50]; char data_accensione[9]; int cod_filiale; double saldo; double tasso_interesse; double max_fido; double tasso_scoperto; };
E' meglio focalizzare sin d'ora che la dichiarazione di un template
di struttura non comporta che il compilatore riservi dello spazio
di memoria per allocare i campi[36]
della struttura stessa. La dichiarazione di template definisce
semplicemente la "forma" della struttura, cioè
il suo modello.
Di solito le dichiarazioni di template di struttura compaiono
all'inizio del sorgente, anche perché i templates devono
essere stati dichiarati per poter essere utilizzati: solo dopo
avere definito l'identifictore (tag) e il modello (template) della
struttura, come negli esempi di poco fa, è possibile dichiarare
ed utilizzare oggetti di quel tipo, vere e proprie variabili struct.
#include <stdio.h> struct concorso { int serie; char organizzatore; int partecipanti; }; void main(void) { struct concorso c0, c1; c0.serie = 2; c0.organizzatore = 'F'; c0.partecipanti = 482; c1.serie = 0; c1.organizzatore = 'G'; c1.partecipanti = 33; printf("Serie della concorso 0: %d\n",c0.serie); printf("Organizzatore della concorso 1: %c\n",c1.organizzatore); }
Nel programma dell'esempio viene dichiarato un template di struttura,
avente tag concorso. Il template è poi utilizzato
in main() per dichiarare due strutture di tipo concorso:
solo a questo punto sono creati gli oggetti concorso
e viene loro riservata memoria. Gli elementi, o campi, delle due
strutture sono inizializzati con dati di tipo opportuno; infine
alcuni di essi sono visualizzati con la solita printf().
Cerchiamo di evidenziare alcuni concetti fondamentali, a scanso
di equivoci. La dichiarazione di template non presenta nulla di
nuovo: parola chiave struct, tag, graffa aperta, campi,
graffa chiusa, punto e virgola. Una novità è invece
rappresentata dalla dichiarazione delle strutture c0
e c1: come si vede essa è fortemente analoga a
quelle di comuni variabili, con la differenza che le variabili
dichiarate non appartengono al tipo int, float,
o a uno degli altri tipi di dati sin qui trattati. Esse appartengono
ad un tipo di dato nuovo, definito da noi: il tipo struct
concorso.
Finora si è indicato con "dichiarazione di template"
l'operazione che serve a definire l'aspetto della struttura, e
con "dichiarazione di struttura" la creazione degli
oggetti, cioè la dichiarazione delle variabili struttura.
E' forse una terminologia prolissa, ma era indispensabile per
chiarezza. Ora che siamo tutti diventati esperti di strutture
potremo essere un poco più concisi e indicare con il termine
"struttura", come comunemente avviene, tanto i template
che le variabili di tipo struct[37].
In effetti, la dichiarazione:
struct concorso { int serie; char organizzatore; int partecipanti; };
crea semplicemente un modello che può essere usato come riferimento per ottenere variabili dotate di quelle particolari caratteristiche. Ciascuna variabile conforme a quel modello contiene, nell'ordine prefissato, un int, un char e un secondo int. A ciascuna di queste variabili, come per quelle di qualsiasi altro tipo, il compilatore alloca un'area di memoria di dimensioni sufficienti, alla quale associa il nome simbolico che compare nella dichiarazione
struct concorso c0;
cioè c0. In quest'ultima dichiarazione, l'identificatore concorso indica il modello particolare al quale si deve conformare la variabile dichiarata. Esso è, in pratica, un'abbreviazione di
{ int serie; char organizzatore; int partecipanti; };
e come tale può venire usato nel programma. In altre parole,
è possibile riferirsi all'intera dichiarazione di struttura
semplicemente usandone il tag.
Una variabile di tipo struct può essere dichiarata
contestualmente al template:
struct concorso { int serie; char organizzatore; int partecipanti; } c0, c1;
Il template può essere normalmente utilizzato per dichiarare
altre strutture nel programma[38].
Tornando a quel che avviene nella main() dell'esempio,
ai campi delle strutture dichiarate sono stati assegnati valori
con una notazione del tipo
nome_della_variabile_struttura.nome_del_campo = valore;
ed in effetti l'operatore punto (".") è lo strumento
offerto dal C per accedere ai singoli campi delle variabili struct,
tanto per assegnarvi un valore, quanto per leggerlo (e lo si vede
dalle printf() che seguono).
Abbiamo parlato delle strutture viste negli esempi precedenti
come di variabili di tipo struct concorso. In effetti
definire un template di struttura significa arricchire il linguaggio
di un nuovo tipo di dato, non intrinseco, ma al quale è
possibile applicare la maggior parte dei concetti e degli strumenti
disponibili con riferimento ai tipi di dato intrinseci.
Le strutture possono quindi essere gestite mediante array e puntatori,
proprio come comuni variabili C. La dichiarazione di un array
di strutture si prsenta come segue:
struct concorso c[3];
Si nota immediatamente la forte somiglianza con la dichiarazione di un array di tipo intrinseco: il valore tra parentesi quadre specifica il numero di elementi, cioè, in questo caso, di strutture che formano l'array. Ogni elemento è, appunto, una struttura conforme al template concorso; l'array ha nome c. Per accedere ai singoli elementi dell'array è necessario, come prevedibile, specificare il nome dell'array seguito dall'indice, tra quadre, dell'elemento da referenziare. La differenza rispetto ad un array "comune", ad esempio di tipo int, sta nel fatto che accedere ad una struttura non significa ancora accedere ai dati che essa contiene: per farlo occorre usare l'operatore punto, come mostrato poco sopra. Un esempio chiarirà le idee:
#include <stdio.h> struct concorso { int serie; char organizzatore; int partecipanti; }; void main(void) { register i; struct concorso c[3]; c[0].serie = 2; c[0].organizzatore = 'F'; c[0].partecipanti = 482; c[1].serie = 0; c[1].organizzatore = 'G'; c[1].partecipanti = 33; c[2].serie = 3; c[2].organizzatore = 'E'; c[2].partecipanti = 107; for(i = 0; i < 3; i++) printf("%d %c %d\n",c[i].serie,c[i].organizzatore,c[i].partecipanti); }
Con riferimento ad un array di strutture, la sintassi usata per
referenziare i campi di ciascuna struttura elemento dell'array
è simile a quella utilizzata per array di tipi intrinseci.
Ci si riferisce, ad esempio, al campo serie dell'elemento
di posto 0 dell'array con la notazione c[0].serie;
è banale osservare che c[0] accede all'elemento
dell'array, mentre .serie accede al campo voluto di quell'elemento.
Si può pensare all'esempio presentato sopra immaginando
di avere tre fogli di carta, ciascuno contenente un elemento dell'array
c. In ciascun foglio sono presenti tre righe di informazioni
che rappresentano, rispettivamente, i 3 campi della struttura.
Se i 3 fogli vengono mantenuti impilati in ordine numerico crescente,
si ottiene una rappresentazione "concreta" dell'array,
in quanto è possibile conoscere sia il contenuto dei tre
campi di ogni elemento, sia la relazione tra i vari elementi dell'array
stesso.
I più attenti hanno sicuramente[39]
notato che, mentre le operazioni di assegnamento, lettura, etc.
con tipi di dato intrinseci vengono effettuate direttamente sulla
variabile dichiarata, nel caso delle strutture esse sono effettuate
sui campi, e non sulla struttura come entità direttamente
accessibile. In realtà le regole del C non vietano di accedere
direttamente ad una struttura intesa come un'unica entità,
ma si tratta di una pratica poco seguita[40].
E' infatti assai più comodo ed efficiente utilizzare i
puntatori.
Anche nel caso dei puntatori le analogie tra strutture e tipi
intrinseci sono forti. La dichiarazione di un puntatore a struttura,
infatti, è:
struct concorso *cPtr;
dove cPtr è il puntatore, che può contenere l'indirizzo di una struttura di template concorso. L'espressione *cPtr restituisce una struct concorso, esattamente come in una dichiarazione quale
int *iPtr;
*iPtr restituisce un int. Attenzione, però: per accedere ai campi di una struttura referenziata mediante un puntatore non si deve usare l'operatore punto, bensì l'operatore "freccia", formato dai caratteri "meno" ("") e "maggiore" (">") in sequenza, con una sintassi del tipo:
nome_del_puntatore_alla_variabile_di_tipo_struttura->nome_del_campo = valore;
Vediamo un esempio.
struct concorso *cPtr; .... cPtr->serie = 2; .... printf("Serie: %d\n",cPtr->serie);
I puntatori a struttura godono di tutte le proprietà dei
puntatori a tipi intrinseci, tra le quali particolarmente interessante
appare l'aritmetica dei puntatori. Incrementare
un puntatore a struttura significa sommare implicitamente al suo
valore tante unità quante ne occorrono per "scavalcare"
tutta la struttura referenziata e puntare quindi alla successiva.
In generale, sommare un intero ad un puntatore a struttura equivale
sommare quell'intero moltiplicato per la dimensione della struttura[41].
E' appena il caso di sottolineare che la dimensione di un puntatore
a struttura e la dimensione della struttura puntata sono due concetti
differenti, come già si è detto per le variabili
di tipo intrinseco. Un puntatore a struttura occupa sempre 2 o
4 byte, a seconda che sia near, oppure far o
huge, indipendentemente dalla dimensione della struttura
a cui punta. Con la dichiarazione di un puntatore a struttura,
dunque, il compilatore non alloca memoria per la struttura stessa.
Rivediamo il programma d'esempio di poco fa, modificandolo per
utilizzare un puntatore a struttura:
#include <stdio.h> struct concorso { int serie; char organizzatore; int partecipanti; }; void main(void) { struct concorso c[3], *cPtr; c[0].serie = 2; c[0].organizzatore = 'F'; c[0].partecipanti = 482; c[1].serie = 0; c[1].organizzatore = 'G'; c[1].partecipanti = 33; c[2].serie = 3; c[2].organizzatore = 'E'; c[2].partecipanti = 107; for(cPtr = c; cPtr < c+3; ++cPtr) printf("%d %c %d\n",cPtr->serie,cPtr->organizzatore,cPtr->partecipanti); }
Come si può notare, la modifica consiste essenzialmente nell'avere dichiarato un puntatore a struct concorso, cPtr, e nell'averlo utilizzato in luogo della notazione c[i] per accedere agli elementi dell'array. Le dichiarazioni dell'array e del puntatore sono state raggruppate in un'unica istruzione, ma sarebbe stato possibile separarle: il codice
struct concorso c[3]; struct concorso *cPtr;
avrebbe avuto esattamente lo stesso significato, sebbene in forma
meno compatta e, forse, più leggibile.
Nel ciclo for dell'esempio, il puntatore cPtr
è inizializzato a c e poiché il nome di
un array è puntatore all'array stesso, cPtr punta
al primo elemento di c, cioè c[0]. Durante
la prima iterazione sono visualizzati i valori dei 3 campi di
c[0]; all'iterazione successiva cPtr viene incrementato
per puntare al successivo elemento di c, cioè
c[1], e quindi l'espressione
cPtr->
è ora equivalente a
c[1].
All'iterazione successiva, l'espressione
cPtr->
diviene equivalente a
c[2].
dal momento che cPtr è stato incrementato ancora
una volta.
A proposito di puntatori, è forse il caso di evidenziare
che una struttura può contare tra i suoi campi puntatori
a qualsiasi tipo di dato. Sono perciò ammessi anche puntatori
a struttura, persino puntatori a struttura identificata dal medesimo
tag. In altre parole, è perfettamente lecito scrivere:
struct TextLine { char *line; int cCount; struct TextLine *prevTL; struct TextLine *nextTL; };
Quella dell'esempio è una struttura (o meglio, un template
di struttura) che potrebbe essere utilizzata per una rudimentale
gestione delle righe di un testo, ad esempio in un programma di
word processing. Essa contiene due puntatori a struttura
dello stesso template: nell'ipotesi che ogni riga di testo sia
gestita attraverso una struttura TextLine, prevTL
è valorizzato con l'indirizzo della struct TextLine
relativa alla riga precedente nel testo, mentre nextTL
punta alla struct TextLine della riga successiva[42].
E' proprio mediante un utilizzo analogo a questo dei puntatori
che vengono implementati oggetti quali le liste. Uno dei vantaggi
immediatamente visibili che derivano dall'uso descritto dei due
puntatori prevTL e nextTL consiste nella possibilità
di implementare algoritmi di ordinamento delle righe di testo
che agiscano solo sui puntatori: è sufficiente modificare
il modo in cui le righe di testo sono legate l'una all'altra da
un punto di vista logico, senza necessità alcuna di modificarne
l'ordine fisico in memoria.
E' ovvio che, come al solito, un puntatore non riserva lo spazio
per l'oggetto a cui punta. Nell'ipotesi di puntatori near,
l'espressione sizeof(struct TextLine) restituisce 8.
La memoria necessaria a contenere la riga di testo e le strutture
TextLine stesse deve essere allocata esplicitamente.
Nel caso degli array, al contrario, la memoria è allocata
staticamente dal compilatore (anche qui nulla di nuovo): riscriviamo
il template in modo da gestire la riga di testo come un array
di caratteri, avente dimensione massima prestabilita (in questo
caso 80):
struct TextLine { char line[80]; int cCount; struct TextLine *prevTL; struct TextLine *nextTL; };
questa volta l'espressione sizeof(struct TextLine) restituisce
86.
Va anche precisato che una struttura può contenere un'altra
struttura (e non solo il puntatore ad essa), purché identificata
da un diverso tag:
struct TextParms { int textLen; int indent; int justifyType; }; struct TextLine { char line[80]; struct TextParms lineParms; struct TextLine *prevTL; struct TextLine *nextTL; };
In casi come questo le dichiarazioni delle due strutture possono perfino essere nidificate:
struct TextLine { char line[80]; struct TextParms { int textLen; int indent; int justifyType; } lineParms; struct TextLine *prevTL; struct TextLine *nextTL; };
Da quanto appena detto appare evidente che una struttura non può mai contenere una struttura avente il proprio stesso tag identificativo: per il compilatore sarebbe impossibile risolvere completamente la definizione della struttura, in quanto essa risulterebbe definita in funzione di se stessa. In altre parole è illecita una dichiarazione come:
struct ST { int number; // OK float *fPtr; // OK struct ST inner; // NO! il tag e' il medesimo e questo non e' un puntatore };
Anche agli elementi di strutture nidificate si accede tramite il punto (".") o la freccia (">"): con riferimento ai templates appena riportati, è possibile, ad esempio, dichiarare un array di strutture TextLine:
struct TextLine tl[100]; // dichiara un array di strutture TextLine
Alla riga di testo gestita dal primo elemento dell'array si accede, come già sappiamo, con l'espressione tl[0].line. Per visualizzare la riga successiva (gestita dall'elemento di tl il cui indirizzo è contenuto in nextTL) vale la seguente:
printf("Prossima riga: %s\n",tl[0].nextTL->line);
Infatti tl[0].nextTL accede al campo nextTL di tl[0]: l'operatore utilizzato è il punto, proprio perchè tl[0] è una struttura e non un puntatore a struttura. Ma nextTL è, al contrario, un puntatore, perciò per referenziare l'elemento line della struttura che si trova all'indirizzo che esso contiene è necessario usare la "freccia". Supponiamo ora di voler conoscere l'indentazione (rientro rispetto al margine) della riga appena visualizzata: è ormai noto che ai campi della struttura "puntata" da nextTL si accede con l'operatore >; se il campo referenziato è, a sua volta, una struttura (lineParms), i campi di questa sono "raggiungibili" mediante il punto.
printf("Indentazione della prossima riga: %d\n",tl[0].nextTL->lineParms.indent);
Insomma, la regola generale (che richiede di utilizzare il punto
se l'elemento fa parte di una struttura referenziata direttamente
e la freccia se l'elemento è raggiungibile attraverso il
puntatore alla struttura) rimane valida e si applica pedestremente
ad ogni livello di nidificazione.
Vale infine la pena di chiarire che le strutture, pur costituendo
un potente strumento per la rappresentazione informatica di entità
complesse (quali record di archivi, etc.), sono ottimi "aiutanti"
anche quando si desideri semplificare il codice ed incrementarne
l'efficienza; se, ad esempio, occorre passare molti parametri
ad una funzione, e questa è richiamata molte volte (si
pensi al caso di un ciclo con molte iterazioni), può essere
conveniente definire una struttura che raggruppi tutti quei parametri,
così da poter passare alla funzione un parametro
soltanto: il puntatore alla struttura stessa.
Da quanto detto circa le strutture, appare evidente come esse
costituiscano uno strumento per la rappresentazione di realtà
complesse, in quanto sono in grado di raggrupparne i molteplici
aspetti quantitativi[43]. In particolare,
ogni singolo campo di una struttura permette di gestire uno degli
aspetti che, insieme, descrivono l'oggetto reale.
Il concetto di unione deriva direttamente da quello di struttura,
ma con una importante differenza: i campi di una union
rappresentano diversi modi di vedere, o meglio, rappresentare,
l'oggetto che la union stessa descrive. Consideriamo
l'esempio seguente:
struct FarPtrWords { unsigned offset; unsigned segment; }; union Far_c_Ptr { char far *ptr; struct FarPtrWords words; };
La dichiarazione di un template di union è del
tutto analoga a quella di un template di struttura: l'unica differenza
è costituita dalla presenza della parola chiave union
in luogo di struct.
La differenza è però enorme a livello concettuale:
la struct FarPtrWords comprende due campi, entrambi di
tipo unsigned int. Non ci vuole molto a capire che essa
occupa 4 byte e descrive un puntatore di tipo "non near",
scomposto nelle due componenti di indirizzo[44].
I due campi della union Far_c_Ptr, invece, sono rispettivamente
un puntatore a 32 bit e una struct FarPtrWords. Contrariamente
a quanto ci si potrebbe aspettare, la union non occupa
8 byte, bensì solo 4: puntatore e struct FarPtrWords
sono due modi alternativi di interpretarli o, in altre parole,
di accedere al loro contenuto. La union Far_c_Ptr
è un comodo strumento per gestire un puntatore come tale,
o nelle sue parti offset e segmento, a seconda della necessità.
L'area di memoria in cui il dato si trova è sempre la stessa,
ma il campo ptr la referenzia come un tutt'uno, mentre
la struttura FarPtrWord consente di accedere ai primi
due byte o agli ultimi due, separatamente.
Si può pensare ad una union come ad un insieme
di "maschere" attraverso le quali interpretare il contenuto
di un'area di memoria.
Vediamo la sintassi, senza preoccuparci dell'espressione (char
far *): si tratta di un cast
e non riguarda in modo diretto l'argomento "union":
union FarPtr fp; fp.ptr = (char far *)0xB8000000L; printf("ptr: %Fp\n",fp.ptr); printf("ptr: %X:%X\n",fp.words.segment,fp.words.offset);
L'accesso ai membri di una union segue le medesime regole dell'accesso ai membri di una struct, cioè mediante l'operatore punto (o l'operatore "freccia" se si lavora con un puntatore). E' interessante notare che inizializzando il campo ptr viene inizializzato anche il campo word, in quanto condividono la stessa memoria fisica. Il medesimo campo ptr è poi utilizzato per ricavare il valore del puntatore, mentre il campo words consente di accedere alle componenti segment ed offset. Entrambe le printf() visualizzano
B800:0000
Se nel codice dell'esempio si sostituisce la riga di inizializzazione del campo ptr con le righe seguenti:
fp.words.offset = 0; fp.words.segment = 0xB800;
le due printf() visualizzano ancora il medesimo output.
La sintassi che consente di accedere ai due campi della struct
FarPtrWords, a prima vista, può apparire strana,
ma in realtà essa è perfettamente coerente con le
regole esposte con riferimento alle strutture: dal momento che
ai campi di una union si accede mediante l'operatore
punto, sono giustificate le scritture fp.ptr e fp.words
ma l'operatore punto si utilizza anche per accedere ai membri
di una struttura, perciò sono lecite le scritture word.offset
e word.segment; ciò spiega fp.word.offset
e fp.word.segment.
Nell'esempio di union analizzato, i membri sono due ed
hanno uguale dimensione (entrambi 4 byte). Va precisato che i
membri di una union possono essere più di due;
inoltre essi possono essere di dimensioni differenti l'uno dall'altro,
nel qual caso il compilatore, allocando la union, le
riserva una quantità di memoria sufficiente a contenere
il più "ingombrante" dei suoi membri, e li "sovrappone"
a partire dall'inizio dell'area di memoria occupata dalla union
stessa. Esempio:
union Far_c_Ptr { char far *ptr; struct FarPtrWords words; unsigned pOffset; }
Il terzo elemento della union è un unsigned
int, e come tale occupa 2 byte. Questi coincidono con i primi
due byte di ptr (e della struct), e rappresentano
pertanto la word offset del puntatore rappresentato dalla union.
I puntatori a union si comportano esattamente come i
puntatori a struct.
Gli enumeratori sono un ulteriore strumento che il C rende disponibile
per rappresentare più agevolmente i dati gestiti dai programmi.
In particolare essi consentono di descrivere con nomi simbolici
gruppi di oggetti ai quali è possibile associare valori
numerici interi.
Come noto, le variabili di un programma possono rappresentare
non solo oggetti quantificabili, come un importo valutario, ma
anche qualità non numerabili (come un colore o il sesso
di un individuo) la cui caratteristica principale è il
fatto di essere mutuamente esclusive. Normalmente si tende a gestire
tali qualità "inventando" una codifica che permette
di assegnare valori di tipo integral ai loro
differenti modi di manifestarsi (ad esempio: al colore nero può
essere associato il valore zero, al rosso il valore 1,
e così via; si può utilizzare il carattere 'M'
per "maschile" e 'F' per "femminile, etc.).
Spesso si ricorre alle direttive #define, che consentono
di associare, mediante la sostituzione di stringhe a livello di
preprocessore, un valore numerico ad un nome
descrittivo.
L'uso degli enumeratori può facilitare la stesura dei programmi,
lasciando al compilatore il compito di effettuare la codifica
dei diversi valori assumibili dalle variabili che gestiscono modalità
qualitative, e consentendo al programmatore di definire ed utilizzare
nomi simbolici per riferirsi a tali valori. Vediamo un esempio:
enum SEX { ignoto, // beh, non si sa mai... maschile, femminile };
La dichiarazione di un enumeratore ricorda da vicino quella di
una struttura: anche in questo caso viene definito un template;
la parola chiave enum è seguita dal tag, cioè
dal nome che si intende dare al modello di enumeratore; vi sono
le parentesi graffe aperta e chiusa, quest'ultima seguita dal
punto e virgola. La differenza più evidente rispetto alla
dichiarazione di un template di struttura consiste nel fatto che
laddove in questo compaiono le dichiarazioni dei campi (vere e
proprie definizioni di variabili con tanto di indicatore di tipo
e punto e virgola), nel template di enum vi è
l'elenco dei nomi simbolici corrispondenti alle possibili manifestazioni
della qualità che l'enumeratore stesso rappresenta. Detti
nomi simbolici sono separati da virgole; la virgola non compare
dopo l'ultimo nome elencato.
Anche la dichiarazione di una variabile di tipo enum
ricorda da vicino quella di una variabile struttura:
enum SEX sesso; .... sesso = maschile; .... if(sesso == maschile) printf("MASCHIO"); else if(sesso == femminile) printf("FEMMINA"); else printf("BOH?");
Il codice riportato chiarisce le modalità di dichiarazione,
inizializzazione e, in generale, di utilizzo di una variabile
di tipo enum.
E' inoltre possibile notare come in C, a differenza di quanto
avviene in molti altri linguaggi, l'operatore di assegnamento
e quello di confronto per uguaglianza hanno grafia differente,
dal momento che quest'ultimo si esprime con il doppio segno di
uguale.
Ovviamente il compilatore, di soppiatto, assegna dei valori ai
nomi simbolici elencati nel template dell'enum: per default
al primo nome è associato il valore 0, al secondo
1, e così via. E' comunque possibile assegnare
valori a piacere, purché integral, ad uno o più
nomi simbolici; ai restanti il valore viene assegnato automaticamente
dal compilatore, incrementando di uno il valore associato al nome
precedente.
enum SEX { ignoto = -1, maschile, femminile };
Nell'esempio, al nome ignoto è assegnato esplicitamente
valore 1: il compilatore assegna valore 0
al nome maschile e 1 a femminile. I
valori esplicitamente assegnati dal programmatore non devono necessariamente
essere consecutivi; la sola condizione da rispettare è
che si tratti di valori interi.
Il vantaggio dell'uso degli enumeratori consiste nella semplicità
di stesura e nella migliore leggibilità del programma,
che non deve più contenere dichiarazioni di costanti manifeste
né utilizzare variabili intere per esprimere modalità
qualitative. Inoltre, la limitazione del fabbisogno di costanti
manifeste rappresenta di per sé un vantaggio di carattere
tecnico, in quanto consente di limitare i rischi connessi al loro
utilizzo, in particolare i cosiddetti side effect o effetti collaterali.
Se una variabile di tipo intero può assumere solo un limitato
numero di valori, è teoricamente possibile memorizzarla
utilizzando un numero di bit inferiore a quello assegnatole dal
compilatore: basta infatti 1 bit per memorizzare un dato che può
assumere solo due valori, 2 bit per un dato che può assumere
quattro valori, 3 bit per uno che può assumere otto valori,
e così via.
Il C non ha tipi intrinseci di dati con un numero di bit inferiori
a 8 (il char), ma consente di "impaccare" più
variabili nel numero di bit strettamente necessario mediante i
cosiddetti campi di bit.
Un esempio di uso di questo strumento può essere ricavato
con riferimento alla gestione di una cartella clinica. Supponiamo
di voler gestire, per ogni paziente, le seguenti informazioni:
il sesso (maschile o femminile), lo stato vitale (vivente, defunto,
in coma), il tipo di medicinale somministrato (sedici categorie,
come antibiotici e sulfamidici), la categoria di ricovero (otto
possibili sistemazioni, da corsia a camera di lusso). In questa
ipotesi sarebbe possibile codificare il sesso mediante un solo
bit, lo stato vitale con 2, il tipo di cura con 4, la sistemazione
in ospedale con 3: in totale 10 bit, senz'altro disponibili in
un'unica variabile di tipo intero.
L'uso dei campi di bit prevede la dichiarazione di un template:
anche in questo caso la somiglianza con le strutture è
palese.
struct CartellaClinica { unsigned sesso: 1; unsigned stato: 2; unsigned cura: 4; unsigned letto: 3; };
La dichiarazione utilizza la parola chiave struct, proprio
come se si trattasse di un template di struttura; le dichiarazioni
dei campi sono introdotte da uno specificatore di tipo e chiusa
dal punto e virgola; la differenza qui consiste nell'indicazione
dell'ampiezza in bit di ogni singolo campo, effettuata posponendo
al nome del campo il carattere due punti (":")
seguito dal numero di bit da assegnare al campo stesso. I due
punti servono, infatti, a indicare la definizione di un campo
di bit, la cui ampiezza viene specificata dal numero seguente;
se il numero totale di bit non è disponibile in un'unica
variabile intera, il compilatore alloca anche la successiva word
in memoria.
I campi di bit del tipo CartellaClinica sono tutti dichiarati
unsigned int: in tal modo tutti i bit sono utilizzabili
per esprimere i valori che di volta in volta i campi stessi assumeranno.
In realtà, i campi di bit possono anche essere dichiarati
int, ma in questo caso il loro bit più significativo
rappresenta il segno e non è quindi disponibile per memorizzare
il valore. Un campo dichiarato int ed ampio un solo bit
può esprimere solo i valori 0 e 1.
I campi di bit sono referenziabili esattamente come i campi di
una comune struttura:
enum SEX { maschile, femminile }; struct CartellaClinica { unsigned sesso: 1; unsigned stato: 2; unsigned cura: 4; unsigned letto: 3; }; char *sessi[] = { "MASCHILE", "FEMMINILE" }; .... struct CartellaClinica Paziente; .... Paziente.sesso = maschile; .... printf("Sesso del paziente: %s\n",sessi[Paziente.sesso]);
E' importante ricordare come sia compito del programmatore assicurarsi
che i valori memorizzati nei campi di bit non occupino più
bit di quanti ne sono stati loro riservati in fase di definizione
del template, dal momento che le regole del C non assicurano che
venga effettuato un controllo nelle operazioni di assegnamento
di valori ai campi. Se si assegna ad un campo di bit un valore
maggiore del massimo previsto per quel campo, può accadere
che i bit più significativi di quel valore siano scritti
nei campi successivi: è bene, ancora una volta, verificare
il comportamento del proprio compilatore.
Naturalmente un campo di bit può essere utilizzato anche
per memorizzare un'informazione di tipo quantitativo: ad esempio,
la struct CartellaClinica potrebbe essere ridefinita
mediante l'aggiunta di un campo atto a memorizzare il numero di
ricoveri subiti dal paziente; impiegando 6 bit tale valore è
limitato a 63.
struct CartellaClinica { unsigned sesso: 1; unsigned stato: 2; unsigned cura: 4; unsigned letto: 3; unsigned ricoveri: 6; };
Nella nuova definizione, tutti i 16 bit delle due word occupate
in memoria dalla struct CartellaClinica sono utilizzati.
OK, andiamo avanti a leggere il libro...
Non ci ho capito niente! Ricominciamo...