我如何防止标题地狱?


44

我们正在从头开始一个新项目。大约有八位开发人员,一打左右的子系统,每个子系统都有四个或五个源文件。

我们怎样才能防止“标题地狱”(又名“意大利面条头”)?

  • 每个源文件一个标头?
  • 每个子系统加一个?
  • 从函数原型中分离出typdef,结构和枚举?
  • 将子系统内部与子系统外部分开?
  • 坚持每个文件,无论标头还是源文件都必须是可独立编译的?

我并不是在寻求“最佳”方法,只是要指出要注意什么以及可能引起悲伤的指针,以便我们可以尝试避免这种情况。

这将是一个C ++项目,但是C信息将对将来的读者有所帮助。


16
获得“ 大型C ++软件设计 ”的副本,它不仅可以避免标题问题,而且还可以解决C ++项目中源文件和对象文件之间的物理依赖性问题。
布朗

6
这里的所有答案都很棒。我想补充一点,有关使用对象,方法,函数的文档应位于头文件中。我仍然在源文件中看到doc。不要让我阅读源代码。这就是头文件的重点。除非我是一个实现者,否则无需阅读源代码。
比尔·道尔

1
我敢肯定我曾经和你一起工作过。通常:-(
Mawg '17

5
您描述的不是一个大项目。总是欢迎好的设计,但是您可能永远不会遇到“大型系统”问题。
山姆

2
Boost实际上确实具有全包方法。每个独立功能都有其自己的头文件,但是每个较大的模块也都有一个包含所有内容的头文件。事实证明,这对于最小化报头标题非常强大,而不必每次都强制#include数百个文件。
Cort Ammon

Answers:


39

简单方法:每个源文件一个标头。如果您有一个不希望用户了解源文件的完整子系统,请为该子系统提供一个标头,其中包括所有必需的标头文件。

任何头文件都应该可以自己编译(或者说包括任何头文件的源文件都应该编译)。如果我发现哪个头文件包含想要的内容,然后不得不查找其他头文件,这是很痛苦的。强制执行此操作的一种简单方法是让每个源文件都首先包含其头文件(感谢doug65536,我想我大部分时间都没有意识到这一点)。

确保使用可用的工具来减少编译时间-每个标头必须仅包含一次,使用预编译的标头可以降低编译时间,并尽可能使用预编译的模块来降低编译时间。


棘手的地方是跨子系统的函数调用,其参数类型在另一个子系统中声明。
Mawg '17

6
不管是否棘手,都应该编译“ #include <subsystem1.h>”。如何实现,取决于您自己。@FrankPuffer:为什么?
gnasher729

13
@Mawg表示您需要一个单独的共享子系统,其中包含不同子系统的共性,或者需要为每个子系统简化的“接口”标头(然后由内部和跨系统的实现标头使用) 。如果没有交叉包含就不能编写接口头,那么您的子系统设计就一团糟,您需要重新设计东西,以便子系统更加独立。(其中可能包括拉出一个公用子系统作为第三个模块。)
RM

8
用于确保报头的好方法是独立的具有规则,即源文件总是包括其自己的报头的第一。这将捕获需要将依赖项包括从实现文件移到头文件中的情况。
doug65536

4
@FrankPuffer:请不要删除您的评论,尤其是在其他人对此发表评论时,因为它会使响应变得无上下文。您始终可以在新评论中更正您的陈述。谢谢!我想知道您的实际意思,但是现在不见了:(
MPW

18

到目前为止,最重要的要求是减少源文件之间的依赖性。在C ++中,通常每个类使用一个源文件和一个头。因此,如果您具有良好的类设计,那么您甚至都不会接近标题地狱。

您也可以反过来查看:如果您的项目中已经有标题行,那么您可以确定需要改进软件设计。

要回答您的特定问题:

  • 每个源文件一个头?→ 是的,这在大多数情况下都能很好地工作,并且更容易找到东西。但是不要把它当作一种宗教。
  • 每个子系统加一个?→ 不,您为什么要这样做?
  • 从函数原型中分离出typdef,结构和枚举?→ 否,功能和相关类型属于同一类。
  • 将子系统内部与子系统外部分开?→ 是的,当然。这将减少依赖性。
  • 坚持每个文件,无论是标头还是独立的源文件,都可以兼容?→ 是,永远不需要在任何其他标题之前都包含任何标题。

12

除了其他建议,还包括减少依赖关系(主要适用于C ++):

  1. 仅包括您真正需要的内容,在需要的地方(最低级别)。例如 如果只需要源中的调用,则不要包含在标题中。
  2. 尽可能在标头中使用前向声明(标头仅包含指向其他类的指针或引用)。
  3. 每次重构后都要清理包含内容(注释掉它们,查看编译失败的地方,将它们移到那里,删除仍然注释的包含行)。
  4. 不要在同一个文件中打包太多的通用功能;按功能将它们分开(例如Logger是一类,因此是一个标头和一个源文件; SystemHelper dito等)。
  5. 坚持OO原则,即使您得到的只是一个仅由静态方法(而不是独立函数)组成的类,也可以改用名称空间
  6. 对于某些常用功能,单例模式非常有用,因为您不需要从其他不相关的对象中请求实例。

5
在#3上,“ 包括什么 ”工具可以提供帮助,从而避免了猜测并检查手动重新编译方法。
RM

1
您能解释一下在这种情况下单身人士的好处吗?我真的不明白。
Frank Puffer

@FrankPuffer我的基本原理是:没有单例,某个类的某些实例通常拥有一个助手类的实例,例如Logger。如果第三类要使用它,则需要从所有者那里请求辅助类的引用,这意味着您要使用两个类,并且当然包括它们的标头-即使用户与所有者没有任何关系。使用单例时,您只需要包括helper类的标题,并可以直接从中请求实例。您是否看到这种逻辑上的缺陷?
墨菲

1
#2(前向声明)可以在编译时间和依赖项检查方面产生巨大差异。如该答案(stackoverflow.com/a/9999752/509928)所示,它同时适用于C ++和C
Dave Compton

3
您也可以在按值传递时使用前向声明,只要该函数未内联定义即可。函数声明不是需要完整类型定义的上下文(函数定义或函数调用就是这样的上下文)。
StoryTeller-Unslander Monica

6

每个源文件一个标头,用于定义其源文件实现/导出的内容。

每个源文件中包含所需数量的头文件(从其自己的头开始)。

避免在其他头文件中包含(最小化)头文件(以避免循环依赖)。有关详细信息,请参见以下答案:“两个类可以使用C ++互相看到吗?”

关于这一主题,有一整本书是Lakos的《大规模C ++软件设计》。它描述了具有软件的“层”:高层使用低层,反之亦然,这又避免了循环依赖。


4

我认为您的问题从根本上来说是无法回答的,因为标题头有两种:

  • 您需要包含一百万个不同的标头的类型,在地狱中谁还能记住所有这些标头?并维护这些标题列表?啊。
  • 在其中包含一件事的类型中,发现您已经包含整个Babel塔(或者我应该说Boost的塔吗?...)

事实是,如果您试图避免前者,那么您在某种程度上会遇到后者,反之亦然。

还有第三种地狱,那就是循环依赖。如果您不小心,这些提示可能会弹出...避免它们不是很复杂,但是您确实需要花时间考虑如何做。看看John洛科什谈话在等级化CppCon 2016(或只是幻灯片)。


1
您不能总是避免循环依赖。一个示例是其中实体相互引用的模型。至少您可以尝试限制子系统中的圆形度,这意味着,如果包含子系统的标头,则可以抽象出圆形度。
nalply

2
@nalply:我的意思是避免标头的循环依赖性,而不是代码...如果您不避免循环标头依赖性,则可能无法构建。但是,是的,观点是+1。
einpoklum-恢复莫妮卡

1

去耦

最终,这是在一天结束时在最基本的设计级别上与我解耦,而没有我们的编译器和链接器特性的细微差别。我的意思是,您可以执行以下操作:使每个标头仅定义一个类,使用pimpls,将声明向前声明为只需要声明而不定义的类型,甚至可以使用仅包含正向声明(例如:)的标头,<iosfwd>每个源文件一个标头,根据要声明/定义的事物的类型一致地组织系统,等等。

减少“编译时依赖性”的技术

而且其中一些技术可以提供很多帮助,但是您可能会筋疲力尽,但仍然发现系统中的平均源文件需要两页的序言, #include如果您过多地关注在头级别上减少编译时依赖性而又不减少接口设计中的逻辑依赖性,那么这些指令就可以在飞速增长的构建时间上做一些有意义的事情,尽管严格来讲,这可能不被认为是“意大利面条头”仍然会说,这在实践中会转化为类似的不利于生产力的问题。归根结底,如果您的编译单元仍然需要一堆可见的信息才能执行任何操作,那么它将转化为增加的构建时间,并增加了您在使开发人员时可能不得不回头并不得不更改事情的原因。感觉就像是他们在试图完成日常编码的过程中对系统的冲击一样。它'

例如,您可以使每个子系统提供一个非常抽象的头文件和接口。但是,如果子系统之间没有相互分离,那么依赖于其他子系统接口的依赖关系图(看起来像是一团糟)才能正常工作,那么子系统接口又会变得类似于意大利面条。

将声明转发到外部类型

在我尝试用所有技术来获得以前的代码库所需的所有技术中,它们花了两个小时来构建,而开发人员有时却等了两天才轮到我们构建服务器上的CI(您几乎可以想象那些构建机器就像是疲惫的野兽般疯狂地尝试着)以便在开发人员进行更改时跟上并失败),对我来说最令人头疼的是向前声明其他标头中定义的类型。而且,我确实设法在经过很长一段时间后逐步将代码库缩减至40分钟左右,同时尝试减少“标头意大利面”,这是事后看来最有问题的做法(因为这使我忽略了设计,而隧道着眼于标头之间的相互依赖性)则正向声明其他标头中定义的类型。

如果您想象一个Foo.hpp标题如下:

#include "Bar.hpp"

并且它仅Bar在标头中使用要求声明的方式,而不是定义。那么似乎可以轻松地声明class Bar;以避免Bar在标头中显示该定义。除了在实践中,通常您会发现Foo.hpp仍然仍然Bar需要定义仍然要使用的大多数编译单元,而这些额外的负担是必须将Bar.hpp自身包括在之上Foo.hpp,否则您会遇到另一种确实有帮助的场景,即99 %的编译单元可以在不包含的情况下工作Bar.hpp,除非这样会引发一个更根本的设计问题(或者至少在今天我认为应该如此),即为什么他们甚至需要查看的声明Bar以及为什么Foo 甚至需要费心去了解它是否与大多数用例无关(为什么一个设计要负担另一个几乎从未使用过的依赖?)。

因为从概念上讲,我们还没有真正Foo脱离Bar。我们已经做到了,因此的标头Foo不需要太多有关的标头的信息Bar,而且还不及真正使这两者完全独立的设计那么重要。

嵌入式脚本

这确实适用于大规模代码库,但我发现另一种非常有用的技术是至少在系统的最高级部分使用嵌入式脚本语言。我发现我可以一天嵌入Lua,并且可以统一调用我们系统中的所有命令(谢天谢地,这些命令是抽象的)。不幸的是,我遇到了一个障碍,在那儿开发人员不信任另一种语言的介绍,而且最奇怪的是,他们以表演为最大怀疑。尽管我可以理解其他问题,但是如果我们仅在用户单击按钮时(例如,他们自己不执行大量循环的情况下)仅利用脚本来调用命令,则性能应该不是问题。担心按钮点击响应时间的纳秒级差异?)。

同时,在用尽减少大型代码库中的编译时间的技术之后,我见过的最有效的方法是架构,这些架构真正减少了系统中任何一项工作所需的信息量,而不仅仅是从编译器中解耦一个头到另一个头从角度来看,但要求这些接口的用户做他们需要做的事情,同时要知道(从人和编译器的角度来看,真正的去耦超出了编译器的依赖性)。

ECS只是一个示例(我不建议您使用一个示例),但是遇到它向您展示了您可以拥有一些真正的史诗级代码库,这些代码库仍然可以令人惊讶地快速构建,同时愉快地使用模板和许多其他优点,因为ECS通过自然,创建了一个高度分离的架构,其中系统仅需要了解ECS数据库,通常只有少数组件类型(有时只有一个)可以完成其任务:

在此处输入图片说明

设计,设计,设计

随着代码库的增长,增长和增长,这种在人类概念层面上分离的体系结构设计在最小化编译时间方面比我上面探讨的任何技术更有效,因为这种增长不会转化为您的平均水平编译单元乘以编译和链接时所需的工作量信息(任何需要您的普通开发人员包括大量工作来做任何事情的系统,都需要他们,而不仅仅是编译器知道很多信息来做任何事情)。它还比减少构建时间和使标头解开具有更多的好处,因为这还意味着您的开发人员除了对系统进行某些处理外,不需要立即了解其他知识。

例如,如果您可以聘请专业的物理开发人员为您的AAA游戏开发一个物理引擎,该引擎跨越数百万个LOC,并且他可以很快上手,同时了解诸如类型和接口之类的绝对最低限度的信息。以及您的系统概念,那么这自然将转化为减少他和编译器构建物理引擎所需的信息量,同样也转化为构建时间的减少,同时通常意味着没有类似意大利面条的内容系统中的任何地方。这就是我建议优先考虑的所有其他技术:如何设计系统。如果您精疲力尽,则其他技巧将锦上添花,否则,


1
一个很好的答案!Althoguh我只好掏了一下,发现什么pimpls是:-)
Mawg

0

这是见仁见智。看到这个答案和那个。而且,这也很大程度上取决于项目的规模(如果您相信自己的项目中将有数百万个源代码行,那与拥有数十万个源代码行是不一样的)。

与其他答案相反,我建议每个子系统一个(相当大的)公共头文件(其中可能包括“私有”头文件,可能具有用于许多内联函数的实现的单独文件)。您甚至可以考虑仅包含多个#include 指令的标头。

我认为不建议使用很多头文件。特别是,我不建议每个类一个头文件,也不建议每个头几十行的许多小头文件。

(如果您有很多小文件,则需要在每个小翻译单元中包含很多小文件,这样可能会影响整个构建时间)

您真正想要的是为每个子系统和文件确定负责它的主要开发人员。

最后,对于一个小型项目(例如,源代码少于十万行),它不是很重要。在项目期间,您将能够轻松重构代码并将其重组到不同的文件中。您只需要将代码块复制并粘贴到新的(头文件)中就可以了,没什么大不了的(更难的是明智地设计如何重组文件,这是特定于项目的)。

(我个人的喜好是避免文件太大和太小;我通常每个源文件都有几千行;而且我不怕头文件(包括内联函数定义)数百行甚至两三行)数千个)

请注意,如果要在GCC中使用预编译的头文件(有时这是减少编译时间的明智方法),则需要一个头文件(包括所有其他头文件和系统头文件)。

注意,在C ++中,标准头文件提取了许多代码。例如,#include <vector>在Linux上的GCC 6上拉了1万多行(18100行)。并#include <map> 扩展到近40KLOC。因此,如果您有许多小的头文件(包括标准头),那么最终将在构建过程中重新解析成千上万行,并且会浪费编译时间。这就是为什么我不喜欢有很多小的C ++源代码行(最多几百行),而赞成拥有更少但更大的C ++文件行(几千行)的原因。

(因此,如果有数百个小型 C ++文件,这些文件总是(甚至间接地)包含多个标准头文件,则会花费大量的构建时间,这会惹恼开发人员)

在C代码中,头文件经常扩展为较小的文件,因此权衡是不同的。

为了获得启发,还可以查看现有 自由软件项目中的先前实践(例如,在github上)。

注意,可以使用良好的构建自动化系统来处理依赖项。研究GNU make文档。请注意GCC 的各种-M预处理器标志(用于自动生成依赖项)。

换句话说,您的项目(少于100个文件和12个开发人员)可能还不够大,不足以引起“ header hell”的真正关注,因此您的关注是没有道理的。您可能只有十几个头文件(甚至更少),可以选择每个翻译单元有一个头文件,甚至可以选择有一个头文件,无论选择哪种方式都不会“ header hell”(并且重构和重组文件将保持相当容易,因此初始选择并不是重要)。

(不要将精力集中在“ header hell”(对您而言不是问题)上,而是一定要专注于设计一个好的体系结构)


您提到的技术可能是正确的。但是,据我了解,OP要求提供提示,以提示如何提高代码的可维护性和组织性,而不是编译时间。我看到这两个目标之间存在直接冲突。
墨菲

但这仍然是一个见解的问题。OP显然正在启动一个不太大的项目。
巴西尔·斯塔林凯维奇
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.