Home > Listati di programmi C > Il mio stile di programmazione C

IL MIO STILE DI PROGRAMMAZIONE C

Quando scrivo un programma, le mie due priorita' sono:

1) la correttezza;

2) la leggibilita'.

Spesso preferisco rinunciare a un po' di velocita' e di risparmio di memoria, per garantire maggiore leggibilita'.


Correttezza

Per gli algoritmi piu' complicati disegno il flow-chart, prima di passare alla stesura del codice; poi eseguo il programma con carta e penna, come se fossi io il calcolatore. Per la gestione di liste concatenate, array e altre strutture dati mi aiuto con delle rappresentazioni grafiche per visualizzare le varie operazioni da compiere.

Cerco di tenere conto di tutti i casi particolari, per quanto improbabili (ma possibili). Ogni caso particolare va trattato in diverse sezioni di codice associate a istruzioni if, opportunamente commentate.

Inizializzo il piu' possibile le variabili stringa (assegnando 0 a tutti i byte), per evitare che in una stringa si trovino byte casuali o residui - dopo il byte di fine stringa - di precedenti assegnazioni. Analogamente, inizializzo tutti i campi stringa di una struct; di solito creo delle funzioni apposite (azzera_stringa(), azzera_struct()).

Per l'inserimento dei dati spesso evito di usare scanf(); uso la funzione leggi_stringa(), che puo' essere vista nei miei listati (funziona con un ciclo di chiamate di getchar()). Anche i dati numerici possono essere letti come stringhe, convertendoli poi con atoi() e atof().

Ultimamente (inizio 2019) ho scritto una nuova versione di leggi_stringa(), che evita il deposito nel buffer degli eventuali caratteri digitati in piu' (rispetto alla capienza della stringa).

Per quanto riguarda la conversione di stringhe in interi, atoi() presenta alcuni limiti e vari esperti consigliano di evitarla; quindi sto preparando una versione personalizzata, che mettero' prossimamente nel sito, insieme ad altre funzioni utili per prevenire overflow nei calcoli.

Quando apro un file in modalita' lettura o lettura/scrittura binaria (rb, rb+) verifico sempre il valore del puntatore al file, che assume valore NULL se il file non e' stato trovato.

Preferisco trattare tutti i file come sequenze di byte (e non di caratteri); per questo uso sempre la modalita' di lettura/scrittura binaria (rb, rb+, ab, wb). Evito di usare la funzione feof(); per leggere tutti i dati di un file calcolo il numero di elementi dello stesso (con fseek() e ftell()) e scrivo un ciclo for; e' utile, per tutte le applicazioni, conoscere il numero di elementi collocati in un file.

Presto sempre la massima attenzione alla modalita' di apertura file:

rb legge solo;

rb+ legge e consente di sovrascrivere certi dati, lasciando invariati gli altri;

ab scrive dati accodandoli a quelli gia' presenti;

wb scrive dati; se al momento della chiamata di fopen(nome_file, "wb") il file contiene gia' dei dati, questi vanno persi; wb e' una modalita' pericolosissima, perche' usata a sproposito puo' provocare la perdita non voluta di tutti i dati faticosamente raccolti; prima di usarla bisogna capire bene cosa avverra' ed e' sempre opportuno, almeno durante i collaudi, salvare prima i dati in un altro file.

Uso wb il meno possibile.

Per cambiare modalita' di apertura bisogna prima chiudere il file e poi riaprirlo con la nuova modalita'.

Per leggere/scrivere dati in un file uso solo fread()/fwrite().

Quando il file contiene dati di dimensione costante, ad esempio struct o numeri, mi posiziono sul dato ennesimo (che deve essere letto o sovrascritto) con l'istruzione

fseek(f_dati, ((posiz - 1) * sizeof(tipo_dato)), SEEK_SET);

posiz indica la posizione del dato nel file, contando a partire da 1 (primo dato posiz=1, secondo posiz=2, ... , ultimo dato posiz=numero di dati del file); l'istruzione suddetta serve solo quando si vuole "saltare" direttamente al dato in questione; quando si leggono/scrivono dati sequenzialmente non serve, perche' il puntatore al dato da leggere/scrivere (segnaposto) si aggiorna automaticamente ad ogni chiamata di fread()/fwrite().

Se, dopo avere letto un dato, lo si vuole anche modificare - sovrascrivendo un nuovo valore - bisogna tornare indietro di un posto con:

fseek(f_dati, (-1) * sizeof(tipo_dato), SEEK_CUR);

altrimenti non si modifica il dato voluto, ma quello successivo, perche' dopo la lettura il segnaposto e' stato incrementato di 1.

Per gestire file di stringhe di lunghezza variabile, creo un file accessorio di struct che indicizzano ogni stringa, memorizzando in ogni struct la posizione e la lunghezza della stringa corrispondente; inoltre, nel file delle stringhe inserisco alla fine di ogni stringa un byte 0, che funge da separatore; potrei anche farne a meno, avendo memorizzato nel file indice posizione e lunghezza di ogni stringa; ma preferisco avere un separatore, perche' cosi' mi sara' piu' facile riciclare i dati in altri ambienti o con altri linguaggi che non danno la possibilita' di leggere il file di struct indice.

Nei miei programmi che gestiscono file di struct (database) inserisco quasi sempre in ogni struct un campo intero positivo id_dato, che identifica in modo univoco ogni struct del file. Quando una struct viene cancellata, i miei programmi decrementano di 1 tutti i campi id_dato delle struct successive. Questo comportamento e' diverso da quello dei database tradizionali, in cui la cancellazione di un record non implica la modifica delle chiavi primarie dei record successivi. Ho adottato questa soluzione perche' cosi' il campo id_dato coincide sempre con la posizione della struct nel file e questo semplifica certe operazioni frequenti (ad es., lettura piu' modifica di una determinata struct). Con il mio sistema, pero', se le struct del file in cui si trova quella cancellata sono puntate dalle chiavi esterne delle struct di un altro file, e' necessario aggiornare le chiavi esterne dell'altro file; ma questo, nei miei programmi, capita raramente.

Ho l'usanza di chiudere un file aperto quando non serve piu', con fclose(f_dati), senza aspettare la chiusura automatica dei file alla fine dell'esecuzione del programma.

In tutti i miei gruppi di programmi che gestiscono dati da salvare su file in formato struct o numerico includo un programma per il salvataggio anche in formato CSV, che mi sembra il piu' semplice e universale, per assicurare la portabilita' dei dati anche in s.o. e linguaggi futuri. Infatti, bisogna lottare contro l'obsolescenza digitale. E un fenomeno ricorrente e' quello dei dati che hanno una vita piu' lunga di quella dei programmi che li hanno creati.

Sto attento a evitare che girino per il programma puntatori non inizializzati. Un puntatore deve avere solo valore NULL oppure deve puntare a un blocco di memoria allocato con calloc() o malloc().

calloc() riceve in input il numero di blocchi da allocare (che devono essere tutti della stessa dimensione) e la dimensione di un singolo blocco; assegna 0 a tutti i byte della memoria allocata.

malloc() riceve il numero totale dei byte da allocare; non assegna 0 ai byte della memoria allocata, che continua a ospitare i byte antecedenti all'allocazione; per questo l'esecuzione di malloc() e' piu' veloce e meno onerosa per il sistema operativo.

Successivamente alla chiamata di calloc()/malloc(), verifico se l'operazione e' riuscita per regolarmi di conseguenza, facendo apparire appositi messaggi e eventualmente uscendo dal programma (l'allocazione non e' riuscita se il puntatore ha valore NULL).

Dopo varie letture e meditazioni, mi sono convinto che e' meglio usare malloc(), se subito dopo tutti gli elementi devono essere inizializzati in un colpo solo con determinati valori; ma e' meglio usare calloc() nei casi in cui il programma inserisca valori solo in una parte degli elementi allocati (anche in momenti diversi e in posizioni non contigue), lasciando liberi gli altri; cosi' il valore 0 (assegnato all'inizio da calloc()) indica che c'e' un posto libero.

E' sconsigliabile modificare con l'aritmetica dei puntatori i p. cui e' stata associata memoria con calloc()/malloc(); per la scansione dei dati memorizzati e' meglio usare dei puntatori ausiliari, lasciando invariati quelli base.

Per evitare i puntatori pendenti, faccio seguire ad ogni istruzione free(p_dato) l'assegnamento p_dato = NULL; cerco anche di evitare che si crei il garbage, cioe' una zona di memoria occupata da dati non piu' accessibili perche' il puntatore associato ha cambiato valore.

Ritengo opportuno "blindare" le funzioni, cioe' verificare preliminarmente che gli argomenti passati consentano l'elaborazione; ad es., una funzione che elabora una stringa deve prima verificare che la stringa non sia vuota. Non bisogna dare per scontato che la funzione sia sempre chiamata a proposito e con gli argomenti corretti.

Per individuare gli errori uso diversi artifizi.

Durante i collaudi uso le asserzioni (bisogna includere il f. header assert.h).

Visualizzo i valori che le variabili assumono nel corso delle varie fasi dell'elaborazione, specialmente dopo certi assegnamenti, espressioni o durante l'esecuzione dei cicli.

Faccio terminare il programma anzitempo, mostrando i valori di tutte le variabili al momento della terminazione.

Collaudo separatamente le diverse funzioni.

Quando un programma viene compilato regolarmente, ma poi si blocca durante l'esecuzione, uso una funzione di questo tipo:

void fin_qui(int numero)
{
printf("\n%s%d\n", "Fin qui ci sono arrivato ", numero);
}

collocando la funzione in punti diversi del programma, individuo l'istruzione che provoca il blocco.

Creo un ciclo che genera migliaia di volte dati casuali di input e che si blocca se il risultato e' scorretto.

Ogni tanto inserisco apposta degli errori, per vedere quali messaggi appaiono in fase di compilazione o esecuzione.

Prendo nota dei miei errori (e dei loro effetti), per evitare di ripeterli o per poterli trovare piu' facilmente in futuro; questo e' un elenco di alcuni miei errori di programmazione:

omettere il punto e virgola dopo un'istruzione o metterlo quando non ci deve essere (es., if (...); oppure for (...);).

dimenticare che il primo elemento di un array ha indice 0 (non 1) e che l'ultimo ha indice n-1 (non n, dove n e' numero di elementi);

non considerare che in un array gli elementi con indice che va da n a m (m>=n) sono m-n+1 (non m-n);

leggere o scrivere dati in array o in blocchi allocati dinamicamente al di fuori dei limiti di memoria;

cercare di leggere o scrivere dati in un blocco di memoria allocato dinamicamente prima della chiamata di calloc()/malloc() o dopo la deallocazione con free();

allocare memoria, con calloc()/malloc(), associandola a un puntatore che punta gia' a una regione di memoria allocata in precedenza: l'operazione riesce, ma la prima regione di memoria non e' piu' accessibile (formazione di garbage);

cercare, con free(), di deallocare memoria gia' deallocata con una precedente chiamata di free();

dimenticare che in una stringa la sequenza di caratteri deve essere seguita dal terminatore di stringa ('\n' = byte 0);

usare stringhe con una lunghezza superiore a quella massima permessa;

entrare in cicli infiniti perche' la condizione di uscita non si verifica mai;

mettere all'interno di un ciclo un'istruzione che dovrebbe starne fuori o viceversa;

in un confronto, mettere = al posto di ==;

dimenticare che se si decrementa di 1 un intero senza segno che vale 0 non si ottiene -1, ma un numero diverso;

dimenticare che dopo l'esecuzione di un ciclo for (k = 1; k <= n; k++) {....} k non vale n, ma n+1;

cercare di accedere direttamente all'elemento ennesimo di un file senza essersi prima posizionato correttamente con fseek();

cercare di accedere a un file che non e' stato ancora aperto o che e' stato gia' chiuso.

cercare, con fopen(), di aprire un file gia' aperto con una precedente chiamata di fopen();

cercare, con fclose(), di chiudere un file gia' chiuso con una precedente chiamata di fclose();

chiamare una funzione senza assegnarne il valore di uscita a una variabile;

nelle funzioni che devono contenere diversi return (in base ai diversi esiti delle elaborazioni) dimenticare un return;

usare il copia-incolla per copiare parti di codice che fanno le stesse (o simili) operazioni con dati o operatori diversi, tralasciando di aggiornare i nomi di tutte le variabili usate o gli operatori;

non tenere conto dei casi particolari nelle operazioni di inserimento/cancellazione dati in liste concatenate;

nella scansione delle liste, non considerare bene a che cosa punta realmente il puntatore corrente (e l'eventuale precedente);

non tenere conto dell'approssimazione dei float, double, che puo' avere effetti indesiderati nei confronti;

correggere un'istruzione e ricompilare dimenticandomi di salvare il file sorgente modificato.

Errori e punti deboli possono essere individuati anche grazie al debugger GDB.

Ecco alcune istruzioni:

in linux compilare: gcc -ggdb -o pippo pippo.c

entrare in gdb: gdb

indicare il programma da esaminare: file pippo

indicare gli argomenti da riga di comando: set args (niente argomenti)

set a1 a2 (ci sono gli argomenti a1 a2)

inserire breakpoint: break main / break numero di riga

avviare il programma: run

passare a riga successiva: next

esaminare esecuzione funzione: step (dopo la riga con la chiamata di funzione)

visualizzare valore variabile: print nome_var

v. tutti i registri: info registers

v. un registro: info registers nome_registro

v. dati stack frame: info frame

v. dati argomenti passati a funzione: info args

v. dati variabili locali: info locals

esaminare esecuzione funzione: step

uscita da gdb: quit


Leggibilita'

Un programma deve essere facilmente comprensibile sia ad altri programmatori, sia allo stesso creatore, che a distanza di tempo puo' aver dimenticato che cosa ha fatto.

Evito il linguaggio "esoterico" (pure consentito dal C), che consente di creare codice piu' compatto, riducendo pero' la leggibilita' e rendendo piu' difficile evitare/individuare errori. Preferisco uno stile "ingenuo", che aumenta il numero di righe, ma rende il codice piu' leggibile e correggibile.

Faccio largo uso di commenti; e' meglio inserire un commento apparentemente superfluo che ometterlo, ovviamente senza esagerare.

La mia teoria e' che i commenti devono essere tali da far capire cosa fa il programma anche a chi ignora totalmente il linguaggio di programmazione.

Per l'indentazione ho adottato lo stile Allman (analogo a quello usato in Pascal), che preferisco allo stile K&R.

Uso le parentesi graffe anche se il blocco e' formato da una sola riga.

Separo con delle righe vuote i blocchi di codice.

Nelle espressioni metto parentesi in abbondanza, per evitare equivoci inerenti alla priorita' degli operatori.

Spesso inserisco commenti dopo la parentesi graffa di chiusura, per indicare che cosa finisce: definizione di funzione, ciclo, istruzione if-else.

Scrivo anche commenti che non sono associati a determinate istruzioni, ma che spiegano cosa e' stato fatto fino ad ora, qual'e' lo stato dei dati e cosa sara' fatto dopo.

Evito di nidificare troppo istruzioni di scelta condizionata e di ciclo; in rari casi, per evitare troppe nidificazioni o verifiche di condizioni, preferisco usare il deprecato goto. Per lo stesso motivo, uso con una certa frequenza break e continue (all'interno di cicli) e return multipli all'interno di funzioni. Mi pare che queste soluzioni - anche se aborrite dai puristi della programmazione strutturata - possano servire in certi casi a rendere il codice piu' leggibile, efficiente e modificabile.

I return multipli mi sembrano comodi quando bisogna fare controlli a cascata su certi tipi di dati: se un controllo ha un esito negativo e' inutile proseguire con i controlli successivi, e' meglio uscire subito. In questi casi, per evitare i return multipli bisognerebbe creare alberi delle decisioni (if-else) assai complicati e poco leggibili; inoltre, l'aggiunta o l'eliminazione di un controllo obbligherebbe ogni volta a ristrutturare l'intero albero.

Uso raramente condizioni formate da piu' di due confronti (legati da and &&, or ||).

Creo variabili, non strettamente necessarie, per rendere piu' comprensibili certe istruzioni; ad esempio, invece di vettore[v[k]] = numero; preferisco:
i = v[k];
vettore[i] = numero;

Precisazione terminologica sulle stringhe: quando nei miei programmi parlo di lunghezza di una stringa (nei commenti, nei nomi di variabili, ecc.), mi riferisco al numero di caratteri presenti in una data stringa; quando parlo di dimensione di una stringa, alludo alla lunghezza massima possibile di una stringa, incrementata di 1 per ospitare il terminatore di stringa.

Esempio:

char stringa[11];

la stringa contiene la parola "Milano".

La lunghezza e' 6, cioe' il numero di caratteri della parola "Milano".

La dimensione e' 11, questo significa che la stringa puo' contenere al massimo 10 caratteri + uno spazio per il terminatore di stringa.

Cerco di attenermi sempre alle stesse convenzioni, nel decidere il nome da dare a una variabile. Quando lo ritengo opportuno, attribuisco un nome che riflette il contenuto, lo scopo e/o il tipo di dato corrispondente.

Uso le minuscole per tutti i nomi di variabili, funzioni e tipi di dato; uso le maiuscole solo per i valori costanti definiti con #define. Quando un nome e' formato da piu' parole, uso il trattino underscore _ per evidenziare le diverse parole, ad esempio num_oggetti, DIM_STR.

Chiamo k, k1, k2, ecc. i contatori di ciclo; quando il ciclo serve per accedere ai caratteri di una stringa uso anche i, i1, i2, ecc. Chiamo continua la variabile che indica la condizione di permanenza in un ciclo do-while.

Chiamo conta le variabili che non sono contatori di ciclo, ma che contano quante volte si verifica un evento all'interno di un ciclo.

Chiamo trovato una variabile che indica se il dato cercato e' stato trovato (1) o no (0); n_trovati indica quante occorrenze sono state trovate del dato cercato.

Nelle liste concatenate chiamo p_corrente il puntatore al nodo il cui dato deve essere letto e p_precedente il puntatore al nodo precedente (quando serve). All'interno di una struct nodo chiamo p_prossimo il puntatore al nodo successivo.

Con ok rappresento il valore di uscita di certe funzioni (che indicano se un dato e' corretto o se un'operazione puo' essere fatta o no) oppure l'esito di un'allocazione di memoria o dell'apertura di un file.

Con cfr rappresento il risultato di un confronto; se uso funzioni mie (non quelle standard) per confrontare certi tipi di dato, le chiamo int cfr_tipo(tipo a, tipo b).

Faccio precedere i nomi di puntatori a file da f_ (ad es., f_rubrica).

Per gli altri puntatori uso p_ (ad es., p_numero); per i puntatori doppi uso pp, pp1, pp2 e simili.

Per gli array monodimensionali uso v_ (ad es., v_numeri[]).

Per le matrici uso m_ (ad es., m_numeri[][]).

Ma le matrici possono essere rappresentate anche con array monodimensionali, usando opportuni calcoli per trasformare i doppi indici (2 dimensioni) in indici singoli (1 dimensione) e viceversa:

se ho una matrice m[n_righe][n_colonne] con i = indice di riga, j = indice di colonna, il valore corrispondente a m[i][j] puo' essere messo in v[i * n_colonne + j].

Per i valori che devono rimanere costanti (come le dimensioni di array) uso #define DIM_V_NUMERI 100 (cosi' se voglio cambiare la dimensione di un array devo solo modificare la direttiva e ricompilare; altrimenti dovrei modificare tutte le istruzioni in cui compare il numero di elementi dell'array).

Quando passo a una funzione un puntatore doppio, creo un puntatore semplice all'interno della funzione e tramite quest'ultimo effettuo le varie operazioni; esempio:

void funz(tipo_dato **pp)
{
tipo_dato *p_locale;
p_locale = *pp;
.....................
istruzioni varie scritte usando p_locale
.....................
*pp = p_locale;
}

la funzione viene chiamata cosi':

funz(&nome_pp_doppio);

cosi' evito di dover usare all'interno della funzione la notazione per i doppi puntatori, cosa che puo' creare confusione e aumenta la probabilita' di sbagliare.

Alle funzioni attribuisco un nome che faccia capire qual'e' il loro scopo.

Evito le funzioni che gestiscono sia l'input dati che l'elaborazione; e' meglio separare le due fasi con funzioni diverse.

La mia filosofia in materia di lunghezza delle funzioni e' la seguente: non sono d'accordo con chi afferma che una funzione non deve assolutamente superare N righe (10? 20? 30?); la lunghezza ottimale puo' variare in base al problema e all'algoritmo usato; a maggior ragione, dissento dal criterio visivo, secondo cui una funzione deve essere integralmente leggibile in una singola schermata o foglio: la stessa funzione puo' essere visualizzata diversamente su schermi di varie dimensioni e risoluzioni, analogamente per le stampe.

Mi sembra comunque ragionevole attenermi, nella maggior parte dei casi, a poche decine di righe, contando solo le istruzioni, senza commenti e righe vuote; ammetto eccezioni quando devono essere compiute elaborazioni ripetitive (che non si possono fare con cicli), con bassa complessita' ciclomatica.

Ad esempio, ho scritto funzioni "lunghe" per inizializzare l'alfabeto cirillico UTF-8 e per effettuare il parsing di stringhe che devono essere interpretate come frazioni o polinomi.

Ritengo molto utile il criterio empirico del nome: se il nome di una funzione e' lungo, oscuro o complicato, puo' significare che la funzione fa troppe cose oppure che fa cose troppo particolari.

Se una funzione deve ricevere in input molti parametri, definisco una struct che li contiene tutti, e passo alla funzione il puntatore alla struct. Utilizzo questo sistema soprattutto quando una funzione deve leggere ed eventualmente modificare diverse strutture dati (array, memoria allocata dinamicamente, file). Di solito chiamo questa struct desc_dati (o simili) e la definisco come tipo di dato con typedef. Le variabili appartenenti al tipo, all'interno delle funzioni, le chiamo dd (= descrittore dati) oppure dd_lista, dd_tab, ecc.

Pero' questo metodo puo' avere lo stesso inconveniente che si manifesta usando variabili globali: modifiche collaterali e non volute di certe variabili. Va usato con la massima accortezza e sagacia. La struct deve essere correttamente inizializzata all'inizio del programma e le modifiche devono essere rigidamente controllate. Quando i dati della struct non devono essere modificati conviene usare il qualificatore const, cosi' eventuali indebite modifiche vengono rilevate in sede di compilazione.

Esempio:

typedef struct
{
..............
} desc_dati;

int funz(const desc_dati *dd);

Fino ad ora non ho mai usato la ricorsione; i libri spiegano che gli algoritmi ricorsivi possono essere piu' adatti per la risoluzione di certi problemi; ma a me la ricorsione non piace e preferisco non usarla; in vari casi (ad es., per il quick sort) ho risolto in modo non ricorsivo utilizzando le pile.


Home


www.corradodamiano.it a cura di Corrado Damiano

posta@corradodamiano.it