Che materia stai cercando?

Guida per la programmazione in C

Struttura di massima di un calcolatore, algoritmi, programmi e linguaggi, compilatori, sistemi operativi e reti. Logica e codifica binaria dell`informazione (logica proposizionale, operatori logici AND, OR, NOT. Aspetti fondamentali della programmazione: il linguaggio di programmazione e le esigenze di astrazione, la sintassi dei linguaggi, struttura di un programma monomodulo, astrazione sui dati... Vedi di più

Esame di Fondamenti di Informatica docente Prof. M. Matera

Anteprima

ESTRATTO DOCUMENTO

a b a AND b

1 1 1

1 0 0

0 1 0

0 0 0

a b a OR b

1 1 1

1 0 1

0 1 1

0 0 0

a b a XOR b

1 1 0

1 0 1

0 1 1

0 0 0

a NOT a

1 0

0 1

A cosa ci possono servire questi rudimenti di logica per la programmazione in C? È

presto detto. Sappiamo che un computer ragiona con una logica binaria; nel

processore tutte le istruzioni che noi mettiamo in una programma diventano, a livello

logico-elettronico, delle semplici operazioni logiche, AND, OR, XOR e NOT. In

particolare, in C useremo perlopiù tali operatori per descrivere meglio le condizioni

all'interno di certi confronti. Ecco come si scrivono in C le operazioni logiche:

Operazione Scrittura in C

AND &&

OR ||

XOR ^

NOT ! 43

Vediamo qualche applicazione pratica: un frammento di codice che stabilisce se un

numero è compreso fra 0 e 10. Senza operatori logici lo scriveremo così:

if (n>0) {

if (n<10)

printf ("n è compreso fra 0 e 10\n");

else

printf ("n è maggiore di 10\n");

} else

printf ("n è minore di 0\n");

Con l'operatore logico AND scriveremo così:

if ((n>0) && (n<10)) {

printf ("n è compreso fra 0 e 10\n");

}

Ossia: se n è maggiore di 0 E contemporaneamente n è minore di 10, allora n è

compreso fra 0 e 10. Facciamo ora un esempio con l'OR: un programma che

stabilisce se un numero è minore di 0 OPPURE maggiore di 10 (il contrario

dell'intervallo che abbiamo visto sopra):

if ((n<0) || (n>10))

printf ("n è minore di 0, oppure n è maggiore di 10\n");

Ossia: controlla se n è minore di 0 OPPURE è maggiore di 10. Ragionamento simile

anche per lo XOR. Lo XOR è un'operazione logica molto usata in Assembly, in

quanto fare lo XOR di un registro con se stesso equivale a svuotare il registro. Il

NOT viene invece usato per sostituire scritture ridondanti come n==0 o n!=0: infatti

una variabile negata è sempre 0:

if (n) // Equivale a scrivere if (n!=0)

printf ("n è diverso da 0\n");

if (!n) // Se "NOT n". Equivale a scrivere if (n==0)

printf ("n è uguale a 0\n");

Gli operatori logici possono anche essere usati fra variabili, consentendo quindi di

effettuare operazioni logiche fra numeri a livello di bit. Occhio che in questo caso

l'AND si scrive come '&', l'OR come '|' e il NOT, che diventa “operatore di

complemento” (ovvero prende il complementare di ogni bit, ad esempio

trasformando 0110 in 1001) si sc rive come '~'.

int a=0xa0a1a2a3;

int b = a & 0x0000ff00; // Fa un AND che azzera tutti i byte tranne

il penultimo -> b = 0x0000a200

// O anche, esempio più immediato:

char a=3; // a = 00000011 44

char b=5; // b = 00000101

char c = a & b; // c = 00000001 = 1

// O ancora:

char a=3; // a = 00000011

char b=5; // b = 00000101

char c = a | b; // c = 00000111 = 7

char a=7; // a = 00000111

char b=~a; // b = 11111000

Un'altra operazione logica messa a disposizione dal C è lo SHIFT.

Immaginiamo di avere una variabile int i = 4; scritta in binario (facciamo per

comodità a 4 bit) sappiamo che equivale a 0100. Fare uno shift a sinistra di 1 bit (la

scrittura in questo caso è <<) equivale a spostare tutti i bit di un posto a sinistra: la

nostra variabile binaria da 0100 diventa quindi 1000, quindi i da 4 diventa per magia

8. Una cosa degenere in C si scrive così:

int i = 4;

i = i << 1; // Faccio lo shift a sinistra di 1 bit

C'è anche lo shift a destra, il simbolo è >>. Ad esempio, se facciamo uno shift a

destra di 1 bit di i, questa variabile da 0100 diventa 0010, quindi da 4 diventa 2:

int i = 4;

i = i >> 1; // Faccio lo shift a destra di 1 bit

Risulta immediato quanto può essere comodo lo switch per calcolare le potenze del

2. Se voglio calcolare 2^n infatti posso scrivere semplicemente .

1 << (n-1)

Pensateci un attimo e capirete perché.

Costrutti switch-case

Le strutture switch-case sono un modo più elegante per gestire un numero piuttosto

alto di costrutti if-else. Prendiamo un programmino che riceve in input un carattere e

stabilisce se il carattere è 'a','b','c','d','e' oppure è diverso da questi cinque. Con l'if-

else scriveremmo una roba del genere:

char ch; // Carattere

printf ("Inserisci un carattere: ");

scanf ("%c", &ch);

if (ch=='a') 45

printf ("Hai digitato a\n");

else {

if (ch=='b')

printf ("Hai digitato b\n");

else {

if (ch=='c')

printf ("Hai digitato c\n");

else {

if (ch=='d')

printf ("Hai digitato d\n");

else {

if (ch=='e')

printf ("Hai digitato e\n");

else

printf ("Non hai digitato un carattere compreso fra a ed

e\n");

}

}

}

}

Tale scrittura non è certo il massimo della leggibilità. Vediamo ora lo stesso

frammento di programma con una struttura switch-case:

char ch;

printf ("Inserisci un carattere: ");

scanf ("%c",&ch);

switch(ch) { // Ciclo switch per la variabile ch

case 'a': // Nel caso ch=='a'...

printf ("Hai digitato a\n");

break; // Interrompe questo case

case 'b':

printf ("Hai digitato b\n");

break;

case 'c':

printf ("Hai digitato c\n");

break;

case 'd':

printf ("Hai digitato d\n");

break;

case 'e':

printf ("Hai digitato e\n");

break;

// Nel caso il valore di ch non sia

// uno di quelli sopra elencati...

default: 46

printf ("Non hai digitato un carattere compreso fra a ed e\n");

break;

} // Fine della struttura switch-case

Metodo molto più pulito ed elegante. La struttura di uno switch-case è la seguente:

switch(variabile) {

case val_1:

codice

break;

case val_2:

codice

break;

...........

case val_n:

codice

break;

default: // La clausola di default non è obbligatoria

codice

break;

}

Ogni etichetta case va interrotta con la clausola break, che interrompe lo switch-case

e ripassa il controllo al programma.

Cicli iterativi - Istruzione for

Immaginiamo di voler far ripetere al nostro programma un blocco di istruzioni per un

tot numero di volte. Immaginiamo ad esempio un programmino che stampi diceci

volte "Hello world!". Con le conoscenze che abbiamo finora, scriveremmo un lavoro

del genere:

int main() {

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

printf ("Hello world!\n");

} 47

Il che è decisamente scomodo. Per evenualità di questo tipo ci viene in aiuto il ciclo

for, che ha la seguente sintassi:

for (variabile_1=valore1, ..., variabile_n=valore_n; condizione;

step) {

codice

}

Dove variabile_1,...,variabile_n sono le cosiddette variabile contatori, condizione è

una condizione booleana che stabilisce il numero di cicli da eseguire (ovvero, finché

la condizione è vera esegui il ciclo for) e step l'eventuale incremento o decremento da

far subire alle variabili contatore ad ogni ciclo.

Esempio chiarificatore: ecco il programmino di sopra scritto con un ciclo for:

int main() {

int i; // Variabile "contatore"

for (i=0; i<10; i++)

printf ("Hello world!\n");

return 0;

}

Dove la variabile contatore è i, e viene inizialmente posta, all'interno del ciclo for,

uguale a 0. La condizione è i<10, ovvero finché la variabile i è minore di 10 esegui il

ciclo, lo step invece è i++, ovvero 'ad ogni ciclo incrementa la variabile i (finché,

ovviamente, non varrà 10 e il ciclo può ritenersi concluso).

Ecco un altro esempio chiarificatore:

int main() {

int i;

for (i=0; i<10; i++)

printf ("Valore di i: %d\n",i);

return 0;

}

Ecco l'output di questo programmino:

Valore di i: 0

Valore di i: 1

Valore di i: 2

Valore di i: 3

Valore di i: 4

Valore di i: 5

Valore di i: 6 48

Valore di i: 7

Valore di i: 8

Valore di i: 9

Ovviamente, il ciclo for di sopra si può scrivere in moltissimi modi:

for (i=1; i<=10; i++)

printf ("Valore di i: %d\n",i);

In questo caso, i ha come valore iniziale 1 e il ciclo termina quando i è esattamente

uguale a 10. In questo caso l'output sarà:

Valore di i: 1

Valore di i: 2

Valore di i: 3

Valore di i: 4

Valore di i: 5

Valore di i: 6

Valore di i: 7

Valore di i: 8

Valore di i: 9

Valore di i: 10

Altro esempio:

for (i=10; i>0; i--)

printf ("Valore di i: %d\n",i);

In questo caso, i ha come valore iniziale 10, viene decrementata di un'unità ad ogni

loop e il ciclo termina quando i vale 0. L'output è il seguente:

Valore di i: 10

Valore di i: 9

Valore di i: 8

Valore di i: 7

Valore di i: 6

Valore di i: 5

Valore di i: 4

Valore di i: 3

Valore di i: 2

Valore di i: 1

Vedremo più avanti che i cicli for sono molto utili per manipolare gli array. Piccola

nota: è possibile usare i cicli for anche per eseguire un blocco di istruzioni

all'infinito:

for (;;)

printf ("Stampa questo all'infinito\n");

In questo caso, dato che non c'è nessuna variabile contatore che limita il ciclo, le

49

istruzioni all'interno del for verrano semplicemente eseguite teoricamente all'infinito.

Questo perché, nonostante l'istruzione for preveda 3 campi (variabili contatore con

valori iniziali, condizione di break e step), nessuno di questi 3 campi è strettamente

obbligatorio.

Cicli iterativi - Istruzione while

I cicli while, o di iterazione per vero, sono cicli che eseguono un blocco di istruzioni

finchè una condizione specificata risulta vera. La loro sintassi è la seguente:

while (espressione_booleana) {

codice

}

Esempio molto semplice:

int i=0;

while (i<10) {

printf ("Valore di i: %d\n",i);

i++;

}

Sotto un punto di vista pratico, questo frammento di codice è esattamente uguale a

quello esaminato sopra, nel paragrafo sul for. Semplicemente, controlla se la

variabile i è minore di 10: se lo è, allora esegue il blocco di istruzioni all'interno del

while (ovviamente, ad ogni loop la variabile i viene incrementata di un'unità).

Quando la condizione di partenza non è più vera, allora il ciclo termina. Esempio un

po' più complesso:

int n;

while (n!=0) {

printf ("Inserisci un numero (0 per finire): ");

scanf ("%d",&n);

printf ("Numero inserito: %d\n",n);

}

In questo caso, il programma mi chiederà di inserire un numero intero e stamperà il

numero che ho appena inserito: se il numero è proprio 0, allora il ciclo termina

(l'espressione while (n!=0) sta per "mentre n è diverso da 0"). Anche attraverso i

while è possibile creare cicli infiniti:

while (1)

printf (“Stampa questo all'infinito!\n”); 50

Il motivo è semplice. Il while viene eseguito finché l'espressione in parentesi risulta

vera (come abbiamo già visto, il C considera vero qualsiasi valore intero diverso da

zero), quindi un while del genere equivale concettualmente a un “finché 1 è diverso

da 0” (sempre vero).

Allo stesso modo si potrebbero creare dei (perfettamente inutili) cicli che non

verranno mai eseguiti:

while (0)

printf (“Questo non verra' mai eseguito\n”);

Cicli iterativi - Istruzione do-while

Una caratteristica dei cicli while è quella che prima verificano la condizione, poi

eseguono il codice contenuto al loro interno. Se la condizione iniziale è falsa a priori,

il codice non verrà mai eseguito. Esempio:

int n = -1; // Variabile int

while (n>0)

printf ("Questo codice non verrà mai eseguito\n");

L'istruzione printf() contenuta all'interno del while non verrà mai eseguita, in quanto

la condizione di partenza è falsa (il valore di n è minore di 0). Se volessimo che il

nostro programma esegua prima il codice e poi controlli la verità della condizione

dobbiamo usare un ciclo do-while. La sua struttora è la seguente:

do {

codice

codice

......

} while(condizione_booleana);

Esempio:

int n = -1; // Variabile int

do {

printf ("Questo codice verrà eseguito una sola volta\n");

} while(n>0);

In questo caso il programma esegue prima l'istruzione printf(), quindi controlla la

condizione specificata. Dato che in questo caso la condizione è falsa, il ciclo termina.

51

Istruzione goto

L'istruzione goto ("vai a") è l'istruzione per i cicli più elementare, e deriva

direttamente dall'istruzione JMP (JuMP) dell'Assembly. La sua sintassi è la seguente:

etichetta:

codice

codice

......

goto etichetta; // Salta all'etichetta specificata

Esempio: prendiamo il classico programmino che stampa 10 volte "Hello world!".

Con ll'istruzione goto verrebbe più o meno così:

int main() {

int i=0; // Variabile contatore

hello: // Etichetta "hello". Ma posso chiamarla

// in qualsiasi altro modo

printf ("Hello world!\n");

i++; // Incremento la variabile contatore

if (i<10)

goto hello; // Se i è minore di 10 salto all'etichetta "hello"

return 0;

}

È possibile scrivere qualsiasi tipo di ciclo visto finora (for, while, do-while)

attraverso una sequenza di if e goto.

Tuttavia, l'istruzione goto oggigiorno è estremamente sconsigliata, in quanto tende a

creare il cosiddetto "codice a spaghetti", ossia un codice spezzettato, pieno di salti e

difficile da leggere (è decisamente più intuitivo vedere come è fatto un ciclo a primo

occhio vedendo un for o un while che seguendo come Pollicino una scia di goto che

non si sa dove portano). In genere i cicli for, while e do-while sono molto più

leggibili di codici scritti con il goto.

Istruzioni break e continue

È possibile manipolare i cicli attraverso le istruzioni break (che abbiamo già

incontrato quando abbiamo parlato delle strutture switch-case) e continue.

Un'istruzione break termina un ciclo, un'istruzione continue interrompe invece

l'iterazione corrente e va alla prossima. Esempio:

int i=0; // Variabile "contatore" 52

// Questo ciclo durerebbe teoricamente all'infinito

for (;;) {

printf ("Ora i vale %d\n",i);

i++;

if (i>5)

break; // Se i è maggiore di 5 interrompo il ciclo

}

printf ("Ora il ciclo è concluso!\n");

L'output sarà il seguente:

Ora i vale 0

Ora i vale 1

Ora i vale 2

Ora i vale 3

Ora i vale 4

Ora i vale 5

Ora i vale 6

Ora il ciclo è concluso!

La clausola break in questo caso interrompe il ciclo che altrimenti sarebbe infinito

dopo 6 iterazioni. È possibile usare queste clausole (tra l'altro abbiamo già incontrare

il break nello switch-case) in qualsiasi punto di un ciclo per interromperlo o

continuarlo, al verificarsi di determinate condizioni. Vediamo invece la continue:

int i;

for ( i=0; i < 5; i++ ) {

if ( i == 2 )

continue;

printf (“i vale %d\n”, i);

}

L'output sarà

i vale 0

i vale 1

i vale 3

i vale 4

Questo perché nel caso i == 2 abbiamo usato la clausola continue, che dice di

interrompere l'iterazione corrente e andare alla prossima. 53

Gli array

Gli array, o vettori, sono le strutture di dati più elementari in informatica, del tutto

simili ai vettori trattati dall'algebra lineare.

Array monodimensionali

Si tratta di un insieme di variabili dello stesso tipo e accomunate dallo stesso nome (il

nome del'array). Ciò che distingue un elemento dell'array da un altro è l'indice,

ovvero il suo numero, la sua posizione all'interno dell'array. Possiamo immaginare un

array come una cassettiera: per sapere dove mettere le mani per trovare qualcosa ci

serve il numero del cassetto dove cercare (prima cassetto, secondo cassetto...). Così,

un array è una raccolta di variabili dello stesso tipo sotto lo stesso nome dove ogni

variabile è un "cassettino" identificato da un numero. Ecco come si dichiara un array

in C:

tipo nome_array[quantità];

Esempio:

int mio_array[10];

dichiara un array di 10 variabili int (N.B. da 0 a 9, non da 1 a 10!) chiamato

mio_array. Se voglio cambiare un valore qualsiasi di questo array, basterà fare così:

mio_array[0] = 3; // Il primo valore ora vale 3

mio_arrar[1] = 2; // Il secondo valore vale 2

.......

Ovviamente posso anche leggere da tastiera il valore di un elemento dell'array:

printf ("Inserisci il valore del primo elemento: ");

scanf("%d",&mio_array[0]); // Leggo il valore del primo elemento

printf ("Il primo elemento vale %d\n",mio_array[0]);

Posso anche leggere tutti i valori e poi stamparli tramite un ciclo for:

int main() {

int mio_array[10];

int i;

for (i=0; i<10; i++) { // Per i volte...

printf ("Elemento n.%d: ",i); 54

// Leggo un valore int dalla tastiera

// e lo memorizzo nell'elemento numero

// i dell'array.

scanf("%d",&mio_array[i]);

}

for (i=0; i<10; i++)

// Stampo tutti i valori contenuti nell'array

printf ("Elemento n.%d: %d\n",i,mio_array[i]);

return 0;

}

Un array può anche essere dichiarato in modo esplicito con il suo contenuto:

int v[] = {2,4,6,2,6,5};

Vediamo ora un esempio più utile: un programma che calcola la media aritmetica di 5

numeri:

int main() {

float numeri[5]; // Array di 5 float

float med=0; // Media aritmetica

int i; // Variabile contatore

for (i=0; i<5; i++) {

printf ("Valore n.%d: ",i);

scanf ("%f",&numeri[i]);

med += numeri[i]; // Sommo fra loro tutti i numeri nell'array

}

// Divido la somma dei numeri per la loro quantità (5)

med /= 5;

printf ("Media aritmetica: %f\n",med);

return 0;

}

Ancora un altro esempio, assimilabile all'algebra lineare vera e propria: un

programmino che effettua il prodotto scalare tra due vettori (ricordo che dati due

vettori v1 e v2 entrambi di n elementi il loro prodotto scalare è un numero uguale a

v1[0]*v2[0] + v1[1]*v2[1] + ... + v1[n-1]*v2[n-1]), dove gli elementi di entrambi i

vettori sono stabiliti dall'utente via input:

#include <stdio.h>

// Dimensione dei due vettori

#define N 5

int main() {

int v1[N],v2[N];

int i; 55

int prod=0;

for (i=0; i<N; i++) {

printf ("Elemento %d del primo vettore: ",i+1);

scanf ("%d",&v1[i]);

}

for (i=0; i<N; i++) {

printf ("Elemento %d del secondo vettore: ",i+1);

scanf ("%d",&v2[i]);

}

for (i=0; i<N; i++)

prod += (v1[i]*v2[i]);

printf ("Prodotto scalare dei due vettori: %d\n", prod);

return 0;

}

Matrici e array pluridimensionali

Negli esempi riportati sopra sono sempre presi in esame array a una dimensione,

ovvero array dove ogni elemento è definito univocamente da un solo indice, che

identifica la loro posizione all'interno dell'array stesso. Il C, al pari degli altri

linguaggi di programmazione ad alto livello, mette anche a disposizione la possibilità

di usare array a più dimensioni. Ci soffermeremo in particolar modo sugli array

bidimensionali (dato che array di dimensioni superiori sono usati molto di rado),

meglio conosciuti come matrici.

Una matrice si dichiara esattamente come un array monodimensionale, ma

specificando sia il numero di righe che di colonne al suo interno:

int matrix[2][2]; // Dichiara una matrice di interi 2x2

La lettura e la scrittura su questi elementi vengono effettuate in modo molto simile

agli array, ma con due indici, in modo da gestire sia il numero di righe che di colonne

della matrice:

int matrix[2][2];

int i,j;

// Leggo i valori della matrice da input

for (i=0; i<2; i++)

for (j=0; j<2; j++) {

printf ("Elemento [%d][%d]: ",i+1,j+1);

scanf ("%d",&matrix[i][j]);

}

// Stampo i valori della matrice

for (i=0; i<2; i++) 56

for (j=0; j<2; j++)

printf ("Elemento in posizione [%d][%d]: %d\n",i+1,j+1,matrix[i]

[j]); 57

I puntatori

La memoria RAM del calcolatore non è altro che un insieme di locazioni di

memoria; per poter localizzare ciascuna locazione, ognuna di esse è identificata da un

indirizzo univoco. Questo significa che:

Per scrivere qualcosa in memoria centrale dobbiamo conoscere l'indirizzo del

• punto esatto in cui scrivere;

Se conosciamo l'indirizzo di una data locazione possiamo leggere ciò che è

• contenuto al suo interno.

Puntatori in C

Il C consente di gestire, oltre al contenuto delle variabili stesse, anche i loro indirizzi

(ovvero le loro locazioni in memoria) attraverso il meccanismo dei puntatori.

Fino ad oggi abbiamo gestito le variabili all'interno dei blocchi di codice in cui tali

variabili erano visibili, quindi non c'è stata la reale necessità di utilizzare l'indirizzo

della locazione di memoria in cui i valori di tali variabili erano stati memorizzati.

L'uso però a volte diventa indispensabile all'interno delle funzioni (anche nella scanf,

come avevo anticipato, si usava implicitamente un puntatore per stabilire l'area fisica

di memoria in cui salvare la variabile letta da input).

Un puntatore ha una sintassi simile:

int a=3; // Variabile

int *x; // ''Puntatore'' ad una variabile di tipo int

x=&a; // Il puntatore ''x'' contiene l'indirizzo della variabile

''a''

*x=4; // In questo modo modifico il contenuto del valore puntato

// da x, quindi modifico indirettamente il valore di a

&a identifica l'indirizzo in memoria al quale si trova la variabile a, indirizzo che

viene salvato nel puntatore x. Quest'uso dei puntatori dovrebbe farci tornare alla

mente la sintassi della scanf:

int a;

printf ("Inserisci un valore intero: ");

scanf ("%d",&a);

Ora possiamo capire appieno la sintassi della scanf. È una funzione che non fa altro

58

che leggere, in questo caso, un valore intero da tastiera, e salvarlo nell'indirizzo fisico

di memoria in cui si trova la variabile a (&a).

Ovviamente, se voglio salvare un valore letto da tastiera in una variabile a cui è già

associato un puntatore, non ho bisogno di ricorrere alla scrittura di sopra:

#include <stdio.h>

int main() {

int a;

int *x=&a;

printf ("Inserisci un valore intero: ");

// Salvo il valore immesso direttamente nell'allocazione

// di memoria puntata da ''x'', ovvero nella variabile ''a''

scanf ("%d",x);

printf ("Valore salvato all'indirizzo: 0x%x: %d\n",x,a);

return 0;

}

ritornerà come output qualcosa del tipo

Valore salvato all'indirizzo: 0xbfc16a24: 4

dove 0xbfc16a24 è, in questo caso, l'indirizzo fisico di memoria (in formato

esadecimale) in cui si trova la variabile intera a (e quindi il valore 4, in questo caso).

Ricordiamo che sulle macchine a 32 bit (quindi tutte le macchine Intel dal 486 in su,

escluse quelle a 64 bit come Itanium e simili) un indirizzo di memoria è sempre

grande 32 bit (come in questo caso), quindi in memoria un puntatore occuperà

sempre, indipendentemente dalla variabile a cui punta, 32 bit = 4 byte.

Passaggio di puntatori alle funzioni

Vediamo ora un'applicazione pratica dell'uso dei puntatori. Abbiamo una classica

applicazione che effettua lo scambio di due numeri interi (ovvero, se ho due variabili,

a=3 e b=4, voglio ottenere a=4 e b=3). Il modo più immediato di risolvere questo

problema è quello di appoggiarsi ad una variabile temporanea:

int a=4;

int b=3;

int tmp;

..... 59

tmp=a; // tmp=4

a=b; // a=3

b=tmp // b=tmp=4

Vogliamo ora implementare questo codice in una funzione a parte che viene poi

richiamata dal nostro programma. Con le nostre conoscenze attuali scriveremo un

codice del genere:

#include <stdio.h>

// Funzione per lo scambio

void swap(int a, int b) {

int tmp;

tmp=a;

a=b;

b=tmp;

}

int main() {

int a=4;

int b=3;

printf ("a=%d, b=%d\n",a,b);

swap(a,b);

printf ("a=%d, b=%d\n",a,b);

return 0;

}

compilandolo avremo una sorpresa inaspettata: i valori sono rimasti immutati. Questo

perché alla funzione swap non passiamo le variabili fisicamente, ma passiamo i loro

valori. Quando invochiamo una funzione, l'atto della chiamata crea in memoria una

nuova area dello stack associata alla funzione appena chiamata. In questo stack

vengono copiati i valori degli argomenti passati. La funzione quindi non opera

fisicamente sulle variabili passate, ma opera piuttosto su copie di esse. Quando la

funzione termina l'area dello stack corrispondente viene distrutta, e con essa anche le

copie delle variabili al suo interno, quindi non è possibile tener traccia delle

modifiche.

La soluzione è proprio quella di ricorrere ai puntatori, ovvero non passare alla

funzione copie delle variabili, ma gli indirizzi fisici in cui esse si trovano, in modo

che la funzione agirà direttamente su quegli indirizzi:

#include <stdio.h>

// Funzione per lo scambio

void swap(int *a, int *b) {

int tmp; 60

// tmp conterrà il contenuto della variabile intera puntata da a

tmp=*a;

// a conterrà il contenuto della variabile intera puntata da b

*a=*b;

// b conterrà il contenuto della variabile intera puntata da tmp

*b=tmp;

}

int main() {

int a=4;

int b=3;

printf ("a=%d, b=%d\n",a,b);

// Non passo le variabili alla funzione ma i loro indirizzi in

memoria

swap(&a,&b);

printf ("a=%d, b=%d\n",a,b);

return 0;

}

e ora il nostro codice funziona a dovere.

Puntatori e array

Nel paragrafo precedente abbiamo visto gli array come oggetti a sé stanti, diversi da

qualsiasi altro tipo di variabile e di dato che abbiamo incontrato. Ai fini del

calcolatore però un array viene trattato esattamente alla stregua di un puntatore, un

puntatore all'area di memoria dov'è contenuto il primo elemento dell'array stesso.

Esempio:

#include <stdio.h>

int main() {

int v[] = {4,2,8,5,2};

// Queste due scritture sono equivalenti

printf ("Primo elemento dell'array: %d\n",v[0]);

printf ("Primo elemento dell'array: %d\n",*v);

return 0;

}

questo vuol dire che possiamo accedere a qualsiasi elemento dell'array specificando o

il suo indice tra parentesi quadre o sommandolo al valore del puntatore al primo

elemento:

#include <stdio.h> 61

int main() {

int v[] = {4,2,8,5,2};

// Queste due scritture sono equivalenti

printf ("Secondo elemento dell'array: %d\n",v[1]);

printf ("Secondo elemento dell'array: %d\n",*(v+1));

return 0;

}

questo perché quando viene instanziato un array non viene fatto altro che creare un

puntatore ad una certa area della memoria centrale, per poi riservare tanto spazio in

memoria quanto specificato dalla dimensione dell'array (nell'esempio di sopra lo

spazio di 5 variabili int, una variabile int in genere è grande 4 byte su una macchina a

32 bit quindi vengono riservati 5*4=20 byte a partire dall'indirizzo del primo

elemento).

Passaggio di array a funzioni

Tale caratteristica si rivela conveniente per molti aspetti. L'aspetto principale consiste

nel poter passare un array ad una funzione come se fosse un puntatore. Esempio:

#include <stdio.h>

int print_array (int *v, int dim) {

int i;

for (i=0; i<dim; i++)

printf ("Elemento [%d]: %d\n",i,v[i]);

}

int main() {

int v[] = { 3,5,2,7,4,2,7 };

print_array(v,7);

}

Allocazione dinamica della memoria

L'altro enorme vantaggio di quest'ottica da parte del C (ovvero il considerare gli

array come semplici puntatori) risiede nel poter allocare dinamicamente dello spazio

in memoria. Non sempre sappiamo a priori quanto spazio può servire in memoria per

un array usato nel nostro programma. Ad esempio, nel caso in cui si dà la possibilità

all'utente di stabilire il numero di elementi da inserire o quando vogliamo salvare dei

dati in memoria senza sapere ancora la quantità di dati da salvare (in questo caso

dovremmo prima contare il numero di dati da salvare, quindi allocare tanta memoria

62

da poterli mantenere). In questi casi ci viene in aiuto una delle caratteristiche più

potenti del C, l'allocazione dinamica della memoria, allocazione che è possibile

attraverso la funzione malloc, definita in stdlib.h. La malloc ha una sintassi simile:

void* malloc (unsigned int size);

al posto di size specificheremo quanta memoria vogliamo allocare per la nostra

variabile o il nostro array. Come è possibile vedere il valore di ritorno di questa

funzione è void*, ovvero ritorna l'indirizzo della zona di memoria allocata in formato

'grezzo'. Per questo motivo è necessario specializzare la funzione attraverso un

operatore di cast. Esempio chiarificatore:

#include <stdio.h>

#include <stdlib.h>

int main() {

int *v;

int i,n;

printf ("Quanti elementi vuoi inserire nell'array? ");

scanf ("%d",&n);

v = (int*) malloc(n*sizeof(int));

for (i=0; i<n; i++) {

printf ("Elemento n.%d: ",i+1);

scanf ("%d",&v[i]);

}

for (i=0; i<n; i++)

printf ("Elemento n.%d: %d\n",i+1,v[i]);

free(v);

return 0;

}

La scrittura sizeof(int) ritorna la dimensione di una variabile int sulla macchina in

uso, quindi n*sizeof(int) è il numero di byte effettivi da allocare in memoria (ovvero

nella malloc diciamo di allocare in memoria n blocchi di dimensione sizeof(int) l'uno

che ospiteranno n variabili intere, e salviamo l'indirizzo a cui comincia questa zona di

memoria nel puntatore v).

È possibile anche allocare dinamicamente vettori multidimensionali. Esempio di

allocazione dinamica di una matrice:

#include <stdio.h>

#include <stdlib.h> 63

int main() {

int **m;

int i,j;

int m,n;

printf ("Numero di righe della matrice: ");

scanf ("%d",&m);

printf ("Numero di colonne della matrice: ");

scanf ("%d",&n);

m = (int**) malloc(m*n*sizeof(int));

// Inizializzo anche tutti i sotto-vettori,

// ovvero le righe della matrice

for (i=0; i<m; i++)

m[i] = (int*) malloc(n*sizeof(int));

for (i=0; i<m; i++)

for (j=0; j<n; j++) {

printf ("Elemento [%d][%d]: ",i+1,j+1);

scanf ("%d",&v[i][j]);

}

for (i=0; i<m; i++)

for (j=0; j<n; j++)

printf ("Elemento [%d][%d]: %d\n",i+1,j+1,v[i]);

free(m);

return 0;

}

È possibile anche usare la funzione realloc() per modificare la dimensione di aree di

memoria. La sintassi è la seguente:

void* realloc (void* ptr, unsigned int new_size);

Esempio chiarificatore:

#include <stdio.h>

#include <stdlib.h>

int main() {

int *v = NULL;

int i, val;

int size = 0;

do { printf (“Inserire un nuovo elemento nell'array “

“(-1 per terminare): “); 64

scanf (“%d”, &val);

v = (int*) realloc( v, (++size)*(sizeof(int)) );

v[size-1] = val;

} while (val != -1);

printf (“Elementi nell'array: “);

for ( i=0; i < size; i++ )

printf (“%d, “, v[i]);

free(v);

return 0;

}

Come nota segnaliamo un comportamento interessante della realloc(). Si noti che non

usiamo mai la malloc() per allocare inizialmente lo spazio di memoria, ma al primo

ciclo la realloc() verrà richiamata su int* v che è ancora NULL. Quando la realloc()

viene richiamata su un puntatore che è NULL si comporta esattamente come una

malloc(), quindi al primo giro allochiamo v come vettore contenente un elemento

intero, e a ogni ciclo aumentiamo la sua dimensione, finché l'utente non inserisce -1.

Deallocazione della memoria, memory leak e garbage

collection

È fondamentale usare la funzione free(), sempre dichiarata in stdlib.h, quando una

certa area di memoria precedentemente allocata non serve più. La malloc() (e, come

vedremo fra poco, anche la realloc()) allocano infatti memoria su una zona di

memoria chiamata heap, mentre tutte le variabili che abbiamo esaminato finora

vengono generalmente allocate sullo stack (caso di variabili locali) o nel segmento

data (caso di variabili globali). Tutto ciò che è allocato sullo stack viene allocato

quando la funzione corrispondente viene invocata e viene distrutto quando tale

funzione termina. Tutto ciò che è sullo heap invece viene allocato e rimane lì finché

qualcuno non lo dealloca esplicitamente (appunto, tramite la funzione free()). Se

devo allocare una zona di memoria grande un milione di byte, quella zona di

memoria rimane lì segnalata come allocata finché qualcuno non dice che non serve

più, oppure finché il processo non termina. Questo porta a un problema noto come

una delle più grandi maledizioni del programmatore che è il memory leak, ovvero

l'aumento esponenziale, nel caso di programmi molto complessi con grande uso

dell'allocazione dinamica della memoria, della quantità di memoria utilizzata, che

può arrivare a rallentare drammaticamente le prestazioni della macchina a causa di

continui swap fra memoria centrale satura e hard disk o peggio al crash del

programma. I memory leak sono anche difficili da scovare nel caso di progetti molto

complessi. Esistono tool come valgrind che aiutano il programmatore a capire se il

65

proprio programma presenta usi cattivi della memoria o meno, ed eventualmente

dove sono localizzabili, ma è comunque molto difficile nel caso di un progetto molto

grosso tenere sotto controllo tutte le allocazioni dinamiche e capire quale è l'origine

del problema.

Esempio tipico di esecuzione di valgrind su un programma in cui tutta la memoria

allocata dinamicamente viene correttamente deallocata dalla free():

[blacklight@wintermute ~]$ valgrind ./leak

...

==16227== HEAP SUMMARY:

==16227== in use at exit: 0 bytes in 0 blocks

==16227== total heap usage: 1 allocs, 1 frees, 100 bytes allocated

==16227==

==16227== All heap blocks were freed -- no leaks are possible

Esempio di esecuzione su un programma che invece presenta memory leak:

[blacklight@wintermute ~]$ valgrind ./leak

...

==30694== HEAP SUMMARY:

==30694== in use at exit: 100 bytes in 1 blocks

==30694== total heap usage: 1 allocs, 0 frees, 100 bytes allocated

==30694==

==30694== LEAK SUMMARY:

==30694== definitely lost: 100 bytes in 1 blocks

==30694== indirectly lost: 0 bytes in 0 blocks

==30694== possibly lost: 0 bytes in 0 blocks

==30694== still reachable: 0 bytes in 0 blocks

==30694== suppressed: 0 bytes in 0 blocks

==30694== Rerun with --leak-check=full to see details of leaked

memory

I memory leak sono considerati errori di programmazione molto seri, in quanto

possono compromettere la stabilità del programma e dell'intero sistema operativo, ma

sono anche molto comuni (è facile allocare della memoria che non servirà più dopo

66

3000 righe di codice e dimenticarsi di deallocarla). Il trucco sta nel piazzare subito

dopo una malloc() o una realloc() la free() corrispondente, per essere sicuri di non

dimenticarsela in seguito, e scrivere il codice che usa quella memoria allocata in

mezzo, fra l'allocazione e la deallocazione.

Linguaggi di livello più alto come Java hanno integrato un meccanismo chiamato

garbage collection. Java infatti non richiede che il programmatore allochi

esplicitamente la memoria dinamica attraverso funzioni come la malloc() del C: la

memoria dinamica viene gestita automaticamente dalla virtual machine, e

periodicamente sulla memoria del processo operano algoritmi di garbage collection,

che servono a deallocare automaticamente zone di memoria allocate in precedenza

quando non servono più. Tali meccanismi volendo sono disponibili anche in C,

sollevando il programmatore dall'onere della deallocazione della memoria e quindi

dal rischio di memory leak. La libreria probabilmente più famosa che implementa il

meccanismo di garbage collection sulla memoria dinamica è la libgc, disponibile per

la maggior parte delle piattaforme moderne. La libgc sostituisce alle funzioni “a

rischio” memory leak se la memoria associata non viene deallocata esplicitamente

(malloc, realloc e, come vedremo più avanti, strdup) le proprie “versioni”

(GC_malloc, GC_realloc e GC_strdup) su cui operano algoritmi di garbage

collection, sollevando quindi il programmatore dalla responsabilità della free().

Osserviamo brevemente come scrivere un sorgente che faccia uso delle funzioni di

questa libreria, ricordando che la prassi che seguiremo ora è simile a quella da

seguire ogni qual volta si voglia eseguire codice da librerie esterne nei propri

programmi in C.

Innanzitutto occorre installare la libgc sul proprio sistema (attraverso il proprio

package manager preferito se si è su un sistema Unix-like, o dal file di setup se si è

su Windows). Alla fine di un'installazione terminata con successo ci si dovrebbe

ritrovare nella directory include del proprio compilatore la directory gc con dentro il

file gc.h, e nella directory di lib il file libgc.a, o libgc.so, o libgc.dll se si opera su

Windows. Ora si può scrivere del codice che faccia uso delle funzioni della libreria:

#include <gc.h>

int main() {

int *v = GC_malloc( 100*sizeof(int) );

return 0;

}

A questo punto compiliamo il sorgente in questo modo: 67

[blacklight@wintermute ~]$ gcc -I/usr/include/gc -o noleak noleak.c

-lgc

L'opzione -I serve a identificare una nuova directory in cui cercare i file header

inclusi (in questo caso, se il file gc.h è incluso in /usr/include/gc, diciamo al

compilatore di cercare i file inclusi anche in quella directory), mentre l'opzione -lgc

dice al compilatore di linkare l'eseguibile usando la libreria libgc. Questo funziona

nel caso in cui il file di libgc sia presente in una directory contenuta, nel caso di

sistemi Unix-like, in una directory standard in cui ricercare i file di libreria (ad

esempio /usr/lib o /usr/local/lib). In caso contrario è necessario specificare

esplicitamente in che directory cercare i file di libreria usando l'opzione -L:

[blacklight@wintermute ~]$ gcc -I/usr/include/gc -L/usr/lib -o

noleak noleak.c -lgc

Pur non essendoci una free() associata alla malloc notiamo che eseguendo valgrind

sull'eseguibile non viene rilevato nessun memory leak:

[blacklight@wintermute ~]$ valgrind ./noleak

...

==3019== HEAP SUMMARY:

==3019== in use at exit: 0 bytes in 0 blocks

==3019== total heap usage: 0 allocs, 0 frees, 0 bytes allocated

==3019==

==3019== All heap blocks were freed -- no leaks are possible

L'uso di questa libreria è tuttavia abbastanza controverso. È vero che è molto comoda

e solleva il programmatore dalla responsabilità della deallocazione della memoria,

ma non sempre ci si ritrova a programmare su sistemi dove questa libreria è presente,

e il programmatore dovrebbe imparare a gestire la memoria indipendentemente dalla

presenza o meno della libgc sul suo sistema. Inoltre un programma che usa la libgc

ha una dipendenza in più, in quanto essendo una libreria dinamica il suo programma

funzionerà solo su sistemi dove è presente la libgc. Questa è una questione che un

programmatore dovrebbe sempre porsi ogni qualvolta crede che il suo software abbia

bisogno di appoggiarsi a una libreria esterna. Non è mai una buona idea reinventare

la ruota riscrivendo da zero funzioni complesse magari presenti già, meglio

implementate, in un'altra libreria, ma ridurre il proprio software a un castello di carta

di dipendenze esterne e rendere difficile la vita all'utente che vuole installarlo sulla

propria macchina e dovrà prima scaricare qualche MB di dipendenze solo per farlo

68

girare non è altrettanto una buona idea.

Funzioni che ritornano array

Come già visto gli array altro non sono che puntatori al primo elemento, quindi una

funzione che ritorni, ad esempio, un array di interi avrà semplicemente un prototipo

del genere:

int* funzione (parametri ...);

Tuttavia se scriviamo un codice del genere

int* foo() {

int i, v[10];

for ( i=0; i < 10; i++ )

v[i] = i;

return v;

}

int main() {

int *v = foo();

return 0;

}

Ci ritroviamo di fronte a una sorpresa. La sorpresa è già preannunciata da un warning

del compilatore

warning: function returns address of local variable

Se proviamo a stampare dal main il contenuto di v dopo la chiamata a foo(), ci

ritroveremo quasi sicuramente di fronte a dei valori casuali, invece di avere un array

contenente gli elementi da 0 a 9 ordinati. Questo perché v dentro foo() è dichiarato

come array statico, quindi allocato sullo stack della funzione foo(), modificato, e

ritornato. Tuttavia quando la funzione foo() termina anche il suo stack viene distrutto,

quindi il contenuto di v non è più reperibile dall'esterno, e questo spiega perché

andiamo a leggere dei valori casuali. Se volessimo correttamente ritornare un array

69

da una funzione dovremmo prima allocarlo dinamicamente attraverso una malloc(),

in modo che sia allocato sullo heap che è una zona di memoria che a differenza dello

stack non viene distrutta quando una funzione termina ma rimane viva per tutto il

processo, quindi ovviamente dovremmo ricordarci di deallocare quello spazio

quando non ci serve più.

int* foo() {

int i, *v = (int*) malloc( 10*sizeof(int) );

for ( i=0; i < 10; i++ )

v[i] = i;

return v;

}

int main() {

int *v = foo();

free(v);

return 0;

}

Puntatori a funzione

Le funzioni a basso livello non sono altro che sequenze di istruzioni binarie piazzate

nella memoria centrale, al pari di una qualsiasi variabile. È quindi possibile anche

costruire puntatori che puntino a funzioni, in quanto normali aree di memoria. La

sintassi è la seguente:

tipo (*nome_ptr)(argomenti) = funzione

Per richiamare la funzione puntata, basta poi un

(*nome_ptr)(argomenti)

Esempio:

#include <stdio.h> 70

void foo() {

printf ("Ciao\n");

}

int main() {

void (*ptr)(void) = foo;

printf ("foo si trova all'indirizzo 0x%.8x\n",ptr);

(*ptr)();

return 0;

}

o ancora

#include <stdio.h>

int foo(int a, int b) {

return a+b;

}

int main() {

int a=2,b=3;

int (*ptr)(int, int) = foo;

printf ("foo si trova all'indirizzo 0x%.8x\n",ptr);

printf ("%d+%d=%d\n",a,b,(*ptr)(a,b));

return 0;

}

Questo tipo di scrittura è molto utile in un'ottica di modularità del programma. Si può

ad esempio lasciare all'utente, o al programmatore finale nel caso di sviluppo di una

libreria, la libertà di stabilire che azioni associare a un determinato contesto. Ad

esempio scegliere a runtime che algoritmo usare per ordinare un insieme di dati, o

per effettuare l'interpolazione o l'approssimazione di un insieme di valori numerici.

Si dichiara un puntatore a funzione, a seconda delle scelte dell'utente si decide a

quale funzione farlo puntare, e si richiama direttamente il puntatore invece della

funzione. Un esempio può essere quello per gestire, ad esempio, l'evento onClick in

un form HTML, a cui si associa una funzione JavaScript. La funzione associata è

trattata a basso livello semplicemente come un puntatore a funzione.

Funzioni come parametri di altre funzioni

A questo punto nulla mi impedisce di passare come parametro di una funzione un

puntatore a funzione, e la funzione richiamata può richiamare la funzione passata

come argomento. Esempio: 71

#include <stdio.h>

void print () {

printf ("Ciao\n");

}

int foo(void (*f)(void)) {

f();

}

int main() {

foo(print);

return 0;

} 72

Stringhe

La gestione delle stringhe è alla base della programmazione in qualsiasi linguaggio di

programmazione. Ogni oggetto che viene stampato sullo schermo è una stringa. I

messaggi che abbiamo scritto finora su stdout con la printf non sono altro che

stringhe, lo stesso vale anche per le stringhe di formato della scanf ecc.

In C una stringa non è altro che un array di elementi di tipo char. Linguaggi di

programmazione più moderni, come Java, Perl, Python, PHP e lo stesso C++, tramite

l'uso della classe 'string', consentono di usare le stringhe in modo più avanzato, come

tipi predefiniti all'interno del linguaggio stesso. La visione del C (ovvero

stringhe=array di tipo char) può essere più macchinosa e a volte anche più pericolosa,

ma mette in mano al programmatore la gestione di questo tipo di entità al 100%.

Dichiarazione di una stringa

Abbiamo visto che in C una stringa non è altro che un array di elementi di tipo char.

Questo ci fa pensare subito a un tipo di dichiarazione immediato (ma alquanto

scomodo):

char my_string[] = { 'H','e','l','l','o' };

La dichiarazione vista sopra non è comodissima, ragion per cui il C consente di

dichiarare le stringhe direttamente così:

char my_string[] = "Hello";

o ancora così, sfruttando una scrittura di tipo puntatore:

char *my_string = "Hello";

Ovviamente possiamo anche dichiarare delle stringhe senza inizializzarle. In questo

caso le dichiariamo specificando il nome e la dimensione:

char my_string[20]; // Stringa che può contenere 20 caratteri

e vale anche lo stesso discorso che abbiamo fatto con gli array per l'inizializzazione

dinamica di una stringa:

char *my_string;

int n;

....... 73

printf ("Quanti caratteri deve contenere la tua stringa? ");

scanf ("%d",&n);

my_string = (char*) malloc (n*sizeof(char));

Per leggere una stringa invece possiamo ricorrere alla funzione scanf, passando come

stringa di formato '%s':

char str[20];

........

printf ("Inserisci una stringa: ");

scanf ("%s",str);

Si noti che non ho usato la scrittura '&str' nella scanf, in quanto la stringa già di suo

rappresenta un puntatore (in quanto un array non è altro che, a livello del

compilatore, un puntatore al suo primo elemento, come abbiamo visto prima).

Attenzione: l'uso della scanf per la lettura delle stringhe è potenzialmente dannoso

per la stabilità e la sicurezza di un programma. In seguito valuteremo metodi per fare

letture in tutta sicurezza. La stessa sequenza di escape “%s” usata per leggere una

stringa è dannosa, in quanto non controlla quanti caratteri vengono effettivamente

letti. Per ora ci basta pensare così per comprendere il rischio: se la mia stringa l'ho

dichiarata come una stringa da 20 caratteri, devo controllare che effettivamente non

vengano inseriti più di 20 caratteri al suo interno. Se non faccio questo controllo, i

caratteri rimanenti verranno piazzati da qualche parte in memoria al di fuori della

stringa, dando un problema di overflow che nel migliore dei casi provocherà un crash

del programma, nel peggiore comprometterà la sicurezza del sistema lasciando che

un utente non autorizzato scriva in zone di memoria in cui non è autorizzato a

scrivere ed esegua codice arbitrario. Per questo invece della sequenza di escape “%s”

è preferibile usare nella scanf la sequenza “%ns”, dove n è il numero di caratteri che

si vogliono leggere al più. Esempio:

char str[20];

........

printf ("Inserisci una stringa: ");

scanf ("%20s",str);

Così facendo mi assicuro che dall'input non verranno letti più di 20 caratteri.

Attenzione anche alla printf. Una scrittura del genere è teoricamente corretta:

char str[] = “Prova”;

printf (str); 74

Di fatto è una scrittura altamente pericolosa in quanto vulnerabile a un tipo di attacco

chiamato format string overflow, in quanto nulla mi impedisce, se io utente ho

controllo sul contenuto di str, di inserire una stringa di formato che mi consenta di

leggere contenuto arbitrario da zone di memoria adiacenti, e quel che è peggio

scriverci su e dirottare l'esecuzione del processo dove voglio io. Se devo stampare

anche solo una stringa, è meglio usare la sequenza di escape “%s” esplicitamente per

evitare questo tipo di problemi:

char str[] = “Prova”;

printf (“%s”, str);

Esercizio pratico: un programmino che prende in input una stringa e trasforma tutti i

suoi eventuali caratteri alfabetici maiuscoli in caratteri minuscoli:

#include <stdio.h>

#include <string.h>

// Funzione per la conversione di tutti i caratteri

// maiuscoli in caratteri minuscoli

void toLower(char *s) {

int i;

for (i=0; i<strlen(s); i++)

// Se il carattere corrispondente della stringa è

// un carattere maiuscolo, ovvero è compreso tra A e Z...

if ( (s[i]>='A') && (s[i]<='Z') )

s[i]+=32;

}

int main() {

char s[20];

int i;

printf ("Inserisci una stringa: ");

scanf ("%20s",s);

toLower(s);

printf ("Stringa convertita completamente in “

“caratteri minuscoli: %s\n",s);

return 0;

}

Da notare l'uso della funzione strlen, definita in string.h. Tale funzione ritorna la

lunghezza di una stringa, ovvero il numero di caratteri presenti fino al carattere

terminatore della stringa. Ogni stringa possiede infatti un carattere terminatore per

75

identificarne la fine (ovvero fin dove il compilatore deve leggere il contenuto della

stringa). Tale carattere è, per convenzione, il carattere NULL, identificato dalla

sequenza di escape '\0' e associato al codice ASCII 0. Ogni stringa quindi, anche se

non è specificato, ha N+1 caratteri, ovvero gli N caratteri che effettivamente la

compongono e il carattere NULL che ne identifica la fine:

char *str = "Hello";

// In realtà a livello del compilatore 'str' è vista come

// 'H','e','l','l','o','\0'

Con le conoscenze che abbiamo in questo momento possiamo anche capire come è

scritto il codice della funzione strlen:

unsigned int strlen(char *s) {

unsigned int len;

for (len=0; s[len] != 0; len++);

return len;

}

ovvero un ciclo for dove la variabile contatore viene incrementata finché il carattere

corrispondente all'indice all'interno della stringa non è uguale al carattere NULL

(appunto con codice ASCII uguale a 0). Il valore della variabile contatore a questo

punto rappresenta il numero effettivo di caratteri fino al NULL, ovvero il numero

effettivo di caratteri all'interno della string, valore che viene ritornato dalla funzione.

Scrivere un

for (i=0; i<strlen(s); i++)

equivale quindi a dire "cicla finché la stringa s contiene dei caratteri, o finché non

viene raggiunta la fine della stringa".

Questa scrittura

if ( (s[i]>='A') && (s[i]<='Z') )

s[i]+=32;

equivale a dire "se il carattere attuale è maggiore o uguale ad A e minore o uguale a

Z, ovvero è una lettera maiuscola, somma al suo valore ASCII attuale il valore 32".

32 è l'offset che nella tabella dei caratteri ASCII esiste tra i caratteri maiuscoli e

quelli minuscoli. Per verificare:

printf ("%d\n",'a'-'A'); 76

Operare sulle stringhe - La libreria string.h

Abbiamo incontrato nel paragrafo precedente la funzione strlen, definita in string.h.

Questo header mette a disposizione molte funzioni per operare su questi tipi di dati.

Tenteremo di esaminare le più importanti nel corso di questo paragrafo.

strcmp

La funzione strcmp (STRing CoMPare) confronta tra di loro i valori di due stringhe,

il suo prototipo è qualcosa di simile:

int strcmp(const char *s1, const char *s2);

dove s1 e s2 sono le due stringhe da confrontare. La funzione ritorna

Un valore > 0 se da un confronto byte a byte s1 ha più caratteri il cui codice

• ASCII è maggiore del corrispondente codice ASCII di s2

0 se le due stringhe sono uguali

• Un valore < 0 nei casi rimanenti

questa funzione è utilizzatissima per vedere se due stringhe hanno lo stesso

contenuto. Sono infatti completamente sbagliate scritture del genere:

char *s1;

char *s2="pippo";

........

if (s1==s2)

printf ("Ciao pippo\n");

questo perché la scrittura sopra non fa altro che vedere se il puntatore s1 è uguale al

puntatore s2, ovvero confronta gli indirizzi in memoria delle due stringhe, ed effettua

le operazioni richieste se gli indirizzi coincidono. Ciò ovviamente non sarà mai

verificato, dato che due variabili diverse in memoria hanno anche indirizzi diversi,

quindi il codice scritto sopra non funzionerà mai. Per confrontare due stringhe è

invece necessario ricorrere alla funzione strcmp, ricordando che la funzione ritorna 0

quando il contenuto di due stringhe è lo stesso. Ecco quindi la versione corretta del

codice di sopra:

char *s1;

char *s2="pippo";

........

if (!strcmp(s1,s2))

// Equivale a scrivere 77

// if (strcmp(s1,s2)==0)

printf ("Ciao pippo\n");

strncmp

La funzione strncmp è molto simile a strcmp, con l'eccezione che confronta solo i

primi n caratteri sia di s1 che di s2. La sua sintassi è qualcosa di simile:

int strncmp(const char *s1, const char *s2, size_t n);

dove n è il numero di caratteri da confrontare.

strcpy

La funzione strcpy copia una stringa in un'altra. La sua sintassi è qualcosa di simile:

char *strcpy(char *dest, const char *src);

dove dest è la stringa all'interno della quale viene copiato il nuovo valore e src è la

stringa da copiare. Il valore di ritorno della funzione è un puntatore a dest.

Quando si vuole copiare una stringa in un altra è infatti sconsigliabile usare una

scrittura del genere:

char *s1="pippo";

char *s2;

s2=s1; // ATTENZIONE!!

La scrittura di sopra infatti copia il puntatore al primo elemento della stringa s1 nella

stringa s2. Ciò vuol dire che ogni eventuale modifica di s1 modifica anche s2, dato

che entrambe le variabili agiscono sulla stessa zona di memoria, e viceversa, il che è

decisamente un effetto collaterale. La scrittura corretta è qualcosa del tipo

char *s1="pippo";

char s2[32];

strcpy(s2,s1);

in quanto la funzione strcpy genera in s2 una copia esatta di s1, che però, essendo

residente in una zona di memoria diversa, è completamente indipendente da s1.

La funzione strcpy ha un codice simile:

char* strcpy (char *s1, char *s2) {

int i;

// Finché la stringa s2 ha dei caratteri... 78

for (i=0; i<strlen(s2); i++)

// ...copia il carattere in s1

s1[i]=s2[i];

return s1;

}

Questa funzione è però potenzialmente dannosa per la sicurezza dell'applicazione e

sconsigliata. È consigliato usare al suo posto la funzione strncpy che effettua una

copia esattamente di n caratteri della stringa, evitando che eventuali byte di troppo

vadano a sovrascrivere pericolosamente zone di memoria adiacenti. Per maggiori

approfondimenti rimando al capitolo "Uso delle stringhe e sicurezza del programma".

strncpy

La funzione strncpy ha una sintassi molto simile a strcpy, con la differenza che copia

solo i primi n caratteri della stringa sorgente nella stringa di destinazione, per tenere

il processo di copia sotto controllo ed evitare problemi di sicurezza nell'operazione,

come vedremo in seguito. La sua sintassi è

char *strncpy(char *dest, const char *src, int n);

La sintassi è la stessa di strcpy, a parte per n, che identifica appunto il numero di

caratteri di src che verranno copiati in dest. L'uso di questa funzione è preferibile a

quello di strcpy quando possibile, proprio per evitare problemi di sicurezza legati ad

una copia non controllata.

strcat

La funzione strcat concatena l'inizio di una stringa alla fine di un'altra. La sua sintassi

è

char *strcat(char *dest, const char *src);

dove dest è la stringa alla cui fine viene concatenata la stringa src. Esempio di

utilizzo:

#include <stdio.h>

#include <string.h>

int main() {

char s1[20];

char *s2 = "pippo";

// Copio all'interno di s1 la stringa "Ciao "

// copiando esattamente il numero di byte che

// mi servono, tramite l'operatore sizeof

strncpy (s1, "Ciao ", sizeof("Ciao "));

strcat (s1,s2);

// s1 ora contiene "Ciao pippo" 79

return 0;

}

La funzione strcat ritorna un puntatore a char che rappresenta un puntatore alla zona

di memoria dove è salvato dest.

Questa funzione è però potenzialmente dannosa per la sicurezza dell'applicazione e

sconsigliata. È consigliato usare al suo posto la funzione strncat che effettua una

copia esattamente di n caratteri della stringa, evitando che eventuali byte di troppo

vadano a sovrascrivere pericolosamente zone di memoria adiacenti. Per maggiori

approfondimenti rimando al capitolo "Uso delle stringhe e sicurezza del programma".

strncat

La sua sintassi è molto simile a strcat, con la differenza che in strncat vanno

specificati anche il numero di caratteri di src da copiare in dest, in modo da tenere la

copia sotto controllo. La sua sintassi è

char *strncat(char *dest, const char *src, int n);

dove n rappresenta il numero di caratteri di src da copiare. Il suo uso, quando

possibile, è preferibile a quello di strcat.

strstr

La funzione strstr serve per verificare se esiste una sottostringa all'interno della

stringa di partenza. La sua sintassi è

char *strstr(const char *haystack, const char *needle);

dove haystack (lett. 'pagliaio') è la stringa all'interno della quale cercare, needle (lett.

'ago') è la stringa da cercare (da notare ancora una volta il sottile umorismo degli

sviluppatori del C). La funzione ritorna

Un puntatore intero, che rappresenta la zona di memoria in cui è stata trovata

• la sottostringa, nel caso in cui la sottostringa dovesse essere trovata

NULL nel caso in cui la sottostringa non dovesse essere trovata

Esempio:

/*

* Questo programmino chiede in input all'utente due stringhe e

* verifica se la seconda stringa è localizzata all'interno della

prima

*/

#include <stdio.h>

#include <string.h>

int main() { 80

char s1[32];

char s2[32];

printf ("Inserire la stringa all'interno della quale cercare: ");

scanf ("%s",s1);

printf ("Inserire la stringa da cercare: ");

scanf ("%s",s2);

if (strstr(s1,s2))

// Equivale a scrivere

// if (strstr(s1,s2) != 0)

// ovvero se il valore di ritorno della funzione non è NULL

printf ("Stringa \"%s\" trovata all'interno di \"%s\", in

posizione %d\n",

s2,s1,(strstr(s1,s2)-s1));

else

printf ("Stringa \"%s\" non trovata “

“all'interno di \"%s\"\n",s2,s1);

return 0;

}

Si noti questa scrittura:

strstr(s1,s2)-s1

strstr ritorna infatti l'indirizzo dell'area di memoria in cui si trova s2 all'interno di s1.

Se a questo indirizzo sottraggo l'indirizzo di s1, ovvero l'indirizzo del primo carattere

di s1, ottengo la locazione effettiva della sottostringa all'interno della stringa di

partenza. Se ad esempio s1="Ciao pippo" e s2="pippo", strstr(s1,s2)-s1 = 5.

Altre funzioni sulle stringhe

sprintf

La funzione sprintf, definita in stdio.h, è del tutto analoga alla printf. La differenza è

che la printf scrive dell'output formattato su standard output, mentre la sprintf scrive

dell'output formattato direttamente su una stringa, che rappresenta il primo

argomento della funzione. Esempio:

#include <stdio.h>

int main() {

char s1[32];

char s2="Ciao";

char s3="pippo";

int age=24;

sprintf (s1, "%s %s ho %d anni", s2, s3, age); 81

// Scrivo su s1 attraverso la sprintf

// Ora s1 contiene la stringa "Ciao pippo ho 24 anni"

}

Anche la funzione sprintf è sulla lista di quelle da usare con cautela, e solo quando si

è sicuri che la stringa di destinazione è in grado di contenere tutti i byte che si stanno

per copiare al suo interno. Se non si ha questa sicurezza, è preferibile usare snprintf

che controlla che non vengano copiati nella stringa di destinazione più di n caratteri.

snprintf

La funzione snprintf è un'alternativa più sicura alla sprintf, e al suo interno va

specificato anche il numero massimo di caratteri da copiare. La sua sintassi è quindi

int snprintf(char *str, int size, const char *format, ...);

dove size rappresenta il numero massimo di byte della stringa di formato da copiare

all'interno di str.

sscanf

La funzione sscanf è del tutto analoga alla scanf classica, solo che invece di leggere i

dati dalla tastiera li legge dall'interno di una stringa. Esempio, ecco un uso classico di

sscanf. Abbiamo una stringa che rappresenta una data, in formato 'gg/mm/aaaa'.

Vogliamo ottenere, dall'interno di questa stringa, il giorno, il mese e l'anno e salvarli

all'interno di 3 variabili intere. Con sscanf la cosa è presto fatta:

char *date = "13/08/2007";

int d,m,y;

sscanf (date,"%d/%d/%d",&d,&m,&y);

// d=13, m=8, y=2007

La funzione ritorna un intero che rappresenta il numero di campi letti all'interno della

stringa. Il controllo su questo valore di ritorno può tornare utile per verificare se

l'utente ha inserito la stringa nel formato giusto:

#include <stdio.h>

#include <stdlib.h>

int main() {

char date[16];

int d,m,y;

printf ("Inserisci una data: ");

scanf ("%16s",date);

// Se non leggo almeno 3 interi nella stringa

// inserita separati da '/'...

if (sscanf(date,"%d/%d/%d",&d,&m,&y) != 3) { 82

printf ("Errore: data inserita non valida: %s\n",date);

return 1;

}

printf ("Giorno: %d\n",d);

printf ("Mese: %d\n",m);

printf ("Anno: %d\n",y);

return 0;

}

gets

Un piccolo limite della lettura delle stringhe sta nel fatto che la lettura si interrompe

quando incontra uno spazio. Se ad esempio un'applicazione richiede all'utente di

inserire una stringa e l'utente inserisce "Ciao mondo", se la lettura avviene tramite

scanf molto probabilmente la stringa risultante dopo la lettura sarà semplicemente

"Ciao". Per evitare questo piccolo inconveniente si ricorre alla funzione gets,

nonostante il suo uso sia deprecato dai nuovi standard C. Esempio di utilizzo:

#include <stdio.h>

int main() {

char str[32];

printf ("Inserisci una stringa: ");

gets (str);

// Se ora inserisco stringhe con degli spazi in mezzo vengono

salvate ugualmente nella

// stringa finale, in quanto la gets legge tutti i caratteri fino

al fine linea

printf ("Stringa inserita: %s\n",str);

return 0;

}

Attenzione: anche la gets è nella lista delle funzioni a rischio. Anzi, si può dire che

un programma che usi tale funzione è SEMPRE a rischio overflow, ed è mantenuta

nella libreria standard del C solo per retrocompatibilità. Se si usa un sorgente che fa

uso di gets() è il compilatore stesso a sollevare un warning:

warning: the `gets' function is dangerous and should not be used.

Se si vuole leggere un'intera riga da input senza fermarsi al primo spazio è meglio

valutare delle alternative, fra cui: 83

fgets(), che può essere vista come una versione “sicura” di gets() (la vedremo

• fra un attimo)

la libreadline (vedremo anche questa fra un attimo)

• la scrittura “in casa” di una funzione che legga dell'input dallo stdin finché

• non incontra '\n' (ovvero finché non viene premuto invio), usando

l'allocazione dinamica della memoria:

#include <stdio.h>

#include <stdlib.h>

char* safe_gets () {

char *s = NULL;

char ch;

unsigned int size = 0;

// Finché leggo un carattere da input e questo

// carattere è diverso da '\n'...

while ((ch = getchar()) != '\n') {

// Creo un nuovo elemento in s e piazzo in coda

// il nuovo carattere

s = (char*) realloc( s, ++size );

s[size-1] = ch;

}

// Termino la stringa correttamente con un '\0' in coda

if ( size > 0 )

s[size] = 0;

return s;

}

int main() {

char* s = safe_gets();

printf ("Hai inserito: %s\n", s);

free(s);

return 0;

}

fgets

La fgets() è una funzione che opera nativamente sui file (vedremo in seguito come)

leggendo una riga da essi, ma può essere usata anche per leggere da stdin, come una

versione “safe” di gets(). Prende come parametri la stringa in cui salvare la sequenza

letta, il numero di byte da leggere, e il descrittore del file da cui leggere (se si vuole

leggere da tastiera, basta passare stdin). Esempio: 84

char nome[30];

printf (“Inserisci il tuo nome e il tuo cognome: “);

fgets ( nome, 30, stdin );

nome[strlen(nome)-1] = 0;

printf (“Ti chiami %s\n”, nome);

Il comando dato dopo fgets() è necessario perché tale funzione legge anche il

carattere '\n', mettendolo alla fine della stringa. Se non si vuole terminare la stringa

con un '\n' si piazza il carattere terminatore '\0' al suo posto (ovvero una posizione

prima della fine della stringa).

libreadline

Una seconda via per la lettura da stdin, adottata anche dalle shell Unix (bash e zsh in

primis) e da moltissime altre applicazioni, è la libreria esterna libreadline. Questa

libreria è installata di default praticamente su tutti i sistemi Unix-based, anche se su

alcuni può essere necessario installare il pacchetto libreadline-dev per poter

compilare i propri sorgenti con questa libreria. libreadline non solo offre il

salvataggio di una qualsiasi sequenza passata via stdin dentro una stringa, ma offre

anche meccanismi ben più avanzati, fra cui un sistema di history per salvare le

stringhe lette e la possibilità di poter editare la riga scritta sul terminale attraverso

una specie di mini-editor, che può supportare perfino degli elementari comandi di

editing in stile Vi o Emacs, a seconda dei gusti dell'utente (ad esempio per fare in

modo che tutte le applicazioni che usano la libreadline supportino un editing della

riga passata in input in stile Vi, ad esempio premendo ESC da terminale per andare in

command mode, dw per cancellare una parola, fc per cercare il carattere c, c$ per

modificare tutto ciò che c'è dalla posizione attuale del cursore fino alla fine della riga,

e così via, basta piazzare nel file $HOME/.inputrc l'opzione set editing-mode vi).

Una volta appurata la presenza della libreria sul proprio sistema è molto semplice

scrivere codice che la usi:

#include <readline.h>

#include <history.h>

int main() {

char *line = readline(“Inserisci una stringa: “); 85

return 0;

}

Ancora una volta, per la compilazione del sorgente che si appoggia a una libreria

esterna la procedura sarà

[blacklight@wintermute ~]$ gcc -I/usr/include/readline -o

test_readline test_readline.c -lreadline

atoi

La funzione atoi (ASCII to int), definita in stdlib.h, è usata per convertire una stringa

in un valore intero. La sua sintassi è molto semplice:

int atoi(const char *nptr);

e restituisce il valore convertito. Nel caso in cui la stringa non contenga un valore

numerico valido, la funzione ritorna zero. Esempio di utilizzo:

#include <stdio.h>

#include <stdlib.h>

main() {

int n;

char *s = "3";

n=atoi(s);

// Ora n contiene il valore intero 3

}

Della stessa famiglia sono le funzioni atol (ASCII to long) e atof (ASCII to float).

Gestione di stringhe binarie

Finora abbiamo esaminato stringhe di testo, ovvero contenenti caratteri ASCII

stampabili e la cui fine è identificata dal carattere con codice ASCII 0. Questo non è

affatto l'unico caso di sequenze di dati in cui ci si può imbattere, anzi è molto comune

il caso in cui si debbano gestire array di char che altro non sono che sequenze di dati

binari qualsiasi. In questo caso le funzioni e l'approccio che abbiamo visto finora non

funzionano più, in quanto la presenza del carattere 0 può diventare “legale” anche nel

mezzo della stringa, e non identificare più la fine della stringa. Se ad esempio

dichiarassimo una stringa del genere 86

char str[5] = “\x01\x00\x02\x03\x0a”;

Ovvero contenente in esadecimale la sequenza { 1, 0, 2, 3, 10 } (questa sequenza può

essere stata letta pari pari da un socket di rete, da un dispositivo fisico o da un file

binario). Se volessimo calcolare la lunghezza di questa stringa via strlen, ad esempio,

vedremo che tale funzione ritornerà 1, in quanto dopo il carattere 0x01 è piazzato il

byte nullo, 0x00, che per le stringhe di testo identifica la fine della stringa. Se

copiassimo via strcpy o simili il contenuto di quella stringa in un'altra stringa

vedremo che la copia si ferma dopo il primo carattere, per lo stesso motivo. Per

fortuna il C mette a disposizione anche funzioni per gestire stringhe binarie. Ma in

questo caso, ovviamente, non essendoci più un carattere a identificare la fine delle

stringhe dovrà essere il programmatore a sapere quanti byte gestire e quanto saranno

grandi i suoi buffer. Si noti che tutte queste funzioni prendono e ritornano argomenti

che sono void*, non char*, in quanto possono operare tanto su stringhe, tanto su zone

di memoria raw di qualsiasi tipo (interi, float, tipi di dati strutturati, ecc.).

memset

Prototipo:

void *memset(void *s, int c, size_t n);

Tale funzione riempie un'area di memoria s, riempiendo n byte con un valore

costante c. Esempio:

char seq[10];

memset ( seq, 0, 10 );

In questo caso abbiamo riempito di zeri i 10 byte di seq.

memcpy

Prototipo: 87

void *memcpy(void *dest, const void *src, size_t n);

Tale funzione copia n byte di src dentro dest.

memmem

Prototipo:

void *memmem(const void *haystack, size_t haystacklen,

const void *needle, size_t needlelen);

Tale funzione cerca la sequenza needle di lunghezza needlelen dentro haystack, di

lunghezza haystacklen, e ritorna un puntatore alla zona di memoria in cui è stata

trovata l'occorrenza, o NULL se non è stata trovata, in modo simile a strstr. 88

Argomenti passati al main

Sappiamo che molte applicazioni accettano una lista di parametri passati dall'utente

in input. Ad esempio, il comando dir del DOS è in grado di accettare alcuni parametri

per configurarne il funzionamento (ad esempio dir /h e simili...). Idem per net send

(net send indirizzo_host messaggio) e per, ad esempio, il comando ls di Unix (ls -l

-h). È possibile passare questi parametri ad un programma sfruttando gli argomenti

del main. Il main è una funzione come tutte le altre, e quindi può anche ricevere

argomenti in input. In particolare, è possibile leggere i parametri eventuali passati ad

un programma tramite l'uso di argv, un vettore di stringhe da passare al main. Il

numero di argomenti passati viene invece salvato nella variabile intera argc. Esempio

di utilizzo:

#include <stdio.h>

main(int argc, char **argv) {

printf ("Nome dell'eseguibile in esecuzione: %s\n",argv[0]);

}

La prima stringa del vettore argv contiene infatti il nome dell'eseguibile (quindi la

variabile argc è sempre settata almeno a uno). Gli eventuali argomenti successivi

passati al programma vengono salvati in argv[1],...,argv[n]. Esempio pratico:

#include <stdio.h>

main(int argc, char **argv) {

int i;

printf ("Argomenti passati al programma:\n");

for (i=1; i<argc; i++)

printf ("%s\n",argv[i]);

}

Se compiliamo questo eseguibile come 'stampa_arg' e lo invochiamo con gli

argomenti "Ciao mondo come stai", così (in ambiente Unix):

./stampa_arg Ciao mondo come stai

avremo come output qualcosa del tipo

Argomenti passati al programma: Ciao mondo come stai 89

Uso delle stringhe e sicurezza del

programma

Nei paragrafi precedenti abbiamo preso in esame alcune funzioni sulle stringhe che

possono rivelarsi potenzialmente dannose per la stabilità e la sicurezza di

un'applicazione. In questo paragrafo esamineremo i rischi concreti connessi ad una

cattiva gestione delle stringhe.

Esempio pratico:

#include <stdio.h>

#include <string.h>

int main() {

char s1[2];

char *s2 = "Questa e' una stringa di prova";

strcpy (s1,s2);

return 0;

}

Eseguendo un codice del genere molto probabilmente l'applicazione andrà in crash.

Se siamo su un sistema Unix il kernel ci risponderà con un bel segmentation fault, su

Windows ci comparirà una finestra che ci avverte che l'applicazione ha tentato la

scrittura su un'area di memoria non valida. Quello che abbiamo fatto è tentare di

copiare in un buffer più byte di quelli che il buffer stesso può contenere, e in modo

non controllato (la strcpy non effettua una copia controllata, non si ferma se i limiti di

capienza della stringa vengono raggiunti). Il risultato è che l'applicazione va in crash,

in quanto, attraverso la strcpy, è andata a scrivere su una zona di memoria al di fuori

di quella della stringa stessa, andando a sovrascrivere l'indirizzo di ritorno della

funzione con un indirizzo non valido. L'indirizzo viene letto dalla CPU, che tenta di

leggere l'istruzione a quell'indirizzo. Indirizzo che nella maggior parte dei casi non

sarà un indirizzo di memoria valido, quindi provocherà il crash del programma.

Ma il crash del programma, nonostante sia un danno non da poco, non è nemmeno il

minore dei danni. Esempio pratico con un'applicazione:

#include <stdio.h>

#include <string.h>

int main(int argc, char **argv) {

char str[16];

strcpy (str,argv[1]); 90

return 0;

}

Attenzione all'uso di strcpy in questa applicazione. La funzione copia il primo

argomento passato al programma nella stringa str, che può tenere 16 caratteri, senza

fare ulteriori controlli sulla lunghezza effettiva della stringa da copiare. Proviamo ad

avviare l'applicazione con il nostro debugger preferito (in questo caso userò Gdb) per

vedere cosa succede in memoria quando passo al programma un argomento molto

lungo:

(gdb) run `perl -e 'print "A" x32'`

Starting program: /home/blacklight/prog/c/5 `perl -e 'print "A" x32'`

Program received signal SIGSEGV, Segmentation fault.

0x41414141 in ?? ()

Il comando `perl -e 'print "A" x32'` non fa altro che richiamare l'interprete Perl (un

linguaggio di programmazione), stampando la lettera "A" 32 volte (un modo per

evitare di scrivere 32 volte "A", giusto una comodità). Il programma, tentando di

copiare un buffer troppo grande in una stringa che non è in grado di contenere tanti

byte, va in crash. Ma vediamo cosa succede a livello dei registri:

(gdb) i r

eax 0xbfab7890 ­1079281520

ecx 0xfffff098 ­3944

edx 0xbfab8818 ­1079277544

ebx 0xb7edeffc ­1209143300

esp 0xbfab78b0 0xbfab78b0

ebp 0x41414141 0x41414141

esi 0xbfab7934 ­1079281356

edi 0xbfab78c0 ­1079281472

eip 0x41414141 0x41414141

eflags 0x210282 [ SF IF RF ID ]

cs 0x73 115

ss 0x7b 123

ds 0x7b 123

es 0x7b 123

fs 0x0 0

gs 0x33 51

Da notare il registro EIP. Tale registro contiene, nelle architetture Intel-based,

l'indirizzo in memoria della prossima istruzione da eseguire. L'indirizzo è stato

sovrascritto da una sequenza di 0x41. E 0x41, in esadecimale, corrisponde al

carattere ASCII "A". In pratica il nostro buffer lungo è andato a sovrascrivere il

registro EIP, cambiando il valore dell'indirizzo della prossima istruzione da pescare

91

in memoria. In questo caso, la sequenza di 0x41 non rappresenta un indirizzo di

memoria valido, o almeno un indirizzo nel quale il programma può accedere, ragion

per cui il programma crasha. Ma, oltre ad una semplice sequenza di "A", possiamo

anche inserire un buffer costruito apposta, che inietta nel registro un indirizzo valido

che punta ad un codice arbitrario. Siamo quindi nella situazione di un buffer overflow

sfruttato in modo da poter eseguire codice arbitrario sul sistema, codice che può

mirare ad aggiungere un nuovo utente con certi privilegi su quel sistema, ad ottenere

i privilegi di amministratore in modo indebito o ad aprire una shell remota o locale in

modo indebito. In ogni caso, quando un attaccante ha sfruttato un codice vulnerabile

iniettando del codice arbitrario al suo interno ha il controllo totale della macchina,

anche se indebito. I bollettini di sicurezza in giro per il web pullulano di bug del

genere trovati ancora oggi in molte applicazioni, e dovuti proprio all'uso errato di

funzioni come quelle che abbiamo visto sopra, bug che in genere sono corretti il più

in fretta possibile dopo la scoperta per evitare che i danni ai sistemi che usano quelle

applicazioni diventino maggiori. Non vedremo in questa sede, per evitare di divagare

troppo nel discorso, in che modo sfruttare tali vulnerabilità per acquisire il controllo

di un sistema, ma per ora ci basta sapere che usando certe funzioni la cosa è possibile

e sapere in che modo funziona.

In conclusione, le funzioni potenzialmente vulnerabili a buffer overflow e da usare

con cautela sono:

scanf

• gets

• strcpy

• strcat

• sprintf

Queste funzioni vanno usate solo quando si è sicuri al 100% delle dimensioni del

buffer di destinazione. In alternativa, è più sicuro usare funzioni come

fgets

• strncpy

• strncat

• snprintf

Soffermiamoci un attimo sulla fgets (ne faremo solo una trattazione sommaria per le

stringhe in questa sede, mentre la studieremo in modo più approfondito nel capitolo

sui file). Abbiamo visto prima che, per la lettura di una stringa da input, sia l'uso di

scanf che di gets è pericoloso. Per leggere stringhe la cosa migliore è fare ricorso a

questa funzione, che prende come primo argomento la stringa di destinazione, come

secondo argomento il numero massimo di caratteri da leggere da input e come terzo

argomento il descrittore da cui leggere (nel nostro caso lo standard input, identificato

da stdin). Esempio di uso:

char str[16];

printf ("Inserisci una stringa: "); 92

fgets (str,sizeof(str),stdin);

str[strlen(str)-1]=0;

printf ("Stringa inserita: %s\n",str);

La notazione sizeof(str) dice di leggere da input al massimo tanti caratteri quanti

sono quelli supportati dalla dimensione di str (ovvero 16 in questo caso), mentre

stdin, costante definita in stdio.h, identifica lo standard input. La scrittura

str[strlen(str)-1]=0; serve perché la funzione fgets salva nella stringa anche la

pressione del carattere invio. Questa scrittura setta il carattere NULL un byte prima,

in modo da rimuovere il carattere invio dalla stringa.

Attenzione anche ad evitare scritture del genere:

char *str = "Ciao";

printf (str);

Se non viene specificato esplicitamente il formato della stringa da stampare nella

printf l'applicazione può potenzialmente essere vulnerabile a format string overflow,

una vulnerabilità scoperta abbastanza recentemente che consente di scrivere dati

arbitrari sullo stack. Seguendo questi passi per evitare buffer overflow e format string

overflow si può essere sicuri almeno a un 70% di scrivere applicazioni relativamente

sicure. 93

Funzione ricorsive

A volte capita di avere a che fare con problemi che sono difficilmente risolvibili

ricorrendo a funzioni imperative “standard”. In alcuni casi, invece di avere una

visione “di insieme” del problema da risolvere può essere più comodo avere una

visione “particolareggiata”, progettare un algoritmo che risolva parte del problema e

ripetere quest'algoritmo finché il problema non è risolto del tutto.

Esempio informale di ricorsione

Un esempio pratico: immaginiamo di dover ordinare un array di numeri in senso

crescente. La soluzione che ci viene in mente ora, senza applicare algoritmi ricorsivi,

è quella di cercare il valore più grande all'interno dell'array, spostarlo nell'ultima

posizione e poi ordinare l'array escludendo il termine appena “ordinato”, e ripetere

questa procedura finché l'array non contiene più nessun elemento da ordinare.

Questo modo però implica una visione “di insieme” del problema, e per questo non è

la più efficiente (è un algoritmo chiamato naive sort).

E se invece dividessimo via via l'array in parti più piccole, fino ad arrivare ad array

contenenti ognuno due elementi? Potremmo ordinare ognuno di questi mini-array (si

tratterebbe al massimo di fare uno scambio tra due elementi), quindi ricorsivamente

in questo modo risalire ad un array ordinato. Questa è la soluzione più ottimizzata in

termini di prestazioni, ed implica un nuovo approccio alla risoluzione di un

problema: un approccio ricorsivo. Dal particolare (l'ordinamento di array di due

elementi) si passa al generale (l'ordinamento di un intero array di dimensioni

maggiori), facendo in modo che la funzione di ordinamento richiami sempre se stessa

(questo è un algoritmo di merge sort, implementato di default in linguaggi come Java

e Perl). 94

Esempio pratico di ricorsione

Facciamo un esempio pratico di ricorsione: il classico calcolo del fattoriale. Il

fattoriale di un numero intero n è n! = n*(n-1)*(n-2)*...*1

Con i cicli classici che abbiamo visto finora potremmo scriverlo così:

/* Questo è il main() */

int main() {

int n;

printf ("Inserire un numero intero: ");

scanf ("%d",&n);

printf ("Fattoriale di %d: %d\n",n,fat(n));

return 0;

}

/* Questa è la funzione che calcola il fattoriale */

int fat(int n) {

int i,f=1;

for (i=n; i>0; i--)

f *= i;

return f;

}

Vediamo ora come riscrivere la funzione fat() in modo ricorsivo, senza nemmeno

usare il ciclo for. Di volta in volta la variabile di appoggio i viene decrementata di

un'unità. Proviamo invece a ragionare in modo ricorsivo:

Ho una variabile n di cui voglio calcolare il fattoriale:

n! = n*(n-1)*(n-2)*...*1

Ma (n-1)! = (n-1)*(n-2)*...*1 -> n! = n*(n-1)! Ma (n-2)! = (n-2)*(n-3)*...*1 -> (n-1)!

= (n-1)*(n-2)! E così via

Per calcolare il fattoriale di n posso quindi semplicemente moltiplicare n per il

fattoriale di n-1, che a sua volta è n-1 moltiplicato per il fattoriale di n-2, e così via

finché non arrivo a 1.

Ecco l'implementazione:

int fat(int n) {

if (n==1)

return 1;

else return n*fat(n-1);

}

Un'implementazione molto più semplice e immediata. 95

Ricorsione tail e non-tail

Una forma di questo tipo di definisce una forma ricorsiva di tipo non-tail. Una forma

ricorsiva si definisce di tipo non-tail quando nella direttiva di ritorno (return) non

compare solo la chiamata alla funzione ricorsiva, ma anche un parametro (in questo

caso n, che viene moltiplicato per la funzione ricorsiva). Quando invece nella

direttiva di ritorno è presente solo la chiamata alla funzione ricorsiva, allora abbiamo

a che fare con una forma ricorsiva di tipo tail. Facciamo un esempio di funzione che

sfrutti una ricorsione di tipo tail. Vogliamo creare una funzione che, dato un array di

interi, ritorna il numero di elementi nulli al suo interno. Potremmo anche crearla in

modo “standard”, con un normale ciclo for o con un ciclo while:

/*

La funzione countNull accetta come parametri un vettore di

interi e la dimensione del vettore stesso, e ritorna il numero

di zeri contenuti all'interno del vettore

*/

int countNull ( int *v, int dim ) {

int i=0,count=0;

// Se il vettore non ha elementi, ritorna 0

if (!dim)

return 0;

else // Finché il vettore ha elementi, controllo se

// l'elemento è zero. Se sì, incremento la variabile

// contatore

for (i=0; i<dim; i++)

if (!v[i]) count++;

return count;

}

Ecco invece come strutturare la funzione con una ricorsione tail:

Se la posizione attuale all'interno del vettore è l'ultima, ritorna il numero di zeri

contati nel vettore Se alla posizione attuale all'interno del vettore corrisponde uno

zero, incrementa la variabile contatore Ritorna la funzione stessa sullo stesso vettore

della stessa dimensione ma sull'elemento successivo nel vettore

int countNull(int *v, int dim, int i) {

if (i==dim)

return zero;

if (v[i]==0)

zero++;

return countNull(v,dim,i+1);

}

In questo caso, quando richiamiamo la funzione dobbiamo anche specificare il valore

96

iniziale della variabile i. Poiché vogliamo cominciare dall'inizio del vettore, i varrà 0.

97

Algoritmi di ordinamento

Una delle caratteristiche irrinunciabili in un calcolatore è la capacità di ordinare dati.

È così irrinunciabile che il nome che i francesi danno al computer moderno è

ordinateur, ordinatore. Gli informatici nel corso degli anni hanno studiato e messo a

punto molti algoritmi di ordinamento, ovvero algoritmi in grado di ordinare insiemi

di dati (nel nostro caso array). Ciò che differenzia un algoritmo dall'altro è il suo

grado di ottimizzazione, ovvero il numero medio di passi compiuti per giungere allo

scopo finale (ovvero avere un vettore ordinato in senso crescente o decrescente), e

spesso e volentieri un algoritmo abbastanza immediato per il nostro modo di

ragionare non lo è per il calcolatore, e viceversa. Ecco che la necessità di risparmiare

in fatto di tempo di esecuzione del codice sul calcolatore (necessità che diventa

irrinunciabile quando si deve ordinare una grande mole di dati) ha portato col tempo

allo sviluppo di algoritmi di ordinamento via via più complessi per la logica umana,

ma estremamente ottimizzati per il calcolatore. In questa sede prenderemo in esame

gli algoritmi più usati, andando in ordine crescente in quanto a complessità (e

decrescente in quanto a ottimizzazione):

Naive sort

Si tratta dell'algoritmo di ordinamento più semplice e anche meno ottimizzato per il

calcolatore. Quello che fa è trovare in un vettore la posizione dell'elemento più

grande. Se la sua posizione non è alla fine del vettore (infatti in un vettore ordinato in

modo crescente l'elemento più grande si trova alla fine) allora scambia tra di loro

l'elemento all'ultima posizione e il valore massimo, in modo che l'elemento più

grande si trovi all'ultima posizione. All'iterazione successiva viene considerato il

vettore come di dimensione dim-1, dove dim è la dimensione di partenza. Vengono

effettuate tali iterazioni finché la dimensione del vettore non è uguale a 1 (ovvero il

vettore è ordinato). Esempio pratico dell'algoritmo:

v = {1,0,5,4}

v = {1,0,4,5}

v = {0,1,4,5}

Ed ecco come scriverlo in C (esempio applicato a un vettore di interi):

// Procedura per lo scambio dei valori tra due variabili

void swap (int *a, int *b) {

int *tmp; 98

tmp=a;

a=b;

b=tmp;

}

int findPosMax(int *v, int n) {

int i,p=0; /* ipotesi: max = v[0] */

// Ciclo su tutti gli elementi dell'array

for (i=1; i<n; i++)

// Se l'elemento attuale è maggiore dell'elemento massimo,

// allora il nuovo indice del massimo è quello appena trovato

if (v[p]<v[i]) p=i;

return p;

}

void naiveSort(int *v, int dim) {

int p;

// Finché nel vettore ci sono elementi...

while (dim>1) {

// ...trova la posizione dell'elemento più grande

p = findPosMax(v, dim);

// Se la sua posizione non è alla fine del vettore,

// scambia tra di loro l'elemento massimo e l'ultimo elemento

if (p < dim-1) scambia(&v[p],&v[dim-1]);

// Decrementa la dimensione del vettore

dim--;

}

}

Bubble sort

Il bubble sort è un algoritmo più efficiente del naive anche se leggermente meno

intuitivo. Il difetto principale del naive sort è infatti quello che non si accorge quando

il vettore è già ordinato, e in tal caso continua a effettuare iterazioni su di esso. Il

bubble sort corregge questo difetto considerando coppie adiacenti di elementi nel

vettore, e non il vettore nella sua interezza, e partendo dal presupposto che il vettore

sia ordinato. Se due coppie adiacenti qualsiasi sono scambiate tra di loro (prima il

valore più grande e poi quello più piccolo) effettua uno scambio, e quindi vuol dire

che il vettore non era ordinato. Se invece non si verifica alcuno scambio il vettore è

già ordinato, e quindi l'algoritmo termina.

Esempio applicativo: 99

Ecco un codice dell'algoritmo:

// Prende come argomenti il vettore da ordinare e la sua dimensione

void bubbleSort(int *v, int dim){

int i;

bool ordinato = false;

// Finché ci sono elementi nel vettore e il vettore non è

ordinato...

while (dim>1 && !ordinato) {

// Ipotesi: vettore ordinato

ordinato = true;

// Per tutti gli elementi nel vettore

for (i=0; i<dim-1; i++)

// Se l'i-esimo elemento è maggiore dell'i+1-esimo elemento...

if (v[i]>v[i+1]) {

// ...scambia tra di loro i due elementi

swap(&v[i],&v[i+1]);

// Il vettore NON è ordinato

ordinato = false;

} 100

// Considera il vettore come di dimensione dim-1

dim--;

}

}

Insert sort

L'insert sort è un algoritmo che parte da un approccio diverso da quelli visti finora:

per ottenere un vettore ordinato basta costruirlo ordinato, inserendo ogni elemento al

posto giusto. Ecco un esempio grafico:

Per implementarlo useremo due funzioni. La funzione insertSort prende come

parametri il vettore da ordinare e la sua dimensione, e, per i che va da 0 a N-1,

inserisce alla posizione corretta all'interno del sottovettore v[0],...,v[i] l'i-esimo

elemento del vettore:

void insertSort(int *v, int dim) {

int i;

// Ciclo su tutti gli elementi

for (i=1; i<dim; i++)

// Inserisco al posto giusto l'i-esimo elemento

insMinore(v,i);

}

La funzione insMinore prende come parametri il vettore e la posizione dell'elemento

da ordinare. Questa funzione determina la posizione in cui va inserito l'elemento alla

posizione specificata, crea lo spazio per l'inserimento spostando gli elementi

all'interno del vettore ed effettua l'inserimento:

void insMinore(int *v, int lastpos) {

int i, x = v[lastpos];

for (i = lastpos-1; i>=0 && x<v[i]; i--)

v[i+1]= v[i]; /* crea lo spazio */

v[i+1]=x;

} 101

Quick sort

Avvicinandoci via via ad algoritmi sempre più ottimizzati giungiamo al quick sort,

algoritmo di default per l'ordinamento usato ancora oggi dal C. Il quick sort si basa

su un principio relativamente semplice: ordinare un vettore di piccole dimensioni è

molto meno costoso dell'ordinare un vettore di grandi dimensioni. L'idea è quella di

dividere il vettore di principio in due sottovettori, con un elemento intermedio

(chiamato pivot). Le celle di memoria prima del pivot conterranno tutti gli elementi

minori del pivot, quelle successive gli elementi maggiori del pivot. A questo punto

l'algoritmo viene applicato ricorsivamente ai due sottovettori, fino ad arrivare a

vettori di dimensione unitaria che, per definizione, sono già ordinati. Ecco una

piccola animazione che illustra il funzionamento:

Ed ecco una possibile specifica:

void qSort(int v[], int first, int last){

if (vettore non vuoto)

<scegli come pivot l’elemento medio>

<isola nella prima metà vettore gli

elementi minori o uguali al pivot e

nella seconda metà quelli maggiori>

<richiama quicksort ricorsivamente

sui due sottovettori >

}

Codice:

void qSort(int *v, int first, int last){

int i,j,pivot;

if (first<last) {

// Partenza: i parte dal primo elemento del vettore, j

dall'ultimo

i = first; j = last;

// Il pivot è l'elemento medio del vettore

pivot = v[(first + last)/2]; 102

do {

// Finché l'elemento generico i-esimo a sinistra del pivot

// è minore del pivot, incrementa i

while (v[i] < pivot) i++;

// Finché l'elemento generico j-esimo a destra del pivot

// è maggiore del pivot, decrementa j

while (v[j] > pivot) j--;

// Altrimenti, scambia tra loro l'elemento i-esimo e quello j-

esimo if (i <= j) {

swap(&v[i], &v[j]);

i++, j--;

}

} while (i <= j); // Cicla finché i e j non si incontrano

// Richiama il quick sort sul primo sottovettore

qSort(v, first, j);

// Richiama il quick sort sul secondo sottovettore

quickSort(v, i, last);

}

} 103

Tipi di dato derivati, enumerazioni e

strutture

I tipi di dato su cui abbiamo operato finora erano tipi di dati semplici. Abbiamo

infatti operato su variabili sia scalari sia vettoriali, ma tutte identificate univocamente

da un tipo (una variabile int, una variabile float, un array di int, un array di char...). Il

C, al pari degli altri linguaggi di programmazione ad alto livello, consente anche al

programmatore di definire dei propri tipi di dato e di operare su tipi di dato composti

(appunto le strutture, chiamate record nell'informatica teorica), ovvero tipi di dato

composti da tipi di variabili eterogenei.

Definire propri tipi - L'operatore typedef

Accennavamo prima alla possibilità di poter definire propri tipi di dato in C, a

seconda delle esigenze del programmatore. Il C mette a disposizione l'operatore

typedef per definire nuovi tipi a partire dai tipi primitivi già esistenti.

Sia ben inteso, non è indispensabile la definizione di nuovi tipi di dato in un

programma. Si possono benissimo manipolare dati primitivi anche in un programma

di grandi dimensioni, o usare strutture specificando in modo esplicito l'etichetta

struct, come vedremo in seguito, ma l'uso di typedef rende la scrittura del programma

più intuitiva (se un tipo di variabile la uso solo per la temperatura posso chiamare il

suo tipo temp, il che rende il suo uso più intuitivo rispetto a un semplice float) e

probabilmente più leggibile.

Esempio di utilizzo dell'operatore typedef: voglio creare un nuovo tipo di variabile

solo per misurare gli angoli in gradi, a partire dal tipo float. Ricorrerò ad una scrittura

del tipo

typedef float degree;

Ora posso sfruttare il nuovo tipo nel programma:

degree alpha=90;

printf ("L'angolo alpha è di %f gradi\n",alpha);

Un altro utilizzo molto comodo è per la definizione di nuovi dati di tipo vettoriale.

Ad esempio, so che un codice fiscale è sempre composto da 17 caratteri. Posso creare

un nuovo tipo di dato dedicato alla memorizzazione dei codici fiscali in questo modo:

typedef char CF[17]; 104

.........

CF c = "AAABBBCCCDDDEEEFF";

Le dichiarazioni dei nuovi tipi in genere vanno messe in modo da essere visibili a

tutte le funzioni del programma, quindi o al di fuori del main o in un file header

importato dall'applicazione.

Enumerazioni

Le enumerazioni in C si dichiarano attraverso la keyword enum, e hanno l'obiettivo

di dichiarare nuovi tipi di dato con un dominio limitato, dove al primo valore

dell'enumerazione viene associato il valore 0, al secondo il valore 1 e così via.

Ad esempio, in C non ho di default un tipo di dato per poter operare su tipi booleani.

Posso però costruirmi un tipo di dato booleano grazie ad un enumerazione:

typedef enum { false, true } boolean;

In questo caso il primo campo dell'enumerazione è false, a cui viene attribuito il

valore 0, e il secondo è true, a cui quindi viene attribuito il valore 1. Ora, grazie alla

specifica typedef, posso usare questo tipo di dato all'interno del mio codice:

boolean trovato=false;

.........

if (valore1==valore2)

trovato=true;

Altro esempio di enumerazione:

typedef enum {

Lunedi,

Martedi,

Mercoledi,

Giovedi,

Venerdi,

Sabato,

Domenica

} giorni;

In questo caso Lunedi=0, Martedi=1, ..., Domenica=6. All'interno del mio codice

posso istanziare una variabile di questo tipo e sfruttarla così:

giorno g1=Lunedi;

giorno g2=Martedi;

...... 105

Dati strutturati

Nella realtà di tutti i giorni abbiamo a che fare con entità descritte da più di una

caratteristica, e anche con tipi diversi di caratteristiche. Per soddisfare questa

esigenza, il C mette a disposizione i tipi strutturati. Esempio classico di tipo

strutturato: un'automobile è descritta da una targa, dall'anno di immatricolazione,

dalla casa produttrice e dal modello. Ecco come implementare queste caratteristiche

in C, creando il tipo di dato strutturato 'automobile':

typedef struct {

char targa[16];

char marca[16];

char modello[16];

int anno_imm;

} automobile;

Usando la keyword typedef, posso usare questo tipo di dato all'interno del mio

programma direttamente così:

automobile a;

In alternativa, potevo specificare la struttura senza typedef:

struct automobile {

char targa[16];

char marca[16];

char modello[16];

int anno_imm;

};

// Ricordate sempre il ; finale

In questo caso, posso usare il tipo di dato strutturato all'interno del mio programma

ma specificando anche il fatto che faccio uso di un tipo di dato strutturato dichiarato

in precedenza:

struct automobile a;

Per comodità e maggiore leggibilità del codice, in questo luogo useremo la prima

scrittura (quella con il typedef).

Per accedere ai dati contenuti all'interno di una struttura posso sfruttare un'istanza

della struttura stessa (ad esempio, nel caso di sopra, una variabile di tipo

'automobile') e specificare il componente a cui voglio accedere separato da un punto

'.'. Esempio:

typedef struct {

char targa[16];

char marca[16];

char modello[16];

int anno_imm;

} automobile; 106

........

automobile a;

printf ("Inserisci la targa: ");

scanf ("%s",a.targa);

printf ("Inserisci la marca: ");

scanf ("%s",a.marca);

printf ("Inserisci il modello: ");

scanf ("%s",a.modello);

printf ("Inserisci l'anno di immatricolazione: ");

scanf ("%d",&a.anno_imm);

printf ("Targa: %s\n",a.targa);

printf ("Marca: %s\n",a.marca);

printf ("Modello: %s\n",a.modello);

printf ("Anno di immatricolazione: %d\n",a.anno_imm);

È possibile anche definire array di tipi strutturati, in questo modo:

// Array di 10 automobili

automobile a[10];

int i;

for (i=0; i<10; i++) {

printf ("Automobile n.%d\n\n",i+1);

printf ("Inserisci la targa: ");

scanf ("%s",a.targa);

printf ("Inserisci la marca: ");

scanf ("%s",a.marca);

printf ("Inserisci il modello: ");

scanf ("%s",a.modello);

printf ("Inserisci l'anno di immatricolazione: ");

scanf ("%d",&a.anno_imm);

}

e ovviamente vale lo stesso discorso fatto con gli array di tipi primitivi per quanto

riguarda l'inizializzazione dinamica:

// Puntatore alla struttura automobile

automobile *a;

int i,n;

printf ("Inserire i dati di quante automobili? ");

scanf ("%d",&n); 107

// Inizializzazione dinamica del vettore di automobili

a = (automobile*) malloc (n*sizeof(automobile));

for (i=0; i<n; i++) {

printf ("Automobile n.%d\n\n",i+1);

printf ("Inserisci la targa: ");

scanf ("%s",a.targa);

printf ("Inserisci la marca: ");

scanf ("%s",a.marca);

printf ("Inserisci il modello: ");

scanf ("%s",a.modello);

printf ("Inserisci l'anno di immatricolazione: ");

scanf ("%d",&a.anno_imm);

}

Posso anche dichiarare puntatori a strutture e accedere alle strutture stesse tramite

questi puntatori. In questo caso, invece del punto '.' per accedere ad un certo

elemento della struttura il C propone un operatore apposito, l'operatore '->':

// Puntatore a struttura

automobile *a;

printf ("Inserisci la targa: ");

scanf ("%s",a->targa);

printf ("Inserisci la marca: ");

scanf ("%s",a->marca);

printf ("Inserisci il modello: ");

scanf ("%s",a->modello);

printf ("Inserisci l'anno di immatricolazione: ");

scanf ("%d",&a->anno_imm);

printf ("Targa: %s\n",a->targa);

printf ("Marca: %s\n",a->marca);

printf ("Modello: %s\n",a->modello);

printf ("Anno di immatricolazione: %d\n",a->anno_imm); 108

Direttive per il preprocessore

Ogni compilatore traduce le istruzioni di un file sorgente in linguaggio macchina. Il

programmatore generalmente non è consapevole del lavoro del compilatore: si

fornisce delle istruzioni di un linguaggio di alto livello per evitare le complessità

gestionali del linguaggio macchina. Ma, comunque, è importante poter comunicare

con il compilatore. Il C fa uso del preprocessore per estendere la sua potenza e la sua

notazione, consentendo al programmatore un'interazione con il compilatore.

L'identificatore delle righe che riguardano le direttive ad esso è #, che nel C ANSI

può essere anche preceduto da spazi mentre nel C Tradizionale deve trovarsi all'inizio

della riga. Le direttive non fanno comunque parte della grammatica del linguaggio,

ampliano solo l'ambiente di programmazione. Per lo standard ANSI, le direttive sono

le seguenti:

#define #error #include #elif #if

#line #else #ifdef #pragma #endif

#ifndef #undef #warning

La direttiva #include

Solitamente anche nei programmi più banali si usa la direttiva #include per, appunto,

includere nel sorgente file esterni o librerie.

Per includere una libreria si usano le parentesti angolari < e >, mentre per includere

un file esterno o magari nella stessa cartella del programma si usano i doppi apici ".

Un esempio di inclusione di una libreria e un file che si trova nella cartella superiore

di dove si trova il sorgente in cui la includiamo:

#include <stdio.h>

#include "../file1.h"

In questo caso il preprocessore quando incontrerà queste righe le sostituirà con il

contenuto del file richiamato.

In Unix solitamente i file d'intestazione specificati nelle parentesi angolari si trovano

nel percorso /usr/include/.

Nei file inclusi possono naturalmente anche esserci altre direttive al preprocessore

che verranno poi a loro volta "lavorate". 109

La direttiva #define

La direttiva #define si usa, appunto, per definire qualcosa ad esempio:

#define scrivi printf

In questo caso la definizione è scrivi che va a sostituire la parola printf quindi nel

corso del programma al posto di:

printf("Ciao preprocessore!");

Si potrà scrivere

scrivi("Ciao preprocessore!");

Comunque il define può anche definire numeri, simboli o altro. Vari esempi di

define:

#define EQ ==

#define OK printf("OK\n");

#define DEBUG 1

Ogni tanto a un programmatore in C può scappare di mettere un solo = nelle

uguaglianze così con la definizione EQ == si potrà scrivere così:

if ( a EQ b ) ...

evitando errori logici.

Un'altra cosa da notare è la definizione DEBUG molto utile nelle fasi di test di un

programma che si può usare nel controllo del flusso tramite sempre direttive al

preprocessore che vedremo adesso.

Controllo del flusso

Con le direttive al preprocessore si può esegure anche un flusso del controllo ( if, else

) utilizzando le direttive #if, #else, #elif, #endif e #ifdef.

Iniziamo a spiegarli dai primi cioè #if, #else, #elif e #endif che corrispondo al

controllo del flusso normalmente utilizzato: if, else, else if mentre l'ultimo #endif è

"originale" del preprocessore.

Esempio:

#include <stdio.h> 110

#define A 2

#define B 4

int main() {

#if A == 2 || B == 2

printf("A o B sono uguali a 2\n");

#elif A == 4 && B == 4

printf("A e B sono uguali a 4\n");

#elif A != B

printf("A è diversa da B\n");

#else

printf("A e B sono di un valore non definito\n");

#endif

return 0;

}

Si possono notare le seguenti cose:

Le variabili su cui eseguire controlli devono essere definite tramite #define

• Anche nel controllo del flusso tramite direttive al preprocessore si possono

• eseguire controlli con || ( OR ), && ( AND ) e != ( NOT ).

La direttiva #endif "dice" al preprocessore che il controllo del flusso è finito.

Per eseguire un debug con questo sistema si potrebbe inserire qualcosa tipo:

#if DEBUG 1

printf("x = %d\n", x);

printf("y = %s\n", y);

...

#endif

Ma ora vedremo con la direttiva #ifdef cosa si può fare, in pratica "ifdef" sta per "se

definito" quindi si può tramite essa controllare se una variabile è stata definita o

meno e con l'aggiunta delle direttive #undef e #ifndef vedremo cosa si può fare con

l'esempio seguente:

#include <stdio.h>

#define NUMERO 4

int main(void)

{

#ifndef NUMERO

#define NUMBER 4

#ifdef NUMBER

#undef NUMBER

#define NUMERO 4

#endif

return 0;

}

Innanzitutto chiariamo cosa vuol dire ifndef e undef, la prima equivale a "se non è

111

definito" ( if not defined ) mentre la seconda equivale a "togli la definizione" (

undefine ).

Nell'esempio sopra definiamo NUMERO dopodichè all'interno del corpo main

iniziamo col verificare se non è definito numero, se ciò è vero definiamo NUMBER,

se invece è definito NUMBER togliamo la definizione di NUMBER e definiamo

NUMERO. Dopodichè si esce dal programma.

La direttiva #undef diciamo che è inutile nei piccoli programmi, ma risulta utilissima

nei programmi di grandi dimensioni composti magari da molti file e da molte persone

che ci lavorano e senza andare in giro o sfogliare tra i file se una cosa è stata definita

o meno questa semplice direttiva ci facilita la vita.

L'uso di #ifdef è utilissimo nel caso in cui si vogliano usare dei file header. Infatti, un

file header potrebbe essere incluso in due diversi file sorgenti che si vanno a

compilare insieme, e questo potrebbe generare ambiguità ed errori in fase di

compilazione (funzioni o dati che risulterebbero dichiarati due volte). Per evitare

questo problema si usano proprio le direttive al preprocessore. Nell'header che

andremo a creare avremo una cosa del genere:

Se la variabile _NOMEHEADER_H non è definita

• Definisci la variabile

• Dichiara tutto il contenuto dell'header

• Altrimenti, termina la dichiarazione dell'header

In codice:

#ifndef _MIOHEADER_H

#define _MIOHEADER_H

// Qui metto tutte le mie funzioni e i miei dati

#endif

In pratica, una volta definita la macro _MIOHEADER_H il file header non verrà più

incluso in nessun altro file, risolvendo quindi gli eventuali problemi di header definiti

due o più volte.

Macro predefinite

Nel C esistono 5 tipi di macro già definite sempre disponibili che non possono essere

ridefinite dal programmatore. Si possono vedere nello schema seguente:

/* MACRO || COSA CONTIENE */

__DATE__ /* Una stringa che contiene la data corrente */

__FILE__ /* Una stringa che contiene il nome del file */

__TIME__ /* Una stringa che contiene l'ora corrente */

__LINE__ /* Un intero che raprresenta il numero di riga corrente

*/

__STDC__ /* Un intero diverso da 0 se l'implementazione segue lo

112

standard ANSI C */

Operatori # e ##

Questo tipo di operatori sono disponibili solo nel C ANSI. L'operatore unario #

trasforma un parametro formale di una definizione di macro in una stringa ad

esempio:

#define nomi(a, b) printf("Ciao " #a " e " #b "! Benvenuti!\n");

da richiamare nel corpo main con:

nomi(HdS619, BlackLight);

Una volta espanso dal preprocessore questa linea diventerà:

printf("Ciao " "HdS619" " e " "BlackLight" "! Benvenuti!\n");

Ora invece vediamo l'operatore binario ## che serve a concatenare token. Ad

esempio:

#include <stdio.h>

#define X(y) x ## y

X(3) = X(4) = X(12) = ...

verrà espanso in:

x3 = x4 = x12 = ...

In pratica si può pensare che "colleghi" i due parametri x e y.

Direttive #error e #warning

Le direttive #error e #warning servono rispettivamente per dare errori nella

compilazione oppure avvisi.

Solitamente queste due direttive vengono usate insieme a quelle che controllano il

"flusso di compilazione" ( #else, #if, #undef, ecc... ).

La loro sintassi è la seguente:

#error Messaggio di errore

#warning Messaggio di avvertimento 113

Ad esempio si può controllare che un codice in C++ venga compilato solo da un

compilatore C++ e non da un compilatore C nel seguente modo (ricordando che i

compilatori C++ definiscono la macro __cplusplus):

#ifndef __cplusplus

#error "Devi compilare questo codice con un compilatore C++"

#endif

// Codice C++ di seguito

Compilando questo codice ad esempio con g++ non si avranno errori, mentre se si

prova a compilare con gcc si avrà

error: #error "Devi compilare questo codice con un compilatore C++"

114

Liste

Una lista è un insieme finito e ordinato di elementi di un certo tipo. In informatica

una lista si indica come un insieme di termini compresi tra parentesi quadre [].

Esempio, ['a','n','c']. Come tutti i tipi di dato astratti, anche le liste sono definite in

termini di

Dominio-base dei suoi elementi (interi, caratteri, stringhe...)

• Operatori di costruzione della lista

• Operatori di selezione sulla lista

Il grosso vantaggio delle liste sugli array è il fatto che una lista si può definire in

modo estremamente dinamico, anche senza conoscere il numero di elementi totale di

partenza dei suoi elementi, e di gestire i collegamenti tra un elemento e un altro in

modo estremamente versatile. Ma andiamo con ordine.

Liste come tipi di dato astratto

Pochi linguaggi offrono di default il tipo 'lista' preimpostato (LISP, Prolog). Negli

altri linguaggi, come C, è necessario costruirsi questo tipo in base alle proprie

esigenze.

Le caratteristiche generali di un tipo di dato astratto sono state illustrate sopra. In

modo più preciso, possiamo definire un tipo di dato astratto in termini di

Dominio base D

• Insieme di funzioni sul dominio D

• Insieme di predicati sul dominio D

Un tipo di dato astratto generico T è quindi definibile come

Nel caso di una lista, possiamo definire

Le funzioni base sulla lista sono così definite: 115

• È il costruttore della lista, ovvero la funzione che, dato una lista di partenza e

un elemento appartenente al dominio da inserire in cima alla lista, costruisce

la lista specificata.

• Funzione che ritorna la 'testa' della lista, ovvero il suo primo elemento.

• Funzione che ritorna la 'coda' della lista, ovvero una lista uguale a quella di

partenza ma privata del primo elemento.

• Funzione che ritorna la costante 'lista vuota'. Per convenzione, in C una lista è

vuota quando il valore della sua testa è NULL.

L'unico predicato elementare sul tipo astratto di lista è così definito:

• Funzione che verifica se la lista è vuota o meno.

Qualche esempio:

cons (5, [3,6,2,3]) crea la lista [5,3,6,2,3]

• head ([7,3,5,6]) ritorna 7 (testa della lista)

• tail ([7,3,5,6]) ritorna la lista [3,5,6] (coda della lista)

• empty ([7,3,5,6]) ritorna falso (la lista non è vuota)

Quelle illustrate sono le operazioni di base che si possono effettuare su una lista.

Tutte le altre operazioni (inserimento ordinato di elementi, ribaltamento degli

elementi, stampa degli elementi presenti...) sono operazioni derivate dalle primitive

appena illustrate. Considerando che esiste il concetto di lista vuota (per convenzione

la lista avente NULL in testa) e che è possibile costruire nuove liste usando il

costruttore cons, si possono definire tutte le eventuali funzioni derivate sulla base di

quelle già definite tramite algoritmi ricorsivi.

Rappresentazione statica

La rappresentazione più ovvia del tipo astratto di lista è gestendo gli elementi della

lista in un array. La lista così costruita conterrà

Un vettore di lunghezza massima prefissata

• Una variabile primo, che identifica l'indice del primo elemento della lista

• 116

Una variabile lunghezza, che indica il numero di elementi contenuti nella lista

L'inconveniente principale è il fatto che le dimensioni del vettore sono fisse. Il tipo di

dato lista è quindi strutturato così in questo caso:

#define N 100

typedef struct {

int primo,lunghezza;

int elementi[N];

} list;

E le primitive che agiscono sulla lista sono così definite:

// Ritorna una lista vuota

list emptylist() {

list l;

// Convenzione: quando la lista è vuota l'indice del primo

elemento

// è un numero negativo

l.primo=-1;

l.lunghezza=0;

}

// Controlla se la lista è vuota

bool empty(list l) {

return (l.primo==-1);

}

// Ritorna il primo elemento della lista

int head (list l) {

if (empty(l)) abort();

return l.elementi[l.primo];

}

// Ritorna la coda della lista

list tail(list l) {

list t=l;

// Se la lista è vuota, esce

if (empty(l)) abort();

// Altrimenti, la lista t avrà come primo elemento

// il primo di l incrementato di 1, e la lunghezza

// di l decrementata di 1 (ovvero scarto la testa della lista)

t.primo++;

t.lunghezza--;

return t;

}

// Crea una nuova lista, prendendo come parametri

// l'elemento da inserire in testa e una lista di partenza 117

// (eventualmente vuota)

list cons (int e, list l) {

list t;

int i;

// Inserisco e in testa alla lista

t.primo=0;

t.elementi[t.primo]=e;

t.lunghezza=1;

// Copio il vettore contenuto in l nella nuova lista

for (i=1; i<=l.lunghezza; i++) {

t.elementi[i]=t.elementi[i-1];

t.lunghezza++;

}

}

Queste sono le funzioni primitive sulla lista. Grazie a queste è possibile costruire

ricorsivamente eventuali funzioni derivate. Esempio, una funzione che stampi tutti

gli elementi della lista:

void showList(list l) {

// Condizione di stop: se la lista è vuota, ritorna

if (empty(l))

return;

// Stampa il primo elemento della lista

printf ("%d\n",head(l));

// Richiama la funzione sulla coda di l

showList(tail(l));

}

Rappresentazione dinamica

Una rappresentazione di liste estremamente utile è quella dinamica. In questo tipo di

rappresentazione si perde ogni riferimento statico (vettori, buffer di dimensione

fissa). Ogni elemento della lista contiene il suo valore e un riferimento all'elemento

successivo nella lista stessa. Si crea quindi così una lista grafica, con nodi (elementi

della lista) e archi (collegamenti tra gli elementi).

Un generico elemento della lista sarà quindi così costruito:

// Creo una lista di interi

// Nel caso volessi riutilizzare il codice per una lista

// di un altro tipo, mi basterà modificare il tipo element

typedef element int;

typedef struct list_element {

element value;

struct list_element *next; 118

} node;

Il tipo element mi consente di scrivere del codice estremamente modulare, in quanto

semplicemente modificando il tipo potrò usare la stessa lista per memorizzare interi,

float, caratteri e quant'altro. Come è possibile notare inoltre nel dichiarare la struttura

node ho usato un'etichetta (list_element). Ciò è indispensabile in quanto all'interno

della struttura c'è un collegamento a un elemento della struttura stessa (il prossimo

elemento della lista). Ma poiché node non è ancora stato dichiarato a quel punto, è

indispensabile mettere un'etichetta temporanea. A questo punto, con un nodo della

lista così definito potrò includere al suo interno il suo stesso valore e il riferimento al

prossimo elemento. Nel caso l'elemento in questione sia l'ultimo della lista, si mette

come suo successore, per convenzione, il valore NULL.

Per una maggiore genericità del codice possiamo creare funzioni che operano sul tipo

element, in modo che se in futuro dovessimo usare lo stesso tipo di lista creato per

gestire degli interi per gestire delle stringhe basterà cambiare queste funzioni che

agiscono su element, e lasciare inalterate le funzioni che operano sulla lista. Si

comincia così a entrare nell'ottica della creazione di codice modulare ovvero codice

che è possibile scrivere una volta e riusare più volte. Vediamo le funzioni di base che

possono agire sul tipo element (in questo caso tipo int, volendo modificando il tipo

basterà cambiare le funzioni):

bool isLess (element a, element b) { return (a<b); }

bool isEqual (element a, element b) { return (a==b); }

element get (element e) { return e; }

element readElement() {

element e;

scanf ("%d",&e);

return e;

}

void printElement (element e) { printf ("%d",e); }

A questo punto è conveniente dichiarare il tipo lista

typedef node* list;

appunto come puntatore a un elemento di tipo node.

Per il tipo lista le primitive saranno le seguenti:

// Ritorna la costante 'lista vuota'

list emptylist() { return NULL; }

// Controlla se una lista è vuota

bool empty(list l) { return (l==NULL); }

// Ritorna la testa della lista

element head (list l) {

if (empty(l)) abort(); 119

return l->value;

}

// Ritorna la coda della lista

list tail (list l) {

if (empty(l)) abort();

return l->next;

}

// Costruttore. Genera una lista dato un elemento

// da inserire in testa e una lista

list cons (element e, list l) {

list t;

t = (list) malloc(sizeof(node));

t.value=get(e);

t.next=l;

return t;

}

Con queste primitive di base è possibile costruire qualsiasi funzione che operi sul

tipo di dato 'lista'. Esempio, per la stampa degli elementi contenuti nella lista:

void printList(list l) {

// Condizione di stop: lista vuota

if (l==NULL)

return;

printElement(l->head);

printf ("\n");

// Scarto l'elemento appena stampato e

// richiamo la funzione in modo ricorsivo

printList(l->tail);

}

E allo stesso modo si possono anche definire per la ricerca di un elemento nella lista,

per la lettura di un elemento all'indice i della lista e così via. 120

Gestione dei file ad alto livello

“Everything is a file!”

Questa è la frase più comune tra i sistemisti Unix quando cercano di illustrarti questo

o quel dettaglio di un socket o di un dispositivo. Sui sistemi Unix ogni entità è un

file, un socket di rete o locale, un processo, un dispositivo, una pipe, una directory,

un file fisico vero e proprio...tutto è un file perché a livello di sistema posso scrivere

e leggere su tutte queste entità con le stesse primitive (write, read, open, close).

Queste sono quelle che vengono chiamate primitive a basso livello per la

manipolazione dei file, a basso livello perché implementate a livello di sistema e non

a livello della libreria C ad alto livello.

Ma facciamo un passo indietro. Noi siamo abituati a vedere, nella vita informatica

quotidiana, un file come un'entità che contiene un certo tipo di dato. Una canzone,

un'immagine, un filmato, un file di testo, la nostra tesi di laurea...tutte queste cose, in

apparenza così diverse da loro, vengono trattate a livello informatico come una sola

entità magica, ovvero come file.

Finora abbiamo visto come scrivere applicazioni che rimangono residenti nella

memoria centrale del computer, nascono quando li eseguiamo, vengono caricati nella

memoria centrale, eseguono un certo numero di operazioni e poi spariscono. Delle

applicazioni del genere non sono poi molto diverse da quelle che può effettuare una

semplice calcolatrice se ci pensiamo...una vecchia calcolatrice non ha memoria, non

si ricorda i calcoli che abbiamo fatto e non ha traccia dei numeri che abbiamo

digitato la settimana scorsa. La grande potenza dei computer, che ne ha decretato il

successo già negli anni '50, è invece la capacità di poter memorizzare dati su

dispositivi fissi e permanenti, non volatili come le memorie centrali, e per

memorizzare questi dati c'è bisogno di ricorrere a queste entità astratte che sono i file.

Ma come fa un linguaggio di programmazione, come il C, a interagire con queste

entità? Come ho già detto, una strada è quella delle primitive a basso livello,

implementate a livello di kernel. Queste primitive hanno il vantaggio di essere

estremamente lineari (come dicevo prima con la stessa primitiva posso scrivere su

entità diverse a livello logico) e veloci. Veloci perché implementate a basso livello,

marchiate a fuoco nel kernel stesso, che al momento della chiamata non le deve

quindi andare a pescare da una libreria esterna. Il difetto, però, è quello della

portabilità. Le funzioni write, read & co. non sono ANSI-C, perché funzionano su un

kernel Unix, ma non su altri tipi di sistemi. Per rendere ANSI-C anche l'accesso ai

files Kernighan e Ritchie hanno ideato delle primitive ad alto livello, indipendenti dal

tipo di sistema su cui sono compilate. 121

Apertura dei file in C

Cominciamo a capire come un sistema operativo, e quindi anche un linguaggio di

programmazione, vede un file. Un file è un'entità identificata in modo univoco da un

nome e una posizione sul filesystem. Non posso interagire direttamente con l'entità

presente sul filesystem, ma ho bisogno di farlo da un livello di astrazione

leggermente più alto: quello dell'identificatore. Quando apro un file all'interno di una

mia applicazione in C non faccio altro che associare a quel file un identificatore, che

altro non è che una variabile o un puntatore di un tipo particolare che mi farà da

tramite nei miei accessi al file. In ANSI-C questa variabile è di tipo FILE, un'entità

definita in stdio.h, e per associarla ad un file ho bisogno di ricorrere alla funzione

fopen (sempre definita in stdio.h, come tutte le funzioni che operano su entità di tipo

FILE). La funzione fopen è così definita:

FILE* fopen(const char* filename, const char* mode);

dove *filename è il nome del nostro file (può essere sia un percorso relativo che

assoluto, ad es. mio_file.txt oppure /home/pippo/mio_file.txt), mentre invece *mode

mi indica il modo in cui voglio aprire il mio file. Ecco le modalità possibili:

r Apre un file di testo per la lettura

• w Crea un file di testo per la scrittura

• a Aggiunge a un file di testo

• rb Apre un file binario per la lettura

• wb Apre un file binario per la scrittura

• ab Aggiunge a un file binario

• r+ Apre un file di testo per la lettura\scrittura

• w+ Crea un file di testo per la lettura\scrittura

• a+ Aggiunge a un file di testo per la lettura\scrittura

• r+b Apre un file binario per la lettura\scrittura

• w+b Crea un file binario per la lettura\scrittura

• a+b Aggiunge a un file binario per la lettura\scrittura

Quando non è possibile aprire un file (es. il file non esiste o non si hanno i permessi

necessari per scrivere o leggere al suo interno) la funzione fopen ritorna un puntatore

NULL. È sempre necessario controllare, quando si usa fopen, che il valore di ritorno

non sia NULL, per evitare di compiere poi operazioni di lettura o scrittura su file non

valide che rischiano di crashare il programma.

Ecco un esempio di utilizzo di fopen per l'apertura di un file in lettura:

#define FILE_NAME "prova.txt"

........

FILE *fp;

fp = fopen (FILE_NAME,"r"); 122

if (!fp) {

printf ("Impossibile aprire il file %s in lettura\n",FILE_NAME);

return;

}

È poi buona norma eliminare il puntatore al file quando non è più necessario. Questo

si fa con la funzione fclose, così definita:

int fclose(FILE *fp);

La funzione fclose ritorna 0 quando la chiusura va a buon fine, -1 negli altri casi (ad

esempio, il puntatore che si prova a eliminare non è associato ad alcun file).

Scrittura su file testuali - fprintf e fputs

Vediamo ora come posso scrivere e leggere su file. In questo campo le funzioni si

dividono in due tipi: quelle per scrivere e leggere su file dati binari e quelle per il

testo semplice (ASCII).

Vediamo prima le funzioni ASCII. Le funzioni ASCII per scrivere e leggere su file

non sono altro che specializzazioni delle corrispettive funzioni per leggere e scrivere

su stdin/stdout. Abbiamo quindi fprintf, fscanf, fgets e fputs.

L'uso di fprintf è del tutto analogo a quello di printf, e prende come argomenti un file

descriptor (puntatore alla struttura FILE) e una stringa di formato con eventuali

argomenti, in modo del tutto analogo a una printf. Esempio:

#define MY_FILE mio_file.txt

.....

FILE *fp;

fp = fopen (MY_FILE,"w");

if (!fp) { printf ("Errore: impossibile aprire il file %s in

scrittura\n",MY_FILE);

return;

}

// Scrivo su file

fprintf (fp,"Questa è una prova di scrittura sul file

%s\n",MY_FILE);

Analogalmente, si può usare anche la fputs() per la scrittura di una stringa su file,

ricordando che la fputs prende sempre due argomenti (il file descriptor e la stringa da

scrivere su file):

#define MY_FILE mio_file.txt

..... 123

FILE *fp;

fp = fopen (MY_FILE,"w");

if (!fp) { printf ("Errore: impossibile aprire il file %s in

scrittura\n",MY_FILE);

return;

}

/* Scrivo su file */

fputs (fp,"Questa è una prova di scrittura\n");

Tramite la fprintf posso scrivere su file anche dati che poi posso andare a rileggere

dopo, creando una specie di piccolo 'database di testo'. Esempio:

#include <stdio.h>

#include <stdlib.h>

#define USER_FILE "user.txt"

typedef struct {

char user[30];

char pass[30];

char email[50];

int age;

} user;

int main(void) {

FILE *fp;

user u;

if (!(fp=fopen(USER_FILE,"a"))) {

printf ("Errore: impossibile aprire il file %s

in modalità append\n",USER_FILE);

exit(1);

}

printf ("=> Inseririmento di un nuovo utente <==\n\n");

printf ("Username: ");

scanf ("%s",u.user);

printf ("Password: ");

scanf ("%s",u.pass);

printf ("Email: ");

scanf ("%s",u.email); 124

printf ("Età: ");

scanf ("%d",&u.age);

/* Scrivo i dati su file */

fprintf (fp,"%s\t%s\t%s\t%d\n",u.user,u.pass,u.email,u.age);

printf ("Dati scritti con successo sul file!\n");

fclose (fp);

return 0;

}

Questo produrrà un file di questo tipo:

username1 password1 email1 età1

username2 password2 email2 età2

.......

Ovvero una riga per ogni utente, dove ogni campo è separato da un carattere di

tabulazione.

Lettura di file testuali - fscanf e fgets

Per leggere dati di testo semplici, come accennato prima, la libreria stdio.h mette a

disposizione la funzione fscanf, la cui sintassi è molto simile a quella di scanf:

int fscanf (FILE *fp, char *format_string, void *arg1, ..., void *argn);

La funzione fscanf, esattamente come scanf, ritorna il numero di oggetti letti in caso

di successo, -1 in caso di errore. Quindi possiamo struttura il nostro algoritmo in

questo modo: "finché fscanf ritorna un valore > 0, scrivi i valori letti"

#include <stdio.h>

#include <stdlib.h>

#define USER_FILE "user.txt"

typedef struct {

char user[30];

char pass[30];

char email[50];

int age;

} user;

int main(void) {

FILE *fp;

user u;

int i=0;

if (!(fp=fopen(USER_FILE,"r"))) {

printf ("Errore: impossibile aprire il file %s

in modalità read-only\n",USER_FILE); 125

exit(1);

}

while (fscanf(fp,"%s\t%s\t%s\t%d\n",

u.user,u.pass,u.email,&u.age)>0) {

printf ("Username: %s\n",u.user);

printf ("Password: %s\n",u.pass);

printf ("Email: %s\n",u.email);

printf ("Età: %d\n\n",u.age);

i++;

}

printf ("Utenti letti nel file: %d\n",i);

fclose (fp);

}

Ci sono modi alternativi per effettuare quest'operazione. Ad esempio, si potrebbero

contare gli utenti semplicemente contando il numero di righe nel file, in modo del

tutto indipendente dal ciclo di fscanf principale. Si tratta semplicemente di introdurre

una funzione del genere:

...

int countLines (char *file) {

FILE *fp;

char ch;

int count=0;

if (!(fp=fopen(file,"r")))

return -1;

while (fscanf(fp,"%c",&ch)>0)

if (ch=='\n')

count++;

return count;

}

...

i=countLines(USER_FILE);

printf ("Numero di utenti letti: %d\n",i);

o ancora usando, invece di ciclare controllando il valore di ritorno di fscanf, si può

ciclare finché non viene raggiunta la fine del file. Per far questo si ricorre in genere

alla funzione feof, funzione che controlla se si è raggiunta la fine del file puntato dal

file descriptor in questione. In caso affermativo, la funzione ritorna un valore diverso

da 0, altrimenti ritorna 0

...

int countLines (char *file) { 126

FILE *fp;

char ch;

int count=0;

if (!(fp=fopen(file,"r")))

return -1;

while (!feof(fp)) {

if ((ch = getc(fp)) == '\n')

count++;

}

return count;

}

...

i=countLines(USER_FILE);

printf ("Numero di utenti letti: %d\n",i);

Anche qui, la funzione feof si pone ad un livello di astrazione superiore a quello del

sistema operativo. Infatti i sistemi operativi usano strategie differenti per identificare

l'EOF (End-of-File). I sistemi Unix e derivati memorizzano a livello di filesystem la

dimensione di ogni file, mentre i sistemi DOS e derivati identificano l'EOF con un

carattere speciale (spesso identificato dal caratteri ASCII di codice -1). La strategia

dei sistemi DOS però si rivela molto pericolosa...infatti, è possibile inserire il

carattere EOF in qualsiasi punto del file, e non necessariamente alla fine, e il sistema

operativo interpreterà quella come fine del file, perdendo tutti gli eventuali dati

successivi.

La funzione feof si erge al di sopra di questi meccanismi di basso livello, rendendo

possibile l'identificazione dell'EOF su qualsiasi sistema operativo.

Se conosco a priori la dimensione del buffer che devo andare a leggere dal file, è

preferibile usare la funzione fgets, che ha questa sintassi:

char* fgets (char *s, int size, FILE *fp);

Ad esempio, ho un file contenente i codici fiscali dei miei utenti. Già so che ogni

codice fiscale è lungo 16 caratteri, quindi userò la fgets:

#include <stdio.h>

#define CF_FILE "cf.txt"

int main(void ) {

FILE *fp;

char cf[16];

int i=1;

if (!(fp=fopen(USER_FILE,"r")) ) {

printf ("Errore: impossibile aprire il file %s 127

in modalità read-only\n",USER_FILE);

exit(1);

}

while (!feof(fp)) {

fgets (cf,sizeof(cf),fp);

printf ("Codice fiscale n.%d: %s\n",i++,cf);

}

}

Scrittura di dati in formato binario - fwrite

Quelle che abbiamo visto finora sono funzioni per la gestione di file di testo, ovvero

funzioni che scrivono su file dati sotto forma di caratteri ASCII. A volte però è molto

più comodo gestire file in modalità binaria, ad esempio per file contenenti dati di tipo

strutturato, e quindi di dimensione fissata, poiché per quanto grande possa essere il

dato strutturato da gestire queste funzioni consentono di gestirlo in una sola lettura e

in una sola scrittura.

Per la scrittura di dati binari su file si usa la funzione fwrite, che ha questa sintassi:

size_t fwrite (void *ptr, size_t size, size_t blocks, FILE *fp);

dove *ptr identifica la locazione di memoria dalla quale prendere i dati da scrivere su

file (può identificare una stringa, un intero, un array...), size la dimensione della zona

di memoria da scrivere su file, blocks il numero di blocchi da scrivere su file (in

genere 1) e *fp è puntatore a nostro file. fwrite ritorna un valore > 0, che identifica il

numero di byte scritti, quando la scrittura va a buon fine, -1 in caso contrario.

Esempio di utilizzo:

#include <stdio.h>

#include <stdlib.h>

#define USER_FILE "user.dat"

typedef struct {

char user[30];

char pass[30];

char email[50];

int age;

} user;

int main() {

FILE *fp;

user u;

if (!(fp=fopen(USER_FILE,"a"))) {

printf ("Errore: impossibile aprire il file %s

in modalità append\n",USER_FILE);

return 1; 128

}

printf ("===Inseririmento di un nuovo utente===\n\n");

printf ("Username: ");

scanf ("%s",u.user);

printf ("Password: ");

scanf ("%s",u.pass);

printf ("Email: ");

scanf ("%s",u.email);

printf ("Età: ");

scanf ("%d",&u.age);

// Scrivo i dati su file

if (fwrite (&u, sizeof(u), 1, fp)>0)

printf ("Dati scritti con successo sul file!\n");

else printf ("Errore nella scrittura dei dati su file\n");

fclose (fp);

return 0;

}

Lettura di dati in formato binario - fread

Per la lettura si ricorre invece alla funzione fread, che ha una sintassi molto simile:

size_t fread (void *ptr, size_t size, size_t blocks, FILE *fp);

Esempio di utilizzo:

#include <stdio.h>

#include <stdlib.h>

#define USER_FILE "user.dat"

typedef struct {

char user[30];

char pass[30];

char email[50];

int age;

} user;

int main(void) {

FILE *fp;

user u;

int i=0;

if (!(fp=fopen(USER_FILE,"r"))) { 129

printf ("Errore: impossibile aprire il file %s

in modalità read-only\n",USER_FILE);

exit(1);

}

while (fread(&u,sizeof(u),1,fp)>0) {

printf ("Username: %s\n",u.user);

printf ("Password: %s\n",u.pass);

printf ("Email: %s\n",u.email);

printf ("Età: %d\n\n",u.age);

i++;

}

printf ("Utenti letti nel file: %d\n",i);

fclose (fp);

return 0;

}

Posizionamento all'intero di un file - fseek e ftell

Vediamo ora altre due funzioni indispensabili per il posizionamento all'interno di un

file.

Un file è un'entità software memorizzata su un dispositivo ad accesso diretto, come

un hard disk o una chiave USB, e in quanto tale è possibile accedere ad esso in

qualsiasi punto dopo l'apertura. Ciò è possibile tramite la funzione fseek:

int fseek (FILE *fp, int offset, int whence);

dove *fp è il puntatore al file in cui ci si vuole spostare, offset una variabile intera

che rappresenta lo spostamento in byte all'interno del file (può essere positiva o

anche negativa, nel caso di spostamenti all'indietro) e whence rappresenta il punto da

prendere come riferimento nello spostamento. In stdio.h vengono definiti 3 tipi di

whence:

SEEK_SET (corrispondente al valore 0), che rappresenta l'inizio del file

• SEEK_CUR (corrispondente al valore 1), che rappresenta la posizione

• corrente all'interno del file

SEEK_END (corrispondente al valore 2), che rappresenta la fine del file

Ad esempio, se come secondo argomento della funzione passo 3 e come terzo

argomento SEEK_CUR, mi sposterò avanti di 3 byte a partire dalla posizione attuale

all'interno del file.

C'è poi la funzione ftell:

int ftell (FILE *fp);

che non fa altro che ritornare la posizione attuale all'interno del file puntato da fp

(ovvero il numero di byte a cui si trova il puntatore a partire dall'inizio del file). 130

Esempio pratico: un programmino per la ricerca di una parola all'interno di un file

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#define MY_FILE "file_to_search.txt"

int main() {

FILE *fp;

char s[100];

char *buff;

int dim;

int i=0;

if (!(fp=fopen(MY_FILE,"r"))) {

printf ("Errore nella lettura dal file %s\n",MY_FILE);

exit(1);

}

printf ("Parola da cercare all'interno del file %s:",

MY_FILE);

scanf ("%s",s);

dim=strlen(s);

buff = (char*) malloc(dim*sizeof(char));

while (!feof(fp)) {

fscanf (fp,"%s",buff);

if (!strcmp(s,buff)) {

printf ("Parola trovata a %d byte dall'inizio\n",

ftell(fp)-dim);

i++;

}

/* Mi posiziono indietro nel file di dim+1 caratteri

* a partire dalla posizione corrente

*/

fseek (fp,-dim+1,SEEK_CUR);

}

printf ("%d occorrenze di %s trovate nel file\n",i,s);

return 0;

} 131

Cenni di programmazione a oggetti in C

La programmazione a oggetti è un paradigma proprio di linguaggi quali C++, Java o

Smalltalk, e prevede la manipolazione dell'informazione attraverso entità astratte (gli

oggetti) caratterizzate da attributi e su cui è possibile operare solo attraverso metodi

(funzioni) prestabiliti. È un paradigma molto utile per modellare entità in un modo

molto vicino alla visione umana di quelle entità. Ad esempio, un mazzo di carte da

poker può essere modellato come un oggetto caratterizzato dagli attributi

numerocarte e carte (inteso a sua volta come array di oggetti), e sul quale è possibile

richiamare i metodi mescola, distribuisci, estrai_carta e reimmetti_carta.

Questo paradigma è molto semplice da sfruttare in linguaggi nativamente a oggetti

quali C++ o Java che prevedono già tutti i costrutti sintattici per poterlo gestire

(classi, ereditarietà, polimorfismo, ridefinizione degli operatori...), ma con un po' più

di impegno si può usare anche in C. Anzi, in grandi progetti come le librerie Gtk in C

o lo stesso kernel Linux questo è un paradigma molto comune, in quanto consente di

mantenere i sorgenti più ordinati, meglio “ingegnerizzati”, più facili da gestire e più

vicini alla logica umana.

Si pensi ad esempio a come gestire l'oggetto “automobile” all'interno di un sorgente.

Un'automobile, nella nostra implementazione volutamente semplificata, è un'oggetto

caratterizzato dai seguenti attributi:

velocità massima

• velocità attuale

• stato (accesa o spenta)

e su cui si può operare tramite i seguenti metodi:

crea un nuovo oggetto automobile (costruttore)

• rimuovi dalla memoria un oggetto automobile (distruttore)

• accelera di tot km/h

• decelera di tot km/h

• metti in moto

• spegni

• 132

Vediamo come modellare quest'oggetto in C. Questo può essere il contenuto del file

“car.h”, contenente la dichiarazione dell'oggetto e dei suoi metodi:

/* car.h */

typedef enum { off, on } car_status;

struct _car {

unsigned int max_speed;

unsigned int cur_speed;

car_status status;

};

/* Typedef creato per comodità, per non portarsi continuamente

dietro degli struct _car*

*/

typedef struct _car* car;

car car_create (unsigned int max_speed);

void car_destroy (car c);

void car_accelerate (car c, unsigned int kmh);

void car_decelerate (car c, unsigned int kmh);

void car_start (car c);

void car_stop (car c);

E questa una possibile implementazione dei metodi nel file “car.c”:

/* car.c */

#include “car.h”

car car_create (unsigned int max_speed) {

car c = (car) malloc(sizeof(struct _car));

car->status = off;

car->cur_speed = 0;

car->max_speed = max_speed;

}

void car_destroy (car c) {

if (c == NULL) return;

free(c);

}

void car_accelerate (car c, unsigned int kmh) {

if (c == NULL) return; 133

if (c->status == off) return;

if (c->cur_speed + kmh > max_speed) return;

c->cur_speed += kmh;

}

void car_decelerate (car c, unsigned int kmh) {

if (c == NULL) return;

if (c->status == off) return;

if ((int) (c->cur_speed - kmh) < 0) return;

c->cur_speed -= kmh;

}

void car_start (car c) {

if (c == NULL) return;

if (c->status == on) return;

c->status = on;

c->cur_speed = 0;

}

void car_stop (car c) {

if (c == NULL) return;

if (c->status == off) return;

c->status = off;

c->cur_speed = 0;

}

E questa una possibile implementazione:

/* main.c */

#include “car.h”

int main() {

car c = car_create(180);

car_start(c);

car_accelerate(10);

car_decelerate(5);

car_accelerate(20);

car_decelerate(25);

car_stop(c);

car_destroy(c);

}

Si noti che attraverso questo paradigma si creano entità al di sopra dei tipi di dato del

C, e su cui per quanto sia “sintatticamente” ammissibile effettuare ogni tipo di

operazione, non lo è “semanticamente”. Ad esempio, pur essendo la velocità un

parametro dichiarato come unsigned int le uniche operazioni sensate su

quest'attributo sono l'incremento o il decremento di un tot di velocità. Per il

134

compilatore può aver senso ad esempio prendere la velocità dell'auto e sostituirgli la

parte intera della sua radice quadrata, ma non ha senso da un punto di vista logico per

come è stato modellato l'oggetto (se le uniche azioni possibili sono accelerare e

decelerare, le uniche operazioni logicamente ammissibili sull'attributo sono

incremento e decremento, e non modificando direttamente il campo della struttura

ma usando i rispettivi metodi car_accelerate e car_decelerate). In linguaggi come

C++ e Java non è neanche possibile accedere ai dati “privati” di un oggetto

dall'esterno. È possibile eventualmente leggerne il valore o modificarlo attraverso i

metodi dell'oggetto stesso, ma attraverso un meccanismo di visibilità (ovvero cosa

ogni entità esterna può toccare, leggere o modificare dell'oggetto) è possibile settare

in modo molto pulito i permessi. Il C, non essendo un linguaggio nativamente a

oggetti, è un po' più rude sotto questo punto di vista, ma dei rudimentali meccanismi

di visibilità e permessi si possono comunque implementare usando variabili di flag

(settate nei metodi che possono modificare i valori dell'oggetto e non settate al di

fuori) o stratagemmi simili. 135

Libreria math.h

Includendo nel proprio codice l'header math.h è possibile utilizzare svariate funzioni

e costanti matematiche. A gcc bisogna passare l'opzione -lm per poter compilare

sorgenti che fanno uso di tali funzioni. Ecco le principali:

Funzioni trigonometriche

cos Calcola il coseno di un numero reale (espresso in radianti)

• sin Calcola il seno di un numero reale (espresso in radianti)

• tan Calcola la tangente di un numero reale (espresso in radianti)

• acos Calcola l'arcocoseno di un numero reale

• asin Calcola l'arcoseno di un numero reale

• atan Calcola l'arcotangente di un numero reale

Funzioni iperboliche

cosh Calcola il coseno iperbolico di un numero reale

• sinh Calcola il seno iperbolico di un numero reale

• tanh Calcola la tangente iperbolica di un numero reale

Funzioni esponenziali e logaritmiche

exp Calcola l'esponenziale di un numero reale

• log Calcola il logaritmo in base e di un numero reale

• log10 Calcola il logaritmo in base 10 di un numero reale

Potenze e radici

pow Calcola una potenza. Prende come primo argomento la base e come

• secondo l'esponente

sqrt Calcola la radice quadrata di un numero reale

Arrotondamento e valore assoluto

ceil Approssima per eccesso un numero reale al numero intero più vicino

• abs Calcola il valore assoluto di un numero reale

• floor Approssima per difetto un numero reale al numero intero più vicino

• 136

Costanti

L'header math.h mette anche a disposizione del programmatore alcune costanti

matematiche di uso comune con un numero notevole di cifre significative dopo la

virgola, senza che ci sia bisogno di definirle di volta in volta. Tra queste il pi greco

(M_PI) e il numero di Nepero e (M_E).

Generazione di numeri pseudocasuali

In C è possibile generare numeri pseudocasuali in modo relativamente semplice, a

patto che si includa l'header stdlib.h. Si comincia inizializzando il seme dei numeri

casuali tramite la funzione srand(). In genere si usa come variabile di inizializzazione

del seme la data locale:

#include <stdlib.h>

#include <time.h>

...

srand((unsigned) time(NULL));

Una volta inizializzato il seme uso la funzione rand() per ottenere un numero

pseudocasuale. Tale funzione ritorna però numeri estremamente grandi. Per

restringere l'intervallo possibile dei numeri pseudocasuali che voglio generare basta

calcolarne uno con rand() e poi calcolarne il modulo della divisione per il numero

più alto dell'intervallo che voglio ottenere. Ad esempio, se voglio ottenere numeri

pseudocasuali in un intervallo da 0 a 9 basterà

int rnd=rand()%10; 137

Libreria time.h

La libreria time.h è dedicata alla gestione della data e dell'ora. Comprende funzioni di

tre tipologie: tempi assoluti, rappresentano data e ora nel calendario gregoriano;

tempi locali, nel fuso orario specifico; variazioni dei tempi locali, specificano una

temporanea modifica dei tempi locali, ad esempio l'introduzione dell'ora legale.

Contiene le dichiarazioni della funzione time(), che ritorna l'ora corrente, e la

funezione clock() che restituisce la quantità di tempo di CPU impiegata dal

proramma.

time_t

Il tipo time_t, definito in time.h, non è altro che un long int addibito al compito di

memorizzare ora e date misurate in numero di secondi trascorsi dalla mezzanotte del

1° gennaio 1970, ora di Greenwich. Bisogna ammetere che è un modo un po' bislacco

di misurare il tempo, ma è così perché così si è misurato il tempo sui sistemi Unix.

Tuttavia, nonostante possa sembrare un modo strano di rappresentare il tempo, questa

rappresentazione è estremamente utile per fare confronti tra date che, essendo tutte

rappresentate in questo modo, si riducono a semplici confronti tra numeri interi,

senza che ci sia bisogno di confrontare giorni, mesi e anni. Ma ci sono anche

problemi legati a questa rappresentazione. La rappresentazione del tipo time_t infatti

è una rappresentazione a 32 bit, che ammette numeri negativi (ovvero numero di

secondi prima del 1 gennaio 1970, che consente la rappresentazione di date fino al

1900) e numeri positivi (numero di secondi passati dal 1 gennaio 1970). Il bit più

significativo del numero binario identifica il segno (0 per i numeri positivi, 1 per

quelli negativi). In questo modo è possibile rappresentare fino a numeri

positivi, ovvero numero di secondi dopo la data per eccellenza, e questo è un

problema perché con una tale rappresentazione la data andrà in overflow intorno al

2038 (ovvero, in un certo momento dopo le prime ore del 2038 si arriverà ad un

punto in cui la cifra più significativa del numero andrà a 1, quindi le date

cominceranno a essere contate dal 1900). Il bug del 2038 è molto conosciuto in

ambiente Unix, e per porre rimedio si sta da tempo pensando di migrare ad una

rappresentazione della data a 64 bit.

struct tm

tm è una struttura dichiarata sempre in time.h, contiene informazioni circa l'ora e la

138

data, questo è il conenuto:

struct tm {

int tm_sec //secondi prima del completamento del minuto

int tm_min //minuti prima del completamento dell'ora

int tm_hour //ore dalla mezzanotte

int tm_mday //giorno del mese

int tm_mon //mesi passati da gennaio

int tm_year //anni passati dal 1900

int tm_wday //giorni passati da Domenica

int tm_yday //giorni passati dal 1 Gennaio

int tm_isdst //''unknow'' (lol)

};

Esempio

Ecco un piccolo programma che ci mostra a schermo ora e data.

#include <stdio.h>

#include <time.h>

int main(int argc, char *argv[])

{

time_t a;

struct tm *b;

time(&a);

b = localtime(&a);

printf("Ora esatta: %s\n", asctime(b));

return 0;

}

Un altro modo per visualizzare la data attuale senza ricorrere a un membro della

struttura tm è il seguente:

#include <stdio.h>

#include <time.h>

main() {

time_t ltime = time();

printf ("%s\n",ctime(&ltime));

}

La funzione ctime prende l'indirizzo di una variabile di tipo time_t inizializzata

tramite time e stampa il suo valore in formato ASCII. Il formato standard è

Giorno della settimana (3 lettere) Mese (3 lettere) Giorno del mese

(2 cifre) hh:mm:ss Anno (4 cifre)

Un modo per stampare il tempo in un altro formato diverso da quello previsto da

139

ctime e asctime è quello di usare la funzione strftime, che prende come parametri

Una stringa nella quale salvare la data nel formato che si è scelto

• La dimensione della stringa

• Una stringa di formato (simile a printf) nel quale si specifica il formato in cui

• stampare la data

Un puntatore a struttura tm

Esempio:

#include <stdio.h>

#include <time.h>

main() {

time_t timer=time();

struct tm *now=localtime(&timer);

char timebuf[20];

strftime (timebuf,sizeof(timebuf),"%d/%m/%Y,%T",now);

printf ("%s\n",timebuf);

}

Stampa la data nel formato

gg/MM/aaaa,hh:mm:ss

La funzione localtime prende come parametro un puntatore a variabile time_t e

ritorna una struttura tm corrispondente a quel tempo. 140

Gestione dei file - primitive a basso

livello

Diverse versioni del C offrono un altro gruppo di funzione per la gestione dei file.

Vengono chiamate funzioni di basso livello, perché rispetto alle altre corrispondono

in modo diretto alle relative funzioni implementate nel kernel del sistema operativo.

Vengono impiegate nello sviluppo di applicazioni che necessitano di raggiungere

notevoli prestazioni. In questa sede esamineremo le primitive a basso livello per la

gestione dei file in ambiente Unix.

Si deve far attenzione a non usare i due tipi di funzione sullo stesso file; le strategie

di gestione dei file infatti sono differenti e usarle insieme può generare effetti

collaterali sul programma.

File pointer e file descriptor

A differenza delle funzioni d'alto livello definite in stdio.h, che utilizzano il concetto

di file pointer, le funzioni di basso livello fanno uso di un concetto analogo, il file

descriptor (conosciuto anche come "canale" o "maniglia"). Il file descriptor è un

numero intero associato dalla funzione di apertura al file sul quale si opera. Per poter

usufruire di queste funzioni è necessario includere nel sorgente i seguenti headers:

fcntl.h

• sys/types.h

• sys/stat.h

Il file descriptor, a differenza del file pointer definito in stdio.h che altro non è che un

puntatore alla struttura FILE, è un numero intero che identifica in modo univoco il

file aperto all'interno della tabella dei files aperti del sistema operativo. In questa

tabella i primi 3 numeri (0,1,2) sono riservati ai cosiddetti descrittori speciali:

0 - stdin (Standard Input)

• 1 - stdout (Standard Output)

• 2 - stderr (Standard Error)

Su un sistema Unix posso quindi scrivere su stdout o stderr e leggere dati da stdin

come se fossero normali file, quindi usando le stesse primitive (everything is a file!, è

un motto comune tra i sistemisti Unix). Se apro un altro file sul mio sistema Unix tale

file assumerà quindi un identificatore pari a 3 nella tabella dei file aperti, se ne apro

un altro ancora avrà un identificatore 4 e così via. 141

open

La funzione di apertura si chiama open, ecco un esempio:

#include <fcntl.h>

#include <sys/types.h>

#include <sys/stat.h>

main()

{ int fd;

fd = open("nomefile", O_WRONLY);

...

Questa funzione associa fd (file descriptor) a nomefile e lo apre in modalità di sola

scrittura.

La open ritorna un valore intero, che è negativo nel caso in cui si è verificato un

errore, ad esempio il file non esiste o non si hanno i diritti di lettura/scrittura.

La sintassi della funzione è questa:

int fd, modo, diritti;

...

fd = open("nomefile", modo [diritti]);

Modalità di apertura

modo rappresenta la modalità di apertura del file, può essere una o più delle seguenti

costanti simboliche (definite in fcntl.h):

O_RDONLY apre il file in sola lettura

• O_WRONLY apre il file in sola scrittura

• O_RDWR apre il file in lettura e scrittura

(Per queste tre costanti simboliche, se il file non esiste la open ritorna errore)

O_CREAT crea il file

• O_TRUNC distrugge il contenuto del file

• O_APPEND tutte le scritture vengono eseguite alla fine del file

• O_EXCL Se al momento dell'apertura il file già esiste, la open ritorna errore

Per poter specificare più di una modalità di apertura si può usare l'operatore di OR bit

a bit, esempio:

fd = open("nomefile", O_CREAT | O_WRONLY, 0640);

Crea il file e lo apre in modalità sola scrittura. 142

Permessi

Ora vi starete chiedendo cos'è quel 0640, sono i diritti, o permessi, con i quali il file

deve essere creato. Sono codificati con una sintassi simile a quella di Unix, che

suddivide gli utenti in tre categorie:

possessore del file;

• appartiene al gruppo collegato al file;

• non è collegato al file in alcun modo.

Per ogni categoria si possono specificare i permessi tramite la forma ottale, costituita

da 3 o 4 cifre comprese tra 0 e 7. Esempio:

0640

Tralasciamo il significato della prima cifra a sinistra, che è opzionale. Ogni cifra è da

interpretare come una somma delle prime tre potenze di 2 (2^0=1, 2^1=2, 2^2=4),

ognuna delle quali corrisponde ad un permesso - andando da sinistra verso destra, la

seconda rappresenta il proprietario, la terza il grupp e l'ultima tutti gli altri utenti; la

corrispondenza è questa:

4 permesso di lettura

• 2 permesso di scrittura

• 1 permesso di esecuzione

• 0 nessun permesso

Dunque per ottenere un permesso di lettura e scrittura non occorre far altro che

sommare il permesso di lettura a quello di scrittura (4+2=6) e così via.

Un altro modo di vedere i permessi Unix di un file è tramite la rappresentazione

binaria. I permessi Unix visti sopra non sono altro che una rappresentazione in modo

ottale di un numero binario che se visto fa capire al volo quali sono i permessi su un

particolare file. Ecco come funziona:

U G O

rwx rwx rwx

110 100 000

In questo caso l'utente (U) ha permessi di lettura e scrittura sul file. Il gruppo (G) ha

solo i permessi di lettura. Gli altri utenti non hanno alcun permesso. Se convertiamo

ogni gruppetto di 3 cifre in ottale otteniamo 0640, che è effettivamente il permesso

che vogliamo.

close

La funzione close serve a chiudere un file descriptor aperto dalla open:

int fd;

... 143


PAGINE

226

PESO

937.88 KB

AUTORE

mdiego

PUBBLICATO

+1 anno fa


DESCRIZIONE DISPENSA

Struttura di massima di un calcolatore, algoritmi, programmi e linguaggi, compilatori, sistemi operativi e reti. Logica e codifica binaria dell`informazione (logica proposizionale, operatori logici AND, OR, NOT. Aspetti fondamentali della programmazione: il linguaggio di programmazione e le esigenze di astrazione, la sintassi dei linguaggi, struttura di un programma monomodulo, astrazione sui dati (concetto di tipo e tipi base del linguaggio, operatori e compatibilita`, i costruttori di tipo array, struct, puntatori), astrazione sul controllo dell`esecuzione (strutture di controllo condizionali, di selezione, iterative). Sottoprogrammi e ricorsione: programmazione in piccolo e in grande, sottoprogrammi come astrazione sul controllo a livello di unita`, passaggio dei parametri, dati locali, regole di visibilita`, sviluppo top down per raffinamento, ricorsione, record di attivazione, pila, cenni ai moduli. Strutture dati dinamiche, liste collegate a puntatori. Strutture dati persistenti: i file (concetti, operazioni, organizzazione logica), integrazione tra strutture dati in memoria centrale e su file. Traduzione di un programma C oppure C++ in assembler. Introduzione ai sistemi operativi: processi e programmazione di sistema.


DETTAGLI
Corso di laurea: Corso di laurea in ingegneria informatica (COMO - CREMONA - MILANO)
SSD:
A.A.: 2014-2015

I contenuti di questa pagina costituiscono rielaborazioni personali del Publisher mdiego di informazioni apprese con la frequenza delle lezioni di Fondamenti di Informatica e studio autonomo di eventuali libri di riferimento in preparazione dell'esame finale o della tesi. Non devono intendersi come materiale ufficiale dell'università Politecnico di Milano - Polimi o del prof Matera Maristella.

Acquista con carta o conto PayPal

Scarica il file tutte le volte che vuoi

Paga con un conto PayPal per usufruire della garanzia Soddisfatto o rimborsato

Recensioni
Ti è piaciuto questo appunto? Valutalo!

Altri appunti di Fondamenti di informatica

Esercizi sulla programmazione in C
Esercitazione
Informatica Teoria
Appunto
Teoria Programmazione in C e esercizi
Appunto
Fondamenti di informatica - Sistemi
Appunto