Ho lavorato di recente su due progetti in C e volevo strutturarli in modo da renderli facili da mantenere e comprendere. Volevo anche assicurarmi che fossero semplici da compilare e testare. In questo post condividerò la mia esperienza e le best practice che ho trovato per strutturare i progetti C.
Il codice sorgente dei progetti su cui ho lavorato è disponibile nei seguenti repository:
- lucavallin/barco: Container Linux da zero in C.
- lucavallin/gnaro: Un proto-database ispirato a SQLite a scopo didattico.
Data la mia esperienza limitata con il C — non ne scrivevo da 10 anni — ho dovuto fare molta ricerca per capire qual è il consenso attuale riguardo al layout delle directory. Ho trovato molte informazioni utili su GitHub, Reddit e Stack Overflow. Ho anche analizzato il codice sorgente di alcuni popolari progetti open-source in C per vedere come erano strutturati. Ho notato che la maggior parte dei progetti che ho esaminato seguiva un layout simile, e ho deciso di usarlo come punto di partenza.
Cosa Dicono le Persone su Internet
Se cerchi su Google "c project structure best practices" otterrai circa 583.000.000 di risultati — non c'è bisogno di fare ricerche autonome — le ho lette tutte, due volte. Sebbene le opinioni varino, ci sono alcuni temi comuni che emergono ripetutamente. Due approcci sono particolarmente diffusi:
- L'approccio "modulare": È l'approccio più comune per i progetti grandi. L'idea è di suddividere il progetto in più directory, ciascuna contenente un modulo diverso. Ogni modulo ha i propri file header, file sorgente e test. Questo approccio rende facile trovare il codice che si cerca e testare i singoli moduli in isolamento. È così che è strutturato il kernel Linux, ad esempio.
- L'approccio "flat": Questo approccio è più comune per i progetti piccoli e si concentra sul mantenere il progetto il più semplice possibile pur rimanendo ben organizzato.
Poiché i progetti su cui ho lavorato erano relativamente piccoli, ho deciso di usare l'approccio "flat", che descriverò di seguito.
Il Mio Approccio alla Struttura dei Progetti C
Dopo aver esaminato tutti i 583.000.000 di risultati due volte, mi sono orientato sul seguente layout di directory per i miei progetti:
├── .devcontainer configuration for GitHub Codespaces
├── .github configuration GitHub Actions and other GitHub features
├── .vscode configuration for Visual Studio Code
├── bin the executable (created by make)
├── build intermediate build files e.g. *.o (created by make)
├── docs documentation
├── include header files
├── lib third-party libraries
├── scripts scripts for setup and other tasks
├── src C source files
│ ├── main.c (main) Entry point for the CLI
│ └── *.c
├── tests contains tests
├── .clang-format configuration for the formatter
├── .clang-tidy configuration for the linter
├── .gitignore
├── compile_commands.json compilation database for clang tools
├── LICENSE
├── Makefile
└── README.mdApprofondiamo questo layout. Possiamo ignorare .devcontainer, .github, .vscode e scripts per ora, in quanto sono specifici del mio ambiente di sviluppo e non rilevanti per la struttura del progetto. I file .clang-format e .clang-tidy sono file di configurazione per il Clang formatter e linter, rispettivamente. Il file compile_commands.json è un database di compilazione per gli strumenti Clang. Questi file non sono strettamente necessari, ma possono essere utili se vuoi usare gli strumenti Clang nel tuo progetto. LICENSE e README.md sono auto-esplicativi, e Makefile non ha bisogno di presentazioni, sebbene tu possa leggere di più su come ho scritto il mio in Crafting a Clean, Maintainable, and Understandable Makefile for a C Project.
Prima di entrare nei dettagli più importanti, liquidiamo alcune altre directory:
- La directory
bincontiene l'eseguibile creato quando si eseguemake. - La directory
buildcontiene i file intermedi di compilazione, come i file.o. - La directory
docscontiene la documentazione del progetto.
Dedichiamo un po' di tempo alle directory src, include, lib e tests.
La Directory src
La directory src contiene i file sorgente C del progetto, e ci trascorrerai la maggior parte del tempo. Ho deciso di mantenerla semplice usando un layout flat. Oltre al file main.c che funge da entry point del programma, ho suddiviso il resto del codice in base alle "responsabilità" e alle strutture dati. Ad esempio, nel progetto gnaro:
btree.c: contiene l'implementazione di una struttura dati B-tree.cursor.c: contiene l'implementazione di un cursore per la lettura e la scrittura nel database.database.c: contiene l'implementazione del database.pager.c: contiene l'implementazione del pager.row.c: contiene l'implementazione di una riga nel database.input.c,meta.cestatement.c: contengono la logica necessaria per il parsing e la preparazione dell'input dell'utente.
Ho trovato questo semplice layout facile da capire e navigare. Rende anche facile trovare il codice che si cerca, a condizione di fare uno sforzo attivo per mantenere i file piccoli e focalizzati. Lo svantaggio di questo approccio è che sarà necessario tenere aggiornato il Makefile con i nuovi file che si aggiungono al progetto in modo che vengano compilati e linkati correttamente. Data la piccola dimensione dei progetti su cui ho lavorato, non ho trovato questo un problema, ma potrebbe esserlo per altri.
La Directory include
La directory include contiene i file header del progetto. La maggior parte se non tutti i file .c nella directory src avranno un file .h corrispondente nella directory include. I file header dovrebbero contenere l'API pubblica del modulo, e i file sorgente dovrebbero contenere l'implementazione. Questo rende facile capire cosa fa il modulo senza dover guardare l'implementazione. Rende anche facile testare il modulo in isolamento, poiché si può semplicemente includere il file header nel file di test.
Usando ancora una volta il progetto gnaro come esempio:
btree.h: incluso insrc/btree.c, definisce l'API pubblica per la struttura dati B-tree.cursor.h: incluso insrc/cursor.c, definisce l'API pubblica per il cursore usato per leggere e scrivere nel database.database.h: incluso insrc/database.c, definisce l'API pubblica per l'implementazione del database.pager.h: incluso insrc/pager.c, definisce l'API pubblica per la logica di paging.row.h: incluso insrc/row.c, definisce l'API pubblica per la struttura delle righe del database.input.h,meta.hestatement.h: inclusi insrc/input.c,src/meta.cesrc/statement.c, definiscono l'API pubblica per la gestione dell'input dell'utente.
Come la directory src, lo svantaggio di questo layout è che i file header devono essere referenziati nel Makefile in modo che vengano inclusi nel processo di compilazione.
La Directory lib
La directory lib contiene le librerie di terze parti da cui dipende il progetto. Ad esempio, lucavallin/gnaro usa le librerie argtable e log.c per il parsing degli argomenti CLI e il logging, rispettivamente.
Non c'è molto da dire su questa directory. È semplicemente un posto dove mettere le dipendenze. Ancora, non dimenticare di includerle anche nel Makefile.
La Directory tests
La directory tests contiene i test del progetto. Ho usato la libreria CUnit per i test, che ho trovato adatta alle mie esigenze. La directory tests contiene un file di test per ogni modulo nella directory src. Ad esempio, nel progetto gnaro, la directory tests contiene il file gnaro_test.c pensato per testare la logica definita in src/gnaro.c.
Al momento, in pratica, il file contiene solo il codice necessario per configurare i test come raccomandato dalla documentazione di CUnit. Sebbene i test vengano eseguiti, in realtà non ho mai seguito a scrivere controlli utili per gnaro e barco, in quanto sono semplici progetti hobbistici.
Conclusione
Grazie per essere arrivato fin qui! Spero che tu abbia trovato questo articolo utile. So che il layout di progetto che ho descritto non è l'unico modo per organizzare un progetto C, ma è quello che ho trovato più efficace per me. Le directory bin, build, docs, scripts e quelle che iniziano con "." sono utili per lo sviluppo, ma è nelle directory src, include, lib e tests che avviene il lavoro vero.
src: contiene i file sorgente C del progetto.include: contiene i file header del progetto, inclusi dai file.cinsrc.lib: contiene le librerie di terze parti da cui dipende il progetto.tests: contiene i test del progetto.
Il Makefile è il collante che tiene tutto insieme e deve essere aggiornato per includere nuovi file e dipendenze. Sebbene siano disponibili sistemi di build più moderni come CMake e Meson, ho trovato che un semplice Makefile fosse sufficiente per le mie esigenze.
Spero che tu possa prendere alcune delle idee che ho presentato qui e applicarle ai tuoi progetti. Se hai domande o commenti, non esitare a contattarmi!
lucavallin

