Abstract
Durante lo sviluppo del progetto “Enne 2D Engine” mi sono posto più volte la domanda: ma perchè continuo a scrivere le header guards qunado posso usare la direttiva #pragma once?
Ho quindi deciso di approfondire la questione fornire la mia analisi.
Perché serve proteggere i file di dichiarazione?
Principio del ODR
Il One Definition Rule è una regola fondamentale da rispettare nel lunguaggio di programmazione C++.
La regola sancisce che una funzione o una variabile debba avere una ed una sola definizione all’interno del singolo programma.
Questo costrutto preveviene i problemi legati alle definizioni multiple della stessa funzione o variabile, che può causare errori e comportamenti indesiderati nel programma.
Violare il principio del ODR può causare problemi come errori di “multiple definitions” in fase di compilazione o addirittura errori a livello del linker, il quale potrebbe non essere in grado di risolvere la definizione di funzione o variabile che deve essere utilizzata.
Nell’esempio sottostante, con i 3 files definiti il processo di compilazione fallisce, a causa di una definizione multipla della struct foo
;
File alpha.hpp
|
|
File bravo.hpp
|
|
File charlie.hpp
|
|
Il preprocessore terminate le opportune sostituzioni, restituisce il seguente risultato.
|
|
Ma quindi come far sì che il ODR venga rispettato?
La soluzione che, principalmente, salta all’occhio, seppur sia un po’ spartana, è gestire manualmente la gerarchia delle direttive #include. Nell esempio mostrato in precedenza si dovrebbe saltare l’inclusione di alpha.hpp in charlie.hpp. Tuttavia si tratta di una tecnica molto ingenua che non risulta scalabile su soluzioni progettuali medio-grandi.
Esistono due alternative che risolvono il nostro porblema.
- Header guards.
- Direttiva #pragma once.
Header guards
La soluzione facente parte dello standard in C++ è l’uso degli header guards. Queste “guardie” o meglio protezioni per le intestazioni (#include <header>), impediscono che un header file venga incluso più di una volta in una singola unità di compilazione. Per ottenere questo risultato utilizzano le macro del preprocessore per verificare se l’intestazione è già stata inclusa in precedenza. Nel caso in cui fosse già stata inclusa, queste clausole impediscono una successiva reinclusione.
Il #define crea una macro, ovvero l’associazione di un identificatore o un identificatore con parametri con una stringa di token. Dopo che la macro è stata definita, il compilatore può sostituire la stringa di token per ogni occorrenza dell’identificatore presente nel file di origine.
Riprendendo l’esempio precedente, con poche modifiche otteniamo:
File alpha.hpp
|
|
File bravo.hpp
|
|
File charlie.hpp
|
|
Il preprocessore terminate le opportune sostituzioni, restituisce il seguente risultato.
|
|
Quando si lavora su progetti molto grandi, vi è da tenere ben presente che devono essere definite delle linee guida sulla definizione del nome della macro, altrimenti potrebbero insorgere scontri sui nomi utilizzati. Per questo è sempre utile utilizzare tools come clang-tidy e similari, i quali aiutano anche a far fronte a dimenticanze tipo non chiudere la macro con un #endif. Un punto importante da tenere presente, soprattutto quando si lavora con basi di codice di grandi dimensioni e si usano le protezioni per le intestazioni, è che deve esserci una forte linea guida sul nome della macro utilizzata, poiché l’uso del solo nome del file, ad esempio, può facilmente portare a scontri di nomi. Altri problemi possono sorgere quando si copia e si incolla un file di intestazione e non si riesce a modificare la macro utilizzata, oppure si manca la direttiva #endif.
Io ad esempio per definire il nome di una macro seguo questo schema <PROJECT_ROOT>_<RELATIVE_PATH_TO_HPP_FILE>_<FILE_NAME>_HPP_
.
Schema che rende impossibile avere due file con lo stesso identificatore.
Nel dettaglio supponiamo la seguente struttura e la root directory nominata CPP_PROJECT. E ipotizziamo di avere due file con lo stesso nome in due directory differenti.
|
|
Il nome della macro sul file alpha.hpp
nella directory detail sarà:
|
|
Il nome della macro sul file alpha.hpp
nella directory common sarà:
|
|
Pragma once
L’alternativa alle header guards, la quale è molto diffusa seppur non parte dello standard di C++, è l’uso della direttiva #pragma once.
File alpha.hpp
|
|
File bravo.hpp
|
|
File charlie.hpp
|
|
Il preprocessore terminate le opportune sostituzioni, restituisce il seguente risultato.
|
|
Dunque, si scrive meno codice e si è meno soggetti ad errori riguardanti il clashes sui nomi, ma questa alternativa presenta solo vantaggi?
Non proprio, questa direttiva non è parte dello standard, ergo i compilatori non sono obbligati dallo ISO C++ a fornire il supporto a #pragma once.
Ma perchè non fa parte dello standard?
La risposta è nella complessità che un compilatore affronta per rilevare correttamente e coerentemente l’uguaglianza dei file.
Uno dei porblemi più noti è relativo ai symbolic links
, riscontrabile sui compilatori GCC e MSVC, porta all’errore di rilevazione dell’uguaglianza dei file
includendolo pù di una volta, causando una rottura dello ODR. Si veda https://en.m.wikipedia.org/wiki/Pragma_once#Caveats.
Non vi è, poi, garanzia che il supporto di #pragma once sia lo stesso tra i diversi compilatori, il che può essere un problema per alcuni sviluppatori.
Header guards o pragma once?
Dipende dal caso.
La questione si riduce alla domanda, conviene brattare una compilazione sicura per guadagnare un briciolo di semplicità nella stesura del codice?
Sicuramente no, le header guards sono affidabili e sono riconosciute nello standard del C++, c’è più codice da scrivere, certo, ma almeno non dobbiamo affrontare l’inaffidabilità di #pragma once.
Conclusione
È preferibile l’occasionale errore di compilazione dovuto al clash dei nomi delle macro, piuttosto di aver a che fare con una direttiva di cui non si ha il controllo.