#include is evil : Comment démêler un graphe d’inclusions en C++ ?
5 février 2008 – 14:46Récemment, alors que je développais un projet C++ / OpenGL d’une ampleur relativement importante, j’ai du faire face à certains problèmes liés à l’utilisation du préprocesseur des langages C et C++. Qui ne connaît pas la directive #include ? Cette instruction bien que très pratique peut très vite devenir un véritable calvaire pour le développeur lorsque le projet commence à prendre certaines proportions.
Ceux qui ont déjà eu des problèmes liés à #include savent de quoi je parle… Qu’y a-t-il de plus énervant que de voir tout un projet se recompiler alors que l’on vient juste de toucher à un seul fichier d’en-tête ? J’entends déjà certains me répondre sans hésiter : «Quand ça ne compile plus à cause d’inclusions et de dépendances circulaires !», et ils ont probablement raison… Le but de ce billet est d’illustrer les problèmes les plus courants que l’on peut rencontrer en C++ et de proposer des solutions plus ou moins automatiques pour s’en sortir. Je vais commencer par illustrer les problèmes de base et leurs solutions respectives, pour finalement converger vers une solution globale, comportant certaines règles à suivre qui permettent de résoudre les problèmes de ce style. Notez que l’approche de ce billet est progressive et que la solution optimale est donnée à la fin.
En fait, après avoir été confronté à des problèmes de ce type et après avoir galéré à les résoudre, je me suis rendu compte que je ne maîtrisais pas comme il le fallait les actions du préprocesseur. Ce qui est plutôt gênant, quand arrivé à plusieurs milliers de lignes de code le projet refuse de compiler. En effet, dès que les projets atteignent une taille conséquente, la prise de tête peut rapidement commencer. Voici les principaux problèmes liés à l’inclusion :
Inclusion multiple
Le premier type de problème se produit quand un fichier se retrouve inclus plusieurs fois. Il est tellement classique que tout le monde le connaît par coeur, et place donc des directives pour se protéger des inclusions multiples (les traditionnels #ifndef, #define et #endif). Je ne m’éternise pas là dessus, j’imagine que je ne vous apprends rien.
Inclusion circulaire (agrégation, composition, utilisation…)
Un problème plus vicieux peut néanmoins apparaître lorsqu’un fichier se retrouve inclus avant un autre, ce dernier ayant besoin du premier. Ce genre de problème arrive lorsqu’on déclare par exemple des classes possédant chacune une référence ou un pointeur vers l’autre. Le compilateur étant par définition bête, il inclura comme il se doit les deux fichiers, mais inéluctablement, la définition d’une classe se retrouvera avant celle de l’autre. Et ça, le compilateur n’aime pas, puisqu’il tient absolument à ce que tous les symboles aient été déclarés à priori. Pour contourner ce genre de problèmes, on place simplement une directive non pas du préprocesseur, mais du langage C++ :
class MaClass;
Cette directive précise juste qu’il sera question d’une classe nommée MaClass dans la suite, et que le compilateur finira par trouver sa définition avant la fin de l’analyse syntaxique. On nomme cette directive la « forward declaration ». Elle doit bien évidemment être placée avant chaque classe et doit être répétée autant de fois qu’il le faut pour être certain que chaque symbole utilisé dans la définition de la classe ait bien été déclaré au préalable.
Il est important de noter que cette solution n’est valable que lorsque l’on utilisera des pointeurs ou des références sur des objets, qu’ils soient des paramètres de méthodes ou des types de retours. Dans ces cas là, la directive #include n’est même plus nécessaire. Ce point sera détaillé plus loin.
Dépendance et inclusion circulaire
Il s’agit d’un cas particulier de l’inclusion circulaire que nous venons de voir, mais il faut comprendre qu’il y a une nuance supplémentaire par rapport à la simple inclusion. Il y a en effet la notion de dépendance ; j’entends par là qu’une classe nécessite absolument la déclaration d’une autre, et que cette déclaration doit avoir eu lieu avant. Dans le problème précédent, peu importe l’ordre dans lequel le préprocesseur inclut les fichiers, l’un sera placé avant l’autre et cela ne gênera pas le compilateur. Cependant, il existe certains cas de force majeur, où le compilateur nécessite absolument la définition préalable d’une classe ; c’est notamment le cas de l’héritage, mais également des constantes (#define) et des structures. Imaginez qu’une classe B, définie dans un fichier B.h hérite d’une classe A définie dans A.h. Naturellement, B.h inclut A.h, et cette inclusion est absolument nécessaire pour que le code soit compilable. Si vous aviez fait l’inverse, c’est à dire inclure B à partir de A, cela n’aurais pas marché, même avec la directive « class ».
Supposez un instant que dans B.h vous ayez inclus le fichier A.h dans le bon sens, mais que A.h inclut un certain nombre de classes, chacune incluant une autre pour finalement former une chaîne d’inclusion qui finirait par inclure B.h… Ce serait très gênant, car la compilation de B aurait beau aboutir, celle de A bloquerait. En effet, Lorsque le préprocesseur inspectera A.h en dépliant le code inclus par les #include, il finira par inclure B.h. Or B.h définit la classe B qui hérite de A… alors que la classe A n’a finalement jamais été trouvée jusqu’ici ! Et là, c’est le drame…
Je n’en vois que deux solutions pour se sortir de ce pétrin. La première, que je qualifierais de « pas propre » consiste à déporter le 1er #include gênant de A.h après la définition de la classe A ; autrement dit le mettre en fin de fichier. Cette solution est sale car non seulement elle oblige à séparer certaines directives ou inclusions qui doivent absolument être au début du fichier des autres qui posent problème. C’est une solution qui peut marcher… Mais je pense que ce n’est vraiment pas la bonne méthode à prendre, et qu’elle risque même de reporter le problème à plus tard, quand le projet aura encore grandi et que la complexité du graphe d’inclusions sera devenu inextricable. Je préfère nettement la 2ème que voici…
LA solution
Quelle est donc la solution miracle ? En fait, il s’agit simplement de limiter au maximum les inclusions entre les fichiers d’en-tête, en revenant aux principes de base de la compilation C/C++. Considérez un cas simple : A.cpp qui inclut A.h dans lequel se trouve la définition d’une classe. Imaginez qu’A.h nécessite d’autres classes, qui sont incluses par A.h. Pour fixer les idées, nommons C.h un des fichiers inclus par A.h Finalement, A.cpp et A.h vont être compilés ensemble pour produire le même fichier objet ; il semble donc équivalent d’inclure C.h à partir de A.h ou de A.cpp, puisque le résultat sera le même. Et pourtant, c’est là que se situe toute la différence… En effet, si l’on déplace le #include « C.h » de A.h dans A.cpp, on a réussi à éliminer une inclusion de A.h. Du coup, il faudra probablement mettre une directive « class C; » en tête de A.h, mais c’est tout de même nettement mieux que d’introduire une inclusion qui risque de provoquer des problèmes du style de ceux que l’on vient de voir ! Ainsi, la règle à suivre est la suivante :
Réduire au minimum vital les inclusions dans les fichiers d’en-tête (.h) en les déportant dans les fichiers d’implémentation (.cpp).
C’est une règle toute bête,mais qui simplifie énormément la vie lorsque l’on développe un projet C ou C++. Le but est ainsi de supprimer le maximum (voire toutes) les directives #include des headers pour éviter les inclusions circulaires. Pour que cela compile, il sera nécessaire de rajouter les directives du type « class X; » en tête des fichiers d’en-têtes, mais qu’importe ! Ces directives sont « gratuites », dans le sens où elles ne risquent pas d’introduire des problèmes.
Concernant l’héritage, nous avons vu qu’il était absolument nécessaire qu’une classe mère soit définie avant une de ses classes filles. C’est pourquoi dans ce cas, on pourra se permettre de conserver la directive #include dans le fichier d’en-tête, afin d’éviter d’avoir à inclure la classe mère dans chaque fichier .cpp utilisant cet en-tête. Mais à ma connaissance, c’est le seul cas où la règle précédente ne s’applique pas.
Et finalement, on s’aperçoit que l’on peut encore mieux faire, en élaguant au maximum le graphe d’inclusions. En effet, il est possible de supprimer les inclusions de classes (qui se trouvent désormais dans les .cpp) lorsque la classe n’est utilisée qu’en tant que pointeur, référence, et qu’aucune méthode n’est utilisée. Autrement dit, si une classe A possède une méthode qui prend un paramètre un pointeur vers un objet de type B, et que cette méthode n’utilise ni les méthodes ni les attributs de B, alors il n’y a pas besoin d’inclure B.h dans A.cpp. Un simple « class B; » suffira, et il sera du coup placé dans A.h vu que le prototype de la méthode y sera déclaré. Et en fait, c’est tout à fait logique : si l’on n’utilise pas les membres de B, et qu’il ne s’agit que d’un pointeur ou d’une référence, le compilateur n’a aucunement besoin de connaître la définition précise de B, puisque pointeurs et références occupent tous la même place en mémoire.
Pour résumer la méthode, voici la solution générale pour résoudre la grande majorité des problèmes d’inclusions :
- Avant tout, chaque fichier d’en-tête doit comporter les classique directives #ifndef, #define et #endif pour s’assurer qu’il ne sera pas inclus plusieurs fois.
- Aucune directive #include ne doit apparaître dans un .h, sauf si une de ces conditions est satisfaite :
- ce fichier .h déclare une classe qui hérite d’une autre classe
- il nécessite la définition de constantes (au sens #define) ou de structures définies dans un autre .h.
- la classe qu’il déclare comporte comme attribut un objet d’autre classe (et non pas un pointeur ou une référence vers celui-ci)
- Hormis ces 3 cas, il faut déporter les inclusions dans les fichiers .cpp.
- Pour permettre la compilation du projet, il faut placer en tête des fichiers .h (avant la déclaration de la classe) autant de directives « class X; » que nécessaire, où X est le nom d’une classe utilisée dans les méthodes du .h.
- Si certaines méthodes d’une classe A utilisent uniquement références ou des pointeurs sur des objets de type B, sans accéder aux membres (attributs ou méthodes) de B, il n’est pas nécessaire de placer une directive #include dans A.cpp. Un simple « class B; » suffit dans A.h.
En fait, on peut se souvenir simplement de ces règles en réfléchissant un minimum. Mettez-vous à la place d’un compilateur : quelles informations vous faut-il pour pouvoir compiler une classe ? Comme un des rôles capitaux d’un compilateur est d’allouer la mémoire, il faut nécessairement toutes les informations concernant la taille des objets. Si un objet A agrège (contient) un objet de type B, on comprend facilement que la taille de l’objet A sera calculée en fonction de la taille de B, donc l’inclusion de la classe de B est inévitable. Idem pour les héritage. Mais quand il s’agit de simples pointeurs et références, pas besoin de la définition complète, puisque tous les pointeurs et références occupent la même place en mémoire (celle du bus d’adresse de la machine).
Au final, l’application de cette méthode permet de démêler le graphe d’inclusions de son projet de façon relativement simple et automatique. Il s’agit de respecter ces règles de développement dans le but de limiter au maximum les dépendances entre fichiers. Les adeptes des gros projets savent combien il est pénible de voir tous les modules d’un projet se recompiler lorsqu’ils viennent de changer une constante dans un fichier d’en-tête… Ce genre de problème est très souvent du à une mauvaise organisation des inclusions entre fichiers sources, ce qui induit une chaîne de dépendance énorme, provoquant la recompilation quasi-totale des sources. Dommage, quand on utilise les outils du style « Makefile » dont le but est justement de ne recompiler que ce qui est nécessaire.
J’espère que cette technique permettra d’en aider plus d’un. En y réfléchissant, elle paraît évidente, mais l’expérience montre le contraire… Une chose est sûre : si je l’avais apprise dès le début, j’aurais économisé un certain temps de débogage. Mais d’un autre côté, le fait de s’être arraché les cheveux dessus me garantit que je ne suis pas prêt de l’oublier…
Une réponse à “#include is evil : Comment démêler un graphe d’inclusions en C++ ?”
Excellent article. Merci pour ce rappel sur les problèmes d’inclusions.
Par Damien le 4 septembre 2013