Approfondimenti sul linguaggio C

Questo documento contiene approfondimenti sul linguaggio C, tralasciando completamente le funzioni di libreria (che comunque non fanno parte del linguaggio), pensando ad un pubblico che sa già programmare in qualche altro linguaggio. È una integrazione ad A-C-X.html.

Approfondimento: puntatori e vettori

Ricordiamo che il C è pensato per essere molto vicino al processore e alle sue strutture; non dovrebbe perciò stupire la scelta degli autori del linguaggi di rappresentare un vettore con l'indirizzo del suo primo elemento. Puntatori e vettori sono perciò concetti intercambiabili e ad un puntatore si può applicare un indice tramite l'operatore parentesi-quadre senza che questo provochi errori o messaggi di attenzione. Il nome di un vettore è diverso da un puntatore in quanto non gli può essere assegnato un nuovo valore: si tratta di un indirizzo costante istanziato all'atto della compilazione del programma.

Ai puntatori possono essere sommati e sottratti numeri interi: il risultato della somma di un puntatore e di un numero intero n è il puntatore all'elemento numero n del vettore. Il numero intero non rappresenta cioè il numero di byte da aggiungere nell'indirizzo, ma il numero di elementi, il fattore di scala appropriato viene applicato dal compilatore in base al tipo di puntatore oggetto di operazione aritmetica. È possibile quindi incrementare/decrementare un puntatore, come pure fare la differenza (ma non la somma) tra puntatori dello stesso tipo, e il risultato è un numero intero). L'aritmetica su puntatori generici (puntatori a void) con gcc usa 1 byte come dimensione dell'elemento puntato, mentre non è definita secondo lo standard del linguaggio. Il "puntatore nullo" vale zero e non è un puntatore valido; nelle funzioni che ritornano un puntatore è spesso ritornato come segnalazione di errore. La macro NULL vale 0, e 0 è confrontabile con qualunque puntatore.

Gli operatori più importanti per usare i puntatori sono "*" (si legge "il puntato da") e "&" ("l'indirizzo di").

Esempi:

int i, v[10], *p; /* un numero intero, un vettore e un puntatore:
                    "è intero l'elemento del vettore v, di dimensione 10"
                    "è intero quello che è puntato da p" */
p = v;         /* assegno a p il valore di v, l'indirizzo del primo elemento */
p = &v[0];     /* come sopra */
p = &v[4];     /* assegno a p l'indirizzo del quinto elemento di v */
p = v+4;       /* come sopra */
p++;           /* incremento p */
i = p-v;       /* assegno 5 a i (in conseguenza delle due righe precedenti) */

Nel seguente ciclo, assegno all'intero i la somma di tutti gli elementi del vettore v che non sono zero. Si noti che deve esserci un elemento di v che vale zero, altrimenti il puntatore assumerà valori non validi. Il linguaggio non effettua alcun controllo implicito su puntatori e indici di vettori.

i=0;
for (p = v; *p; p++)
    i += *p;

È un errore fare assegnamenti tra puntatori di tipo diverso, così come è un errore fare operazioni aritmetiche tra puntatori di tipo diverso. È comunque possibile convertire un puntatore da un tipo ad un altro (ma anche un puntatore in numero intero e viceversa); queste conversioni non provocano la generazione di codice macchina, perché comunque nel processore sono tutti numeri interi, ma sono necessarie per la pulizia semantica del codice sorgente.

È sempre consentito l'assegnamento di un puntatore-a-void a qualunque altro tipo di puntatore, come pure l'assegnamento di qualunque puntatore ad un puntatore-a-void. Questo perché il tipo "void *" è quello che normalmente si usa per gestire indirizzi generici di memoria, operazione comunissima nei sistemi operativi e nelle librerie di sistema.

L'operatore sizeof(), applicato ad un tipo, ad un nome di variabile o ad un'espressione, ritorna la dimensione in byte dell'oggetto indicato. Tale calcolo viene effettuato in compilazione in base ai tipi di dato che vengono passato a sizeof. Se incremento un puntatore p, il suo valore numerico (indirizzo in memoria in byte) viene incrementato di sizeof(*p).

Esempio:

int i, v[10], *p;   	  /* le stesse variabili di prima */
i = sizeof(int);    	  /* normalmente 4, ma può essere 8, oppure 2 */
i = sizeof(i);      	  /* come sopra */
i = sizeof(v[0]);      	  /* come sopra */
i = sizeof(*p);      	  /* come sopra */
i = sizeof(p);            /* 4 (dimensione del puntatore), oppure 8 */ 
i = sizeof(v);      	  /* 40, oppure 80, oppure 20 */
i = sizeof(v)/sizeof(*v); /* 10: il numero di elementi nel vettore v */
#define ARRAY_SIZE(x) (sizeof(x)/sizeof(*x)) /* una comoda macro */

Approfondimento: stringhe

Una stringa in C è un vettore di caratteri terminato da un byte a zero. La rappresentazione tra virgolette è solo una notazione semplificata per rappresentare un vettore. Ogni volta che nel testo del programma appare una stringa tra virgolette, il compilatore memorizza la stringa nel segmento dati del programma e la rappresenta con l'indirizzo del suo primo elemento. Un carattere incluso in apice singolo è un numero intero, cioè il codice ASCII del carattere indicato.

Esempi di dichiarazioni di stringhe e puntatori:

char s[] = "prova";  /* un vettore di 6 caratteri, compreso il terminatore */
char s[] = {'p', 'r', 'o', 'v', 'a', 0}; /* lo stesso, in notazione barocca */
char c, *t; /* un carattere, un puntatore a carattere */
c = *s;     /* c prende il valore 'p' */
t = s+2;    /* t rappresenta la stringa "ova" */
s[0] = 't'; /* ora la stringa s è "trova" */

char *name = "arturo"; /* un puntatore ad un'area preiinizializzata di 7 byte */
char surname[] = "rossi"; /* un'area di 6 byte, con indirizzo "surname" */
name++;                /*  ora name indica "rturo" */
surname++;             /*  errore: surname è un indirizzo costante */
Una implementazione di strlen, funzione che ritorna la lunghezza di una stringa, può quindi essere la seguente:
int strlen(char *s)
{
    char *t = s;
    for (; *t; t++)
        ;
    return t - s;
}

Approfondimento: puntatori a funzione

Come nel caso dei vettori, una funzione viene rappresentata dall'indirizzo del codice associato; tutte le volte che si usa un nome di funzione in un programma si sta in pratica usando il puntatore a tale funzione. L'uso più consueto di un puntatore a funzione è l'applicazione dell'operatore parentesi-tonde, situazione che normalmente non viene pensata in termini di puntatori ed operatori da parte del programmatore. Un puntatore a funzione può anche essere assegnato ad altri puntatori, per esempio all'interno di strutture dati che definiscono i metodi con cui operare sugli oggetti, oppure passato come argomento a altre funzioni, per esempio la funzione di libreria qsort, funzione che implementa l'algoritmo "quick sort" su un vettore. Il compilatore verifica in compilazione che i tipi dei puntatori a funzione siano compatibili, cioè le funzioni come dichiarate ricevano gli stessi argomenti. Esempio:

#include <string.h> /* per la dichiarazione di strcmp */
#include <stdlib.h> /* per la dichiarazione di qsort */

char *strings[100]; /* definisco un vettore di 100 puntatori */

strcmp(strings[0], strings[1]); /* confronto due stringhe */

/* chiamo qsort dicendo che strcmp() è la funzione di confronto da usare */
qsort(strings, 100, sizeof(char *), strcmp);

strncmp(strings[0], strings[1], 5); /* confronto solo i primi 5 caratteri */

/* questo invece è un errore, perché strncmp riceve tre argomenti */
qsort(strings, 100, sizeof(char *), strncmp);

Approfondimento: allocazione di memoria

Il linguaggio non offre primitive di gestione di memoria (cose tipo new, creatori e distruttori) e nemmeno la raccolta della spazzatura («garbage collection» sembra più raffinato, ma di quello si tratta).

La memoria usata dai programmi può essere di tre tipi: statica, dinamica, automatica. Una variabile o struttura dati statica è quella dichiarata in compilazione, cui il linker assegna un indirizzo immutabile. Una struttura dati dinamica è allocata durante il funzionamento del programma, per esempio chiamando malloc e accedendo allo spazio così ottenuto tramite un puntatore. Una variabile cosiddetta "automatica" viene allocata sullo stack e scompare al termine del blocco di codice che la dichiara.

Una variabile statica è inizializzata a zero, a meno che il programma non dichiari un valore costante da precaricare nella variabile. Le variabili inizializzate sono salvate su disco e risiedono nel "segmento dati" del programma e del file eseguibile ELF; le variabili non inizializzate stanno nel "segmento bss" del programma, una zona di memoria che viene allocata e azzerata prima dell'esecuzione del programma, il file su disco non contiene una copia del bss ma solo la sua dichiarazione.

Una variabile dinamica risiede in memoria che viene richiesta al sistema durante il funzionamento del programma. Al momento dell'allocazione non si possono fare assunzioni sul contenuto di tale memoria: potrebbe essere azzerata ma potrebbe contenere informazioni residue di precedenti allocazioni poi liberate. Ad ogni malloc deve corrispondere una free, in mancanza della quale abbiamo una situazione di perdita di memoria («memory leakage») e la dimensione del programma in esecuzione aumenterà in continuazione. Mentre la memoria di un programma in spazio utente viene liberata tutta al termine del programma, una allocazione non liberata in spazio kernel causa una perdita di memoria che può essere recuperata solo riavviando la macchina.

Una variabile automatica è una variabile locale di una funzione o di un blocco di codice, risiede sullo stack e non viene inizializzata a meno che il programmatore non imponga un valore a tale variabile; in tal caso il codice macchina generato dal compilatore contiene le istruzioni necessarie a riempire la variabile come richiesto. La memoria delle variabili automatiche, essendo parte dello stack del programma, non è più utilizzabile al termine della procedura che definisce la variabile stessa.

Esempi:

int i;              /* dato inizializzato a zero, segmento bss */
int v[4] = {2,1,};  /* dato inizializzato a {2,1,0,0}, segmento dati */
int j = f(3, i);    /* errore: valore non noto in compilazione */

int *f(int x, int y)
{
    int z;                            /* var. automatica, non so quanto vale */
    int a=0, b=1, c=2;                /* variabili  inizializzate a run-time */
    int *p = malloc(4 * sizeof(int)); /* inizializzazione valida a run-time */
    int *q, *r = &z;                  /* due puntatori, uno vale l'.ind. di z */

    *q = y;          /* errore: il puntatore non è stato assegnato */
    *r = y;          /* corretto: r è l'indirizzo di z, quindi assegno z */
    if (x) return p; /* corretto: la memoria allocata rimane disponibile */
    else return &z;  /* errore: all'esterno di f non posso usare z */
}

Approfondimento: tutti gli operatori

Si veda il file operator.tbl per una tabella di tutti gli operatori con la loro precedenza e associatività. Tale file può essere stampato in una pagina a4 o a5. Gli operandi di ogni operatore sono altre espressioni tranne in due casi particolari. Questa sezione mostra solo l'uso di ogni operatore, nello stesso ordine di operator.tbl:

Approfondimento: il costrutto switch

Il costrutto di controllo switch serve a scegliere tra diversi comportamenti in base al valore di una espressione intera, ricordando che un carattere tra apici è un numero intero. La sintassi è diversa da quella degli altri costrutti di controllo, perché le parentesi graffe sono obbligatorie; inoltre fa uso delle parole chiave case e default che non hanno altri usi nel linguaggio.

La sintassi completa è la seguente:

switch ( espressione-intera ) {
    case espressione-costante :
        [ istruzione ... ]
        [ break ; ]
    case espressione-costante :
        [ istruzione ... ]
        [ break ; ]
    [ default: ]
        [ istruzione ... ]
        [ break ; ]
}

Le espressioni di ogni case devono essere espressioni intere e costanti, cioè valutabili all'atto della compilazione. La presenza di istruzioni dopo ogni case è facoltativa, per permettere di raggruppare lo stesso codice in relazione a diversi casi.

La presenza di break alla fine di un caso è facoltativa, per permettere che le istruzioni associate ad un caso continuino con il codice del caso successivo; è sempre meglio commentare la mancanza di break, perché non sembri una dimenticanza a chi legge il codice.

Il costrutto default è facoltativo; se presente viene selezionato quando l'espressione del costrutto switch non trova corrispondenza tra i casi elencati. Non è obbligatorio che default sia l'ultimo caso del costrutto.

Esempio: conversione estremamente inefficiente da esadecimale a decimale, un carattere alla volta. Si noti come c viene modificato dopo essere stato usato per la selezione del caso corretto; non deve stupire in quanto l'espressione di switch viene valutata una volta sola.

int value;
int nextchar(int c)
{
    switch(c) {
        case 'a': case 'b': case 'c': case 'd: case 'e': case 'f':
            c = c - 'a' + 10 + '0';
            /* fall through */
        case '0': case '1': case '2': case '3': case '4':
        case '5': case '6': case '7': case '8': case '9':
            value = value * 10 + c - '0';
            break;
        case 'p':
            printf("%i\n", value); value = 0;
            break;
        default:
            return -1; /* error */
    }
    return 0;
}

Normalmente switch viene usato per selezionare tra diversi comandi, per esempio nell'implementazione della chiamata di sistema ioctl(), oppure nella valutazione dei parametri sulla riga di comando. L'uso di due o più case per lo stesso blocco di codice è raro, come è rara la necessità di scavalcare un case nell'esecuzione di quello precedente.

Approfondimento: le strutture dati

Una struttura dati (struct) può includerne altre o includere puntatori ad altre strutture. Mentre i puntatori possono riferire una struttura da un'altra ciclicamente, l'inclusione di strutture non può essere ricorsiva, perché la struttura inclusa è interamente contenuta nella struttura includente.

Poiché il compilatore effettua una sola passata sul codice, se una struttura contiene il puntatore ad un'altra struttura, occorre dichiarare tale struttura preventivamente, anche senza definirne l'elenco dei campi. Tale struttura non può essere istanziata, perché il compilatore non sa la sua dimensione in byte, ma si possono definire puntatori ad essa, perché i puntatori hanno tutti la stessa dimensione.

Esempio:

struct father;

struct child {
    struct father *father;
    /* ... */
};

struct father {
    struct child *child;
    /* ... */
};

Dichiarare una struttura senza specificarne l'elenco dei campi permette anche di creare strutture «opache» in una libreria, normalmente usate per dati privati della libreria stessa. Se una struttura contiene un puntatore alla struttura stessa non serve la dichiarazione preventiva, perché mentre il compilatore legge la lista dei campi ha già visto il nome della struttura stessa.

struct dpriv;
struct datum {
    struct dpriv *priv; /* lista dei campi ignota all'utente di datum */
    /* ... */
    struct datum *next; /* per l'inserimento in una lista */
};

Approfondimento: la visibilità (scope) di dati e funzioni

In C, lo spazio dei nomi di variabili e funzioni è piatto, non esiste cioè il supporto per «namespace» separati; anzi variabili e funzioni non possono avere lo stesso nome, perché ad un nome può essere associato un solo indirizzo, sia esso codice o dati.

A differenza delle variabili globali, le variabili locali, o «automatiche», sono visibili solo all'interno del blocco in cui sono dichiarate; tale blocco può essere una funzione o anche un'istruzione composta racchiusa tra graffe, sia essa il corpo di un costrutto di controllo (if, for, eccetera) o un'istruzione composta a se stante. Le variabili locali a un blocco sono allocate sullo stack, mentre non è possibile definire funzioni "locali" all'interno di un blocco. Se una variabile definita all'interno di un blocco ha lo stesso nome di un'altra variabile, globale o locale, all'interno del blocco il nome si riferisce alla sua definizione più interna. Come già detto, gli argomenti di una funzione sono variabili locali della funzione stessa.

La parola chiave static, un qualificatore per codice e dati, serve per cambiare le regole di visibilità. Un simbolo globale (funzione o variabile) se dichiarato static non è visibile all'esterno del file ove è definito, perché il suo nome non viene reso disponibile al linker. Una variabile locale, se static, viene allocata nello spazio dati globale, ma senza esportarne il nome; permette quindi di avere uno stato persistente tra le varie invocazioni del blocco in cui è definita. Esempio:

int i; /* globale */
static int j; /* globale, ma visibile solo in questo file */
static int invert(int i) /* invert può essere chiamata solo in questo file */
{
      int j; /* allocata sullo stack */
      j = -i; /* variabili locali, "i" è l'argomento della funzione  */
      return j;
}
int count(void) /* count è definita globalmente nel programma */
{
      static int i;  /* locale ma persistente, inizializzata a zero */
      return ++i; /* incrementa il contatore e ritorna il valore */
}

Approfondimento: opzioni più importanti del gcc

Il compilatore gcc, come ogni implementazione di cc, riceve opzioni sulla riga di comando. I file vengono elaborati in base al proprio nome: se terminano in .c vengono compilati, se terminano in .S vengono solo passati all'assemblatore, se terminano in .o vengono solo passati al linker. Opzioni più importanti (file indica un nome di file, ogni volta diverso):

Esempio:
gcc -DDEBUG jpegdemo.c -I/usr/local/include -L/usr/local/lib -ljpeg -o jpegdemo

Approfondimento: stile di programmazione

Siate coerenti nello stile di programmazione, fate rientrare i blocchi sempre allo stesso modo, qualunque esso sia. Lo stile più diffuso è quello di Kernighan e Ritchie (graffa aperta a fine riga e graffa chiusa da sola in una riga a se stante); non importa molto la vostra preferenza stilistica quanto la coerenza in tutto il codice sorgente.

Qualunque sia il vostro livello di rientro dei blocchi (in spaghetti-inglisc: «indentazione»): 2, 4 o 8 caratteri, il carattere TAB vale 8 spazi; attenzione alla configurazione predefinita del vostro editor che potrebbe essere scorretta.

Mantenete le funzioni brevi e comprensibili, se una funzione diventa troppo complessa è meglio dividerne il codice in blocchi concettualmente separati (funzioni distinte).

Usate le strutture dati per maggior chiarezza e manutenibilità; definite creatori e distruttori per gli oggetti più che usare variabili globali.

Controllate sempre tutti gli errori: ogni funzione che chiamate può fallire, il codice chiamante deve verificare il valore di ritorno e comportarsi in maniera appropriata, che spesso vuol dire propagare l'errore alla funzione chiamante.

Non chiamate exit dall'interno di una funzione in caso di errore, lasciate decidere al programma principale.

Commentate bene il vostro codice; evitate costrutti particolarmente «furbi», ma se lo fate spiegate il perché della vostra scelta.

Specificate sempre i termini di licenza nel file sorgente; in assenza di permessi specifici vale la clausola «tutti i diritti riservati», ma anche se questa è la vostra intenzione è sempre meglio specificarlo per chiarire ogni dubbio.

Non fate interazione utente se non strettamente necessario. Se necessario, leggete stdin con fgets e poi sscanf, mai con scanf direttamente; scrivete stdout per righe complete, terminate da '\n'. Evitate l'output inutile («il silenzio è d'oro») e le righe vuote supreflue.

Cosa manca

I costrutti che non sono stati trattati in questi due documenti, in quanto relativamente poco usati, sono:


Alessandro Rubini
Last modified: Marzo 2010