Comment puis-je empêcher l'en-tête?


45

Nous commençons un nouveau projet, à partir de zéro. Environ huit développeurs, une dizaine de sous-systèmes, chacun contenant quatre ou cinq fichiers sources.

Que pouvons-nous faire pour empêcher “l'en-tête”, AKA “en-têtes de spaghetti”?

  • Un en-tête par fichier source?
  • Plus un par sous-système?
  • Séparez typdefs, stucts & enums des prototypes de fonctions?
  • Séparez le matériel interne du sous-système externe du sous-système?
  • Insister sur le fait que chaque fichier, qu’il soit en-tête ou source, doit être compilable de manière autonome?

Je ne demande pas un «meilleur» moyen, mais simplement un indicateur de ce qui doit être surveillé et de 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.


16
Obtenez une copie de la conception logicielle C ++ à grande échelle . Elle ne vous apprendra pas seulement à éviter les problèmes d’en-têtes, mais bien d’autres problèmes concernant les dépendances physiques entre les fichiers source et les objets d’un projet C ++.
Doc Brown

6
Toutes les réponses ici sont super. Je voulais ajouter que la documentation pour utiliser des objets, des méthodes, des fonctions devrait se trouver dans les fichiers d’en-tête. Je vois toujours doc dans les fichiers sources. Ne me faites pas lire la source. C'est le point du fichier d'en-tête. Je ne devrais pas avoir besoin de lire le source sauf si je suis un implémenteur.
Bill Door

1
Je suis sûr d'avoir déjà travaillé avec vous. Souvent :-(
Mawg

5
Ce que vous décrivez n'est pas un gros projet. Un bon design est toujours le bienvenu, mais vous ne rencontrerez peut-être jamais le problème des "systèmes à grande échelle".
Sam

2
Boost a en fait une approche globale. Chaque entité individuelle a son propre fichier d’en-tête, mais chaque module plus volumineux possède également un en-tête qui inclut tout. Cela s'avère très puissant pour minimiser l'en-tête sans vous obliger à inclure quelques centaines de fichiers à chaque fois.
Cort Ammon

Réponses:


39

Méthode simple: un en-tête par fichier source. Si vous disposez d'un sous-système complet dans lequel les utilisateurs ne sont pas censés connaître les fichiers source, créez 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 seul (ou supposons qu'un fichier source incluant tout en-tête compile). C'est pénible 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 le faire est d’inclure tout d’abord le fichier d’en-tête dans chaque fichier source (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 si possible des modules précompilés pour réduire les temps de compilation.


Les appels de fonction inter-sous-systèmes, avec les paramètres des types déclarés dans l’autre sous-système, posent des problèmes.
Mawg

6
Difficile ou pas, "#include <subsystem1.h>" devrait être compilé. Comment vous y parvenez, à vous de voir. @ FrankPuffer: Pourquoi?
gnasher729

13
@Mawg Cela indique soit que vous avez besoin d'un sous-système distinct et partagé incluant les points communs entre les sous-systèmes distincts, soit que vous avez besoin d'en-têtes d'interface simplifiés pour chaque sous-système (qui est ensuite utilisé par les en-têtes d'implémentation, internes et intersystème). . Si vous ne pouvez pas écrire les en-têtes d'interface sans inclusions croisées, la conception de votre sous-système est perturbée et vous devez redéfinir les éléments pour que vos sous-systèmes soient plus indépendants. (Ce qui peut inclure le retrait d'un sous-système commun en tant que troisième module.)
RM

8
Une bonne technique pour assurer un en- tête est indépendant d' avoir une règle que le fichier source comprend toujours son propre en- tête d' abord . Cela interceptera les cas où vous devrez déplacer les dépendances hors du fichier d'implémentation dans le fichier d'en-tête.
Doug65536

4
@FrankPuffer: S'il vous plaît, ne supprimez pas vos commentaires, surtout si d'autres y répondent, car cela rend les réponses sans contexte. Vous pouvez toujours corriger votre déclaration dans un nouveau commentaire. Merci! Je suis intéressé de savoir ce que vous avez réellement dit, mais maintenant il est parti :(
MPW le

18

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 vous approcherez même pas de l'en-tête.

Vous pouvez également voir l’inverse: si vous avez déjà un en-tête dans votre projet, vous pouvez être certain que la conception du logiciel doit être améliorée.

Pour répondre à vos questions spécifiques:

  • Un en-tête par fichier source? → Oui, cela fonctionne bien dans la plupart des cas et facilite la recherche d'informations. Mais n'en fais pas une religion.
  • Plus un par sous-système? → Non, pourquoi voudriez-vous faire cela?
  • Séparez typdefs, stucts & enums des prototypes de fonctions? → Non, les fonctions et les types associés vont de pair.
  • Séparez le matériel interne du sous-système externe du sous-système? → oui bien sûr. Cela réduira les dépendances.
  • Insister sur le fait que chaque fichier, qu'il soit en-tête ou source par standalones, soit compliable? → Oui, n'exigez jamais qu'un en-tête soit inclus avant un autre en-tête.

12

Outre les autres recommandations, dans le sens de la réduction des dépendances (principalement applicables au C ++):

  1. N'incluez que ce dont vous avez vraiment besoin, où vous en avez besoin (niveau le plus bas possible). Par exemple. n'incluez pas d'en-tête si vous avez besoin d'appels uniquement dans la source.
  2. Utilisez des déclarations en aval dans les en-têtes chaque fois que possible (l'en-tête contient uniquement des pointeurs ou des références à d'autres classes).
  3. Nettoyez les inclus après chaque refactoring (commentez-les, voyez où la compilation échoue, déplacez-les là, supprimez les lignes d'inclusion encore commentées).
  4. Ne mettez pas trop d'installations communes dans le même fichier; divisez-les par fonctionnalité (par exemple, Logger est une classe, donc un en-tête et un fichier source; SystemHelper dito, etc.).
  5. Respectez les principes de OO, même si tout ce que vous obtenez est une classe composée uniquement de méthodes statiques (au lieu de fonctions autonomes) - ou utilisez plutôt un espace de noms .
  6. Pour certaines installations courantes, le modèle singleton est plutôt utile, car vous n'avez pas besoin de demander l'instance à un autre objet non associé.

5
Sur # 3, l'outil include-what-you-use peut vous aider, en évitant la méthode de recompilation manuelle qui consiste à deviner et à vérifier.
RM

1
Pourriez-vous expliquer quel est l'avantage de singletons dans ce contexte? Je ne comprends vraiment pas.
Frank Puffer

@FrankPuffer Mon raisonnement est le suivant: sans singleton, une instance d'une classe possède généralement l'instance d'une classe d'assistance, comme un enregistreur. Si une troisième classe souhaite l'utiliser, elle doit demander au propriétaire une référence de la classe d'assistance, ce qui signifie que vous utilisez deux classes et, bien sûr, que vous incluez leurs en-têtes, même si l'utilisateur n'a pas d'affaire avec le propriétaire. Avec un singleton, il vous suffit d'inclure l'en-tête de la classe d'assistance et de lui demander directement l'instance. Voyez-vous une faille dans cette logique?
Murphy

1
# 2 (déclarations en aval) peut faire une énorme différence en termes de temps de compilation et de vérification des dépendances. Comme l'indique cette réponse ( stackoverflow.com/a/9999752/509928 ), elle s'applique à la fois à C ++ et à C
Dave Compton

3
Vous pouvez également utiliser les déclarations forward lorsque vous passez par valeur, tant que la fonction n'est pas définie inline. Une déclaration de fonction n'est pas un contexte dans lequel la définition de type complète est nécessaire (une définition de fonction ou un appel de fonction est un tel contexte).
StoryTeller - Unslander Monica Le

6

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 (minimisez l'inclusion de) les fichiers d'en-tête dans les 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 existe tout un livre sur ce sujet, Conception de logiciels à grande échelle C ++ par Lakos. Il décrit le fait de disposer de "couches" de logiciels: les couches de niveau supérieur utilisent des couches de niveau inférieur et non l'inverse, ce qui évite également les dépendances circulaires.


4

Je dirais que votre question est fondamentalement sans réponse, car il existe deux types d'en-tête:

  • Le genre où vous devez inclure un million d'en-têtes différents, et qui diable peut même se souvenir de tous? Et maintenir ces listes d'en-têtes? Pouah.
  • Le genre où vous incluez une chose et découvrez que vous avez inclus toute la tour de Babel (ou devrais-je dire tour de Boost? ...)

Le problème est que si vous essayez d'éviter le premier, vous vous retrouvez, dans une certaine mesure, avec le dernier et vice-versa.

Il y a aussi un troisième type d'enfer, qui est les dépendances circulaires. Celles-ci peuvent apparaître si vous ne faites pas attention ... les éviter n'est pas compliqué, mais vous devez prendre le temps de réfléchir à la façon de le faire. Voir John Lakos talk sur en nivellement CppCon 2016 (ou tout simplement les diapositives ).


1
Vous ne pouvez pas toujours éviter les dépendances circulaires. Un exemple est un modèle dans lequel les entités se référent les unes aux autres. Au moins, vous pouvez essayer de limiter la circularité dans le sous-système. Cela signifie que, si vous incluez un en-tête du sous-système, vous supprimez la circularité.
Nalply

2
@ nalply: Je voulais dire éviter la dépendance circulaire des en-têtes, pas du code ... si vous n'évitez pas la dépendance des en-têtes circulaires, vous ne pourrez probablement pas le créer. Mais oui, point pris, +1.
einpoklum - réintègre Monica le

1

Découplage

Il s’agit en fin de compte de me dissocier en fin de journée au niveau de conception le plus fondamental, dépourvu de la nuance des caractéristiques de nos compilateurs et de nos lieurs. Je veux dire que vous pouvez faire des choses comme faire en sorte que chaque en-tête définisse une seule classe, utiliser pimpls, transmettre des déclarations à des types qui doivent seulement être déclarés, non définis, peut-être même utiliser des en-têtes ne contenant que des déclarations en aval (ex:) <iosfwd>, un en-tête par fichier source , organisez 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 au moment de la compilation"

Certaines techniques peuvent aider, mais vous pouvez épuiser ces pratiques tout en trouvant que votre fichier source moyen dans votre système nécessite un préambule de deux pages. #includeSi 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 la conception de vos interfaces, les directives ne font rien de vraiment significatif avec des temps de construction en flèche, et si cela peut ne pas être considéré à proprement parler comme un "en-tête de spaghetti", je Je dirais toujours 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 requièrent toujours une batterie d'informations visibles pour pouvoir faire quoi que ce soit, elles se traduiront par des temps de construction de plus en plus longs et multiplieront les raisons pour lesquelles vous devez potentiellement revenir en arrière et devoir changer les choses tout en faisant en sorte que les développeurs ils ont l’impression d’être en train d’amorcer le système en essayant de terminer leur codage quotidien. Il'

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 qui ressemble à spaghetti avec des interfaces de sous-système dépendant d'autres interfaces de sous-système avec un graphe de dépendance qui ressemble à un gâchis pour fonctionner.

Déclarations de transfert vers des types externes

De toutes les techniques que j'ai épuisées pour essayer d'obtenir une ancienne base de code qui prenait deux heures à construire tandis que les développeurs attendaient parfois deux jours pour leur tour chez CI sur nos serveurs de build (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 modifications), le plus douteux pour moi était les types de déclaration en aval 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 siècles d’avoir fait cela petit à petit tout en essayant de réduire les "en-têtes spaghettis", la pratique la plus discutable en rétrospective (comme me faire perdre de vue la nature fondamentale de conception alors que tunnel envisageait les interdépendances d’en-têtes) déclarait en aval les types définis dans d’autres en-têtes.

Si vous imaginez un en- Foo.hpptête qui a quelque chose comme:

#include "Bar.hpp"

Et il utilise uniquement Bardans l'en-tête une manière qui nécessite sa déclaration, pas sa définition. alors, cela peut sembler une évidence de dire class Bar;d'éviter de rendre Barvisible la définition de dans l'en-tête. Excepté dans la pratique, vous constaterez souvent que la plupart des unités de compilation utilisées Foo.hppdoivent encore Barêtre définies avec le fardeau supplémentaire de devoir s’inclure par Bar.hpp-dessus Foo.hpp, ou que vous rencontrez un autre scénario dans lequel cela aide réellement. % de vos unités de compilation peuvent travailler sans inclure Bar.hpp, sauf que cela soulève la question plus fondamentale de la conception (ou du moins, je pense que cela devrait être le cas de nos jours): pourquoi elles ont même besoin de voir la déclaration de Baret pourquoiFoo il faut même être dérangé de le savoir si cela n’est pas pertinent pour la plupart des cas d’utilisation (pourquoi alourdir une conception avec des dépendances les unes par rapport aux autres)?

Parce que conceptuellement, nous ne sommes pas vraiment découplés Foode Bar. Nous venons de faire en sorte que l'en-tête de Foone nécessite pas autant d'informations sur l'en-tête de Bar, ce qui est loin d'être aussi substantiel qu'un design qui rend véritablement ces deux éléments complètement indépendants l'un de l'autre.

Script intégré

C’est vraiment pour les 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 capable d'intégrer Lua en une journée et de l'avoir uniformément capable d'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, ce qui est peut-être le plus étrange, de la performance, leur principal soupçon. 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, n’effectuant aucune boucle lourde (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?).

Exemple

Entre-temps, le moyen le plus efficace que je connaisse après avoir épuisé les techniques de réduction du temps de compilation dans les bases de code volumineuses consiste en des architectures qui réduisent réellement la quantité d'informations requises pour le fonctionnement d'un élément du système, et non pas simplement le découplage d'un en-tête d'un compilateur. perspective mais en demandant aux utilisateurs de ces interfaces de faire ce qu’ils doivent faire tout en sachant (du point de vue humain et du compilateur, un véritable découplage qui va au-delà des dépendances du compilateur) le strict minimum.

L’ECS n’est qu’un exemple (et je ne vous suggère pas d’en utiliser un), mais sa découverte m’a montré que vous pouvez avoir des bases de code vraiment épiques qui construisent toujours étonnamment rapidement tout en utilisant avec bonheur des modèles et beaucoup d’autres bienfaits, car ECS, par nature, crée une architecture très découplée où les systèmes ont seulement besoin de connaître la base de données ECS et en général seulement quelques types de composants (parfois un seul) pour faire leur travail:

entrez la description de l'image ici

Design, Design, Design

Et ces types de conceptions architecturales découplées au niveau humain et conceptuel sont plus efficaces en termes de réduction des temps de compilation que toutes les techniques que j'ai expliquées ci-dessus à mesure que votre base de code grandit et grandit et grandit, car cette croissance ne correspond pas à votre moyenne. unité de compilation en multipliant la quantité d’information requise lors de la compilation et les temps de liaison pour fonctionner (tout système qui oblige votre développeur moyen à inclure un paquet de données pour faire quoi que ce soit exige de la part du compilateur, et pas seulement du compilateur à connaître beaucoup d’informations pour faire quoi que ce soit ). Il présente également plus d'avantages que des temps de construction réduits et des en-têtes non démêlés, car cela signifie également que vos développeurs n'ont pas besoin d'en savoir plus sur le système, au-delà de ce qui est immédiatement requis pour en faire quelque chose.

Si, par exemple, vous pouvez engager un développeur expert en physique pour développer un moteur physique pour votre jeu AAA qui couvre des millions de LOC, il peut démarrer très rapidement tout en connaissant le minimum d'informations absolues en ce qui concerne les types et les interfaces disponibles. ainsi que vos concepts de système, alors cela va naturellement se traduire par une quantité réduite d'informations pour lui et le compilateur, ce qui se traduira également par une réduction considérable 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 propose d’accorder la priorité à toutes ces autres techniques: la conception de vos systèmes. Épuiser d’autres techniques sera la cerise sur le gâteau si vous le faites pendant, sinon,


1
Une excellente réponse! Althoguh, j'ai dû creuser un peu pour découvrir ce que pimpls est :-)
Mawg

0

C'est une question d'opinion. Voir cette réponse et celle- là. Et cela dépend aussi beaucoup de la taille du projet (si vous pensez que votre projet contient des millions de lignes de source, ce n’est pas la même chose que quelques dizaines de milliers de lignes).

Contrairement à d'autres réponses, je recommande un en-tête public (plutôt volumineux) par sous-système (pouvant inclure des en-têtes "privés", contenant éventuellement des fichiers distincts pour la mise en oeuvre de nombreuses fonctions intégrées). Vous pourriez même envisager un en-tête n'ayant que plusieurs #include directives.

Je ne pense pas que beaucoup de fichiers d'en-tête sont 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 beaucoup de petits fichiers, vous devez en inclure beaucoup dans chaque petite unité de traduction , et le temps de construction peut en souffrir)

Ce que vous voulez vraiment, c'est identifier, pour chaque sous-système et chaque fichier, le développeur principal responsable.

Enfin, pour un petit projet (par exemple moins de cent mille lignes de code source), ce n’est pas très important. Pendant le projet, vous pourrez facilement refactoriser le code et le réorganiser dans différents fichiers. Il vous suffit de copier et coller des morceaux de code dans de nouveaux fichiers (en-tête), ce qui n’est pas grave (ce qui est plus difficile, c’est de concevoir judicieusement la façon dont vous réorganiseriez vos fichiers, et qui est spécifique à un projet).

(Ma préférence personnelle est d’éviter les fichiers trop volumineux et trop petits; j’ai souvent des fichiers sources de plusieurs milliers de lignes chacun; et je n’ai pas peur d’un fichier d’en-tête comprenant les définitions de fonctions en ligne de plusieurs centaines ou même de quelques lignes. des milliers d'entre eux)

Notez que si vous souhaitez utiliser des en- têtes pré-compilés avec GCC (ce qui est parfois une approche judicieuse pour réduire le temps de compilation), vous avez besoin d'un fichier d'en-tête unique (comprenant tous les autres, ainsi que les en-têtes système).

Notez qu'en C ++, les fichiers d'en-tête standard extraient beaucoup 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 beaucoup de petits fichiers d'en-tête, y compris des en-têtes standard, vous devez ré-analyser plusieurs milliers de lignes lors de la génération et votre temps de compilation en souffre. C'est pourquoi je n'aime pas beaucoup de petites lignes sources C ++ (de quelques centaines de lignes au plus), mais je préfère avoir moins de fichiers C ++, mais de plus grande taille (plusieurs milliers de lignes).

(avoir des centaines de petits fichiers C ++ qui incluent toujours indirectement même plusieurs fichiers d’en-tête standard donne un temps de construction énorme, ce qui gêne les développeurs)

Dans le code C, les fichiers d’en-têtes sont souvent plus petits, le compromis est donc différent.

Inspirez-vous également de la pratique antérieure des projets de logiciels libres existants (par exemple, sur github ).

Notez que les dépendances pourraient être traitées avec un bon système d' automatisation de la construction . Étudiez la documentation de GNU make . Tenez compte de divers -Mindicateurs de préprocesseur sur 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 important pour être vraiment concerné par «l’en-tête», votre préoccupation n’est donc pas justifiée . Vous pourriez ne posséder qu'une douzaine de fichiers d'en-tête (voire beaucoup moins), choisir un fichier d'en-tête par unité de traduction, un seul fichier d'en-tête, et quoi que vous choisissiez de faire, ce ne serait pas "header hell" (et le refactoring et la réorganisation de vos fichiers resteraient relativement faciles, le choix initial n’est donc pas vraiment important ).

(Ne concentrez pas vos efforts sur "l'en-tête" - ce qui n'est pas un problème pour vous - mais concentrez-vous sur la conception d'une bonne architecture)


Les détails techniques que vous mentionnez sont peut-être corrects. Cependant, si j'ai bien compris, le PO demandait des astuces pour améliorer la maintenabilité et l'organisation du code, et non le temps de compilation. Et je vois un conflit direct entre ces deux objectifs.
Murphy

Mais c'est toujours une question d'opinion. Et le PO commence apparemment un projet pas si gros.
Basile Starynkevitch
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.