Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

domenica 16 agosto 2020

Prototipi? Si, grazie!
considerazioni sull'uso dei prototipi in C (e C++)

(...una premessa: questo post è un remake di un mio vecchio post. Ma, anche se tratta lo stesso argomento, amplia notevolmente il discorso è affronta anche altri temi. Leggete e mi direte...

Dante: Dunque un modo per aprirla è quello della dinamite. Sistema che usava il famoso fu Cimin.
Tiberio: Fu Chi Min? Chi è, un cinese?
Dante: Ma che cinese! Veneziano era! “Fu” sarebbe che morì, Cimin è il cognome, no?!

Questo surreale dialogo tra Dante Cruciani (Totò) e Tiberio Braschi (Marcello Mastroianni) è tratto dal bellissimo I soliti ignoti, che è considerato (a ragione) uno dei prototipi della grande Commedia all'italiana. Ma cos'è un prototipo? Uhm... "Primo esemplare, modello originale di una serie di realizzazioni successive..." [Cit. Treccani]. Ecco, oggi parleremo dei Prototipi di Funzione nel linguaggio C (con qualche incursione nel C++) che sono molto somiglianti alla definizione del dizionario.

...facce da: "cosa sono sti' prototipi?"...
Dopo una rapida ispezione in rete ho notato una certa confusione sull'argomento. Prototipi obbligatori, forse consigliati, a volte sconosciuti... ho notato informazioni fuorvianti perfino in dispense universitarie (ahi, ahi). Tra l'altro, nei miei trascorsi, ho incontrato anche colleghi programmatori che non avevano le idee chiare sull'argomento. Beh, allora è giunta l'ora di fare chiarezza!

Partiamo dai dati di fatto, lasciando alla seconda parte del post le considerazioni tecniche/filosofiche sull'argomento. Mi raccomando di prestare attenzione, nel seguito del testo, ad alcune parole chiave che useremo e cercheremo di illustrare: prototipo, dichiarazione e definizione. E, faremo riferimento anche alle varie versioni del C che ci hanno accompagnato fino ad oggi che, in ordine di tempo, sono: K&R C, ANSI C (C89/C90) e C99/C11. Se non altrimenti specificato tutte le prossime affermazioni/considerazioni si riferiranno alle versioni più recenti, C99 e C11 (che non hanno, tra di loro, differenze significative su questo argomento).

Veniamo al dunque: nel C i prototipi non sono obbligatori. La confusione su questo fatto deriva dalla doppia personalità che hanno molti programmatori C (incluso il sottoscritto) che devono, spesso, districarsi tra C e C++ facendo, a volte, un po' di confusione: i prototipi sono obbligatori nel C++, per motivi strettamente collegati ad alcune funzionalità del linguaggio (vi suona il Function Overloading?).

Nel C, invece, è obbligatoria la dichiarazione di una funzione.

Facciamo, allora, un esempio sulle parole chiave dichiarazione, prototipo e definizione, usando solo una sintassi di tipo moderno (ANSI C o C99/C11):

// dichiarazione di funzione (valida ma sconsigliata perché ambigua e obsoleta)
int myFunc();

// dichiarazione di funzione con prototipo
int myFunc(int val):

// definizione di funzione con prototipo
int myFunc(int val)
{
    int retval;

    // faccio cose
    ...

    return retval;
}
L'ordine nell'esempio descritto, come evidente, non è casuale: la dichiarazione è il caso elementare, mentre il prototipo contiene implicitamente una dichiarazione e, infine, la definizione contiene implicitamente un prototipo (e quindi anche una dichiarazione). E, dato che ci siamo, aggiungiamo, per completezza, le sintassi di definizione permesse ma troppo old-fashioned, e le sintassi vietate da C99/C11:
// definizione con dichiarazione "old style" senza prototipo
int myFunc(val)
    int val;
{
    // faccio cose
    ...

    return 0;
}

// definizione implicitamente dichiarata:
// equivale a "int myFunc(int val)" (non permessa nel C99)
myFunc(val)
{
    // faccio cose
    ...

    return 0;
}
Prima di passare alla parte filosofica, facciamo una breve analisi storica: nel K&R C non c'era l'obbligo di dichiarazione delle funzioni, quindi non c'era nessun controllo a compile-time sul valore di ritorno e, ancor meno, sulla coerenza dei parametri passati: in mancanza della dichiarazione il compilatore applicava un comportamento di default e assumeva che la funzione ritornava un int. Per i parametri si applicava la default argument promotion: gli interi venivano promossi a int, e i float erano promossi a double.

Con l'avvento del ANSI C (o C89/C90), sono arrivati i prototipi, però è stata mantenuta la retro-compatibilità con la vecchia sintassi (per non obbligare a sistemare milioni di linee di codice funzionante). Con questa novità era, finalmente, possibile controllare a compile-time la correttezza d'uso delle funzioni, sia sui parametri che sui valori di ritorno. A causa della retro-compatibilità rimaneva, però, possibile scrivere nuovo codice con la sintassi antica, ed, inoltre, rimaneva valido il concetto del default return value in assenza di dichiarazione.

Con il C99 si è fatto un ulteriore passo in avanti: va bene la ricerca della compatibilità con il codice pre-esistente, ma il valore di ritorno di default era una falla troppo grande nella solidità del linguaggio, per cui si è introdotta la dichiarazione obbligatoria, come indicato all'inizio del post (aggiungo che si è anche reso obbligatorio l'uso dei prototipi negli standard headers del linguaggio, ma questa è un altra storia...).

E ora, dopo avere descritto quello che lo standard ci obbliga e/o permette di fare, veniamo, finalmente, a ciò che è meglio fare: secondo me un buon programmatore usa i prototipi (quindi, presumo, per la proprietà transitiva chi non usa i prototipi non è un buon programmatore. Ho detto presumo, quindi se qualcuno si è offeso non se la prenda con me, se la prenda con la proprietà transitiva). E perché consiglio così caldamente l'uso dei prototipi? Beh, il C è un linguaggio tipizzato, per cui è così evidente l'aiuto che questo meccanismo ci può dare per produrre codice senza errori di tipo, migliorando al tempo stesso leggibilità e manutenibilità, che non c'è neanche bisogno di spiegarlo!

E, per aggiungere un tocco di radicalità che non guasta mai, aggiungo che, per le suddette questioni di leggibilità e manutenibilità del software, non è conveniente affidarsi al fatto che, usando definizioni con prototipo (vedi esempio sopra), e scrivendo il codice nel giusto ordine (cioè usando una funzione solo dopo la sua definizione), non è necessario scrivere dei veri e propri prototipi. Non siate pigri nelle cose utili, per favore!

E come deve essere strutturato un buon codice rispetto a quanto detto sopra? Vediamo un esempio di struttura elementare con tre file:

  1. un header-file che contiene i prototipi globali.
  2. un library-file che include l'header del punto 1 e contiene le definizioni (con prototipo) delle funzioni prototipate nel header-file.
  3. un implementation-file che include l'header del punto 1, e usa le funzioni prototipate nel header-file. L'implementation-file contiene, ovviamente, anche i prototipi delle eventuali funzioni locali e le relative definizioni (con prototipo).

E con questo sarebbe tutto, anche se possiamo aggiungere una interessante curiosità un po' OT, ma che merita un approfondimento per evitare equivoci. Vediamo di cosa stiamo parlando:

// due dichiarazioni in C
int myFunc1();      // funzione con numero arbitrario di argomenti
int myFunc2(void);  // funzione con 0 argomenti

// due dichiarazioni in C++
int myFunc1();      // funzione con 0 argomenti
int myFunc2(void);  // funzione con 0 argomenti
Avete letto i commenti? Attenzione, quindi! In C int myFunc1() e int myFunc2(void) sono funzioni diverse, mentre in C++ significano la stessa cosa. Quindi occhio a non fare (in C) stranezze tipo queste:
// definizione con dichiarazione "old style" senza prototipo
int myFunc1()
{
    // faccio cose
    ...

    return 0;
}

// definizione di funzione con prototipo
int myFunc2(void)
{
    // faccio cose
    ...

    return 0;
}

// funzione main
main()
{
    ...
    myFunc1(1, 2);  // NOK: "undefined behaviour" e nessun warning/errore in compilazione
    myFunc2(1, 2);  // OK:  errore in compilazione per uso improprio della funzione
    ...
}
Quindi, usando impropriamente una funzione con dichiarazione old-style (come la myFunc1() qui sopra) si produce un undefined behaviour (come ci conferma lo standard del C99) che si potrebbe trasformare in qualche mal di testa...

Ecco, quando passate frequentemente da C a C++ (e viceversa) ricordatevi di queste cose (e di alcune altre, ma questa è un altra storia...). Comunque, una cosa è sicura: le dichiarazioni old-style sono state mantenute per retro-compatibilità, ma non usatele mai, per favore!

E ho detto tutto!

Ciao, e al prossimo post!