我们正在从头开始一个新项目。大约有八位开发人员,一打左右的子系统,每个子系统都有四个或五个源文件。
我们怎样才能防止“标题地狱”(又名“意大利面条头”)?
- 每个源文件一个标头?
- 每个子系统加一个?
- 从函数原型中分离出typdef,结构和枚举?
- 将子系统内部与子系统外部分开?
- 坚持每个文件,无论标头还是源文件都必须是可独立编译的?
我并不是在寻求“最佳”方法,只是要指出要注意什么以及可能引起悲伤的指针,以便我们可以尝试避免这种情况。
这将是一个C ++项目,但是C信息将对将来的读者有所帮助。
我们正在从头开始一个新项目。大约有八位开发人员,一打左右的子系统,每个子系统都有四个或五个源文件。
我们怎样才能防止“标题地狱”(又名“意大利面条头”)?
我并不是在寻求“最佳”方法,只是要指出要注意什么以及可能引起悲伤的指针,以便我们可以尝试避免这种情况。
这将是一个C ++项目,但是C信息将对将来的读者有所帮助。
Answers:
简单方法:每个源文件一个标头。如果您有一个不希望用户了解源文件的完整子系统,请为该子系统提供一个标头,其中包括所有必需的标头文件。
任何头文件都应该可以自己编译(或者说包括任何头文件的源文件都应该编译)。如果我发现哪个头文件包含想要的内容,然后不得不查找其他头文件,这是很痛苦的。强制执行此操作的一种简单方法是让每个源文件都首先包含其头文件(感谢doug65536,我想我大部分时间都没有意识到这一点)。
确保使用可用的工具来减少编译时间-每个标头必须仅包含一次,使用预编译的标头可以降低编译时间,并尽可能使用预编译的模块来降低编译时间。
到目前为止,最重要的要求是减少源文件之间的依赖性。在C ++中,通常每个类使用一个源文件和一个头。因此,如果您具有良好的类设计,那么您甚至都不会接近标题地狱。
您也可以反过来查看:如果您的项目中已经有标题行,那么您可以确定需要改进软件设计。
要回答您的特定问题:
除了其他建议,还包括减少依赖关系(主要适用于C ++):
每个源文件一个标头,用于定义其源文件实现/导出的内容。
每个源文件中包含所需数量的头文件(从其自己的头开始)。
避免在其他头文件中包含(最小化)头文件(以避免循环依赖)。有关详细信息,请参见以下答案:“两个类可以使用C ++互相看到吗?”
关于这一主题,有一整本书是Lakos的《大规模C ++软件设计》。它描述了具有软件的“层”:高层使用低层,反之亦然,这又避免了循环依赖。
我认为您的问题从根本上来说是无法回答的,因为标题头有两种:
事实是,如果您试图避免前者,那么您在某种程度上会遇到后者,反之亦然。
还有第三种地狱,那就是循环依赖。如果您不小心,这些提示可能会弹出...避免它们不是很复杂,但是您确实需要花时间考虑如何做。看看John洛科什谈话在等级化CppCon 2016(或只是幻灯片)。
去耦
最终,这是在一天结束时在最基本的设计级别上与我解耦,而没有我们的编译器和链接器特性的细微差别。我的意思是,您可以执行以下操作:使每个标头仅定义一个类,使用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,并且他可以很快上手,同时了解诸如类型和接口之类的绝对最低限度的信息。以及您的系统概念,那么这自然将转化为减少他和编译器构建物理引擎所需的信息量,同样也转化为构建时间的减少,同时通常意味着没有类似意大利面条的内容系统中的任何地方。这就是我建议优先考虑的所有其他技术:如何设计系统。如果您精疲力尽,则其他技巧将锦上添花,否则,
这是见仁见智。看到这个答案和那个。而且,这也很大程度上取决于项目的规模(如果您相信自己的项目中将有数百万个源代码行,那与拥有数十万个源代码行是不一样的)。
与其他答案相反,我建议每个子系统一个(相当大的)公共头文件(其中可能包括“私有”头文件,可能具有用于许多内联函数的实现的单独文件)。您甚至可以考虑仅包含多个#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”(对您而言不是问题)上,而是一定要专注于设计一个好的体系结构)