Capitolo 4 - Tipi, costanti, espressioni, operatori

 

 

 

 

            4.1 - Tipi di dati in C

 

 

                I tipi di dato predefiniti sono solo quattro, da dichiarare utilizzando le parole chiave:

 

  (4.1.1)                   char,      int,          float,       double,

 

che in memoria utilizzano rispettivamente uno, due, quattro ed otto byte. Il tipo char  viene generalmente utilizzato per rappresentare valori simbolici (caratteri grafici o di controllo) secondo la tabella di codifica ASCII, mentre i rimanenti sono rappresentazioni binarie di valori numerici, nella normale forma posizionale per gli interi - int - ed in quella esponenziale normalizzata per i non interi - float e double.

 

                Il C permette però di trattare anche i singoli byte come valori numerici in calcoli aritmetici; vale a dire che, a seconda delle operazioni che si intende compiere, la sequenza di bit in un byte può essere vista come codice di un carattere, oppure come un effettivo (piccolo) numero intero.

 

                Esistono però altre parole chiave dette modificatori, che introducono varianti nella rappresentazione in memoria e/o nelle operazioni ammesse, ampliando notevolmente la gamma di tipi; essi sono:

 

  (4.1.2)                   short                      long

                               signed                   unsigned

                               register

                               extern

                               static                     auto                        volatile

 

e debbono essere impiegati premettendoli al nome del tipo fondamentale, ad esempio come in:

 

  (4.1.3)                   unsigned char primo ;

                               long int secondo ;

                               register int x ;

                               unsigned long int z ;

                               register unsigned char c ;

 

                Gli ultimi due esempi mostrano come più modificatori possano anche essere combinati per alterare un tipo base, secondo la sintassi:

 

  (4.1.4)                   [modificatore_1] ... [modificatore_n] tipo_base lista ;

 

generando una grande quantità di combinazioni possibili.

 

                Al riguardo sono però opportune alcune osservazioni:

 

(a) - Non tutti i modificatori sono applicabili a tutti i tipi: in particolare signed, unsigned, register possono essere impiegati solo in connessione a tipo interi, cioè solo per char ed int, ma non per float e double.

 

(b) - Alcuni modificatori sono impliciti o ridondanti: ad esempio short int é sinonimo di char, mentre un intero é automaticamente considerato signed, ossia si intende che il suo primo bit rappresenti il segno, quindi si deve scrivere esplicitamente solo il modificatore opposto, unsigned. Altri casi ‘inutili’ o addirittura privi di senso sono short e long applicati a float o double.


(c) - Quanto detto in (b) ha validità largamente generale, ma deve essere verificato per la specifica versione di linguaggio che si sta utilizzando; può infatti accadere che la unità di memorizzazione standard sia di quattro byte invece che due, e che quindi int sia implicitamente long int, e di conseguenza risulti significativa la combinazione short int per interi di due byte. Un altro caso possibile é una ulteriore estensione di mantissa e caratteristica con long double.

 

(d) - Un caso particolarmente importante é quello della combinazione long int, che definisce un intero di quattro byte, cioè di 32 bit, capace di rappresentare numeri interi molto grandi (oltre 2,100,000,000 e dotati di segno); questa combinazione può essere abbreviata nel solo long, come in:

 

  (4.1.5)                   long numero ;

 

(e) - Mentre unsigned e long modificano la struttura di un dato intero, nel primo caso utilizzando anche il primo bit come parte del valore assoluto del numero (raddoppiando così l’estensione dei valori positivi) e nel secondo ampliandone la dimensione, il modificatore register non la cambia, ma richiede un diverso modo di trattare il dato in macchina: anziché nella memoria centrale esso deve ‘risiedere’ in un registro del microprocessore, cosa che rende molto più veloce l’elaborazione. Ogni funzione può però disporre al massimo di due variabili trattate in questo modo.

 

(f) - Non é conveniente trattare ora il significato di extern, static, auto e volatile, ma ci limitiamo a dire che essi impongono l’appartenenza delle risorse (variabili o funzioni) a certe classi di memoria, qualificate da termini come locale, globale, privata, pubblica, statica, dinamica, tutti da riprendere nel contesto opportuno.

 

 

                I modificatori permettono di ampliare molto il numero di tipi trattabili in C; l’utente può però ulteriormente arricchire il ‘vocabolario’ dei tipi creando per essi dei nuovi nomi tramite la parola chiave typedef, da utilizzare con la sintassi:

 

  (4.1.6)                   typedef tipo sinonimo ;

 

ove tipo é uno qualunque di quelli già noti (eventualmente creato con un precedente typedef), e sinonimo é un identificatore creato dall’utente, con le medesime regole che si applicano ai nomi di variabili e funzioni.

 

                Se ad esempio si volesse disporre di due sinonimi abbreviati per combinazioni piuttosto lunghe si potrebbe porre:

 

  (4.1.7)                   typedef   unsigned char     byte ;

                               typedef   unsigned int         uint ;

 

e dal punto in cui si incontrano queste due istruzioni dichiarative in avanti, il programma che le contiene può disporre dei termini byte ed uint come se fossero tipi predefiniti del C, cioè con lo stesso ‘stato giuridico’ delle parole chiave in (4.1.1).

 

                L’esempio scelto prova che typedef può risultare utile per sostituire sequenze ‘scomode’, ma con la introduzioni delle costruzioni dette dati strutturati si vedrà che questo strumento ha potenzialità assai maggiori di quanto non possa apparire a questo stadio.

 

                Nonostante la ricchezza delle combinazioni date dai modificatori, mancano alcuni tipi di dati, che sono invece disponibili in altri linguaggi come classi operative autonome; i due esempi più evidenti sono i numeri complessi e le variabili logiche, o booleane. Il Fortran, ad esempio, definisce tali tipi di oggetti tramite le dichiarazioni complex e logical (in genere definite più esattamente come complex*8, oppure complex*16 e logical*1).

 

                In C i numeri complessi possono essere trattati con apposite funzioni della libreria standard (ciò può dipendere dalla versione di linguaggio utilizzata - si veda più avanti la costruzione struct - ), mentre i valori logici sono trattati come un caso particolare di valori aritmetici:


 

  (4.1.8)                   Ogni valore numerico, indipendentemente dal tipo, é considerato nel contesto del

                               calcolo logico come un valore booleano, considerando falso un valore nullo e vero

                               ogni valore non nullo.

 

                La (4.1.8) rende quindi superflua la presenza di un apposito tipo logico; mentre qualunque valore numerico é adatto allo scopo, quando invece si esegue un calcolo logico, il risultato é ovviamente il valore zero per falso, ma convenzionalmente uno per vero, cosa che permette espressioni miste logico-aritmetiche di notevole interesse, come nel seguente segmento di programma, in cui utilizziamo l’operatore ‘<‘,  di carattere immediatamente intuitivo:

 

  (4.1.9)                   int n ;  double x, y ;

                               ........

                               n = ( x < y ) * 5 ;

 

che ha come risultato che il valore di n può essere soltanto 0 oppure 5, a seconda che la relazione logica x < y sia valutata in falso (zero), oppure vero (uno).

 

 

 

 

 

 

            4.2 - Costanti

 

 

                L’appartenenza di un valore ad un tipo ne condiziona l’intervallo dei valori ammissibili, che in ogni caso costituisce un insieme totalmente ordinato, tra due elementi del quale é cioè sempre possibile stabilire qual’é minore e qual’è maggiore. La tabella illustra tutti i casi possibili:

 

  (4.2.1)

tipo

minimo

massimo

char

-128

127

unsigned char

0

255

int

-32768

32767

unsigned int

0

65535

long

-2147483468

2147483467

unsigned long

0

4294967295

float

~ -3.4e38

~ 3.4e38

double

~ -1.7e308

~ 1.7e308

 

 

in cui nelle ultime due righe si é utilizzata la notazione esponenziale standard, in cui ‘e’ é la indicazione di esponente, o potenza del 10; si dovrebbero inoltre aggiungere il massimo negativo e minimo positivo, che ad esempio nel caso dei float sono ~ -3.4e-38 e ~ 3.4e-38.

 

                E’ necessario utilizzare spesso delle costanti, che per quanto detto in (4.1.8) in C sono sempre numeriche, ad esempio nell’inizializzazione di variabili o in espressioni aritmetiche, come in:

 

  (4.2.2)                   int numero = 4 ;   unsigned char c = 210 ;

                               double e = 2.718281828459045 ;

 

  (4.2.3)                   double a, b, c, d ;

                               ........

                               d = b * b - 4.0 * a * c ;


                Le costanti si dividono fondamentalmente in intere e non intere, e nel loro impiego é necessario tenere conto dei limiti dati dalla tabella (4.2.1); ad esempio con:

 

  (4.2.4)                   char c1 = 1022, c2 = 1025 ;

 

si ha una evidente incongruenza; in casi come questo il compilatore può emettere avvertimenti, ma provvede automaticamente ad una riduzione ‘al meglio’, che in questo caso é nella aritmetica modulo 256; vale a dire che le due variabili c1 e c2 assumono valori -1 e +1 rispettivamente.

 

                Per le costanti intere il C ammette tre tipi di notazioni, decimale, ottale ed esadecimale; nel secondo caso basta premettere uno zero (non significativo), e nel terzo la sequenza 0x, come in:

 

  (4.2.5)                   int a1 = 10, a2 = 010, a3 = 0x10 ;

 

che inizializzano a1, a2, a3 ai rispettivi valori decimali 10, 8 e 16. Si deve ovviamente essere coerenti con la notazione scelta, vale a dire che, ad esempio, 09 é scorretto, perché 9 non é una cifra ottale valida.

 

                Per le costanti non intere sono disponibili la consueta notazione decimale posizionale e quella decimale esponenziale, non necessariamente normalizzata, che richiede l’impiego del simbolo ‘e’ per separare la mantissa dalla caratteristica, come mostrato nella tabella (4.2.1).

 

                Per le costanti carattere sono pure disponibili diverse notazioni; la più comune é quella che utilizza una coppia di apici semplici per ‘racchiudere’ il carattere, rendendolo così distinguibile, ad esempio, dai nomi delle variabili; é però possibile impiegare lo speciale simbolo backslash ‘\’, che nelle stringhe di formato apre le cosiddette sequenze di escape, facendolo in questo caso seguire da un massimo di tre cifre ottali, oppure esadecimali se precedute da ‘x’; ad esempio la dichiarazione/definizione:

 

  (4.2.6)                   char c1 = ‘A’, c2 = ‘\102’, c3 = ‘\x43’ ;

 

produce tre byte di rispettivi contenuti decimali 65, 66, 67, equivalenti alle rappresentazioni dei caratteri ‘A’, ‘B’ e ‘C’ rispettivamente.

 

                La notazione che utilizza il backslash per le costanti carattere non é limitata ai soli valori numerici espliciti, ma comprende anche un certo numero di valori simbolici, in buona parte dedicati alle funzioni di controllo definite per caratteri non grafici della tabella ASCII

 

  (4.2.7)                   simbolo                 funzione                                               ASCII

                               ‘\a’                         beep - avvisatore acustico  7

                               ‘\b’                         backspace                                            8

                               ‘\f’                          salto pagina in stampa                       12

                               ‘\n’                         salto riga (video/stampa)                   13

                               ‘\t’                          tabulazione orizzontale                     9

                               ‘\v’                          tabulazione verticale                         10

 

                Se nella notazione backslash il carattere che lo segue é uno di quelli della lista, il byte che contiene questo valore descrive l’azione detta in tabella; se si tratta di qualunque altro simbolo, ad esempio come in ‘\”’ (doppio apice) il backslash viene ignorato ed il byte contiene il codice del simbolo stesso (in questo caso 34). In certi contesti, in particolare nelle stringhe, questo é spesso il solo modo di rendere disponibili alcuni caratteri dotati di significato speciale.

 

                L’ultima notazione delle costanti riguarda le stringhe, o sequenze lineari di caratteri arbitrari, di cui abbiamo visto esempi come primi argomenti (stringhe di formato) nelle funzioni printf del programma (3.1.1); ci limitiamo qui a richiamarne la forma, che utilizza una coppia di doppi apici:

 

  (4.2.7)                   “abcdef”,              “esempio di stringa molto lunga”

 

 ma l’argomento potrà essere approfondito solo più avanti, con una più generale trattazione delle stringhe.


 

            4.3 - Costanti simboliche e metaistruzione #define

 

                Le costanti vengono di norma utilizzate in modo letterale, ossia scritte esplicitamente nel contesto in cui esse sono utilizzate; ciò può però risultare poco conveniente. Se ad esempio in un programma é necessario utilizzare il valore di p in diverse occasioni, in ognuna di esse é possibile scriverne esplicitamente la sequenza di decimali fino alla precisione voluta, ma tale soluzione noiosamente ripetitiva espone al rischio di possibili errori di digitazione, banali, ma assai difficili da scoprire.

 

                Una soluzione alternativa é l’impiego di una variabile: ad esempio ponendo:

 

 (4.3.1)                    double pigreco = 3.14159265358979 ;

ma questo é concettualmente poco accettabile, perché p non é una variabile, il cui valore potrebbe venire modificato, magari accidentalmente, nel corso del programma.

 

                La vera soluzione é fornita dalla metaistruzione #define, da impiegare nella forma:

 

  (4.3.2)                   #define  oggetto_definito   testo_definente

 

ove la separazione tra i tre elementi é data da almeno una spaziatura, e nello stesso ruolo sono ammesse spaziature multiple o tabulazioni; ne segue che la stringa di caratteri oggetto_definito non può contenere spaziature, mentre quelle eventualmente comprese in testo_definiente ne sono considerate parte integrante.

 

                La metaistruzione #define può dare luogo a casi molto complessi; il più semplice é quello in cui l’oggetto definito é un nome costruito esattamente come quelli delle variabili, mentre il testo che definisce é una semplice costante; ad esempio:

 

  (4.3.3)                   #define  PIGRECO  3.14159265358979

                               #define  MASSIMO  20

 

di cui la prima evita la ripetizione della sequenza di cifre, ed in ogni caso rende più leggibile il testo del programma, mentre la seconda é presumibilmente una costante destinata a comparire più volte e trattata in questo modo, perché in caso di modifica la variazione deve essere fatta in un posto soltanto.

 

                Un altro vantaggio della definizione delle costanti simboliche è che esse sono vere costanti, ossia non possono essere modificate; se dopo la (4.3.3) in un programma si scrivesse:

 

  (4.3.4)                   MASSIMO = 30 ;

 

ne deriverebbe un errore di sintassi, poiché durante la compilazione il testo si presenterebbe come:

 

  (4.3.5)                   20 = 30 ;

 

che é un errore formale, oltre che un nonsenso.

 

                Poiché tutte le parole chiave del C sono scritte in caratteri minuscoli, é abitudine generale scrivere tutto il testo del programma in minuscole, anche se questo non é in nessun modo un obbligo; la maggior parte degli utenti del C preferisce generalmente i caratteri maiuscoli per elementi che hanno uno scopo speciale, come é appunto il caso per le costanti simboliche.

 

                Aderire o meno a tale convenzione é questione di gusti personali; si deve però tenere conto che essa é in genere seguita in modo consistente nella formulazione dei file di testata che accompagnano la libreria e che contengono una grande quantità di costanti simboliche; si consulti, ad esempio, limits.h, in cui si trovano definizioni come:

 

  (4.3.6)                   #define  INT_MAX            32767

                               #define  UINT_MAX          65535


                Le definizioni date con #define vengono trattate prima della compilazione stessa da una parte speciale del compilatore, detta preprocessore, in una scansione del testo che effettua letteralmente tutte le sostituzioni della sequenza di caratteri in oggetto_definito, a meno che essi compaiano in una stringa tra doppi apici, che non può essere modificata, oppure siano parte di un identificatore; ad esempio come in:

 

  (4.3.7)                   #define  VERO    1

                               ......

                               int veronica ;

 

in cui la sostituzione non avviene.

 

                Le sostituzioni per una #define avvengono dal punto in cui la si incontra in avanti ed é lecito utilizzare in una #define elementi già definiti da una precedente #define. Inoltre, é ammessa la ridefinizione, ossia il medesimo oggetto_definito può comparire anche in una #define successiva, con l’effetto di cambiarne la definizione da quel punto in avanti. Ciò non é raccomandabile come buona pratica di programmazione.

 

                Presentiamo ancora due esempi più complessi di #define per la loro immediata utilità, anche se con questo anticipiamo qualche elemento che verrà presentato nel seguito.

 

  (4.3.8)                   #define  BEEP                     putch ( 7 )

                               #define  ESCAPE               27

                               #define  CLS                       printf ( “%1c]2J”, ESCAPE ) ;

 

che permettono di inserire in un programma istruzioni come:

 

  (4.3.9)                   BEEP ;   CLS ;

 

che hanno rispettivamente l’effetto di attivare l’avvisatore acustico e quello di pulire lo schermo, posizionando in genere il cursore sulla prima colonna della prima riga.

 

                Nella terza delle (4.3.8) é evidente l’impiego della costante ESCAPE definita nella riga immediatamente precedente - si tratta del valore ASCII associato al tasto che ha lo stesso nome; essa é un esempio delle sequenze di escape ANSI per il controllo dello schermo, ed opera correttamente solo se tali norme sono in vigore per lo schermo del sistema che si sta utilizzando.

 

                In genere ciò si ottiene attivando nel sistema operativo uno speciale programma filtro, che nei sistemi MS-DOS si chiama ANSI.SYS; la prova é comunque molto semplice: si sperimenti l’istruzione, e se il risultato é “spazzatura” sullo schermo, vuol dire che tali convenzioni non sono attivate o che esse sono  inaccettabili nel sistema con cui si opera.

 

                Terminiamo questo primo esame della metaistruzione #define con un esempio che prova come essa possa essere utilizzata in modo “contorto”, rendendo logicamente poco chiaro un programma, se non peggio, contrariamente alle intenzioni per cui essa esiste:

 

  (4.3.10) #define  entrambi               a, b

                               .......

                               int a, b ;

                               .......

                               printf ( “%d %d”, entrambi ) ;

 

                Un programma che contenga questa sequenza é corretto, ma é assai discutibile la citazione di a e b prima di averli dichiarati, come pure l’impiego di entrambi come se fosse una variabile, in un contesto che, data la stringa di formato, ne richiede due.

 

                Altre caratteristiche più avanzate di #define, dette definizione di macro, verranno prese in considerazione dopo avere esaminato gli aspetti formali e la tecnica di impiego delle funzioni.


 

 

            4.4 - Espressioni ed operatori

 

 

                Abbiamo già incontrato diversi esempi di espressioni, in generale come secondi membri di assegnazioni; essi si presentano però anche in altri contesti, ad esempio come parametri di funzioni:

 

  (4.4.1)                   double a, b, c, d ;

                               ......

                               a = sqrt ( ( b - 1 ) / ( c + d ) ) ;

 

ed in C nulla impedisce di scrivere righe “insensate” come:

 

  (4.4.2)                   int k1, k2 ;

                               ....

                               k1 + k2 ;

 

in cui la somma viene valutata nel microprocessore, ma non é poi utilizzata in alcun modo. Questo prova però che in C una espressione é una entità logica a sé stante, cosa che permette ad esempio assegnazioni multiple come quella vista in (3.3.4).

 

                In altri linguaggi é comune distinguere tra espressioni aritmetiche, logiche ed eventualmente di altri tipi, ad esempio per stringhe. Nel C, invece, valgono i principi che seguono.

 

  (4.4.3)                   Tutte le espressioni sono aritmetiche, ossia determinano un valore numerico;

                               costituisce quindi una espressione valida ogni combinazione di variabili,

                               costanti, operatori e funzioni aritmetiche che sia ben formata secondo le

                               ordinarie regole algebriche.

 

  (4.4.4)                   La valutazione di un’espressione dipende dall’ordine di precedenza degli

                               operatori in essa impiegati, ordine che può essere modificato dalla presenza

                               esplicita di una coppia di parentesi tonde.

 

                Ne segue che é essenziale definire la lista completa degli operatori e delle loro precedenze, che diamo subito integralmente, anche se molti di essi potranno essere commentati adeguatamente solo più avanti (fonte: QuickHelp del Microsoft C, versione 6.0) :

 

                Alcuni di essi sono comuni in tutti i linguaggi, anche se a volte con diversa rappresentazione grafica, ma la maggior parte sono specifici del linguaggio C, che nella sintassi possiede un numero di operatori molto superiore rispetto ai ‘concorrenti’.

 

  (4.4.5)                   aritmetici :                           +   -   *   /   %

                               logici                                     &&   ||   !

                               relazionali:                          <  <=   >   >=   ==   !=

                               assegnazioni                       =   +=   -=   *=   /=   %=   <<=   >>=   &=   ^=   |=

                               incremento/decremento:   ++   --

                               bit-a-bit                 &   ^   |   <<   >>   ~

                               puntatori                              &   *

                               condizionale                        ?

                               misti                                      ( )   [ ]   .   ->   (tipo)   sizeof

 

 

                Non commentiamo i primi tre gruppi ‘tradizionali’, aritmetici, logici e relazionali, ma ci limitiamo a qualche osservazione sugli altri, quasi tutti caratteristici del solo linguaggio C


 

 (a) - Con le assegnazioni composte é possibile combinare un operatore aritmetico ed un’assegnazione, quando la variabile a primo membro figuri anche all’inizio del secondo membro, come nelle due istruzioni, equivalenti negli effetti, ma con la seconda potenzialmente più efficiente nella traduzione in linguaggio macchina:

 

  (4.4.6)                   x = x + y ;                              x += y ;

 

 

(b) - Un simile potenziale guadagno in efficienza si ha con le due notazioni di incremento e decremento:

 

  (4.4.7)                   k = k + 1 ;                            k++ ;                      oppure                  ++k ;

                               n = n - 1 ;                              n-- ;                        oppure                   --n ;

 

che nelle due forme si dicono rispettivamente postincremento e preincremento, postdecremento e predecremento; tra i due casi non vi é differenza quando si ha una istruzione simile alle (4.4.7), in cui figura la sola richiesta di incremento, poiché alla fine dell’esecuzione la variabile interessata risulta comunque aumentata (o diminuita) di una unità.

 

                La differenza si ha invece quando tale notazione compare in un contesto più complesso, che richiede l’impiego della variabile anche per qualche altro scopo: nelle varianti post, la variabile viene utilizzata dopo  avere subito la variazione e prima nelle varianti pre.

 

                Ad esempio in:

 

  (4.4.8)                   int a, b = 2, c, d = 5 ;

                               .....

                               a = b++ ;   c = ++d ;

 

alla fine delle operazioni risulta a = 2, b = 3 , c = 6 e d = 6.

                              

 

(c) - Per gli operatori bit a bit il maggiore elemento di interesse sta nella possibilità che essi danno di operare direttamente sui singoli bit di un byte, di cui altri linguaggi dispongono solo in qualche caso e solo come opzioni aggiunte di libreria.

 

                Poter agire sui singoli bit con le operazioni logiche fondamentali e con i due tipi di shift permette di disporre di alcune importanti operazioni, che normalmente richiedono l’impiego di un linguaggio Assembler, senza uscire dal contesto di un linguaggio pienamente strutturato ad alto livello; esse sono probabilmente un motivo non secondario del rapido successo che fin dall’inizio il linguaggio C ha avuto tra  i progettisti di sistemi operativi e di compilatori.

 

 

(d) - Dei puntatori diciamo soltanto che essi permettono di trattare gli indirizzi di memoria direttamente nelle istruzioni del programma in modo analogo alle normali variabili, assegnandoli come valori a nomi simbolici ed operando aritmeticamente su di essi, e ciò che più conta, questo é possibile indipendentemente dal modo in cui una particolare macchina o sistema operativo tratta gli indirizzi, cioè da quanti byte utilizza allo scopo e da come li gestisce.

 

                I due operatori sono detti di indirizzo o riferimento il primo (&) e di indirezione o deferimento il secondo (*) ; la loro disponibilità ha contribuito al successo del linguaggio C in modo determinante; per l’approfondimento é però necessario rinviare al capitolo dedicato all’argomento.

 

 

(e) - L’operatore condizionale é un’altra caratteristica specifica del C; esso permette di esprimere in forma molto sintetica una alternativa esclusiva regolata da una condizione logica; la sua sintassi é:

 

  (4.4.9)                   ( espressione_1 )  ?  espressione_2  :  espressione_3 ;


 

con il significato:

 

  (4.4.10) valutare espressione_1 (le parentesi sono obbligatorie) ; se essa risulta vera,

                               cioè non nulla, eseguire espressione_2, altrimenti eseguire espressione_3.

 

                Poiché come già accade per le assegnazioni, e come visto con la (4.4.2), l’intera (4.4.9) é considerata una espressione, come se vi fosse una implicita parentesi che la racchiude; sono molto comuni forme come la seguente:

 

  (4.4.11) a = ( x < y )  ?  2 : 3 ;

 

da cui la variabile a ‘emerge’ con il valore 2 o 3, a seconda che la relazione logica x < y risulti vera o falsa; per la ricchezza delle espressioni in C (si ricordi che è tale, ad esempio, una intera assegnazione) le costruzioni cui la (4.4.9) può dare luogo, possono divenire anche molto complesse.

 

                Non si deve però dimenticare una regola generale sulle strutture di istruzioni troppo complesse, che risultano spesso agevolate dalla sintassi del C: costruirle  può essere anche conveniente per l’efficienza del programma in fase di esecuzione, ma é sicuramente poco o per nulla raccomandabile per la chiarezza della sua logica e per la sua leggibilità, specialmente importanti ai fini della ricerca degli errori o se per qualche motivo un programma dovesse richiedere modifiche sostanziali.

 

 

(f) - Per quel che riguarda gli operatori misti, ci soffermeremo su di essi nei contesti in cui divengono significativi.

 

 

                La ricchezza di operatori del C e la potenziale grande complessità delle espressioni rende molto importante un problema che per i linguaggi di programmazione è pressoché scontato, perché rinviato quasi integralmente alle regole associative e di precedenza dell’algebra. La tabella che segue riassume le regole di precedenza, dalla più alta alla più bassa:

 

  (4.4.6)

                -1-           ++ (post)                -- (post)                  ( )                            [ ]                            ->                            .

                -2-           ++ (pre) -- (pre)                   !                              ~                             + (unario)

                               - (unario)              & (riferimento)   * (deferimento)    sizeof                     (tipo)

                -3-           * (prodotto)           / (divisione)          % (modulo)

                -4-           + (binario)            - (binario)

                -5-           <<                           >>

                -6-           <                             <=                           >                             >=

                -7-           ==                           !=

                -8/13-     &                            ^                             |                              &&                        ||                             ?:

                -14-        = (assegnaz)         *=                           /=                            %=                        +=                           -=

                               <<=                        >>=                        &=                         ^=                          |=

                -15-        ,

 

                Per brevità nella riga -8/13- sono stati raggruppati sei elementi in reale ordine gerarchico; per  completare la tabella manca solo la regola di associatività, che stabilisce la precedenza a parità gerarchica: di norma essa è da sinistra a destra, con l’eccezione degli operatori che figurano nei gruppi -2-, -13- (il solo ?:) e -14-, in cui é rovesciata, da destra a sinistra.

 

                La tabella (4.4.6) risolve completamente le ambiguità sull’ordine di precedenza delle operazioni che figurano in espressioni comunque complicate; in linea con altre osservazioni già fatte, si dovrebbe però evitare di mettersi in condizione di doverla utilizzare.


 

            4.5 - I puntatori - definizioni ed operazioni

 

 

                Il C definisce nella propria sintassi un tipo particolare di oggetto logico, detto puntatore; si tratta di un dato capace di rappresentare un indirizzo della memoria di lavoro della macchina. Dicendo ‘dato’ si intende che un programma può disporre delle relative informazioni in forma simbolica, esattamente come per ogni altro tipo di dato ‘ordinario’,

 

                Le architetture di macchina ed il modo di gestirne le risorse fisiche con il sistema operativo possono cambiare, anche di molto, da un caso all’altro. Ne segue che sono possibili molte soluzioni per il controllo degli indirizzi della memoria di lavoro.

 

                Al momento la maggior parte del microprocessori opera con registri di 4 byte, pari a 32 bit e capaci di rappresentare un numero intero che, se privo del segno, può assumere un valore superiore a 4.200.000.000; se i byte della memoria vengono numerati progressivamente secondo un modello lineare (semplice numerazione consecutiva) il contenuto di un registro permette di distinguere tra altrettante posizioni di memoria.

 

                Si potrebbe quindi supporre che questo sia il metodo di indirizzamento universale, e che un dato capace di rappresentare un indirizzo di memoria sia quindi un intero di 4 byte privo di segno. Questo però non é vero, in particolare per le versioni correnti di MS.DOS, in cui si utilizzano effettivamente 4 byte, ma in uno schema complesso detto di segmento ed offset (o scostamento),  che sono una coppia di interi privi di segno di 2 byte da trattare in una combinazione particolare; il modo di trattare la memoria viene detto in questo caso modello segmentato.

 

                Sono possibili anche altri schemi: ad esempio nulla impedisce ad un costruttore di utilizzare un insieme di 3 o 5 byte per formare un indirizzo, anche se questa é una soluzione poco razionale per una macchina basata su informazioni binarie.

 

                Nella gestione degli indirizzi di memoria con un linguaggio ‘universale’, quale deve essere il C, l’utente non deve in alcun modo essere interessato all’effettiva implementazione degli indirizzi, ossia tutto ciò che li riguarda deve essere formulato senza supporre nulla sul numero di byte necessario per costruirne uno e sul tipo di operazioni aritmetiche con cui essi vengono elaborati.

 

                La sintassi deve cioè solo fornire strumenti formali, la cui effettiva traduzione in termini compatibili con il sistema su cui si sta operando è compito del compilatore, che deve quindi essere costruito ‘su misura’ per il sistema stesso; ciò riguarda però il costruttore e non l’utente.

 

                Nella tabella (4.4.5) gli operatori disponibili per la gestione degli indirizzi sono due:

 

  (4.5.1)                   &            operatore di indirizzo o di riferimento

                               *             operatore di indirezione o di deferimento

 

dei quali il primo deve essere utilizzato in un contesto soltanto, mentre il secondo é presente in due contesti distinti con significati ben diversi, cosa che può all’inizio generare qualche confusione.

 

  (4.5.2)                   Se al nome di una variabile viene premesso l’operatore di riferimento ‘&’, il valore

                               della espressione che ne risulta é l’indirizzo di memoria della variabile, espresso

                               in forma adeguata al sistema in cui il programma é compilato.

 

                Quindi, se un programma contiene la dichiarazione:

 

  (4.5.3)                  int dato ;

 

l’espressione detta é

 

  (4.5.4)                     &dato


 

                Si osservi però che l’espressione in (4.5.4) non é un nuovo identificatore, ma semplicemente un valore di tipo particolare, disponibile per essere utilizzato in un contesto adeguato.

 

  (4.5.5)                   Un puntatore é un identificatore creato dall’utente con dichiarazioni simili a

                               quelle delle variabili ordinarie, ma adatto a contenere come valore un indirizzo

                               di memoria; per assumere tale natura, in fase di dichiarazione all’identificatore

                               si deve premettere l’operatore ‘*’.

 

                Vale a dire, la dichiarazione deve avere una forma come:

 

  (4.5.6)                   int *punt ;

 

con cui punt diviene il nome di una variabile ‘speciale’, che ha le caratteristiche dette ed è disponibile nel programma per le operazioni previste per gli indirizzi; ad esempio:

 

  (4.5.6)                   double x, *px ;

                               ......

                               px = &x ;

 

costituisce un caso di impiego corretto dei puntatori, anche se al momento é difficile immaginare a cosa serva una simile informazione.

 

                Le convenzioni date permettono di trattare gli indirizzi in modo astratto, senza riferimento alla forma effettiva dell’indirizzo in macchina; é però interessante che la dichiarazione di un puntatore, o variabile atta a contenere un indirizzo, sia simile a quella di una variabile ordinaria, magari associandole entrambe in una sola istruzione dichiarativa, come in (4.5.6).

 

  (4.5.7)                   Un puntatore é sempre dichiarato in riferimento ad un tipo; l’informazione ad

                               esso associata ha due aspetti: (a) l’indirizzo di memoria e (b) il tipo logico di

                               oggetto cui l’indirizzo si riferisce. Ciò determina quanti byte debbono essere

                               considerati a partire dall’indirizzo stesso e con quali modalità operative.

 

  (4.5.8)                   E’ ammessa una eccezione: è possibile utilizzare la parola chiave void come tipo

                               per un puntatore; esso non é però disponibile per operazioni di tipo generale se

                               non ne viene ridefinito il tipo tramite un operatore di conversione, o cast nella

                               forma (tipo) puntatore .

 

                E’ difficile giustificare ora il motivo della eccezione in (4.5.8), che deriva da esigenze di una certa complessità che riguardano soprattutto una gestione avanzata delle funzioni; il piccolo programma di esempio riportato più avanti dovrebbe però chiarirne perlomeno il meccanismo.

 

                Inoltre, non si deve identificare la parola chiave void con un nome di tipo di dato; essa trova invece applicazioni in più contesti, ma i dettagli debbono ancora essere rinviati alla trattazione delle funzioni.

 

  (4.5.9)                   Tramite un puntatore é possibile avere accesso all’indirizzo che esso contiene,

                               premettendo ad esso il simbolo ‘*’ di indirezione, o deferimento; tale impiego

                               del simbolo é riservato alle istruzioni di tipo operativo.

 

                In altre parole, l’operatore ‘*’ premesso ad un puntatore lo rende equivalente ad una ordinaria variabile, ed utilizzabile ovunque essa sia accettabile; ad esempio in:

 

  (4.5.10) int k, *pk ;

                               ....

                               pk = &k ;

                               *pk = 5 ;


 

l’effetto dell’ultima riga é del tutto equivalente a quello dell’assegnazione:

 

  (4.5.11) k = 5 ;

 

ma é evidente che non é probabile che le notazioni e le convenzioni sui puntatori siano state introdotte per costruire simili ‘contorsionismi’, ma per un diverso tipo di utilizzazioni, in cui essi sarebbero poco o per nulla sostituibili.

 

                E’ ora evidente cosa si intendeva accennando ad un operatore presente in due modi diversi: si tratta di ‘*’, ma non é mai possibile l’ambiguità tra le istruzione dichiarative, che autorizzano l’impiego di una variabile di tipo puntatore e quelle operative, che la utilizzano.

 

                Una ragione della grande utilità dei puntatori sta nella possibilità di modificarli con operazioni aritmetiche; ne esistono due, di cui la seconda di impiego poco frequente; esse sono:

 

  (4.5.12) (a) - la somma di un puntatore con un valore intero (eventualmente negativo);

                               il puntatore risulta modificato per un numero di byte pari al valore dell’intero,

                               moltiplicato per la misura della unità di memorizzazione del tipo di dato per

                               cui il puntatore é stato dichiarato.

                               (b) - la differenza tra due puntatori, che ha come risultato un valore intero.

 

                Risulta evidente dalla (a) l’importanza di dichiarare il puntatore ad un tipo (non void), poiché in caso contrario mancherebbe una informazione necessaria per i calcoli.

 

                Consideriamo un esempio per il solo primo caso:

 

  (4.5.13) int a, b, *pp ;

                               ......

                               pp = &a ;

                               pp = pp + 1 ;          /*  oppure,   pp++ ;   */

                               *pp = 5 ;

 

al puntatore pp viene assegnato l’indirizzo della variabile a, che essendo di tipo intero ha come misura associata 2 byte; nell’incremento di una unità di memorizzazione pp aumenta quindi numericamente di due, e l’assegnazione del valore costante 5 viene fatta non ad a, bensì ai due byte che lo seguono in memoria, che forse sono stati assegnati a b, ma nulla lo garantisce. Il risultato finale é quindi quello di ‘sporcare’ una posizione di memoria che potrebbe non avere informazioni utili, ma potrebbe anche averne di essenziali per il programma, quindi una operazione comunque logicamente scorretta.

 

                Questo esempio, anche se non può dare un’idea della grande capacità operativa dei puntatori, illustra invece bene i pericoli in essi intrinseci; non esiste modo per il compilatore, né per altri strumenti di controllo attivabili durante l’esecuzione, di rilevare un errore logico come quello contenuto nel segmento di programma in (4.5.13).

 

 

                Nel file <stdio.h> é definita una particolare costante simbolica per puntatori, NULL, detta puntatore nullo, che é in genere privo di senso ai fini operativi. Molte funzioni di libreria utilizzano puntatori, specialmente per le stringhe e le operazioni di lettura/scrittura su dispositivi esterni; un errore nelle operazioni della funzione viene spesso segnalato dal sistema tramite un puntatore nullo; é quindi possibile utilizzare la costante suddetta come criterio di verifica.

 

                Come preannunciato, presentiamo un semplice esempio di programma che illustra le operazioni sui puntatori:


 

  (4.5.14)

      /*    T E S T P U N T . C  -  test di puntatori     */

 

      #include <stdio.h>

 

      main ( )

      {

         int i1 = 4, i2 = 7, *ip ;

 

            printf ( "\n ----   valori iniziali   ---" ) ;

            printf ( "\n i1 / indirizzo : %5d,  %p ", i1, &i1 ) ;

            printf ( "\n i2 / indirizzo : %5d,  %p ", i2, &i2 ) ;

            printf ( "\n ----   modifica puntatori   ---" ) ;

            ip = & i2 ;

            *ip = 2 ;

            printf ( "\n i2 / indirizzo : %5d,  %p ", i2, ip ) ;

            ip++ ;

            *ip = 6 ;

            printf ( "\n i1 / indirizzo : %5d,  %p ", i1, ip ) ;

      }

 

                Abbiamo utilizzato un’operazione come quella descritta in (4.5.13) come da evitarsi sempre; in questo caso un test preliminare, fatto con la sola prima parte del programma aveva mostrato che nelle condizioni dette il compilatore predispone la collocazione in memoria prima della variabile definita per seconda (i2), poi di quella definita per prima.(i1), quindi il test era ragionevolmente sicuro.

 

                Poichè la prova é stata condotta in ambiente MS-DOS, la scrittura degli indirizzi ottenuta con la opzione ‘%p’ li mostra nella forma ‘nativa’, come i due valori esadecimali di segmento (fisso) ed offset (variabile), che nei due casi ha appunto una differenza di due.

 

                Il commento dettagliato della composizione delle stringhe di formato, in particolare delle opzioni ‘%’ é rinviato al capitolo successivo; segue invece l’immagine di quanto l’esecuzione del programma permette di osservare sullo schermo, avvertendo che se il programma viene riprodotto e ricompilato, i valori esadecimali degli indirizzi cambieranno, poichè essi sono funzione dello stato globale della macchina al momento della esecuzione del programma.

 

(4.5.15)

      ----   valori iniziali   ---

      i1 / indirizzo :     4,  0E92:0EDA

      i2 / indirizzo :     7,  0E92:0ED8

      ----   modifica puntatori   ---

      i2 / indirizzo :     2,  0E92:0ED8

      i1 / indirizzo :     6,  0E92:0EDA

 

                Per gli indirizzi, in questo caso importa osservare le sole seconde parti: il numero 0ED8 per i1 e 0EDA per i2, la cui differenza é 2, ossia proprio la lunghezza della variabile i2, che è memorizzata per prima nella sequenza crescente degli indirizzi.