Nous commençons un nouveau projet à partir de zéro. Environ huit développeurs, une dizaine de sous-systèmes, chacun avec quatre ou cinq fichiers source.
Que pouvons-nous faire pour empêcher "l'enfer d'en-tête", AKA "en-têtes de spaghetti"?
Je ne demande pas une "meilleure" façon, juste un pointeur sur ce qu'il faut surveiller et ce qui pourrait causer du chagrin, afin que nous puissions essayer de l'éviter.
Ce sera un projet C++, mais C info aiderait les futurs lecteurs.
Méthode simple: un en-tête par fichier source. Si vous avez un sous-système complet où les utilisateurs ne sont pas censés connaître les fichiers source, ayez un en-tête pour le sous-système, y compris tous les fichiers d'en-tête requis.
Tout fichier d'en-tête doit être compilable par lui-même (ou disons qu'un fichier source comprenant un seul en-tête doit être compilé). C'est une douleur si je trouve quel fichier d'en-tête contient ce que je veux, puis je dois traquer les autres fichiers d'en-tête. Un moyen simple de faire respecter cela est de faire en sorte que chaque fichier source inclue d'abord son fichier d'en-tête (merci doug65536, je pense que je le fais la plupart du temps sans même m'en rendre compte).
Assurez-vous d'utiliser les outils disponibles pour réduire les temps de compilation - chaque en-tête ne doit être inclus qu'une seule fois, utilisez des en-têtes précompilés pour réduire les temps de compilation, utilisez des modules précompilés si possible pour garder les temps de compilation plus bas.
De loin, l'exigence la plus importante est de réduire les dépendances entre vos fichiers source. En C++, il est courant d'utiliser un fichier source et un en-tête par classe. Par conséquent, si vous avez un bon design de classe, vous ne serez même pas proche de l'enfer d'en-tête.
Vous pouvez également voir cela dans l'autre sens: si vous avez déjà un enfer d'en-tête dans votre projet, vous pouvez être sûr que la conception du logiciel doit être améliorée.
Pour répondre à vos questions spécifiques:
En plus des autres recommandations, dans le sens de la réduction des dépendances (principalement applicable au C++):
Un en-tête par fichier source, qui définit ce que son fichier source implémente/exporte.
Autant de fichiers d'en-tête que nécessaire, inclus dans chaque fichier source (en commençant par son propre en-tête).
Évitez d'inclure (minimiser l'inclusion) des fichiers d'en-tête dans d'autres fichiers d'en-tête (pour éviter les dépendances circulaires). Pour plus de détails, voir cette réponse à "deux classes peuvent-elles se voir en utilisant C++?"
Il y a tout un livre sur ce sujet, Conception de logiciels C++ à grande échelle par Lakos. Il décrit la présence de "couches" de logiciel: les couches de haut niveau utilisent des couches de niveau inférieur et non l'inverse, ce qui évite à nouveau les dépendances circulaires.
Je dirais que votre question est fondamentalement sans réponse, car il existe deux types d'enfer d'en-tête:
le fait est que si vous essayez d'éviter les premiers, vous vous retrouvez, dans une certaine mesure, avec les seconds, et vice-versa.
Il y a aussi un troisième type d'enfer, qui est les dépendances circulaires. Ceux-ci peuvent apparaître si vous ne faites pas attention ... les éviter n'est pas super compliqué, mais vous devez prendre le temps de réfléchir à la façon de le faire. Voir John Lakos talk on Levelization in CppCon 2016 (ou juste slides ).
Découplage
Il s'agit finalement de me découpler en fin de compte au niveau de conception le plus fondamental dépourvu de la nuance des caractéristiques de nos compilateurs et linkers. Je veux dire que vous pouvez faire des choses comme faire en sorte que chaque en-tête ne définisse qu'une seule classe, utiliser des boutons, des déclarations directes vers des types qui n'ont besoin que d'être déclarés, non définis, peut-être même utiliser des en-têtes qui contiennent juste des déclarations directes (ex: <iosfwd>
), un en-tête par fichier source, organise le système de manière cohérente en fonction du type de chose déclarée/définie, etc.
Techniques pour réduire les "dépendances de compilation"
Et certaines des techniques peuvent vous aider un peu, mais vous pouvez vous retrouver à épuiser ces pratiques tout en trouvant que votre fichier source moyen dans votre système a besoin d'un préambule de deux pages de #include
directives pour faire quelque chose de légèrement significatif avec des temps de construction montés en flèche si vous vous concentrez trop sur la réduction des dépendances au moment de la compilation au niveau de l'en-tête sans réduire les dépendances logiques dans vos conceptions d'interface, et bien que cela ne puisse pas être considéré comme des "en-têtes spaghetti" à proprement parler , Je dirais quand même que cela se traduit par des problèmes préjudiciables similaires à la productivité dans la pratique. À la fin de la journée, si vos unités de compilation nécessitent encore une cargaison d'informations pour être visibles pour faire quoi que ce soit, cela se traduira par une augmentation des temps de construction et multipliera les raisons pour lesquelles vous devez potentiellement revenir en arrière et changer les choses tout en rendant les développeurs l'impression qu'ils lancent le système juste en essayant de terminer leur codage quotidien. C'est comme si ces types de techniques, sans un découplage approprié, pouvaient vous donner une nouille à spaghetti à la fois, mais vous allez quand même finir par faire des spaghettis dans vos unités de compilation.
Vous pouvez, par exemple, faire en sorte que chaque sous-système fournisse un fichier d'en-tête et une interface très abstraits. Mais si les sous-systèmes ne sont pas découplés les uns des autres, vous obtenez à nouveau quelque chose ressemblant à des spaghettis avec des interfaces de sous-système en fonction d'autres interfaces de sous-système avec un graphique de dépendance qui ressemble à un gâchis pour fonctionner.
Transférer les déclarations aux types externes
De toutes les techniques que j'ai épuisées pour essayer d'obtenir une ancienne base de code qui a pris deux heures à construire tandis que les développeurs attendaient parfois 2 jours leur tour à CI sur nos serveurs de construction (vous pouvez presque imaginer ces machines de construction comme des bêtes de somme épuisées essayant frénétiquement pour suivre et échouer pendant que les développeurs poussent leurs changements), le plus discutable pour moi était de déclarer des types définis dans d'autres en-têtes. Et j'ai réussi à réduire cette base de code à 40 minutes environ après des années à le faire en petites étapes incrémentielles tout en essayant de réduire les "spaghettis d'en-tête", la pratique la plus discutable avec le recul (comme pour me faire perdre de vue la nature fondamentale de conception tandis que tunnel envisagé sur les interdépendances des en-têtes) déclarait des types définis dans d'autres en-têtes.
Si vous imaginez un Foo.hpp
en-tête qui a quelque chose comme:
#include "Bar.hpp"
Et il utilise uniquement Bar
dans l'en-tête d'une manière qui nécessite sa déclaration, pas sa définition. alors il peut sembler évident de déclarer class Bar;
pour éviter de rendre visible la définition de Bar
dans l'en-tête. Sauf que dans la pratique, vous trouverez souvent la plupart des unités de compilation qui utilisent Foo.hpp
finit quand même par avoir besoin de définir Bar
avec le fardeau supplémentaire d'avoir à inclure Bar.hpp
eux-mêmes au-dessus de Foo.hpp
, ou vous rencontrez un autre scénario où cela aide vraiment et où 99% de vos unités de compilation peuvent fonctionner sans inclure Bar.hpp
, sauf que cela soulève la question de conception la plus fondamentale (ou du moins je pense que cela devrait l'être de nos jours) pourquoi ils ont besoin de voir même la déclaration de Bar
et pourquoi Foo
doit même être pris la peine de le savoir s'il n'est pas pertinent pour la plupart des cas d'utilisation (pourquoi charger une conception avec des dépendances à une autre à peine utilisée?).
Parce que conceptuellement, nous n'avons pas vraiment découplé Foo
de Bar
. Nous venons de faire en sorte que l'en-tête de Foo
n'ait pas besoin d'autant d'informations sur l'en-tête de Bar
, et ce n'est pas aussi substantiel qu'un design qui rend véritablement ces deux complètement indépendants les uns des autres.
Script intégré
C'est vraiment pour des bases de code à plus grande échelle, mais une autre technique que je trouve extrêmement utile consiste à utiliser un langage de script intégré pour au moins les parties les plus avancées de votre système. J'ai découvert que j'étais en mesure d'intégrer Lua en une journée et qu'il pouvait uniformément appeler toutes les commandes de notre système (les commandes étaient abstraites, heureusement). Malheureusement, je suis tombé sur un barrage routier où les développeurs se méfiaient de l'introduction d'une autre langue et, peut-être le plus bizarrement, avec la performance comme leur plus grand soupçon. Pourtant, bien que je puisse comprendre d'autres préoccupations, les performances ne devraient pas être un problème si nous n'utilisons le script que pour appeler des commandes lorsque les utilisateurs cliquent sur des boutons, par exemple, qui n'effectuent pas de boucles lourdes (ce que nous essayons de faire, vous inquiétez-vous des différences de nanosecondes dans les temps de réponse pour un clic de bouton?). Donc, cela pourrait ne pas vous être applicable, mais c'est une option que j'envisagerais pour des bases de code plus grandes se concentrant davantage sur les problèmes de temps de construction que les autres problèmes associés aux "spaghettis d'en-tête", car cela peut éliminer beaucoup de ce qui pourrait autrement être nécessaire. être précompilé et lié (de nos jours j'écris même tous mes tests d'intégration dans un script de haut niveau qui est une charge sur le système de construction et CI).
Exemple
Pendant ce temps, le moyen le plus efficace que j'aie jamais vu après avoir épuisé des techniques pour réduire les temps de compilation dans de grandes bases de code sont des architectures qui véritablement réduisent la quantité d'informations requises pour que quelque chose dans le système fonctionne, non découpler simplement un en-tête d'un autre du point de vue du compilateur, mais obliger les utilisateurs de ces interfaces à faire ce qu'ils doivent faire tout en connaissant (à la fois du point de vue humain et du compilateur, le vrai découplage qui va au-delà des dépendances du compilateur) le strict minimum.
L'ECS n'est qu'un exemple (et je ne suggère pas que vous en utilisiez un), mais le rencontrer m'a montré que vous pouvez avoir des bases de code vraiment épiques qui se construisent encore étonnamment rapidement tout en utilisant avec bonheur des modèles et beaucoup d'autres goodies car l'ECS, en nature, crée une architecture très découplée où les systèmes n'ont besoin que de connaître la base de données ECS et généralement seulement une poignée de types de composants (parfois un seul) pour faire leur travail:
Conception, conception, conception
Et ces types de conceptions architecturales découplées à un niveau humain et conceptuel sont plus efficaces en termes de minimisation des temps de compilation que toutes les techniques que j'ai explorées ci-dessus à mesure que votre base de code grandit et grandit et grandit, car cette croissance ne se traduit pas par votre moyenne unité de compilation multipliant la quantité d'informations requises au moment de la compilation et de la liaison pour fonctionner (tout système qui nécessite que votre développeur moyen inclue une cargaison de choses pour faire quoi que ce soit les requiert également et pas seulement le compilateur pour connaître une grande quantité d'informations pour faire quoi que ce soit ). Il présente également plus d'avantages que la réduction des temps de construction et le démêlage des en-têtes, car cela signifie également que vos développeurs n'ont pas besoin d'en savoir beaucoup sur le système au-delà de ce qui est immédiatement requis pour faire quelque chose avec.
Si, par exemple, vous pouvez embaucher un développeur physique expert pour développer un moteur physique pour votre jeu AAA qui couvre des millions de LOC, et il peut commencer très rapidement tout en connaissant le strict minimum en ce qui concerne les types et les interfaces disponibles ainsi que les concepts de votre système, cela se traduira naturellement par une quantité d'informations réduite pour lui et le compilateur, nécessaire pour construire son moteur physique, et se traduira également par une grande réduction des temps de construction tout en impliquant généralement qu'il n'y a rien qui ressemble à des spaghettis n'importe où dans le système. Et c'est ce que je suggère de prioriser par-dessus toutes ces autres techniques: comment vous concevez vos systèmes. Épuiser d'autres techniques sera la cerise sur le gâteau si vous le faites alors que sinon, cela peut être comme du glaçage sans le gâteau dans le pire des cas.
C'est une question d'opinion. Voir cette réponse et que une. Et cela dépend aussi beaucoup de la taille du projet (si vous pensez que vous aurez des millions de lignes sources dans votre projet, ce n'est pas la même chose que d'en avoir quelques dizaines de milliers).
Contrairement à d'autres réponses, je recommande un en-tête public (plutôt grand) par sous-système (qui pourrait inclure des en-têtes "privés", ayant peut-être des fichiers séparés pour les implémentations de nombreuses fonctions intégrées). Vous pourriez même envisager un en-tête ayant seulement plusieurs #include
directive.
Je ne pense pas que de nombreux fichiers d'en-tête soient recommandés. En particulier, je ne recommande pas un fichier d'en-tête par classe, ni de nombreux petits fichiers d'en-tête de quelques dizaines de lignes chacun.
(Si vous avez un grand nombre de petits fichiers, vous devrez en inclure beaucoup dans chaque petit nité de traduction , et le temps de construction global pourrait en souffrir)
Ce que vous voulez vraiment, c'est identifier, pour chaque sous-système et fichier, le développeur principal qui en est responsable.
Enfin, pour un petit projet (par exemple de moins de cent mille lignes de code source), ce n'est pas très important. Pendant le projet, vous ' ll sera assez facile de refactoriser le code et de le réorganiser dans différents fichiers. Vous allez juste copier et coller des morceaux de code dans de nouveaux fichiers (en-tête), ce n'est pas grave (ce qui est plus difficile est de concevoir judicieusement la façon dont vous réorganiseriez vos fichiers, et cela est spécifique au projet).
(ma préférence personnelle est d'éviter les fichiers trop gros et trop petits; j'ai souvent des fichiers source de plusieurs milliers de lignes chacun; et je n'ai pas peur d'un fichier d'en-tête - y compris les définitions de fonctions intégrées - de plusieurs centaines de lignes ou même de quelques des milliers)
Notez que si vous voulez utiliser les en-têtes précompilés avec GCC (qui parfois est un bon choix approche pour réduire le temps de compilation) vous avez besoin d'un seul fichier d'en-tête (y compris tous les autres, et les en-têtes du système également).
Notez que en C++, les fichiers d'en-tête standard tirent un lot de code . Par exemple #include <vector>
tire plus de dix mille lignes sur mon GCC 6 sous Linux (18100 lignes). Et #include <map>
s'étend à près de 40KLOC. Par conséquent, si vous avez de nombreux petits fichiers d'en-tête, y compris des en-têtes standard, vous finissez par ré-analyser plusieurs milliers de lignes pendant la génération, et votre temps de compilation en souffre. C'est pourquoi je n'aime pas beaucoup de petites lignes source C++ (de quelques centaines de lignes au maximum), mais je préfère avoir des fichiers C++ moins nombreux mais plus volumineux (de plusieurs milliers de lignes).
(donc avoir des centaines de petits fichiers C++ qui incluent toujours -même indirectement- plusieurs fichiers d'en-tête standard donne un temps de construction énorme, ce qui agace les développeurs)
Dans le code C, les fichiers d'en-tête s'étendent souvent à quelque chose de plus petit, donc le compromis est différent.
Recherchez également, pour vous inspirer, les pratiques antérieures dans les projets existantslogiciels libres (par exemple sur github ).
Notez que les dépendances peuvent être traitées avec un bon système build automation . Étudiez la documentation de GNU make . Soyez conscient de divers -M
drapeaux de préprocesseur vers GCC (utile pour générer automatiquement des dépendances).
En d'autres termes, votre projet (avec moins d'une centaine de fichiers et une douzaine de développeurs) n'est probablement pas assez gros pour être vraiment concerné par l'en-tête d'enfer, donc votre préoccupation est non justifié . Vous pouvez avoir seulement une douzaine de fichiers d'en-tête (ou même beaucoup moins), vous pouvez choisir d'avoir un fichier d'en-tête par unité de traduction, vous pouvez même choisir d'avoir un seul fichier d'en-tête, et tout ce que vous choisissez de faire ne sera pas un "l'enfer d'en-tête" (et la refactorisation et la réorganisation de vos fichiers resteraient relativement faciles, donc le choix initial est pas vraiment important).
(Ne concentrez pas vos efforts sur "l'enfer d'en-tête" - ce qui n'est pas un problème pour vous -, mais concentrez-vous sur la conception d'une bonne architecture)