Pellegrino Principe
© Apogeo - IF - Idee editoriali Feltrinelli s.r.l.
Socio Unico Giangiacomo Feltrinelli Editore s.r.l.
Il presente file può essere usato esclusivamente per finalità di carattere personale. Tutti i
contenuti sono protetti dalla Legge sul diritto d’autore.
Nomi e marchi citati nel testo sono generalmente depositati o registrati dalle rispettive case
produttrici.
Snippet
CON_NetBeans
— Linux
• [nome cartella del progetto; es. 2.1]
• ...
— Windows
• [nome cartella del progetto; es. 2.1]
• ...
SENZA_NetBeans
• [nome file .c dello snippet; es. 2.1.c]
• ...
Il linguaggio C è ancora oggi, nonostante siano passati all’incirca quarant’anni dalla sua
prima apparizione, uno straordinario linguaggio di programmazione; ricco, espressivo,
potente e flessibile. Esso ha influenzato moltissimi altri linguaggi di programmazione che
hanno preso in “prestito” molti aspetti della sua filosofia, della sua sintassi e dei suoi
costrutti principali. Tra questi è sufficiente citare alcuni dei maggiori linguaggi mainstream
come Java, C# e, naturalmente, C++ che, per certi versi è un C “potenziato” con l’astrazione
della programmazione orientata agli oggetti e della programmazione generica attraverso il
meccanismo dei template.
NOTA
Anche se C++ è spesso definito come un superset del linguaggio C, è comunque un
linguaggio “diverso” che ha sia delle caratteristiche aggiuntive (per esempio, fornisce dei
costrutti propri della programmazione a oggetti) sia, per alcuni costrutti, delle regole diverse
(per esempio, non consente di assegnare un puntatore a void a un puntatore a un altro tipo
senza un opportuno cast). Tuttavia, imparare C permetterà di padroneggiare C++ almeno
nei suoi aspetti essenziali e nei suoi costrutti basici; infatti, con la dovuta attenzione e senza
l’utilizzo delle astrazioni proprie di C++, un programma scritto in C potrà compilarsi senza
problemi con un compilatore C++.
Allo stesso tempo, però, tali linguaggi hanno anche “eliminato” alcuni aspetti di C che
sono complessi, a basso livello e richiedono una certa attenzione e conoscenza come per
esempio i puntatori che sono, in breve, un potente meccanismo attraverso il quale è
possibile accedere in modo diretto alla memoria per leggerla e manipolarla.
In buona sostanza linguaggi come Java, C# e così via, se è vero che da un lato hanno reso
la scrittura del software più “controllata” e “sicura” è anche vero che, dall’altro lato, l’hanno
resa maggiormente vincolata e meno permissiva in termini di libertà operativa ed
espressiva. In definitiva, il programmatore ha sì meno margini di errore, ma ha anche più
lacci e vincoli.
In ogni caso, giova da subito dire che è proprio la totale libertà di azione offerta dal
linguaggio C che ne rappresenta la sua grande forza e, perché no, lo straordinario fascino
che continua a riscuotere sui programmatori sia alle prime armi sia in quelli più esperti. In
sostanza C dà grande responsabilità al programmatore e assume che questi sappia cosa sta
facendo e sia consapevole degli effetti delle sue azioni.
C, come detto, malgrado l’avanzata di altri linguaggi di programmazione, è, in modo
netto e assoluto, uno straordinario e moderno strumento di programmazione con il quale è
possibile scrivere programmi per i più svariati dispositivi hardware, da quelli altamente
performanti e multiprocessore a quelli embedded dove, in taluni casi, le risorse di memoria
e la potenza della CPU sono limitate.
TERMINOLOGIA
Per sistema embedded si intende un sistema elettronico che ha del software e
dell’hardware “incorporati” deputati a eseguire in modo esclusivo un task o una serie di task
per i quali il sistema è stato ideato e progettato. In pratica, è pensabile come un sistema
hardware/software dedicato e non general-purpose come i comuni PC, che può essere
indipendente oppure parte di un sistema più grande. Un sistema embedded ha,
generalmente, i seguenti principali componenti incorporati: dell’hardware simile a quello di
un computer; il software dell’applicazione principale; un sistema operativo real time (RTOS).
In più si caratterizza anche per dei limiti o vincoli che possono influire sulla scelta della
progettazione e del design, quali la quantità di memoria e la potenza del processore
disponibili, le dimensioni richieste, i costi di produzione e così via. I sistemi embedded
trovano applicazione in svariati settori, come quelli delle telecomunicazioni e del networking
(router, firewall...), della medicina, dei satelliti, delle automobili (motor control system, cruise
control...), dell’elettronica digitale di consumo (set top box, fotocamere digitali, lettori MP3...)
e via dicendo.
I lavori sullo standard terminarono nel dicembre del 1989 e produssero la specifica
X3.159-1989, riferita anche come C89 o ANSI C, che standardizzava, per l’appunto, sia il
core del linguaggio C sia una serie di funzionalità o API (Application Programming
Interface) che andavano a costituire il core di una libreria di base.
Questo standard fu poi, nel 1990, approvato e ratificato dall’ISO (International
Organization for Standardization) un’organizzazione indipendente non governativa,
composta da membri provenienti da oltre 160 paesi, che produce standard internazionali per
svariati settori (agricoltura, informatica, sanità e così via). Il documento di specifica
prodotto dall’ISO fu denominato ISO/IEC 9899:1990 (è riferito anche come C90 o ISO C, e
rispetto alla specifica C89 non ha alcuna differenza se non per la diversa formattazione).
Successivamente, l’ISO diffuse documenti con correzioni ed emendamenti denominati
rispettivamente ISO/IEC 9899/COR1:1994, ISO/IEC 9899/AMD1:1995 (questo riferito
anche come C95) e ISO/IEC 9899/COR2:1996, cui seguì nel 1999 la pubblicazione di un
nuovo documento di specifica del linguaggio C, denominato ISO/IEC 9899:1999 e riferito
come C99, che introdusse molte importanti aggiunte al linguaggio sia nel core (variable-
length arrays, flexible array members, restricted pointers, compound literals, designated
initializers, inline functions, extended identifiers e così via) sia nella libreria standard
(header <complex.h>, <stdbool.h>, <tgmath.h> e così via).
Seguirono, quindi, negli anni successivi i documenti correttivi ISO/IEC 9899:1999/Cor
1:2001, ISO/IEC 9899:1999/Cor 2:2004 e ISO/IEC 9899:1999/Cor 3:2007, che
culminarono nella creazione del nuovo e attuale standard denominato ISO/IEC 9899:2011 e
riferito come C11, che ha apportato ulteriori migliorie al linguaggio (anonymous structures,
anonymous unions, type-generic expressions e così via) e alla libreria standard attraverso,
soprattutto, gli header <stdatomic.h> e <threads.h> per il supporto alla programmazione
concorrente.
C99 E C11
Lo standard C99 ha indubbiamente introdotto innumerevoli modifiche al linguaggio C che
l’hanno migliorato in molti aspetti e, per certi versi, l’hanno “avvicinato” a C++ con l’aggiunta di
caratteristiche come le funzioni inline, i commenti a singola riga tramite i caratteri //, le
dichiarazioni di variabili inseribili in qualsiasi punto di un blocco di codice e così via. Tuttavia, il
supporto da parte dei vendor dei compilatori alle nuove caratteristiche è stato molto lento, e a
oggi vi sono ancora alcuni di essi che le hanno implementate solo parzialmente. Ecco,
dunque, che nel documento di specifica C11, per rendere meno difficoltoso l’adozione di tale
standard, sono state esplicitate alcune caratteristiche del linguaggio che i vendor dei
compilatori possono implementare solo se lo desiderano o se lo ritengono essenziale per il
loro ambiente target (in pratica, il supporto ad alcune caratteristiche come, per esempio,
quelle relativa al multithreading o ai numeri complessi sono opzionali). In ogni caso devono
essere implementate delle macro come __STDC_NO_VLA__, __STDC_NO_COMPLEX__, __STDC_NO_THREADS__
e così via, che consentono di verificare se quella particolare feature è supportata o meno. Per
esempio, se la macro __STDC_NO_THREADS__ ha come valore 1, ciò indicherà che l’attuale
compilatore non supporta l’header <threads.h> e, dunque, la programmazione concorrente.
Caratteristiche
C, come in più parti già evidenziato, è uno straordinario e moderno linguaggio di
programmazione contraddistinto dalle seguenti caratteristiche.
Efficienza. Come in precedenza detto, C è stato ideato, tra le altre cose, anche per
soppiantare l’assembly come linguaggio di programmazione a basso livello per lo
sviluppo del sistema operativo Unix. Esso, infatti, tende a produrre codice compatto e
con elevata velocità di esecuzione, quasi quanto quella di codice prodotto da un
assemblatore, che ben si adatta al sistema hardware per il quale il relativo compilatore
è stato progettato e implementato.
Portabilità. C fa della portabilità uno dei suoi massimi punti di forza. Il linguaggio è
portabile perché un sorgente scritto secondo lo standard C e con la libreria di funzioni
standard può essere compilato senza particolari problemi su altre piattaforme hardware
che hanno il relativo compilatore. C, infatti, si è talmente diffuso che è possibile
trovare compilatori per i più svariati dispositivi hardware, dai sistemi embedded con
poca memoria e poca capacità di calcolo ai sofisticati e potenti supercomputer.
Potenza. C è tra i pochi linguaggi di programmazione che consentono di manifestare
una reale potenza, sia espressiva, grazie a pochi e mirati costrutti sintattici, sia
algoritmica, grazie alla presenza di determinati operatori che permettono l’accesso e la
manipolazione dei singoli bit della memoria.
Flessibilità. A oggi non esiste praticamente alcun dominio applicativo che C non possa
coprire. Esso è, infatti, utilizzato per sviluppare qualsiasi tipologia di software, come
compilatori, sistemi operativi, driver hardware, motori per grafica 3D, editor di testi,
player musicali, videogame e così via. Da questo punto di vista C si è “evoluto” da
mero linguaggio di programmazione di sistema qual era ab origine a vero e proprio
linguaggio di programmazione general-purpose.
Permissività. Rispetto ad altri linguaggi di programmazione, C è stato pensato per
essere un linguaggio per programmatori e pertanto lascia agli stessi una grande libertà
di costruzione dei programmi con gli strumenti offerti (si pensi ai puntatori) senza
fornire, quindi, soprattutto in fase di compilazione, una cattura e un rilevamento di
determinati tipi di errori. In pratica C dà piena fiducia e grande responsabilità al
programmatore; presuppone che lo stesso, se sta facendo una cosa, sappia a cosa va
incontro e sia in grado di valutarne gli effetti.
NOTA
I compilatori moderni sono oggi in grado di effettuare un check del sorgente alla ricerca sia
dei comuni errori in violazione delle regole sintattiche sia di altri tipi di errori, evidenziati
tramite dei messaggi di diagnostica o warning, che nonostante non producano una mancata
compilazione del sorgente potrebbero essere fonte di problemi durante l’esecuzione del
programma. Per esempio, con GCC possiamo usare l’opzione -Warray-bounds per ottenere un
warning se accediamo a un indice di un array fuori dal suoi limiti, -Waddress per ottenere un
warning per utilizzi sospetti degli indirizzi di memoria, -Wreturn-type per ottenere un warning
se, per esempio, in una funzione con un tipo di ritorno non void si scrive un’istruzione di
return senza un valore di ritorno o si omette di scriverla e così via per altri warning.
La CPU
La CPU (Central Processing Unit) è il “cervello” di ogni computer ed è deputata
principalmente a interpretare ed eseguire le istruzioni elementari che rappresentano i
programmi e a effettuare operazioni di coordinamento tra le varie parti di un computer.
Questa unità di processing, dal punto di vista fisico, è un circuito elettronico formato da
un elevato numero di transistor (varia da diverse centinaia di milioni fino ai miliardi delle
più potenti attuali CPU) che sono ubicati in un circuito integrato (chip) di ridotte dimensioni
(pochi centimetri quadrati).
Dal punto di vista logico, invece, una CPU è formata dalle seguenti unità (Figura 1.4).
ALU (Arithmetic Logic Unit), ossia l’unità aritmetico-logica. Quest’unità è costituita
da un insieme di circuiti deputati a effettuare calcoli aritmetici (addizioni, sottrazioni e
così via) e operazioni logiche (boolean AND, boolean OR e così via) sui dati. Il suo
funzionamento si può così sintetizzare ponendo, per esempio, un’operazione di somma
tra due numeri: preleva da appositi registri di memoria di input gli operandi trasmessi
(diciamo X e Y); effettua l’operazione di addizione (X + Y); scrive il risultato della
somma in un registro di memoria di output. In pratica possiamo vedere una ALU come
una sorta di calcolatrice “primitiva” che riceve dati sui quali effettuare calcoli al fine di
produrre un risultato.
Control Unit, ossia l’unità di controllo. Questa unità coordina e dirige le varie parti di
un calcolatore in modo da consentire la corretta esecuzione dei programmi. In pratica
essa, in una sorta di ciclo infinito, preleva (fetch), decodifica (decode) ed esegue
(execute) le istruzioni dei programmi. Attraverso la fase di prelievo acquisisce
un’istruzione dalla memoria, la carica nel registro istruzione e rileva la successiva
istruzione da prelevare. Attraverso la fase di decodifica interpreta l’istruzione corrente
da eseguire. Attraverso la fase di esecuzione esegue ciò che l’istruzione indica: è
un’azione di input? Allora comanda l’unità di input relativa al trasferimento dei dati
nella memoria centrale. È un’azione di output? Allora comanda l’unità di output
relativa al trasferimento dei dati dalla memoria centrale verso l’esterno. È un’azione di
processing dei dati? Allora comanda il trasferimento dei dati nell’ALU, comanda
l’ALU alla loro elaborazione e al trasferimento del risultato nella memoria centrale. È
un’azione di salto? Allora aggiorna il registro contatore di programma con l’indirizzo
cui saltare. Infine, le operazioni svolte dall’unità di controllo sono regolate da un
orologio interno di sistema (system clock) che genera segnali o impulsi regolari a una
certa frequenza, espressa in Hertz, che consentono alle varie parti di operare in modo
coordinato e sincronizzato. Maggiori sono questi segnali per secondo, detti anche cicli
di clock, maggiore è la quantità di istruzioni per secondo che una CPU può processare
e quindi la sua velocità.
Registers, ossia i registri. I registri sono unità di memoria, estremamente veloci
(possono essere letti e scritti ad alta velocità perché sono interni alla CPU) e di una
certa dimensione, utilizzati per specifiche funzionalità. Vi sono numerosi registri, tra
cui quelli più importanti sono: l’Instruction Register (registro istruzione), che contiene
la corrente istruzione che si sta eseguendo; il Program Counter (contatore di
programma), che contiene l’indirizzo della successiva istruzione da eseguire; gli
Accumulator (accumulatori), che contengono, temporaneamente, gli operandi di
un’istruzione e alla fine della computazione il risultato dell’operazione eseguita
dall’ALU.
Figura 1.4 Vista logica dell’interno di una CPU.
La memoria centrale
La memoria centrale, detta anche primaria o principale, è quella parte del computer dove
sono memorizzate le istruzioni e i dati dei programmi.
Essa ha diverse caratteristiche: è volatile perché il contenuto è perso nel momento in cui
il relativo calcolatore è spento; è veloce, ossia il suo accesso in lettura/scrittura può avvenire
in tempi estremamente ridotti e minimi; è ad accesso casuale perché il tempo di accesso alle
relative celle è indipendente dalla loro posizione e dunque costante per tutte le celle (da
questo punto di vista è definita come RAM, che sta per Random Access Memory).
L’unità basica di memorizzazione è la cifra binaria, o bit, che è il composto aplologico
delle parole binary digit. Un bit può contenere solo due valori, la cifra 0 oppure la cifra 1, e
il correlativo sistema di numerazione binario richiede pertanto solo quei due valori per la
codifica dell’informazione digitale.
NOTA
Consultare l’Appendice C per un approfondimento sul sistema di numerazione binario.
La memoria primaria, dal punto di visto logico, è rappresentabile come una sequenza di
celle o locazioni di memoria ciascuna delle quali può memorizzare una certa quantità di
informazione (Figura 1.5). Ogni cella, detta parola (memory word) ha una dimensione fissa
e tale dimensione, espressa in multipli di 8, ne indica la lunghezza, ossia il suo numero di
bit (possiamo avere, per esempio, word di 8 bit, di 16 bit, di 32 bit e così via). Così se una
cella ha k bit allora la stessa può contenere una delle 2k combinazioni differenti di bit.
Inoltre la lunghezza della word indica anche che quella è la quantità di informazione che
un computer durante un’operazione può elaborare allo stesso tempo e in parallelo.
Altra caratteristica essenziale di una cella di memoria è che ha un indirizzo, ossia un
numero binario che ne consente la localizzazione da parte della CPU al fine del reperimento
o della scrittura di contenuto informativo anch’esso binario. La quantità di indirizzi
referenziabili dipende dal numero di bit di un indirizzo: generalizzando, se un indirizzo ha n
bit allora il massimo numero di celle indirizzabili sarà 2n.
Per esempio, la Figura 1.6 mostra tre differenti layout per una memoria di 160 bit
rispettivamente con celle di 8 bit, 16 bit e 32 bit. Nel primo caso sono necessari almeno 5
bit per esprimere 20 indirizzi da 0 a 19 (infatti 25 ne permette fino a 32); nel secondo caso
sono necessari almeno 4 bit per esprimere 10 indirizzi da 0 a 9 (infatti 24 ne permette fino a
16); nel terzo caso sono necessari almeno 3 bit per esprimere 5 indirizzi da 0 a 4 (infatti 23
ne permette fino a 8).
Figura 1.6 Tre differenti layout per una memoria di 160 bit.
In più è importante dire che il numero di bit di un indirizzo è indipendente dal numero di
bit per cella: una memoria con 210 celle di 8 bit ciascuna e una memoria con 210 celle di 16
bit ciascuna necessiteranno entrambe di indirizzi di 10 bit.
La dimensione di una memoria è dunque data dal numero di celle per la loro lunghezza in
bit. Nei nostri tre casi è sempre di 160 bit (correntemente, un computer moderno avrà una
dimensione di 4 Gigabyte di memoria se avrà almeno 232 celle indirizzabili di 1 byte di
lunghezza ciascuna).
Per comprendere quanto detto, consideriamo come viene memorizzato un numero come
2854123 (binario, 00000000001010111000110011101011) in una word di 32 bit secondo
un’architettura big endian e secondo un’architettura little endian (Figura 1.7): nel primo
caso il byte più a sinistra (più significativo) è memorizzato nell’indirizzo 0 e poi a seguire,
da sinistra a destra, gli altri byte negli indirizzi 1, 2 e 3; nel secondo caso il byte più a destra
(meno significativo) è memorizzato nell’indirizzo 0 e poi a seguire, da destra a sinistra, gli
atri byte negli indirizzi 1, 2 e 3.
TERMINOLOGIA
Se vediamo una word come una sequenza di bit (Figura 1.8) piuttosto che come una
sequenza di byte, possiamo altresì dire che essa avrà un bit più significativo (most
significant bit) che sarà ubicato al limite sinistro di tale sequenza (high-order end) e un bit
meno significativo (least significant bit) che sarà ubicato al limite destro sempre della stessa
sequenza (low-order end).
Figura 1.7 Ordinamento della memoria: differenza tra big endian e little endian.
Figura 1.8 Word come sequenza di bit.
Per esempio, se un’applicazione scritta su un sistema SPARC memorizza dei dati binari
in un file e poi lo stesso file è aperto il lettura su un sistema x86, si avranno dei problemi di
congruità perché il file è stato scritto in modo endian-dependent.
Per evitare tale problema si possono adottare vari accorgimenti come, per esempio, quello
di scrivere i dati in un formato neutrale che prevede file testuali e stringhe, oppure adottare
idonee routine di conversione (byte swapping) che, a seconda del sistema in uso, forniscano
la corretta rappresentazione dell’informazione binaria.
Quando si deve scrivere software per diverse piattaforme hardware che hanno sistemi di
endianness incompatibili lo stesso deve essere sempre pensato in modo portabile, non
assumendo mai, dunque, un particolare ordinamento in memoria dei byte.
Paradigmi di programmazione
Un paradigma o stile di programmazione indica un determinato modello concettuale e
metodologico, offerto in termini concreti da un linguaggio di programmazione, al quale fa
riferimento un programmatore per la progettazione e scrittura di un programma informatico
e dunque per la risoluzione del suo particolare problema algoritmico. Si conoscono
numerosi paradigmi di programmazione, ma quelli che seguono rappresentano i più comuni.
Nel paradigma procedurale l’unità principale di programmazione è, per l’appunto, la
procedura o la funzione che ha lo scopo di manipolare i dati del programma. Questo
paradigma è talune volte indicato anche come imperativo, perché consente di costruire un
programma indicando dei comandi (assegna, chiama una procedura, esegui un loop e così
via) che esplicitano quali azioni si devono eseguire, e in quale ordine, per risolvere un
determinato compito. Questo paradigma si basa, dunque, su due aspetti di rilievo: il primo è
riferito al cambiamento di stato del programma che è causa delle istruzioni eseguite (si
pensi al cambiamento del valore di una variabile in un determinato tempo durante
l’esecuzione del programma); il secondo è inerente allo stile di programmazione adottato
che è orientato al “come fare o come risolvere” piuttosto che al “cosa si desidera ottenere o
cosa risolvere”. Esempi di linguaggi che supportano il paradigma procedurale sono
FORTRAN, COBOL, Pascal e C.
Nel paradigma a oggetti l’unità principale di programmazione è l’oggetto (nei sistemi
basati sui prototipi) oppure la classe (nei sistemi basati sulle classi). Questi oggetti,
definibili come virtuali, rappresentano in estrema sintesi astrazioni concettuali degli oggetti
reali del mondo fisico che si vogliono modellare. Questi ultimi possono essere oggetti più
generali (per esempio un computer) oppure oggetti più specifici, ovvero maggiormente
specializzati (per esempio una scheda madre, una scheda video e così via). Noi utilizziamo
tali oggetti senza sapere nulla della complessità con cui sono costruiti e comunichiamo con
essi attraverso l’invio di messaggi (sposta il puntatore, digita dei caratteri) e mediante delle
interfacce (il mouse, la tastiera). Inoltre, essi sono dotati di attributi (velocità del processore,
colore del case e così via) che possono essere letti e, in alcuni casi, modificati. Questi
oggetti reali vengono presi come modello per la costruzione di sistemi software a oggetti,
dove l’oggetto (o la classe) avrà metodi per l’invio di messaggi e proprietà che
rappresenteranno gli attributi da manipolare. Principi fondamentali di tale paradigma sono i
seguenti.
L’incapsulamento, che è un meccanismo attraverso il quale i dati e il codice di un
oggetto sono protetti da accessi arbitrari (information hiding). Per dati e codice
intendiamo tutti i membri di una classe, ovvero sia i dati membro (come le variabili),
sia le funzioni membro (definite anche metodi in molti linguaggi di programmazione
orientati agli oggetti). La protezione dell’accesso viene effettuata applicando ai membri
della classe degli specificatori di accesso, definibili come: pubblico, con cui si
consente l’accesso a un membro di una classe da parte di altri metodi di altre classi;
protetto, con cui si consente l’accesso a un membro di una classe solo da parte di
metodi appartenenti alle sue classi derivate; privato, con cui un membro di una classe
non è accessibile né da metodi di altre classi né da quelli delle sue classi derivate ma
soltanto dai metodi della sua stessa classe.
L’ereditarietà, che è un meccanismo attraverso il quale una classe può avere relazioni
di ereditarietà nei confronti di altre classi. Per relazione di ereditarietà intendiamo una
relazione gerarchica di parentela padre-figlio, dove una classe figlio (definita classe
derivata o sottoclasse) deriva da una classe padre (definita classe base o superclasse) i
metodi e le proprietà pubbliche e protette, e dove essa stessa ne definisce di proprie.
Con l’ereditarietà si può costruire, di fatto, un modello orientato agli oggetti che in
principio è generico e minimale (ha solo classi base) e poi, man mano che se ne
presenta l’esigenza, può essere esteso attraverso la creazione di sottomodelli sempre
più specializzati (ha anche classi derivate).
Il polimorfismo, che è un meccanismo attraverso il quale si può scrivere codice in
modo generico ed estendibile grazie al potente concetto che una classe base può
riferirsi a tutte le sue classi derivate cambiando, di fatto, la sua forma. Ciò si traduce, in
pratica, nella possibilità di assegnare a una variabile A (istanza di una classe base) il
riferimento di una variabile B (istanza di una classe derivata da A) e, successivamente,
riassegnare alla stessa variabile A il riferimento di una variabile C (istanza di un’altra
classe derivata da A). La caratteristica appena indicata ci consentirà, attraverso il
riferimento A, di invocare i metodi di A che B o C hanno ridefinito in modo specialistico,
con la garanzia che il sistema run-time del linguaggio di programmazione a oggetti
saprà sempre a quale esatta classe derivata appartengono. La discriminazione
automatica, effettuata dal sistema run-time di un tale linguaggio, di quale oggetto
(istanza di una classe derivata) è contenuto in una variabile (istanza di una classe base)
avviene con un meccanismo definito dynamic binding (binding dinamico).
Esempi di linguaggi che supportano il paradigma a oggetti sono Java, C#, C++,
JavaScript, Smalltalk e Python.
Nel paradigma funzionale l’unità principale di programmazione è la funzione vista in
puro senso matematico. Infatti, il flusso esecutivo del codice è guidato da una serie di
valutazioni di funzioni che, trasformando i dati che elaborano, conducono alla
soluzione di un problema. Gli aspetti rilevanti di questo paradigma sono: nessuna
mutabilità di stato (le funzioni sono side-effect free, ossia non modificano alcuna
variabile); il programmatore non si deve preoccupare dei dettagli implementativi del
“come” risolvere un problema ma piuttosto di “cosa” si vuole ottenere dalla
computazione. Esempi di linguaggi che supportano il paradigma funzionale sono: Lisp,
Haskell, F#, Erlang e Clojure.
Nel paradigma logico l’unità principale di programmazione è il predicato logico. In
pratica con questo paradigma il programmatore dichiara solo i “fatti” e le “proprietà”
che descrivono il problema da risolvere lasciando al sistema il compito di “inferirne” la
soluzione e dunque raggiungerne il “goal” (l’obiettivo). Esempi di linguaggi che
supportano il paradigma logico sono: Datalog, Mercury, Prolog e ROOP.
Dunque, il linguaggio C supporta pienamente il paradigma procedurale dove l’unità
principale di astrazione è rappresentata dalla funzione attraverso la quale si manipolano i
dati di un programma. Da questo punto di vista, si differenzia dai linguaggi di
programmazione che sposano il paradigma a oggetti come, per esempio, C++ o Java, perché
in quest’ultimo paradigma ci si concentra prima sulla creazione di nuovi tipi di dato (le
classi) e poi sui metodi e le variabili a essi relativi.
In altre parole, mentre in un linguaggio procedurale come C la modularità di un
programma viene fondamentalmente descritta dalle procedure o funzioni che manipolano i
dati, nella programmazione a oggetti la modularità viene descritta dalle classi che
incapsulano al loro interno metodi e variabili. Per questa ragione si suole dire che nel
mondo a oggetti la dinamica (metodi) è subordinata alla struttura (classi).
TERMINOLOGIA
Nei linguaggi di programmazione si usano termini come funzione (function), metodo
(method), procedura (procedure), sotto-programma (subprogram), sotto-routine (subroutine)
e così via per indicare un blocco di codice posto a un determinato indirizzo di memoria che
è invocabile (chiamabile), per eseguire le istruzioni ivi collocate. Dal punto di vista pratico,
pertanto, significano tutte la stessa cosa anche se, in letteratura, talune volte sono
evidenziate delle differenze soprattutto in base al linguaggio di programmazione che si
prende in esame. Per esempio, in Pascal una funzione ritorna un valore, mentre una
procedura non ritorna nulla; in C una funzione può agire anche come una procedura,
mentre il termine metodo non è esistente; in C++ un metodo è una funzionalità “associata”
all’oggetto o alla classe dove è stato definito ed è anche denominato funzione membro.
Concetti introduttivi allo sviluppo
Prima di affrontare lo studio sistematico della programmazione in C, e preliminarmente
all’illustrazione di un semplice programma che ne darà una panoramica generale, appare
opportuno soffermarci su alcuni concetti propedeutici allo sviluppo di C che sono trasversali
al linguaggio e che servono quindi per inquadrarlo meglio nel suo complesso.
NOTA
L’Appendice A contiene un breve tutorial introduttivo sull’utilizzo del debugger GDB.
Il comando cc, è solitamente un alias verso il reale comando di compilazione che può
essere, per esempio, gcc, presente in genere come compilatore di default nei sistemi
GNU/Linux con installata la suite GCC (GNU Compiler Collection) oppure clang, presente
come compilatore di default nei sistemi FreeBSD con installata la suite LLVM (Low Level
Virtual Machine).
Questi comuni compilatori, tuttavia, non sono gli unici utilizzabili in ambienti Unix-like;
è possibile sceglierne altri, quali, solo per citarne alcuni, SAS/C, Amsterdam Compiler Kit,
LCC (Little C Compiler) e PCC (Portable C Compiler).
Per quanto attiene ai sistemi Windows, di default, non sono mai installati né un
compilatore né la relativa suite di strumenti e librerie. È possibile, comunque, usare la suite
GCC tramite i progetti Cygwin e MinGW (Minimalist GNU for Windows), che forniscono
rispettivamente un ambiente runtime Unix/POSIX-like sotto Windows e un ambiente di
compilazione per applicazioni native sotto Windows.
In ogni caso è possibile utilizzare, così come visto per i sistemi Unix, anche altri
compilatori C, quali quello fornito da Microsoft tramite l’installazione dell’IDE Visual
Studio e denominato cl (in questo caso il file oggetto prodotto avrà la stessa basename del
file sorgente ma l’estensione .obj, mentre il file eseguibile avrà la stessa basename del file
sorgente ma l’estensione .exe), Digital Mars e Pelles C.
NOTA
Ricordiamo, come anticipato nella Prefazione, che il compilatore di elezione scelto per la
didattica del corrente testo è quello fornito dalla suite GCC utilizzabile nativamente con il
sistema operativo GNU/Linux. Per il sistema operativo Windows, invece, GCC è utilizzabile,
nel nostro caso, mediante l’installazione dell’environment MinGW. L’Appendice A spiega
come installare e utilizzare GCC sia sotto sistemi GNU/Linux sia sotto sistemi Windows. In
ogni caso verrà anche utilizzato il compilatore cl di Microsoft per evidenziare eventuali
differenze tra differenti implementazioni del linguaggio. Per quanto concerne invece il
sistema target hardware, esso è una piattaforma x86-64, ovvero una versione a 64 bit
dell’instruction set della piattaforma x86 e ciò sia per il sistema GNU/Linux sia per il sistema
Windows (in quest’ultimo caso, comunque, la suite MinGW sarà a 32 bit e ciò per
evidenziare le differenze tra compilatori a 64 bit, come quello del sistema GNU/Linux,
rispetto a quelli a 32 bit).
Il primo programma
Vediamo, attraverso la disamina del Listato 1.1, quali sono gli elementi basilari per
strutturare e scrivere un programma in C, con l’avvertenza che tutte le informazioni
didattiche successivamente enucleate su alcuni costrutti del linguaggio saranno introduttive
ai concetti che tratteranno e giocoforza non dettagliate. Le stesse saranno trattate con
dovizia di particolari nei relativi capitoli di pertinenza.
Abbiamo, in questa sede, infatti, inteso perseguire solamente i seguenti obiettivi:
illustrare una struttura di massima degli elementi costitutivi di un programma in C; fornire
una terminologia basica applicabile ai principali costrutti del linguaggio.
#define MULTIPLICAND 10
#define MULTIPLIER 20
float f; // dichiarazione
f = 44.5f; // inizializzazione
// stampa qualcosa...
printf("%s%s\n", text_1, text_2);
printf("Stampero' un test condizionale tra a=%d e b=%d:\n", a, b);
/*
* ciclo for
*/
for (int i = 0; i < 10; i++)
{
printf("Passo %d ", i);
printf("--> a=%d\n", a);
}
/*
// esce dalla funzione main
*/
return (EXIT_SUCCESS);
}
/****************************************
* Funzione: mult *
* Scopo: moltiplicazione di due valori *
* Parametri: a, b -> int *
* Ritorno: int *
****************************************/
int mult(int a, int b)
{
return a * b;
}
Infine, i commenti multiriga possono essere anche scritti di fianco, a lato di una porzione
di codice (winged comment), come mostra quello definito dopo l’istruzione else, così come
essere scritti in forma di riquadro di contenimento (boxed comment), come mostra quello
scritto prima della definizione della funzione mult, oppure scrivendo degli asterischi * per
ogni riga di separazione, come mostra quello indicato prima del ciclo for.
In ogni caso, a parte le forme indicate di scrittura dei commenti multiriga che sono quelle
più comuni, il programmatore è libero di scegliere la formattazione che più gli aggrada a
condizione, però, che via sia sempre una corrispondenza tra un marcatore di commento
iniziale /* e un marcatore di commento finale */.
C99 ha introdotto, inoltre, un secondo tipo di commento, che è indicato tramite l’utilizzo
di due caratteri slash // cui si fa seguire il testo da commentare. Questo commento, poiché
termina in automatico alla fine della corrispettiva riga, è definito commento a singola riga e
ha l’importante caratteristica che può essere innestato all’interno di un commento multiriga,
come mostra in modo evidente il commento posto prima dell’istruzione return all’interno
della funzione main. In più, anche con questo tipo di commento, è possibile scrivere
commenti multiriga semplicemente scrivendo su ogni riga i caratteri // e la porzione di testo
da commentare. Un esempio di quanto detto è visibile nei primi due commenti posti subito
dopo la parentesi graffa di apertura ({) della funzione main.
DETTAGLIO
Il compilatore quando trova dei commenti, prima della compilazione, li rimuove tutti
sostituendo ciascuno di essi con un carattere di spazio.
includeranno rispettivamente il contenuto del file stdio.h, che fornisce le funzionalità per
effettuare delle operazioni di input/output, e il contenuto del file stdlib.h, che fornisce
funzionalità di carattere generale.
Il contenuto di questi file, detti anche header file, è essenziale per la corretta
compilazione del codice sorgente quando si utilizzano, per l’appunto, le loro funzionalità.
Nel nostro caso è impiegata intensivamente la funzione printf, che visualizza nello
standard output la stringa di caratteri indicata come argomento, la quale è dichiarata nel file
stdio.h che deve, per l’appunto, essere incluso per permetterne l’utilizzo. Lo stesso
La direttiva #define consente di definire, invece, delle macro, cioè degli identificatori che
possono essere utilizzati all’interno di un programma come nomi costanti che il
preprocessore sostituisce con gli effettivi valori collegati. Il programma in esame, dunque,
definisce le macro MULTIPLICAND e MULTIPLIER con i valori relativi di 10 e 20 e, pertanto, quando
il preprocessore le individuerà nel sorgente le sostituirà con i predetti valori.
Abbiamo poi la scrittura o dichiarazione dell’identificatore mult, che rappresenta il
cosiddetto prototipo di funzione con il quale si indicano le sue proprietà quali il tipo di
ritorno e gli eventuali parametri che può processare quando invocata. Nel complesso il
nome di una funzione, il tipo di ritorno e i tipi dei suoi parametri rappresentano il suo
header.
Il prototipo di una funzione è un aspetto fondamentale perché è utilizzato dal compilatore
per verificare la congruità delle relativa definizione (per esempio se i tipi dei parametri
corrispondono) e il suo impiego corretto durante una sua chiamata (per esempio se il tipo di
un argomento fornito è compatibile con il tipo del parametro dichiarato).
Il prototipo di mult specifica che ritorna un tipo di dato intero e accetta due argomenti
sempre di tipo intero. Nell’ambito della funzione main notiamo come mult sia invocata con
due argomenti di tipo intero (i valori 10 e 20) e ritorni un valore di tipo intero assegnato alla
variabile res. La stessa funzione mult ha infatti una sua definizione, ossia una sua
implementazione algoritmica scritta dopo la definizione della funzione main che evidenzia
come ritorni un valore di tipo intero che è il risultato della moltiplicazione tra i parametri a e
b sempre di tipo intero.
La funzione main, invece, è la funzione principale di un qualsiasi programma in C e deve
essere sempre presente perché ne rappresenta l’entry point, ossia il “punto di ingresso”
attraverso il quale viene eseguito. In pratica la funzione main è invocata automaticamente
quando il programma è avviato e poi attraverso di essa vengono eseguite le relative
istruzioni e invocate le altre funzioni eventualmente indicate. Da questo punto di vista un
programma in C è composto sempre almeno da una funzione main e poi da una o più
funzioni ausiliarie; non è altro, quindi, che una collezione di una o più funzioni.
Tale funzione può essere definita con un tipo di ritorno int e con nessun parametro
(keyword void) oppure con un tipo di ritorno int e con due parametri l’uno di tipo int e
l’altro di tipo array di puntatori a carattere (Snippet 1.2 e 1.3).
In pratica quando si utilizza la prima definizione si indica che main non accetta argomenti
dalla riga di comando, mentre quando si utilizza la seconda definizione si esprime la
volontà di processare gli argomenti forniti dalla relativa shell.
NOTA
Scorrendo codice in C è possibile vedere altre due modalità di definizione della funzione
main: main() { /* ... */ } e void main() { /* ... */ }. Tuttavia, anche se il compilatore in uso
può tollerare lo standard C11, esplicita solo quelle indicate negli Snippet 1.2 e 1.3.
Il tipo di ritorno int di main indica un valore numerico che deve essere ritornato al sistema
operativo e che assume per esso un certo significato: per esempio, il valore 0 (espresso dal
nostro programma attraverso la macro EXIT_SUCCESS definita nel file header <stdlib.h>), indica
una terminazione corretta del corrente programma. Nel caso della funzione main, inoltre,
l’istruzione return termina anche l’esecuzione di un programma (spesso, in alcuni
programmi, si trova, al posto dell’istruzione return 0 l’istruzione exit(0), che termina allo
stesso modo l’esecuzione di un programma).
È altresì possibile omettere l’istruzione return dal main, poiché quando il programma
raggiunge la parentesi graffa di chiusura } della funzione viene in automatico ritornato il
valore 0 (in C89 il valore è non definito, in C11 il valore è comunque non definito se il main
è definito con un tipo di ritorno diverso da int).
Per quanto attiene al contenuto della funzione main notiamo subito una serie di istruzioni
che definiscono delle variabili, ossia delle locazioni di memoria modificabili deputate a
contenere un valore di un determinato tipo di dato. Così gli identificatori text_1 e text_2
indicano variabili che possono contenere caratteri, gli identificatori a, b e res indicano
variabili che possono contenere numeri interi e l’identificatore f indica una variabile che
può contenere numeri decimali, cioè numeri formati da una parte intera e una parte
frazionaria separati da un determinato carattere (in C tale carattere è il punto .).
Una variabile, solitamente, può essere prima dichiarata e poi inizializzata (è il caso della
variabile f) oppure può essere dichiarata e inizializzata contestualmente in un’unica
istruzione (è il caso delle altre variabili).
A parte le varie istruzioni di stampa su console dei valori espressi dalle corrispettive
stringhe delle funzioni printf, notiamo l’impiego: di un’istruzione di selezione doppia
if/else che valuta se una data espressione è vera o falsa eseguendone, a seconda del risultato
della valutazione, il codice corrispondente (quello del ramo valutato vero oppure quello del
ramo valutato falso); di un’istruzione di iterazione for che consente di eseguire ciclicamente
una serie di istruzioni finché una data espressione è vera.
Pertanto l’istruzione if/else valuta se il valore della variabile a è minore del valore della
variabile b e nel caso stampa la relativa stringa, altrimenti, in caso di valutazione falsa,
stampa l’altra stringa a esso relativa. L’istruzione for, invece, stampa su console per 10
volte informazioni sul valore delle variabili i e a.
Un “assaggio” di printf
La funzione della libreria standard printf è dichiarata nel file header <stdio.h> e consente
di visualizzare sullo standard output (generalmente a video) il letterale stringa fornitogli
come argomento. All’interno del letterale stringa è possibile inserire degli appositi caratteri
prefissi dal simbolo percento (%), definiti nel complesso come specifiche di conversione o
specificatori di formato, che rappresentano dei segnaposto di un determinato tipo di dato
che saranno sostituiti, in quella precisa locazione, con i valori delle relative variabili fornite
come ulteriori argomenti alla funzione.
Così il segnaposto %d permette di stampare il valore di una variabile come intero in base
10, %f permette di stampare il valore di una variabile con un determinato numero di cifre
dopo il punto decimale di separazione, %s permette di stampare il valore di una variabile
come stringa di caratteri e così via per altri specificatori.
Quando si utilizza la funzione printf è essenziale sapere che essa non stampa altresì i
caratteri di delimitazione della stringa (i doppi apici ") e non avanza in automatico sulla
successiva riga di output al termine della visualizzazione dei suoi caratteri.
Per istruire printf in modo che avanzi alla successiva riga di output, a partire dalla quale
riprendere (oppure iniziare) la visualizzazione di altri caratteri, è possibile utilizzare una
sequenza di escape formata dalla combinazione dei caratteri \n (newline character).
Degli elementi lessicali indicati, tutti, tranne i commenti, sono definiti come token e
rappresentano dunque delle entità minimali significative per il linguaggio C.
La Tabella 1.1 mostra tutte le keyword del linguaggio aggiornate allo standard C11; la
Tabella 1.2 mostra i segni di punteggiatura utilizzabili; lo Snippet 1.4 evidenzia alcuni
identificatori, letterali stringa e costanti.
Tabella 1.1 Keyword del linguaggio C (sono case sensitive e riservate nell’uso).
auto else long switch _Atomic
break enum register typedef _Bool
case extern restrict union _Complex
char float return unsigned _Generic
const for short void _Imaginary
continue goto signed volatile _Noreturn
default if sizeof while _Static_assert
do inline static _Alignas _Thread_local
double int struct _Alignof
int main(void)
{
// identificatori
int number, temp, status;
void foo(void) { /*...*/ }
L’ammontare di caratteri di spaziatura (spazio, tabulazione, invio e così via) atti a fungere
da separazione tra i token non è obbligatorio: ogni programmatore può scegliere quello che
più gli aggrada secondo il suo personale stile di scrittura. Tuttavia un token non può essere
“diviso” senza causare un probabile errore e un cambiamento della sua semantica. Lo stesso
vale per un letterale stringa con il seguente distinguo: al suo interno è sempre possibile
inserire dei caratteri di spazio, ma è un errore separarlo all’interno dell’editor su più righe
mediante la pressione del tasto Invio.
Per quanto riguarda invece le istruzioni, esse rappresentano azioni o comandi che devono
essere eseguiti durante l’esecuzione del programma. In C, ogni statement deve terminare
con il carattere punto e virgola (;) e due o più statement possono essere raggruppate insieme
a formare un’unica entità sintattica, definita blocco, se incluse tra la parentesi graffa aperta
({) e la parentesi graffa chiusa (}).
Compilazione ed esecuzione del codice
Dopo aver scritto il programma del Listato 1.1, con un qualunque editor di testo o con un
IDE di preferenza (per noi l’IDE sarà NetBeans), vediamo come eseguirne la compilazione
che, lo ricordiamo, è quel procedimento mediante il quale un compilatore C legge un file
sorgente (nel nostro caso PrimoProgramma.c) e lo trasforma in un file (per esempio
PrimoProgramma.exe) che conterrà istruzioni scritte nel linguaggio macchina del sistema
hardware di riferimento.
NOTA
Per i dettagli su come eseguire la compilazione di un programma in C, sia in ambiente
GNU/Linux sia in ambiente Windows e senza l’ausilio di alcun IDE, consultare l’Appendice
A.
Alla fase di compilazione segue la fase di esecuzione, nella quale un file eseguibile (nel
nostro caso PrimoProgramma.exe o PrimoProgramma), memorizzato per esempio su una memoria
secondaria come un hard disk, viene caricato nella memoria principale (la RAM)
da un apposito loader dove la CPU corrente preleva, decodifica ed esegue le relative
istruzioni che compongono il programma medesimo (Figura 1.11).
Dichiarazione
Una dichiarazione (Sintassi 2.1) è una statement (declaration statement) attraverso la
quale si indica al compilatore di predisporre e riservare spazio in memoria atto a contenere
un dato del tipo espressamente specificato. In più, si indica un identificatore, o nome, con
cui riferirsi nel programma a quella locazione di memoria.
Lo Snippet 2.1 evidenzia la dichiarazione di una variabile di tipo int di nome number.
NOTA
In accordo con quanto indicato dallo standard C11, quando una variabile viene dichiarata
senza fornirle un esplicito valore di inizializzazione la stessa, a seconda della sua classe di
memorizzazione, potrà assumere o meno un valore significativo. Nel nostro caso
assumiamo che la variabile number abbia una classe di memorizzazione automatica, pertanto
il suo valore sarà indeterminato, ossia la variabile potrà contenerne uno qualsiasi, come nel
nostro caso è -858993460, che è stato assegnato dal compilatore cl di Microsoft. Nel caso del
compilatore gcc il valore sarà, per esempio, 0.
Lo Snippet 2.2 evidenzia, invece, come attraverso il carattere virgola (,) è possibile
esprimere più dichiarazioni di variabili dello stesso tipo come un’unica istruzione.
Una variabile, prima del suo utilizzo, deve essere sempre dichiarata. Secondo lo standard
C89, le statement di dichiarazione devono essere sempre espresse all’inizio di un blocco di
codice e, tra di esse, non possono esservi altri tipi di istruzioni (Snippet 2.3).
A partire invece dallo standard C99, è possibile dichiarare le variabili ovunque si desidera
nell’ambito di un blocco di codice così come frapporre tra di esse altre istruzioni (Snippet
2.4). Quest’ultima modalità di dichiarazione è molto comune nei linguaggi di
programmazione come C++ e Java.
Come già detto, dopo aver dichiarato il tipo, si deve scrivere un identificatore, ovvero un
nome simbolico con cui referenziare la variabile per il suo utilizzo. Tale identificatore può
essere scritto utilizzando qualsiasi combinazione di lettere minuscole, maiuscole, numeri e
caratteri di sottolineatura (_). ma non può essere composto da più parole separate da spazi e
non può iniziare con un numero e in genere con caratteri che hanno a che fare con la sintassi
del linguaggio (si pensi alle parentesi ( ), ai simboli di relazione > < e così via) o che ne
rappresentino una keyword riservata (per esempio int, struct, auto e così via).
Inoltre gli identificatori sono case-sensitive, nel senso che si fa distinzione tra lettere
minuscole e lettere maiuscole; inoltre, secondo lo standard C11, un compilatore dovrebbe
almeno considerare i primi 63 caratteri come significativi per un identificatore interno o un
nome di una macro e i primi 31 caratteri per gli identificatori esterni. Per lo standard C89,
invece, i limiti considerabili potevano essere, rispettivamente, fino a 31 caratteri e fino a 6
caratteri. Quanto detto significa che se si hanno due identificatori interni lunghi 64 caratteri
allora una qualche implementazione di C potrebbe considerali sia come nomi diversi sia
come nomi uguali perché ha tenuto conto solo dei primi 63 caratteri (per il compilatore gcc
tutti i caratteri di un identificatore interno sono significativi mentre per i nomi esterni il
numero di caratteri significativi è definito dal linker).
NAMING CONVENTION
Per naming convention si intende la regola di scrittura utilizzata per la denominazione degli
elementi di un programma. Nel linguaggio C esistono molte convenzioni di denominazione
degli elementi e, tuttavia, nessuna si può dire migliore o peggiore di un’altra; ciascuna
esprime, alla fine, un gusto personale del programmatore. L’importante, ai fini della leggibilità
del codice, è seguire la stessa convenzione di denominazione per tutto il programma e non
frapporla ad altre. Per esempio, una delle convenzioni comunemente usate prevede che la
scrittura degli identificatori di variabili e funzioni sia effettuata con tutte le lettere minuscole
(number, push, sort e così via). In caso gli identificatori siano formati da più parole, possono
essere separati dal carattere underscore (tipo next_line, get_score, make_average e così via). La
scrittura, invece, delle macro è effettuata utilizzando tutte le lettere maiuscole (tipo
STDIN_FILENO, EOF, BUFFER e così via). Un’altra convenzione, molto utilizzata in ambito Java o C#,
ma che sta prendendo piede anche in C e in C++, è quella che prevede che le strutture si
scrivano usando la notazione UpperCamelCase (Pascal Case), in cui l’identificatore, se
formato da più parole, viene scritto tutto unito e ogni parola inizia con la lettera maiuscola (per
esempio BookInformations), mentre le variabili e le funzioni si scrivono utilizzando la notazione
definita lowerCamelCase in cui, se l’identificatore è formato da più parole, lo stesso viene
scritto tutto unito, e la prima parola inizia con la minuscola mentre le altre con la maiuscola
(per esempio nextLine, getScore, makeAverage e così via).
NOTA
È buona prassi in C non dichiarare variabili con identificatori che inizino con uno _ o due __
caratteri di underscore perché essi sono impiegati estensivamente per i nomi dei tipi
dichiarati all’interno dei file della libreria standard.
DETTAGLIO
A partire dallo standard C99 è possibile scrivere identificatori utilizzando caratteri (UCN,
Universal Character Names) presi da un set di caratteri esteso (per dettagli si rimanda al
Capitolo 11). Così un identificatore come _Bool conterò dove è presente il carattere ò
normalmente non riconoscibile potrà essere scritto come: _Bool conter\u00F2, dove \u00F2 è il
code point, per l’appunto, del carattere ò (LATIN SMALL LETTER O WITH GRAVE).
Inizializzazione
Un’inizializzazione (Sintassi 2.2) è una statement (assignment statement) attraverso la
quale si indica al compilatore di assegnare, scrivere, un determinato valore nella locazione
di memoria precedentemente predisposta da una corrispettiva istruzione di dichiarazione. A
tal fine si utilizza l’operatore di assegnamento contraddistinto dal carattere uguale (=) e la
stessa istruzione è terminata, come di consueto, dal carattere punto e virgola (;).
Lo Snippet 2.6 evidenzia la dichiarazione di una variabile di tipo int di nome current_line
e la sua inizializzazione con il valore numerico 10 sempre di tipo intero (Figura 2.2).
// dichiarazione
float fl1, fl2;
// inizializzazione
fl1 = 33.33f;
fl2 = 44.44f;
Una variabile può ottenere un valore anche in modo dinamico, ovvero mediante la
valutazione di un’espressione (Snippet 2.8).
Nello Snippet 2.8 la variabile db di tipo double otterrà un valore che è il risultato del
calcolo della radice quadrata di 44.44, dopo l’invocazione della funzione sqrt dichiarata nel
file header <math.h> della libreria standard.
Costanti
Una costante rappresenta uno spazio di memoria a sola lettura (read-only), ovverosia una
locazione di storage dove è memorizzato un valore che non può essere più alterato dopo che
vi è stato assegnato.
Per far sì che un oggetto sia costante è necessario anteporre al tipo di dato relativo un
qualificatore di tipo espresso mediante la keyword const (Sintassi 2.3).
TERMINOLOGIA
Una qualificatore di tipo (type qualifier) è un attributo applicato a uno specificatore di tipo
che modifica le proprietà di un oggetto come una variabile o una costante.
// ERRORE!!!
const int FLAG;
FLAG = 1000; // assignment of read-only variable 'FLAG'
La sintassi della direttiva #define è più articolata di quanto illustrato ma, per il nostro
attuale obiettivo didattico, quella presentata è più che sufficiente per mostrare una delle sue
caratteristiche essenziali: quella di fungere da mezzo per scrivere delle semplici macro
(object-like macro) che agiscono come simboli di costanti che sono sostituiti dal
preprocessore nel codice sorgente con i relativi valori assegnati.
Nella terminologia adottata da Kernighan e Ritchie queste semplici macro sono definite
anche costanti manifeste (manifest constants) e associano: in linea generale, nomi sostituiti
da sequenze di caratteri; in modo più specifico, nomi sostituiti a valori di tipo numerico, di
tipo carattere o di tipo stringa (Listato 2.1).
int main(void)
BEGIN // {
float raggio = 5.0f;
putchar(BEEP);
// putchar('\a');
printf(HELLO);
// printf("Un saluto a tutti!");
putchar(NL);
// putchar('\012');
return (EXIT_SUCCESS);
END // }
Il Listato 2.1 mostra come creare attraverso una serie di direttive #define delle macro i cui
nomi, per prassi consolidata scritti con le lettere maiuscole, saranno sostituiti nel sorgente
dai corrispettivi valori. Nella funzione main sono indicate delle istruzioni che fanno uso di
specifiche direttive #define; al di sotto di ciascuna di esse, attraverso dei commenti, sono
indicate le sostituzioni con gli effettivi valori che saranno compiute dal preprocessore prima
dell’avvio della compilazione.
NOTA
La direttiva #define e le altre direttive del preprocessore saranno illustrate in dettaglio nel
Capitolo 10.
Tipi di dato fondamentali
Le variabili e le costanti contengono un valore che è dipendente dal tipo di dato scelto in
fase di dichiarazione. In C i tipi di dato utilizzabili sono quelli esprimibili attraverso le
keyword indicate nella Tabella 2.1 dove, in breve, un tipo char consente di memorizzare
singoli caratteri, un tipo int consente di memorizzare numeri interi, i tipi float e double
consentono di memorizzare numeri decimali, un tipo _Bool consente di memorizzare i valori
booleani false e true e i tipi _Complex e _Imaginary consentono di rappresentare i numeri
complessi e immaginari. I tipi short, long, signed e unsigned consentono, invece, laddove
previsto, di apportate variazioni di semantica ai tipi basici.
Tabella 2.1 Keyword del linguaggio C per esprimere i tipi di dato.
char (K&R) int (K&R) long (K&R) _Bool (C99)
Tipi interi
Un tipo intero (integer type) rappresenta un valore numerico intero, ossia un numero
senza la parte frazionaria o decimale oppure, detto in altri termini, senza il simbolo punto e
le cifre numeriche poste dopo di esso.
Il tipo intero fondamentale è espresso attraverso la keyword int e ha, generalmente, una
dimensione di 32 bit sui computer moderni e una di 16 bit sui computer meno recenti. Di
default un tipo int è altresì signed, ovvero con segno, e accetta quindi anche valori negativi.
converso, un intero si dice senza segno (unsigned integer) se il bit di sinistra più significativo
è considerato parte della magnitudine del numero. Così il più grande valore di un intero a 16
bit avrà una rappresentazione binaria di 1111111111111111 che corrisponderà al valore di 65535,
ossia 216 - 1, mentre il più grande valore di un intero a 32 bit avrà una rappresentazione
binaria di 11111111111111111111111111111111, che corrisponderà al valore di 4294967295, ossia 232 -
1.
Al tipo int è possibile anteporre altre keyword che consentono di variare il range di valori
utilizzabili e di decidere se i numeri negativi devono essere impiegati.
Esse sono: unsigned, con cui si stabilisce che il tipo intero non può accettare valori
negativi; short, con cui si stabilisce che il tipo intero può occupare meno spazio di
memorizzazione del tipo int; long, con cui si stabilisce che il tipo intero può occupare più
spazio di memorizzazione del tipo int.
Le keyword citate possono essere combinare nei seguenti modi, in ordine crescente di
dimensione, che producono differenti tipi laddove altre combinazioni non sono altro che
sinonimi di questi tipi:
short int (abbreviato usualmente in short che ne è un sinonimo);
unsigned short int (abbreviato usualmente in unsigned short che ne è un sinonimo);
int (scritto alcune volte come signed che ne è un sinonimo);
unsigned int (abbreviato usualmente in unsigned che ne è un sinonimo);
long int (abbreviato usualmente in long che ne è un sinonimo);
unsigned long int (abbreviato usualmente in unsigned long che ne è un sinonimo);
long long int (abbreviato usualmente in long long che ne è un sinonimo);
unsigned long long int (abbreviato usualmente in unsigned long long che ne è un
sinonimo).
NOTA
La keyword signed può essere utilizzata con tutti i tipi con segno per rendere esplicita la
volontà di utilizzare anche valori negativi. Così, signed short int oppure short int oppure
short sono tutti nomi di tipo per la stessa specie di dato.
SUGGERIMENTO
C consente di abbreviare il nome dei tipi interi utilizzabili “eliminando” la keyword int quando
combinata con altre keyword. Così, se dovete utilizzare uno short int potete scrivere
direttamente short; se dovete utilizzare un long int potete scrivere direttamente long.
L’intervallo di valori rappresentabile dai tipi di dato descritti varia da sistema a sistema.
Lo standard C11, tuttavia, stabilisce delle regole cui gli implementatori dei compilatori
devono sottostare.
La prima: un tipo int non deve mai essere più piccolo di un tipo short e un tipo long
non deve mai essere più piccolo di un tipo int (è possibile, comunque, che uno short
rappresenti lo stesso range di valori di un int oppure che un int rappresenti lo stesso
range di valori di un long). Questa regola è importante perché permette agli
implementatori di “adattare” la dimensione dei tipi al sistema target di destinazione.
La seconda: i tipi short, int e long e rispettive varianti devono garantire un certo range
di valori, ovvero devono essere almeno uguali o più grandi rispetto a quelli mostrati
dalla Tabella 2.2.
NOTA
Lo standard C11 asserisce che i valori dei tipi dovranno essere uguali o più grandi in
magnitudine rispetto a quelli da esso previsti così come elencati nella Tabella 2.2.
Tipicamente, come evidenziato dalle prossime tabelle, i valori minimi dei tipi differiscono di
un’unità e ciò è perfettamente lecito. Per esempio, per lo standard, il valore minimo di uno
short int è -32767 (espressione: -(2n-1 -1)) ma, in molte delle più comuni implementazioni,
tale valore minimo è -32768 (espressione: -(2n-1)) e ciò perché è utilizzato il complemento a
due per la rappresentazione dei numeri con segno. In definitiva ciò implica che si può
essere sicuri che nei compilatori aderenti allo standard un short int avrà almeno un valore
minimo di -32767, che però potrà essere anche maggiore come, per esempio, -32678.
Tabella 2.4 Range di valori su un sistema a 32 bit (un int e un long int hanno la stessa
ampiezza).
Tipo Valore minimo Valore massimo
short int -32768 32767
unsigned short int 0 65535
int -2147483648 2147483647
unsigned int 0 4294967295
long int -2147483648 2147483647
unsigned long int 0 4294967295
Tabella 2.5 Range di valori su un sistema a 64 bit (un long int e un long long int hanno la stessa
ampiezza).
Tipo Valore minimo Valore massimo
short int -32768 32767
unsigned short int 0 65535
int -2147483648 2147483647
unsigned int 0 4294967295
long int -9223372036854775808 9223372036854775807
unsigned long int 0 18446744073709551615
long long int -9223372036854775808 9223372036854775807
unsigned long long int 0 18446744073709551615
DETTAGLIO
Per verificare sul sistema in uso il range effettivo di valori utilizzabili è possibile dare uno
sguardo all’interno del file header <limits.h> che è parte della libreria standard di C. Infatti, in
esso sono presenti delle macro, tipo #define INT_MAX 2147483647 e #define INT_MIN (-INT_MAX-1),
che indicano i valori massimi e i valori minimi rappresentabili per un dato di tipo int.
Letterali interi
Un letterale intero è un qualsiasi numero tipo 100, 22, -30 e così via, che rappresenta una
sorta di costante intera, cioè un valore non alterabile senza la parte decimale che è scritto
direttamente nel flusso testuale del codice sorgente.
Una costante intera può essere espressa: in base decimale, quando contiene solo cifre
numeriche da 0 a 9; in base ottale, quando contiene solo cifre numeriche da 0 a 7 e le si deve
anteporre il prefisso 0; in base esadecimale, quando contiene cifre numeriche da 0 a 9 e
lettere da a o A a f o F e le si deve anteporre il prefisso 0x o 0X (zero-ex).
Inoltre, è importante comprendere che nel linguaggio C non ha alcuna importanza con
quale base numerica si decide di rappresentare una costante intera nell’ambito del sorgente
perché tale decisione non influisce su come il corrispettivo valore sarà memorizzato nel
computer. Infatti, scrivere 100, 0144 oppure 0x64 memorizzerà sempre allo stesso modo tale
valore, ossia come numero binario e dunque come 0000000001100100 considerando solo i
primi 16 bit.
Il programma seguente (Listato 2.2) mostra come, dato un numero, sia possibile
stamparne tramite la funzione printf l’equivalente rappresentazione decimale, ottale ed
esadecimale.
int main(void)
{
int number;
// istruzioni di input/output
printf("Digita un numero intero: ");
scanf("%d", &number);
return (EXIT_SUCCESS);
}
In pratica la funzione printf, attraverso gli specificatori di formato %#o e %#x, attua le
rispettive conversioni in base ottale e in base esadecimale del numero in base decimale
fornito in input dalla funzione scanf (se dagli specificatori di formato si omette il carattere
cancelletto #, i prefissi ottale 0 ed esadecimale 0x non saranno visualizzati nell’output della
printf).
DETTAGLIO
La funzione scanf della libreria standard, dichiarata nel file header <stdio.h>, consente di
ottenere dallo standard input (generalmente la tastiera) la sequenza di caratteri digitati. Tali
caratteri, se possibile, sono poi convertiti in un valore corrispondente al tipo di dato indicato
dal relativo specificatore di formato passato come primo argomento, e tale valore è posto
nella variabile indicata dal secondo argomento. Alle funzioni scanf e printf sarà dedicato un
apposito paragrafo nel Capitolo 11.
La funzione scanf del sorgente in esame attende che l’utente digiti da tastiera un numero
intero in base 10 (specificatore %d uguale a quello della funzione printf) e poi ne memorizza
il risultato nella variable number (tralasciando per ora i dettagli motivazionali
dell’apposizione del carattere & come prefisso a number, diciamo per ora che esso fornisce
l’indirizzo di memoria di number dove memorizzare il valore letto dalla tastiera ed
eventualmente convertito).
I letterali interi, infine, hanno un tipo associato che è dipendente dal valore che
esprimono e che è generalmente di tipo int.
In ogni caso a seconda della dimensione del valore del letterale intero e alla base
numerica in cui è espresso lo stesso potrà essere tipizzato dal compilatore con l’esatto tipo
atto a contenerlo secondo le seguenti regole, che indicano i tipi che in successione verranno
provati come idonei a rappresentare il relativo valore:
letterale intero in base decimale: int, long int, long long int;
letterale intero in base ottale o esadecimale: int, unsigned int, long int, unsigned long
Infine è importante dire che è possibile “forzare” un letterale intero a essere trattato
esplicitamente come:
un unsigned int aggiungendogli il suffisso u o U;
un long int aggiungendogli il suffisso l o L;
un unsigned long int aggiungendogli il suffisso ul o UL;
un long long int aggiungendogli il suffisso ll o LL;
un unsigned long long int aggiungendogli il suffisso ull oppure ULL.
NOTA
L’ordine di scrittura dei suffissi U e L non ha importanza così come se sono scritti in
maiuscolo oppure in minuscolo.
In questi ultimi casi, però, l’ordine di ricerca dell’esatto tipo attribuibile al letterale intero
sarà diverso rispetto a quanto indicato in precedenza, perché il compilatore terrà anche in
considerazione il significato dei suffissi. Per esempio, se un letterale intero in base 10 ha il
suffisso L, allora i tipi ricercati saranno, nell’ordine: long int e long long int e così via per gli
altri suffissi (in pratica il suffisso indica il tipo da cui partire fino al massimo tipo
rappresentabile sul sistema target considerando che per i letterali interi espressi in base
diversa da 10 l’ordine tiene conto anche dei tipi unsigned).
Il Listato 2.3 mostra come utilizzare con la funzione printf gli specificatori di formato
che consentono di stampare correttamente l’esatto tipo di un valore di tipo intero.
int main(void)
{
// tipi di dato intero considerando i valori garantiti dallo standard C11
short s = -10000;
unsigned short us = 60000;
int i = -32000;
unsigned int ui = 40000U;
long l = -1000000000L;
unsigned long ul = 4000000000UL; // equivalente LU
return (EXIT_SUCCESS);
}
In pratica per stampare uno short si antepone il carattere h ai caratteri d, o oppure x; per
stampare un long si antepone il carattere l ai caratteri d, o oppure x; per stampare un long long
Il tipo in virgola mobile può essere espresso attraverso una delle seguenti keyword: float,
double e long double che, rispettivamente, consentono di indicare numeri in floating point in
precisione singola, in precisione doppia e con una precisione estesa o multipla.
In pratica maggiore è la precisione, da una più bassa offerta da un tipo float a una più
alta offerta dal tipo long double, maggiori saranno sia il range di valori rappresentabile sia
l’accuratezza di precisione per i calcoli. Tutto ciò è evidenziato dalla Tabella 2.6, che
riporta questi valori in accordo con lo standard IEEE 754 (conosciuto anche come IEC
60559), che è quello indicato come sistema di rappresentazione dei tipi in virgola mobile
dallo standard C11 e che le implementazioni dovrebbero quindi seguire.
Tabella 2.6 Range di valori dei tipi ini virgola mobile come da standard IEEE 754.
Tipo Valore minimo Valore massimo Precisione Bit
float -3.4×10-38 3.4×1038 6 cifre 32
double -1.7×10-308 1.7×10308 15 cifre 64
long double -1.19×10-4932 1.19×104932 18 cifre >=79
APPROFONDIMENTO
Lo standard internazionale IEEE 754 (IEEE Standard for Binary Floating-Point Arithmetic)
definisce delle regole per i sistemi di computazione in virgola mobile, ovvero formalizza
come essi devono essere rappresentati, quali operazioni possono essere compiute, le
conversioni operabili e come devono essere gestite le condizioni di eccezione come, per
esempio, la divisione per 0. I formati esistenti sono: a precisione singola (32 bit), a
precisione singola estesa (>= 43 bit), a precisione doppia (64 bit) e a precisione doppia
estesa (>= 79 bit).
int main(void)
{
float a_float = 1234.444f; // suffisso f per letterale float
double a_double = 4.58e-2; // letterale double in notazione esponenziale
long double a_ldouble = 1.660538921e-27L; // unità di massa atomica...
return (EXIT_SUCCESS);
}
Tipi carattere
Un tipo carattere (character type) rappresenta un’unità di informazione atta a contenere
sia un valore che è un simbolo (per esempio una lettera dell’alfabeto, un segno di
punteggiatura e così via) che corrisponde a un grafema visualizzabile di un determinato
linguaggio naturale, sia un qualsiasi altro valore che è un codice di controllo che non
corrisponde ad alcun grafema e che è, quindi, non stampabile (per esempio, il carriage
return, il bell, il backspace e così via).
Il valore attribuito a uno specifico carattere è dipendente dal sistema di codifica di
caratteri (character set) adottato dal sistema in uso.
Tra questi, quello più comune è l’ASCII (American Standard Code for Information
Interchange), un sistema di codifica dei caratteri a 7 bit basato sull’alfabeto inglese che è in
grado di codificare 128 caratteri (codici da 0 a 127) tra quelli stampabili e quelli non
stampabili (esso è spesso riferito come US-ASCII).
Su molti sistemi, talune volte, è tuttavia adottata una versione estesa dell’originario
sistema ASCII a 7 bit denominata Latin-1 (ISO 8859-1), che prevede la possibilità di
rappresentare un più alto numero di caratteri (per esempio quelli con le lettere accentate)
grazie a una codifica che utilizza 8 bit (codici da 0 a 255).
In pratica la codifica Latin-1 consente di rappresentare i caratteri in uso nelle lingue dei
paesi dell’Europa Occidentale e in molti paesi dell’Africa.
NOTA
ISO 8859-1 è una delle 16 parti dello standard conosciuto come ISO/IEC 8859 che definisce
sistemi per la codifica di caratteri a 8 bit per il supporto delle lingue in uso in molti paesi del
mondo, a esclusione però di quella cinese, coreana, giapponese e vietnamita. Per esempio,
la parte denominata ISO-8859-2 o Latin-2 prevede la codifica dei caratteri utilizzati nei paesi
dell’Europa dell’Est e del Centro, la parte denominata Latin/Greek prevede la codifica dei
caratteri del greco moderno e così via per le altre. Lo schema di codifica dei caratteri in tutte
le parti è isomorfa, ovvero avrà sempre questa forma di rappresentazione: i codici da 0 a
127 conterranno i caratteri ASCII standard; i codici da 128 a 159 conterranno i caratteri di
controllo non stampabili; i codici da 160 a 255 conterranno i caratteri variabili codificati in
accordo con il relativo linguaggio.
Il tipo carattere è espresso attraverso la keyword char e deve avere una dimensione che gli
consenta di memorizzare qualsiasi membro del character set del sistema in uso.
Tipicamente, tale dimensione è di 1 byte, che permette a un tipo char di contenere l’insieme
di caratteri dei sistemi di codifica a 8 bit.
Tabella 2.7 Range di valori del tipo char secondo lo standard C11.
Tipo Valore minimo Valore massimo
char -127 // -(27 - 1) 127 // 27 - 1
unsigned char 0 255 // 28 - 1
Tabella 2.8 Range di valori del tipo char secondo una tipica implementazione.
Tipo Valore minimo Valore massimo
char -128 127
unsigned char 0 255
Letterali carattere
Un letterale carattere è un qualsiasi carattere scritto tra singoli apici tipo 'A', '3', 'z' e
così via, che rappresenta una sorta di costante carattere, ossia un valore non alterabile che è
scritto direttamente nel flusso testuale del codice sorgente.
Quando il compilatore incontra un letterale carattere lo converte automaticamente nel
corrispettivo codice numerico così come rappresentato dal corrente set di caratteri.
Così, in un sistema che usa l’ASCII, i letterali 'A', '3', 'z' saranno convertiti nei valori di
tipo int 65, 51 e 122 e ciò non causerà alcun problema di memorizzazione nei correlativi tipi
char perché tali valori “rientreranno” perfettamente nel range di valori di un char.
Quanto detto, ossia che C tratta di fatto i caratteri come numeri interi, consente anche di
assegnare direttamente un valore numerico in un tipo char e di compiere le stesse operazioni
che si potrebbero compiere con in tipi numerici (Snippet 2.10).
Anche se è possibile usare il tipo char come tipo per memorizzare “piccoli” valori interi
che richiedono solo un byte di spazio, bisogna considerare che inserire direttamente dei
valori interi con lo scopo di rappresentare i correlativi caratteri non è consigliabile per
motivi di portabilità perché differenti sistemi target potrebbero usare una codifica di
caratteri diversa, per esempio, dall’ASCII.
Le regole da seguire per utilizzare correttamente un tipo char dovrebbero essere, dunque,
le seguenti: se il tipo char è usato per memorizzare esplicitamente caratteri, allora si deve
scrivere lo stesso tra i singoli apici; se il tipo char è usato come tipo per piccoli interi che
non rappresentano, però, alcun carattere, allora si può scrivere un valore numerico che
rientra nel range di valori accettato da un tipo char.
NOTA
Lo standard del linguaggio C non specifica se un tipo char di default deve essere signed o
unsigned. Ogni implementazione lo può pertanto definire nell’uno o nell’altro caso. Per
“scoprire” per il compilatore in uso se un char è signed o unsigned si può leggere nel file
header <limits.h> la macro CHAR_MIN e verificare il suo valore: se vale 0, allora char sarà
unsigned se vale SCHAR_MIN, allora sarà signed.
All’interno dei letterali carattere possiamo altresì utilizzare lo speciale carattere
backslash, con simbolo \, definito carattere di escape, che consente di inserire dopo di esso:
1. caratteri speciali non inseribili dalla tastiera (per esempio quello che genera un beep
sonoro) oppure non visualizzabili in output (per esempio quello che genera un
backspace);
2. caratteri propri della definizione del letterale, come il carattere singolo apice (') e il
carattere backslash (\);
3. caratteri numerici (numeric escapes) che indicano, in ottale o in esadecimale il codice
del carattere che si desidera utilizzare.
Utilizzando il carattere backslash (\) insieme a uno dei caratteri indicati dai precedenti
punti si forma la cosiddetta sequenza di escape (escape sequence).
Per quanto attiene al punto 1 e al punto 2, la Tabella 2.9 mostra le sequenze di escape
costruibili (character escapes) e la relativa semantica considerando che per posizione attiva
(p.a.), termine utilizzato dallo standard, si intende una generalizzazione del concetto di
locazione, rispetto a un display device che può essere un monitor, una stampante e così via,
dove si posizionerebbe un carattere dopo una sua elaborazione.
Se il display device è un monitor, per esempio, la posizione attiva è comunemente
caratterizzata dal simbolo del cursore visibile in un command prompt o in una shell.
TERMINOLOGIA
Una sequenza trigraph (trigramma) è una successione di tre caratteri, i cui primi due sono
dati dai caratteri punti interrogativi (??), che consentono di rappresentare dei simboli che
potrebbero non essere forniti su alcune keyboard. Per esempio, il trigraph ??< viene
rimpiazzato nell’ambito di un file di codice sorgente con il simbolo parentesi graffa aperta
({). Nel Capitolo 11, nel paragrafo “Grafie alternative <iso646.h>”, sono mostrate tutte le
sequenze trigraph.
\t
Horizontal Muove la p.a. alla successiva posizione orizzontale di tabulazione
tab sulla corrente riga.
\v Vertical tab Muove la p.a. alla successiva posizione verticale di tabulazione.
\\ Backslash Visualizza il carattere backslash (\).
Single
\' Visualizza il carattere apice singolo (').
quote
Double
\" Visualizza il carattere doppio apice (").
quote
\?
Question Visualizza il carattere punto interrogativo (?) evitando la sua
mark interpretazione come parte di una sequenza trigraph.
\xhexadecimal-digits
Hexadecimal escape Esprime un codice carattere in
sequence esadecimale.
// qui 7CC vale 1996 in base 10 ed è fuori dal range massimo rappresentabile
// di un unsigned char che è FF, ossia 255 in base 10
char ch_illegal = '\x7CC'; // warning: hex escape sequence out of range
int main(void)
{
char ch;
return (EXIT_SUCCESS);
}
Il Listato 2.5 mostra come utilizzare lo specificatore di formato %c per ottenere in input
dalla funzione scanf un carattere così come per visualizzare in output con la seconda
funzione printf il medesimo carattere. Nel contempo, tale funzione printf usa lo
specificatore di formato %d per mostrare, dato lo stesso carattere, il suo codice numerico nel
corrente sistema di codifica caratteri che nel nostro caso è l’ASCII, e la sequenza di escape
\007 che è il codice ottale che consente la riproduzione o la visualizzazione di un alert
rispetto alla tabella ASCII (si poteva usare anche la sequenza di escape \a, che anzi sarebbe
stata preferibile al codice ottale per ragioni di portabilità).
La prima funzione printf mostra, invece, come impiegare all’interno di un letterale
stringa delle sequenze di escape: la prima, ovvero la double quote (\"), consente di
racchiudere la parola minuscolo tra doppi apici ed è necessaria perché se avessimo omesso il
carattere \ i primi caratteri " sarebbero stati visti dal compilatore come terminatori del
letterale stringa e la parola minuscolo, in quel contesto, non avrebbe avuto senso e gli avrebbe
fatto generare un errore tipo error: expected ')' before 'minuscolo'.
Tipi booleani
Un tipo booleano (boolean type) rappresenta un valore che può essere true oppure false
che deriva, normalmente, da valutazioni logiche di verità o falsità.
Il tipo booleano è espresso attraverso la keyword _Bool, disponibile a partire da C99, e ha
una dimensione che deve consentirgli di memorizzare i valori 0 (false) e 1 (true).
Solitamente la dimensione di un tipo _Bool è di 1 byte o comunque pari almeno al valore
della macro CHAR_BIT definita nel file header <limits.h>.
Si può dunque dire che un tipo _Bool è di fatto, per effetto dei valori che può contenere,
ossia 0 e 1, un tipo intero (fa parte della famiglia degli unsigned integer types).
Letterali booleani
Un letterale booleano è, tipicamente, un letterale intero come 0 (indica una condizione di
falsità), oppure 1 (indica una condizione di verità) e rappresenta una sorta di costante
booleana, cioè un valore non alterabile che è scritto direttamente nel flusso testuale del
codice sorgente.
In ogni caso, qualsiasi valore che si prova a inserire in un tipo _Bool viene valutato nel
seguente modo e così convertito: se è valutato come uguale a 0 allora rappresenta il valore
false e viene inserito 0; se è valutato come diverso da 0 (maggiore di 0 o minore di 0 e
dunque negativo) allora rappresenta il valore true e viene inserito 1.
Tabella 2.11 Range di valori di un _Bool.
Tipo Valore minimo Valore massimo
_Bool 0 1
int main(void)
{
int a = 82, b = 90;
_Bool b1 = 10,
b2 = -22,
b3 = '\000', // ASCII code NUL
b4 = 'A',
b5 = a < b; // valutazione di un'espressione
return (EXIT_SUCCESS);
}
La funzione main del Listato 2.6 definisce 5 variabili di tipo _Bool e ne stampa a video i
valori ivi contenuti tramite la consueta funzione di output printf e lo specificatore di
formato %d (ricordiamo che un _Bool è di fatto un tipo intero).
In dettaglio: la variabile b1 conterrà il valore 1 (true) perché il valore 10 assegnatole è
maggiore di 0; la variabile b2 conterrà il valore 1 (true) perché il valore -22 assegnatole è
minore di 0; la variabile b3 conterrà il valore 0 (false) perché la costante carattere '\000'
assegnatale rappresenta un carattere di controllo ASCII con il valore 0 (null character); la
variabile b4 conterrà il valore 1 (true) perché la costante carattere 'A' assegnatale rappresenta
il codice ASCI 65 che è un numero maggiore di 0; la variabile b5 conterrà il valore 1 (true)
perché l’espressione a < b è vera (82 è minore di 90) e ritorna, pertanto, di già il valore 1 che
è dunque assegnato a b5.
Tipo vuoto
Un tipo vuoto (void type) rappresenta un insieme vuoto di valori (non esistente), senza
operazioni associabili con esso, ed è espresso attraverso la keyword void che si può
applicare in vari contesti: per designare che una funzione non ritorna “nulla” oppure che
non ha parametri; per specificare che un puntatore punta a un valore che non ha un tipo; per
scrivere espressioni che non hanno un valore (Snippet 2.12).
int main(void)
{
// invocazione di foo: il fatto che non ritorna alcun valore
// la designa come un'espressione di tipo void
foo();
Figura 2.3 Categorizzazione dei tipi di dato in accordo con lo standard C11.
I tipi standard unsigned integer types e i tipi standard signed integer types sono
collettivamente denominati standard integer types.
I tipi in virgola mobile sono denominati real floating types, i tipi complessi sono
denominati complex types e i tipi immaginari sono denominati imaginary types.
I tipi real floating types unitamente ai tipi complex types e ai tipi imaginary types sono
collettivamente denominati floating types.
Infine:
I tipi standard integer types e i tipi floating types sono collettivamente denominati
basic types.
Da questa categorizzazione è escluso il tipo void, che è un tipo a se stante.
Dimensione dei tipi di dato
In base al sistema che si utilizza per lo sviluppo dei programmi in C si ha a disposizione
un range di valori ben determinato che è possibile impiegare nei tipi di dato scelti.
Ciò significa che la memoria richiesta per memorizzare dei valori, per esempio di tipo
intero o in virgola mobile, è dipendente dal sistema in uso così come evidenziato, per
l’appunto per i tipi interi, dalle precedenti Tabelle 2.3, 2.4 e 2.5.
Dato comunque un corrente sistema target, è possibile “scoprire” l’esatta dimensione in
byte usata dai tipi mediante un operatore del linguaggio espresso tramite la keyword sizeof,
di cui diamo un primo esempio di utilizzo (Listato 2.7), impiegando una sintassi che
prevede la scrittura, tra la coppia di parentesi tonde, del nome di un determinato tipo di dato
come operando.
int main(void)
{
printf("******************************************\n");
printf("* TYPE SIZES DEL CORRENTE SISTEMA IN USO *\n");
printf("******************************************\n\n");
printf(" _Bool\t\t%zu byte\n", sizeof (_Bool));
printf(" char\t\t%zu byte\n", sizeof (char));
printf(" int\t\t%zu byte\n", sizeof (int));
printf(" long\t\t%zu byte\n", sizeof (long));
printf(" long long\t%zu byte\n", sizeof (long long));
printf(" float\t\t%zu byte\n", sizeof (float));
printf(" double\t\t%zu byte\n", sizeof (double));
printf(" long double\t%zu byte\n\n", sizeof (long double));
printf("******************************************\n");
return (EXIT_SUCCESS);
}
Nel main del programma notiamo l’utilizzo della funzione printf con lo specificatore di
formato %zu, fornito a partire dallo standard C99, che consente di stampare correttamente il
valore ritornato dall’operatore sizeof che deve essere di tipo size_t, che è un sinonimo per
un tipo intero unsigned definito all’interno del file header <stddef.h> (come vedremo tra
breve, l’utilizzo dei sinonimi per i tipi consente, tra le altre cose, di fornire un nome di tipo
“standard” e portabile che però “nasconde” l’esatto tipo scelto dalla corrente
implementazione per il corrente sistema target).
NOTA
Nei compilatori non aderenti allo standard C99 o C11 è possibile utilizzare lo specificatore di
formato %lu per visualizzare il valore ritornato dall’operatore sizeof.
Output 2.7 Dal Listato 2.7 TypeSizes.c (sistema a 64 bit).
******************************************
* TYPE SIZES DEL CORRENTE SISTEMA IN USO *
******************************************
_Bool 1 byte
char 1 byte
short 2 byte
int 4 byte
long 8 byte
long long 8 byte
float 4 byte
double 8 byte
long double 16 byte
******************************************
_Bool 1 byte
char 1 byte
short 2 byte
int 4 byte
long 4 byte
long long 8 byte
float 4 byte
double 8 byte
long double 12 byte
******************************************
Gli output del programma mostrano con chiarezza la differenza di dimensionamento dei
tipi tra un sistema a 64 bit rispetto a un sistema a 32 bit: nel primo caso un long e un long
long hanno la stessa dimensione (8 byte) in accordo con quanto indicato in Tabella 2.5,
mentre nel secondo caso un int e un long (4 byte) hanno la stessa dimensione così come
indicato nella Tabella 2.4. In più nel sistema a 64 bit il long double è di 16 byte, mentre nel
sistema a 32 bit è di 12 byte.
ATTENZIONE
Il vostro sistema in uso potrebbe generare valori differenti.
Conversioni di tipo
Quando si scrivono programmi in C è possibile utilizzare diversi tipi di dato combinati tra
loro, ovvero che appaiono nell’ambito di una stessa istruzione o espressione.
Si pensi, per esempio, a una comune operazione aritmetica che somma due valori di tipo
diverso, oppure alla fondamentale istruzione di assegnamento dove si può avere che il
valore posto a destra dell’operatore di assegnamento è di tipo diverso rispetto al valore
atteso dal tipo posto alla sinistra del medesimo operatore.
In queste situazioni il compilatore effettua delle conversioni automatiche o implicite
(implicit conversion) che possono portare a una “promozione” (data type promotion) di un
tipo più piccolo (per esempio uno short) verso un altro tipo più grande (per esempio un int)
oppure a una “retrocessione” (data type demotion) di un tipo più grande (per esempio un
long) verso un tipo più piccolo (per esempio un char).
Nel primo caso non si hanno particolari problemi, perché se un valore di tipo short viene
assegnato a un tipo int, lo stesso rientra sicuramente nel range di valori di quest’ultimo e
dunque non subisce alcuna modifica adattiva.
Nel secondo caso, invece, si può avere una grave conseguenza legata a una possibile
perdita di informazione, soprattutto se il valore contenuto nel tipo long è più grande del
massimo valore rappresentabile nel tipo char.
ATTENZIONE
In C non è considerato illegale compiere un’operazione di data type demotion. Ciò implica
che un compilatore può o meno avvisare, in fase di compilazione, della possibile alterazione
di valore dal tipo più grande rispetto al tipo più piccolo. In GCC si può attivare un apposito
warning passando al comando di compilazione il flag -Wconversion.
NOTA
Le conversioni implicite sono compiute dal compilatore anche in altre due situazioni: quando
i tipi degli argomenti di una funzione non corrispondono ai tipi dei suoi parametri; quando il
tipo ritornato da una funzione non corrisponde al tipo dichiarato per la stessa. Affronteremo
in dettaglio queste conversioni nel Capitolo 6.
Analizziamo subito un primo esempio (Listato 2.8) che mostra cosa avviene in due
comuni casi di data type demotion in virtù delle seguenti regole.
Se si assegna una variabile contenente un valore decimale a una variabile di tipo intero,
si avrà un troncamento della sua parte frazionaria. Se, tuttavia, il valore della parte
intera non può essere rappresentata dal tipo intero di destinazione, allora il
comportamento intrapreso dal compilatore sarà non definito.
Se si assegna un valore di una variabile intera che è più grande del valore massimo
contenibile nell’altra variabile intera senza segno, sarà assegnato un valore (detto
modulo) che rappresenta il resto della divisione tra i due valori (se, tuttavia, la variabile
di destinazione è con segno, il risultato sarà dipendente dall’implementazione
corrente).
int main(void)
{
int a = 260;
double d = 323.123;
unsigned char b;
return (EXIT_SUCCESS);
}
Le regole complete che C segue per adottare promozioni e conversioni tra operandi di
tipo diverso nell’ambito di espressioni binarie sono molto più complesse di quelle indicate,
che sono comunque sufficienti per avere consapevolezza di cosa avviene durante le delicate
fasi di promozione dei tipi.
Ciononostante, per ragioni di completezza di trattazione, specifichiamo meglio tali regole
partendo dal dire che per C11 ogni tipo intero ha un ranking (integer conversion rank) che
determina chi, come tipo, ha peso maggiore (è superiore) rispetto a un altro tipo (è inferiore)
durante la fase di promozione.
Dal più alto al più basso abbiamo:
1. long long int, unsigned long long int;
6. _Bool.
Scorrendo l’elenco di ranking possiamo notare come i tipi unsigned hanno la stessa
posizione dei rispettivi tipi signed e possiamo esprimere la regola generale per cui:
dati tutti i tipi interi, se abbiamo i tipi A, B e C: se A ha un rank più alto di B e B ha un
rank più alto di C, allora A avrà un rank più alto di C.
Avendo questo ranking possiamo illustrare lo schema completo di regole che C esegue
durante un’abituale conversione aritmetica, in ordine e finché non ne trova una che si
applica:
1. se il tipo di uno degli operandi è in virgola mobile, allora quello con peso minore sarà
convertito in tale operando con peso maggiore secondo quest’ordine, dal più alto al più
basso: long double, double, float (ciò significa, per esempio, che se un operando è di tipo
double e l’altro di tipo float, allora quest’ultimo sarà promosso in double; se un
operando è di tipo float e l’altro di tipo int, allora quest’ultimo sarà promosso in float;
così via per le alte combinazioni); altrimenti,
2. se nessuno degli operandi è in virgola mobile, allora prima prova ad attuare una
promozione verso il tipo int e se i due operandi non sono ancora dello stesso tipo,
allora segue una delle seguenti regole finché una di esse non è applicabile:
a. se entrambi gli operandi sono tipi interi con segno oppure se entrambi gli operandi
sono tipi interi senza segno, allora quello con ranking minore è convertito in quello con
ranking maggiore in accordo con le posizioni dell’integer conversion rank prima elencato;
altrimenti,
b. se un operando è un tipo intero senza segno che ha un ranking maggiore o uguale al
tipo intero con segno dell’altro operando, quest’ultimo è convertito nel tipo intero
dell’operando senza segno; altrimenti,
c. se il tipo intero con segno di un operando può rappresentare tutti i valori del tipo intero
dell’altro operando senza segno, quest’ultimo è convertito nel tipo intero dell’operando con
segno; altrimenti,
d. entrambi gli operandi sono convertiti nel tipo intero senza segno dell’operando che
corrisponde al tipo intero dell’operando con segno.
Lo Snippet 2.14 mostra in modo concreto cosa avviene durante una serie di conversioni
in ragione dell’applicazione delle regole esposte.
Snippet 2.14 Regole implicite per le conversioni aritmetiche abituali (sistema a 64 bit).
// regola punto 1
int a = 11;
double d = 33.455;
double d_res = a + d; // a promosso in double
// regola punto 2
char g = 'F';
short int s = 345;
int i_res = g + s; // g e s promossi in int
Mostriamo ancora un altro esempio (Snippet 2.15) che evidenzia come avverrà la
valutazione di un’espressione con operandi di diverso tipo considerando l’utilizzo della
funzione printf con lo specificatore di formato %f che stampa, di default, un valore in double
con 6 cifre decimali di precisione.
Nello Snippet 2.15 l’intera espressione sarà valutata ed eseguita come segue.
1. (c*i) sarà convertito in un int con il valore 213100.
2. (f*b) sarà convertito in un float con il valore 621.599976.
3. (d/s) sarà convertito in un double con il valore 7.4822297297297293.
4. Il punto 3 / l è sempre un double con il valore 0.0035427224099099097.
5. Il punto 1 + il punto 2 dà un float con il valore 213721.594.
6. Il punto 5 - il punto 4 dà un double con il valore 213721.59020727756.
7. ris sarà uguale a un valore double ovvero quello di cui il punto 6.
In definitiva, per valutare se un’espressione genera un valore dello stesso tipo della
variabile di destinazione bisogna sempre guardare al tipo degli operandi che ha il massimo
range di valori rappresentabili. Ciò significa che se un’espressione ha un operando di tipo
double e altri operandi di tipo differente, l’intera espressione darà sempre il valore convertito
in double, perché un double sicuramente potrà contenere valori interi (long long, long, int,
int main(void)
{
float res;
int number_1 = 100, number_2 = 33;
// la divisione tra interi ritorna solo la parte intera che per una conversione
// implicita è "promossa" in float
res = number_1 / number_2;
printf("Divisione: %d / %d = %f\n", number_1, number_2, res);
return (EXIT_SUCCESS);
}
Il Listato 2.9 definisce le variabili res di tipo float, number_1 e number_2 di tipo int e poi
compie due divisioni: la prima, tra number_1 e number_2, dà come risultato il valore intero 3 (la
parte frazionaria è esclusa) che, essendo assegnato alla variabile res, viene convertito
implicitamente nel suo tipo ossia float e la funzione printf equivalente mostra come
risultato il valore 3.000000; la seconda, sempre tra number_1 e number_2, dà come risultato il
valore in virgola mobile 3.030303 perché, prima di effettuare la divisione, la variabile
number_1 è stata esplicitamente convertita in un tipo float e poi, per le regole di conversione
implicita prima esaminate, anche la variabile number_2 è stata convertita in un tipo float e
dunque tutta l’operazione di divisione è proceduta tra tipi float.
La variabile res ha quindi ricevuto un valore float che è del suo stesso tipo: infatti, la
funzione printf equivalente ne mostra in modo inequivoco il valore ossia 3.030303.
Sinonimi per i tipi
Una caratteristica di rilievo offerta dal linguaggio consiste nel permettere di attribuire
“nuovi nomi” per i tipi di dati esistenti senza produrre, nel contempo, dei nuovi tipi.
Per avvantaggiarsi di ciò è sufficiente utilizzare la keyword typedef (Sintassi 2.7), cui far
seguire tramite type_identifier il nome di un tipo di dato e tramite new_name il nuovo nome o
alias con il quale tale tipo di dato sarà utilizzato nell’ambito delle comuni operazioni di
dichiarazione di variabili, di cast e così via.
Lo Snippet 2.16 mostra la creazione di alias per i tipi unsigned char, _Bool e char * con cui
si evidenzia uno dei vantaggi offerti da typedef, ossia la possibilità di rendere un programma
più “comprensibile” quando si utilizzano determinati tipi di dato.
Si pensi alla dichiarazione della variabile data che è di tipo byte e che rende subito chiaro
che la stessa può memorizzare l’intervallo di valori di un byte che va da 0 a 255, oppure alla
dichiarazione della variabile message che è di tipo String e che consente di comprendere in
modo netto che la stessa può memorizzare una stringa di caratteri.
Un altro vantaggio offerto da typedef è quello inerente la possibilità di scrivere
programmi portabili su sistemi differenti dove i tipi di dato hanno diversi range di valori.
L’esempio più comune è quello che implica l’utilizzo di tipi interi che sono adattati alla
piattaforma di destinazione di un programma.
Così, se avremo l’esigenza di impiegare interi con un range di valori da -2147483648 a
2147483647, allora potremo utilizzare una definizione come typedef int Integer che sarà valida
per un sistema a 32 bit, oppure come typedef long int Integer che sarà valida, invece, su un
sistema a 16 bit. Ciò fatto potremo essere sicuri che nel nostro programma ogni utilizzo di
Integer sarà “interpretato” con il corretto tipo di dato, ossia int oppure long int.
La libreria standard del linguaggio C utilizza typedef in modo sistematico per creare nomi
(o sinonimi) per tipi che diventano “portabili” ancorché dipendenti e variabili rispetto alla
particolare implementazione per un determinato sistema target.
Per convenzione, questi nomi terminano con i caratteri _t, come size_t, wchar_t, time_t,
int32_t e così via, e per utilizzarli è sufficiente includere nel relativo programma i file
header dove il corrispondente typedef è stato impiegato.
TYPEDEF VS #DEFINE
Anche se typedef e #define possono entrambe definire nuovi nomi per tipi preesistenti, esistono
tra di esse le seguenti importanti differenze:
typedef è uno specificatore di classe di memorizzazione interpretato dal compilatore,
mentre #define è una direttiva interpretata dal preprocessore;
typedef può attribuire nomi simbolici solo a tipi di dato, mentre #define può attribuire nomi
anche a valori;
typedef permette di creare degli “effettivi sostituti” di nomi per i tipi (definisce tipi), mentre
#define esegue solo una sostituzione letterale di quanto rappresentato dal relativo nome
(definisce macro; Snippet 2.17 e 2.18);
typedef segue le stesse regole di scope delle dichiarazioni delle variabili (una typedef
può avere, per esempio, una visibilità legata solo al blocco di funzione dove è stata
dichiarata), mentre #define non obbedisce a tali regole. Con #define la sua definizione ha
visibilità dal punto in cui è stata scritta fino alla fine del corrente file.
TERMINOLOGIA
Uno specificatore di classe di memorizzazione determina la durata in memoria di un oggetto
ed è indicabile con apposite keyword, come static, extern e così via, che saranno trattate in
dettaglio nel Capitolo 9. Tuttavia, da un punto di vista semantico, typedef non è un vero e
proprio specificatore di classe di memorizzazione ma è indicato come tale solo per una
“convenienza” sintattica.
TERMINOLOGIA
Per scope si intende quella regione del codice sorgente dove un identificatore è visibile e
dunque utilizzabile. Approfondiremo tale importante concetto nel Capitolo 9.
Sinora abbiamo incontrato un solo tipo di entità o oggetto utilizzabile per memorizzare
dei valori, ossia la variabile, o la sua forma read-only, ovvero la costante.
Questo tipo di oggetto, denominato anche variabile scalare (o tipo scalare se ci riferiamo
in modo esplicito al suo tipo di dato come char, int, float e così via) ha la caratteristica di
poter rappresentare e gestire un solo valore alla volta.
Tuttavia, vi possono essere delle circostanze per cui si ha la necessità di memorizzare un
insieme di valori, come unica e indivisibile entità, e riferirsi a essi attraverso un unico
oggetto che li rappresenti.
Si pensi, per esempio, a un programma che deve rappresentare i mesi di un anno e per
ciascun mese deve indicare la quantità precisa di giorni cui è composto.
In questa situazione è possibile impiegare dodici diverse variabili, denominate january,
february, march e così via e assegnare loro dei valori comi 31, 28 (se l’anno non è bisestile), 30
e così via, oppure utilizzare un “particolare” oggetto, l’array, denominato come variabile
aggregato (o tipo aggregato se ci riferiamo a esso come a un tipo di dato array) che
consente per il suo tramite di rappresentare direttamente tutti i mesi dell’anno come
un’unica e omogenea collezione di dati.
Avendo dunque una sola variabile di tipo array, denominata per esempio months, potremo
accedere tramite essa, secondo una particolare sintassi che tra breve esamineremo,
direttamente al mese di interesse evitando così di avere dodici diverse variabili che, di fatto,
non rappresentano “cose” differenti ma sono tra di loro collegate da un significato: tutte
rappresentano i mesi di un anno.
TERMINOLOGIA
Un tipo array fa parte dei cosiddetti tipi derivati, che sono tipi che si costruiscono a partire
dai tipi fondamentali (char, int, long, float e così via). Fanno altresì parte dei tipi derivati: il
tipo funzione, che descrive una funzione con uno specifico tipo di ritorno; il tipo puntatore,
che fa parte anch’esso dei tipi scalari, che descrive un oggetto che si riferisce a un altro
oggetto di un determinato tipo; il tipo struttura, che descrive un insieme sequenziale di
oggetti di diverso tipo; il tipo unione, che descrive un insieme “sovrapponibile” di oggetti di
diverso tipo; il tipo atomico, che descrive un tipo qualificato attraverso la keyword _Atomic.
Array monodimensionali
Un array monodimensionale, denominato anche vettore, è una struttura dati rappresentata
da un insieme di variabili, identificate da un nome univoco e definite elementi, che sono
tutte di uno stesso tipo e sono disposte in memoria in modo contiguo, ovvero sono
concettualmente visualizzabili come una serie di oggetti posizionati l’uno dopo un altro in
una singola riga o in una singola colonna che rappresentano, nella sostanza, la sua unica e
sola dimensione (Figura 3.1).
Dichiarazione
Un array di dichiara utilizzando la Sintassi 3.1.
Qui data_type indica il tipo di dato che avranno gli elementi costituenti l’array (int, char e
così via); identifier indica il nome o identificatore dell’array e deve essere scritto
utilizzando le stesse regole viste per gli identificatori delle variabili (per esempio, non può
cominciare con un carattere numerico); le parentesi quadre [ ] (dette operatore di subscript)
indicano che la variabile relativa è un array del tipo stabilito e devono essere sempre
presenti; NR_OF_ELEMENTS, poste all’interno dell’operatore [ ], indicano la quantità di elementi
facenti parte dell’array e tale valore deve essere espresso attraverso un’espressione costante
intera.
TERMINOLOGIA
Un’espressione costante intera (integer constant expression) è un’espressione che deve
essere di tipo intero e deve contenere come operandi solo costanti intere, costanti di
enumerazione, costanti carattere, espressioni sizeof che ritornano costanti interi e così via.
Un oggetto dichiarato con il qualificatore const non è tuttavia considerato come
un’espressione costante se utilizzato per fornire la dimensione di un array.
IMPORTANTE
Gli elementi di un array sono collocati contiguamente a partire da una posizione che ha
valore 0 e non valore 1. Pertanto, il primo elemento di un array sarà l’elemento 0 e avrà la
posizione 0 (zeroth element).
Un tipo array è dunque caratterizzato dal tipo dei suoi elementi e dalla loro quantità, ossia
dal loro numero che ne determina anche la lunghezza.
Se, quindi, il tipo di un elemento è T, allora un tipo array da esso derivabile è altresì
indicabile come array di T avente un numero di elementi di tipo T pari alla dimensione
specificata.
int main(void)
{
// dichiarazione dell'array a di 10 elementi
int a[SIZE];
...
}
Lo Snippet 3.1 dichiara l’array a di tipo int che avrà 10 elementi (array di int di
dimensione 10) che è, per l’appunto, il valore ritornato dalla valutazione dell’identificatore
SIZE che rappresenta un’espressione costante intera (SIZE è infatti una costante simbolica
Una possibile rappresentazione in memoria del nostro Snippet 3.1 è data dalla Figura 3.2,
laddove i valori di ciascun elemento possono variare da sistema a sistema proprio perché
per lo standard C11 le variabili automatiche non inizializzate in modo esplicito conterranno
valori indefiniti.
Inizializzazione
Un array può essere inizializzato (Sintassi 3.2) contestualmente alla sua dichiarazione al
fine di dare valori significativi ai corrispondenti elementi.
NOTA
Non deve apparire strano che GCC riporti solo dei messaggi nei casi di violazione delle
regole sopra citate. Lo standard C11, infatti, prescrive che un’implementazione conforme
deve riprodurre almeno un messaggio di diagnostica qualora vi siano delle violazioni alle
specifiche.
int main(void)
{
// i rimanenti 7 elementi dell'array avranno il valore 0
int a[SIZE] = {1, 2, 3};
In pratica, tra la coppia di parentesi graffe { }, viene posta una serie di parentesi quadre
aperte/chiuse [ ] al cui interno si scrive la posizione (index) dell’elemento dell’array cui
attribuire il corrispettivo valore value.
Questa posizione, che rappresenta un designatore, deve essere un’espressione costante
intera e il suo valore può andare da 0 a NR_OF_ELEMENTS - 1.
È altresì interessante rilevare come gli inizializzatori designati possono essere utilizzati
congiuntamente ai normali inizializzatori non designati e che l’ordine degli elementi
inizializzati dai designatori non ha importanza (vi può essere un designatore con index pari a
10, poi un altro designatore con index pari 1 e così via).
int main(void)
{
// elemento 0 = 1000, elemento 1 = 0, elemento 2 = 11
// elemento 3 = 3; elemento 4 = 5; elemento 5 = 0, elemento 6 = 0
// elemento 7 = 0, elemento 8 = 0; elemento 9 = 0
int a[SIZE] = {5000, [3] = 100, [2] = 11, 3, 5, [0] = 1000};
Dagli step elencati si può notare (punti 5. e 8.) come un designatore può interrompere
l’inizializzazione di un successivo elemento facendo ripartire il conteggio da quanto esso
indicato e che un elemento può anche avere più valori assegnati poiché solo l’ultimo sarà
quello effettivamente contenuto.
L’array a_2, invece, mostra che quando un array non ha l’indicazione del numero di
elementi, e sono presenti degli inizializzatori designati, quello con indice maggiore
determina la lunghezza dell’array medesimo.
Nel nostro caso l’array a_2 avrà 7 elementi inizializzati nel seguente modo:
Subscripting
Dopo la fase di dichiarazione e/o di inizializzazione di un array, gli elementi relativi
possono essere utilizzati in fase di lettura e scrittura mediante la Sintassi 3.4
int main(void)
{
// array di int di 10 elementi
int c[SIZE] = {[6] = 2450};
int u = 2, z = 4;
c[1] = 333; // scrivo alla posizione con indice 1
Lo Snippet 3.4 dichiara l’array c deputato a contenere 10 elementi di tipo intero, così
come stabilito dalla costante simbolica SIZE, e poi le variabili intere u e z.
Mostriamo quindi come, utilizzando il nome dell’array c e l’operatore di subscript [ ],
accediamo con l’istruzione c[1] al suo secondo elemento e gli assegniamo il valore 333,
mentre con l’istruzione c[u + z] accediamo al suo settimo elemento, perché la valutazione
dell’espressione u + z dà come risultato l’intero 6, e ne assegniamo il corrispettivo valore
alla variabile intera x.
Le operazioni discusse evidenziano la seguente cosa: espressioni indicate nella forma di
c[1] oppure nella forma di c[u + z] sono considerabili alla stessa stregua di espressioni che
Infine, l’ultima istruzione che assegna il valore 1000 all’elemento con indice 10 dell’array
c solleva un interessante quesito: cosa accade se si prova a utilizzare un indice che è fuori
dai limiti massimi (o minimi) stabiliti per un determinato array?
La risposta è che per lo standard del linguaggio C il comportamento è non definito, ossia
il programma può continuare a funzionare, può comportarsi in modo anomalo oppure può
interrompersi. Ogni implementazione può decidere come gestire quest’anomalia e se
avvisare o meno il programmatore in fase di compilazione di un programma.
Questa libertà concessa dallo standard non deve essere considerata negativa oppure
stupire, anzi, è in linea con due dei principi cardine del linguaggio: il primo, più filosofico, è
legato al fatto di “credere” e avere fiducia nelle capacità di un programmatore che non può
non sapere come indicizzare correttamente un array; il secondo, più pratico, è legato a
ragioni di efficienza e velocità di esecuzione di un programma perché il compilatore non è
costretto ad aggiungere codice supplementare che, a run-time, si deve occupare di verificare
se l’accesso a un elemento di un array è fuori dai limiti consentiti.
Ritornando al nostro caso, GCC non rileva e non avvisa del tentativo di scrivere il valore
1000 fuori dal limite massimo dell’array c che ricordiamo è 9 e non 10 (è comunque
NOTA
Quando studieremo la relazione tra array e puntatori sarà più chiaro il perché
dell’indicizzazione negativa. Per ora si può ragionare nel seguente modo: dato un array di T,
ogni indice fornito sposta la corrente locazione di memoria (in avanti se positivo oppure
indietro se negativo) di tante posizioni in relazione al valore dell’indice e all’ampiezza del
tipo di dato T.
In conclusione vediamo un semplice esempio (Listato 3.1) che crea un array di interi
contenente 10 numeri scelti a caso e poi stampa a video solo quelli dispari scorrendo in una
struttura iterativa (for) i singoli elementi dell’array ove sono contenuti.
#define SIZE 10
int main(void)
{
// array di interi di 10 numeri
int numbers[SIZE] = {122, 4, 66, 7, 33, 1, 2, 30, 45, 10};
return (EXIT_SUCCESS);
}
DETTAGLIO
In questo esempio abbiamo utilizzato una struttura iterativa, creata con l’istruzione for, che
consente di effettuare ciclicamente le operazioni in essa contenute finché una determinata
condizione è vera. Nel nostro caso la struttura iterativa visita gli elementi contenuti nell’array
numbers finché il contatore ix è minore della lunghezza dell’array stesso. In pratica il ciclo è
].
ATTENZIONE
L’utilizzo di array multidimensionali, soprattutto quelli con più di due dimensioni, deve
essere ponderato con estrema cautela quando il numero di elementi per indice è notevole,
e ciò per evitare un impiego inutile ed eccessivo di memoria necessaria per la loro completa
creazione (si potrebbe, infatti, non avere alcuna necessità di utilizzare subito tutti gli
elementi allocati). In questo caso può essere più opportuno definire un nuovo tipo di dato
(per esempio una struttura Velocity) che ha come proprietà le dimensioni di interesse (per
esempio le variabili x, y, z e t) e poi creare un array dove gli elementi riferiscono solo alle
strutture di tipo Velocity effettivamente occorrenti.
Array bidimensionali
Un array bidimensionale, definito anche matrice, è una struttura dati che al pari dell’array
monodimensionale è composta di un insieme di variabili, che ne rappresentano sempre gli
elementi, dove però, a differenza del vettore, sono concettualmente visualizzabili (Figura
3.5) come una serie di oggetti posizionati l’uno dopo l’altro in una sorta di tabella ovvero in
righe (prima dimensione) e colonne (seconda dimensione).
Figura 3.5 Visualizzazione di un array bidimensionale denominato data.
NOTA
Nel Capitolo 7 vedremo un’altra rappresentazione concettuale di un array bidimensionale
tramite una tabella di righe e colonne che terrà conto della relazione tra array e puntatori e
del fatto che un array a due dimensioni è di fatto un array di array.
Nella pratica gli elementi di una matrice sono memorizzati in modo sequenziale, riga per
riga (row-major order), ovvero, nell’ordine: prima tutti gli elementi della riga 0, poi tutti gli
elementi della riga 1 e così via per gli elementi delle rimanenti righe (Figura 3.6).
Dichiarazione
Un array bidimensionale si dichiara utilizzando la Sintassi 3.5.
Qui data_type e identifier hanno lo stesso significato e le stesse regole di quelle viste per
la dichiarazione del tipo vettore, mentre le doppie parentesi quadre [ ], sempre obbligatorie,
indicano che la variabile relativa è una matrice del tipo stabilito costituita dal numero di
righe indicate da NUMBER_OF_ROWS (prima dimensione) e ogni riga dal numero di colonne
indicate da NUMBER_OF_COLS (seconda dimensione).
Anche in questo caso NUMBER_OF_ROWS e NUMBER_OF_COLS devono essere espressioni costanti
intere.
int main(void)
{
// dichiarazione della matrice data di 2x3 elementi
int data[NR_OF_ROWS][NR_OF_COLS];
...
}
Lo Snippet 3.5 dichiara l’array bidimensionale data di tipo int che avrà 2 righe e ogni riga
avrà 3 colonne, come da risultato della valutazione delle costanti simboliche NR_OF_ROWS e
NR_OF_COLS utilizzate con l’operatore di subscript [ ].
Gli elementi totali a disposizione della matrice saranno pertanto 6, valore che è dato dalla
moltiplicazione del numero di righe (2) per il numero relativo di colonne (3).
Come per il vettore, anche in questo caso il compilatore riserva la giusta quantità di
memoria adatta a contenere gli elementi della matrice, e i valori di inizializzazione sono
quelli che si trovavano nella corrispondente locazione di memoria utilizzata.
Per comprendere la dichiarazione di una matrice può essere opportuno separarla in due
fasi, dove una si riferisce alla prima dimensione e l’altra si riferisce alla seconda
dimensione; così, la matrice int data[2][3] può essere logicamente divisa in:
Figura 3.8 Una possibile rappresentazione concettuale della matrice data visualizzata come
tabella.
Inizializzazione
Un array bidimensionale può essere inizializzato (Sintassi 3.6) contestualmente alla sua
dichiarazione in modo da fornire valori significativi ai corrispondenti elementi.
interne ciascuna contenente degli inizializzatori separati dalla virgola che forniranno dei
valori ai corrispondenti elementi della corrente riga. Ogni lista di inizializzatori interna
inclusiva delle parentesi graffe { } è altresì separata dal carattere virgola (,).
Le regole di scrittura delle liste di inizializzatori ricalcano quelle già viste per
l’inizializzazione di un vettore; considerando però una matrice abbiamo che (Snippet 3.6):
se una lista di inizializzatori per una riga non contiene tutti gli inizializzatori, allora i
rimanenti elementi conterranno il valore 0;
se non sono presenti tutte le liste di inizializzatori, ciascuna per ogni riga, le ultime
righe conterranno elementi con valore 0;
se in una lista di inizializzatori sono presenti più valori di quelli che una riga può
contenere, allora gli stessi saranno scartati e il compilatore potrà generare un errore
oppure un warning, come per esempio questo emesso da GCC: excess elements in array
initializer;
è possibile omettere le coppie di parentesi graffe { } interne e usare solo quelle esterne
e dunque scrivere gli inizializzatori direttamente. In questo caso il compilatore conta
tanti inizializzatori quanti sono sufficienti per valorizzare gli elementi di una riga e poi
passa ai successivi per la riga successiva e così via fino al riempimento di tutte le righe
della matrice. Alcuni compilatori potrebbero generare un messaggio di avviso del tipo
missing braces around initializer. Per GCC, al fine di far emettere tale messaggio, è
Infine è possibile omettere la dimensione delle righe e delle colonne solo se non si
omettono le liste di inizializzatori interne inclusive delle relative parentesi { }, così come è
consentito utilizzare gli inizializzatori designati.
int main(void)
{
// matrice di 3×3 elementi
int data[NR_OF_ROWS][NR_OF_COLS] =
{
{10, 100}, // prima riga - meno inizializzatori ultimo elemento valore 0
{-10, -100, -1000, 99999} // seconda riga - più inizializzatori warning o errore?
/* terza riga omessa: tutti gli elementi conterranno il valore 0 */
};
int main(void)
{
// matrice di 3×3 elementi
int data[NR_OF_ROWS][NR_OF_COLS] =
{
{10, 100, 1000},
{-10, -100, -1000},
{1, 11, 1111}
};
Lo Snippet 3.7 dichiara e inizializza la matrice data composta da 3 righe per 3 colonne e
assegna alla variabile di tipo int element il contenuto della sua seconda riga e seconda
colonna così come vi pone il valore 0 nella terza riga e terza colonna.
La Figura 3.9 mostra una rappresentazione della matrice data al termine delle suddette
operazioni visualizzata nel consueto modello tabellare e come è effettivamente disposta in
memoria, evidenziando in quest’ultimo caso il meccanismo del row-major order e che ogni
indirizzo successivo è, dato un int di 32 bit, esattamente 4 byte dopo il precedente.
Figura 3.9 Rappresentazione tabellare e in memoria della matrice data.
#define YEARS 4
#define QUARTERS 4
#define START_YEAR 2010
#define END_YEAR 2013
int main(void)
{
double total = 0; // totale introiti in tutti gli anni e trimestri
double subtotal; // totale parziale introiti di un anno
// introiti percepiti negli anni dal 2010 al 2013 nei relativi 4 trimestri
double earnings[YEARS][QUARTERS] =
{
/* I II III IV trimestre */
{890.00, 899.00, 1000.11, 998.55}, /* anno 2010 */
{789.59, 800.00, 1234.99, 699.00}, /* anno 2011 */
{1490.00, 497.33, 100.00, 2045.60}, /* anno 2012 */
{678.00, 1999.00, 632.50, 1090.00} /* anno 2013 */
};
return (EXIT_SUCCESS);
}
2010 3787.66
2011 3523.58
2012 4132.93
2013 4399.50
Totale 15843.67
1 3847.59
2 4195.33
3 2967.60
4 4833.15
Totale 15843.67
Array tridimensionali
Un array tridimensionale è una struttura dati che, al pari del vettore e della matrice, è
anch’essa composta da un insieme di variabili che ne rappresentano sempre gli elementi e
che sono disposte in memoria in modo lineare, ma vi differisce perché ha tre indici e perché
può essere visualizzata come una sorta di pila di tabelle posizionate le une sopra le altre
(Figura 3.10).
In questo caso possiamo assumere che la prima dimensione indica la posizione di
paginazione della corrispettiva tabella nell’ambito di un astratto spazio tridimensionale,
mentre la seconda e la terza dimensione indicano, come di consueto, la quantità di righe e
colonne della tabella.
Tuttavia, per C, anche in questo caso, un array a 3 dimensioni è in realtà un array a una
dimensione dove ogni elemento è esso stesso un array a una dimensione e dove ogni
elemento di quest’ultimo array è esso stesso un array a una dimensione (array di array di
array).
Dichiarazione
Un array tridimensionale si dichiara utilizzando la seguente sintassi, dove si noti
l’utilizzo di tre coppie di parentesi quadre [ ] ciascuna a indicare una dimensione (Sintassi
3.8).
Sintassi 3.8 Dichiarazione di un array tridimensionale.
data_type identifier[NUMBER_OF_PAGES][NUMBER_OF_ROWS][NUMBER_OF_COLS];
int main(void)
{
// dichiarazione dell'array 3D data con 3 tabelle di 2×3 elementi
int data[NR_OF_PAGES][NR_OF_ROWS][NR_OF_COLS];
...
}
Lo Snippet 3.8 dichiara l’array tridimensionale data di tipo int che rappresenterà una pila
di 3 (NR_OF_PAGES) tabelle, ciascuna con 2 (NR_OF_ROWS) righe e 3 (NR_OF_COLS) colonne. Gli
elementi totali dell’array 3D saranno 18, valore che è dato dalla moltiplicazione del numero
di pagine (3), per il numero di righe (2) e colonne (3) della relativa tabella.
Figura 3.11 Una possibile rappresentazione concettuale dell’array data come pila di tabelle.
Inizializzazione
Un array tridimensionale può essere inizializzato (Sintassi 3.9) contestualmente alla sua
dichiarazione in modo da fornire valori significativi ai corrispondenti elementi.
int main(void)
{
// array 3D con 3 tabelle di 2×3 elementi
int data[NR_OF_PAGES][NR_OF_ROWS][NR_OF_COLS] =
{
{ // prima tabella
{10, 100}, // prima riga
{-10, -1000} // seconda riga
},
{ // seconda tabella
{44}, // prima riga
{55} // seconda riga
},
// terza tabella: utilizzo di un inizializzatore designato
// e vi pongo 999 alla riga 0 e colonna 0
[2][0][0] = 999
};
...
}
Subscripting
Dopo la fase di dichiarazione e/o di inizializzazione di un array tridimensionale gli
elementi relativi possono essere utilizzati in fase di lettura e scrittura mediante la Sintassi
3.10.
int main(void)
{
// array 3D con 3 tabelle di 2×3 elementi
int data[NR_OF_PAGES][NR_OF_ROWS][NR_OF_COLS] =
{
{ // prima tabella
{10, 100}, // prima riga
{-10, -1000} // seconda riga
},
{ // seconda tabella
{44},
{55}
},
// terza tabella; utilizzo un inizializzatore designato
// e vi pongo 999 alla riga 0 e colonna 0
[2][0][0] = 999
};
// accesso alla prima colonna della seconda riga della seconda tabella
// element conterrà il valore 55
int element = data[1][1][0];
// scrittura nella seconda colonna della prima riga della terza tabella
data[2][0][1] = -630;
...
}
#define NR_OF_PAGES 3
#define NR_OF_ROWS 20
#define NR_OF_COLS 50
int main(void)
{
// inizializzazione del generatore pseudo-casuale dei numeri
srand((unsigned int) time(NULL));
// array 3D
int data[NR_OF_PAGES][NR_OF_ROWS][NR_OF_COLS];
return (EXIT_SUCCESS);
}
Il Listato 3.3 mostra come creare un array tridimensionale che vuole modellare un
ipotetico taccuino composto da 3 pagine dove ciascuna pagina è composta da 20 righe e 50
colonne che conterranno come elementi dei codici numerici scelti a caso tra 0 e 999.
Inoltre, per ogni pagina, è memorizzato in un apposito vettore sum la somma di tutti gli
elementi, che è infine mostrata a video con la consueta istruzione printf.
Per la visita dell’array tridimensionale utilizziamo tre cicli for, dove quello più esterno fa
scorrere l’indice delle pagine, quello a esso innestato fa scorrere l’indice delle righe e quello
a sua volta a quest’ultimo innestato fa scorrere l’indice delle colonne.
Anche in questo caso, dunque, come nell’esempio della visita di un array bidimensionale,
i cicli for nidificati rivestono un ruolo fondamentale per il corretto processing degli elementi
degli array multidimensionali.
se è 15999 allora 15999 % 1000 produce 999 e così via. La funzione srand, invece, è stata
inizializzata con un valore ritornato dalla funzione time, dichiarata nel file header <time.h>, che,
essendo uguale ai secondi passati dalla mezzanotte del 1° gennaio 1970 fino al momento
dell’invocazione, consente di ottenere un seme sempre differente e di conseguenza una
sequenza diversa di numeri pseudo-casuali a ogni esecuzione del programma e dunque una
differente somma degli elementi delle tre tabelle.
#define X 100
#define Y 100
#define Z 20
#define false 0
#define true 1
int main(void)
{
// uno spazio tridimensionale: coordinate x, y e z
_Bool space[X][Y][Z] =
{
{
{false} // assenza di punti in tutte le coordinate
}
};
return (EXIT_SUCCESS);
}
Il Listato 3.4 dichiara e inizializza l’array a tre dimensioni space atto a contenere
informazioni se un punto è presente (valore true) o meno (valore false) in una determinata
locazione spaziale. A tal fine assegniamo il valore true a sei posizioni dell’array space con le
successive istruzioni di assegnamento e mediante l’utilizzo della consueta square bracket
notation.
In conclusione, utilizziamo il solito ciclo for per scorrere i valori dell’array e per
stampare a video solo le coordinate dove è effettivamente presente un punto.
Array di lunghezza variabile
Gli array sin qui esaminati, sia monodimensionali sia multidimensionali, nella loro fase di
dichiarazione, hanno espresso la quantità di elementi in essi contenuti tramite delle
espressioni che fornivano un valore costante intero.
A partire da C99, invece, è possibile dichiarare un array fornendo agli operatori di
subscript relativi dei valori che possono derivare anche da valutazioni di variabili, e che
sono perciò computati a run-time dal compilatore.
Questa caratteristica, che da C11 è diventata opzionale, permette quindi la creazione dei
cosiddetti array di lunghezza variabile (variable-length array, abbreviati in VLA), ossia di
array la cui dimensione, per l’appunto, è variabile, cioè ricavabile anche da espressioni che
non producono necessariamente valori costanti di tipo intero.
Grazie a questa feature, è possibile creare degli array con un congruo e non arbitrario
numero di elementi; ossia che non hanno più elementi del necessario e dunque non si spreca
inutilmente la memoria; che non hanno meno elementi del necessario e dunque si evita che
il programma possa fallire a causa di inserimenti di valori in posizioni di memoria non
relative all’array, cioè non allocate correttamente.
int main(void)
{
int dim;
printf("Digita la dimensione del vettore: ");
scanf("%d", &dim);
return (EXIT_SUCCESS);
}
Il Listato 3.5 consente di inserire da tastiera un numero arbitrario di elementi con cui sarà
creato il vettore vector. Tale vettore, infatti, è dichiarato in modo che a run-time il
compilatore allochi una quantità di memoria che è pari alla dimensione di un int per il
numero di elementi assegnato alla variabile dim, che è poi usata nell’ambito delle parentesi
quadre [ ] per dichiarare il predetto vettore vector (per esempio, se dim sarà pari a 5 avremo
che il compilatore allocherà, dato un int di 4 byte, 20 byte di memoria atti a contenere i
cinque elementi dell’array vector).
Dopo la creazione del vettore il programma permette anche di ottenere da tastiera dei
valori che saranno inseriti negli elementi specificati da dim e che serviranno per
inizializzarlo con valori significativi.
I valori degli elementi di questo vettore sono poi copiati negli elementi di un altro vettore,
denominato other_vector, e infine i valori degli elementi di entrambi i vettori sono stampati a
video al fine di permettere di verificare che entrambi i vettori contengano elementi con gli
stessi valori.
IMPORTANTE
In C non è possibile assegnare direttamente un array a un altro array per copiare i relativi
elementi. Un’istruzione come other_vector = vector non è dunque sintatticamente corretta.
Infine, nell’utilizzo degli array di tipo VLA bisogna tenere presenti le seguenti limitazioni
(Snippet 3.11):
non possono essere inizializzatati contestualmente alla loro dichiarazione con le
consuete liste di inizializzatori;
non possono avere lo specificatore di classe di memorizzazione static o extern;
possono essere dichiarati solo nelle funzioni (anche come parametri), in qualsiasi
blocco di codice e anche come parametri nei prototipi di funzioni.
int main(void)
{
// vettore read-only
const int data[SIZE] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Lo Snippet 3.12 mostra in modo inequivocabile come l’array data, essendo qualificato
come costante, non permetta dopo la sua inizializzazione di cambiare i valori dei suoi
elementi. Infatti, l’istruzione data[1] causa un errore in fase di compilazione perché tenta di
assegnare il valore 100 al secondo elemento dell’array data, che però è una locazione di
memoria a sola lettura.
La dimensione degli array e l’operatore sizeof
L’operatore sizeof, già visto per l’utilizzo con i tipi di dati, è impiegato anche per
ritornare, sempre in byte, la dimensione del tipo di un suo operando che può essere una
variabile, un array o una qualsiasi espressione unaria, ossia un’espressione che contiene un
operando e un operatore unario (Sintassi 3.11).
NOTA
Nei casi di utilizzo citati, le parentesi tonde non sono obbligatorie. Pertanto, se j è una
variabile di un determinato tipo, è del tutto equivalente scrivere sizeof(j) oppure sizeof j.
Nel caso di un array, applicare a esso l’operatore sizeof consente di ottenere il numero di
totale di byte di tutti i suoi elementi.
#define SIZE 10
int main(void)
{
// un array monodimensionale
int data[SIZE] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
return (EXIT_SUCCESS);
}
Il Listato 3.6 mostra un pattern molto comune impiegato per “scoprire” la quantità di
elementi di un array che prevede, in primo luogo, la valutazione delle due seguenti
espressioni contenenti l’operatore sizeof considerando un sistema target dove il tipo int è di
4 byte:
sizeof data ritorna la quantità in byte di tutti gli elementi dell’array, ovvero 40;
sizeof data[0] ritorna la quantità in byte del primo elemento dell’array, ovvero 4.
Un operatore è definibile come una sorta di istruzione che agisce su dei dati, detti
operandi, e permette di ottenere un risultato eseguendo un’operazione. Ogni operatore è
rappresentato da simboli che determinano su quali operandi agisce.
Quando in un’espressione si incontrano diversi operatori, l’ordine di esecuzione viene
scelto in base alla precedenza (che indica se un operatore ha priorità maggiore di un altro) e
all’associatività (se più operatori hanno la stessa precedenza allora viene eseguito, nel caso
dell’associatività da sinistra a destra, prima quello che si trova più a sinistra e poi a seguire
gli altri sempre da sinistra; nel caso dell’associatività da destra a sinistra, viene eseguito
prima quello che si trova più a destra e poi sempre da destra a seguire gli altri).
In ogni caso, l’ordine di precedenza prestabilito può essere variato con l’utilizzo
dell’operatore parentesi tonde ( ), che ha la precedenza più alta in assoluto; se vi sono varie
coppie di parentesi annidate, la priorità sarà dalla più interna verso quella più esterna,
mentre se ve ne sono varie sullo stesso livello, l’associatività sarà da sinistra a destra.
In ogni caso è bene tenere presente che non vi è alcuna regola di precedenza tra gli
operandi, data un’espressione complessa formata da più operatori e operandi, la cui
decisione su quale valutare per primo è lasciata all’arbitrio dell’implementazione, e ciò per
le consuete ragioni di efficienza: su una determinata architettura hardware potrebbe essere
per l’appunto più conveniente, valutare prima un operando piuttosto che un altro.
NOTA
In espressioni dove vi sono gli operatori logical AND con simbolo &&, logical OR con simbolo
||, conditional con simboli ?: e comma con simbolo , l’ordine di precedenza degli operandi
nelle relative sotto-espressioni è invece garantito: verranno valutati prima quelli scritti più a
sinistra e poi a seguire gli altri (left-to-right).
CONSIGLIO
Non scrivere mai codice che dipende dall’ordine di valutazione degli operandi in espressioni
complesse perché questo non è portabile tra sistemi diversi che possono scegliere, come
già detto, una priorità di valutazione piuttosto che un’altra. Questo non riguarda però l’ordine
di valutazione degli operatori, le cui regole sono ben definite e non arbitrarie. Consultare a
tal fine la Tabella 4.7 di precedenza degli operatori posta alla fine del corrente capitolo.
Lo Snippet 4.1 assegna alla variabile res il risultato della valutazione dell’espressione
posta alla destra dell’operatore di assegnamento =.
In questo caso, per le regole di precedenza degli operatori, l’operatore di moltiplicazione
* e l’operatore di divisione / hanno una precedenza più alta rispetto all’operatore di
1. a - 5 dà come risultato 5 che viene assegnato alla variabile b. Poi, 11 viene assegnato
alla variabile a. Quindi la valutazione di b + a dà come valore 16;
2. a = 11 assegna tale valore ad a. Poi, a - 5 dà come risultato 6 che viene assegnato alla
variabile b. Quindi la valutazione di b + a dà come valore 17.
NOTA
Alcuni compilatori, quando si compila codice contenente istruzioni come quelle dello Snippet
4.2, possono riportare un messaggio di avviso di comportamento non definito. Con GCC, se
usiamo il flag -Wall oppure il flag esplicito -Wsequence-point, avremo il seguente messaggio:
warning: operation on 'a' may be undefined.
Per evitare, dunque, ambiguità sul risultato valutato non bisogna mai usare quel tipo di
sotto-espressioni ma piuttosto “scomporle” in varie espressioni separate che diano chiarezza
sul quale possa essere il risultato atteso (Snippet 4.3).
Snippet 4.3 Ordine di valutazione di operatori e operandi: eliminazione di ambiguità del risultato.
int a = 10;
int b;
// ordine di valutazione esplicita delle espressioni che useranno la variabile a
b = a - 5; // prima questa espressione...
a = 11; // poi questa espressione...
int res = b + a; // 16
Terminologia essenziale: un dettaglio
Prima di iniziare la disamina degli operatori è opportuno soffermarci sulla corretta
semantica dei termini che lo standard utilizza per descrivere gli elementi fondamentali del
linguaggio considerando che, per alcuni di essi, già citati nel Capitolo 1, amplieremo il
dettaglio esplicativo, mentre di altri forniremo una descrizione ex-novo.
L’importanza di precisare il significato di questi termini, se da un lato può apparire
pedante, dall’altro lato può divenire essenziale per comprendere in modo chiaro e
approfondito i successivi elementi didattici trattati, dove utilizzeremo una terminologia la
cui semantica dovrà essere stata già stata correttamente appresa.
object indica una regione o area della memoria impiegata per memorizzare dei valori.
Un nome di una variabile, un elemento di un array e così via sono modi per identificare
un object.
lvalue indica un’espressione che identifica, localizza, l’area di memoria propria di un
object. Il termine lvalue proviene da una contrazione di left value (valore di sinistra) e
sta indicare una qualsiasi espressione che può comparire come operando a sinistra
dell’operatore di assegnamento (per esempio, se data è una variabile di tipo int la
stessa può comparire in un’istruzione come data = 10). In ogni caso, per effetto del
qualificatore const un lvalue può rappresentare un object ma può non essere un left
value valido. Pertanto, lo standard attuale, predilige designare un lvalue come un
locator value piuttosto che come un left value esplicitando che, data un’istruzione
generale di assegnamento come E1 = E2, E1 deve essere un modifiable lvalue, cioè un
left value modificabile.
ATTENZIONE
Un’istruzione come const int data = 10 è lecita perché in questo contesto l’operatore =
rvalue indica un valore risultato dalla valutazione di un’espressione che può essere
assegnato a un lvalue modificabile. Il termine rvalue è una contrazione di right value e
sta a indicare, per l’appunto, una costante, una variabile o una qualsiasi espressione che
produce un valore e che può comparire come operando a destra dell’operatore di
assegnamento (per esempio, considerando sempre la nostra variabile data, in
un’istruzione come data = 1999, 1999 è un rvalue). L’attuale standard usa il termine
value of an expression per riferirsi a un rvalue.
expression indica, solitamente, un’espressione, che è definibile come una sequenza di
operatori e operandi atti a computare un valore. Un operando è esso stesso
un’espressione (si pensi al nome di una variabile che valutato ne ritorna il valore
contenuto) e per subexpression si intende una sotto-espressione, ossia un’espressione
che è parte di un’espressione più complessa (per esempio, nell’espressione a * b - c /
d, a * b ec / d sono considerabili come sue sotto-espressioni). Per full expression,
invece, si intende un’espressione che non è parte di un’altra espressione (non è una
subexpression). Esempi di full expression sono quelle utilizzate nelle istruzioni if,
switch, do, while, for e return.
statement indica un’istruzione completa atta a compiere una qualche operazione
nell’ambito di un programma. In C, si formalizza mediante l’apposizione, alla fine, del
carattere punto e virgola (;). Così, qualcosa come data = 4 è un’espressione che può
comparire nell’ambito di un’espressione più complessa, ma data = 4; è un’istruzione
completa denominata expression statement. Un’espressione di un’expression statement
è considerata una full expression. In realtà qualsiasi espressione può essere
“convertita” in una statement apponendole il punto e virgola finale. Così scrivere 8;
oppure 10 - 4; non produrrebbe alcun errore in fase di compilazione; al massimo
potremmo avere un avviso da parte del compilatore che tali statement non hanno
effetto (con GCC, se usiamo il flag -Wall oppure il flag specifico -Wunused-value, viene
emesso il messaggio warning: statement with no effect). Per compound statement si
intende, infine, un gruppo di due o più statement, racchiuse tra le partentesi graffe
aperte/chiuse { }, che formano un’unica unità sintattica (questa istruzione composta è
anche chiamata block, poiché forma un blocco di codice).
side effect indica un’istruzione che modifica lo stato di un object o di un file. Per
esempio, una statement come data = 100; ha come effetto collaterale quello di cambiare
il contenuto della locazione di memoria riferito dalla variabile data.
sequence point indica un “punto” nell’ambito dell’esecuzione di un programma dove
vi è la garanzia che tutte le operazioni che producono dei side effect siano state
eseguite prima di procedere con il prossimo flusso esecutivo. Questo significa che se si
hanno due espressioni A e B, la presenza di un sequence point tra la valutazione di A e B
garantisce che la computazione di A o qualsiasi side effect causato di A avvenga
(sequenziati) prima della computazione o di side effect causati da B. Seguono alcuni
esempi di sequence point così come sono espressamente indicati dallo standard C11:
tra la valutazione del primo e del secondo operando degli operatori &&, ||, , (comma);
tra la valutazione di una full expression e la successiva full expression da valutare; tra
la valutazione del primo operando dell’operatore ?: e il secondo oppure il suo terzo
operando (a seconda di quello che viene valutato); tra la valutazione di un’invocazione
di funzione e relativi argomenti e la effettiva invocazione; immediatamente prima che
una funzione ritorni al chiamante.
Operatore di assegnamento semplice
L’operatore di assegnamento, con simbolo uguale (=), consente di assegnare un valore in
una variabile ovvero, detto in termini più rigorosi, permette di porre un valore computato da
un’espressione posta alla sua destra, nell’area di memoria rappresentata da un lvalue
modificabile posto alla sua sinistra.
Da ciò ne discende che non è possibile porre alla sinistra dell’operatore = un rvalue come
è il caso, per esempio, di un letterale costante o di una costante.
TERMINOLOGIA
Il termine assegnamento è utilizzato quanto assegnazione ed entrambi hanno praticamente
lo stesso significato. Per i programmatori è più consueto parlare di assegnamento, forse
perché c’è maggiore assonanza con il termine inglese assignment.
Infine, questo operatore associa da destra a sinistra, permettendo così di effettuare una
catena di assegnamenti.
TERMINOLOGIA
Un operatore si dice binario o diadico quando richiede due operandi e unario quando
richiede un operando. In C vi è anche l’operatore condizionale ?:, che è ternario perché
richiede tre operandi.
Operatore di addizione
L’operatore di addizione con simbolo più + consente di sommare il valore dell’operando
posto alla sua sinistra con il valore dell’operando posto alla sua destra.
Entrambi gli operandi sono denominati addendi e il risultato dell’operazione è detto
somma.
Gli operandi possono essere sia lvalue sia rvalue e tale operatore associa da sinistra a
destra.
Operatore di sottrazione
L’operatore di sottrazione con simbolo meno - consente di sottrarre il valore
dell’operando posto alla sua destra, che rappresenta il sottraendo, dal valore dell’operando
posto alla sua sinistra, che rappresenta il minuendo. Il risultato dell’operazione è
denominato differenza.
Gli operandi possono essere sia lvalue sia rvalue e tale operatore associa da sinistra a
destra.
Operatore di moltiplicazione
L’operatore di moltiplicazione con simbolo asterisco * consente di moltiplicare il valore
dell’operando posto alla sua sinistra, che rappresenta il moltiplicando, con il valore
dell’operando posto alla sua destra, che rappresenta il moltiplicatore. Il risultato
dell’operazione è denominato prodotto.
Gli operandi possono essere sia lvalue sia rvalue e tale operatore associa da sinistra a
destra.
CURIOSITÀ
In aritmetica il simbolo comunemente utilizzato per denotare la moltiplicazione è una sorta
di croce ruotata × (codice Unicode U+00D7), introdotto nel 1631 dal matematico inglese
William Oughtred, che non bisogna però confondere con la x minuscola (codice Unicode
U+0078). In algebra, invece, il simbolo utilizzato per il prodotto è un punto con simbolo ·
(codice Unicode U+22C5) detto dot operator. In informatica, infine, il simbolo * usato per la
moltiplicazione si fa risalire al FORTRAN, che è stato uno dei primi linguaggi di
programmazione ad alto livello, nato negli anni Cinquanta, orientato al calcolo scientifico e
numerico.
Operatore di divisione
L’operatore di divisione con simbolo barra (/) consente di dividere il valore
dell’operando posto alla sua sinistra, che rappresenta il dividendo, con il valore
dell’operando posto alla sua destra, che rappresenta il divisore. Il risultato dell’operazione è
denominato quoto.
Gli operandi possono essere sia lvalue sia rvalue e tale operatore associa da sinistra a
destra.
Quando si utilizza l’operatore di divisione bisogna considerare se gli operandi sono di
tipo intero oppure di tipo in virgola mobile. Nel primo caso, infatti, se il risultato della
divisione ha anche una parte frazionaria quest’ultima è scartata (troncata), mentre nel
secondo caso il risultato, essendo di tipo in virgola mobile, conserva anche la parte
frazionaria.
Inoltre, se l’operando divisore è 0 si causerà un comportamento non definito (undefined
behavior), ossia il relativo programma potrà comportarsi in modo diverso su diversi
compilatori oppure non compilarsi, bloccarsi e così via.
E se gli operandi della divisione tra interi hanno valori negativi come viene “trattato” un
eventuale risultato con parte frazionaria? In questo caso, secondo C89 un’implementazione
è libera se arrotondare il numero in eccesso o in difetto sempre eliminando la parte
frazionaria, mentre a partire da C99 il numero è sempre troncato eliminando le cifre
decimali (truncating toward zero).
Operatore modulo
L’operatore modulo con simbolo percentuale (%) consente di ottenere un valore che
rappresenta il resto di una divisione tra due operandi interi.
Non è possibile quindi utilizzare con l’operatore modulo tipi che non siano interi come,
per esempio, quelli in virgola mobile, altrimenti il compilatore genererà un errore specifico.
Gli operandi possono essere sia lvalue sia rvalue e tale operatore associa da sinistra a
destra.
Per quanto attiene alla possibilità di avere operandi negativi, le regole su come debba
essere prodotto il risultato del resto sono: per C89, il segno del resto dipende
dall’implementazione; a partire da C99, il segno del resto è negativo se il primo operando è
negativo altrimenti è positivo.
Infine, come per l’operatore di divisione, se l’operando di destra vale 0 il comportamento
sarà non definito.
int n = -7, m = 5;
int other_mod = n % m; // -2 perché n negativo
DETTAGLIO
Se abbiamo due valori interi, diciamo j e k, allora possiamo ottenere il valore del loro resto
(j % k) con la seguente formula: j - (j / k) * k. Per esempio, 5 / 4 ha come resto il valore 1
risultato del calcolo dell’espressione 5 - (5 / 4) * 4.
Operatori unari + e –
Gli operatori unari con simboli più + e meno - consentono, rispettivamente, di ritornare il
valore dell’operando senza alterarne il segno e di ritornare il valore dell’operando
alterandone il segno (in pratica il valore è il negativo dell’operando).
L’operando può essere sia un lvalue sia un rvalue e tali operatori associano da destra a
sinistra.
In effetti, il + unario è stato introdotto con C90 per simmetria con il - unario ed è usabile
per evidenziare che un valore è positivo (nel K&R C tale operatore non era presente).
NOTA
In matematica un’espressione come n = n + 1 non ha alcun senso. Infatti, se aggiungiamo 1
a un qualsiasi valore di n il risultato non potrà mai essere uguale al numero n stesso. In C, di
converso, quell’espressione ha un preciso significato, ossia: leggi il valore della variabile n,
sommagli 1 e poi assegna tale risultato nella variabile n medesima.
Lo Snippet 4.14 assegna alla variabile intera res il valore 1, risultato della computazione
del valore della variabile a meno il valore della variabile b. Infatti, dato che sia a sia b hanno
un operatore di incremento e decremento postfisso, prima viene ottenuto il rispettivo valore
(per a è 10 e per b è 9) e poi ne vengono effettuati l’incremento e il decremento.
Suffraga quando detto il valore della variabile intera after che ha come valore 19, risultato
dall’addizione del valore di a, che dopo l’incremento vale 11, e il valore di b, che dopo il
decremento vale 8.
Lo Snippet 4.15 assegna alla variabile intera res il valore 3, risultato della computazione
del valore della variabile a meno il valore della variabile b. In questo contesto, dato che sia a
sia b hanno un operatore di incremento e decremento prefisso, prima ne viene effettuato
l’incremento e il decremento (a varrà 11 e b varrà 8) e poi ne viene ottenuto il rispettivo
valore che è utilizzato per l’operazione di sottrazione.
In più, come nello Snippet 4.14, la variabile intera after ha come valore 19, risultato
dall’addizione del valore di a, che vale 11, e il valore di b, che vale 8.
int main(void)
{
int vector[SIZE] = {0};
int ix = 5;
Nello Snippet 4.16 definiamo il vettore vector di 10 elementi e poi usiamo un’espressione
che nelle nostre intenzioni “dovrebbe” assegnare all’elemento nella posizione 5 (valore di
ix) la somma tra il letterale intero 2 e il valore della variabile ix.
NOTA
Ricordiamo che in C qualsiasi valore diverso da 0 è considerato vero. Pertanto valori come
10 oppure -3 sono entrambi valutati come veri. Quanto detto riguarda solo il fatto che
un’espressione relazionale, quando valutata, ritorna 0 per indicare un risultato falso e 1 per
indicare un risultato vero.
ATTENZIONE
Un’espressione relazionale, come per esempio a < b < c, non verifica se b è tra a e c ma per
effetto dell’associatività da sinistra a destra è valutata come segue: (a < b) < c, ossia: il
valore ritornato dalla comparazione di a < b, 0 o 1, è poi comparato con il valore di c e viene
ritornato 0 o 1. Come vedremo, per avere quell’esito, dovremo far uso dell’operatore AND
logico e scrivere qualcosa come a < b && b < c.
Operatore maggiore di
L’operatore maggiore di con simbolo > esegue un confronto tra le espressioni suoi
operandi e determina se il valore dell’espressione posta alla sua sinistra è maggiore del
valore dell’espressione posta alla sua destra.
In caso affermativo ritorna il valore 1, in caso contrario ritorna il valore 0.
Operatore minore di
L’operatore minore di con simbolo < esegue un confronto tra le espressioni suoi operandi
e determina se il valore dell’espressione posta alla sua sinistra è minore del valore
dell’espressione posta alla sua destra.
In caso affermativo ritorna il valore 1, in caso contrario ritorna il valore 0.
Vediamo, infine, un esempio che fa uso degli operatori relazionali (Listato 4.1) in cui,
data una matrice di valori, si cerca di determinare se vi sono dei valori che sono minori di
altri valori, passati come criteri di ricerca, e per ogni valore che soddisfa tale condizione si
incrementa di un’unità una variabile che tiene traccia della quantità trovata.
#define SIZE 3
#define NR_ROWS 3
#define NR_COLS 3
int main(void)
{
// matrice per la ricerca
int values[NR_ROWS][NR_COLS] =
{
{10, 20, 30},
{-22, -11, -18},
{105, 205, -963}
};
printf("Il valore %4d e' minore del valore %3d ?", value1, value2);
return (EXIT_SUCCESS);
}
Operatore uguale a
L’operatore uguale a con simbolo == esegue un confronto tra le espressioni suoi operandi
e determina se il valore dell’espressione posta alla sua sinistra è uguale al valore
dell’espressione posta alla sua destra.
In caso affermativo ritorna il valore 1, in caso contrario ritorna il valore 0.
Nello Snippet 4.22 l’espressione sarà valutata come vera (1) perché verranno eseguiti: a <
b che darà 0 (è falso che 120 è minore di 111); c > d che darà 0 (è falso che 111 è maggiore di
112); 0 == 0 (è vero che 0 è uguale a 0).
L’espressione a < b == c > d è stata valutata nell’ordine degli operatori sopra descritti
perché gli operatori relazionali hanno una precedenza maggiore rispetto agli operatori di
eguaglianza. A volte, comunque, per ragioni di chiarezza, l’espressione descritta è
codificabile nel seguente modo del tutto equivalente (a < b) == (c > d).
Operatore non uguale a
L’operatore non uguale a con simbolo != esegue un confronto tra le espressioni suoi
operandi e determina se il valore dell’espressione posta alla sua sinistra è non uguale o
diversa rispetto al valore dell’espressione posta alla sua destra.
In caso affermativo ritorna il valore 1, in caso contrario ritorna il valore 0.
Per l’operatore OR logico (||) l’espressione complessa sarà valutata vera (valore 1) se
una delle espressioni semplici che la costituiscono sarà vera o se entrambe le espressioni
semplici saranno vere. Se la prima espressione semplice risulterà falsa, la valutazione
dell’altra espressione verrà effettuata per verificare se sarà vera, e allora l’espressione
complessa sarà vera (valore 1) altrimenti sarà falsa (valore 0).
Tabella 4.3 Operatore OR logico (||).
Espressione 1 Espressione 2 Espressione 1 || Espressione 2
falsa (0) falsa (0) falsa (0)
falsa (0) vera (non 0) vera (1)
vera (non 0) falsa (0) vera (1)
vera (non 0) vera (non 0) vera (1)
Una proprietà importante degli operatori AND logico e OR logico riguarda il fatto che,
nel loro caso, l’ordine di valutazione degli operandi è garantito, ossia viene sempre valutato
prima l’operando sinistro e poi l’operando destro.
Ciò implica che questi operatori marcano dei sequence point e pertanto tutte le operazioni
definite dall’operando di sinistra, anche quelle che producono side effect, sono valutate e
completate prima che il compilatore passi a valutare e a completare le operazioni
dell’operando di destra.
Lo Snippet 4.24 è lecito perché, per effetto della garanzia che l’operando di sinistra è
sempre valutato prima dell’operando di destra, il side effect sulla variabile x a opera
dell’istruzione x++ ha luogo prima che la stessa variabile x subisca l’addizione del valore 1 a
opera dell’istruzione x + 1.
Ciò avviene, ribadiamo, perché l’operatore AND logico && marca un sequence point, e ciò
garantisce che la variabile x sia incrementata a opera dell’operatore di incremento postfisso
++ prima che l’espressione dell’operatore destro sia valutata e computata.
Infine, la circostanza che la valutazione degli operandi degli operatori && e || avvenga da
sinistra a destra produce un’ulteriore conseguenza che in letteratura è definita come
valutazione di cortocircuito.
Il modo di operare degli operatori && e || è una valutazione di cortocircuito proprio
perché essi interrompono le altre eventuali valutazioni se sono subito soddisfatte le seguenti
condizioni che consentono immediatamente di rilevare il risultato di tutta l’espressione
complessa: per l’operatore && se l’espressione alla sua sinistra è falsa, allora tutta
l’espressione complessa sarà subito falsa (l’espressione alla sua destra non verrà valutata);
per l’operatore || se l’espressione alla sua sinistra è vera, allora tutta l’espressione
complessa sarà subito vera (l’espressione alla sua destra non verrà valutata).
Operatore OR logico
L’operatore OR logico con simbolo || esegue una comparazione tra i valori dei suoi
operandi e ritorna il valore 1 se uno di essi è non uguale a 0, altrimenti ritorna il valore 0.
La Sintassi 4.1 si legge nel seguente modo: “Se expression_1 è vera (non uguale a 0), allora
(rappresentato dal simbolo ?) valuta expression_2, altrimenti (rappresentato dal simbolo :)
valuta expression_3”.
In definitiva il secondo operando è valutato solo se il primo operando è diverso da 0
mentre il terzo operando è valutato solo se il primo operando è uguale a 0.
In risultato dell’espressione condizionale sarà dunque il valore del secondo o del terzo
operando a seconda di quello che verrà valutato.
NOTA
Ricordiamo che l’operatore condizionale introduce un sequence point e pertanto il suo
operando di sinistra (il primo operando) sarà completamente valutato prima della
valutazione del secondo oppure del terzo operando.
L’esempio seguente (Listato 4.2) mostra un utilizzo dell’operatore condizionale con cui
verifichiamo se, dato un valore estratto dalla matrice values che sia pari, lo stesso sia
maggiore del valore 33 (filter_value); se tale verifica è positiva, lo memorizziamo nell’array
found_values nella stessa posizione dove si trovava nell’ambito della sua matrice originaria;
altrimenti, in quella posizione, memorizziamo il valore 0.
Infine, per la verifica del risultato mostriamo a video il contenuto del predetto array.
#define SIZE 9
#define NR_ROWS 3
#define NR_COLS 3
int main(void)
{
// matrice per la ricerca
int values[NR_ROWS][NR_COLS] =
{
{10, 100, 30},
{-22, -11, 66},
{105, 204, 333}
};
return (EXIT_SUCCESS);
}
CONSIGLIO
Per comprendere in modo più compiuto il funzionamento degli operatori bit per bit si
raccomanda di leggere preliminarmente l’Appendice C.
La Tabella 4.6, invece, indica come tali operatori, eccetto quelli di scorrimento, agiscono
sui bit: ogni riga evidenzia infatti la valutazione di un’espressione tra A e B, contenenti la
cifra 0 o 1, rispetto all’operatore utilizzato.
Tabella 4.6 Valutazione di espressioni con gli operatori bit per bit (eccetto gli operatori di
scorrimento).
A B ~A A&B A|B A^B
0 0 1 0 0 0
1 0 0 0 1 1
0 1 1 0 1 1
1 1 0 1 1 0
uno);
per gli operatori AND, OR inclusivo e OR esclusivo, sui relativi operandi, saranno
applicate le regole viste per le conversioni aritmetiche abituali (per esempio, se
abbiamo un operando di tipo int e un operando di tipo long, allora tutta l’espressione
produrrà un risultato di tipo long);
per gli operatori di scorrimento si avrà sempre una promozione integrale dei propri
operandi e il tipo del risultato sarà quello dell’operando di sinistra promosso. Se
l’operando di destra è negativo oppure è più grande o uguale dell’ampiezza
dell’operando di sinistra, il risultato sarà non definito.
// 1111 0101
unsigned char result = (unsigned char) ~number; // 245
// 0001 0100
unsigned char number_2 = 20;
// 0001 0100
unsigned char result = (unsigned char) number_1 & number_2; // 20
// 0001 0100
unsigned char number_2 = 20;
// 0001 1110
unsigned char result = (unsigned char) number_1 | number_2; // 30
// 0001 0100
unsigned char number_2 = 20;
// 0001 1110
unsigned char result = (unsigned char) number_1 ^ number_2; // 10
// 0000 0001
unsigned char positions = 1;
// 1000 0000
unsigned char result = (unsigned char) (number << positions); // 128 o 64 * 21
// 0000 0010
unsigned char positions = 2;
// 0001 0000
unsigned char result = (unsigned char) (number >> positions); // 16 o 64 / 22
Per tutti, il bit meno significativo si trova alla posizione 0 e il bit più significativo si trova
alla posizione 15 (per lo short), 31 (per l’int) o 7 (per il char).
Impostazione di singoli bit.
Problema: dato un valore vogliamo scegliere uno o più bit di esso da impostare con il
valore 1.
Soluzione: utilizzare l’operatore OR inclusivo con il suo operando sinistro che
rappresenta il valore da manipolare e il suo operando destro che rappresenta una maschera
con i bit da “accendere” nel valore impostati a 1.
Spiegazione: i bit di una maschera impostati a 1 combinati in OR inclusivo con i
corrispondenti bit di un valore, impostati a 0, li impostano a 1. I bit di una maschera
impostati a 0 lasciano, invece, invariati i corrispondenti bit di un valore.
// valore decimale: 96
// valore binario: 0000 0000 0110 0000
// valore esadecimale: 60
const unsigned short MASK = 0x0060;
// valore decimale: 4
// valore binario: 0000 0000 0000 0100
// valore esadecimale: 4
value = value & ~MASK; // cancello i bit 5 e 6
Verifica di un bit.
Problema: dato un valore vogliamo sapere se uno o più bit di esso è impostato con il
valore 1.
Soluzione: utilizzare l’operatore AND con il suo operando sinistro che rappresenta il
valore da manipolare e il suo operando destro che rappresenta una maschera con i bit da
verificare nel valore impostati a 1.
Spiegazione: i bit di una maschera impostati a 1 combinati in AND con i corrispondenti
bit di un valore, impostati a 1, li preservano. I bit di una maschera impostati a 0 spengono,
invece, i corrispondenti bit di un valore.
// valore decimale: 4
// valore binario: 0000 0000 0000 0100
// valore esadecimale: 4
const unsigned short MASK = 0x0004;
// operatore
j = (a++, b = a - 5, c = c + b); // 18
// separatore
printf("%d %d %d %d\n", j, a, b, c); // 18 11 6 18
Lo Snippet 4.39 evidenzia l’utilizzo del simbolo virgola sia come semplice separatore (è
il caso della dichiarazione delle variabili a, b e c e dell’invocazione della funzione printf con
una serie di argomenti) sia come operatore (è il caso dell’inizializzazione della variabile j).
In quest’ultimo caso j conterrà il valore 18 perché la valutazione delle sotto-espressioni
delle espressioni con l’operatore virgola a destra dell’operatore di assegnamento procederà
da sinistra a destra e terrà anche conto dei relativi side effect.
A causa anche dell’associatività da sinistra a destra dell’operatore virgola la valutazione
delle espressioni contenenti l’operatore virgola avverrà nel seguente modo (è come se
l’espressione complessa fosse scritta come: ((a++, b = a - 5), c = c + b):
darà il valore 6, che sarà il valore di tutta l’espressione mentre il valore 11 sarà scartato;
2. il valore 6 diverrà l’operando sinistro dell’altra espressione, laddove la sotto-
espressione c = c + b sarà valutata e darà come risultato 18. Di questi il valore 6 sarà
scartato mentre il valore 18 diverrà il valore di tutta l’espressione.
Operatori di assegnamento composti
Gli operatori di assegnamento composti consentono di assegnare un valore a una
variabile che è uguale al valore della variabile medesima aggiornato con il valore di
un’altra espressione appositamente fornita.
La Sintassi 4.2 ne mostra una forma generale in cui op deve essere sostituito con uno
degli operatori della Tabella 4.8, E1 è l’operando di sinistra che deve essere un lvalue
modificabile ed E2 è l’operando di destra che può essere una qualsiasi espressione che
produce un lvalue oppure in rvalue.
Gli operatori di assegnamento composti associano da destra a sinistra.
Dalla Tabella 4.7 si evince come tutti gli operatori di assegnamento composti sono
formati da un simbolo che è uguale a un operatore come +, -, % e altri, cui segue, senza spazi,
il simbolo dell’operatore di assegnamento semplice =.
ATTENZIONE
Quando si utilizza un operatore di assegnamento composto, ricordarsi di indicare come
primo simbolo sempre l’operatore che indica il tipo di operazione da compiere (+, -, >> e altri)
e poi come ulteriore simbolo l’operatore di assegnamento =. Invertire gli operatori, tipo =- al
posto di -=, per quanto non costituisca un errore di sintassi, cambia la semantica
dell’espressione. Infatti, scrivere a =- 10; assegnerà ad a il valore -10 e non diminuirà il
precedente valore di a del valore 10.
L’equivalenza di E1 op= E2 con E1 = E1 op (E2) è però “parziale”, perché nel primo caso E1
è valutata solo una volta mentre nel secondo caso E1 è valutata due volte, e ciò ha
implicazioni importanti se la sua valutazione causa anche dei side effect.
a = 10;
// qui si vede, nel risultato, la non equivalenza con a = a * b + c
a = a * b + c; // 130
c rispetto ad a * b.
E2.
Infatti, per effetto dell’ordine di precedenza degli operatori * e +, avremo che prima sarà
eseguita la moltiplicazione tra a e b e poi tale valore sarà addizionato a c e questo risultato
sarà ad a medesima assegnato.
ix = 0;
NOTA
In C esistono anche altre tipologie di istruzioni, come le istruzioni composte (compound
statement), rappresentate dai blocchi di codice delimitati dalle parentesi graffe { }; le
istruzioni nulle (null statement), rappresentate dal singolo carattere punto e virgola; le
istruzioni espressione (expression statement), rappresentate da un’espressione terminata
dal carattere punto e virgola.
La Sintassi 5.2 mostra, invece, come scrivere un’istruzione if che esegue un blocco di
istruzioni se expression è diversa da 0. In pratica è sufficiente scrivere le istruzioni di
interesse all’interno della consueta coppia di parentesi graffe { }, le quali non
necessiteranno del punto e virgola finale che marca, solitamente, un’istruzione.
int main(void)
{
int a = -1;
// a è minore di 10?
if (a < 10)
printf("a < 10\n");
return (EXIT_SUCCESS);
}
Nel Listato 5.1 l’istruzione if valuta l’espressione a < 10 la quale ritorna come valore 1,
ossia una valore diverso da 0 che è dunque vero, e pertanto viene eseguita la correlativa
istruzione printf che manda in output la stringa "a < 10\n".
La Sintassi 5.3 ha la prima parte del tutto simile alla Sintassi 5.1 ma in più ha l’aggiunta
della keyword else (che rappresenta una clausola) e di un ulteriore statement che
rappresenta l’istruzione che verrà eseguita se expression risulterà pari a 0.
Se però expression risulterà diversa da 0, allora l’istruzione del ramo else non sarà eseguita
perché sarà eseguita l’istruzione del ramo if.
Anche in questo caso è possibile definire un blocco di istruzioni da eseguire, nel ramo if
oppure nel ramo else, ponendole tra le parentesi graffe { }.
int main(void)
{
int a = 5;
if (a >= 10)
printf("a >= 10\n"); // eseguita se a è maggiore o uguale a 10
else
printf("a < 10\n"); // eseguita in caso contrario
return (EXIT_SUCCESS);
}
La struttura di controllo if/else può essere costruita con più livelli di annidamento
considerando che lo standard stabilisce che un’implementazione ne deve garantire al
minimo 127.
int main(void)
{
int a = 3;
return (EXIT_SUCCESS);
}
Lo stesso codice, è scritto, quasi sempre, nel modo seguente (sicuramente più leggibile),
dove ogni else if è scritto nell’ambito della stessa riga e sono indentati esattamente a partire
dal primo if.
int main(void)
{
int a = 3;
if (a >= 10)
printf("a >= 10\n");
else if (a >= 5)
printf("a >= 5 e a < 10\n");
else if (a >= 0)
printf("a >= 0 e a < 5\n");
return (EXIT_SUCCESS);
}
È importante rilevare che questa forma di scrittura delle istruzioni if/else non introduce
alcuna nuova forma di statement di controllo, ma è solo una maniera per rendere più chiaro
l’obiettivo computazionale della struttura di controllo, che è quello di compiere una serie di
valutazioni di espressioni laddove solo una o nessuna potrà essere vera e dunque eseguire o
meno le istruzioni correlate.
In definitiva una struttura di controllo con una serie di if/else nidificati altro non è che
una struttura di selezione doppia if/else dove ogni else ha come istruzione un’altra
istruzione if e così via per altre clausole else.
Output 5.3 Dal Listato 5.3 IfElseNested.c e dal Listato 5.4 IfElseNestedAndWithIndentation.c.
a >= 0 e a < 5
L’Output 5.3 rileva che, comunque si scriva la struttura di controllo, la logica è sempre la
stessa: se è vera una delle espressioni, allora vengono eseguite le istruzioni corrispondenti e
il programma salta al di fuori di tutti gli altri if/else; se nessuna condizione è vera, allora il
programma le salta tutte.
Nel nostro caso, dunque, dato che la variabile a vale 3:
l’espressione del primo if sarà valutata falsa: a non è maggiore o uguale a 10;
l’espressione del secondo if della prima clausola else sarà valutata falsa: a non è
maggiore o uguale a 5;
l’espressione del terzo if della seconda clausola else sarà valutata vera: a è maggiore o
uguale a 0.
Quando si scrivono più strutture if/else, si può incorrere nell’errore denominato dell’else
pendente (dangling else), in cui l’else non è attribuito all corretto e correlativo if.
int main(void)
{
int a = 9, b = 3;
if (a > 10)
if (b > 10)
printf("a e b > 10\n"); // eseguita se a e b sono maggiori di 10
else
printf("a < 10\n");
return (EXIT_SUCCESS);
}
L’intento del programma del Listato 5.5 sarebbe quello di far stampare a e b > 10 se le
variabili a e b fossero maggiori di 10 e a < 10 nel caso in cui a fosse minore di 10.
Tuttavia, per come è stato scritto il codice, eseguendo il programma non viene stampata
l’istruzione dell’else, anche se la variabile a è minore di 10 (è infatti uguale a 9) e quindi
soddisfa la condizione corrispondente.
Ciò si verifica perché, come regola, il compilatore associa la clausola else al primo if
precedente che trova (a quello cioè lessicalmente più vicino permesso dalla sintassi).
Nel nostro caso, infatti, il compilatore associa la clausola else all’if più vicino che risulta
essere quello con l’espressione b > 10.
Per questa ragione il compilatore interpreta le istruzioni nel seguente modo: la variabile a
è maggiore di 10? Se lo è, allora valuta se la variabile b è maggiore di 10, e in caso
affermativo lo stampa a e b > 10, altrimenti stampa a < 10.
NOTA
Se lanciamo il compilatore GCC con il flag -Wall oppure -Wparentheses otterremo il messaggio
warning: suggest explicit braces to avoid ambiguous 'else' [-Wparentheses].
int main(void)
{
int a = 9, b = 3;
if (a > 10)
{
if (b > 10)
printf("a e b > 10\n"); // eseguita se a e b sono maggiori di 10
}
else
printf("a < 10\n");
return (EXIT_SUCCESS);
}
Prima di vedere degli esempi pratici di utilizzo del costrutto switch è importante dare
anche altre indicazioni: se le etichette case hanno due o più istruzioni, le stesse possono
essere scritte senza racchiuderle tra le parentesi graffe { }; non possono esservi etichette
case duplicate nell’ambito dello stesso switch (in pratica più espressioni dei casi non possono
rintonare lo stesso valore) e l’ordine di scrittura delle stesse non ha importanza; ci può
essere solo un’etichetta default che in genere è posta come ultima label (è possibile
collocarla ovunque all’interno dello switch).
Figura 5.3 Diagramma dell’istruzione di selezione multipla switch (secondo un comune ordine di
scrittura).
int main(void)
{
int number = 4;
// valuto number
switch (number)
{
case 1: // vale 1?
printf("number = 1\n");
break;
case 2: // vale 2?
printf("number = 2\n");
break;
case 3: // vale 3?
printf("number = 3\n");
break;
case 4: // vale 4?
printf("number = 4\n");
break;
default: // nessuna corrispondenza?
printf("number = [no matching]\n");
}
return (EXIT_SUCCESS);
}
Nel Listato 5.7 la keyword switch valuta il valore della variabile number (in questo caso 4) e
cerca una corrispondenza tra i valori delle etichette case. Se trova un’etichetta case che
soddisfa tale valutazione (e nel nostro caso la trova: case 4:), allora ne esegue le istruzioni
ivi indicate: l’una si limita a stampare i caratteri number = 4 tramite printf; l’altra, break, esce
dallo switch e fa riprendere l’esecuzione del programma dalla prossima istruzione che è
identificata dalla keyword return.
Più etichette case possono essere “raggruppate” in modo da esplicitare delle istruzioni che
saranno eseguite se uno qualsiasi dei valori delle relative espressioni corrisponderà al valore
dell’espressione dello switch.
int main(void)
{
char letter = 'e';
return (EXIT_SUCCESS);
}
Lo Snippet 5.2 mostra un ulteriore esempio del fatto che un’espressione di controllo è
una full expression: infatti, la valutazione dell’espressione a dello switch darà come risultato
9 che corrisponderà all’etichetta case 9: laddove, quando il compilatore eseguirà la relativa
istruzione, a sarà stata prima incrementata a 10 dall’operatore postfisso ++.
Istruzioni di iterazione
Le istruzioni di iterazione (iteration statement) consentono di eseguire una o più
istruzioni (loop body) in modo ripetuto (ciclico), finché un’espressione di controllo non
diventa falsa ossia uguale a 0. L’espressione di controllo delle istruzioni while e do e le
espressioni facoltative dell’istruzione for sono considerate delle full expression, e dunque
qualsiasi eventuale effetto collaterale che possa lì prodursi sarà valutato prima della
valutazione di una successiva full expression.
TERMINOLOGIA
A volte le istruzioni di iterazione sono denominate loop condizionali (conditional loop)
perché l’esecuzione ciclica delle istruzioni relative dipenderà da una “condizione”
evidenziata dall’espressione di controllo. Se, per esempio, scrivessimo un’espressione di
controllo per un loop come a < 10, è come se ponessimo come condizione per l’esecuzione
ciclica delle sue istruzioni quella che a deve essere minore del valore 10.
In pratica per utilizzare tale istruzione di iterazione utilizziamo la keyword while, una
coppia di parentesi tonde ( ) al cui interno vi sarà l’espressione di controllo da valutare e
l’istruzione da eseguire finché expression sarà diversa da 0. È altresì possibile definire due o
più istruzioni da eseguire ciclicamente ponendole tra le parentesi graffe { }.
int main(void)
{
int a = 8;
printf("a = [ ");
while (a >= 0) // finché a >= 0 esegue il ciclo
printf("%d ", a--);
printf("]\n");
return (EXIT_SUCCESS);
}
Nel programma del Listato 5.9 il ciclo while si può interpretare in questo modo: finché la
variabile a è maggiore o uguale al valore 0, stampane il valore ripetutamente. Nel blocco di
codice del while l’istruzione a-- è fondamentale poiché permette di decrementarne il valore.
Se non ci fosse questa istruzione, il ciclo while sarebbe infinito, perché la condizione
sarebbe sempre vera (diversa da 0), dato che la variabile a sarebbe sempre maggiore o
uguale a 0.
Ripetiamo, per chiarire meglio come si comporta il flusso esecutivo della struttura
iterativa while:
while (j--)
a = j; // qua a varrà 0
Lo Snippet 5.3 evidenzia che quando il compilatore elaborerà l’istruzione while, prima
verificherà se j è diversa da 0 e poi completerà il side effect dell’operatore postfisso ++ che
la decrementerà a 0. Poi assegnerà ad a quel valore.
In pratica anche se l’operatore ++ è postfisso, non assegnerà prima ad a il valore originario
di j, ossia 1, e poi decrementerà j.
int main(void)
{
int a = 8;
printf("a = [ ");
do // parentesi non necessarie... scritte solo per maggiore chiarezza
{
printf("%d ", a--);
}
while (a >= 0); // finché a >= 0 esegue il ciclo
printf("]\n");
return (EXIT_SUCCESS);
}
Il ciclo del Listato 5.10 si comporta allo stesso modo di quello del Listato 5.9 ma, a
differenza di esso, stampa il valore di a e poi lo decrementa almeno una volta, anche se
potrebbe essere subito minore di 0, e poi verifica se a è maggiore o uguale a 0.
Il flusso esecutivo del blocco do/while è il seguente.
do
{
// prima della verifica della condizione a varrà 1
// dopo le valutazioni delle espressioni nel while a varrà 6...
a = j;
if (a == 6)
break;
}
while (j = (a + 1, b += a, b));
Lo Snippet 5.4 mostra come anche nell’ambito di un ciclo do/while tutti i side effect
saranno valutati prima di eseguire il loop body.
Infatti sia la variabile j sia la variabile b subiranno una modifica del loro valore in
memoria, e tali modifiche saranno attuate prima di entrare nel corpo del ciclo e ciò perché,
ribadiamo, vi sono due sequence point: il primo è marcato dall’operatore virgola (,) e il
secondo è marcato dall’espressione di controllo del while che è una full expression.
Di fatto, per usare tale struttura, si scrive la keyword for seguita dalle parentesi tonde ( )
che racchiudono tre espressioni opzionali di cui la prima può agire anche come istruzione di
dichiarazione. Abbiamo:
decl_OR_expr_1, che esprime una dichiarazione di una o più variabili che possono essere
impiegate dalle altre espressioni del for o nell’ambito del suo loop body oppure
un’espressione che viene valutata inizialmente prima dell’espressione di controllo.
Segue un punto e virgola;
expression_2, che indica l’espressione di controllo che viene valutata prima
dell’esecuzione del loop body del for e che controlla, quindi, la condizione di
terminazione del ciclo medesimo. Segue un punto e virgola;
expression_3, che designa l’espressione da valutare dopo l’esecuzione del loop body del
for.
// un ciclo while...
expression_1;
while (expression_2)
{
statement;
expression_3;
}
int main(void)
{
printf("a = [ ");
for (int a = 8; a >= 0; a--) // finché a >= 0 esegue il ciclo
printf("%d ", a);
printf("]\n");
return (EXIT_SUCCESS);
}
Esaminando il Listato 5.11 vediamo che la struttura iterativa for impiegata è formata dai
seguenti blocchi costitutivi: un’istruzione di dichiarazione, eseguita solo una volta, in cui è
inizializzata una variabile di controllo (visibile solamente nel ciclo for), rappresentata
dall’istruzione int a = 8; un’espressione di controllo della condizione di continuazione del
ciclo, rappresentata dall’istruzione a >= 0; un’espressione di modifica della variabile di
controllo, rappresentata dall’istruzione a--.
Il flusso esecutivo del ciclo è invece il seguente.
1. Dichiara e inizializza la variabile a.
2. Controlla se a >= 0 e se lo è va al punto 3, altrimenti va al punto 6.
3. Stampa il valore di a.
4. Decrementa la variabile a.
5. Ritorna al punto 2.
6. Esce dal ciclo.
TERMINOLOGIA
Per ambito di visibilità si intende una regione del codice sorgente dove un identificatore è
accessibile e dunque utilizzabile. Ritorneremo su questo importante concetto nel Capitolo 9,
“Dichiarazioni”.
Vediamo un altro esempio di utilizzo (Listato 5.12) di un ciclo for dove la prima
espressione dichiara e inizializza più variabili, mentre la terza espressione ne modifica il
valore.
int main(void)
{
int var1 = 3, var2 = 2;
printf("a\tz\n");
for (int a = var1 * 2 + var2, z = 0; a >= 0; a--, z++)
printf("%d\t%d\n", a, z);
return (EXIT_SUCCESS);
}
In definitiva la prima espressione di un ciclo for può contenere una dichiarazione di più
variabili utilizzando il carattere virgola (,) che agisce come separatore, oppure più
espressioni usando sempre il carattere virgola ma che agisce come operatore.
La terza espressione può altresì contenere più espressioni usando ancora l’operatore
virgola.
I blocchi costitutivi della struttura iterativa for (decl_OR_expr_1, expression_2 e expression_3)
si possono anche omettere, a condizione però che i punti e virgola ; di separazione vengano
scritti. Quando, tuttavia, viene omesso expression_2, il compilatore lo sostituisce con una
costante diversa da 0 e pertanto ne rende infinito il ciclo.
int main(void)
{
int a = 8;
printf("a = [ ");
for (;;) // ciclo infinito che è interrotto dal break
{
if (a < 0)
break; // senza questa istruzione il ciclo diventa infinito
printf("%d ", a--);
}
printf("]\n");
return (EXIT_SUCCESS);
}
Nel Listato 5.13 notiamo come le espressioni costituenti la struttura iterativa siano state
poste prima dell’istruzione for per la definizione della variabile di controllo, e all’interno
del blocco di istruzioni del ciclo per il controllo di terminazione e per la modifica della
variabile di controllo. Vediamo, infine, che i cicli for si possono costruire anche come cicli
senza corpo. Essi devono però contenere sempre almeno un’istruzione definita come vuota
o nulla (carattere punto e virgola).
int main(void)
{
int val_max = 100, i = 0;
return (EXIT_SUCCESS);
}
ATTENZIONE
Quando si utilizza un’istruzione nulla bisogna fare attenzione a non incorrere in un errore di
tipo logico come quello mostrato dallo Snippet 5.7, dove, quando a == -5, il compilatore
esegue l’istruzione nulla e anche quella di stampa del valore 5, che probabilmente non si
aveva intenzione di eseguire. Lo Snippet 5.8 mostra invece un errore in tipo sintattico: in
questo caso il compilatore non troverà un if da associare all’ultimo else perché il primo if
non ha racchiuso le sue istruzioni in un blocco delimitato dalle parentesi graffe { }.
FOR E WHILE
Come detto, se esiste un’equivalenza tra un costrutto for e un costrutto while, quando usare
l’uno e quando usare l’altro? La risposta, quantunque possa essere legata a un gusto
personale, può anche essere data facendo le seguenti considerazioni di ordine pratico: usare
un’istruzione for quando si ha la necessità di creare dei loop che effettuano delle operazioni di
inizializzazione e aggiornamento di variabili che fungono da contatori; usare un’istruzione
while quando si ha l’esigenza di definire dei loop che devono eseguire le relative operazioni
Analizziamo come il compilatore processa ogni espressione del ciclo for in accordo con
le regole di priorità delle valutazioni delle espressioni e del loop body e avendo la
consapevolezza che ciascuna marca un sequence point (sono infatti delle full expression):
1. a++: al termine della valutazione a avrà il valore 4;
2. b < a++ +10: al termine della valutazione b avrà ancora il valore 2 e a avrà il valore 5;
3. printf("%d - % d - %d\n", a, b, j): saranno stampati i valori: per a 5, per b 2 e per j 1;
4. j = ++b: al termine della valutazione b conterrà il valore 3 così come j;
5. b < a++ +10: al termine della valutazione b avrà ancora il valore 3 e a avrà il valore 6;
6. printf("%d - % d - %d\n", a, b, j): saranno stampati i valori: per a 6, per b 3 e per j 3;
7. j = ++b: al termine della valutazione b conterrà il valore 4 così come j;
8. b < a++ +10: al termine della valutazione b avrà ancora il valore 4 e a avrà il valore 7;
9. printf("%d - % d - %d\n", a, b, j): saranno stampati i valori: per a 7, per b 4 e per j 4. In
più il ciclo si interromperà a causa dell’istruzione break perché a == 7 sarà vera.
Istruzioni di salto
Le istruzioni di salto consentono di trasferire, spostare il controllo dell’esecuzione del
codice in un altro punto e in modo incondizionato, ossia senza la dipendenza da alcuna
espressione di controllo.
Istruzione break
Un’istruzione break consente di interrompere l’esecuzione del codice posto all’interno di
un ciclo espresso mediante le keyword while, do/while e for e contestualmente di trasferirne il
controllo alla successiva istruzione.
Questa istruzione può comparire oltre che all’interno di un loop body anche all’interno di
un costrutto switch, in relazione a una determinata clausola case, dove adempie allo stesso
scopo.
int main(void)
{
printf("a = ");
for (int a = 1; a <= 10; a++) // finché a <= 10
{
if (a == 5)
{
break;
}
printf("%d ", a);
}
printf("\n");
int a = 1;
printf("a = ");
while (a <= 10) // finché a <= 10
{
if (a == 5)
break;
printf("%d ", a++);
}
printf("\n");
return (EXIT_SUCCESS);
}
L’istruzione break nel Listato 5.15 interrompe l’iterazione sia del ciclo for sia del ciclo
while; infatti quando la variabile a è uguale a 5 il programma esce dall’iterazione, pertanto
saranno stampati solo i valori fino a 4.
Di fatto, un’istruzione break è utile per interrompere un ciclo “nel mezzo” della sua
esecuzione rispetto a una comune interruzione che potrebbe avvenire all’inizio (nel caso di
un while o di un for dove l’exit point è definito dall’espressione di controllo posta prima o
all’inizio del loop body) oppure alla fine (nel caso di un do/while dove l’exit point è definito
dall’espressione di controllo posta dopo o alla fine del loop body).
Quando si utilizza un’istruzione break è importante tenere presente che essa interrompe
l’esecuzione del codice della “più piccola” istruzione while, for, do/while o switch innestata
(Snippet 5.10).
Istruzione continue
Un’istruzione continue consente di saltare alla fine di un loop body, evitando il processing
di eventuali istruzioni poste dopo di esse, e di riprendere il prossimo step di esecuzione.
Non esce mai, dunque, dal relativo loop body.
Questa istruzione può comparire solo all’interno di un loop body espresso dalle keyword
while, do/while e for ma mai all’interno di un costrutto switch che non sia innestato in un
costrutto di iterazione dove, in quest’ultimo caso, causa un salto alla fine del loop body dal
costrutto.
Nel caso di un ciclo while e do/while il prossimo step di esecuzione è la valutazione della
relativa espressione di controllo, mentre nel caso di un ciclo for il prossimo step di
esecuzione è la valutazione della terza espressione (in genere quella che aggiorna il valore
di una variabile di controllo) e poi della seconda (in genere quella che verifica una
condizione di terminazione del ciclo).
int main(void)
{
printf("a = ");
for (int a = 1; a <= 10; a++) // finché a <= 10
{
if (a == 5) // salta l'istruzione successiva se a == 5
continue;
printf("%d%c ", a, a != 10 ? ',' : ' ');
// continue fa spostare il flusso di esecuzione qui;
// poi il ciclo riprende con a++ quindi a <= 10
}
printf("\n");
int a = 1;
printf("a = ");
while (a <= 10) // finché a <= 10
{
if (a == 5) // salta le istruzioni successive se a == 5
{
a++;
continue;
}
printf("%d%c ", a, a != 10 ? ',' : ' ');
a++;
// continue fa spostare il flusso di esecuzione qui;
// poi il ciclo riprende con a <= 10
}
printf("\n");
return (EXIT_SUCCESS);
}
Il Listato 5.16 mostra come nella pratica l’istruzione continue salti le rimanenti istruzioni
del corpo di una struttura iterativa, procedendo quindi con la successiva iterazione.
Nel nostro esempio, nonostante i valori stampati dal for e dal while saranno, ugualmente,
da 1 a 10 eccetto il 5, i due cicli avranno un differente flusso esecutivo in ragione anche di
quanto prima detto sul “dove” il codice riprende l’esecuzione dopo l’esecuzione di
un’istruzione continue.
Infatti, per la sequenza del for avremo quanto segue.
1. Inizializza la variabile a = 1.
Istruzione goto
Un’istruzione goto permette di saltare verso un punto del codice arbitrario marcato da un
identificatore scritto con una particolare sintassi che lo individua come un’etichetta (label).
L’etichetta e l’istruzione goto devono trovarsi nell’ambito della stessa funzione.
NOTA
A partire dallo standard C99 esiste anche la seguente restrizione all’uso del goto (Snippet
5.11): non può essere usato prima della dichiarazione di un array di lunghezza variabile per
“aggirarla” e trasferire il flusso di esecuzione del codice dopo di essa.
a[2] = 100;
label: // etichetta
val = 10;
Per quanto attiene quest’istruzione, è bene subito dire che il suo uso è, in linea generale,
sconsigliato e ciò per le seguenti ragioni: può produrre il cosiddetto spaghetti code ossia
codice disordinato e non manutenibile che salta da un punto all’altro del programma, avanti
e indietro e in modo intrecciato; può essere sostituito da forme specializzate e ristrette di
goto quali sono le istruzioni break, continue e return e, alcune volte, anche dalla funzione exit
int main(void)
{
int a = 0, b = 0, c = 0;
end: // etichetta
printf("c contiene finalmente il valore 6!!!\n");
return (EXIT_SUCCESS);
}
Il Listato 5.17 definisce tre cicli while, dove il primo dovrebbe iterare finché a è minore di
10, il secondo finché b è minore di 10 e il terzo finché c è minore di 10.
Tuttavia, nel terzo ciclo, quando c è uguale a 5 decidiamo di “interrompere” bruscamente
tutte e tre le iterazioni e trasferire, mediante un’istruzione goto, il flusso di esecuzione del
codice all’etichetta print, la quale stampa a video il valore delle variabili a, b e c.
Poi, definiamo un ciclo for dove, finché il valore della variabile c è inferiore a 6
stampiamo una determinata stringa di caratteri che lo rammenta, mentre quando c è uguale a
6, giacché il ciclo for è infinito, decidiamo tramite goto di uscire direttamente da esso e
passare il controllo del flusso del codice alla label end, la cui istruzione etichettata manderà a
video un’altra stringa informativa.
A parte le eccezioni citate, che in ogni caso è sempre possibile evitare anche se al prezzo
di rendere il codice più complesso (Snippet 5.12), ribadiamo che l’utilizzo del goto non
dovrebbe mai essere preso in considerazione.
Istruzione return
Un’istruzione return permette di terminare l’esecuzione della corrente funzione e di
ritornare, trasferire il controllo del flusso di esecuzione del codice a un’altra funzione
chiamante.
Eventualmente, tale istruzione può anche ritornare al chiamante un valore che, prima di
essere restituito, è convertito nel tipo di ritorno esplicitato all’atto di dichiarazione della
funzione medesima.
NOTA
Un dettaglio significativo su tale istruzione sarà fornito nel Capitolo 6 e ciò perché una sua
piena comprensione potrà essere raggiunta solo dopo aver affrontato le funzioni. È apparso
comunque opportuno citarla in questa sede perché un’istruzione return è categorizzata
come un’istruzione di salto, e dunque è qui che il lettore ne deve avere una prima
indicazione sia termologica sia semantica, per quanto in modo breve e sommario.
Istruzioni etichettate
Le istruzioni etichettate rappresentano delle statement che sono precedute da apposite
etichette (label) definibili attraverso le seguenti sintassi.
La Sintassi 5.10 e la Sintassi 5.11 definiscono le comuni etichette case e default già viste e
trattate nell’ambito dell’istruzione di selezione multipla switch che, ricordiamo, è l’unico
costrutto all’interno del quale è possibile utilizzarle.
Capitolo 6
Funzioni
Abbiamo: return_type, che indica il tipo di dato ritornato dalla funzione al suo chiamante,
che non può essere però un tipo funzione o un tipo array (in C89 è possibile omettere il tipo
di ritorno, e allora si presume che esso sia di tipo int, mentre a partire da C99 è obbligatorio
porre sempre un tipo di ritorno); function_identifier, che indica il nome della funzione; una
coppia di parentesi tonde ( ) al cui interno porre una lista di variabili precedute dal tipo e
separate dal carattere virgola (,) che indicano nell’insieme i parametri della funzione,
oppure la keyword void, che indica che la funzione non ha parametri; una coppia di
parentesi graffe { } al cui interno porre le consuete dichiarazioni di variabili, costanti e altro,
le istruzioni che rappresentano l’algoritmo computazionale della funzione e un’opzionale
istruzione di salto return se la funzione deve ritornare un valore.
Per quanto riguarda gli elementi strutturali della definizione di una funzione è importante
fare le seguenti ulteriori precisazioni.
Un tipo funzione (function type) è un tipo caratterizzato dal suo tipo di ritorno e dal
numero e il tipo dei suoi parametri. Così potremmo dire che in una funzione definita
come int foo(double a, double b) {/*... */} la valutazione dell’espressione foo sarebbe
di tipo (double, double) -> int, ovvero di una funzione che ritorna un int e ha due
parametri double.
CURIOSITÀ
L’identificatore foo è utilizzato nella letteratura informatica per indicare un nome generico e
non significativo da attribuire a variabili, funzioni e così via in porzioni di codice sorgente che
hanno l’unico scopo di illustrare dei concetti didattici. Oltre all’identificatore foo sono utilizzati
gli identificatori bar, baz e foobar.
Il fatto che a partire da C99 sia illegale tralasciare il tipo di ritorno non implica che un
compilatore non debba permettere di compilare correttamente il relativo codice
sorgente. In ogni caso, in accordo con lo standard, quando un compilatore che è
un’implementazione conforme incontra una violazione di sintassi oppure di restrizione
sintattica o semantica (constraint), deve produrre, all’atto della compilazione, almeno
un messaggio di diagnostica che può essere espresso in modo arbitrario. Nel caso di
GCC se compiliamo con il flag -std=c99 o -std=c11 un sorgente che contiene la
definizione di una funzione senza il tipo di ritorno, lo stesso ritornerà solo un
messaggio di diagnostica come warning: return type defaults to 'int' permettendo
comunque la compilazione del predetto sorgente. Tuttavia, anche se il programma è
stato compilato, bisognerà prestare attenzione a quel warning perché il comportamento
dell’eseguibile sarà non definito. La morale di quanto detto è che i messaggi di
diagnostica di un compilatore non vanno mai trascurati, anche se sono dei “semplici” e
apparentemente “innocui” warning.
Un parametro, detto parametro formale (formal parameter), è un oggetto dichiarato
nell’ambito della dichiarazione o della definizione di una funzione che è deputato a
ricevere un valore nel momento dell’invocazione della funzione medesima. Questo
parametro ha l’importante caratteristica di essere una variabile locale alla funzione
dove è stato dichiarato, ossia è visibile e utilizzabile solamente nel suo ambito. Al di
fuori della funzione nessun’altra funzione potrà accedere a tale variabile e ciascuna
funzione potrà avere una variabile dichiarata con lo stesso nome.
TERMINOLOGIA
Se una variabile è dichiarata al di fuori di ogni funzione, la stessa sarà utilizzabile
direttamente da ciascuna di esse. Questa variabile è detta variabile esterna (o globale).
Ritorneremo su questo punto, in modo più preciso e con una terminologia più corretta e
appropriata quando tratteremo l’importante concetto dello scope delle variabili nel Capitolo
9.
Data la funzione int foo(double a, double b) {/*... */} per intestazione della funzione
(function header) si intende la parte composta dal tipo di ritorno, il nome della
funzione e la lista di parametri (tipo più identificatore), mentre per dichiaratore di
funzione (function declarator) si intende la parte composta dal nome della funzione e
la lista dei parametri (tipo più identificatore).
Lo Snippet 6.1 mostra come definire una semplice funzione che dato un numero ne
calcola il cubo, ossia la sua terza potenza.
int main(void)
{
...
}
NOTA
In alcuni sorgenti è ancora possibile trovare definizioni di funzioni espresse con una sintassi
definita pre-ANSI C (Sintassi 6.2), dove per la parte dei parametri si indicano solo i relativi
identificatori mentre subito dopo e prima dell’inizio del body della funzione, per ogni
parametro, si indica anche il relativo tipo di dato (Snippet 6.2). È importante segnalare che
questa forma di definizione di una funzione è ancora presente nell’attuale standard C11, ma
la stessa è marcata come obsolescent feature ossia, quantunque la si possa ancora
utilizzare, ne è comunque deprecato l’impiego.
Snippet 6.2 Definizione della funzione cube secondo una sintassi pre-ANSI C.
...
// definizione della funzione cube secondo una sintassi pre-ANSI C
long cube(number) // number è l'identificatore
long number; // dichiarazione del tipo
{
long res; // variabile locale e privata alla funzione cube
res = number * number * number; // algoritmo
return res; // ritorna al chiamante il risultato
}
int main(void)
{
...
}
Invocazione di una funzione
Dopo la definizione di una funzione è necessario conoscere la corretta sintassi da
adoperare al fine di utilizzarne i servizi offerti (Sintassi 6.3).
int main(void)
{
long number;
printf("Digita un numero cui far calcolare il suo cubo tra -1000 e 1000: ");
scanf("%ld", &number);
while (number < -1000 || number > 1000)
{
printf("\007Il numero deve essere compreso tra -1000 e 1000!\n");
printf("Digita un numero cui far calcolare il suo cubo tra -1000 e 1000: ");
scanf("%ld", &number);
}
return (EXIT_SUCCESS);
}
Il Listato 6.1 definisce la funzione cube che ritorna il cubo di un numero e poi, nell’ambito
della funzione main (funzione chiamante), la invoca solo se un numero immesso da tastiera è
compreso tra -1000 e 1000.
Nel nostro caso, quando cube è invocata, la variabile number, posta tra l’operatore di
invocazione, rappresenta l’argomento il cui valore è copiato nel corrispondente parametro
anch’esso denominato number (ricordiamo che non v’è conflitto di nomi tra variabili che si
chiamano allo stesso modo tra funzioni differenti).
Dopo l’invocazione, il controllo del flusso del codice passa all’interno della funzione cube
la quale utilizza il valore di number per calcolarne il cubo e poi tramite l’istruzione return, ne
termina il compito elaborativo passando al chiamante tale risultato (nel punto del main dove
cube è stata invocata), questo è utilizzato direttamente (non è posto in alcuna variabile
“d’appoggio”) come valore del terzo argomento della funzione printf che lo impiegherà
visualizzandolo a video.
Essendo anche printf una funzione, al termine della sua elaborazione il flusso di
processing del codice riprenderà, nel main chiamante, dall’istruzione successiva alla sua
invocazione, ossia verrà elaborata l’istruzione return (EXIT_SUCCESS).
Dichiarazione di una funzione
Nel programma appena mostrato possiamo notare come la definizione della funzione cube
sia posta prima della definizione della funzione main; questo perché il compilatore prima di
utilizzarla ha la necessità di sapere della sua esistenza ossia di come è strutturata in merito
al tipo di ritorno, al suo nome e agli eventuali parametri (numero e tipo).
In ogni caso nessuno ci vieta di porre la definizione della funzione cube in un altro punto
del programma, per esempio dopo la funzione main (Listato 6.2).
int main(void)
{
long res = cube(5); /* dichiarazione implicita... */
printf("%ld\n", res);
return 0;
return (EXIT_SUCCESS);
}
In pratica quando il compilatore incontra nella funzione main la funzione cube, non
sapendo nulla in merito alla sua struttura, prende una decisione in autonomia “effettuando”
una dichiarazione implicita della stessa attribuendole come tipo di ritorno un tipo int e non
assumendo nulla in merito al tipo e al numero dei parametri (la dichiarazione implicita è
qualcosa come int cube();).
In seguito, inoltre, quando incontra la definizione di cube rileva che la stessa ritorna un
tipo long che è diverso dal tipo int attribuito nella dichiarazione implicita, e pertanto genera
un messaggio di errore che ci avvisa di un conflitto di tipi.
NOTA
A partire da C99 un compilatore dovrebbe generare un messaggio di diagnostica di una
dichiarazione di funzione implicita (implicit function declaration) perché tale possibilità,
legale nei compilatori che aderivano agli standard precedenti, è diventata scorretta. Se,
infatti, rimuoviamo dal sorgente la definizione della funzione cube e lo compiliamo con gcc
(flag -std=c11), lo stesso genererà il messaggio warning: implicit declaration of function
'cube', che invece non sarà generato se gcc è compilato il flag -std=c89.
Ciò detto, il linguaggio C fornisce un potente meccanismo che consente di indicare solo il
function header di una funzione prima del suo utilizzo in modo che il compilatore abbia in
anticipo tutte le informazioni necessarie per controllare la correttezza dell’invocazione
anche se la relativa definizione è posta altrove, ossia dopo la sua effettiva chiamata.
Questo meccanismo consente di creare il cosiddetto prototipo di funzione (function
prototype) che è rappresentato, in sostanza, da una dichiarazione esplicita di una funzione
(Sintassi 6.4) dove sono indicati il tipo di ritorno, il suo nome, il tipo dei parametri e,
opzionalmente, anche il loro nome.
CONSIGLIO
Anche se è possibile scrivere un prototipo di funzione omettendo il nome dei parametri, è
buona norma non farlo perché tali nomi garantiscono sicuramente una migliore
documentazione del codice sorgente.
NOTA
Se si decide di scrivere il nome dei parametri in un prototipo di funzione, gli stessi possono
essere diversi da quelli indicati nella corrispettiva definizione.
TERMINOLOGIA
Dato un prototipo di funzione, diremo che per firma o segnatura di una funzione si intendono
le informazioni riguardanti il suo tipo di ritorno e il tipo dei suoi parametri. Per esempio, un
prototipo come double foo(int); indicherà una funzione con una segnatura con un tipo di
ritorno double e un parametro di tipo int.
Avendo questa possibilità possiamo, dunque, riscrivere il nostro sorgente come nel
Listato 6.3 ed essere certi che non vi sarà più alcun errore di compilazione, perché non vi
sarà più alcun conflitto tra i tipi.
int main(void)
{
long res = cube(5);
printf("Il cubo di 5 e': %ld\n", res);
return (EXIT_SUCCESS);
}
NOTA
È ancora possibile, per ragioni di compatibilità, scrivere la dichiarazione di una funzione non
indicando i parametri come era in uso nel pre-ANSI C, dove non era consentita l’indicazione
di una lista di parametri [per esempio long cube();]. Tuttavia, anche se tale forma di
dichiarazione è ancora ammessa, se ne scoraggia vivamente l’impiego perché il
compilatore non rileva alcun errore in merito al numero e al tipo degli argomenti passati
all’atto dell’invocazione della relativa funzione.
IMPORTANTE
Per dichiarare il prototipo di una funzione che non accetta argomenti (non ha parametri) non
utilizzare mai qualcosa come void foo();, ma impiegare esplicitamente la keyword void come
in void foo(void). Infatti, solo in quest’ultimo caso un compilatore verificherà se
effettivamente durante l’utilizzo dell’operatore di invocazione non si saranno forniti
argomenti e in caso contrario lo segnalerà come errore (per esempio error: too many
arguments to function 'foo').
Parametri di una funzione: dettaglio
Come detto, una funzione può avere dei parametri che, di fatto, sono delle variabili a essa
locali atte a contenere dei valori forniti durante la sua invocazione dai corrispondenti
argomenti.
I valori forniti dagli argomenti sono però delle loro copie, ossia i relativi parametri
potranno agire su di essi senza la preoccupazione che qualsiasi manipolazione
eventualmente prodotta abbia effetto sui valori originari; detto in altri termini, ogni modifica
effettuata sul parametro non si ripercuoterà sul corrispondente argomento.
Ciò accade perché i valori degli argomenti sono assegnati (copiati) in variabili
“temporanee”, ossia i parametri, ed è tramite questi e non tramite gli argomenti che gli stessi
valori possono subire cambiamenti.
Questa modalità di passaggio degli argomenti a una funzione è detta per valore (by value)
e permette proprio di evitare che una funzione chiamata alteri direttamente una variabile di
una funzione chiamante (può solo modificarne una “copia” privata e temporanea).
IMPORTANTE
In C gli argomenti sono sempre passati by value. Il fatto che sia possibile modificare, come
vedremo poi, un argomento tramite un parametro grazie ai puntatori non comporta che
automaticamente esista un’altra modalità di passaggio (per esempio by pointer o by
reference).
/* prototipo di swap */
void swap(int a, int b);
int main(void)
{
int a = 10, b = 20;
printf("a e b prima dello swap: a=%d - b=%d\n", a, b);
return (EXIT_SUCCESS);
}
/* definizione di swap */
void swap(int w, int z) /* ATTENZIONE gli argomenti non sono modificati!!! */
{
int tmp = w;
w = z;
z = tmp;
}
Il Listato 6.4 definisce una funzione swap il cui scopo è quello di scambiare il valore delle
due variabili passate come argomento ossia, nel nostro caso, di assegnare alla variabile a il
valore della variabile b (ossia 20) e alla variabile b il valore della variabile a (ossia 10).
Tuttavia, poiché in C gli argomenti sono sempre passati by value, la funzione swap riesce
a scambiare solo i valori delle copie di a e b che sono rappresentate dalle variabili w e z, le
quali sicuramente avranno i valori scambiati (w avrà il valore 20 e z il valore 10).
La Figura 6.1 aiuterà a capire meglio cosa significa passare gli argomenti per valore con
una rappresentazione grafica delle variabili a e b prima dell’invocazione di swap e poi delle
stesse dopo l’invocazione di swap. Mostra altresì come le variabili w e z, copie di a e b,
eseguano correttamente lo scambio tra di esse.
int main(void)
{
// in questo caso l'ordine di precedenza degli operatori farà si che
// ciò che sarà ritornato da G() sarà moltiplicato per ciò
// che sarà ritornato da H() e tale risultato sarà poi addizionato
// a ciò che sarà ritornato da H();
// tuttavia il compilatore potrà scegliere qualsiasi ordine di valutazione
// degli operandi ossia non è detto che invocherà nell'ordine, come abbiamo scritto,
// prima F() poi G() e poi H();
// questo può essere un problema se una delle funzioni, per esempio F,
// manipola un oggetto globale che poi è riferito da un'altra funzione,
// per esempio G, soprattutto se quest'altra funzione faceva affidamento
// su quella manipolazione;
// infatti, se viene prima valutata G di F che valore avrà quell'oggetto per G?
// ritornando al risultato di questa printf un compilatore potrà ritornare:
// F
// G
// H
// 7
// mentre un altro potrà ritornare:
// G
// H
// F
// 7
printf("%d\n", F() + G() * H()); // ???
int j = 10;
int k = 11;
int l = 100;
return (EXIT_SUCCESS);
}
// definizione di F
int F(void)
{
printf("F\n");
return 1;
}
// definizione di G
int G(void)
{
printf("G\n");
return 2;
}
// definizione di H
int H(void)
{
printf("H\n");
return 3;
}
// definizione di foo
void foo(int a, int b)
{
printf("[a = %d] [b = %d]\n", a, b); // [a = 11] [b = 100]
}
Conversione e promozione degli argomenti
Quando si invoca una funzione può certamente accadere che la stessa sia chiamata con
degli argomenti il cui tipo non concorda con il tipo dei corrispondenti parametri.
In questo caso il compilatore può eseguire due distinte azioni dipendenti dal fatto se è
stato dichiarato o meno un prototipo della funzione che si sta invocando oppure se è stata
scritta una dichiarazione nel vecchio stile pre-ANSI C.
Nel primo caso, ossia se è presente un prototipo di funzione, il tipo degli argomenti è
implicitamente convertito, come per assegnamento, nel tipo dei parametri, mentre nel
secondo caso, ovvero se non è presente un prototipo di funzione oppure è presente una
dichiarazione pre-ANSI C, è effettuata una promozione di default degli argomenti (default
argument promotion) dove gli argomenti di tipo float sono convertiti nel tipo double e gli
argomenti di tipo _Bool, char e short sono convertiti nel tipo int (integer promotion).
In quest’ultimo caso, se il numero di argomenti non è uguale al numero dei parametri
oppure se il tipo dell’argomento promosso non concorda con il tipo del parametro, il
comportamento dell’invocazione della funzione sarà non definito.
int main(void)
{
/* invocazione di sum con un solo argomento */
/* numero degli argomenti non uguale al numero dei parametri */
int res_1 = sum(6);
printf("Somma tra 6 e ? = %d\n", res_1);
/* invocazione di sum con i tipi degli argomenti diversi dai tipi dei parametri */
/* il tipo degli argomenti promossi non concorda con il tipo dei parametri attesi */
int res_2 = sum(6.7f, 7.8f);
return (EXIT_SUCCESS);
}
/* definizione di sum */
int sum(int x, int y)
{
return x + y;
}
Il Listato 6.5 evidenzia come in assenza di un prototipo di funzione per la funzione sum,
quando nel main si invoca sum con un solo argomento così come quando lo si invoca con
degli argomenti che non corrispondono al tipo atteso dai parametri, il risultato sia non
definito ossia, nel nostro caso, privo di senso.
Infatti, nella prima invocazione avremo come risultato la somma tra 6, che sarà il valore
assegnato alla variabile x e 4201552, che sarà un valore arbitrario che in quel momento si
troverà alla locazione di memoria della variabile y.
Nella seconda invocazione, invece, gli argomenti 6.7f e 7.8f di tipo float saranno sì
convertiti nel tipo double, ma poiché la funzione si attendeva degli int la somma avverrà con
i valori arbitrari -858993459 e 1075498188 che si trovavano alle rispettive locazioni di memoria
“di tipo intero” dei parametri x e y.
In definitiva, per entrambe le invocazioni l’esecuzione della funzione sum darà un esito
non definito e un risultato non congruo con quanto atteso.
int main(void)
{
/* errore di compilazione: meno argomenti di quelli attesi */
int res_1 = sum(6);
printf("Somma tra 6 e ? = %d\n", res_1);
return (EXIT_SUCCESS);
}
/* definizione di sum */
int sum(int x, int y)
{
return x + y;
}
Dall’Output 6.6 si evince con chiarezza che la somma tra 6.7 e 7.8 ha dato come risultato
il valore 13, che è un valore intero perché, ribadiamo, la conversione automatica tra float a
int ne ha fatto perdere informazioni, ossia la parte frazionaria.
Snippet 6.4 Dichiarazione del prototipo della funzione subtraction con un parametro di tipo array.
/* prototipo della funzione subtraction */
int subtraction(int data[]);
Snippet 6.5 Definizione della funzione subtraction con un parametro di tipo array.
/* definizione della funzione subtraction */
int subtraction(int data[])
{
...
}
Per invocare una funzione che richiede un argomento di tipo array lo stesso deve essere
fornito indicando solamente il nome dell’array, senza l’apposizione delle parentesi [ ].
Snippet 6.6 Invocazione della funzione subtraction con un argomento di tipo array.
...
int main(void)
{
int some_data[] = {1, 2, 3, 5};
int res = subtraction(some_data);
...
}
Il Listato 6.7 elabora un programma che utilizza la funzione subtraction che, dato un array
come argomento, fornisce un risultato che è dato dalla sottrazione dei valori di tutti i suoi
elementi.
#define SIZE 6
int main(void)
{
int some_data[] = {369, 10, 15, 65, 88, 66};
return (EXIT_SUCCESS);
}
return result;
}
Snippet 6.7 Operatore sizeof: array come variabile e array come parametro di funzione.
...
int main(void)
{
int some_data[] = {369, 10, 15, 65, 88, 66};
Snippet 6.8 Dichiarazione del prototipo della funzione search con un parametro di tipo 2d-array.
/* prototipo della funzione search */
int search(int data[][COLS]);
Snippet 6.9 Definizione della funzione search con un parametro di tipo 2d-array.
/* definizione della funzione search */
int search(int data[][COLS])
{
...
}
Per l’invocazione di una funzione che accetta un argomento di tipo array a 2 dimensioni
anche qui è sufficiente indicare solo il nome della corrispondente variabile (rivedere la
Sintassi 6.7), senza quindi scrivere anche le doppie parentesi quadre [ ] [ ].
#define ROWS 3
#define COLS 5
int main(void)
{
int data[][COLS] =
{
{1, 2, 3, 4, 5},
{-4, -6, 10, 2, 9},
{100, -100, 33, 34, 24}
};
// invocazione di search
int res = search(data, ROWS);
return (EXIT_SUCCESS);
}
// equivalente
return_type function_identifier (data_type identifier_1, data_type identifier[identifier_1]);
// equivalente
return_type function_identifier (data_type identifier, data_type identifier[*]);
Si utilizza un parametro che rappresenta una variabile che indica la dimensione dell’array
e poi un altro parametro che indica l’array stesso, laddove tra le parentesi quadre [ ] è
possibile porre il simbolo asterisco * (solo nei prototipi di funzione, a indicare una sorta di
dimensione non specificata) oppure l’identificatore di quel primo parametro.
In quest’ultimo caso il parametro che indica la dimensione dell’array deve sempre essere
scritto come primo parametro perché utilizzato, poi, dalla dichiarazione del secondo
parametro di tipo array.
NOTA
Come visto, dato che in C è possibile omettere la lunghezza della dimensione nella
dichiarazione di un array monodimensionale come parametro di una funzione, è anche
possibile utilizzare le seguenti dichiarazioni di VLA che sono simili a quelle dei “normali”
vettori: return_type function_identifier (data_type, data_type []); e return_type
Snippet 6.10 Dichiarazione del prototipo della funzione sum con un parametro di tipo array a
lunghezza variabile.
/* prototipo della funzione sum */
int sum(int l, int data[l]);
Snippet 6.11 Definizione della funzione sum con un parametro di tipo array a lunghezza variabile.
/* definizione della funzione sum */
int sum(int l, int data[l])
{
...
}
Anche in questo caso non vi è alcuna sintassi particolare per invocare una funzione con
un parametro che è un array a lunghezza variabile: è sufficiente scrivere come relativo
argomento solamente il nome dell’array.
#define ROWS 3
#define COLS 5
int main(void)
{
int some_data[ROWS][COLS] =
{
{10, 100, 1000, 10000, 99},
{1, 10, 100, 1000, 9},
{210, 2100, 21000, 210000, 299}
};
// invocazione di sum
int res = sum(ROWS, COLS, some_data);
// stampo la somma del primo array di 3*5
printf("La somma degli elementi dell'array some_data di 3*5 elementi e': %d\n", res);
int r = 2, c = 3;
int other_data[r][c];
return (EXIT_SUCCESS);
}
Il Listato 6.9 mostra tutta l’utilità degli array a lunghezza variabile quando gli stessi
rappresentano degli array multidimensionali che sono impiegati come parametri di una
funzione. Grazie a essi, è possibile costruire delle funzioni che sono in grado di computare
dinamicamente array multidimensionali le cui dimensioni successive alla prima sono, di
volta in volta, differenti.
Nel nostro caso abbiamo potuto costruire una funzione sum che è in grado di ritornare la
somma degli elementi di una qualsiasi matrice il cui numero di colonne può variare rispetto
a un valore fornito come argomento (per noi la variabile c) che viene valutato dal
compilatore, a run-time, all’ingresso della funzione.
Resta inteso che se non avessimo avuto a disposizione tale caratteristica non avremmo
potuto scrivere la funzione sum in quel modo, ma avremmo dovuto indicare come
dimensione delle colonne un valore computato da un’espressione costante intera come, per
esempio, quella derivante da un direttiva #define, e sum non avrebbe potuto computare array
bidimensionali di diversa lunghezza.
In sostanza un array letterale si dichiara allo stesso modo di un normale array ma con le
seguenti differenze: è omesso l’identificatore; sono presenti delle parentesi tonde ( ) al cui
interno si indica il tipo di dato e le parentesi quadre [ ] che ne esplicitano la dimensione.
int main(void)
{
// invoco la funzione subtraction passando "al volo" un array
int res = subtraction((int []){1, 2, 3, 4, 5, 6}, SIZE);
...
}
Lo Snippet 6.12 invoca la funzione subtraction passando come primo parametro un array
letterale inizializzato contestualmente con sei valori di tipo intero.
In definitiva l’utilizzo di un array letterale, o di un letterale composto in generale, è utile
perché evita di dichiarare una variabile utilizzata, nel caso, in una sola occasione.
TERMINOLOGIA
Un letterale composto è indicato dallo standard come un’espressione postfissa formata da
una coppia di parentesi tonde ( ) al cui interno si indica un nome di un tipo di dato e cui fa
seguito una coppia di parentesi graffe { } in cui si pongono degli inizializzatori. È quindi un
concetto più generale riferibile non solo agli array ma anche ad altri oggetti come le struct,
le union e le stesse variabili. Per esempio, anche se di scarsa utilità, è possibile creare on
the fly una variabile di tipo int e passarla come argomento a una funzione, come nel caso
dell’invocazione foo((int){10});.
Snippet 6.13 Utilizzo della keyword static con un parametro di tipo array.
void printMe(int value[static 5])
{
...
}
DETTAGLIO
va_start, va_arg, va_end e va_copy sono delle function-like macro, ossia della macro che
accettano argomenti e che si comportano, dunque, come se fossero delle funzioni.
Ritorneremo su questo punto nel Capitolo 10.
1. Definire una funzione che ha almeno un parametro ordinario con nome e, di seguito,
scrivere dei punti di sospensione ... (ellissi) che indicano che il tipo e il numero di
argomenti forniti alla funzione è variabile. Il parametro ordinario, denominato dallo
standard parmN, deve sempre essere quello più a destra rispetto agli altri eventuali
parametri, mentre le ellissi devono apparire solamente al termine della lista di
parametri.
2. Dichiarare nel corpo della funzione una variabile di tipo va_list deputata a contenere
informazioni necessarie alle macro va_start, va_arg, va_end e va_copy che la utilizzeranno
per la manipolazione degli argomenti variabili. Per convenzione, l’identificatore
utilizzato per questo tipo di dato è ap, ossia argument pointer (puntatore ad
argomento), e sta proprio a indicare che tale variabile potrà contenere, di volta in volta,
un riferimento a un argomento, tra quelli variabili, da processare.
3. Utilizzare la macro va_start fornendo come primo parametro la variabile di tipo va_list
e come secondo parametro il parametro con nome che precede l’ellissi nella
definizione della funzione. Di fatto questa macro inizializza la variabile di tipo va_list
facendola puntare al primo argomento anonimo che si trova dopo il parametro con
nome fornito allo scopo. Questo parametro con nome è dunque essenziale perché
indica a va_start la “posizione” dopo la quale è possibile individuare l’inizio della lista
di argomenti variabili. Lo standard fornisce la seguente indicazione di come dovrebbe
essere dichiarata: va_start: void va_start(va_list ap, parmN).
4. Utilizzare la macro va_arg fornendo come primo parametro la variabile di tipo va_list
precedentemente inizializzata da va_start e come secondo parametro il tipo di dato
dell’argomento da processare. Questa macro permette di ottenere il valore di un
argomento anonimo e modifica la variable di tipo va_list in modo che possa puntare al
successivo argomento anonimo (se non vi è un successivo argomento, oppure se il tipo
di dato fornito non è compatibile con il tipo di dato del successivo argomento, il
comportamento è non definito). Lo standard fornisce la seguente indicazione di come
dovrebbe essere dichiarata: va_arg: type va_arg(va_list ap, type).
NOTA
Quando una funzione con un numero variabile di argomenti è invocata, il compilatore
effettua una promozione di default degli argomenti variabili (per esempio, un char è
convertito in un int e un float in un double).
1. Utilizzare la macro va_end fornendo come primo parametro la variabile di tipo va_list.
Questa macro è essenziale perché effettua delle operazioni necessarie alla “pulizia”
della variabile di tipo va_list per un successivo riutilizzo (per esempio, potrebbe
deallocare la memoria impiegata per contenere gli argomenti variabili e renderla
inutilizzabile finché non avviene un’altra inizializzazione con va_start) oppure per un
corretto ritorno dalla funzione dove è stata impiegata. Lo standard fornisce la seguente
indicazione di come dovrebbe essere dichiarata: va_end: void va_end(va_list ap).
int main(void)
{
// sottrazione con 6 elementi
int res = subtraction(6, 369, 10, 15, 65, 88, 66);
printf("Il risultato della sottrazione con 6 elementi e': %d\n", res);
// sottrazione con 3 elementi
res = subtraction(3, 100, 50, 20);
printf("Il risultato della sottrazione con 3 elementi e': %d\n", res);
return (EXIT_SUCCESS);
}
va_end(ap); // clean up di ap
return result;
}
Il Listato 6.10 definisce la funzione subtraction con il primo argomento che indica quanti
argomenti variabili sono utilizzati e poi l’ellissi che indica che la funzione accetta una
quantità variabile di argomenti.
Infatti, nel main, notiamo come subtraction sia chiamata la prima volta, con 6 elementi,
mentre la seconda volta con 3 elementi e in ambedue le situazioni ritorni il corretto risultato
“adattato” alla giusta quantità di argomenti passati.
La definizione della funzione subtraction è invece scritta in modo che va_list, va_start,
va_arg e va_end, nella giusta sequenza, gestiscano e scansionino gli argomenti variabili.
In particolare, ap rappresenta il tipo va_list che è utilizzato da:
Infine, a partire da C99 è possibile utilizzare anche la macro va_copy, che consente di
inizializzare il suo primo parametro (la destinazione), che è una variabile di tipo va_list, con
il corrente valore di un’altra variabile di tipo va_list passata come secondo parametro (la
sorgente). Lo standard ne fornisce, come indicazione, la seguente dichiarazione: void
va_copy(va_list dest, va_list src).
Questa macro è dunque utile perché consente di memorizzare la corrente “posizione” di
attraversamento di una lista di argomenti variabili e di utilizzare i successivi argomenti
anche se quelli originari sono stati completamente processati.
Snippet 6.14 Utilizzo della macro va_copy nel primo caso di sottrazione con 6 elementi.
/* definizione della funzione subtraction */
int subtraction(int length, ...)
{
va_list ap; // dichiaro una variabile di tipo va_list
// I invocazione con ap
int result = va_arg(ap, int); // 369
// II invocazione con ap
result = va_arg(ap, int); // 10
// copia di ap
va_list ap_copy;
va_copy(ap_copy, ap);
// IV invocazione con ap
result = va_arg(ap, int); // 65
va_end(ap); // clean up di ap
va_end(ap_copy); // clean up di ap_copy
Lo Snippet 6.14 mostra l’uso della macro va_copy adattata nella funzione subtraction, la
quale però non ritorna più alcun valore valido e congruo.
Vediamo nell’ordine cosa accade.
1. La prima invocazione di va_arg con ap ritorna il risultato del primo argomento variabile,
ossia 369, e modifica ap in modo che possa puntare al successivo argomento.
2. La seconda invocazione di va_arg con ap ritorna il risultato del secondo argomento
variabile, ossia 10, e modifica ap in modo che possa puntare al successivo argomento.
3. L’invocazione di va_copy inizializza la variabile ap_copy di tipo va_list con il corrente
valore di ap che sta puntando al terzo argomento.
4. La terza invocazione di va_arg con ap ritorna il risultato del terzo argomento variabile,
ossia 15, e modifica ap in modo che possa puntare al successivo argomento.
5. La quarta invocazione di va_arg con ap ritorna il risultato del quarto argomento
variabile, ossia 65, e modifica ap in modo che possa puntare al successivo argomento.
6. La prima invocazione di va_arg con ap_copy ritorna il risultato del terzo argomento
variabile, ossia 15, in accordo con quanto accaduto al punto 3, e modifica ap_copy in
modo che possa puntare al successivo argomento.
7. L’invocazione di va_end con ap e poi con ap_copy compie le necessarie operazioni di
pulizia.
L’istruzione return: dettaglio
L’istruzione return (Sintassi 6.13) termina l’elaborazione delle istruzioni nella corrente
funzione e ritorna il controllo dell’esecuzione del codice alla funzione chiamante che
riprende l’elaborazione dall’istruzione successiva alla chiamata della funzione.
In definitiva possiamo dire che mentre gli argomenti passano delle informazioni in
ingresso da una funzione chiamante a una funzione chiamata, l’istruzione return passa delle
informazioni in uscita dalla funzione chiamata alla funzione chiamante.
Come evidenziato dalla Sintassi 6.13, un’istruzione return può ritornare un valore alla
funzione chiamante per il tramite di una qualsiasi espressione.
NOTA
A volte, anche se non è necessario, expression è posta tra una coppia di parentesi tonde ( )
per semplici ragioni di stile di scrittura del codice. Esse non sono, comunque, obbligatorie.
Se una funzione ha un tipo di ritorno, allora expression deve indicare il valore ritornato e il
suo tipo deve concordare con tale tipo di ritorno, altrimenti il compilatore ne effettua una
conversione (per esempio, se una funzione ha come tipo di ritorno un int ma expression ha
come tipo un double, allora il valore di expression è convertito in int prima dell’esecuzione di
return).
Se, viceversa, una funzione ha come tipo di ritorno void, che statuisce che la stessa non
deve ritornare alcun valore, allora può essere utilizzata return senza expression in qualsiasi
punto della funzione oppure si può attendere la naturale terminazione della funzione che
avviene quando il flusso di esecuzione del codice raggiunge la parentesi graffa } di chiusura
del suo blocco costitutivo.
with a value, in function returning void. Il comportamento del programma sarà non
definito. Inoltre se la funzione chiamante ne vuole utilizzare il valore, per esempio
assegnandolo a una variabile, un compilatore potrebbe generare un errore come error:
void value not ignored as it ought to be.
Snippet 6.17 Utilizzi scorretti dell’istruzione return e/o del tipo di ritorno.
...
int case_1()
{
int v = 100; // warning: control reaches end of non-void function
}
int case_2()
{
int v = 100;
return; // warning: 'return' with no value, in function returning non-void
}
void case_3()
{
int v = 100;
return v; // warning: 'return' with a value, in function returning void
}
int main(void)
{
int v = case_1();
v = case_2();
v = case_3(); // error: void value not ignored as it ought to be
...
}
int b = 100;
int main(void)
{
int val = foo();
...
}
// definizione di foo
int foo(void)
{
// sequence point prima dell'uscita dalla funzione;
// questo garantirà che il side-effect su b sia completamente portato a termine
// e infatti la variabile esterna b sarà comunque decrementata e varrà 99;
// al contempo, val conterrà il valore 100 perché su b è stato impiegato l'operatore
// di decremento postfisso
return b--;
}
Lo specificatore di funzione _Noreturn
A partire da C11 è possibile utilizzare lo specificatore di funzione _Noreturn per indicare
che una funzione, al termine del suo processo elaborativo, non ritornerà al chiamante.
Questa modalità di comportamento della funzione è differente rispetto allo specificare
come tipo di ritorno void, perché in questo caso la funzione chiamante, al termine delle sue
operazioni, ritornerà il controllo alla funzione chiamata.
// fa qualcosa...
Nel caso dello Snippet 6.19 la funzione makeATask esplicita, tramite _Noreturn, che al
termine della sua esecuzione non ritornerà al chiamante (per esempio la funzione main) ma
tramite la funzione exit terminerà l’applicazione.
NOTA
Nello standard C11 la stessa funzione exit è prototipata come _Noreturn void exit(int
function declared 'noreturn' has a 'return' statement e warning: 'noreturn' function does return.
Le funzioni inline
A partire da C99 è possibile utilizzare lo specificatore di funzione inline per “suggerire”
al compilatore di chiamare una funzione nel modo più efficiente e veloce possibile al fine di
ridurre o eliminare il consueto overhead legato, per l’appunto, alla sua invocazione (per
esempio, nella chiamata di una funzione non inline, un compilatore deve compiere una serie
di operazioni dispendiose come preparare la chiamata eseguendo determinate istruzioni, fare
una copia degli argomenti, saltare all’indirizzo di memoria dove iniziano le istruzioni della
funzione e così via).
TERMINOLOGIA
In questo contesto, per overhead si intende quel complesso di operazioni supplementari o
extra, e dunque “costose”, che un compilatore deve compiere per invocare una funzione e
che sono quindi ulteriori rispetto a quelle che comportano la sola esecuzione del codice ivi
contenuto.
Tuttavia, contrariamente a quanto il nome possa far intendere, non è detto che un
compilatore per ridurre o eliminare l’overhead di chiamata “sostituisca”, inline, l’istruzione
di invocazione di una funzione con il contenuto nel suo body ovvero con le sue istruzioni
costitutive (un compilatore può decidere di adottare altri meccanismi di ottimizzazione o di
non fare nulla; infatti, il come e il se utilizzare questa feature è implementation-defined).
int main(void)
{
int x = 10, y = 12;
NOTA
La keyword static applicata alla definizione della funzione inline ha un significato che è
legato al concetto di linkage. Ritorneremo in modo dettagliato su questo punto nel Capitolo
9. Per il momento è sufficiente dire che esso impone che la funzione max sia utilizzabile solo
dalle funzioni definite nel corrente file.
NOTA
GCC può compiere un inline di una funzione se è attiva l’ottimizzazione, per esempio
compilando il sorgente che contiene la funzione inline con il flag -O3.
Vediamo subito un esempio che chiarirà meglio quanto detto, illustrando il concetto
matematico di fattoriale e vedendo come possiamo scrivere un metodo che ne effettua il
calcolo.
IL FATTORIALE DI UN NUMERO
In matematica il calcolo del fattoriale è un procedimento mediante il quale, dato un numero
intero positivo, si deve trovare quel valore che è il prodotto di tutti i numeri interi positivi minori
o uguali del numero stesso. Lo si può trovare usando la formula iterativa: N! = N * (N - 1) * (N
- 2) * ... * 1 oppure quella ricorsiva, N! = N * (N - 1)! considerando che, in entrambi i casi, 0!
= 1 e 1! = 1. Per esempio, con la formula iterativa il fattoriale di 5 è 5 * 4 * 3 * 2 * 1, mentre
con quella ricorsiva il fattoriale di 5 è 5 * (5 - 1)!. In entrambi i casi, ovviamente, il risultato
sarà uguale a 120.
int main(void)
{
long number, result;
printf("*** Calcolo del fattoriale di un numero ***\n\n");
printf("Digita un numero [q per uscire]: ");
while (scanf("%ld", &number) == 1)
{
if (number < 0)
printf("Digita solo numeri maggiori o uguali a 0.\n");
else if (number > 18)
printf("Digita solo numeri minori o uguali a 18.\n");
else
{
result = factorial(number);
printf("Il fattoriale di %lu e' %lu.\n", number, result);
}
printf("Digita un numero [q per uscire]: ");
}
printf("\n*** Computazione terminata ***\n");
return (EXIT_SUCCESS);
}
2. number vale 3 e non è minore o uguale al numero 1, cosicché viene invocato nuovamente
factorial con argomento 3 – 1;
3. number vale 2 e non è minore o uguale al numero 1, cosicché viene invocato nuovamente
factorial con argomento 2 – 1;
Figura 6.2 Calcolo del fattoriale di 4: passi ricorsivi e ritorno dalla funzione factorial.
3. il risultato 3 * 2;
4. il risultato 4 * 6;
5. il risultato 24 che sarà ritornato al chiamante, che nel nostro caso è la funzione main.
Per comprendere ancora meglio quest’importante concetto appare utile fare una breve
divagazione teorica su come in C sono effettivamente eseguite le chiamate alle funzioni.
Prima di tutto è impiegata una struttura di dato denominata function call stack che serve a
memorizzare, secondo una modalità definita LIFO (Last In First Out), un cosiddetto stack
frame (riferito anche come activation record); questo è visualizzabile come una sorta di
“scatola” contenente informazioni e dati essenziali per una funzione come l’indirizzo di
ritorno alla funzione chiamante (ossia il punto del codice dove tornare al termine della sua
esecuzione dove si troverà la successiva istruzione da eseguire), le sue variabili locali, i suoi
eventuali parametri e così via.
La modalità di memorizzazione LIFO utilizzata dal function call stack comporta che ogni
stack frame creato (causa di un’invocazione di funzione da una precedente funzione) venga
“impilato” sopra un altro, proprio come accade quando nella realtà si collocano dei piatti di
portata gli uni sopra gli altri, e l’ultimo stack frame inserito (pushed) è anche il primo a
essere rimosso (popped) quando la funzione cessa il suo compito elaborativo (ritorna alla
funzione chiamante).
Ritornando all’esempio dei piatti di portata, l’ultimo piatto collocato in cima sarà anche
quello che, per primo, bisognerà togliere, pena la caduta di tutta la pila di piatti.
Ciò detto abbiamo che funzioni che chiamano altre funzioni danno origine a una
sequenza di stack frame che sono allocati secondo l’ordine delle chiamate (prima la
funzione A, poi la funzione B, poi la funzione C e così via) e sono deallocati in ordine inverso
(prima la funzione C, poi la funzione B, poi la funzione A).
Ritornando al nostro esempio del Listato 6.11, e in accordo con quanto detto, proviamo a
“disegnare” cosa accade per il calcolo del fattoriale quando viene invocata ricorsivamente la
funzione factorial. Nella Figura 6.3 vediamo il function call stack completamente impilato
dopo che l’ultima invocazione di factorial è stata effettuata (dalla I alla IV in quest’ordine);
nella Figura 6.4 vediamo gli step di deallocazione della funzione factorial dopo che la IV è
ritornata con il valore 1 (dalla III invocazione alla I in quest’ordine).
Figura 6.3 Function call stack dopo l’ultima invocazione di factorial.
Vediamo allora quali sono i vantaggi e gli svantaggi relativi all’utilizzo della ricorsione:
tra i vantaggi abbiamo che “pensare” alla soluzione di un problema algoritmico in termini
ricorsivi può produrre a volte un codice più elegante e di chiara lettura.
In più, molti problemi computazionali sono risolvibili in modo più intuitivo e agevole
tramite l’utilizzo della ricorsione, che si presta quindi meglio a “codificare” in modo
naturale la relativa soluzione (si pensi ancora una volta al calcolo del fattoriale e a come la
sua formula risolutiva, ossia N * (N - 1)!, è stata codificata tale e quale nel codice).
Tra gli svantaggi abbiamo sicuramente quello della scarsa efficienza prestazionale,
perché richiamare molte volte una funzione richiede tempo per la gestione del function call
stack e fa consumare molta memoria per allocare, a ogni chiamata, un nuovo stack frame
con tutte le sue informazioni e dati (per esempio per allocare una propria copia delle
variabili locali).
La funzione main: nozioni conclusive
Ora che abbiamo appreso tutti i concetti relativi alla definizione e l’utilizzo delle
funzioni, possiamo terminare l’analisi dell’importante funzione main, che ricordiamo essere
la funzione che è invocata in automatico quando il programma che la contiene viene
avviato.
Lo standard C11 stabilisce che la funzione main non è dotata di alcun prototipo e deve
essere definita in uno dei seguenti modi (Sintassi 6.14 e 6.15).
Abbiamo un tipo di ritorno int e con il tipo void per la lista dei parametri.
Abbiamo un tipo di ritorno int e con due parametri: il primo, argc (argument count), di
tipo intero, conterrà il numero di argomenti forniti dalla riga di comando quando si
invocherà il relativo programma, il cui nome verrà incluso in questo conteggio; il secondo,
argv (argument vector), di tipo vettore o array di puntatori a char, conterrà, come stringhe, i
predetti argomenti forniti (un argomento sarà anche il nome del programma).
NOTA
Il concetto di array di puntatori a un tipo sarà chiarito in dettaglio nel Capitolo 7. Per ora
proviamo a spiegarlo così: dato che un puntatore può contenere un indirizzo in memoria, un
puntatore a carattere può contenere un indirizzo in memoria a partire dal quale si può
trovare una stringa (che è un array di caratteri). Ciò detto, quando si invoca un programma
con una serie di argomenti questi sono trattati come stringhe, dove l’indirizzo in memoria di
ciascuna è memorizzato come valore nel corrispettivo elemento dell’array argv, che quindi è
un vettore a una serie di puntatori a caratteri.
TERMINOLOGIA
Per command-line interface (interfaccia a riga di comando) si intende un ambiente di
interazione utente/computer, messo a disposizione da un sistema operativo, con il quale un
utente invia comandi (esegue programmi di sistema e non) tramite delle righe di testo
(command lines). Così per command-line arguments (argomenti dalla riga di comando) si
intendono eventuali argomenti forniti a un programma sulla stessa riga. Esempi di
command-line interface sono il prompt dei comandi in Windows oppure la shell in ambienti
Unix.
Quando l’array argv sarà popolato, il primo elemento, argv[0], conterrà il nome del
programma (il program name come definito dallo standard), mentre, se presenti degli
argomenti dalla riga di comando (program parameters come definiti dallo standard), gli
stessi si troveranno localizzati come stringhe negli elementi che dell’array che andranno da
argv[1] ad argv[argc - 1]. Così se impartiamo da una shell un comando come chmod 644
data.html, avremo che il parametro argc conterrà il valore 3, argv[0] conterrà la stringa chmod,
argv[1], conterrà la stringa 644 e argv[2] conterrà la stringa data.html.
NOTA
Gli identificatori argc a argv sono del tutto arbitrari. Nulla vieta di utilizzarne altri.
return (EXIT_SUCCESS);
}
Il Listato 6.12 mostra un semplice programma che si limita a ripetere gli argomenti forniti
in una sorta di eco.
Questo programma non ha alcuna utilità pratica: serve solo a illustrare come sia possibile
ottenere dall’array argv i relativi argomenti forniti tramite un semplice ciclo for che inizia
dall’indice 1 perché l’indice 0 contiene il nome del programma.
Per verificare il suo funzionamento è sufficiente digitare quanto riportato di seguito.
I puntatori sono senza dubbio una delle caratteristiche più importanti ed essenziali del
linguaggio C. La corretta e adeguata comprensione del loro utilizzo è imprescindibile per
padroneggiare proficuamente il linguaggio.
Essi forniscono sia un potente meccanismo per scrivere programmi veloci, efficienti e
compatti sia un utile strumento attraverso il quale fornire un accesso diretto alla memoria,
allocare e deallocare dinamicamente la memoria, costruire complesse strutture di dati (liste
collegate, pile, code, alberi, grafi e così via), passare alle funzioni dei riferimenti a tipi di
dato derivati e complessi (per esempio i tipi struttura) evitando l’overhead del copia dei
rispettivi membri, modificare direttamente all’interno di una funzione chiamata una
variabile passata come argomento dalla rispettiva funzione chiamante, fornire una “sintassi”
alternativa di accesso e manipolazione degli elementi di un array e così via per altre
soluzioni a problemi algoritmici o computazionali di vario tipo.
Dal punto di vista semantico, per dirla in modo semplice, un puntatore altro non è che
una variabile specializzata a contenere un indirizzo di memoria di un’altra variabile.
Ricordiamo, infatti, che ogni variabile di un programma occupa una determinata quantità
di memoria a seconda del suo tipo (per esempio, in un sistema a 32 bit una variabile di tipo
int può occupare 4 byte) ed è localizzabile precisamente attraverso un indirizzo che è esso
stesso un valore numerico (per esempio, la predetta variabile di tipo int potrebbe occupare
gli indirizzi di memoria, byte per byte, 0x0109f9f4, 0x0109f9f5, 0x0109f9f6 e 0x0109f9f7 e
l’indirizzo 0x0109f9f4 sarebbe il suo indirizzo a partire dal quale localizzarla).
NOTA
Il Capitolo 1, al paragrafo “La memoria centrale”, contiene un’analisi dettagliata su come è
organizzata e rappresentata la memoria di un elaboratore.
Figura 7.1 Rappresentazione in memoria di una variabile di tipo int contenente il valore 10.
Così lo Snippet 7.1 dichiara la variabile data come un puntatore a un tipo int, ossia
stabilisce che data potrà contenere un riferimento verso qualsiasi oggetto di tipo intero
(potrà contenerne l’indirizzo di memoria).
Figura 7.4 Rappresentazione del puntatore data dopo l’assegnamento dell’indirizzo di value.
Dalla Figura 7.4 si evince come dopo l’assegnamento dell’indirizzo di memoria di value il
puntatore data punti alla variabile value medesima perché, ripetiamo, tale puntatore contiene
come valore quell’indirizzo.
Infine, il seguente operatore, detto di indirezione o deriferimento, espresso mediante il
simbolo asterisco * e prefisso all’identificatore di un puntatore, consente di accedere al
contenuto di un oggetto riferito da un puntatore (Sintassi 7.3).
Ritornando al precedente esempio, lo Snippet 7.3 assegna alla variabile tmp il contenuto
della variabile value riferita dal puntatore data.
In buona sostanza l’istruzione *data può essere espressa letteralmente nel seguente modo:
“accedi al contenuto dell’oggetto puntato da data e non al contenuto di data stesso”.
Da questo punto di vista, quindi, *data può essere considerato un alias di value, ossia
qualsiasi manipolazione effettuata per il tramite di esso si ripercuoterà su value stessa
(Snippet 7.4 e Figura 7.5).
ATTENZIONE
Non applicare mai l’operatore di deriferimento a un puntatore non inizializzato con un
corretto indirizzo di memoria, altrimenti si potrà avere un comportamento non definito: crash
del programma (l’indirizzo di memoria memorizzato nel puntatore è al di fuori dell’address
space valido del programma), stampa di valori garbage o insensati (l’indirizzo di memoria
memorizzato nel puntatore mostra quello che in quel momento è presente a quell’indirizzo
quantunque valido) e così via.
DETTAGLIO
Perché al punto 2 si è precisato che l’indirizzo di memoria assegnato a un puntatore deve
essere di un oggetto dello stesso tipo da esso espresso in fase di dichiarazione? Perché un
puntatore, indipendentemente dall’oggetto puntato, conterrà sempre e solo un indirizzo di
memoria; pertanto, indicando durante la sua dichiarazione qual è il tipo di oggetto
localizzato a quell’indirizzo di memoria, si otterrà che, in fase di accesso, il contenuto di tale
locazione sarà interpretato correttamente. Per esempio, compilando e mandando in
esecuzione il Listato 7.1 avremo sia il messaggio: warning: assignment from incompatible
pointer type, sia un output dove il valore della variabile di tipo float non sarà stato
interpretato correttamente.
int main(void)
{
int i_number = 100;
float f_number = 100.44f;
return (EXIT_SUCCESS);
}
NOTA
In C gli argomenti sono passati, sempre, per valore anche se sono dei puntatori. Il fatto che
un parametro sia un puntatore e che consenta di modificare il relativo argomento non
implica che esista, nativamente, la modalità di passaggio di un argomento “per riferimento”
(come in C++) o “per indirizzo”. Infatti, quando si passa a una funzione l’indirizzo del suo
argomento è più corretto dire che si sta passando “un suo riferimento” e non che
l’argomento è passato “per riferimento”.
int main(void)
{
int a = 10;
int *j = &a;
return (EXIT_SUCCESS);
}
Il Listato 7.2 mostra come anche un puntatore sia passato by value: infatti, quando nel
main viene invocata la funzione foo viene fatta una copia dell’indirizzo di memoria contenuto
nel puntatore j, e tale copia viene posta nel parametro p, anch’esso un puntatore.
A questo punto entrambi i puntatori referenziano lo stesso oggetto, ovvero la variabile a.
Poi, nell’ambito della funzione foo, al puntatore p viene assegnato un altro indirizzo di
memoria, quello della variabile k, senza però che tale assegnamento incida sull’indirizzo di
memoria dell’argomento j che rimane, infatti, inalterato.
A questo punto il puntatore j continua a puntare alla variabile a mentre il puntatore p
“rompe” il puntamento verso la variabile a e imposta un nuovo puntamento verso la
variabile k (Figura 7.6).
/* prototipo di swap */
void swap(int *w, int *z);
int main(void)
{
int a = 10, b = 20;
printf("a e b prima dello swap: a=%d - b=%d\n", a, b);
// passo i puntatori ad a e b
swap(&a, &b);
return (EXIT_SUCCESS);
}
/* definizione di swap */
void swap(int *w, int *z)
{
int tmp = *w;
*w = *z;
*z = tmp;
}
TERMINOLOGIA
Quando si passa a una funzione un argomento tipo &a si può anche direttamente dire che si
sta passando un puntatore ad a piuttosto che l’indirizzo di a perché, dato che l’operatore &
La funzione foo dello Snippet 7.5 è definita con un valore di ritorno che è un puntatore a
un int, cioè dovrà ritornare un indirizzo di memoria di un oggetto dello stesso tipo di dato.
Quando si definisce una funzione in questo modo bisogna prestare attenzione a non
ritornare mai un indirizzo di memoria di una variabile locale automatica alla funzione
stessa: questo perché, quando la funzione ritorna, tale variabile cesserà di esistere e pertanto
il relativo puntatore sarà considerato invalido (Listato 7.4).
int *foo(void);
void bar(void);
int main(void)
{
int *p = foo();
return (EXIT_SUCCESS);
}
int *foo(void)
{
int j = 1000;
printf("Indirizzo di j in foo: %#p\n", &j);
return &j;
}
void bar(void)
{
int b = 2000;
printf("Indirizzo di b in bar: %#p\n", &b);
}
Figura 7.7 ptr_to_data, dopo l’assegnamento, punterà al primo elemento dell’array data.
Lo Snippet 7.6 definisce l’array data deputato a contenere 7 elementi di tipo intero e poi
ne assegna l’indirizzo del primo elemento (l’elemento 0 con valore 10) al puntatore a int
denominato ptr_to_data.
L’assegnamento di data a ptr_to_data è equivalente al seguente che, comunque, anche se
più esplicito e chiaro è raramente usato: int *ptr_to_data = &data[0].
CURIOSITÀ
È lecito scrivere qualcosa come i[data] al posto di data[i]? La risposta è certamente
affermativa perché il compilatore, per effetto delle equivalenze discusse tra array e
puntatori, quando incontra un’espressione come questa la trasforma in *(i + data), che è
dunque corretta. Allo stesso modo data[i] sarebbe trasformata in *(data + i).
Lo Snippet 7.10 mostra come il tipo utilizzato per contenere la differenza tra due
puntatori sia ptrdiff_t, il quale è definito nel file header <stddef.h> (generalmente con un
typedef di un tipo intero con segno) ed è il modo “portabile” per esprimere tale differenza
(con la funzione printf il correlativo specificatore di formato utilizzabile è, per esempio,
%td).
ATTENZIONE
Se si compiono le operazioni di aritmetica dei puntatori qui citate con un puntatore che non
punta a un elemento di un array oppure con dei puntatori che non riferiscono elementi di
uno stesso array (come è il caso di sottrazione di un puntatore da un altro), il
comportamento sarà non definito. In ogni caso l’indirizzo di memoria subito successivo a
quello dell’ultimo elemento di un array è garantito essere valido.
ATTENZIONE
Se si compiono le operazioni di comparazione dei puntatori qui citate con gli operatori
relazionali, con dei puntatori che non riferiscono elementi di uno stesso array, il
comportamento sarà non definito. Tuttavia l’indirizzo di memoria subito successivo a quello
dell’ultimo elemento di un array è garantito essere valido e può essere utilizzato per un
confronto.
#define SIZE 6
int main(void)
{
int some_data[] = {369, 10, 15, 65, 88, 66};
printf("L'array some_data, nella funzione main, ha una dimensione di "
"%zu byte\n", sizeof some_data);
return (EXIT_SUCCESS);
}
return result;
}
Il Listato 7.5 definisce una funzione subtraction che, dato un array come argomento, ne
restituisce un valore che è la differenza di tutti i valori dei suoi elementi.
L’importanza del programma del listato non risiede di sicuro nella funzione di
sottrazione, che è banale, quanto piuttosto perché evidenzia due aspetti di rilievo: il primo è
legato alla verifica che nell’ambito della definizione di una funzione un parametro di tipo
array è di fatto considerato come un puntatore a un suo elemento; il secondo è relativo a
come è possibile utilizzare l’aritmetica dei puntatori per scorrere gli elementi di un array
riferito da un puntatore in sostituzione della consueta indicizzazione.
Per quanto riguarda il primo aspetto, lo stesso è verificabile guardando all’output del
programma, dove:
l’operatore sizeof applicato all’array some_data nell’ambito della funzione main dà come
risultato il valore 24 in accordo con il fatto che è un tipo array di int che contiene 6
elementi di tipo int di 4 byte ciascuno sul corrente sistema a 32 bit;
l’operatore sizeof applicato nell’ambito della funzione subtraction dà come risultato il
valore 4 in accordo con il fatto che è un tipo puntatore a un int, e dunque sul corrente
sistema a 32 bit 4 byte sono lo spazio utilizzato per allocare un tipo puntatore atto a
contenere un indirizzo di memoria.
Il secondo aspetto è invece dimostrabile nel ciclo for della funzione subtraction: qui si
utilizza espressamente l’aritmetica dei puntatori per manipolare il parametro data fornendo
al puntatore p l’indirizzo dell’elemento 1 (data + 1) e verificando, come condizione di
continuazione del ciclo, che l’indirizzo corrente di p sia nel range di indirizzi dove sia
possibile validamente ottenere un valore numerico da computare (p < data + length)
Prima di procedere oltre ricordiamo che per C: un array a due dimensioni è un array di
array, ossia un array a una dimensione dove ogni elemento è esso stesso un altro array; la
disposizione in memoria di un array a due dimensioni è fatta riga per riga (row major order,
prima la riga 0, poi la riga 1 e così via per tutte le altre righe).
NOTA
Per semplicità useremo gli array a due dimensioni come forma di array multidimensionale. I
concetti generali esposti saranno comunque validi anche per array di dimensioni maggiori.
Nel prossimo elenco riportiamo la “valutazione” da parte del compilatore
dell’identificatore data, ossia a che tipo di puntatore “corrisponde” quando gli applichiamo o
meno l’operatore di indirizzamento &.
1. data corrisponde a int (*)[3], ossia a un puntatore a un array di 3 elementi. Esso ritorna
l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come
la riga 0 della matrice contenente i valori 1, 2 e 3.
2. &data[0] corrisponde a int (*)[3], ossia a un puntatore a un array di 3 elementi. Esso
ritorna l’indirizzo di memoria dell’elemento 0 (0x00a2fbac) che è in pratica
visualizzabile come la riga 0 della matrice contenente i valori 1, 2 e 3. Quindi, data e
&data[0] sono “sinonimi”.
3. data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di
memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0
della matrice contenente i valori 1, 2 e 3.
4. &data[0][0] corrisponde a int *, ossia a un puntatore a int. Esso ritorna l’indirizzo di
memoria (0x00a2fbac) dell’elemento che si trova alla riga 0 e alla colonna 0;
5. &data corrisponde a int (*)[2][3], ossia a un puntatore a un array di 2 righe per 3
colonne. Esso ritorna l’indirizzo di memoria (0x00a2fbac) che è in pratica quello a
partire dal quale inizia tutto l’array a due dimensioni.
Le espressioni viste sono importanti perché evidenziano che, pur ritornando tutte lo
stesso indirizzo di memoria, avranno comunque dei tipi di puntatori differenti, e ciò ci sarà
utile per comprendere cosa accadrà quando dovremo applicare l’operatore di deriferimento *
su di esse. Avremo infatti quanto segue
1. *data corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di
memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0
della matrice contenente i valori 1, 2 e 3.
2. *&data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di
memoria dell’elemento 0 (0x00a2fbac) che è in pratica visualizzabile come la riga 0
della matrice contenente i valori 1, 2 e 3.
3. *data[0] corrisponde a un int. Esso ritorna il valore dell’elemento, ossia 1, posto alla
colonna 0 e riga 0.
4. *&data[0][0] corrisponde a un int. Esso ritorna il valore dell’elemento, ossia 1, posto
ritornando l’indirizzo di memoria dell’elemento 0 come tipo int * che poi sposta di c
unità di storage. Infine, dereferenzia tale indirizzo di memoria ritornando un tipo int.
Per esempio *(*(data + 1) + 1), ritornerà il valore -2 che è posto nella colonna 1 della
riga 1.
Quanto mostrato sinora è senza dubbio complesso e richiede una certa attenzione e
pazienza per comprendere in modo ottimale la relazione tra un puntatore e un array
bidimensionale; comunque ciò non deve indurre a eccessive preoccupazioni perché, quando
si scrivono programmi che fanno uso di array bidimensionali, è sufficiente:
per accedere a un determinato elemento, utilizzare la più semplice notazione con
indicizzazione propria degli array (per esempio, data[1][1] accede alla colonna 1 della
riga 1);
per impiegare un parametro formale di tipo array bidimensionale, dichiararlo con la
consueta notazione con indicizzazione degli array già vista (per esempio void foo(int
data[][3]) { … }) oppure con la notazione che fa uso dei puntatori (per esempio void
foo(int (*data)[3]) { … }).
ATTENZIONE
Le parentesi ( ) intorno a *data sono essenziali. Se le omettessimo, la dichiarazione relativa,
ossia int *data[3], per effetto della più alta precedenza dell’operatore [ ] rispetto
all’operatore *, significherebbe che data è un array di 3 puntatori a int. Invece, scrivere int
#define ROWS 3
#define COLS 5
int main(void)
{
int data[][COLS] =
{
{1, 2, 3, 4, 5},
{-4, -6, 10, 2, 9},
{100, -100, 33, 34, 24}
};
// invocazione di search
int res = search(data, ROWS);
return (EXIT_SUCCESS);
}
Il Listato 7.6 esplicita in modo pratico, nell’ambito della funzione search, come utilizzare
la notazione puntatore/offset per riferirsi agli elementi di una matrice. Il ciclo for più esterno
si occupa di far spostare il puntatore corrente alla riga successiva; lo fa incrementando
ptr_to_data di una unità di storage alla volta che è pari a 20 byte perché ogni riga contiene 5
elementi di tipo int con un int di 4 byte (ricordiamo che ptr_to_data è di tipo int (*)[5]). Il
ciclo for più interno, invece, fa spostare il corrente puntatore alla colonna successiva, e lo fa
incrementando *(ptr_to_data + r) di una unità di storage alla volta che è pari a 4 byte perché
ogni colonna è un elemento di tipo int con un int di 4 byte (ricordiamo che *(ptr_to_data +
Figura 7.14 Visualizzazione del puntatore ptr_to_data dopo uno spostamento per offset.
Per quanto concerne, infine, la scrittura dell’array bidimensionale come parametro di una
funzione, possiamo ora comprendere perché una sintassi come int data[][] non sarebbe mai
accettata dal compilatore; dato che esso “converte” la notazione a indicizzazione degli array
come notazione a puntatore, quando valuterà qualcosa come data + 1 non avrà dati a
sufficienza per sapere di quante unità di storage spostare il puntatore corrente alla prossima
riga.
Ecco, quindi, perché bisogna sempre indicare il numero di colonne dell’array mentre non
è obbligatorio indicare il numero di righe (il parametro int data[][COLS] è trattato dal
compilatore come int (*data)[COLS], e infatti il relativo argomento passato è un puntatore a
un array di COLS colonne).
Array di puntatori
Gli array di puntatori sono vettori dove ciascun elemento è un puntatore a un determinato
tipo. Così, una dichiarazione come int *data[4] stabilisce che il nome data è un array di 4
elementi ciascuno dei quali contiene come valore un indirizzo di memoria di un tipo int
ossia un suo puntatore.
Questo tipo di oggetto si presta in modo ottimale a creare i cosiddetti array triangolari o
irregolari, ossia array dove ogni riga può avere un numero di colonne differente.
Lo Snippet 7.13 crea l’array data composto da 4 righe dove ciascuna riga punta a un array
di colonne, definito con la sintassi “letterale”, di differente dimensione.
Poiché il nome di un array è valutato come un puntatore al suo primo elemento, di fatto,
ogni elemento di data ne contiene un adeguato riferimento ossia un puntatore a int.
Così, data[0] conterrà l’indirizzo di memoria del primo elemento del primo array letterale
(elementi con valori 1 e 2), data[1] conterrà l’indirizzo di memoria del primo elemento del
secondo array letterale (elementi con valori 3, 4 e 5) e così via per data[2] e data[3].
Per quanto riguarda la valutazione da parte del compilatore del nome data, da solo e con
l’operatore di indirizzo &, abbiamo che:
1. data corrisponde a int **, ossia a un puntatore a un puntatore a int. Esso ritorna
l’indirizzo di memoria dell’elemento 0 (0x0028fe90) che è in pratica visualizzabile come
il primo puntatore all’array contenente i valori 1 e 2;
2. &data[0] corrisponde a int **, ossia a un puntatore a un puntatore a int. Esso ritorna
l’indirizzo di memoria dell’elemento 0 (0x0028fe90) che è in pratica visualizzabile come
il primo puntatore all’array contenente i valori 1 e 2;
3. data[0] corrisponde a int *, ossia a un puntatore a un int. Esso ritorna l’indirizzo di
memoria riferito dall’elemento 0 (0x0028fea0) che è in pratica visualizzabile come il
primo elemento dell’array puntato (valore 1);
4. &data[0][0] corrisponde a int *, ossia a un puntatore a int. Esso ritorna l’indirizzo di
memoria (0x0028fea0) dell’elemento 0 del primo array riferito (valore 1);
5. &data corrisponde a int *(*)[4], ossia a un puntatore a un array di 4 elementi a puntatori
a int. Esso ritorna l’indirizzo di memoria (0x0028fe90) che è in pratica quello a partire
dal quale inizia tutto l’array di puntatori a int.
Figura 7.15 Rappresentazione tabellare e in memoria con GCC della matrice irregolare data.
DETTAGLIO
In sostanza in questo caso, rispetto al puntatore data di int (*data)[4], il nome data è
l’indirizzo di memoria del primo puntatore a int allocato che contiene un puntamento verso il
primo array (quello con i valori 1 e 2). Quindi l’incremento di una unità farà spostare il
puntamento corrente al secondo puntatore a int che contiene un puntamento verso il
secondo array (quello con i valori 3, 4 e 5). Lo spostamento sarà di 4 byte alla volta perché
con il sistema corrente il compilatore userà 4 byte per allocare un tipo puntatore a int. La
Figura 7.15 evidenzia, per esempio, come &data[0] sia il primo puntatore allocato all’indirizzo
0x0028fe90 (l’elemento 0 dell’array data), che contiene come valore l’indirizzo 0x0028fea0, che è
l’area di memoria a partire da cui si troveranno gli elementi dell’array riferito (in pratica la
prima riga dell’array data).
ritornando l’indirizzo di memoria dell’elemento 0 come tipo int *, che poi sposta di c
unità di storage. Infine, dereferenzia tale indirizzo di memoria ritornando un tipo int.
Per esempio *(*(data + 1) + 1), ritornerà il valore 4 che è posto nella colonna 1 della
riga 1.
Quanto mostrato, seppur complesso, si può sintetizzare dicendo che:
per accedere a un determinato elemento della matrice, si può utilizzare la più semplice
notazione con indicizzazione propria degli array (per esempio data[1][1] accede alla
colonna 1 della riga 1);
per impiegare un parametro formale di tipo array bidimensionale irregolare, si può
dichiararlo con la consueta notazione con indicizzazione degli array (per esempio void
foo(int *data[]) { … }) oppure con la notazione che fa uso esclusivo dei puntatori (per
#define ROWS 4
#define COLS_I 2
#define COLS_II 3
#define COLS_III 4
#define COLS_IV 5
int main(void)
{
// array di 4 puntatori a int
int *data[] =
{
(int[]) {1, 2}, // 2 colonne
(int[]) {3, -4, 5}, // 3 colonne
(int[]) {6, -7, 8, 9}, // 4 colonne
(int[]) {10, 11, -12, 13, -14} // 5 colonne
};
// invocazione di search
int res = search(data, ROWS);
return (EXIT_SUCCESS);
}
// qui c++ fa spostare alla colonna successiva della riga corrente perché
// *(ptr_to_data + r) è di tipo int *
for (int c = 0; c < cols_nr; c++)
{
// sintassi alternativa puntatore/offset a quella propria degli array
int val = *(*(ptr_to_data + r) + c);
if (val < 0)
nr++;
}
}
return nr;
}
Il Listato 7.7 è simile come logica al Listato 7.6, ossia data una matrice ne deve cercare
gli elementi che contengono dei numeri negativi.
Tuttavia presenta le seguenti importanti differenze:
la matrice data è dichiarata come array di puntatori a int ed è irregolare, ossia ogni riga
ha un diverso numero di colonne;
il prototipo e la definizione della funzione search hanno un parametro dichiarato,
rispettivamente, come int *[] e int *ptr_to_data[] (come detto, sarebbe preferibile
scrivere i prototipi di funzione indicando anche gli identificatori dei tipi dei relativi
parametri; tuttavia in taluni casi, come quello appena indicato, abbiamo ritenuto
opportuno non farlo per ragioni di chiarezza);
dato che non è possibile sapere dinamicamente quante colonne ha ciascuna riga [per
esempio, un sizeof(ptr_to_data) quando ptr_to_data si riferisce alla riga 0 darebbe come
valore 4 e non 8 perché tanti sono i byte che occorrono per allocare un tipo int *],
siamo costretti a utilizzare un costrutto switch che a seconda della riga corrente
valorizza la variabile cols_nr con la costante simbolica relativa (per esempio, se r vale 0
allora cols_nr conterrà il valore di COLS_I e così via per gli altri valori di r).
I puntatori dichiarati nello Snippet 7.14 sono definiti dallo standard C11 come variably
modified types perché il loro tipo dipende da valori non costanti: dal valore della variabile
nr_of_el, nel caso di ptr_to_data, e dal valore della variabile nr_of_rows, nel caso di
ptr_to_data_m e ptr_to_data_o_m.
TERMINOLOGIA
Un variably modified (VM) type è nella sostanza un puntatore a un array a lunghezza
variabile (VLA).
Questi puntatori hanno poi, al pari dei VLA, alcune restrizioni come, per esempio:
possono essere dichiarati solo nelle funzioni (anche come parametri) o in qualsiasi blocco di
codice oppure come parametri formali nei prototipi delle funzioni; i relativi identificatori
devono essere degli identificatori ordinari (non possono essere, per esempio, identificatori
di puntatori a VLA che sono membri di struct o union).
Infine, per essi, l’aritmetica dei puntatori è ben definita, si comporta cioè come se tali
puntatori puntassero ad array non VLA.
Puntatori a puntatori
Un puntatore a puntatore, già incontrato nel corso della trattazione sugli array di
puntatori, è, in linea più generale, una variabile che contiene un indirizzo di memoria di
un’altra variabile la quale contiene, anch’essa, un indirizzo di memoria di un’altra variabile
che contiene un valore di un tipo determinato (Snippet 7.15).
// un deriferimento
// ritorna come valore l'indirizzo di memoria contenuto in ptr_to_number
int *first_der = *ptr_to_ptr_to_number;
// doppio deriferimento
// ritorna come valore il numero 100 che è contenuto in number
int value = **ptr_to_ptr_to_number;
In sostanza lo Snippet 7.15 si può leggere come segue, considerando anche, per i primi
tre punti, la Figura 7.16.
1. Dichiariamo la variabile di tipo int number contenente il valore 100 che viene allocata
all’indirizzo di memoria 0x006ffb84.
2. Dichiariamo la variabile di tipo puntatore a int ptr_to_number contenente il valore
0x006ffb84 (che è l’indirizzo di memoria di number) che viene allocata all’indirizzo di
memoria 0x006ffb78.
3. Dichiariamo la variabile di tipo puntatore a puntatore a int ptr_to_ptr_to_number
contenente il valore 0x006ffb78 (che è l’indirizzo di memoria di ptr_to_number) che viene
allocata all’indirizzo di memoria 0x006ffb6c.
4. Dichiariamo la variabile di tipo puntatore a int first_der contenente il valore 0x006ffb84
(che è l’indirizzo di memoria di number). Essa contiene tale indirizzo perché l’operatore
di deriferimento applicato sul nome ptr_to_ptr_to_number fa ritornare il valore contenuto
nell’indirizzo di memoria 0x006ffb78 che è, per l’appunto, 0x006ffb84.
5. Dichiariamo la variabile di tipo int value contenente il valore 100. Essa contiene tale
valore perché il primo operatore di deriferimento applicato sul nome
ptr_to_ptr_to_number fa ritornare il valore 0x006ffb84 (che è l’indirizzo di memoria di
Vediamo ora un pratico esempio di utilizzo di un doppio puntatore (Listato 7.7) che
consente di “simulare” un passaggio per riferimento di un argomento a un parametro di un
funzione (ricordiamo che in C gli argomenti sono passati sempre per valore), in modo che
venga modificato l’argomento stesso piuttosto che il valore da esso riferito (per effetto di
ciò il parametro della funzione può essere considerato una sorta di alias dell’argomento,
ossia un nome alternativo cui riferirlo e attraverso cui manipolarlo).
int main(void)
{
int a = 10;
int *j = &a;
return (EXIT_SUCCESS);
}
Nel Listato 7.8 la funzione main dichiara la variabile a di tipo int e poi la variabile j come
puntatore a essa. Stampa quindi l’indirizzo di memoria contenuto in j (0x0037fed8) che è
quello dove la variabile a è stata allocata. Invoca poi la funzione foo alla quale passa
l’indirizzo di memoria dove è stato allocato il puntatore j medesimo (0x0037fecc).
A questo punto la funzione foo, che prende il controllo dell’esecuzione del codice,
definisce la variabile locale statica k e poi ne assegna l’indirizzo di memoria (0x003a901c) a
ciò cui punta p, ossia al puntatore j, che d’ora in poi punterà a questa nuova variabile
piuttosto che a quella originaria a.
TERMINOLOGIA
Una variabile locale a una funzione si definisce statica quando conserva il suo valore anche
se il flusso di esecuzione del codice esce dal blocco ove è dichiarata. Per definire una
variabile come statica si deve usare lo specificatore di classe di memorizzazione espresso
tramite la keyword static. Ritorneremo in modo approfondito su questo punto nel Capitolo 9.
NOTA
Nella funzione foo si è reso necessario dichiarare la variabile k come statica perché non
viene distrutta al termine dell’esecuzione della funzione, e dunque il suo indirizzo di
memoria, riferito dal puntatore j dopo l’assegnamento a esso compiuto per mezzo
dell’istruzione *p = &k;, resta ancora valido (non è utilizzato per allocare altre variabili) così
come il valore lì contenuto.
Quando, infine, il flusso di esecuzione del codice ritorna nella funzione main, la
successiva istruzione printf stampa nuovamente il valore dell’indirizzo di memoria riferito
dal puntatore j (0x003a901c) che, questa volta, è quello non più alla variabile a, ma quello
della variabile k.
Dunque, per il tramite del parametro p della funzione foo è stato possibile cambiare
l’argomento stesso riferito grazie a un’implementazione “manuale” del meccanismo del
passaggio degli argomenti by reference non presente, di “serie”, nel linguaggio C.
La Figura 7.17 dà una panoramica visuale di quanto descritto, sia dal punto di vista dei
puntamenti sia della disposizione in memoria delle variabili utilizzate.
Figura 7.17 Modificabilità di un puntatore passato come argomento a un doppio puntatore.
Nello Snippet 7.17 il puntatore a funzione ptr_to_func è inizializzato con tre valori
differenti, ma solo il primo assegnamento è valido e dunque corretto perché il nome sub_P si
riferisce all’indirizzo di memoria di una funzione del suo stesso tipo.
Il secondo assegnamento non è valido perché il nome sqrt_P si riferisce all’indirizzo di
memoria di una funzione di un tipo diverso (double) -> double, mentre il terzo assegnamento
è ancora non valido perché il valore assegnato è di tipo int, ossia quello ritornato
dall’invocazione della funzione sub con i valori 1 e 2.
Nel secondo e terzo caso un compilatore come GCC ritornerà, nell’ordine, i messaggi di
warning assignment from incompatible pointer type e assignment makes pointer from integer
without a cast.
int main(void)
{
// puntatore a funzione di tipo (int, int) -> int
int (*ptr_to_func)(int, int);
NOTA
È possibile invocare una funzione riferita tramite un puntatore a funzione utilizzando anche
la forma ptr_to_func(100, 100), ossia senza usare le parentesi tonde ( ) e l’operatore di
deriferimento *; questo perché quando ptr_to_func è valutato ritorna l’indirizzo di memoria di
una funzione direttamente chiamabile (ne è fatto un implicito deriferimento). In ogni caso,
anche se più prolissa, la prima forma, cioè (*ptr_to_func)(100,100), rende più esplicito che
ptr_to_func è un puntatore a funzione ed è per il suo tramite che si sta invocando un’altra
funzione. Se, infatti, si utilizzasse un identificatore non significativo, per esempio func, si
potrebbe pensare che func sia “direttamente” il nome della funzione che si sta invocando.
(const void *, const void *)), il cui obiettivo computazionale è quello di ordinare gli
elementi di un array in base a un determinato criterio.
Analizzando il suo prototipo si nota subito come tale funzione sia in effetti “generica”
perché sarà il client utilizzatore che dovrà fornirgli, come ultimo argomento, un puntatore a
una funzione di comparazione che deciderà come gli elementi dell’array dovranno essere
ordinati, ossia secondo quale modalità un elemento dovrà essere considerato minore,
maggiore oppure uguale rispetto a un altro elemento.
In definitiva, la funzione qsort dice “cosa” sta eseguendo in quel momento, cioè che sta
ordinando gli elementi di un array; il “come” debbano essere ordinati, però, è espresso
tramite un’altra funzione che le viene passata come argomento.
TERMINOLOGIA
Una funzione che può accettare come suo argomento un’altra funzione e/o restituire una
funzione come risultato della sua computazione è sovente indicata con il termine di higher-
order function (funzione di ordine superiore).
// prototipi di funzione
// notare come gli identificatori dei parametri abbiano un nome diverso da quello
// dei corrispettivi identificatori indicati nella definizione di tali funzioni;
// ricordiamo che ciò non rappresenta alcun problema: sono semplicemente ignorati!
int makeOperations(int a, int b, int (*f)(int, int));
int main(void)
{
int val1, val2, op = 0;
// stampa il risultato
printf("\nLa %s tra %d e %d ha prodotto come risultato %d\n",
op_name[op], val1, val2, makeOperations(val1, val2, array_of_op[op]));
printf("********************************************************************\n\n");
return (EXIT_SUCCESS);
}
[0] addizione
[1] sottrazione
[2] moltiplicazione
[3] divisione
Primo numero: 20
Secondo numero: 30
Il Listato 7.9 mette in pratica quanto sin qui detto sulla possibilità di scrivere funzioni
generiche che si avvalgono dei puntatori a funzioni; illustra anche un altro comune pattern
di utilizzo degli stessi, ovvero quello che prevede la capacità di definire un array di
puntatori a funzioni che consente di scegliere quale funzione invocare direttamente e in
modo arbitrario tramite la comune e compatta notazione a indice propria degli array.
Nel programma, tutto ruota attorno alla funzione makeOperations che esegue una qualsiasi
operazione tra due valori numerici di tipo int espressa tramite un’apposita funzione di tipo
(int, int) -> int che le viene passata come argomento.
NOTA
Nel prototipo di makeOperations il parametro puntatore a funzione può essere scritto anche
senza indicarne il nome, come in int (*)(int, int). Invece, nella definizione di makeOperations,
il parametro puntatore a funzione può essere scritto con la stessa sintassi vista per la
dichiarazione (Sintassi 7.8), come in int (*op)(int, int).
#define NR_OF_ELEMS 10
// prototipi di funzione
void done(int res); // viene invocata solo se il risultato della somma è positivo
void fail(int code_nr); // viene invocata solo se il risultato della somma è negativo
int main(void)
{
// inizializzazione del generatore pseudo-casuale dei numeri
srand((unsigned int) time(NULL));
int elems[NR_OF_ELEMS];
return (EXIT_SUCCESS);
}
if (total >= 0)
(*done)(total); // chiamo la callback riferita dal parametro done
else
(*fail)(-1); // chiamo la callback riferita dal parametro fail
}
int main(void)
{
int value1 = 2000;
int value2 = 1000;
// eseguo prima l'addizione
printf("Addizione tra %d e %d = %d\n", value1, value2,
makeComputation(choose('+'), value1, value2));
return (EXIT_SUCCESS);
}
int main(void)
{
int i_data = 100;
double d_data = 223.2232;
int *ptr_to_int = &i_data;
double *ptr_to_double = &d_data;
ptr_to_int = &i_data;
// puntatore a void
void *ptr_to_void = ptr_to_int; // ora punta a un int
return (EXIT_SUCCESS);
}
Il Listato 7.12 definisce alcune variabili e dei puntatori a esse. Poi prova ad assegnare la
variabile ptr_to_double, che è un puntatore a un double, alla variabile ptr_to_int, che è un
puntatore a int, facendo emettere dal compilatore in uso (GCC) il messaggio warning:
assignment from incompatible pointer type.
// prototipo di swap
void g_swap(void *val_1, void *val_2, size_t size);
int main(void)
{
int a = 10;
int b = 20;
float f = 10.20f;
float g = 22.33f;
return (EXIT_SUCCESS);
}
// definizione di swap
void g_swap(void *val_1, void *val_2, size_t size)
{
void *tmp = malloc(size);
memcpy(tmp, val_1, size);
memcpy(val_1, val_2, size);
memcpy(val_2, tmp, size);
free(tmp);
}
Il Listato 7.13 definisce la funzione generica g_swap che consente di scambiare il valore di
due variabili di qualsiasi tipo. Essa è definita con due parametri formali di tipo puntatore a
void e un terzo parametro formale, di tipo size_t, atto a contenere la dimensione del tipo di
dato da elaborare e che è fondamentale per processare i valori dei tipi passati (un void * è
considerabile come un puntatore a un blocco di memoria grezzo, e non sa nulla in merito a
cosa è un tipo di dato o alla sua dimensione in byte).
Il body della funzione g_swap dichiara, come primo oggetto, la variabile tmp come un tipo
void * e le passa, grazie alla funzione malloc, l’indirizzo di memoria di partenza dello spazio
di storage allocato della dimensione fornita dal parametro size.
Ciò permetterà di allocare e processare la giusta quantità di memoria occorrente per dei
tipi int, float, double e così via, passati come argomenti alla funzione g_swap.
Successivamente, utilizza la funzione memcpy, dichiarata nel file header <string.h>, per
copiare il contenuto della memoria di una variabile sorgente (il secondo argomento)
nell’area di memoria riferita da una variabile destinazione (il primo argomento):
con la prima invocazione, memcpy copierà il contenuto della variabile riferita da val_1
nell’area di memoria riferita da tmp;
con la seconda invocazione, memcpy copierà il contenuto della variabile riferita da val_2
nell’area di memoria riferita da val_1;
con la terza invocazione di memcpy, copierà il contenuto della memoria riferita da tmp
nell’area di memoria riferita da val_2.
Infine utilizza la funzione free, dichiarata nel file header <stdlib.h>, per liberare la
memoria allocata e puntata dalla variabile tmp.
Puntatori nulli
Un puntatore nullo (null pointer) è un puntatore che contiene un valore “speciale” atto a
segnalare che esso non punta a niente, ossia non punta e non riferisce alcun indirizzo di
memoria concretamente utilizzabile di alcun oggetto (dato) o funzione (codice).
Lo standard del linguaggio C stabilisce che un’espressione costante intera con il valore 0
oppure la stessa espressione convertita in un puntatore a void (void *) rappresenta una
costante di tipo puntatore nullo (null pointer constant), la quale genera un puntatore nullo
quando è utilizzata durante un’istruzione di inizializzazione, assegnamento o comparazione
con una variabile di tipo puntatore.
Tale costante di tipo puntatore nullo è espressa attraverso la macro NULL, che è definita, in
linea generale e dalla maggior parte dei compilatori, come #define NULL ((void *)0) in molti
file header come <stdio.h>, <stdlib.h>, <stddef.h> e così via.
ATTENZIONE
Un puntatore nullo è differente da un puntatore non inizializzato. Il primo, infatti, non punta
né a un oggetto né a una funzione; il secondo, invece, punta a qualsiasi cosa.
NOTA
Ogni compilatore è libero di scegliere la propria rappresentazione di un puntatore nullo, e
dunque questo non necessariamente dovrà avere come riferimento un indirizzo di memoria
tipo 0x00000000 ma potrà anche contenere un indirizzo di memoria non esistente. Pertanto,
dal punto di vista di un programmatore, è sufficiente utilizzare NULL o 0 per generare un
puntatore nullo e avere la certezza che esso non punterà a niente di validamente
utilizzabile.
Sintassi 7.9 Puntatore a costante – qualificatore const prima del tipo di dato.
const data_type *ptr_identifier;
// array costante
const int ro_data[] = {-1, -2, -3};
// array costante
const int ro_data[] = {-1, -2, -3};
int other = 2;
ptr_1 = &other; // error: assignment of read-only variable 'ptr_1'
ptr_2 = &other; // error: assignment of read-only variable 'ptr_2'
Sintassi 7.11 Puntatore costante a costante – qualificatore const prima del tipo e prima
dell’identificatore.
const data_type *const ptr_identifier;
// array costante
const int ro_data[] = {-1, -2, -3};
int other = 2;
ptr_1 = &other; // error: assignment of read-only variable 'ptr_1'
ptr_2 = &other; // error: assignment of read-only variable 'ptr_2'
CONSIGLIO
Per leggere e comprendere correttamente le dichiarazioni di puntatori con o senza l’uso del
qualificatore const si può procedere nel seguente modo, partendo dall’identificatore e poi
procedendo con tutti gli altri elementi posti alla sua sinistra; per esempio, int *p; dichiara p
come un puntatore a un int (p as pointer to int); const int *p; dichiara p come un puntatore a
una costante di tipo int (p as pointer to const int); int *const p; dichiara p come un puntatore
costante a un int (p as const pointer to int); const int *const p; dichiara p come un puntatore
costante a una costante di tipo int (p as const pointer to const int).
La keyword const viene sovente impiegata con i parametri di una funzione di tipo
puntatore per specificare che gli stessi non potranno modificare i relativi argomenti.
È molto comune, per esempio, dichiarare una funzione che deve elaborare gli elementi di
un array passato come argomento con un parametro che è un puntatore a costante del tipo
degli elementi dell’array. Questa tecnica, di fatto, permette il raggiungimento di due scopi
contemporaneamente: il primo è legato all’efficienza, perché nel parametro non sono copiati
tutti gli elementi dell’array ma solo l’indirizzo di memoria del suo primo elemento; il
secondo, invece, è legato alla sicurezza, perché gli elementi dell’array passato come
argomento non potranno subire modifiche inattese per il tramite del parametro puntatore.
Snippet 7.23 Caso d’uso di un puntatore a costante di tipo int come parametro di una funzione.
...
int sumArray(const int *elems, int size)
{
int sum = 0;
for (int i = 0; i < size; i++)
{
// se un elemento è negativo lo voglio rendere positivo...
if (*elems < 0)
*elems = -(*elems); // error: assignment of read-only location '*elems'
sum += elems[i]; // ok elems è usato solo in lettura
}
return sum;
}
int main(void)
{
int data[] = {-1, -2, -3, -4, -5, -6, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
sumArray(data, sizeof data / sizeof (int));
...
}
Lo Snippet 7.23 definisce la funzione sumArray, dove esplicita che il parametro elems è un
puntatore a costante di tipo int, ossia per il tramite di esso non sarà possibile modificare un
qualsiasi oggetto riferito che, per il nostro obiettivo didattico, sono tutti gli elementi
dell’array data passato come argomento e processati nel relativo ciclo for.
Questa limitazione ha permesso di evitare che nel body di sumArray potessimo modificare
il valore di un elemento dell’array data, quando negativo, in positivo; in pratica abbiamo sia
protetto da modifiche l’array originario sia ottenuto un’adeguata performance, perché nel
puntatore elems, quando sumArray è stata invocata, sono stati copiati solo 4 byte (nel sistema
in uso a 32 bit), ossia la dimensione dello spazio di storage richiesto per memorizzare un
puntatore che nel nostro caso è rappresentato dall’indirizzo di memoria del primo elemento
dell’array data.
La keyword restrict e i puntatori
A partire dallo standard C99, è stato introdotto un nuovo qualificatore di tipo espresso
tramite la keyword restrict che è applicabile solo a un puntatore il quale diventa, in
conseguenza della sua applicazione, un puntatore ristretto (restricted pointer).
La Sintassi 7.12 illustra che la keyword restrict deve essere posta prima
dell’identificatore del puntatore; essa, infatti, qualifica il puntatore come ristretto e non
l’oggetto cui punta. Prima di spiegare cos’è un puntatore ristretto, è opportuno illustrare
alcuni concetti preliminari che aiuteranno a comprendere il perché della sua introduzione.
Aliasing: situazione per cui due o più oggetti si riferiscono alla stessa locazione di
memoria che può dunque essere manipolata, in modo equivalente, per il tramite dei
predetti oggetti (Snippet 7.24). In pratica l’aliasing consente di riferire uno stesso
oggetto mediante l’impiego di più nomi.
La possibilità nel linguaggio C di creare alias può però portare sia a sottili errori di
programmazione difficili da scoprire se tali alias non sono stati pianificati correttamente
(per esempio, in una funzione si modifica il valore di un oggetto puntato da un parametro di
tipo puntatore ma tale modifica non doveva però accadere) sia a possibili restrizioni cui
devono sottostare i compilatori che non possono compiere eventuali ottimizzazioni sul
codice eseguibile prodotto (per esempio, se un indirizzo di memoria è riferito da più oggetti,
il compilatore dovrà necessariamente, per ogni oggetto, compiere opportune operazioni di
lettura e/o scrittura del relativo valore perché ciascun oggetto potrà avere compiuto con tale
indirizzo delle manipolazioni; in caso contrario, però, se un solo oggetto riferisce un
indirizzo di memoria un compilatore potrebbe memorizzarne il valore nelle veloci unità di
memoria quali sono i registri). Dal punto di vista di un compilatore, pertanto, non viene
fatta alcuna assunzione che l’aliasing non avvenga e pertanto non compie, nell’eventualità,
alcuna ottimizzazione.
Strict aliasing: regola per cui un compilatore assume che due o più puntatori, di tipo
differente non si riferiscono mai alla stessa locazione di memoria. In questo caso,
quindi, un compilatore è in grado di compiere certe ottimizzazioni per rendere il codice
eseguibile più veloce ed efficiente. Questo, comunque, se da una parte può migliorare
l’efficienza del codice generato, può portare a comportamenti non definiti se il
programmatore vìola la regola dello strict aliasing (GCC, per esempio, genera solo il
messaggio warning: dereferencing type-punned pointer will break strict-aliasing rules,
se si accorge della sua violazione).
TERMINOLOGIA
Per type punning si intende una tecnica mediante la quale è possibile “aggirare” il type
system di un linguaggio di programmazione al fine di far manipolare il valore di un tipo come
valore di un altro tipo. Ciò può avvenire, per esempio, quando si fa il cast in un puntatore a
int (destinazione) da un puntatore a float (sorgente) e se ne manipola il valore tramite il
puntatore di destinazione.
NOTA
In GCC si possono usare durante la fase di compilazione i seguenti flag: -fstrict-aliasing
per consentire al compilatore di assumere che venga rispettata la regola dello strict aliasing;
-Wstrict-aliasing=2 per far attivare dei warning se il codice di un programma vìola la regola
dello strict aliasing che il compilatore usa per compiere delle ottimizzazioni; -O3 per far
attivare tutte le ottimizzazioni possibili. Ciò detto è possibile mandare in esecuzione del
codice sorgente contenente lo Snippet 7.25 con, per esempio, il seguente comando: gcc -
std=c11 -fstrict-aliasing -Wstrict-aliasing=2 -O3 7.25.c -o 7.25.
Come abbiamo detto, un compilatore assumerà sempre che l’aliasing tra oggetti sia
potenzialmente effettuabile e pertanto non provvederà mai a compiere alcuna
ottimizzazione sul codice al fine di renderlo più efficiente.
Ecco allora che entra in gioco l’utilità della keyword restrict: essa darà l’indicazione al
compilatore che il programmatore “si impegnerà” a non far mai puntare la stessa area di
memoria a due o più puntatori dello stesso tipo e, pertanto, potrà tentare di eseguire
qualsiasi ottimizzazione sul codice che riterrà opportuno.
NOTA
Un compilatore può anche ignorare la keyword restrict e non compiere alcuna
ottimizzazione.
TERMINOLOGIA
Lo standard di C11 fornisce le seguenti indicazioni terminologiche, laddove per behavior
intende quel comportamento che un programma può intraprendere a seguito dell’utilizzo dei
costrutti del linguaggio C e per implementation intende l’ambiente software (compilatore,
linker, sistema di run-time e così via) utilizzato per una particolare piattaforma hardware.
Undefined behavior (comportamento non definito): indica il comportamento di un
programma a seguito di codice non portabile o erroneo per cui lo standard non impone
alcun requisito in particolare. Ciò significa che un programma: può ignorare la situazione
producendo però risultati non prevedibili; può segnalare il problema durante la compilazione
o esecuzione; può terminare bruscamente e così via. In pratica, poiché qualsiasi cosa può
accadere, è buona norma evitare sempre di scrivere codice che può produrre
comportamenti non definiti.
Unspecified behavior (comportamento non specificato): indica che un programma può
“scegliere” di comportarsi sulla base di un set di comportamenti definiti dallo standard. Per
esempio, un comportamento non specificato si ha sull’ordine di valutazione degli argomenti
di una funzione laddove, data una funzione invocata come foo(a, b), potrebbe essere
valutato prima l’argomento b e poi l’argomento a o viceversa. Resta inteso che se gli
argomenti di una funzione producono side-effect, e pertanto l’ordine di valutazione è
importante, allora il codice eseguibile può produrre bug non facilmente individuabili. Anche
in questo caso, quindi, è bene evitare di scrivere codice che può produrre comportamenti
non specificati.
Implementation-defined behavior (comportamento definito dall’implementazione): indica che
un programma può intraprendere uno dei comportamenti non specificati ma
l’implementazione ne documenta la scelta. In pratica, il comportamento di un programma è
dipendente dalla corrente implementazione e può, dunque, variare da implementazione a
implementazione. Se si devono scrivere programmi portabili è consigliabile evitare di
scrivere codice dipendente da una specifica implementazione.
double foo(void)
{
return 1.0;
}
int main(void)
{
int a = 100;
int b = 200;
// puntatore a un tipo T
int *ptr_to_a = &a;
NOTA
Se un puntatore a un tipo T è convertito in un puntatore a void e poi il puntatore a void è
convertito nuovamente nel puntatore al tipo T, dovrà essere garantito che non ci sarà alcuna
perdita di informazione (in pratica il puntatore risultante dalla riconversione del puntatore a
void nel puntatore al tipo T dovrà essere uguale al puntatore originario).
double foo(void)
{
return 1.0;
}
int main(void)
{
int a = 100;
int b = 200;
// puntatore a un tipo T
int *ptr_to_a = &a;
// puntatore a un tipo T
int *ptr_to_a = &a;
// puntatore a un tipo W
float *ptr_to_c = &c;
double foo(void)
{
return 1.0;
}
int main(void)
{
// puntatore a una funzione di tipo T
int(*ptr_to_f)(int, int) = sum;
Tutti i listati e gli snippet di codice che abbiamo sin qui scritto per illustrare nella pratica
il significato dei fondamentali costrutti del linguaggio C hanno fatto uso, per la
rappresentazione e la memorizzazione delle relative informazioni, di due tipi di oggetti
principali: le variabili scalari (tipi di dato elementari o semplici), ideali per gestire e
manipolare un solo valore alla volta (per esempio, una variabile come int number è utile per
rappresentare un qualsiasi numero intero), e gli array (tipi di dato aggregati o complessi),
ideali per raggruppare in modo logico valori correlati e dello stesso tipo (per esempio, un
array come int months[12] è utile per rappresentare i dodici mesi di un anno).
Tuttavia, quando si scrivono dei programmi, si possono avere delle esigenze ulteriori per
la rappresentazione o per la strutturazione delle informazioni che i tipi citati non sono in
grado di coprire; si pensi, per esempio, alla necessità di memorizzare un insieme di dati che
rappresentano un’ipotetica agenda telefonica che deve contenere informazioni quali un
identificativo di un soggetto (nome e cognome), un numero di telefono (prefisso e numero),
un indirizzo civico (via e numero), un indirizzo e-mail e così via per le altre.
In questo caso appare evidente che le informazioni sono sì correlate, ma i tipi di dati
occorrenti sono di differente tipo (il nome e il cognome potrebbero essere di tipo array di
caratteri, mentre il numero civico potrebbe essere di tipo int); pertanto, il tipo array non può
essere utilizzato per la strutturazione di questa informazione complessa.
Ancora, si pensi alla necessità di avere una struttura di dati capace di memorizzare
informazioni di tipo diverso in modo “dinamico”, ossia in un determinato momento un suo
elemento può essere “solo” di un tipo (per esempio, o int o float o double e così via).
TERMINOLOGIA
Il termine struttura di dati indica il modo in cui i dati sono conservati e organizzati nella
memoria e le operazioni che si possono compiere su di essi (algoritmi). È pertanto
generalizzabile nella seguente forma: STRUTTURA DI DATI = INSIEME DI ELEMENTI +
OPERAZIONI.
Così potremmo definire una sorta di array contenente elementi di tipo diverso, dove cioè
ciascun suo elemento è del tipo della struttura succitata che usa, però, un indirizzo di
memoria condiviso che, cioè, di volta in volta, può contenere un valore di un tipo di dato
differente.
Si pensi, infine, all’esigenza di migliorare la leggibilità di un programma quando fa uso
di valori interi che esprimono un determinato significato; per esempio, potremmo avere
l’array months inizializzato con i valori 1 (per gennaio), 2 (per febbraio), 3 (per marzo) e così
via fino a 12 (per dicembre), e utilizzarlo per assegnare a una variabile di tipo int
current_month il mese corrente con un’istruzione come current_month = months[2].
In questo caso, però, è evidente come si debba, in qualche modo, documentare che
months[2] rappresenti il mese di febbraio perché esso non è implicitamente chiaro.
Se, invece, abbiamo la possibilità di creare un nuovo tipo di dato i cui valori sono
espressi trami dei nomi significativi che possono essere “assegnati” direttamente a delle
variabili, ecco che la leggibilità del programma migliora significativamente.
Ritornando, quindi, al problema di rappresentare i mesi dell’anno potremmo creare un
tipo months inizializzato con i valori JANUARY, FEBRUARY, MARCH e così via fino a DECEMBER, e poi
assegnare alla variabile current_month il valore del mese corrente con un’istruzione come
current_month = FEBRUARY.
NOTA
Si poterebbe rilevare come lo stesso risultato di dotare il codice di maggiore leggibilità sia
raggiungibile anche con l’uso delle macro proprie della direttiva #define. Tuttavia ciò
presenta diversi svantaggi quali, solo per citarne due: il preprocessore sostituisce nel codice
sorgente qualsiasi riferimento al nome simbolico con il valore relativo, e dunque tale nome
non è disponibile in caso di debugging del programma; dobbiamo scrivere tante direttive
#define quanti sono i nomi che dobbiamo impiegare, e ciò, oltre che essere scomodo, non
evidenzia neppure che tra di esse vi sia una sorta di collegamento, ossia che siano parte di
uno stesso tipo di dato.
Una struttura si dichiara indicando la keyword struct; un tag o etichetta facoltativa utile
per dare un identificativo alla relativa struttura; una coppia di parentesi graffe { } al cui
interno esplicitare le dichiarazioni di altri tipi di oggetti e che ne rappresentano i membri;
uno o più identificatori opzionalmente indicati dopo la parentesi graffa di chiusura (}) della
struttura che rappresentano i nomi degli oggetti del tipo di struttura definita.
int main(void)
{
// dichiara una struttura di tipo struct book contenente
// come membri gli oggetti lì dichiarati
struct book
{
char title[MAX_A_LENGTH];
char author_name[MAX_A_LENGTH];
char publisher_name[MAX_A_LENGTH];
int publication_year;
float price;
int stock;
};
TERMINOLOGIA
Quando si dichiara una struttura è prassi dire che si crea un tipo struttura (structure type),
mentre quando si dichiara una variabile del suo tipo è abitudine dire che si crea una
variabile di struttura o variabile di tipo struttura (structure variable).
struct // STRUTTURA 2
{
int red;
int green;
int blue;
} RGB_2;
// nessun conflitto con i nomi dei membri delle strutture sopra indicate
// e anche con il tag color dell'ultima struttura
int red, green, blue, color;
Sintassi 8.2 Inizializzazione di una variabile di struttura che non ha un tipo espresso con un tag.
struct
{
data_type identifier_1;
data_type identifier_2;
...
data_type identifier_N;
} identifier_1 = {value_1, value_2, ..., value_N};
In sostanza, le regole per l’inizializzazione dei membri di una struttura sono le seguenti:
ogni inizializzatore assegna un valore al membro corrispondente e nell’ordine dato (per
esempio, value_1 sarà il valore per identifier_1, value_2 sarà il valore per identifier_2 e così
via per gli altri; i valori possono essere forniti anche da espressioni non costanti, magari
ricavati da valutazioni di variabili, ma ciò è consentito solo se la struttura ha una classe di
memorizzazione diversa da quella statica, come quella automatica); è possibile fornire
meno inizializzatori rispetto ai membri di una struttura e, in questo caso, i membri non
esplicitamente inizializzati conterranno il valore 0 (se un membro è un puntatore, sarà
inizializzato con un null pointer).
int main(void)
{
struct employee // dichiarazione di una struttura di tipo struct employee
{
char first_name[MAX_A_LENGTH];
char last_name[MAX_A_LENGTH];
int identifier_code;
char job_title[MAX_A_LENGTH];
};
Una struttura può essere inizializzata anche mediante l’utilizzo degli inizializzatori
designati che però, a differenza di quelli visti per gli array hanno una diversa sintassi
(Sintassi 8.5).
Tra la coppia di parentesi graffe { } devono essere impiegati dei designatori costituiti dal
simbolo punto . (dot operator) e il nome del membro da valorizzare. Segue, quindi, per
ogni designatore l’operatore di assegnamento e il relativo valore.
Anche nel caso degli inizializzatori designati si possono applicare le seguenti regole di
utilizzo: l’ordine di collocazione dei designatori può non corrispondere a quello dei membri
da valorizzare perché l’operatore punto consente di accedere direttamente al membro
indicato dopo di esso; è possibile valorizzare i membri di una struttura sia mediante dei
designatori sia mediante dei valori senza designatori (in quest’ultimo caso il valore senza
designatore valorizzerà il membro posto subito dopo il membro indicato dal designatore che
lo precede).
Snippet 8.5 Utilizzo degli inizializzatori designati e di inizializzatori regolari per la struct color.
struct color // dichiarazione della struttura struct color
{
int red;
int green;
int blue;
};
#define MAX_A_LENGTH 50
int main(void)
{
struct employee // dichiarazione di una struttura di tipo struct employee
{
char first_name[MAX_A_LENGTH];
char last_name[MAX_A_LENGTH];
int identifier_code;
char job_title[MAX_A_LENGTH];
} PP; // PP è una variabile di struttura di tipo struct employee
// copia permessa: fuchsia e silver sono dello stesso tipo ossia struct color
silver = fuchsia;
// error: incompatible types when assigning to type 'struct RGB_color' from type 'struct
// HSB_color'
silver = fuchsia_2;
// alcune strutture senza tag; anche se hanno gli stessi membri sono strutture
// di "differente" tipo, ossia di tipi senza nome
struct
{
int j[2];
int k;
} a = {
{2, 2}, // primo membro: un array, necessaria un'altra lista di inizializzatori
1000 // secondo membro
},
b = {{4, 4}, 2000}; // scrittura compatta ma equivalente
struct
{
int j[2];
int k;
} c = {{8, 8}, 3000};
// in questa dichiarazione uso typedef per creare un alias del tipo struct point
typedef struct point point;
Lo Snippet 8.7 dichiara: una struttura di tipo struct color nel consueto modo, ossia
mediante il tag color posto subito dopo la keyword struct; una struttura senza un nome di
tipo cui viene associato, però, il nome di tipo color mediante la keyword typedef; una
struttura di tipo struct rect per la quale viene anche definito l’alias di nome di tipo rect
mediante la keyword typedef (il tag è comunque superfluo); una struttura di tipo struct point
di cui, successivamente, in un’istruzione separata, viene fatto un alias del tipo mediante la
keyword typedef.
NOTA
Leggendo le dichiarazioni di alcune delle strutture presentate è stato usato per esse, senza
problemi di conflitto, lo stesso identificatore per il tag (per esempio rect) e per la variabile di
struttura (per esempio rect). Ribadiamo che ciò è perfettamente lecito perché una struttura
ha un proprio spazio dei nomi disgiunto dallo spazio dei nomi delle variabili ordinarie;
pertanto, ritornando alla nostra struttura che definisce un template per un rettangolo, anche
se l’identificatore rect si trova dichiarato due volte nello stesso scope (quello della funzione
main), esso è di fatto “inserito” nei due spazi di nomi differenti detti.
Di istinto, la risposta su un sistema dove gli int sono di 32 bit potrebbe essere 5 byte, cioè
1 byte per allocare il tipo char c_data e 4 byte per allocare il tipo int data.
Purtroppo, invece, la risposta è diversa; sono infatti allocati in totale 8 byte: 1 byte per il
tipo char, 3 byte di memoria “non usati” detti holes (buchi) e 4 byte per il tipo int.
Cosa rappresentano, dunque, quei 3 byte di memoria non usati? Per poterlo comprendere
appieno dobbiamo, in via preliminare, fare una breve digressione sul concetto di
allineamento della memoria.
Iniziamo ricordando che ogni dato è allocato in memoria a partire da un determinato
indirizzo e per una certa quantità di storage che è uguale alla dimensione del tipo di dato
scelto (per esempio, il numero 100 potrebbe essere memorizzato come tipo int, memorizzato
a partire dalla locazione 0x003efa64, e occupare in totale 4 byte, su un sistema a 32 bit, fino,
quindi, alla locazione 0x003efa67).
Chiaramente non tutti i dati richiedono lo stessa quantità di memoria di allocazione; un
dato di tipo char, infatti, richiede 1 byte, un dato di tipo short può richiedere 2 byte, un dato
di tipo double può richiedere 8 byte e così via.
Se, quindi, abbiamo una serie di dati allocati senza alcuna considerazione di un possibile
allineamento e tali dati sono di tipo char, di tipo short e di tipo int, la memoria potrebbe
apparire non allineata (Figura 8.1), data un’architettura dove una word è di 32 bit (4 byte).
Figura 8.1 Memorizzazione di dati non allineati.
word che inizia all’indirizzo 0x0000000 e l’altro per prendere i rimanenti byte posti alla
successiva word che inizia all’indirizzo 0x00000004) sia una qualche operazione di bitshifting
per ottenere il dato completo.
Per sopperire a questo importante problema e per garantire un’efficienza di accesso ai
dati in memoria, i compilatori moderni, in caso di dati non allineati, tendono ad aggiungere
delle unità di memoria inutilizzate (buchi) che hanno come scopo quello di riempire
(padding) la memoria al fine di allinearla correttamente.
Così, allineata con dei buchi, la nostra memoria potrebbe apparire come quella presentata
nella Figura 8.2, dove appare ora evidente come, se si volesse accedere al dato di tipo int,
sarebbe richiesto un solo accesso in memoria, cioè a partire dall’indirizzo della word
0x00000004 che contiene tutti i 4 byte del dato in esame.
Figura 8.2 Memorizzazione di dati allineati.
Ciò detto, appare chiaro perché il compilatore abbia posto 3 byte in più di memoria
inutilizzata alla nostra struttura, che potrebbe essere disposta in memoria come mostrato
dalla Figura 8.3, dove risulta evidente il suo corretto allineamento e dove si nota anche
come i membri della stessa siano stati posti, sequenzialmente nello stesso ordine in cui sono
stati scritti all’interno della dichiarazione di S medesima.
NOTA
Lo standard di C stabilisce che solo gli elementi di un array e i membri di una struttura
debbano essere posti in memoria in locazioni di memoria sequenziali, in modo crescente e
secondo l’ordine di dichiarazione (nel caso delle strutture, però, non necessariamente
devono occupare locazioni di memoria contigue perché potrebbero esserci dei buchi a
causa di problemi di allineamento). Ciò implica che se dichiariamo, in modo disgiunto, tre
diverse variabili, queste possono essere poste in memoria, per esempio, in locazioni di
memoria consecutive, in modo crescente o decrescente, oppure in locazioni di memoria non
consecutive. In pratica, è a discrezione dell’implementazione come e dove allocarle.
Facciamo un ulteriore passo in avanti dicendo, in modo generalizzato, che: data una
struttura un compilatore tenderà sempre ad allinearla rispetto alle necessità di allineamento
del tipo più grande e inserirà, se occorrenti, anche dei buchi supplementari di memoria
garbage per allineare la struttura stessa.
Così se abbiamo la seguente struttura (Snippet 8.9 e Figura 8.4), l’operatore sizeof
ritornerà un valore di dimensionamento di 24 byte e non di 10 byte, che è la somma della
dimensione dei due tipi char e del tipo double, e ciò perché la struttura è stata allineata per
multipli di 8 byte laddove tale valore è la dimensione del tipo double che è, infatti, come
dimensione, il tipo più grande.
Snippet 8.9 Dimensione di una struttura con il tipo maggiore di tipo double.
struct S
{
char c_1;
double d;
char c_2;
} s = {'A', 44.44, 'Z'};
Il fatto che la struttura s di tipo struct S sia stata allineata per multipli di 8 byte garantisce
anche che, se si definisce un array di tipi struct S, il membro d di tipo double si verrà sempre
a trovare in locazioni di memoria che partiranno a un indirizzo multiplo di 8 byte.
Listato 8.2 StructAlignment.c (StructAlignment).
/* StructAlignment.c :: Allineamento delle strutture :: */
#include <stdio.h>
#include <stdlib.h>
#define S_LENGTH 2
int main(void)
{
struct S
{
char c_1;
double d;
char c_2;
};
return (EXIT_SUCCESS);
}
Da quanto sinora detto appare evidente che una struttura con membri di diverso tipo può
portare a consumare molta quantità di memoria necessaria per allineare correttamente i tipi
e la struttura stessa (si immagini cosa possa significare in termini di spreco di memoria in
programmi complessi con centinaia o migliaia di strutture).
Al fine di minimizzare la quantità di memoria utilizzata in caso di strutture che
necessitano di un allineamento, è possibile porre in atto una tecnica di “riordino” dei relativi
membri (structure packing) collocando gli stessi in ordine decrescente di spazio di storage
richiesto dai corrispettivi tipi.
Ritornando alla struttura dichiarata nello Snippet 8.9 e nel Listato 8.2, che pesava in
memoria 24 byte, potremmo dichiararla come segue (Snippet 8.10 e Figura 8.5), ossia
ponendo come primo membro il tipo double e a seguire i tipi char, e avere un recupero di 8
byte di spazio (la struttura pesa ora 16 byte con un recupero di circa il 33% di spazio).
Figura 8.5 Allineamento di una struttura di tipo struct S con i membri ordinati.
In pratica, il compilatore ha posto dei buchi solamente per allineare la struttura stessa
(trailing padding) a un multiplo di 8 byte proprio del tipo più grande, ossia il tipo double.
NOTA
Lo standard di C specifica che i buchi possono essere posti solo tra i membri di una
struttura oppure dopo il suo ultimo membro. Ciò implica che non vi sarà mai un leading
padding, e l’indirizzo della struttura coinciderà sempre con l’indirizzo del suo primo membro.
Snippet 8.11 Utilizzo di diversi letterali composti applicati a vari tipi di strutture.
struct color // una struttura di tipo struct color
{
int red;
int green;
int blue;
};
// letterali strutture
struct color a_color = (struct color){255, 0, 0}; // via struct tag
point a_point = (point){300, 10}; // via typedef
Strutture annidate
Una struttura può contenere come suoi membri non solo variabili dei consueti tipi come
int, char, double e altri, ma anche, come già visto, tipi di array e tipi di struttura.
#define MAX_A_LENGTH 50
int main(void)
{
struct names // una struttura di tipo struct names
{
char first_name[MAX_A_LENGTH];
char last_name[MAX_A_LENGTH];
};
struct employee PP =
{
{"Pellegrino", "Principe"}, // inizializza la struttura annidata struct names
45456,
"Sviluppatore Software"
};
return (EXIT_SUCCESS);
}
Il Listato 8.3 dichiara la struttura di tipo struct names deputata a contenere informazioni
sul nome e cognome di una generica persona e la struttura di tipo struct employee atta a
contenere informazioni su un qualsiasi impiegato.
Quest’ultima struttura contiene come membro una struttura annidata, ossia e_names, che è
una variabile di struttura di tipo struct names, la quale conterrà le informazioni sul nome e
sul cognome del corrente oggetto impiegato definito per questo.
Infatti, l’istruzione successiva, definisce la variabile PP di tipo struct employee e la
inizializza fornendo, dapprima, una lista di inizializzatori separata per valorizzare i membri
first_name e last_name della struttura annidata di tipo struct names, e poi altri due valori che
Infine, l’istruzione printf stampa i valori di PP di tipo struct employee evidenziando anche
che per stampare i valori della sua struttura annidata di tipo struct names è necessario
utilizzare, sull’identificatore PP, due volte in successione l’operatore punto di selezione;
difatti, la valutazione di PP.e_names ritorna come valore il riferimento alla relativa struttura di
tipo struct names sulla quale è poi applicato nuovamente l’operatore punto per selezionare i
membri first_name e last_name.
Strutture e array
Una struttura può contenere come suo membro un tipo array di qualsiasi dimensione
desiderata, così come un array può contenere come suo elemento una struttura di un
qualsiasi tipo (è definito come array di strutture).
La dichiarazione di un array capace a contenere elementi di un certo tipo di struttura è
fatta allo stesso modo di quella utilizzata per dichiarare un array di un qualsiasi tipo
primitivo: per esempio, se int data[10]; dichiara un array di 10 elementi di tipo int, struct
employee ems[10]; dichiara un array di 10 elementi di tipo struct employee.
int main(void)
{
struct color // una struttura di tipo struct color
{
int red;
int green;
int blue;
};
Lo Snippet 8.12 dichiara l’array colors dove ogni elemento sarà di tipo struct color.
Contestualmente inizializza tutti i suoi cinque elementi; di questi, notiamo come il terzo
elemento è inizializzato tramite l’utilizzo di un apposito inizializzatore designato, mentre il
quarto elemento è inizializzato utilizzando anche qui gli inizializzatori designati, che sono
però applicati per valorizzare i membri della relativa struttura.
In ogni caso, in linea generale, un array di strutture si inizializza scrivendo dopo il
simbolo di uguale = una coppia di parentesi graffe { } esterne e poi, per ogni struttura suo
elemento, un’altra coppia di parentesi graffe { } interne al cui interno si indicano i valori di
inizializzazione dei relativi membri.
NOTA
È opportuno precisare che l’utilizzo degli inizializzatori designati, sia per accedere agli
elementi di un array sia per accedere ai membri di una struttura, è facoltativo. Nel nostro
caso il loro impiego ha voluto solo dimostrare come sia possibile inizializzare gli elementi di
un array oppure i membri di una struttura in modo intercambiabile e frammisto ai normali
inizializzatori.
Strutture e puntatori
Una struttura è allocata a un indirizzo di memoria a partire dal quale sono disposti
consecutivamente i suoi membri e tale indirizzo può lecitamente essere assegnato a una
variabile che è un puntatore a quel tipo di struttura (è un puntatore a struttura).
int main(void)
{
struct color // una struttura di tipo struct color
{
int red;
int green;
int blue;
};
return (EXIT_SUCCESS);
}
Il Listato 8.4 dichiara la struttura color, ne crea la variabile di struttura red e poi ne passa
l’indirizzo di memoria, tramite il consueto operatore di indirizzamento &, al puntatore
ptr_to_red dello stesso tipo (rispetto al nome di un array, il nome di una variabile di struttura
non è valutato direttamente come l’indirizzo del suo primo membro, e pertanto bisogna
sempre utilizzare l’operatore & sul suo nome).
In definitiva un puntatore a una struttura si dichiara scrivendo la keyword struct e il suo
tag, il simbolo asterisco * e poi il solito identificatore; nel nostro caso, dunque, ptr_to_red è
un puntatore a una struttura di tipo struct color.
Utilizza, quindi, due istruzioni printf che stampano l’indirizzo di memoria di red e di
ptr_to_red (quello che contiene) evidenziando come la valutazione di un’espressione come
&red == ptr_to_red sia sempre vera; infatti, ptr_to_red conterrà come valore memorizzato lo
stesso indirizzo di memoria dove red sarà stato allocato.
Dopo di ciò impiega un’istruzione printf per stampare i valori dei membri red, green e blue
della struttura puntata dalla variabile puntatore ptr_to_red.
In questo caso viene però utilizzato un nuovo operatore, costituito dalla combinazione in
successione del carattere trattino (-) e maggiore di (>), che agisce anch’esso, al pari
dell’operatore punto (.), come operatore di selezione dei membri.
TERMINOLOGIA
L’operatore -> è spesso indicato con il termine arrow operator o right arrow selection.
Lo Snippet 8.14, invece, mostra cosa accade quando si utilizza l’aritmetica dei puntatori
con dei puntatori a strutture (Figura 8.6).
int main(void)
{
struct point // una struttura di tipo struct point
{
int x;
int y;
};
Ma quale sarà il valore di queste unità di memoria? Dato che la dimensione della struttura
point è di 8 byte (pari alla somma dei suoi membri di tipo int senza aggiunta di alcun valore
di padding in quanto i dati sono già allineati in memoria), ogni incremento effettuato sul
puntatore ptr_to_point lo farà spostare di 8 byte e pertanto le 4 unità di memoria addizionate
(MAX_POINT - 1) saranno, in effetti, pari a 32 byte di scostamento reale dall’indirizzo di
partenza di ptr_to_point, che lo faranno dunque puntare all’indirizzo dell’ultima struttura di
tipo struct point elemento dell’array points.
// prototipi di funzione
struct point buildAPoint(int x, int y);
struct point sumOfPoints(struct point p1, struct point p2);
void randomPoint(struct point *ptr_to_point);
int main(void)
{
// inizializzazione del generatore pseudo-casuale dei numeri
srand((unsigned int) time(NULL));
// calcola la somma di due punti e li ritorna in una struttura di tipo struct point
struct point sumOfPoints(struct point p1, struct point p2)
{
return (struct point){.x = p1.x + p2.x, .y = p1.y + p2.y};
}
dichiarare dei parametri formali di tipo struct point; dichiarare un parametro formale di tipo
puntatore a una struttura di tipo struct point.
NOTA
È possibile far ritornare a una funzione un puntatore a una struttura di un certo tipo usando
la stessa sintassi vista per dichiarazione di un parametro di tipo puntatore a struttura. Per
esempio, un prototipo di funzione come struct node *addToListOfPoints(struct node *, struct
point); ritornerà un puntatore a una struttura di tipo struct node.
Snippet 8.15 Allocazione di memoria per una struttura con un membro array flessibile.
int nr = 4;
struct IntegerData // una struttura di tipo struct IntegerData
{
int length;
int min;
int max;
float average;
int data[]; // array flessibile
};
STRUCT HACK
Prima che C99 introducesse i membri array flessibili, era comune aggirare la limitazione di
non poter avere un membro di tipo array con dimensione variabile nell’ambito di una struttura
tramite una tecnica conosciuta con il nome di struct hack. In sostanza si dichiarava come
ultimo membro di una struttura un array con un solo elemento (dummy element), e poi si
utilizzava la funzione malloc per allocare la quantità di memoria effettivamente occorrente.
Tuttavia, questo approccio presentava (e presenta) un inconveniente non indifferente; un
compilatore può infatti generare del codice che non ritorna il valore atteso quando si prova ad
accedere al secondo elemento dell’array. Quanto detto può accadere perché, in accordo con
quanto stabilito dallo standard di C, si può avere un comportamento non definito se si prova
ad accedere a un elemento dell’array che è posto oltre il suo limite massimo.
// attenzione sto scrivendo e leggendo fuori dai limiti massimi dell'array data
// undefined behavior?
an_int->data[1] = 200;
int value = an_int->data[1];
Presentiamo ora un sorgente (Listato 8.6) che mostra un uso effettivo della struttura
IntegerData e come i suoi membri possono essere valorizzati.
int main(void)
{
// inizializzazione del generatore pseudo-casuale dei numeri
srand((unsigned int) time(NULL));
int nr = 4;
struct IntegerData // una struttura di tipo struct IntegerData
{
int length;
int min;
int max;
float average;
int data[]; // array flessibile
};
// valorizzazione average
int tot = 0;
for (int i = 0; i < an_int->length; i++)
tot += an_int->data[i];
an_int->average = (float)tot / an_int->length;
return (EXIT_SUCCESS);
}
Snippet 8.17 Copia di una struttura in un’altra con un membro di array flessibile.
int nr = 4;
struct IntegerData // una struttura di tipo struct IntegerData
{
int length;
int min;
int max;
float average;
int data[]; // array flessibile
};
Snippet 8.19 Struttura con membro array flessibile come elemento di un array o membro di
un’altra struttura.
...
#define NRS 5
int main(void)
{
int nr = 4;
struct IntegerData // una struttura di tipo struct IntegerData
{
int length;
int min;
int max;
float average;
int data[]; // array flessibile
};
a_rect.width = a_rect.height = 0;
Lo Snippet 8.20 dichiara una struttura di tipo struct rect, che descrive un generico
rettangolo che ha come suoi membri, tra gli altri, due strutture annidate anonime che
rappresentano due coppie di punti su un piano utili a individuare le coordinate grazie alle
quali “generare” il relativo rettangolo.
Definisce, quindi, la variabile di struttura a_rect e poi valorizza i membri delle strutture
anonime annidate accedendo direttamente a essi; ossia tramite l’operatore punto
sull’oggetto a_rect; questo è possibile perché i membri di una struttura anonima sono
membri della struttura contenitrice (in pratica è come se i membri x1, y1 e x2, y2 fossero non
membri delle rispettive strutture anonime bensì della struttura rect).
NOTA
L’inizializzazione dei membri delle strutture anonime può essere compiuta allo stesso modo
di quella già vista per le strutture annidate non anonime, ossia ponendo per ognuna delle
coppie di parentesi graffe { } interne a quelle più esterne della struttura contenitrice
(Snippet 8.21).
Campi di bit
Nel Capitolo 4 abbiamo studiato gli operatori bit a bit (bitwise operators) che,
ricordiamo, consentono di accedere e manipolare direttamente i bit di un operando.
In quel contesto abbiamo anche analizzato alcune tecniche utili per impostare, cancellare,
verificare, commutare o estrarre dei bit da un operando impiegando tali operatori e
opportune maschere di bit.
In ogni caso le tecniche mostrate, ancorché utili, soffrono di uno svantaggio: sono
complicate da usare e a volte la loro comprensione è poco agevole.
C, da “variegato” linguaggio quale è, offre una valida e più semplice alternativa per
manipolare a basso livello i bit di un dato che si realizza nella possibilità di usare appositi
campi di bit (bit fields) dichiarati come membri all’interno di una struttura.
La Sintassi 8.8 evidenzia che una struttura contenente membri che sono campi di bit si
dichiara quasi similmente a una struttura contenente membri ordinari. Si hanno però le
seguenti differenze: data_type può essere solo uno dei seguenti tipi predefiniti del
linguaggio, come _Bool, int, signed int e unsigned int, oppure un tipo definito dalla corrente
implementazione; dopo l’identificatore del membro, che è opzionale, deve apparire il
carattere due punti (:) cui far seguire, tramite size, un’espressione costante intera con un
valore non negativo che indica la dimensione in bit del relativo campo (tale valore deve
anche essere minore o uguale alla dimensione massima dei bit del tipo specificato; per
esempio, massimo 32 se il tipo è unsigned o signed int).
Snippet 8.22 Una struttura per delle date con dei campi di bit.
struct date // una struttura di tipo struct date
{
unsigned int day : 5; // 5 bit -> valori da 0 a 31
unsigned int month : 4; // 4 bit -> valori da 0 a 15
unsigned int year : 11; // 11 bit -> valori da 0 a 2047
_Bool isLeapYear : 1; // 1 bit -> valori 0 o 1
};
Lo Snippet 8.22 dichiara la struttura date utile a rappresentare una generica data formata
dalle indicazioni di un giorno, mese, anno e se l’anno è bisestile o meno.
Essa ha come membri dei campi di bit dove: il campo day, lungo 5 bit, è capace di
contenere un valore che può esprimere un giorno (valori significativi da 1 a 31); il campo
month, lungo 4 bit, è capace di contenere un valore che può esprimere un mese (valori
significativi da 1 a 12); il campo year, lungo 11 bit, è capace di contenere un valore che può
esprimere un anno (valori significativi da 0 a 2047); il campo isLeapYear, lungo 1 bit, è capace
di contenere un valore che può esprimere se il corrente anno è bisestile (valore significativo
1) oppure non lo è (valore significativo 0).
In definitiva, l’utilizzo di una struttura dichiarata come quella vista, ossia con dei campi
di bit, a volte consente di risparmiare memoria; se infatti non avessimo avuto a disposizione
tale feature, avremmo potuto dichiarare una struttura di una data (date_V2) nel seguente
inefficiente modo (Snippet 8.23).
Snippet 8.23 Una struttura per delle date senza dei campi di bit.
struct date_V2 // una struttura di tipo struct date_V2
{
int day; // 32 bit
int month; // 32 bit
int year; // 32 bit
_Bool isLeapYear; // 8 bit
};
Avremo così sprecato memoria non necessaria (infatti, un sizeof di una variabile di una
struttura date può dare, in genere, 4 come valore dei byte utilizzati, mentre un sizeof di una
variabile di una struttura date_V2 può dare 16 come valore dei byte utilizzati).
Perché sono utilizzati solo 4 byte per allocare i campi di bit della struttura date? La
risposta è che un compilatore quando incontra una struttura con dei campi di bit utilizza una
unità di storage, usualmente grande come una memory word della dimensione di un int nel
corrente sistema, nella quale “impacchetta”, in successione, i bit dichiarati per i relativi
campi di bit.
NOTA
Lo standard C11 non dice espressamente quanto debba essere grande una unità di
storage, ma asserisce che un’implementazione può allocare qualsiasi unità di storage
indirizzabile che sia grande abbastanza per contenere un bit field.
Per esempio, nel corrente sistema la memory word scelta sarà grande 4 byte (32 bit), e
rappresenterà l’unità di storage dove verranno collocati, consecutivamente e senza vuoti, da
sinistra a destra oppure da destra a sinistra, i 21 bit della struttura date.
Quindi, una variabile di struttura di tipo struct date peserà in memoria solo 4 byte perché
i 32 bit propri della memory word in uso saranno sufficienti a contenere tutti i bit dei campi
di bit lì definiti.
// prototipo di isLeapYear
_Bool isLeapYear(int year);
int main(void)
{
struct date // una struttura di tipo struct date
{
unsigned int day : 5; // 5 bit -> valori da 0 a 31
unsigned int month : 4; // 4 bit -> valori da 0 a 15
unsigned int year : 11; // 11 bit -> valori da 0 a 2047
unsigned int isLeapYear : 1; // 1 bit -> valori 0 o 1
};
return (EXIT_SUCCESS);
}
// definizione di isLeapYear
_Bool isLeapYear(int year)
{
return (year % 4 == 0) &&
(year % 100 != 0) ||
(year % 400 == 0);
Figura 8.7 Rappresentazione in memoria dei campi di bit della variabile di struttura current_date.
Snippet 8.24 Una struttura per delle date con dei campi di bit anonimi.
struct date // una struttura di tipo struct date
{
unsigned int day : 5; // 5 bit -> valori da 0 a 31
unsigned int month : 4; // 4 bit -> valori da 0 a 15
unsigned int : 7; // 7 bit di padding
unsigned int year : 11; // 11 bit -> valori da 0 a 2047
unsigned int : 0; // isLeapYear inizierà alla successiva unità di storage
unsigned int isLeapYear : 1; // 1 bit -> valori 0 o 1
} current_date = {29, 9, 2014, 0};
Lo Snippet 8.24 dichiara la struttura date con il campo day lungo 5 bit, il campo month
lungo 4 bit, un campo anonimo lungo 7 bit, il campo year lungo 11 bit, un campo anonimo
lungo 0 bit e il campo isLeapYear lungo 1 bit.
Contestualmente dichiara la variabile current_date di tipo struct date la quale avrà la
rappresentazione in memoria di cui alla Figura 8.8 e una dimensione di 8 byte perché sono
state utilizzate due unità di storage da 4 byte; la prima atta a contenere i campi di bit day,
month e year; la seconda atta a contenere il campo di bit isLeapYear che è stato posto a partire
dal suo inizio.
Figura 8.8 Rappresentazione in memoria dei campi di bit della variabile di struttura current_date.
Vediamo ora un altro esempio (Listato 8.9) che mostra come in concreto sia più semplice
utilizzare i campi di bit rispetto agli operatori bitwise per manipolare dei bit di un dato
operando.
int main(void)
{
// un colore espresso nella forma RGB
// Light blue RGB -> R = 173, G = 216, B = 230
unsigned int bw_color = 0xADD8E6;
// accesso alle componenti; si noti come per estrarre tali componenti occorra
// chiamare le relative funzioni che usano gli operatori bitwise >>, & e una maschera
// di bit; è inoltre indispensabile sapere la "posizione" delle componenti, ossia che
// le abbiamo disposte nel formato RGB e non, per esempio, come BGR
printf("Componenti bw_color: [%d, %d, %d]\n",
getRED(bw_color),
getGREEN(bw_color),
getBLUE(bw_color)
);
return (EXIT_SUCCESS);
}
In conclusione è comunque opportuno dire che le strutture con dei campi di bit,
quantunque consentano di manipolare i bit di un dato in modo più pratico e leggibile
rispetto agli operatori bitwise, presentano lo svantaggio che i programmi che ne fanno uso
non sono portabili, e ciò perché molte “decisioni” sulla loro gestione dipendono dalla
corrente implementazione e dal corrente sistema target (quanto sarà lunga la word? Come
saranno allineati i bit? Se c’è spazio insufficiente nella corrente unità di storage, il
successivo campo di bit sarà posto in una successiva unità di storage oppure potrà
sovrapporsi in unità di storage adiacenti? E così via).
Unioni
Un’unione (union) è un tipo di dato derivato rappresentato da una sequenza o insieme di
elementi, detti membri o campi (fields), che sono allocati in memoria in modo
“sovrapposto” e che possono anche essere di differente tipo (Sintassi 8.9).
Un’unione si dichiara indicando la keyword union; un tag o etichetta opzionale utile per
dare un identificativo alla relativa unione; una coppia di parentesi graffe { } al cui interno
esplicitare le dichiarazioni di altri tipi di oggetti e che ne rappresentano i membri; uno o più
identificatori opzionalmente indicati dopo la parentesi graffa di chiusura } dell’unione che
rappresentano i nomi degli oggetti del tipo di unione definita.
In sostanza un’unione è un tipo “molto” simile al tipo struttura ma ha rispetto a esso
un’importante differenza: il compilatore allocherà per un’unione uno spazio di memoria
adeguato a contenere i valori del membro che ha il tipo di dimensioni maggiori.
Ciò significa che, rispetto a una struttura che potrà contenere nello stesso tempo dei
valori per tutti i tipi dichiarati (per esempio un valore int e un valore float e un valore char e
così via), un’unione potrà contenere solo un valore di uno dei tipi dichiarati (per esempio un
valore int o un valore float o un valore char e così via).
Quanto detto si verifica perché il compilatore utilizza la memoria per allocare un tipo
struttura oppure un tipo unione in modo differente.
Per una struttura avremo l’allocazione di uno spazio di memoria grande abbastanza per
contenere tutti i suoi membri più eventuale spazio di padding per l’allineamento. Il
primo membro è memorizzato a partire dall’indirizzo di memoria dove inizia la
struttura stessa, e gli altri membri sono memorizzati a indirizzi di locazioni di memoria
successive e nell’ordine in cui sono stati dichiarati. In questo caso una struttura potrà
contenere tanti valori quanti sono i membri lì dichiarati.
Per un’unione avremo l’allocazione di uno spazio di memoria grande quanto il più
grande dei tipi dei suoi membri. Tutti i suoi membri sono memorizzati a partire
dall’indirizzo di memoria dove inizia l’unione stessa. In questo caso un’unione potrà
contenere il valore di un solo membro alla volta.
La Figura 8.9 evidenzia in modo esplicito la differenza di allocazione di memoria per la
variabile di struttura s di tipo struct S (i suoi membri sono memorizzati in indirizzi di
memoria diversi) e la variabile di unione u di tipo union U (i suoi membri sono memorizzati
allo stesso indirizzo di memoria) di cui lo Snippet 8.25.
Per quanto riguarda l’utilizzo di una variabile di tipo unione esso è identico a quello visto
per una variabile di tipo struttura; è cioè sufficiente impiegare l’identificatore del tipo
unione, l’operatore di selezione punto . (o freccia -> se è un puntatore a un unione) e il
nome del membro da manipolare.
Tuttavia, in ragione di quanto sin qui detto, quando si accede a un membro di un’unione
per valorizzarlo il valore del precedente membro eventualmente valorizzato andrà perso.
Ritornando alla variabile u di tipo union U dello Snippet 8.25, se assegniamo il valore 100
al membro i di tipo int il contenuto del membro c di tipo char sarà “distrutto”; ciò è
perfettamente logico perché il valore 100 verrà posto a partire dalla locazione di memoria
0x00eefbf4 che è “condivisa” dal membro i e dal membro c (solo uno di questi membri potrà
contenere un valore valido in un determinato momento).
NOTA
In fase di inizializzazione di un’unione è possibile assegnare un solo valore che sarà per il
primo membro. Nel nostro caso il letterale carattere 'a' è stato assegnato al primo membro,
ossia c. Chiaramente è sempre possibile usare un inizializzatore designato per scegliere
quale membro dell’unione inizializzare (Snippet 8.27).
Per il resto, come già anticipato, un’unione, al pari di una struttura (Snippet 8.28):
può essere assegnata a un’altra unione;
se ne può creare un alias di tipo tramite la keyword typedef;
se ne può definire un letterale composto; è legale, cioè, scrivere delle dichiarazioni di
union “senza nome”;
può essere annidata in un’altra unione;
può contenere come suo membro un tipo array, di qualsiasi dimensione desiderata, così
come un array può contenere come suo elemento un’unione di un qualsiasi tipo (è
definito array di unioni);
può essere di tipo puntatore (puntatore a unione);
può essere utilizzata come parametro formale di una funzione e anche come suo tipo di
ritorno. Si può, allo stesso modo, dichiarare un parametro di funzione e un tipo di
ritorno come puntatore a un’unione di un tipo;
può avere come propri membri delle unioni senza nome (anonymous unions), ossia
unioni dotate del solo specificatore union, senza alcun tag e senza alcuna variabile di
unione;
può avere come propri membri dei bit field.
int main(void)
{
union U // unione di tipo union U
{
char c;
int i;
} u = {'a'}, z;
z = u; // assegnamento di un'unione a un'altra unione
// letterale union
union U u_i = (union U){.i = 1000};
// annidamento di unioni
union parent // unione di tipo union parent
{
int a;
// unioni anonime
union A // un'unione di tipo union A
{
union // unione anonima
{
short s;
int i;
};
};
union B b;
b.a = 1023; // ok il valore "entra" nei 10 i bit di a
...
// definizione della funzione setZ che ritorna un'unione di tipo union Z e che
// accetta come argomento un'unione sempre di tipo union Z
union Z setZ(union Z z)
{
return (union Z){.c = z.c + 1};
}
NOTA
Gli esempi di unioni presentati hanno solo uno scopo didattico. Molte di esse sono infatti
servite solo per mostrarne la sintassi di utilizzo.
#define SIZE 30
#define A_SIZE 100
#define T_SIZE 20
#define ITEMS_SIZE 50
#define NR_STORE 3
#define NR_ITEMS 10
#define BOOK_STORE 0
#define CLOTHING_STORE 1
#define COMPUTER_STORE 2
int main(void)
{
struct ShoppingCenter // una struttura di tipo struct ShoppingCenter
{
// dati comuni al centro commerciale
char name[SIZE];
char address[A_SIZE];
char shopping_center_tel[T_SIZE];
int number_of_floors;
int number_of_store;
return (EXIT_SUCCESS);
}
Il Listato 8.9 crea una struttura atta a modellare un generico centro commerciale nel quale
sono presenti una moltitudine di negozi tra i quali, nel nostro caso e per semplicità: una
libreria, un negozio di abbigliamento e un negozio di computer.
Questa struttura è identificata dal tag ShoppingCenter ed è costruita nel seguente modo.
1. Ha dei membri che sono delle variabili scalari che rappresentano dei dati comuni a un
centro commerciale (name, address, number_of_floorse così via).
2. Ha un membro che è una struttura annidata che rappresenta un negozio generico. Per
essa viene dichiarata la variabile di struttura store che è un array contenente tre
elementi di questo tipo di struttura (in pratica ci sono tre negozi).
La struttura di un negozio è invece costruita in questo modo.
1. Ha dei membri che sono delle variabili scalari che rappresentano dei dati comuni a un
negozio (name, floor e store_nr).
2. Ha un membro che è una struttura annidata che rappresenta un generico item (o
articolo). Per essa viene dichiarata la variabile di struttura item che è un array
contenente dieci elementi di questo tipo di struttura (in pratica ci sono dieci articoli per
negozio).
La struttura di un item è invece costruita in questo modo.
1. Ha dei membri che sono delle variabili scalari che rappresentano dei dati comuni a un
item (price e general_item_code).
2. Ha un membro che è un’unione anonima annidata atta a descrivere uno specifico item
ossia: un libro (variabile di struttura book) oppure uno specifico capo di abbigliamento
(variabile di struttura clothing) oppure uno specifico computer (variabile di struttura
computer). Ognuna di queste strutture ha dei membri a essa specifici e dunque non
comuni (per esempio, per la struttura di cui la variabile book avremo per un item libro il
dato title, che è assente perché non necessario per la struttura di cui la variabile
clothing, dove è invece presente per un item abbigliamento il dato color).
Di tutte le strutture dichiarate la struttura che descrive un item è quella che mostra come
risparmiare spazio di memorizzazione grazie all’ausilio di un’unione.
Infatti, dato un item, le uniche informazioni comuni a tutti sono quelle che riguardano il
prezzo (variabile price) e il codice (variabile general_item_code). Le altre informazioni sono
invece specifiche per ogni item e pertanto, piuttosto che replicarle tutte nell’ambito della
struttura di un item, qualsiasi esso sia, è parso più opportuno dichiarale nell’ambito di una
specifica struttura, membro di un’unione, che verrà scelta di volta in volta.
Questa modellazione di una struttura di un item che fa uso di un’unione che descrive tre
diverse tipologie di articoli ci ha permesso di risparmiare una notevole quantità di memoria,
che avremmo invece sprecato se avessimo dichiarato la struttura di un item ponendo in essa
tutte le informazioni di tutti gli item (essa avrebbe pesato in memoria 2840 byte invece dei
1160 byte impiegati grazie alla nostra unione).
char title[ITEMS_SIZE];
char author[ITEMS_SIZE];
int number_of_pages;
char type[ITEMS_SIZE];
int size;
char color[ITEMS_SIZE];
char model_name[ITEMS_SIZE];
int ram; // in GByte
int cpu_speed; // in GHertz
int hd_capacity; // in GByte
_Bool printer;
_Bool scanner;
_Bool integrated_video_card;
} item[NR_ITEMS];
#define CHAR 0
#define INT 1
#define FLOAT 2
#define DOUBLE 3
#define SIZE 4
// creiamo un alias per una struttura capace di contenere quattro tipi di dato diversi
typedef struct
{
int type;
// prototipo di printValue
void printValue(variant v);
int main(void)
{
variant v; // dichiaro v di tipo variant
// ora è char
v.type = CHAR;
v.c = 'Z';
printValue(v);
// ora è int
v.type = INT;
v.i = 100;
printValue(v);
// ora è double
v.type = DOUBLE;
v.d = 33.56;
printValue(v);
return (EXIT_SUCCESS);
}
// definizione di printValue
void printValue(variant v)
{
switch (v.type)
{
case CHAR:
printf("%c\n", v.c);
break;
case INT:
printf("%d\n", v.i);
break;
case FLOAT:
printf("%.2f\n", v.f);
break;
case DOUBLE:
printf("%.2f\n", v.d);
}
}
Il Listato 8.10 definisce una struttura con un membro type, che descrive il corrente tipo di
dato gestito tra CHAR, INT, FLOAT e DOUBLE, e un membro union anonima capace di contenere un
char o un int o un float o un double.
Di questa struttura viene fatto un alias di tipo mediante typedef e le viene attribuito il
nome variant che può essere utilizzato in modo semplice per creare variabili di tale tipo.
La funzione main evidenzia come usare il tipo variant; in pratica la variabile v di tipo
variant viene di volta in volta valorizzata con un valore di tipo differente (prima il tipo char,
poi il tipo int e infine il tipo double) e con un valore che rappresenta il tipo immesso.
È inoltre interessante notare la creazione di un array, array_of_v, capace di contenere
elementi di tipo differente (di tipo variant), cosa non possibile quando utilizziamo gli array
con i tipi fondamentali (int, float e così via) poiché tali array possono contenere solo
elementi dello stesso tipo.
NOTA
Avremmo potuto creare un’unione variant senza che questa fosse stata un membro di una
struttura. Tuttavia, così facendo, non avremmo potuto sapere, in modo agevole e in un
determinato momento, qual era il tipo correntemente utilizzato.
Enumerazioni
Un’enumerazione (enumeration) è un tipo di dato intero (enumerated type) rappresentato
da una serie di nomi o identificatori simbolici costanti di tipo int (enumeration constants)
che indicano quell’insieme di valori che dovrebbe “possibilmente” contenere (Sintassi
8.10).
int main(void)
{
enum cardinal_points // enumerazione di tipo enum cardinal_points
{
NORTH,
NORTH_EAST,
EAST,
SOUTH_EAST,
SOUTH,
SOUTH_WEST,
WEST,
NORTH_WEST
};
return (EXIT_SUCCESS);
}
Continuando la disamina del programma notiamo come il valore della variabile cp di tipo
enum cardinal_points sia assegnato senza problemi alla variabile value di tipo int; ciò è
legittimo perché, di fatto, cp è di un tipo intero scelto dal compilatore corrente.
Quanto detto è suffragato dalla possibilità di assegnare a cp il valore intero 10, anche se
non è un valore contemplato dalle sue costanti di enumerazione, e dal risultato
dell’operatore sizeof che ritorna 4 come sua dimensione di tipo in memoria (corrisponde al
tipo int sul corrente sistema).
Infine, l’operatore sizeof impiegato sull’identificatore EAST ritorna altresì 4 a
dimostrazione che le costanti di enumerazione sono di tipo int.
CURIOSITÀ
In diversi sorgenti di C è possibile trovare delle dichiarazioni di tipi enumerati dove l’ultima
costante di enumerazione è terminata da un carattere virgola , (trailing comma). A partire
da C99 tale pratica è divenuta legale ed è stata standardizzata. La ragione di questa
curiosa caratteristica risiede in due motivazioni principali; la prima è legata a ragioni di
consistenza perché era già previsto il trailing comma nelle liste di inizializzatori; la seconda
è legata a una ragione di semplicità di inserimento di una nuova costante di enumerazione
che può essere direttamente inserita alla fine della lista di enumeratori senza influire sulle
eventuali “righe” di dichiarazione degli altri enumeratori (Snippet 8.31).
Variabili interne
Una variabile è definita interna (internal variable) o anche locale (come è a volte
informalmente chiamata) quando è dichiarata nell’ambito di un blocco di codice come può
essere, per esempio, uno che delimita il corpo di una funzione (in questo caso di dice che la
variabile è locale a quella funzione), oppure uno più generico creato mediante una coppia di
parentesi graffe { } (in quest’altro caso si dice che la variabile è locale a quel blocco).
NOTA
Anche un parametro di una funzione è considerato come un variabile locale e ha le
medesime proprietà appena illustrate.
Tornando al nostro Snippet 9.1, per la variabile a dichiarata nella funzione foo viene
allocata la memoria, quando il programma entra in tale funzione, e viene deallocata la
memoria quando il programma esce.
Per la variabile j, invece, la memoria viene allocata quando il codice entra nel blocco
contenitore e ne viene deallocata quando il codice esce.
Per la visibilità avremo che: la variabile a sarà referenziabile a partire dal suo punto di
dichiarazione e per tutta la funzione foo (anche nel blocco di codice innestato che contiene
la variabile j); la variabile j sarà referenziabile a partire dal suo punto di dichiarazione e per
tutto il suo blocco contenitore (ma non al di fuori di esso e dunque in altri punti della
funzione foo).
NOTA
A partire da C99 è possibile dichiarare una variabile locale in qualsiasi punto di un blocco di
codice e prima del suo utilizzo (mixed declarations and code). Prima di C99, invece, una
variabile locale poteva essere dichiarata solo all’inizio del relativo blocco di codice.
printf("bar\n"); // bar
int main(void)
{
// ok number e data sono visibili perché globali
number = 10;
data = 15;
foo();
...
}
Blocchi
Un blocco è un’unità sintattica, delimitata da una coppia di parentesi graffe { }, al cui
interno possono essere raggruppate delle dichiarazioni e delle istruzioni proprie del
linguaggio C (per esempio, il corpo di definizione di una funzione è un blocco).
TERMINOLOGIA
Per lo standard di C un blocco espresso nella forma { [block-item-list] }, dove block-item-
list è una lista di dichiarazioni e/o istruzioni, è denominato istruzione composta (compound
statement).
// data appartiene allo spazio dei nomi dei tag delle strutture,
// unioni o enumerazioni
struct data
{
int member;
};
union U
{
// data appartiene allo spazio dei nomi dei membri delle strutture o unioni
int data;
};
Lo Snippet 9.5 evidenzia come l’identificatore data, quantunque dichiarato nello stesso
scope, ossia nell’ambito della funzione main, non farà generare al compilatore alcun errore di
conflitto di nomi perché, ribadiamo, ogni nome di data sarà appartenente a un determinato
spazio dei nomi correttamente individuato e attribuito dal compilatore.
Scope
Per scope o campo (ambito) di visibilità si intende quella “regione” di codice di un
programma dove un determinato identificatore è visibile e dunque utilizzabile. In C vi sono
quattro differenti ambiti di visibilità.
Block scope (visibilità nel blocco). La regione di codice di questo scope è rappresentata
da un blocco delimitato dalle parentesi graffe { }, e una variabile lì dichiarata è visibile
dal suo punto di dichiarazione (esattamente dopo la scrittura del relativo identificatore)
e fino al termine della definizione del blocco medesimo (la parentesi graffa di
chiusura). Le variabili locali e i parametri formali di una funzione hanno sempre un
block scope. A partire da C99 un block scope è esteso anche alle istruzioni di selezione
(if), alle istruzioni di iterazione (for, while e do/while) e alle inner statement di tali
istruzioni con o senza la consueta coppia di parentesi graffe di delimitazione di un
blocco. Ciò significa, per esempio, che se in un’istruzione for usiamo come prima
espressione una dichiarazione di una variabile, la stessa sarà visibile solo nell’ambito
del costrutto for. Al termine del for, quindi, tale variabile non esisterà più e non sarà
referenziabile in altre regioni di codice poste dopo il for stesso.
// qui a non è visibile; error: 'a' undeclared (first use in this function)
int b = a;
int n = 10;
int *data;
// qui i non è visibile; error: 'i' undeclared (first use in this function)
b = i;
è posta (per esempio anche in un blocco nidificato all’interno di una funzione), essa è
sempre referenziabile perché, per l’appunto, il suo ambito di visibilità si estende in
tutta la funzione.
int main(void)
{
// data è visibile perché variabile esterna con scope a livello di file
int x = data;
...
}
TERMINOLOGIA
Quando asseriamo che una variabile esterna è visibile in tutto il file dove è stata dichiarata,
dobbiamo precisare che per file si deve intendere quella che C considera “unità di
traduzione” (translation unit). Un’unità di traduzione è un’unità di input, formata da
quell’insieme di codice (file sorgente più file header) risultato del lavoro svolto dal
preprocessore, che il compilatore utilizzerà per produrre il relativo file oggetto che potrà poi
essere impiegato per generare un corrispettivo file eseguibile.
Possiamo quindi dire che, dato un identificatore che può denotare un oggetto (per
esempio una variabile), una funzione, un tag, un membro (per una struttura, un’unione o
un’enumerazione), un nome typedef o una label, è certamente possibile per esso denominare
più entità differenti in divere regioni di un programma (per esempio, potremmo avere
l’identificatore foo che denomina una variabile ma anche una funzione).
In questo caso, però, le differenti entità denotate dallo stesso identificatore devono avere
differenti scope oppure devono risiedere in differenti spazi di nomi, pena la generazione da
parte di un compilatore di un errore come error: 'foo' redeclared as different kind of
symbol, che evidenzia un “conflitto” di nomi di dichiarazioni.
Quando un identificatore che denomina diverse entità risiede nello stesso spazio dei
nomi, lo scope potrà “sovrapporsi” e il compilatore adotterà la seguente fondamentale
regola: l’identificatore dell’entità dichiarata in uno scope più interno (inner scope) sarà
prevalente (ossia sarà quello impiegato) rispetto all’identificatore dell’entità dichiarata in
uno scope più esterno (outer scope) che sarà quindi “nascosto”.
// I dichiarazione
int number = 100; // file scope
int main(void) // block scope di main
{
// la ricerca dell'entità corretta parte dal punto di utilizzo verso "l'alto",
// ossia dal blocco corrente verso gli eventuali blocchi contenitori
// la prima dichiarazione di number trovata è int number = 100
printf("%d\n", number); // qua stampa 100;
return (EXIT_SUCCESS);
}
Il Listato 9.1 dichiara dei tipi int in diversi punti del codice sorgente del file Scope.c e con
uno stesso identificatore, ossia number; la prima dichiarazione assocerà number alla relativa
variabile che conterrà il valore 100 e avrà un file scope; la seconda dichiarazione assocerà
number alla relativa variabile che conterrà il valore 10 e avrà un block scope; la terza
dichiarazione assocerà number alla relativa variabile che conterrà il valore 1 e avrà un block
scope.
In tutte e tre le dichiarazioni non vi sarà alcun conflitto di nomi perché ciascun
identificatore avrà un proprio scope; il primo number avrà un file scope; il secondo number
avrà un block scope (outer scope rappresentato dal corpo di definizione del main); il terzo
number avrà un block scope (inner scope rappresentato dalla coppia di parentesi graffe { }).
In più, poiché tutti gli identificatori number sono parte dello stesso name space (quello
degli identificatori ordinari) gli scope citati si sovrapporranno; ciò significherà, per esempio,
che l’istruzione printf eseguita nell’ambito del blocco interno al main stamperà il valore 1
che sarà relativo alla variabile number lì dichiarata, la cui dichiarazione “nasconderà” la
dichiarazione della variabile number nel blocco del main, che non sarà quindi utilizzata.
REFERENCING ENVIRONMENT, LEXICAL SCOPE E DYNAMIC SCOPE
C è un linguaggio di programmazione che ha un tipo di scope definito lessicale o statico
(static o lexical scope). Ma cosa significa? Per rispondere alla domanda partiamo dal definire
il concetto di referencing environment. Il referencing environment è un’astrazione software
che contiene l’insieme di tutti i nomi che associano variabili e che sono visibili in un
determinato punto di un programma, ovvero utilizzabili. In pratica, identifica una collezione di
scopes (ambiti di risoluzione o visibilità) che vengono esaminati al fine di trovare il binding
nome-variabile ricercato. Nei linguaggi con scope statico (static-scoped), come C, tale
insieme include tutti i nomi delle variabili definite localmente e i nomi di quelle definite negli
eventuali blocchi contenitori (i suoi ancestor). Nei linguaggi con scope dinamico (dynamic
scoped), come per esempio Common Lisp, tale insieme contiene tutti i nomi delle variabili
definite localmente e i nomi di quelle delle funzioni, in un’eventuale pila di chiamate, ancora in
esecuzione (cioè correntemente attive e non terminate). Come più volte detto, lo scope di una
variabile è dato da quell’insieme di righe di codice in cui essa è visibile, e quindi
referenziabile, oppure, in altri termini, da quella regione di un programma dove un determinato
binding nome-variabile è attivo. I linguaggi di programmazione possono quindi adottare una
regola di scope definita statica o lessicale, che è basata sulla struttura testuale del
programma (è detta anche spaziale, basata cioè sul più vicino blocco nidificato), o dinamica,
che è basata sull’ordine in cui le funzioni sono invocate (è detta anche temporale, basata cioè
sulla più recente associazione). In pratica, con lo scope lessicale, il binding del nome a una
variabile è determinato prima dell’esecuzione del relativo programma (a compile time)
leggendo il codice sorgente e senza considerare il flusso di esecuzione computazionale che
avviene a run-time. Con lo scope dinamico, invece, il binding del nome a una variabile è
determinato solo durante l’esecuzione di un programma. Infatti, quando si raggiunge una
statement che accede al nome di una variabile, l’ultima dichiarazione raggiunta
dall’esecuzione del programma sarà quella che determinerà il binding tra il nome e la variabile
riferita. Dato, per esempio, il blocco di codice di cui lo Snippet 9.10 avremo che:
con lo scope lessicale, la funzione bar, non trovando una dichiarazione locale della
variabile x, utilizzerà per l’assegnamento del relativo valore alla variabile z quella trovata
nel blocco contenitore ascendente più prossimo, ossia quello della funzione foo dove x =
10;
con lo scope dinamico la funzione bar, non trovando una dichiarazione locale della
variabile x, utilizzerà per l’assegnamento del relativo valore alla variabile z quella trovata
nella sua precedente funzione chiamante (dynamic parent), ossia la funzione foo dove x
= 10;
con lo scope lessicale, la funzione baz, non trovando una dichiarazione locale della
variabile k, utilizzerà per l’assegnamento del relativo valore alla variabile j quella trovata
nel blocco contenitore ascendente più prossimo, ossia quello della funzione foo dove k =
20;
con lo scope dinamico la funzione baz, non trovando una dichiarazione locale della
variabile k, utilizzerà per l’assegnamento del relativo valore alla variabile j quella trovata
nella sua precedente funzione chiamante (dynamic parent), ossia la funzione foobar dove
k = 10.
In definitiva, la ricerca di un binding per una variabile partirà sempre dal blocco contenitore
locale, ma se esso non sarà trovato, allora: per lo scope statico, la ricerca proseguirà nei
successivi blocchi contenitori ascendenti (static parent) così come lessicalmente scritti nel
codice; per lo scope dinamico, la ricerca proseguirà con le precedenti funzioni chiamanti
(dynamic parent) in base al loro ordine di invocazione.
Function bar()
{
var z = x;
var k = 1000;
Function baz()
{
var j = k;
Function foobar()
{
var k = 10;
baz();
}
bar();
}
NOTA
Lo Snippet 9.10 è stato scritto con una sintassi propria di uno pseudo-linguaggio perché
meglio si presta a esemplificare in modo generico il comportamento di un linguaggio con
scope statico rispetto a un linguaggio con scope dinamico (per esempio, in C non avremmo
potuto scrivere lo stesso codice perché una funzione non può essere definita all’interno di
un’altra funzione).
Linkage
Come abbiamo visto, quando si scrive un programma in C possiamo impiegare degli
oggetti interni, per esempio delle variabili definite localmente a una funzione, oppure degli
oggetti esterni, per esempio delle variabili definite al di fuori da ogni funzione ma anche
delle funzioni che sono sempre e solo entità esterne, visto che in C non è possibile definire
una funzione all’interno di un’altra funzione.
Gli oggetti interni e quelli esterni, siano essi semplici variabili o complesse funzioni,
hanno una proprietà importante definita dallo standard come linkage (collegamento), che
permette di stabilire se tali oggetti sono visibili solo all’interno di un’unità di traduzione
(linkage interno) oppure anche in altre unità di traduzione (linkage esterno), ossia
nell’ambito di tutto un programma complesso formato da più file sorgenti e file header.
Questi oggetti possono anche non avere alcun collegamento, come è il caso delle variabili
locali a una funzione, e sono detti senza linkage (in questo caso sono oggetti “privati” alla
funzione dove sono stati dichiarati, e nessun’altra funzione potrà utilizzarli).
TERMINOLOGIA
Quando parliamo di linkage, ma anche di scope, dobbiamo precisare che questi termini si
riferiscono agli identificatori piuttosto che agli oggetti o alle funzioni. Per esempio, è più
corretto dire “l’identificatore foo ha un linkage esterno e un scope a livello di file” piuttosto
che “la variabile foo ha un linkage esterno e uno scope a livello di file”. In ogni caso, per non
“appesantire” di rigidi formalismi terminologici una spiegazione, sia essa scritta od orale, è
molto comune dire che è la tale variabile ad avere un certo scope oppure un certo linkage.
int main(void)
{
// STORAGE DURATION: automatic
// SCOPE: block
// LINKAGE: none
int number; // variabile interna
...
}
Dichiarazioni
Una dichiarazione consente di specificare la “natura” di un identificatore di un oggetto o
funzione permettendo di indicarne, in modo raffinato, le proprietà.
Al contempo fornisce al compilatore tali informazioni in modo che lo stesso possa
interpretarle correttamente.
La Sintassi 9.1 mostra la forma generale con la quale si scrivere una dichiarazione:
abbiamo la parte declaration-specifiers (specificatori di dichiarazione), che fornisce una
serie di specificatori con i quali si indicano le proprietà di durata in memoria, linkage o tipo
dell’entità che il relativo identificatore (dichiaratore) denota. Segue la parte declarators
(dichiaratori), che fornisce uno o più dichiaratori separati dal carattere virgola, ciascuno dei
quali può avere, opzionalmente, informazioni addizionali sul tipo oppure un inizializzatore
(in pratica un dichiaratore rappresenta un identificatore per un entità).
Gli specificatori di dichiarazione sono raggruppati nelle seguenti categorie.
Classi di memorizzazione (storage classes): sono esplicitate tramite le keyword auto,
static, extern, register, _Thread_local e typedef. In una dichiarazione può essere presente
al massimo uno di questi specificatori e, nel caso, deve essere indicato come prim, a
eccezione però di _Thread_local, che può apparire anche con static o extern.
Qualificatori di tipo (type qualifiers): sono specificati tramite le keyword const,
restrict, volatile e _Atomic. In una dichiarazione possono esservi 0 o più di questi
qualificatori.
Specificatori di tipo (type specifiers): sono esplicitati tramite le keyword void, char,
short, int, long, float, double, signed, unsigned, _Bool, _Complex, _Atomic, struct, union, enum e
typedef-name. In una dichiarazione deve esserci almeno uno di questi specificatori,
alcuni dei quali possono anche essere lecitamente combinati come indicato dal
seguente elenco (la virgola su una stessa riga di un elenco separa gli specificatori che
hanno la stessa semantica e sono impiegabili in modo interscambiabile perché
rappresentano lo stesso tipo):
— void;
— char;
— signed char;
— unsigned char;
— short, signed short, short int oppure signed short int;
— long long, signed long long, long long int oppure signed long long int;
— float;
— double;
— long double;
— _Bool;
— float _Complex;
— double _Complex;
— _Atomic;
— struct;
— union;
— enum;
— typedef-name.
specificatori.
Specificatore di allineamento (alignment specifier). È esplicitato tramite la keyword
_Alignas. In una dichiarazione può essere o meno presente.
DICHIARAZIONE E DEFINIZIONE
Come detto, una dichiarazione indica le proprietà di uno o più identificatori associati a
un’entità (introduce cioè un nome al compilatore e gli dice qualcosa di quel nome). Una
definizione di un identificatore, invece, è una dichiarazione di quell’identificatore che fa
allocare, generalmente, una determinata quantità di memoria per l’entità da esso denotata.
Per esempio, per una varabile, una definizione di un relativo identificatore farà allocare da
parte del compilatore una quantità di memoria capace di contenere valori del tipo indicato; per
una funzione, invece, la definizione del relativo identificatore ne fornirà il corpo (function
body), ossia la descrizione di cosa essa sarà deputata a compiere (la sua implementazione).
TERMINOLOGIA
Così come nel caso del linkage e dello scope, è più corretto terminologicamente parlare di
dichiarazione o definizione di un identificatore piuttosto che di dichiarazione o definizione di
un’oggetto o una funzione. Anche in questo caso, tuttavia, è prassi non appesantire una
trattazione e dire senza problema che si sta dichiarando o definendo la tal variabile o la tal
funzione.
NOTA
Solitamente quando si costruisce una dichiarazione è prassi scriverla indicando come prima
cosa uno specificatore di classe, come seconda cosa un qualificatore di tipo, come terza
cosa uno specificatore di tipo e come quarta cosa un dichiaratore.
Dichiarazioni complesse
Un dichiaratore, come visto, può contenere oltre al nome semplice di un identificatore,
anche i simboli [ ], * e ( ). Questi simboli possono essere combinati insieme, per tipo e per
numero di occorrenze, al fine di scrivere dichiaratori di qualsivoglia natura la cui
“decifrazione” dell’entità denotata, talune volte, può essere di notevole difficoltà. La
dichiarazione di una siffatta entità è definita in gergo dichiarazione complessa, e per
comprenderla possiamo adottare le seguenti regole.
1. Iniziare a leggere il dichiaratore a partire dal nome dell’identificatore e avviare la
decifrazione della dichiarazione complessa da quel punto.
2. Prediligere sempre i simboli [ ] e ( ) rispetto al simbolo *. Per esempio, se il simbolo *
precede un identificatore e il simbolo [ ] lo segue, tale identificatore rappresenterà un
array di e non un puntatore a. Allo stesso modo, se il simbolo * precede un
identificatore e il simbolo ( ) lo segue, tale identificatore rappresenterà una funzione di
tipo e non un puntatore a. La priorità dei simboli [ ] e( ) rispetto al simbolo * può
essere cambiata con il consueto uso del simbolo ( ).
NOTA
L’interpretazione delle dichiarazioni complesse richiede tempo e pratica. Un buon punto di
partenza per fare esperienza può essere quello di collegarsi al sito http://cdecl.org/ e
inserire nell’apposito campo di testo la dichiarazione che si vuole decifrare per ottenere una
risposta sul suo significato (Figura 9.1).
Figura 9.1 Il risultato di una dichiarazione complessa sul sito cdecl.org.
Specificatori della classe di memorizzazione
Gli specificatori della classe di memorizzazione consentono di indicare per un’entità (per
esempio una variabile o una funzione) la sua durata in memoria ma anche il tipo di linkage.
auto
Lo specificatore di classe auto attribuisce agli oggetti dichiarati una classe di
memorizzazione automatica e il suo impiego è valido solo per le dichiarazioni degli stessi
effettuate all’interno di un blocco (in pratica all’interno del corpo delle funzioni).
Questo specificatore è usato di default per le variabili interne (non è obbligatorio
esplicitarlo) e fa sì che esse nascano (quando il flusso di esecuzione del codice entra nel
relativo blocco) e muoiano (quando il flusso di esecuzione del codice esce dal relativo
blocco), per l’appunto, in automatico.
NOTA
Nel caso di un array di lunghezza variabile c’è un’eccezione alla regola sopra esposta:
esso, infatti, nasce dal punto della sua dichiarazione piuttosto che dall’entrata del flusso di
esecuzione del codice nel blocco dove è stato dichiarato.
int main(void)
{
// auto scritto in modo esplicito; non necessario perché le variabili interne
// hanno sempre, di default, lo specificatore di classe auto
auto int b = 200;
...
}
static
Lo specificatore di classe static attribuisce agli oggetti dichiarati una classe di
memorizzazione statica e il suo impiego è valido sia per dichiarazioni degli stessi effettuate
all’esterno di un blocco sia per quelle effettuate all’interno di un blocco.
La sua semantica cambia a seconda di dove esso è usato; per un oggetto esterno, indica
che lo stesso può essere usato solo dalle funzioni definite nello stesso file dove tale oggetto
è stato definito; per un oggetto interno, indica che lo stesso ha una durata statica in
memoria, ossia permanente per tutta la durata del programma.
In più, una dichiarazione static per una variabile interna si comporta anche come
definizione e provoca per essa lo stanziamento di memoria (la sua allocazione).
Una variabile con lo specificatore di classe static dichiarata all’interno di un blocco ha
dunque: una durata in memoria statica; un block scope; nessun linkage.
Una variabile con lo specificatore di classe static dichiarata all’esterno di un blocco ha
dunque: una durata statica in memoria; un file scope; un linkage interno.
NOTA
Lo specificatore static è utilizzabile anche con una dichiarazione di funzione al fine di
renderla usabile solo nel corrente file.
int main(void)
{
foo(); // I invocazione
foo(); // II invocazione
...
}
// definizione di foo
void foo(void)
{
// data ha una durata in memoria permanente e conserva il suo valore
// tra la I e la II invocazione della sua funzione avvenuta da main
static int data = 2000;
int a = number; // ok number è visibile...
}
NOTA
Dato che una variabile locale e statica permane in memoria per tutta la durata di un
programma, è lecito assegnare il suo indirizzo di memoria o puntatore come valore di ritorno
dalla funzione dove è stata dichiarata. Lo stesso non è però consigliabile per una variabile
locale automatica perché, ribadiamo, al termine dell’esecuzione della funzione dove è stata
dichiarata cessa di esistere (il suo indirizzo di memoria non è più valido e potrebbe essere
usato per altri oggetti).
extern
Lo specificatore di classe extern attribuisce agli oggetti dichiarati una classe di
memorizzazione esterna e il suo impiego è valido sia per dichiarazioni degli stessi effettuate
all’esterno di un blocco sia per quelle effettuate all’interno di un blocco.
La sua semantica è la seguente: dichiara senza definire, e dunque senza allocare memoria,
la relativa variabile cui è applicato; in pratica, informa il compilatore che si ha intenzione di
usare tale variabile che è stata, però, definita altrove (nello stesso file oppure in qualche
altro file) con un linkage normalmente esterno.
TERMINOLOGIA
Questo tipo di dichiarazione è detto anche “dichiarazione che riferisce” (referencing
declaration) perché il compilatore non alloca memoria per il corrispettivo oggetto come è il
caso di una “dichiarazione che definisce” (defining declaration) propria della scrittura senza
alcuno specificatore di un oggetto esterno.
TERMINOLOGIA
Se dichiariamo una variabile esterna senza lo specificatore di classe extern e senza
un’esplicita inizializzazione, tale dichiarazione è indicata dallo standard di C con il termine di
definizione provvisoria (tentative definition).
int main(void)
{
// esplicito la volontà di usare number che è stata definita altrove: in questo
// caso si riferisce alla variabile esterna number che avendo scope a livello di file
// consentirebbe anche di omettere tale dichiarazione
// infatti, è consuetudine ometterla sempre!!!
extern int number;
int a = number; // 100
foo();
NOTA
Nel Capitolo 10 vedremo un esempio pratico di utilizzo dello specificatore di classe extern
per riferirsi a oggetti esterni definiti in altri file, caso in cui il suo impiego è obbligatorio.
register
Lo specificatore di classe register attribuisce agli oggetti dichiarati una classe di
memorizzazione di tipo registro e il suo impiego è valido per le dichiarazioni degli stessi
effettuate all’interno di un blocco (in pratica all’interno del corpo delle funzioni) ma anche
all’interno delle parentesi tonde di scrittura dei parametri formali (una variabile che è un
parametro formale può avere lo specificatore di classe register).
Una variabile dichiarata con tale specificatore di classe ha le stesse proprietà di una
variabile dichiarata con lo specificatore di classe auto (per esempio una durata in memoria
automatica, un block scope e nessun linkage), ma rispetto a essa ha un’importante
differenza: lo spazio di storage può essere, eventualmente, allocato nei registri della CPU
piuttosto che nella memoria principale.
NOTA
Ricordiamo che un registro è un’area di storage della CPU dove i dati lì memorizzati
possono essere manipolati in modo estremamente veloce. Si rimanda al Capitolo 1 per un
maggior dettaglio sulla CPU e sui relativi registri.
int main(void)
{
int data[SIZE] = {1, 2, 3, 4, 5, 6};
int res = 1;
_Thread_local
Lo specificatore di classe _Thread_local, introdotto dallo standard C11, attribuisce agli
oggetti dichiarati una classe di memorizzazione di tipo thread per effetto della quale essi
hanno una durata in memoria che è uguale alla durata del correlativo thread di esecuzione
dove sono stati, per l’appunto, dichiarati.
Per impiegarlo correttamente bisogna rammentare le seguenti costrizioni:
se è usato per una dichiarazione di un oggetto con un block scope, allora deve essere
obbligatoriamente presente anche lo specificatore di classe static o extern;
non può apparire come specificatore di classe per una dichiarazione o definizione di
funzione.
DETTAGLIO
Un processo è definibile come un ambiente di esecuzione all’interno del quale gira il
programma che l’ha creato. È un’unità di elaborazione, indipendente dagli altri processi,
costituita dal proprio spazio di memoria assegnato, dal proprio codice eseguibile e da
riferimenti a eventuali risorse di sistema allocate per esso. Un thread è invece definibile
come un’unità di elaborazione in cui può essere diviso un processo. Esso vive, pertanto,
all’interno di un processo dove condivide, con altri thread eventualmente presenti, le risorse,
la memoria e le informazioni di stato del processo medesimo. Un processo ha sempre un
thread di esecuzione rappresentato da se stesso, ma può avere anche altri thread creati al
suo interno per girare in parallelo.
TERMINOLOGIA
Il termine inglese thread si può tradurre in italiano come “filo”; visivamente, se immaginiamo
il processo come una fune, possiamo vedere i thread come i vari fili in esso avviluppati.
Per utilizzare questo specificatore di classe, bisogna usare un compilatore che abbia
implementato tale caratteristica e le API della programmazione concorrente in generale
come da specifica dello standard C11; nel momento in cui vengono scritte queste righe,
però, nessun compilatore, incluso GCC, ha fornito ancora il supporto.
typedef
Lo specificatore di classe typedef non stanza memoria ed è posto tra questi specificatori
solo per convenienza sintattica. Esso consente di creare identificatori che rappresentano dei
nuovi nomi per dei tipi preesistenti, ossia dei sinonimi di tipi (questi identificatori sono
denominati nomi typedef).
int main(void)
{
// number è di tipo int; MYINT ne è un sinonimo
// lo scope di MYINT è a livello di file perché è lì che è stato creato
// tale identificatore, ossia il nome typedef di int
MYINT number = 100;
typedef struct P
{
int x;
int y;
} point;
const
Il qualificatore di tipo const consente di rendere un oggetto “a sola lettura”: dopo che lo
stesso è stato inizializzato con un valore, quest’ultimo non potrà essere più cambiato.
int main(void)
{
// costante di tipo int
const int number = 10;
restrict
Il qualificatore di tipo restrict, introdotto dallo standard C99 e applicabile solo ai
puntatori, “suggerisce” al compilatore di compiere eventuali ottimizzazioni per produrre
codice più efficiente, perché si farà in modo che l’oggetto riferito dal puntatore ristretto
venga manipolato solo per il suo tramite.
int main(void)
{
// un array...
int data[] = {1, 2, 3};
// puntatore ristretto; asseriamo che solo per il suo tramite gli elementi
// di data saranno manipolati
int *restrict r_data = data;
// VALIDO
// p1 punterà a un'area di memoria Es. 0x28feb8 + 5 ossia 0x0028fecc con valore 600
// p2 punterà a un'altra area di memoria Es. 0x28feb8 con valore 100
// p1 e p2 punteranno ad aree di memoria in modo non "sovrapposto"
// quando il loop sarà eseguito;
// Es.
// p1 = 0x0028fecc - p2 = 0x0028feb8
// p1 = 0x0028fed0 - p2 = 0x0028febc
// p1 = 0x0028fed4 - p2 = 0x0028fec0
// p1 = 0x0028fed8 - p2 = 0x0028fec4
// p1 = 0x0028fedc - p2 = 0x0028fec8
// al termine values avrà questi valori
// {100, 200, 300, 400, 500, 100, 200, 300, 400, 500}
int values[] = {100, 200, 300, 400, 500, 600, 700, 800, 900, 1000};
copy(5, values + 5, values);
return (EXIT_SUCCESS);
}
// definizione di copy
// copiamo un certo numero di valori da un indirizzo di memoria a un altro
// in questo caso facciamo una "promessa" al compilatore che non vi sarà alcuna
// "sovrapposizione" di accesso agli indirizzi di memoria riferiti; ossia p2 accederà
// a un'area a cui non si accederà anche per il tramite di p1 e viceversa
// in caso di "non mantenimento della promessa" il comportamento sarà non definito...
// restrict usabile anche per i parametri
void copy(int nr, int *restrict p1, int *restrict p2)
{
while (nr-- > 0)
*p1++ = *p2++;
}
volatile
Il qualificatore di tipo volatile indica al compilatore che l’oggetto cui è applicato potrà
subire dei cambiamenti di valore da parte di “agenti esterni” al corrente programma (si
pensi a una periferica hardware che modifica in autonomia il valore di una variabile per
indicare un suo cambiamento di stato). Pertanto, nel caso, il predetto compilatore non deve
provare a eseguire eventuali ottimizzazioni sul codice quali possono essere, per esempio,
quelle di eliminare una variabile se non è usata in una statement, quelle di impiegare i veloci
registri della CPU per memorizzare valori usati frequentemente e così via.
Infatti, dato che una variabile è volatile (il suo valore può cambiare inaspettatamente),
bisogna garantire che la stessa sia utilizzabile sempre anche ai predetti agenti esterni oppure
che il valore letto sia sempre quello effettivamente da essi aggiornato. Le eventuali
ottimizzazioni eseguite potrebbero quindi impedire quell’importante accesso oppure quella
fondamentale lettura; una dichiarazione volatile, permette proprio di porre in essere questa
garanzia.
In pratica ogni lettura o scrittura di una variabile volatile deve essere sempre effettuata,
nel numero e nell’ordine cui compaiono nel relativo codice sorgente; il compilatore non
deve, dunque, riordinare tali statement per ragioni di ottimizzazione del codice.
DETTAGLIO
Il qualificatore di tipo volatile è usualmente impiegato nella scrittura di driver per i device
hardware laddove gli indirizzi di memoria utilizzati contengono dati che possono essere
modificati, in autonomia, dal sistema operativo oppure dall’hardware che gestisce tali
device.
// assegnamenti multipli; in questo caso, dato che la variabile DVD_status non viene
// modificata il compilatore potrebbe impiegare dei registri CPU per migliorare
// il suo accesso consecutivo; tuttavia, poiché DVD_status può essere cambiato anche
// dall'hardware che gestisce il DVD, questa ottimizzazione è un problema perché
// il compilatore leggerebbe il valore da un registro invece che dalla memoria principale
// e tale valore sarebbe, pertanto, non aggiornato correttamente
int stat_1 = *DVD_status;
int stat_2 = *DVD_status;
int stat_3 = *DVD_status;
int stat_4 = *DVD_status;
// current_time = *timer; // legge solo una volta il valore corrente del timer
// while (current_time < 1000) { ; /* non fa niente */ }
// perché può ritenere che sia inutile leggere ripetutamente il valore di *timer
// e assegnarlo alla variabile current_time non essendo quest'ultimo affetto da alcun
// cambiamento (loop invariant expression);
// in pratica può ritenere inefficiente dover assegnare un valore che, dal suo punto
// di vista, "è sempre lo stesso" per 1000 volte alla variabile current_time;
// a questo scopo, quindi, può ritenere che sia sufficiente leggere il valore di *timer
// solo una volta...;
// chiaramente quest'ottimizzazione è un problema sia perché causa un loop infinito sia
// perché c'è necessità di leggere, sempre, ogni millisecondo il valore cambiato
// dal timer hardware
while (current_time < 1000)
current_time = *timer;
_Atomic
Il qualificatore di tipo _Atomic, introdotto dallo standard C11 e non applicabile al tipo
array e al tipo funzione, consente di dichiarare un oggetto come di tipo atomico, ossia come
un oggetto al quale, nel contesto della programmazione concorrente, si potrà accedere solo
dal corrente thread di esecuzione; nessun altro thread potrà, cioè, accedere a un oggetto di
un tipo atomico durante un’operazione atomica.
TERMINOLOGIA
Un’operazione è definita atomica quando tutte le istruzioni che la rappresentano sono viste
come un’unica entità che può fallire o riuscire soltanto nel suo complesso, senza che
durante la loro esecuzione vi possano essere interferenze esterne da parte di altro codice.
int main(void)
{
// data è un oggetto di tipo atomico
// per lo standard la dimensione, la rappresentazione e l'allineamento di un
// tipo atomico non necessitano essere uguali a quelli del corrispondente tipo
// non qualificato
_Atomic int data;
// lo store del valore 100 nella variabile data è garantito essere un'operazione
// atomica; durante tale operazione nessun altro thread potrà accedere a data
atomic_store(&data, 100); // atomic_store è una macro definita nel file header
// <stdatomic.h>
...
}
NOTA
Per utilizzare questo qualificatore di tipo, bisogna usare un compilatore che abbia
implementato tale caratteristica, come per esempio GCC dalla versione 4.9. Ricordiamo che
questa è una conditional feature, ossia una caratteristica che un’implementazione non deve
necessariamente applicare.
Specificatori di tipo
Gli specificatori di tipo consentono di indicare in modo generale per un’entità il suo tipo
di dato, ossia la natura, il significato dei valori che potrà contenere (si pensi al tipo int che
potrà contenere valori interi oppure al tipo float che potrà contenere valori decimali) oppure
che potrà ritornare (si pensi a un valore di un certo tipo ritornato da una funzione).
In accordo con lo standard C11 i tipi sono suddivisi in object types, ossia tipi che
descrivono oggetti, e in function types, ossia tipi che descrivono funzioni.
Questi object type, in un determinato punto di un’unità di traduzione, possono poi essere:
tipi incompleti se mancano di sufficienti informazioni per determinare la dimensione di
storage del relativo oggetto (si pensi a un tipo array dichiarato con una dimensione
sconosciuta come per esempio extern double data[]; oppure a un tipo struttura o unione
dichiarati con del contenuto sconosciuto, ovvero senza l’indicazione dei propri membri,
come per esempio struct tag;); tipi completi se hanno, invece, sufficienti informazioni per
determinare lo spazio di storage del relativo oggetto.
Dagli object type e function type possono poi essere costruiti qualsiasi numero di derived
types (tipi derivati) che sono categorizzati in: array types (vettori di oggetti di un certo tipo),
structure types (strutture contenenti una sequenza di oggetti di vario tipo), union types
(unioni capaci di contenere uno fra diversi oggetti di vario tipo), function types (funzioni
che ritornano un oggetto di un determinato tipo), pointer types (puntatori a oggetti o
funzioni di un certo tipo) e atomic types (oggetti di tipo atomico)
void
Il tipo void descrive un tipo di oggetto incompleto che rappresenta un insieme vuoto di
valori. Esso è applicabile in vari contesti in cui designa una specifica semantica (per
esempio: se impiegato come tipo di ritorno di una funzione indica che la stessa non ritorna
nulla; se indicato come tipo di un puntatore indica che tale puntatore non punta, in
particolare, ad alcuno specifico tipo di dato).
// consentito!
// ptr_to_void è un puntatore a void, ossia un puntatore generico che può puntare
// a qualsiasi tipo di dato; detto in altri termini, è un puntatore che non punta
// a nulla di specifico
void *ptr_to_void;
char
Il tipo char descrive un tipo di oggetto grande abbastanza da poter memorizzare qualsiasi
carattere del set di caratteri in uso. Quando in un oggetto di tipo char è memorizzato un
carattere valido dell’insieme di caratteri del set di caratteri in uso, il suo valore è equivalente
al codice intero del carattere ed è garantito essere mai negativo.
Viceversa, se è memorizzato un carattere non valido il valore equivalente è definito
dall’implementazione.
// carattere valido:
// valore memorizzato 74 come da codice ASCII
char j = 'J';
NOTA
I tipi char, signed char e unsigned char sono tipi differenti e sono categorizzati come character
types.
short
Il tipo short descrive un tipo di oggetto che deve essere in grado di contenere dei
“piccoli” valori numerici interi senza parte frazionaria. Per C11 il range di valori da
garantire va da -32767 a 32767. Esso è categorizzato come uno standard signed integer type.
int
Il tipo int descrive un tipo di oggetto che deve essere in grado di contenere dei “normali”
valori numerici interi senza parte frazionaria e avente la grandezza naturale dell’architettura
del corrente ambiente di esecuzione (deve essere grande abbastanza da contenere qualsiasi
valore nel range da INT_MIN a INT_MAX così come definito nel file header <limits.h>). Per C11 il
range di valori da garantire va da -32767 a 32767. Esso è categorizzato come uno standard
signed integer type.
Snippet 9.25 Lo specificatore di tipo int.
// un oggetto di tipo int
// dichiarazioni equivalenti:
// signed i = INT_MAX;
// signed int i = INT_MAX;
int i = INT_MAX;
long
Il tipo long descrive un tipo di oggetto che deve essere in grado di contenere dei “grandi”
valori numerici interi senza parte frazionaria. Per C11 il range di valori da garantire va da
-2147483647 a 2147483647. Esso è categorizzato come uno standard signed integer type.
NOTA
Anteponendo lo specificatore di tipo long allo specificatore di tipo long si definisce un tipo
long long che descrive un tipo di oggetto in grado di contenere dei valori numerici interi
senza parte frazionaria “estremamente” grandi (per C11, il range di valori da garantire va da
-(263 - 1) a 263 - 1).
float
Il tipo float descrive un tipo di oggetto che deve essere in grado di contenere dei valori
numerici in virgola mobile a singola precisione. Per C11, in accordo con lo standard IEEE
754, il range di valori da garantire va da -3.4×10-38 a 3.4×1038 e la precisione deve essere di 6
cifre. Esso è categorizzato come un real floating type.
double
Il tipo double descrive un tipo di oggetto che deve essere in grado di contenere dei valori
numerici in virgola mobile a doppia precisione. Per C11, in accordo con lo standard IEEE
754, il range di valori da garantire va da -1.7×10-308 a 1.7×10308 e la precisione deve essere di
15 cifre. Esso è categorizzato come un real floating type.
NOTA
Anteponendo lo specificatore di tipo long allo specificatore di tipo double si definisce un tipo
long double che descrive un tipo di oggetto in grado di contenere dei valori numerici in virgola
mobile a precisione estesa (per C11, in accordo con lo standard IEEE 754, il range di valori
da garantire va da -1.19×10-4932 a 1.19×104932 e con una precisione di 18 cifre).
signed
Il tipo signed descrive un tipo di oggetto che deve essere in grado di contenere dei valori
numerici interi senza la parte frazionaria che possono essere anche valori negativi.
In effetti lo specificatore di tipo signed è ridondante con i tipi short, int, long e long long
perché, di default, tali tipi possono anche contenere dei numeri minori di 0; invece, si può
esplicitare con il tipo char per imporne il segno perché lo standard di C non specifica se un
tipo char di default deve essere con segno oppure senza segno.
unsigned
Il tipo unsigned descrive un tipo di oggetto che deve essere in grado di contenere dei valori
numerici interi senza la parte frazionaria che non possono essere anche valori negativi.
È applicabile ai tipi char, short, int e long che, in questo caso sono categorizzati come
standard unsigned integer types.
_Bool
Il tipo _Bool descrive un tipo di oggetto che deve essere in grado di contenere i valori 0
(per rappresentare un valore di falsità) e 1 (per rappresentare un valore di verità).
Esso è categorizzato come uno standard unsigned integer type.
NOTA
Nello standard corrente è anche previsto lo specificatore di tipo _Imaginary che è utilizzabile
per descrivere un tipo di oggetto che deve essere in grado di contenere un valore che è la
parte immaginaria di un numero complesso. Tale specificatore di tipo è usabile come
specificatore di tipo _Complex, ossia si aggiunge dopo il tipo float, double o long double;
possiamo avere quindi i tipi: _Imaginary float, _Imaginary double e _Imaginary long double. I tipi
immaginari sono una feature condizionale, e un’implementazione può scegliere di non
supportarli. I tipi immaginari unitamente ai tipi real floating e ai tipi complex sono
categorizzati come floating types.
_Atomic
Il tipo _Atomic, introdotto dallo standard C11, descrive un tipo di oggetto atomico. Esso si
utilizza scrivendo la relativa keyword e una coppia di parentesi tonde ( ) al cui interno si
indica il nome del tipo che dovrà essere “modificato” in modo che il relativo oggetto
dichiarato sia trattato in modo atomico (Sintassi 9.2).
I tipi atomici sono una feature condizionale che un’implementazione può non supportare.
NOTA
Per utilizzare questo specificatore di tipo, bisogna usare un compilatore che abbia
implementato tale caratteristica come, per esempio GCC dalla versione 4.9. Ricordiamo che
questa è una conditional feature, ovvero una caratteristica che un’implementazione non
deve necessariamente implementare.
struct
Il tipo struct descrive un tipo di oggetto che è di tipo struttura, ossia un tipo che è
costituito da un insieme di oggetti, detti membri, allocati sequenzialmente e nell’ordine in
cui sono stati dichiarati. Per lo standard un tipo struct è un derived type chiamato anche
aggregate type (tipo aggregato).
union
Il tipo union descrive un tipo di oggetto che è di tipo unione, ossia un tipo che è costituito
da un insieme di oggetti (membri), allocati in modo sovrapposto.
Per lo standard un tipo union è un derived type ma non un aggregate type, perché un
oggetto di tipo unione può contenere, validamente, un solo membro alla volta.
typedef-name
Il tipo typedef-name descrive un tipo di oggetto che è di tipo typedef-name, dove typedef-
name è un nome di tipo definito mediante lo specificatore della classe di memorizzazione
typedef.
inline
Lo specificatore di funzione inline, introdotto con lo standard C99, suggerisce al
compilatore di porre in essere, eventualmente, delle ottimizzazioni al fine di rendere la
chiamata alla relativa funzione il più veloce ed efficiente possibile.
Una funzione inline può avere linkage interno (si utilizza lo specificatore della classe di
memorizzazione static) oppure linkage esterno, e in quest’ultimo caso vi sono le seguenti
restrizioni.
Deve essere definita nella stessa unità di traduzione e allora tale definizione diventa
una inline definition (definizione inline).
Non può essere comunque invocata da altre funzioni in altre unità di traduzione anche
se il linkage esterno, per semantica, ne permetterebbe una referenziazione. In pratica
una definizione inline non è una definizione esterna per la relativa funzione. Come
corollario si ha che non vi è alcun divieto nello scrivere una definizione esterna della
stessa funzione in un’altra unità di traduzione. In questo caso il compilatore, quando
dovrà invocare la relativa funzione, potrà scegliere se usare le istruzioni della
definizione inline oppure quelle della definizione esterna.
Non deve contenere una definizione di un oggetto con lo specificatore della classe di
memorizzazione static.
Non deve contenere riferimenti a oggetti con linkage interno.
return i * ratio;
}
int main(void)
{
double cels = 28;
return (EXIT_SUCCESS);
}
_Noreturn
Lo specificatore di funzione _Noreturn, introdotto dallo standard C11, indica che una
funzione, al termine del suo processo elaborativo, non ritornerà il controllo del flusso di
esecuzione del codice alla funzione che l’ha chiamata.
L’OPERATORE _ALIGNOF
L’operatore _Alignof (Sintassi 9.3), disponibile a partire da C11, consente di ottenere il
requisito di allineamento del suo operando, che è il nome di un tipo, ossia ritorna un valore
intero costante (che deve essere una potenza intera non negativa di 2) che indica il numero di
byte tra indirizzi consecutivi dove saranno memorizzati i valori di quel tipo; detto in altri
termini, il tipo di dato sarà memorizzato a indirizzi di memoria che saranno multipli del valore
ritornato da _Alignof.
TERMINOLOGIA
Un numero è un multiplo di un altro se la divisione del primo per il secondo dà come resto 0;
ovvero, detto in termini più formali, un numero intero j è un multiplo di un altro numero
intero k se vi è un altro numero intero l tale che l * k dà come risultato j. Per esempio, 8 (j)
è multiplo di 2 (k) perché vi è un altro numero, 4 (l), per cui 4 (l) * 2 (k) = 8 (j).
// 2 byte; per un tipo array il valore di allineamento ritornato sarà quello del tipo
// del suo elemento; nel nostro caso il tipo è short
// NOTA: _Alignof non valuterà tipi funzione e tipi incompleti
size_t ar_3 = _Alignof(short [3]); // 2
_Alignas
Lo specificatore di allineamento _Alignas (Sintassi 9.4), introdotto con lo standard C11,
consente di indicare per un oggetto un determinato valore di allineamento, definito dallo
standard come stricter alignment, che può derivare da quello relativo a un determinato tipo
oppure dalla valutazione di un’espressione costante intera.
Sintassi 9.4 Lo specificatore di allineamento _Alignas.
_Alignas(type_identifier | integer_constant_expression) object;
Il valore dell’espressione costante intera dovrà ritornare un intero che dovrà essere un
valido valore di allineamento indicato dallo standard con il termine di fundamental
alignment, oppure un intero che dovrà essere un valore di allineamento supportato dalla
corrente implementazione indicato dallo standard con il termine di extended alignment,
oppure il valore 0. In ogni caso il valore di allineamento specificato non dovrà essere
inferiore rispetto all’allineamento normalmente richiesto per il tipo da allineare.
TERMINOLOGIA
Per fundamental alignment lo standard intende un valore di allineamento che è minore o
uguale al più grande valore di allineamento supportato dall’implementazione corrente e che
è uguale a quello ritornato da _Alignof (max_align_t). Per extended alignment lo standard
intende un valore di allineamento che è più grande del più grande valore di allineamento
supportato dall’implementazione corrente e che è uguale a quello ritornato da _Alignof
(max_align_t). In quest’ultimo caso è definito dall’implementazione corrente se supportare o
meno tale allineamento. Precisiamo che max_align_t è un nome di tipo (typedef di una struct)
// nessun effetto...
_Alignas(0) int i2 = 10;
Inizializzazioni
Un’inizializzazione è un’operazione tramite la quale si fornisce a un oggetto un valore
iniziale. È attuabile utilizzando, nell’ambito di una dichiarazione, il simbolo uguale = posto
subito dopo il dichiaratore, e un apposito inizializzatore o più inizializzatori, se tale oggetto
è un tipo aggregato o un tipo unione, posto dopo il predetto simbolo.
TERMINOLOGIA
In C inizializzazione e assegnamento non sono la stessa cosa; l’inizializzazione è
un’operazione che avviene nel momento della dichiarazione di un oggetto, ossia quando
deve essere creato, e ne fornisce un valore iniziale in modo esplicito oppure in modo
implicito (al termine di quest’operazione l’oggetto sarà stato allocato in memoria e conterrà
quel valore di inizializzazione); l’assegnamento è un’operazione che avviene dopo che un
oggetto è stato già creato (allocato in memoria) e ne sovrascrive il valore lì contenuto.
Un inizializzatore per un oggetto di tipo scalare (per esempio una variabile data di tipo
int) è rappresentato da una singola espressione, opzionalmente racchiusa tra le parentesi
graffe { }, che fornisce un valore inziale per tale oggetto eventualmente convertito nel suo
tipo se di tipo diverso (le regole di conversione adottate sono le stesse già viste per le
operazioni di assegnamento).
Un inizializzatore per un oggetto di tipo aggregato (per esempio una struttura o un array)
o di tipo unione è, invece, rappresentato da un lista di inizializzatori separati dal carattere
virgola , e racchiusi tra le parentesi graffe { }, che forniscono dei valori iniziali per i
membri o gli elementi di tale oggetto eventualmente convertiti nel suo tipo se di tipo
diverso (le regole di conversione adottate sono le stesse già viste per le operazioni di
assegnamento). In quest’ultimo caso, un tipo di una struttura o di un’unione che ha una
durata automatica in memoria può altresì essere inizializzato con un’espressione che ritorna
un oggetto del medesimo tipo di struttura o di unione.
Abbiamo poi le seguenti regole applicabili a seconda che un oggetto abbia una durata
automatica o statica in memoria.
Se un oggetto ha una durata automatica in memoria, e non si fornisce durante la fase di
inizializzazione un esplicito valore, allora sarà indeterminato, ossia potrà contenere
qualsiasi cosa (per esempio, se abbiamo data che è un oggetto automatico di tipo int
potrà contenere valori come 0, -44, 2000 e così via in modo del tutto casuale e dunque
qualsiasi valore). Se invece si fornisce un esplicito valore di inizializzazione, lo stesso
potrà essere fornito da espressioni non necessariamente costanti (per esempio come
valore ricavato dalla valutazione di un variabile).
Se un oggetto ha una durata statica in memoria, e non si fornisce durante la fase di
inizializzazione un esplicito valore, allora sarà: per un tipo puntatore, un puntatore
nullo; per un tipo aritmetico, uno 0; per un tipo aggregato, uno 0 per ciascun membro o
elemento; per un tipo unione, uno 0 per il suo primo membro. Se invece si fornisce un
esplicito valore di inizializzazione, lo stesso dovrà essere fornito da espressioni
necessariamente costanti (per esempio come valore ricavato dalla valutazione di una
direttiva #define).
TERMINOLOGIA
Per C i tipi aritmetici (arithmetic types) sono rappresentati, collettivamente, dai tipi interi
(integer types) e dai tipi in virgola mobile (floating types).
// ok inizializzazione consentita
// ext_b, che ha una durata statica in memoria, può essere inizializzata con
// il valore di VALUE perché, essendo una macro, è considerata un'espressione costante
int ext_b = VALUE;
int main(void)
{
// a, che è un oggetto scalare, è inizializzato con il valore 2
int a = 2;
int main(void)
{
// affermi che un char ha un allineamento di 1 byte?
// se sì, allora non interrompere la compilazione
// se no, allora interrompi la compilazione
_Static_assert(_Alignof(char) == 1, "Alignment of a char must be 1!");
struct time_T
{
char UTC;
int h;
int m;
int s;
};
NOTA
Un’asserzione statica è per lo standard una dichiarazione; pertanto, come evidenzia il
codice dello Snippet 9.42, può essere usata sia a livello di file scope sia a livello di block
scope.
Capitolo 10
Il preprocessore
Il linguaggio C, come visto in più occasioni, ha delle caratteristiche che lo rendono unico
e “speciale” rispetto ad altri linguaggi di programmazione mainstream.
Tra queste, vi è sicuramente la presenza di un tool, denominato preprocessore di C
(abbreviato come cpp, che sta per C preprocessor), che compie delle operazioni preliminari
di “modifica” del codice sorgente in base a degli appositi comandi, scritti con una
particolare sintassi e denominati direttive.
Al termine dell’esecuzione di queste operazioni, effettuate prima della fase di
compilazione vera e propria, il sorgente così modificato viene processato dal compilatore
stesso, durante la fase di compilazione, per produrre il relativo codice oggetto.
In pratica, possiamo dire che l’input del preprocessore, strumento che in alcune
implementazioni dei compilatori è parte del compilatore mentre in altre è un programma a
se stante automaticamente invocato, è un file di codice C contenente delle direttive che lo
stesso comprende ed esegue e poi al termine della fase di preprocessing rimuove dal
sorgente stesso.
Nel contempo, l’output del preprocessore è un altro file di codice C, modificato in
accordo con quanto indicato dalla direttive e senza di esse, che viene preso come input dal
compilatore il quale compie la consueta operazione di compilazione.
TERMINOLOGIA
Un set di caratteri (character set) nell’ambito di C indica quell’insieme di caratteri che può
essere validamente usato nella scrittura di un sorgente, definito dallo standard come source
character set (set di caratteri del codice sorgente o di origine), oppure che può essere
validamente interpretato ed è dunque disponibile durante l’esecuzione di programma ed è
definito dallo standard come execution character set (set di caratteri di esecuzione).
Ciascuno dei due set di caratteri è poi diviso in un basic character set (set di caratteri di
base) i cui membri sono indicati nella Tabella 10.1, e in 0 o più membri specifici del corrente
linguaggio locale definiti come extended characters (caratteri estesi). Inoltre, il set di
caratteri di base unitamente ai caratteri estesi disponibili è definito extended character set
(set di caratteri estesi).
Tabella 10.1 Membri appartenenti al set di carattere di base in accordo con lo standard C11.
Cifre
Lettere maiuscole e minuscole dell’alfabeto latino Caratteri grafici
decimali
ABCDEFGHIJKLMNOPQRSTUVWXYZ 01234 !“#%&‘()*+,-./:;
abcdefghijklmnopqrstuvwxyz 56789 <=>?[\]^_{|}~
NOTA
Ai membri della Tabella 10.1 vanno aggiunti il carattere di spazio (space character) e i
caratteri di controllo rappresentanti: la tabulazione orizzontale (horizontal tab); la
tabulazione verticale (vertical tab); il salto o avanzamento di pagina (form feed); il segnale
d’allerta (alert); la cancellazione dell’ultimo carattere (backspace); il ritorno del carrello
(carriage return) e l’avanzamento all’inizio della riga successiva (new line). Infine va anche
aggiunto il carattere nullo (null character) utilizzato come marcatore di terminazione di una
stringa di caratteri.
#define SIZE 5
int main(void)
{
// due righe "fisiche" separate dal carattere \ e new line
printf("Un programma che mostra come effettuare un ciclo che\
consente di scansionare\nogni elemento di un determinato array!!!\n");
// un array
int data[SIZE] = {1, 2, 3, 4, NR};
printf("]\n");
return (EXIT_SUCCESS);
}
Il Listato 10.1 è un semplice programma in C utile solo per dimostrare cosa avviene
durante le fasi di traduzione citate. Ne mostriamo alcune significative; per esempio:
in accordo con la fase 1, la sequenza triplice ??= è sostituita con il carattere #;
in accordo con la fase 2, le due righe fisiche in cui è divisa la prima istruzione printf,
posta subito dopo il main, vengono congiunte per formare una sola riga logica; avremo
qualcosa come printf("Un programma che mostra come effettuare un ciclo che consente di
valori: [ ";
in accordo con la fase 7, vengono identificati i token regolari int, for e così via;
in accordo con la fase 8, viene prodotto in ambiente GNU/Linux il file eseguibile
PreprocessingDirectives (o PreprocessingDirectives.exe in ambiente Windows).
Infine mostriamo un esempio dell’output del preprocessore cpp così come è espresso
grazie all’invocazione del comando gcc con il flag -E (Shell 10.1), mantenendo nella
funzione main la spaziatura relativa.
Shell 10.1 Utilizzo di gcc per generare il file di output del preprocessore.
gcc -std=c11 -E PreprocessingDirectives.c -o PreprocessingDirectives.i
printf("Un programma che mostra come effettuare un ciclo che consente di scansionare\nogni
elemento di un determinato array!!!\n");
printf("]\n");
return (0);
}
NOTA
L’Output 10.2 mostra il risultato delle fasi di traduzione dalla 1 alla 4. Non vedremo, quindi,
ciò che accadrà nelle altre fasi, come per esempio nella fase 6, dove i letterali stringa
adiacenti saranno concatenati.
Concetti preliminari
Una direttiva del preprocessore è rappresentata da una sequenza di elementi lessicali
minimi (detti token pre-elaborazione), laddove per la loro corretta scrittura si devono e/o si
possono soddisfare determinati requisiti.
Il primo token della sequenza deve essere il carattere cancelletto # che può essere posto
come primo carattere oppure come carattere preceduto da caratteri di spaziatura.
Il successivo token deve essere la keyword o il nome della direttiva (per esempio
define, include e così via) che può essere anche preceduto da caratteri di spaziatura (per
Tra tutti i token che rappresentano nel complesso una direttiva del preprocessore può
esservi qualsiasi numero di caratteri di spaziatura arbitrari (per esempio è valido
scrivere qualcosa come # define SIZE 10).
L’ultimo token della sequenza può essere un carattere di new line, e allora esso
marcherà la terminazione della direttiva stessa. È comunque possibile scrivere i token
di una direttiva su più righe di testo utilizzando il carattere backslash \ e il carattere
new line. Questa tecnica è spesso utilizzata per consentire a una direttiva
particolarmente lunga di continuare su più righe di testo e rendere più leggibile la sua
strutturazione.
Infine, per completezza di trattazione è utile dire che: una direttiva del preprocessore può
essere posta dovunque all’interno del codice sorgente anche se le direttive #include e #define
sono usualmente poste all’inizio del predetto codice; è possibile usare i consueti commenti
per esplicitare la semantica di una direttiva.
Definizione di macro
Una macro, denominata anche macroistruzione, è una sequenza arbitraria di token cui
viene attribuito un nome che, quando utilizzato nell’ambito del codice sorgente, viene
sostituito dal preprocessore da quella esatta sequenza di token.
Detto in termini più pratici, una macro altro non è che un “frammento” di testo con un
identificatore che viene inserito nel codice sorgente nell’esatto punto dove è posto tale
identificatore che lo denomina, che viene a tal fine eliminato e dunque rimpiazzato.
Object-like macro
Una macro definita nella forma della Sintassi 10.1 è denominata dallo standard come
object-like macro (macro simile a oggetto) ed è utilizzata, comunemente, per creare dei
nomi simbolici che rappresentano delle costanti numeriche, carattere o stringa.
Questa tipologia di macro, riferita talune volte anche come macro semplice, è denominata
object-like macro perché ricorda la definizione di un qualsiasi oggetto del linguaggio come
può essere, per esempio, la definizione di una variabile che è caratterizzata, oltre che da un
tipo, da un identificatore e da un valore associato.
La Sintassi 10.1 evidenzia che una macro semplice si definisce utilizzando, nell’ordine: il
carattere cancelletto #, il nome define, un nome per la macro e una lista di token.
Il simbolo cancelletto # e la keyword define rappresentano in modo congiunto la direttiva
del preprocessore #define, mentre per macro_identifier il nome indicato deve seguire le stesse
regole di scrittura dei comuni identificatori utilizzati per designare le entità del linguaggio,
come gli oggetti, le funzioni, i tag delle strutture e così via; ciò significa, per esempio, che
tale nome non può essere separato da caratteri di spaziatura, deve contenere solo lettere,
numeri, il carattere underscore _ e via discorrendo.
Invece, token_replacement_list, è rappresentato da una sequenza di token pre-elaborazione
quali identificatori, costanti numeriche, costanti carattere, letterali stringa, segni di
punteggiatura e così via.
TERMINOLOGIA
In accordo con lo standard di C, fa parte dei token pre-elaborazione, ed è dunque
utilizzabile con la direttiva #define, anche quello definito preprocessing number, che è un
token rappresentato da un numero, opzionalmente preceduto dal carattere punto (.), che
può essere seguito da un qualsiasi altro numero, carattere e sequenza di caratteri e+, e-, E+,
E-, p+, p-, P+, o P-. Ciò implica che è valido sia un comune numero intero come 100 o in
virgola mobile come 4.5 sia un “numero” come .314E+1 o 1a2b.
Ciò detto avremo che, durante la fase 4 di traduzione del codice, il preprocessore, quando
nel codice sorgente troverà ogni occorrenza di macro_identifier la sostituirà con quanto
espresso da token_replacement_list.
TERMINOLOGIA
L’operazione con cui il preprocessore sostituisce ogni occorrenza di un nome di una macro
con l’equivalente lista di token è anche detta espansione della macro (macro expansion).
// per questa macro tutti gli spazi dopo il nome della macro e dopo la cifra 4
// non sono considerati come parte dell'espansione; allo stesso modo tutti i
// caratteri extra tra la cifra 0 il carattere * e tra lo stesso carattere * e la cifra 4
// non sono considerati, ossia è considerato solo un carattere di spazio di separazione;
// ciò significa che in un sorgente dove avremo qualcosa come int a = SIZE; la stessa
// sarà presentata al compilatore come int a = 10 * 4;
#define SIZE 10 * 4 /* una macro semplice ... */
// ridefinizione consentita!
#define TWO 10
#define TWO 10
Lo Snippet 10.1 evidenzia la scrittura di una serie di macro semplici tutte effettuate
seguendo sempre lo stesso pattern che è caratterizzato dall’indicazione della direttiva
#define, dal nome della macro e da una lista di token di sostituzione.
Di tutte le macro è interessante notare quelle denominate ONE e quelle denominate TWO. Nel
primo caso un compilatore come GCC ci avvisa che abbiamo ridefinito una macro; in
questo contesto, per ridefinizione si intende quell’operazione per cui si definiscono due o
più macro con lo stesso nome ma con una lista di token differente.
In ogni caso, lo standard di C chiarisce la semantica del processo di ridefinizione di una
macro asserendo che la stessa è valida solo se le liste di sostituzioni di due o più macro con
lo stesso nome sono perfettamente uguali, ossia sono considerate identiche.
Nel secondo caso, infatti, non avremo alcun avviso da parte del compilatore perché le due
macro TWO, quantunque abbiano lo stesso nome, hanno altresì una lista di token uguali
laddove l’uguaglianza è verificata in merito a quali sono questi token, al loro numero e al
loro ordine di scrittura (per esempio, i token della lista di sostituzione delle macro
denominate SIX non sono uguali perché la prima definizione ha una lista di sostituzione con
tre token ossia 3, * e 2, la seconda definizione ha una lista di sostituzione con un token ossia
3*2 e la terza definizione ha una lista di sostituzione con 3 token che, seppur uguali a quelli
della prima, sono scritti in ordine diverso ossia 2, * e 3).
NOTA
Il compilatore GCC, in caso di ridefinizione di più macro, si limiterà a emettere degli appositi
warning e utilizzerà, in caso di espansione delle stesse, la lista di token dell’ultima
definizione. Altri compilatori potrebbero però segnalare tali ridefinizioni come errore. La
morale: seguire ciò che dice lo standard di C ed evitare di ridefinire le macro.
int main(void)
{
printf("%s\n", MSG); // espande MSG con l'equivalente letterale stringa
PRINT_SIZE; // espande PRINT_SIZE con gli equivalenti token
return (EXIT_SUCCESS);
}
void foo(void)
{
// qui NR sarà visibile ed espansa con 10
int nr = NR;
}
Function-like macro
Una macro definita nella forma della Sintassi 10.2 è denominata dallo standard come
function-like macro (macro simile a funzione) ed è utilizzata, comunemente, per creare delle
“piccole funzioni” deputate a svolgere semplici elaborazioni.
Questa tipologia di macro, riferita talune volte anche come macro parametrica o macro
con argomenti, è denominata function-like macro sia perché la sua definizione ricorda
quella di una comune funzione del linguaggio – dove è cioè presente un nome, una coppia
di parentesi tonde ( ), una lista di parametri formali e un corpo di istruzioni – sia anche
perché il suo utilizzo è sintatticamente uguale a quello delle comuni funzioni, ossia si scrive
il suo nome e una coppia di parentesi tonde ( ) al cui interno si inseriscono gli eventuali
argomenti.
int main(void)
{
printf("Di seguito il risultato dell'invocazione di alcune macro parametriche:");
nl(); /* nl sarà espansa come printf("\n") */
return (EXIT_SUCCESS);
}
Il Listato 10.3 definisce la serie di macro parametriche max, cube, c_print, i_print e nl che
si comportano come se fossero delle funzioni che, rispettivamente, ritornano il massimo tra
due numeri, ritornano il cubo di un numero, visualizzano un carattere e inviano un carattere
di new line, visualizzano un intero e inviano un carattere di new line, inviano un carattere di
new line.
Per comprendere come avviene la sostituzione di una macro parametrica possiamo
prendere come esempio quella denominata max: gli identificatori x e y posti tra le parentesi
tonde ( ) di definizione sono “replicati” nell’ambito del corpo delle istruzioni che ne
rappresenta sia il processo elaborativo sia la lista di sostituzione.
Dunque, quando durante la fase 4 di traduzione il preprocessore troverà nell’ambito della
funzione main la definizione max(j, p), la cancellerà e la sostituirà con la sua replacement list
laddove tutte le occorrenze dell’identificatore del parametro x saranno sostituite con
l’identificatore j e tutte le occorrenze dell’identificatore del parametro y saranno sostituite
con l’identificatore p.
In definitiva la chiave di scrittura di una macro parametrica è la seguente: gli argomenti
forniti all’atto della sua invocazione saranno sempre sostituiti ai corrispondenti parametri e
ne prenderanno, quindi, il loro posto.
Scorrendo ulteriormente il sorgente del Listato 10.3 notiamo altresì come, nell’ambito
delle liste di sostituzione, gli identificatori dei parametri delle macro max e cube siano stati
posti tra una coppia di parentesi tonde ( ) e lo stesso è stato fatto per tutte le liste di
sostituzione ossia sono state esse stesse racchiuse tra tali parentesi.
Per comprenderne la motivazione vediamo cosa accadrebbe se definissimo la macro
parametrica cube come nello Snippet 10.2, ossia senza quelle parentesi tonde, e la usassimo
poi per calcolare il cubo di valore dato da un’espressione come, per esempio, j + 3.
int main(void)
{
int j = 10;
Dopodiché, tale espressione verrà valutata dal compilatore, il quale, in accordo con le
consuete regole di precedenza e associatività, produrrà come valore 73 (in pratica saranno
prima eseguite le moltiplicazioni tra 3 e j e poi le addizioni tra quei risultati e i rimanenti
operandi). Chiaramente quello non era il risultato atteso perché noi avremmo voluto avere la
computazione del cubo di j + 3 che avrebbe dovuto dare come valore 2197 che è, per
l’appunto, il cubo di 13 laddove 13 è il risultato di j + 3 dove j vale 10.
Ecco quindi l’importanza delle parentesi tonde ( ) poste attorno ai parametri di una lista
di sostituzione; esse consentono di rispettare l’ordine di valutazione desiderato e atteso.
Ancora, vediamo lo Snippet 10.3 che mostra cosa accadrebbe se definissimo la macro
parametrica cube senza le parentesi tonde che racchiudono tutta la relativa lista di
sostituzione e la usassimo per calcolare un valore che è il risultato di un altro valore diviso
per il cubo di un altro valore.
int main(void)
{
int j = 10;
int val = 10000;
In questo caso senza parentesi il preprocessore sostituirà cube(j) con (j) * (j) * (j).
Dopodiché, tale espressione verrà valutata dal compilatore unitamente a val e all’operatore
di divisione /, il quale, in accordo con le consuete regole di precedenza e associatività,
produrrà come valore 100000 (in pratica sarà prima eseguita la divisione tra val e la prima
occorrenza di j e poi le moltiplicazioni tra quel risultato e i valori espressi dalle altre due
occorrenze di j). Chiaramente, anche qui, quello non era il risultato atteso perché noi
avremmo voluto avere la computazione del valore di val diviso per il cubo di j che avrebbe
dovuto dare come valore 10 risultante, per l’appunto, dal valore di val, che vale 10000, diviso
per il cubo di j che è 1000 perché j vale 10.
Vediamo, inoltre, un altro esempio di codice (Snippet 10.4) che evidenzia un’ulteriore
problematica legata, questa volta, a un utilizzo di una macro parametrica con un argomento
che produce un side-effect.
Snippet 10.4 Risultati non voluti prodotti da valutazioni di argomenti che producono side-effect.
...
#define max(x, y) ((x) > (y) ? (x) : (y)) /* calcola il massimo tra due numeri */
int main(void)
{
int j = 100, p = 50;
// j_val conterrà 102 perché j è stato valutato 2 volte al termine della valutazione
// di tutta la full expression che ricordiamo marca un sequence point
int j_val = j;
...
}
In pratica quando la macro parametrica max viene invocata come max(j++, p) il compilatore
si trova ad analizzare l’espressione ((j++) > (p) ? (j++) : (p)), conseguenza dell’espansione
effettuata dal preprocessore, dove è evidente come l’argomento j viene incrementato di
un’unità per ben due volte; la prima volta durante la valutazione del primo operando
dell’operatore condizionale, ossia (j++) > (p), e la seconda volta durante la valutazione del
secondo operando dello stesso operatore ossia (j++). Quanto detto, è poi dimostrato dalla
successiva operazione di assegnamento dove il valore della variabile j_val è 102 ossia quanto
contenuto nella predetta variabile j.
Anche in questo caso quello che avremmo voluto era semplicemente che m avesse
contenuto il valore più grande tra j e p, senza però che durante quella valutazione il valore
di j fosse incrementato. Allo stesso tempo, comunque, per effetto dell’operatore di
incremento postfisso ++ il valore di j avrebbe potuto contenere il valore 101 che sarebbe stato
assegnato lecitamente alla variabile j_val.
Quanto indicato è proprio il risultato che sarebbe stato garantito se avessimo definito max
non come una macro parametrica ma come una normale funzione.
Snippet 10.5 Correttezza di risultato in caso max fosse una normale funzione.
...
// prototipo di max
int max(int x, int y);
int main(void)
{
int j = 100, p = 50;
// definizione di max
int max(int x, int y)
{
return x > y ? x : y;
}
CURIOSITÀ
Rispetto alla convenzione di scrittura evidenziata in precedenza per le macro semplici, che
è in modo abbastanza uniforme accettato dalla comunità di programmatori in C, quella
adottata per la scrittura delle macro parametriche è invece divergente. Alcuni, cioè,
preferiscono scrivere gli identificatori delle macro parametriche con le lettere tutte in
maiuscolo, mentre altri preferiscono adottare lo stile adottato nel già citato libro sul C di
Kernighan e Ritchie, dove gli identificatori delle macro parametriche sono scritti con le
lettere tutte in minuscolo.
// prototipo di foo
void foo(void);
int main(void)
{
int a = 10;
int b = 11;
foo();
return (EXIT_SUCCESS);
}
// definizione di foo
void foo(void)
{
int x = 100, y = 101, z = 102;
Il Listato 10.4 definisce la macro parametrica debug in modo che possa accettare un
numero variabile di argomenti e che abbia come scopo quello di stampare a video il nome
della corrente funzione di esecuzione, tramite l’identificatore predefinito __func__, e il nome
di un numero arbitrario di variabili unitamente al loro valore.
In ambedue i casi, nell’ambito della lista di sostituzione, utilizziamo l’istruzione printf
che è essa stessa definita come una funzione in grado di accettare un numero variabile di
argomenti e, soprattutto nel secondo caso, si presta bene all’impiego dell’identificatore
__VA_ARGS__ che gli può fornire, per l’appunto, un qualsiasi numero di argomenti.
Per esempio, quando nella funzione main verrà invocata la macro parametrica debug, tutti i
suoi argomenti, ossia il letterale stringa, l’identificatore della variabile a e l’identificatore
della variabile b, unitamente al carattere virgola (,) di separazione, sostituiranno nella
seconda printf posta all’interno del do/while l’identificatore __VA_ARGS__.
DETTAGLIO
L’identificare __func__ è stato introdotto con lo standard C99 e ogni implementazione ne
deve perciò fornire una dichiarazione implicita. In pratica, esso deve essere dichiarato come
se ogni funzione avesse come prima istruzione del suo body qualcosa come static const
char __func__[] = "function_name"; dove function_name è il nome della relativa funzione.
ATTENZIONE
L’identificatore __func__ non è in alcun modo correlato al preprocessore. Infatti durante la
fase 4 di traduzione tale identificatore non viene espanso con il nome della corrente
funzione.
int main(void)
{
int a = 10, b = 11;
Lo Snippet 10.6 definisce la macro parametrica min che ritorna il più piccolo tra due
valori. La funzione main dichiara quindi le variabili a e b da comparare e invoca la macro min
più volte e in modo differente in quanto a indicazione dei relativi argomenti.
Tutte quelle invocazioni di min, però, presentano dei problemi di invalidità rilevate sia dal
compilatore (è il caso della prima, seconda e quarta invocazione dove le macro, una volta
espanse, mostrano dei problemi sintattici) sia dal preprocessore (è il caso della terza
invocazione dove l’espansione non viene neppure elaborata, sempre per problemi sintattici,
e la compilazione si ferma durante la fase 4 di traduzione).
In pratica, per il preprocessore non sarà mai considerato un errore l’omissione di uno o
più argomenti durante un’invocazione di una macro parametrica ma solo se si scriverà
l’esatta quantità di virgole di separazione tra di essi; per esempio, per una macro con due
argomenti bisognerà indicare una sola virgola, per una macro con tre argomenti bisognerà
indicare due virgole e così via per gli altri casi.
NOTA
Se la macro accetta un solo argomento è possibile ometterlo scrivendo l’identificatore della
macro e le parentesi tonde ( ) di invocazione vuote. Così, se è definita una macro
parametrica come #define QUAL(q) q sarà possibile scrivere, in modo valido, qualcosa come
QUAL().
e così via).
Allo stesso tempo le macro parametriche hanno anche, sempre rispetto alle normali
funzioni, i seguenti svantaggi.
Possono provocare un aumento di dimensione del codice sorgente e di conseguenza del
codice compilato. Ciò è tanto più evidente se si utilizzano molte macro parametriche
quando le relative liste di sostituzione sono inserite inline in molti punti del codice
sorgente.
Non vi è un type checking degli argomenti e neppure un’eventuale conversione verso i
tipi dei rispettivi parametri. Quest’assenza di controllo sui tipi può chiaramente
provocare dei problemi soprattutto se i tipi utilizzati sono differenti (si pensi alla macro
min e alla possibilità di passare come argomenti un tipo int e un tipo array i cui
elementi sono di tipo char).
Gli argomenti possono essere valutati più di una volta causando ogni tanto dei
comportamenti non attesi soprattutto in presenza di side-effect (vedere, a tal proposito,
lo Snippet 10.4 presentato in precedenza).
In buona sostanza, prima di decidere se usare una macro parametrica rispetto a una
normale funzione bisogna sempre valutare quali sono gli obiettivi che vogliamo raggiungere
e se i relativi svantaggi sono accettabili per il particolare processo computazionale che
intendiamo rappresentare e dunque codificare.
NOTA
Ricordiamo che da C99 è possibile utilizzare le funzioni inline per scrivere “piccole” funzioni
che consentono di evitare il consueto overhead legato all’invocazione di normali funzioni,
rendendole preferibili rispetto alle macro parametriche. In ogni caso, prima di decidere se
privilegiare una funzione inline rispetto a una macro parametrica, rammentiamo che bisogna
tener presente che il comportamento di un compilatore rispetto a una funzione inline è
implementation-defined, ossia può tanto decidere di “espandere” inline il codice che è parte
del suo body quanto decidere di non far nulla.
La direttiva #undef
La direttiva del preprocessore #undef (Sintassi 10.4) permette di rimuovere una
definizione di una macro, sia semplice sia parametrica, ossia provoca l’annullamento del
suo identificatore che non può, quindi, dal quel punto in poi, essere più utilizzato (tranne,
chiaramente, se non se ne fornisce un’altra definizione).
In genere la direttiva #undef si usa sia per rimuovere una definizione di una macro già
esistente per la quale si desidera dare una nuova definizione (ricordiamo che per lo standard
è lecito ridefinire una macro solo se le liste di sostituzione sono uguali) sia per garantire che
un certo nome denoti un identificatore di una funzione piuttosto che quello di una macro
parametrica (Snippet 10.7).
int main(void)
{
char a = '8';
char b = 'a';
NOTA
Se si utilizza la direttiva #undef con un identificatore non definito oppure che non rappresenta
una macro, non accadrà nulla: la direttiva sarà senza effetto e il preprocessore non
genererà alcun avviso diagnostico.
L’operatore hash #
È possibile utilizzare nell’ambito di una lista di sostituzione di una macro parametrica lo
speciale operatore del preprocessore avente come simbolo il carattere hash #.
Quest’operatore applicato in modo prefisso all’identificatore di un parametro della macro
fa sì che del corrispettivo argomento venga creato un letterale stringa, e tale letterale venga
inserito al posto del predetto parametro.
TERMINOLOGIA
Il processo di creazione di un letterale stringa di un argomento di una macro è definito dallo
standard come stringizing.
int main(void)
{
int a = 100;
int b = 200;
IMPORTANTE
Se come argomento di una macro parametrica che usa l’operatore # non forniamo nulla
(argomento vuoto), allora il risultato sarà una stringa vuota "". Per esempio, se definiamo la
macro #define P(p) #p e poi la invochiamo come in char eS[] = P();, tale istruzione sarà
presentata al compilatore come char eS[] = "";.
int main(void)
{
int a = 10, b = 11;
int min_1 = intMin(a, b); // 10
Lo Snippet 10.9 mostra un interessante esempio di una macro parametrica, makeMin, che è
capace di creare delle definizioni di funzioni ciascuna con un proprio identificatore e
ciascuna deputata a ritornare un risultato che è il valore minimo tra due valori laddove ogni
valore è di uno specifico tipo di dato.
Al fine di far generare un identificatore sempre diverso per ogni definizione di funzione
creata, la macro makeMin usa, nell’ambito della lista di sostituzione, l’operatore ## con i token
type e Min, dove type è l’identificatore di un parametro mentre Min è una serie arbitraria di
caratteri.
Ciò implicherà che, quando per esempio sarà invocata la macro makeMin(int), l’argomento
int sarà sostituito al parametro type e poi sarà concatenato con Min per formare il token
intMin. Lo stesso procedimento avverrà con l’invocazione di makeMin(double), dove al termine
dell’operazione di pasting si avrà il token doubleMin.
IMPORTANTE
Se come argomento di una macro parametrica che usa l’operatore ## non forniamo nulla
(argomento vuoto), allora tale argomento sarà sostituito da uno speciale token “invisibile”
chiamato placemarker token che sarà, poi, concatenato con un token ordinario e ritornerà
quel token originario (Snippet 10.10). Se invece a essere concatenati sono due placemarker
token, perché per esempio abbiamo invocato un macro con due argomenti vuoti, allora sarà
ritornato un singolo placemarker token. In ogni caso, al termine dell’espansione della macro,
ogni eventuale placemarker token sarà rimosso.
int main(void)
{
// alla fine delle espansioni data sarà visto dal compilatore come:
// int data[] =
// {
// 123,
// 13,
// 23,
// 12,
// 1,
// 2,
// 3,
//
// };
// da notare lo spazio "vuoto" dopo 3, lasciato dal preprocessore
// a seguito dell'espansione di list(,,)
int data[] =
{
list(1, 2, 3), /* list sarà espansa come 123 */
list(1,, 3), /* list sarà espansa come 13 */
list(, 2, 3), /* list sarà espansa come 23 */
list(1, 2,), /* list sarà espansa come 12 */
list(1,,), /* list sarà espansa come 1 */
list(, 2,), /* list sarà espansa come 2 */
list(,, 3), /* list sarà espansa come 3 */
list(,,) /* espansione "vuota"... */
};
...
}
Espressioni di selezioni generiche
A partire dallo standard C11 è stata introdotta la keyword _Generic (Sintassi 10.5) che
permette di costruire un’espressione che è generica rispetto a un determinato tipo di dato
(type-generic expressions), ossia che è in grado di ritornare un determinato valore in base,
per l’appunto, a uno specifico tipo di dato fornito come “valore” di input.
TERMINOLOGIA
Lo standard C11 utilizza anche il termine di generic selection expression (espressione di
selezione generica) per indicare un’espressione di tipo generico che “seleziona” un valore in
base al tipo di un’espressione.
#define NL printf("\n")
int main(void)
{
char c = 'A';
g_print(c); NL;
int i = 10;
g_print(i); NL;
float f = 10.3f;
g_print(f); NL;
double d = 10.3344;
g_print(d); NL;
return (EXIT_SUCCESS);
}
NOTA
Il presente listato è compilabile correttamente con una versione di GCC dalla 4.9 in poi; è
solo a partire da quella versione che è stato introdotto il supporto alle espressioni di
selezioni generiche tramite la keyword _Generic.
Il Listato 10.5 definisce la macro parametrica gen_fmt con una lista di sostituzione formata
dalla definizione di un’espressione di una selezione generica che ha come obiettivo quello
di ritornare un letterale stringa che rappresenta un determinato specificatore di formato
utilizzabile con la funzione printf.
Lo specificatore di formato ritornato sarà quello indicato da un’etichetta di un nome di
tipo di dato che sarà compatibile con il nome di tipo di dato di cui l’espressione f (per
esempio, se invochiamo direttamente la macro gen_fmt nel seguente modo gen_fmt(10); sarà
ritornato il letterale stringa "%d" perché il valore 10 è di tipo int).
Successivamente definisce anche la macro parametrica g_print la cui lista di sostituzione
è formata dall’istruzione printf che ha come argomenti la macro gen_fmt stessa e
l’identificatore p del relativo parametro.
Questa macro, nella sostanza, consente di usare l’istruzione printf in modo “generico”,
ossia consente di stampare il valore di un’espressione senza indicare un determinato
specificatore perché lo stesso, come detto, sarà fornito dall’espressione di selezione generica
ricavata dall’espansione della macro gen_fmt.
Così, quando per esempio nella funzione main sarà usata la macro parametrica g_print(i);,
il compilatore si troverà ad analizzare la seguente istruzione frutto della sua espansione:
printf( _Generic(( i ), char: "%c", short: "%hd", int: "%d", long:"%ld", long long: "%lld",
float:"%f", double: "%f", long double: "%Lf") , i );. Qui appare evidente come sia anche
stata espansa la macro gen_fmt il cui parametro p è stato sostituito dall’argomento i, il quale
essendo di tipo int farà ritornare come valore della selezione generica lo specificatore di
formato "%d" che sarà poi usato dalla printf stessa per stampare correttamente il valore della
variabile i.
IMPORTANTE
Un’espressione di selezione generica è per lo standard un’espressione primaria e non è una
direttiva per il preprocessore. Abbiamo comunque preferito inserirla in questo contesto
didattico perché essa è usata, generalmente, con la direttiva #define per creare delle macro
parametriche che si possono comportare come delle funzioni generiche, cioè come delle
funzioni che agiscono in modo indipendente da un tipo di dato.
Inclusione condizionale
Una caratteristica importante del preprocessore è quella che permette di includere in
modo selettivo o condizionale pezzi di codice ovvero, in base al valore di una condizione
esaminata, il preprocessore può scegliere se includere o meno sezioni di testo del corrente
codice sorgente di un determinato programma.
L’inclusione condizionale, detta anche compilazione condizionale, è una tecnica
comunemente utilizzata per: verificare determinati punti di codice facendo stampare certe
informazioni diagnostiche solo quando si decide di attivare tale fase di debugging; scrivere
programmi portabili su hardware o sistemi operativi differenti; scrivere programmi adattati
per certe implementazioni di C piuttosto che per altre; proteggere l’inclusione multipla di
uno stesso file header; fornire una definizione di default di una macro verificando che non
sia stata già definita.
La direttiva #if
La direttiva del preprocessore #if (Sintassi 10.5) verifica se l’espressione costante intera
che deve valutare è diversa da 0 e, nel caso, indica al preprocessore di non rimuovere dal
codice sorgente da compilare il relativo gruppo di righe di codice; in questo caso, le righe di
codice considerate sono quelle scritte fino alla relativa direttiva #endif o #else o #elif.
Se, invece, la predetta espressione vale 0, allora indica al preprocessore di rimuovere dal
codice sorgente da compilare quel relativo gruppo di righe di codice che non saranno
pertanto visibili al compilatore. In ambedue i casi, comunque, la direttiva #if viene
eliminata dal preprocessore.
Come evidenziato dalla Sintassi 10.6, la direttiva del preprocessore #if valuta, attraverso
integer_constant_expression, un’espressione costante intera che è però soggetta, in questo
contesto di utilizzo, alle seguenti ulteriori restrizioni rispetto a quelle già esaminate nel
Capitolo 3: non può contenere espressioni sizeof; non può contenere il costrutto cast; se si
utilizzano come operandi delle costanti enumerative esse saranno trattate come degli
identificatori non appartenenti a macro è saranno sostituite con il valore 0.
Segue, quindi, un carattere effettivo di new line (digitato cioè da tastiera) e poi attraverso
rows_group l’indicazione delle righe di testo di codice da includere o meno nella fase di
compilazione a seconda se l’espressione costante intera sia o meno diversa da 0.
Snippet 10.11 La direttiva #if.
...
// 0 = disattiva debug; 1 = attiva debug
#define DEBUG 1
#define SIZE 10
int main(void)
{
int data[SIZE] = {1, 2, 44, 55, 11, 2, 4, 5, 6, -1};
int sum = 0;
char c = '1';
Lo Snippet 10.11 definisce un array data e poi elabora un ciclo for che deve ottenere la
somma di tutti i valori degli elementi del predetto array, da memorizzare, poi, nella variabile
sum definita allo scopo.
Il ciclo for ha nel suo blocco una direttiva #if che valuta la macro DEBUG la quale,
espandendosi in un valore diverso da 0, farà sì che il preprocessore lasci all’interno del
codice sorgente la relativa riga che è rappresentata da un’istruzione printf che manda a
video, per scopi diagnostici, un letterale stringa contenente, per ogni step dell’iterazione,
l’indicazione del corrente elemento analizzato unitamente al suo valore.
Se decidiamo di non volere più quei messaggi di debug, sarà sufficiente porre come lista
di sostituzione della macro DEBUG la costante 0 e ricompilare il codice sorgente; non sarà
quindi necessario eliminare le direttive del preprocessore, #if ed #endif, e la relativa
istruzione printf, che potranno sempre tornarci utili in futuro se vorremo rielaborare quelle
informazioni diagnostiche.
NOTA
Se una direttiva #if valuterà un identificatore non definito, lo tratterà come se fosse una
macro con il valore 0.
La direttiva #elif
La direttiva del preprocessore #elif (Sintassi 10.7) permette di valutare la sua espressione
costante intera solo se l’espressione costante intera della relativa direttiva #if è risultata
uguale a 0 (#elif è paragonabile al costrutto else if incontrato nel Capitolo 5).
int main(void)
{
#if CURRENT_OS == WINDOWS
printf("Windows...\n");
#elif CURRENT_OS == MAC_OS
printf("Mac...\n");
#elif CURRENT_OS == AMIGA_OS
printf("Amiga...\n");
#elif CURRENT_OS == LINUX /* questa espressione sarà diversa da 0 */
printf("Linux...\n");
#endif /* CURRENT_OS */
...
}
Lo Snippet 10.12 mostra come utilizzare la direttiva #elif in una serie di valutazioni per
verificare qual è il corrente sistema operativo dove compileremo il programma.
Analizzando il codice appare evidente come solo l’ultima espressione della direttiva #elif
ritornerà un valore diverso da 0 perché il valore della macro CURRENT_OS sarà uguale a LINUX,
che è esso stesso l’identificatore di una macro che sarà uguale a 1.
In più, è importante rilevare come, senza alcun problema, le espressioni costanti intere
siano interessate dall’utilizzo dell’operatore di uguaglianza uguale a ==; infatti, allo stesso
modo, è possibile impiegare l’altro operatore di uguaglianza non uguale a !=, gli operatori
aritmetici, gli operatori relazionali, gli operatori logici, gli operatori bit a bit e l’operatore
condizionale.
In pratica è certamente fattibile costruire delle espressioni complesse utilizzando la
molteplicità di operatori indicati perché la valutazione di tali espressioni ritornerà sempre un
valore intero utilizzabile nell’ambito delle predette direttive condizionali.
NOTA
Vi è un ulteriore modo per scrivere il codice presentato, ma lo vedremo tra breve quando
tratteremo della direttiva #ifdef.
La direttiva #else
La direttiva del preprocessore #else (Sintassi 10.8) include le righe di codice relative solo
se l’espressione costante intera della relativa direttiva #if, o di altre direttive #elif, sono
risultate uguali a 0 (#else è paragonabile al costrutto else incontrato nel Capitolo 5.
Per quanto riguarda questa direttiva bisogna rammentare che al massimo una di esse può
essere presente nell’ambito di un blocco #if / #endif, mentre questa limitazione non è
presente per le direttive #elif che possono esservi in qualsiasi numero si desidera.
La direttiva #endif
La direttiva del preprocessore #endif (Sintassi 10.9) permette di evidenziare la chiusura di
un blocco definito dalla direttiva #if (oppure dalle direttive #ifdef e #ifndef).
Lo Snippet 10.14 è utile solo per mostrare come sia possibile costruire delle strutture di
selezione innestate anche mediante le direttive #if / #endif.
In ogni caso è prassi codificarle senza porre degli spazi di indentazione ma facendo
terminare le direttive #endif con un commento che evidenzia a quale #if appartiene.
int main(void)
{
// una serie di #if / #endif innestati; saranno incluse tutte e tre le istruzioni
// di printf
#if A
printf("Ramo A...\n");
#if B
printf("Ramo B...\n");
#if C
printf("Ramo C...\n");
#endif /* C */
#endif /* B */
#endif /* A */
...
}
La direttiva #ifdef
La direttiva del preprocessore #ifdef (Sintassi 10.10) consente di verificare se un
determinato identificatore è stato definito come un nome di una macro.
Sintassi 10.10 La direttiva #ifdef.
#ifdef macro_identifier new_line [rows_group]
La direttiva #ifdef è per certi versi simile alla direttiva #if perché indica al preprocessore
se includere o meno nel codice che sarà compilato il gruppo di righe di testo relativo;
tuttavia essa ha un’importante differenza, ovvero non valuta un’espressione costante intera
ma verifica solo se un nome di una macro è definito (infatti, non è necessario che la macro
abbia associato un valore come 0, 1 e così via).
int main(void)
{
int data[] = {1, 2, 3, 4, 5};
Nello Snippet 10.5 il ciclo for stamperà a video i valori di tutti gli elementi dell’array data
in modo che saranno visualizzati ciascuno su una riga di testo separata.
Ciò sarà possibile perché la direttiva #ifdef verificherà che il nome NL è definito come
nome di una macro, e pertanto indicherà al preprocessore di lasciare nel codice sorgente la
relativa istruzione printf che conterrà anche la sequenza di escape di nuova riga.
La direttiva #ifndef
La direttiva del preprocessore #ifndef (Sintassi 10.11) consente di verificare se un
determinato identificatore non è stato definito come un nome di una macro.
L’identificatore controllato da defined può, opzionalmente, essere posto tra una coppia di
parentesi tonde ( ), come evidenzia lo Snippet 10.17, dove verifichiamo se è stato definito
l’identificatore LINUX come nome di una macro e nel caso lasciamo inserita nel codice la
relativa istruzione printf.
int main(void)
{
#if defined (WINDOWS)
printf("Windows...\n");
#elif defined (MAC_OS)
printf("Mac...\n");
#elif defined (AMIGA_OS)
printf("Amiga...\n");
#elif defined (LINUX) /* questa espressione sarà diversa da 0 */
printf("Linux...\n");
#endif /* WINDOWS */
...
}
NOTA
Non vi è alcuna differenza semantica nell’usare #ifdef macro_identifier e #if defined
per scrivere codice che contiene delle dichiarazioni di funzioni e i file .c per scrivere codice
che contiene delle definizioni di funzioni.
TERMINOLOGIA
Più precisamente, possiamo dire che un file di codice sorgente per C è sia un file .c sia un
file .h. In ogni caso, è nella prassi indicare come file di codice sorgente solo i file .c e come
file di intestazione solo i file .h che sono disgiunti dai primi. Per quanto riguarda la
terminologia adottata in questo testo essa si avvarrà a volte di un significato più preciso
(come è il caso di quest’unità didattica che tratta dell’inclusione di file sorgente), mentre
altre volte di un significato più snello e pratico (come è il caso, per esempio, del Capitolo 1).
La direttiva #include
La direttiva del preprocessore #include (Sintassi 10.13, 10.14 e 10.15) permette di
incorporare, dal punto dove è definita e in un file sorgente che ne fa uso, il contenuto di
altro file sorgente da essa riferito.
Con la Sintassi 10.13 si include il contenuto del file sorgente specificato tra una coppia di
parentesi angolari < > e tale contenuto sostituisce quella direttiva che viene eliminata.
La sequenza di caratteri h_char_sequence definisce generalmente il nome di un file esterno
e può contenere qualsiasi membro del corrente set di caratteri eccetto i caratteri new line e >
(parentesi angolare chiusa).
Con la direttiva #include scritta in questa forma il preprocessore cerca il file sorgente
indicato in una serie di locazioni che sono dipendenti dall’implementazione corrente la
quale, a sua volta, a seconda del sistema target, ne ha di predefinite; per esempio:
per GCC in ambienti Unix-like i percorsi di ricerca sono usualmente /usr/local/include,
/usr/include e [libdir]/gcc/[target]/[version]/include (nel nostro sistema GNU/Linux
quest’ultimo percorso è espanso come /usr/lib/gcc/x86_64-redhat-linux/4.9.2/include);
per GCC (MinGW) in ambienti Windows i percorsi di ricerca sono usualmente
C:\MinGW\lib\gcc\mingw32\[version]\include, C:\MinGW\lib\gcc\mingw32\[version]\include-
fixed (nel nostro sistema Window 8.1 [version] è espanso come 4.8.1) e
C:\MinGW\include, C:\MinGW\mingw32\include.
I file sorgente inclusi riguardano, tipicamente, i file header della libreria di funzioni dello
standard di C, e la loro corretta denominazione è quella indicata dalla relativa specifica del
linguaggio (per esempio stdio.h, math.h, assert.h, stdlib.h, time.h e così via).
int main(void)
{
...
}
Lo Snippet 10.18 include una serie di file header propri della libreria standard del
linguaggio C; quando il preprocessore incontrerà ciascuna direttiva #include la eliminerà
sostituendola con il contenuto effettivo che si troverà nel file specificato, in accordo anche
con quanto è lì indicato (per esempio, potrà includere o meno dello specifico codice per
effetto della valutazione delle direttive condizionali #if, #else e via discorrendo).
Con la Sintassi 10.14 si include il contenuto di un file sorgente specificato tra una coppia
di doppi apici " " e tale contenuto sostituisce quella direttiva, che viene eliminata.
La sequenza di caratteri q_char_sequence definisce generalmente il nome di un file esterno
e può contenere qualsiasi membro del corrente set di caratteri eccetto i caratteri new line e "
(doppio apice).
Con la direttiva #include scritta in questa forma il preprocessore cerca il file sorgente in
un modo che è definito dall’implementazione corrente laddove, tuttavia, i correnti
compilatori tipicamente seguono questo procedimento: iniziano a cercare a partire dalla
locazione dove è presente il file sorgente che fa uso di tale direttiva, e se lì non trovano il
file da includere allora provano a cercarlo nei path utilizzati nel caso la direttiva #include
fosse stata scritta nella I forma ossia con le parentesi angolari < >.
ATTENZIONE
Con questa II forma di utilizzo della direttiva #include è possibile utilizzare dei percorsi di
ricerca prestabiliti come, per esempio, /opt/cl/my_include oppure d:\cl\my_include. È tuttavia
sconsigliato esplicitare path in modo assoluto per evitare problemi di portabilità del codice
su altri sistemi.
I file sorgente inclusi riguardano, generalmente, i file header di librerie di funzioni scritte
dal programmatore stesso e indispensabili per il corretto funzionamento del proprio
programma.
int main(void)
{
...
}
Lo Snippet 10.19 include il file header stack.h che dovrà trovarsi, in accordo con quanto
detto, almeno nella stessa locazione del file di codice sorgente che lo sta includendo.
Con la Sintassi 10.15 si indicano dei token pre-elaborazione che sono usualmente
rappresentati dall’identificatore di una macro semplice, la cui lista di sostituzione deve
contenere dei token in grado di rappresentare una delle due forme della direttiva #include
prima citate; detto in altri termini, la macro deve espandersi con una coppia di parentesi
angolari con il nome di un header <header_name> oppure con una coppia di doppi apici con il
nome del file sorgente "file_source_name".
DETTAGLIO
Leggendo quanto appena detto potremmo chiederci perché nel caso della I forma della
direttiva #include esplicitiamo il “nome” di un header e nel caso della II forma della stessa
direttiva esplicitiamo il “nome di un file” di un sorgente. Il motivo risiede nel fatto che la
specifica di C non richiede espressamente che il nome di un header sia il nome di un file
sorgente da ricercare in uno specifico filesystem (i dettagli su come accedere a
quell’intestazione dipendono infatti dall’implementazione corrente). Resta inteso, tuttavia,
che quanto evidenziato è solo per un rigore formale e terminologico; infatti, nella prassi è
lecito indicare la sintassi della I forma della direttiva #include come #include
<file_source_name>.
#if VERSION == 1
#define INC_FILE "stack_v1.h"
#elif VERSION == 2
#define INC_FILE "stack_v2.h"
#elif VERSION == 3
#define INC_FILE "stack_v3.h"
#else
#define INC_FILE "stack_beta.h"
#endif
#include INC_FILE /* sarà: #include "stack_v3.h" */
Lo Snippet 10.20 mostra come sia possibile includere un file sorgente in modo
“variabile”, ossia con un valore dipendente da quanto indicato da un lista di sostituzione di
una specifica macro. Nel nostro caso questa possibilità, definita computed include, ci ha
permesso di scrivere una sola istruzione #include piuttosto che tante istruzioni #include
ciascuna con un proprio valore di un nome di un file sorgente lì codificato direttamente.
int main(void)
{
printf("Il valore di data e': %d\n", data);
return (EXIT_SUCCESS);
}
Per condividere, invece, tra più file sorgente una funzione dobbiamo compiere i seguenti
passi (Listati 10.9, 10.10 e 10.11):
1. definirla in un file .c, ossia scriverla indicando il relativo corpo di istruzioni;
2. dichiararla in un file .h, ossia scriverla indicando il relativo prototipo.
// definizione di foo
void foo(void)
{
printf("Elaborazione di foo...\n");
}
int main(void)
{
// eseguo foo...
foo();
return (EXIT_SUCCESS);
}
header e includerlo nei file .c che ne necessitano (Listati 10.12, 10.13, 10.14).
// dichiarazione di un enum
enum colors
{
RED = 0xF00, GREEN = 0x0F0, BLUE = 0x00F, WHITE = 0xFFF, BLACK = 0x000
};
// prototipo di setPixelAt
void setPixelAt(struct point p, enum colors c);
// definizione di setPixelAt
void setPixelAt(struct point p, enum colors c)
{
printf(MSG);
printf("Accendo il pixel alle coordinate [%d, %d] con il colore: [%X]\n",
p.x, p.y, c);
}
int main(void)
{
// uso un alias di tipo e una macro semplice definiti in A_3.h
BigInt numbers[SIZE];
return (EXIT_SUCCESS);
}