将所有代码放在 Headfile 中的利弊 C++?

Pros & Cons of putting all code in Header files in C++?

本文关键字:C++ Headfile 代码      更新时间:2023-10-16

您可以构建一个C++程序,使(几乎)所有代码都位于头文件中。它本质上看起来像一个C#或Java程序。但是,在编译时,您确实需要至少一个.cpp文件来拉入所有头文件。现在我知道有些人会非常讨厌这种想法。但我没有发现这样做有任何令人信服的负面影响。我可以列出一些优点:

[1] 更快的编译时间。所有头文件只解析一次,因为只有一个.cpp文件。此外,一个头文件不能包含多次,否则将导致构建中断。当使用替代方法时,还有其他方法可以实现更快的编译,但这非常简单。

[2] 它通过使循环依赖关系绝对清晰,避免了循环依赖关系。如果ClassA.hClassAClassB.hClassB有循环依赖关系;它很突出。(注意,这与编译器自动解析循环依赖关系的C#和Java不同。这助长了糟糕的编码实践IMO)。同样,如果您的代码在.cpp文件中,则可以避免循环依赖,但在现实世界的项目中,.cpp文件往往包含随机标头,直到您无法确定谁依赖于谁。

你的想法?

原因[1]更快的编译时间

不在我的项目中:源文件(CPP)只包括它们需要的头文件(HPP)。因此,当我因为一个微小的更改而只需要重新编译一个CPP时,我有十倍于相同数量的文件没有重新编译。

也许你应该把你的项目分解成更多的逻辑源/头:对A类实现的修改不应该需要重新编译B、C、D、E等类的实现

原因[2]它避免了循环依赖

代码中的循环依赖关系?

对不起,但我还没有遇到这种真正的问题:假设a取决于B,B取决于a:

struct A
{
   B * b ;
   void doSomethingWithB() ;
} ;
struct B
{
   A * a ;
   void doSomethingWithA() ;
} ;
void A::doSomethingWithB() { /* etc. */ }
void B::doSomethingWithA() { /* etc. */ }

解决这个问题的一个好方法是将这个源分解为每个类至少一个源/头(以类似于Java的方式,但每个类有一个源和一个头):

// A.hpp
struct B ;
struct A
{
   B * b ;
   void doSomethingWithB() ;
} ;

// B.hpp
struct A ;
struct B
{
   A * a ;
   void doSomethingWithA() ;
} ;

// A.cpp
#include "A.hpp"
#include "B.hpp"
void A::doSomethingWithB() { /* etc. */ }

// B.cpp
#include "B.hpp"
#include "A.hpp"
void B::doSomethingWithA() { /* etc. */ }

因此,没有依赖性问题,并且编译时间仍然很快。

我错过什么了吗?

当在";"真实世界";项目

在现实世界中的项目中,cpp文件往往包含随机头,直到你无法确定谁依赖于

当然。但是,如果你有时间重新组织这些文件;一个CPP";解决方案,然后您有时间清理这些标头。我的标题规则是:

  • 分解收割台,使其尽可能模块化
  • 永远不要包含不需要的标题
  • 如果您需要符号,请转发声明
  • 仅当上述操作失败时,才包括标头

无论如何,所有标头都必须自给自足,这意味着:

  • 标头包括所有需要的标头(以及仅需要的标头-请参阅上文)
  • 包含一个标头的空CPP文件必须在不需要包含任何其他内容的情况下进行编译

这将消除排序问题和循环依赖关系。

编译时间是个问题吗?然后

如果编译时间真的是个问题,我会考虑:

  • 使用预编译头(这对STL和BOOST非常有用)
  • 通过PImpl习惯用法减少耦合,如中所述http://en.wikipedia.org/wiki/Opaque_pointer
  • 使用网络共享编译

结论

你所做的不是把所有的东西都放在标题里。

您基本上将所有文件都包含在一个并且只有一个最终源中。

也许你在完整的项目汇编方面是赢家。

但是,当为一个小的更改进行编译时,您总是会失败。

在编码时,我知道我经常编译小的更改(如果只是为了让编译器验证我的代码),然后最后一次,做一个完整的项目更改。

如果我的项目按照你的方式组织,我会损失很多时间

我不同意第1点。

是的,只有一个.cpp,从头开始构建的时间更快。但是,你很少从头开始构建。您进行了一些小的更改,每次都需要重新编译整个项目。

我更喜欢用另一种方式:

  • 将共享声明保存在.h文件中
  • 保留.cpp文件中仅在一个位置使用的类的定义

因此,我的一些.cpp文件开始看起来像Java或C#代码;)

但是,在设计系统时,由于第2点的原因,"保持东西在.h中"方法是很好的。你做的。我通常在构建类层次结构时这样做,稍后当代码体系结构变得稳定时,我会将代码移动到.cpp文件中。

您说您的解决方案有效是对的。它甚至可能对您当前的项目和开发环境没有任何不利影响。

但是。。。

正如其他人所说,每次更改一行代码时,将所有代码放在头文件中会强制进行完整编译。这可能还不是问题,但您的项目可能会增长到编译时间成为问题的程度。

另一个问题是共享代码时。虽然您可能还没有直接关注,但重要的是要尽可能多地对代码的潜在用户隐藏代码。通过将代码放入头文件,任何使用代码的程序员都必须查看整个代码,而只是对如何使用它感兴趣。将代码放入cpp文件只允许将二进制组件(静态或动态库)及其接口作为头文件提供,这在某些环境中可能更简单。

如果您希望能够将当前代码转换为动态库,那么这就是一个问题。因为您没有一个与实际代码解耦的正确接口声明,所以您将无法将编译后的动态库及其使用接口作为可读头文件提供。

您可能还没有这些问题,这就是为什么我说您的解决方案在您当前的环境中可能还可以。但最好是为任何变化做好准备,其中一些问题应该得到解决。

附言:关于C#或Java,你应该记住,这些语言并没有按照你说的去做。它们实际上是独立编译文件(如cpp文件),并为每个文件全局存储接口。这些接口(以及任何其他链接的接口)然后用于链接整个项目,这就是为什么它们能够处理循环引用。因为C++每个文件只进行一次编译,所以它不能全局存储接口。这就是为什么您需要在头文件中明确地编写它们。

对我来说,明显的缺点是你总是必须一次构建所有代码。使用.cpp文件,您可以进行单独的编译,因此您只能重建真正更改的位。

您误解了该语言的使用方式。cpp文件实际上是(或者应该是内联代码和模板代码除外)系统中仅有的可执行代码模块。cpp文件被编译成对象文件,然后链接在一起。h文件的存在仅用于.cpp文件中实现的代码的前向声明。

这将导致更快的编译时间和更小的可执行文件。它看起来也相当干净,因为您可以通过查看它的.h声明来快速概述您的类。

至于内联代码和模板代码——因为这两种代码都是由编译器而不是链接器生成的——它们必须始终可用于每个.cpp文件的编译器。因此,唯一的解决方案是将其包含在.h文件中。

然而,我已经开发了一个解决方案,其中我的类声明在.h文件中,所有模板和内联代码在.inl文件中,非模板/内联代码的所有实现在.cpp文件中。.inl文件#包含在我的.h文件的底部。这样可以保持事物的清洁和一致性。

您的方法的一个缺点是不能进行并行编译。您可能认为现在的编译速度更快了,但如果您有多个.cpp文件,您可以在自己的机器上的多个核心上并行构建它们,也可以使用distcc或Incredbuild等分布式构建系统。

您可能想看看Lazy C++。它允许您将所有内容放在一个文件中,然后在编译之前运行,并将代码拆分为.h和.cpp文件。这可能会让你两全其美。

编译速度慢通常是由于用C++编写的系统中的过度耦合造成的。也许您需要将代码拆分为具有外部接口的子系统。这些模块可以在单独的项目中编译。通过这种方式,您可以最大限度地减少系统不同模块之间的依赖关系。

我喜欢从接口和实现的角度考虑.h和.cpp文件的分离。.h文件包含一个或多个类的接口描述,.cpp文件包含实现。有时会有一些实际问题或明确性阻碍了完全彻底的分离,但这就是我的出发点。例如,为了清楚起见,我通常在类声明中内联编写小型访问器函数。较大的函数编码在.cpp文件中

无论如何,不要让编译时间决定如何构建程序。最好有一个可读和可维护的程序,而不是一个1.5分钟而不是2分钟编译的程序。

如果没有匿名名称空间,我会很难接受你要放弃的一件事。

我发现它们对于定义类特定的实用程序函数非常有价值,这些函数应该在类的实现文件之外不可见。它们还非常适合保存系统其他部分不可见的任何全局数据,比如单例实例。

您超出了该语言的设计范围。虽然你可能有一些好处,但它最终会咬你的屁股。

C++是为具有声明的h文件和具有实现的cpp文件设计的。编译器是围绕这种设计构建的。

是的,人们争论这是否是一个好的架构,但这是设计。与其重新发明设计C++文件体系结构的新方法,不如把时间花在你的问题上。

正如许多人所指出的,这个想法有很多缺点,但为了平衡一下并提供一个专业版,我想说,将一些库代码完全放在头中是有意义的,因为这将使它独立于所用项目中的其他设置。

例如,如果试图使用不同的开源库,可以将它们设置为使用不同的方法来链接到您的程序——有些可能使用操作系统的动态加载库代码,另一些则设置为静态链接;有些可能被设置为使用多线程,而另一些则没有。对于程序员来说,试图找出这些不兼容的方法可能是一项艰巨的任务,尤其是在时间有限的情况下。

然而,当使用完全包含在头中的库时,所有这些都不是问题。对于一个写得很好的合理的图书馆来说,"它只是起作用"。

我认为,除非您使用MSVC的预编译头,并且您使用Makefile或其他基于依赖关系的构建系统,否则在迭代构建时,拥有单独的源文件应该会编译得更快。由于我的开发几乎总是迭代的,所以我更关心它能以多快的速度重新编译我在文件x.cpp中所做的更改,而不是在其他20个我没有更改的源文件中。此外,我对源文件的更改频率比对API的更改频率高得多,因此更改频率较低。

关于,循环依赖关系。我更愿意接受帕塞巴尔的建议。他有两个互相有指针的班级。相反,我更频繁地遇到一个类需要另一个类的情况。当这种情况发生时,我会将依赖项的头文件包含在另一个类的头文件中。一个例子:

// foo.hpp
#ifndef __FOO_HPP__
#define __FOO_HPP__
struct foo
{
   int data ;
} ;
#endif // __FOO_HPP__

// bar.hpp
#ifndef __BAR_HPP__
#define __BAR_HPP__
#include "foo.hpp"
struct bar
{
   foo f ;
   void doSomethingWithFoo() ;
} ;
#endif // __BAR_HPP__

// bar.cpp
#include "bar.hpp"
void bar::doSomethingWithFoo()
{
  // Initialize f
  f.data = 0;
  // etc.
}

我之所以包含这一点,与循环依赖关系略有无关,是因为我觉得除了随意包含头文件之外,还有其他选择。在本例中,结构条源文件不包括结构foo头文件。这是在头文件中完成的。这样做的优点在于,使用bar的开发人员不必知道开发人员使用该头文件所需包含的任何其他文件。

头中代码的一个问题是它必须内联,否则在链接包含同一头的多个翻译单元时会出现多个定义问题。

最初的问题指定项目中只有一个cpp,但如果您正在创建一个用于可重用库的组件,则情况并非如此。

因此,为了创建尽可能可重用和可维护的代码,只需将内联和可内联代码放在头文件中。

面向对象编程的重要理念在于隐藏数据,从而产生封装类,实现对用户隐藏。这主要是为了提供一个抽象层,其中类的用户主要使用可公开访问的成员函数,例如特定类型和静态类型。然后,类的开发人员可以自由修改实际的实现,前提是这些实现不向用户公开。即使实现是私有的并在头文件中声明,更改实现也需要重新编译所有依赖的代码库。然而,如果实现(成员函数的定义)在源代码(非头文件)中,则库会发生更改,依赖代码库需要与库的修订版本重新链接。如果该库是动态链接的,就像共享库一样,那么保持函数签名(接口)不变和实现更改也不需要重新链接。有利条件当然

静态或全局变量的拼凑甚至不那么透明,可能无法调试

例如对用于分析的迭代的总次数进行计数

在我的拼凑文件中,将这些项目放在cpp文件的顶部可以很容易地找到它们

所谓"也许不可调试",我的意思是,我通常会把这样一个全局放入WATCH窗口。由于它总是在范围内,所以无论程序计数器现在恰好在哪里,WATCH窗口都可以访问它。通过将这些变量放在头文件顶部的{}之外,可以让所有下游代码"看到"它们。通过将它们放在{}中,我认为调试器将不再认为它们"在范围内",如果您的程序计数器在{}之外。然而,在Cpp顶部有了kludge global,即使它可能是全局的,显示在链接映射pdb等中,如果没有外部语句,其他Cpp文件就无法访问它,从而避免意外耦合。

有一件事没有人提到,编译大文件需要大量的内存。一次编译整个项目需要巨大的内存空间,即使可以将所有代码放在头中,这也是不可行的。

如果您使用模板类,您必须将整个实现放在头中。。。

一次性编译整个项目(通过单个base.cpp文件)应该允许类似于"整个程序优化"或"跨模块优化"的功能,这只在少数高级编译器中可用。如果要将所有.cpp文件预编译到对象文件中,然后进行链接,那么使用标准编译器是不可能做到这一点的。

头中的代码可以方便编码过程。此外,预编译的能力提高了效率。一个副作用是,总体二进制大小通常会随着标头中代码量的增加而增加,例如,如果在多个libs/ex中使用标头。

但是,C++20模块现在是这样的:
https://learn.microsoft.com/en-us/cpp/cpp/modules-cpp?view=msvc-170

报价:

模块消除或减少了与头文件的使用。

使用模块的提示:
https://github.com/MicrosoftDocs/cpp-docs/blob/main/docs/cpp/tutorial-named-modules-cpp.md

请注意,对于模块(与预编译头不同),在实现文件中包含代码(而不是模块接口文件)可能会带来编译时的好处。

关于二进制大小(以Visual C++为例),模块构建生成对象(.obj)文件(以及接口.ifc文件等其他文件),因此二进制大小与接口文件源代码大小的比例问题较小
https://lists.isocpp.org/sg15/att-1346/C___20_Modules_Build_in_Visual_Studio.pdf