优点&将所有代码放在C ++中的Header文件中的缺点?

时间:2008-10-11 08:44:25

标签: c++ architecture compilation header-files circular-dependency

您可以构建一个C ++程序,以便(几乎)所有代码都驻留在Header文件中。它本质上看起来像一个C#或Java程序。但是,在编译时,您至少需要一个.cpp文件来提取所有头文件。现在我知道有些人绝对会厌恶这个想法。但我没有发现任何令人信服的缺点。我可以列举一些优点:

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

[2]它通过使它们绝对清楚来避免循环依赖。如果ClassA中的ClassA.hClassB中的ClassB.h具有循环依赖关系,则必须提供前向参考&它突出了。 (请注意,这与C#和Java不同,编译器会自动解析循环依赖关系。这会鼓励编写错误的编码实践IMO)。同样,如果您的代码位于.cpp文件中,则可以避免循环依赖,但在实际项目中,.cpp文件往往包含随机标题,直到您无法确定谁依赖于谁。

你的想法?

17 个答案:

答案 0 :(得分:33)

原因[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文件必须编译而无需包含任何其他内容

这将删除排序问题和循环依赖。

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

如果编译时间确实存在问题,我会考虑:

结论

你正在做的不是把所有东西放在标题中。

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

也许你在全项目编译方面取胜。

但是,当编译一个小的改变时,你总会失败。

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

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

答案 1 :(得分:26)

我不同意第1点。

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

我更喜欢这样做:

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

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

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

答案 2 :(得分:17)

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

但是...

正如其他人所说,每次更改一行代码时,将所有代码放在头文件中都会强制进行完整编译。这可能不是一个问题,但您的项目可能会变得足够大,以至于编译时间将是一个问题。

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

如果您希望能够将当前代码转换为动态库,则会出现此问题。因为您没有与实际代码分离的正确接口声明,所以您将无法将已编译的动态库及其使用接口作为可读头文件提供。

您可能还没有这些问题,这就是为什么我告诉您的解决方案在您当前的环境中可能没问题。但是,随时准备应对任何变化总是更好,其中一些问题应该得到解决。

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

答案 3 :(得分:11)

您误解了该语言的用途。 .cpp文件实际上(或者应该是内联和模板代码除外)是系统中可执行代码的唯一模块。 .cpp文件被编译为目标文件,然后链接在一起。 .h文件仅用于正向声明.cpp文件中实现的代码。

这导致更快的编译时间和更小的可执行文件。它看起来也相当清晰,因为你可以通过查看它的.h声明来快速了解你的课程。

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

但是,我开发了一个解决方案,我在.h文件中有我的类声明,在.inl文件中有所有模板和内联代码,在我的.cpp文件中有非模板/内联代码的所有实现。 .inl文件在我的.h文件底部是#included。这使事情保持清洁和一致。

答案 4 :(得分:9)

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

答案 5 :(得分:3)

你放弃的一件事我将很难生活没有匿名命名空间。

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

答案 6 :(得分:3)

您的方法的一个缺点是您无法进行并行编译。您可能认为现在正在获得更快的编译,但如果您有多个.cpp文件,则可以在您自己的计算机上的多个核心上或使用分布式构建系统(如distcc或Incredibuild)并行构建它们。

答案 7 :(得分:3)

你超出了语言的设计范围。虽然你可能会有一些好处,但它最终会让你陷入困境。

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

,人们争论这是一个好的架构,但它是设计。最好是把时间花在你的问题上,而不是重新设计C ++文件架构的新方法。

答案 8 :(得分:3)

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

编译时间慢通常是由于用C ++编写的系统中的过度耦合。也许您需要使用外部接口将代码拆分为子系统。这些模块可以在单独的项目中编译。这样,您可以最小化系统的不同模块之间的依赖关系。

答案 9 :(得分:2)

我喜欢考虑在接口和实现方面分离.h和.cpp文件。 .h文件包含一个类的接口描述,.cpp文件包含实现。有时会出现实际问题或清晰度,导致分离完全干净,但这就是我开始的地方。例如,为了清楚起见,我通常在类声明中内联编码小访问器函数。较大的函数在.cpp文件中编码

在任何情况下,都不要让编译时间决定如何构建程序。最好有一个可读和可维护的程序,编译为1.5分钟而不是2分钟。

答案 10 :(得分:2)

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

关于循环依赖。我会将paercebal的建议更进一步。他有两个有相互指针的课程。相反,我更频繁地遇到一个类需要另一个类的情况。发生这种情况时,我将依赖项的头文件包含在另一个类的头文件中。一个例子:

// 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.
}

我包含这个与循环依赖关系略有不相关的原因是我觉得有其他选择包含头文件willy-nilly。在此示例中,struct bar源文件不包含struct foo头文件。这是在头文件中完成的。这样做的一个优点是,使用bar的开发人员不必知道开发人员需要包含的任何其他文件才能使用该头文件。

答案 11 :(得分:2)

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

原始问题指出项目中只有一个cpp,但如果您正在创建一个目标为可重用库的组件,则情况并非如此。

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

答案 12 :(得分:1)

好吧,正如许多人已经指出这个想法有很多缺点,但为了平衡一点并提供一个专业人士,我会说完全在头文件中有一些库代码是有道理的,因为它会使它成为现实独立于项目中使用的其他设置。

例如,如果一个人试图使用不同的开源库,可以将它们设置为使用不同的方法链接到您的程序 - 有些可能使用操作系统的动态加载的库代码,其他的则设置为静态链接;有些可能被设置为使用多线程,而有些则不是。对于程序员来说,这可能是一项艰巨的任务 - 特别是有时间限制 - 试图对这些不兼容的程序进行排序。

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

答案 13 :(得分:0)

静态或全局变量kludges甚至更不透明,可能无法调试。

例如,计算分析的总迭代次数。

在MY kludged文件中,将这些项放在cpp文件的顶部使它们很容易找到。

通过“也许是不可调试的”,我的意思是我会定期将这样的全局放入WATCH窗口。由于它始终在范围内,因此无论程序计数器恰好在何处,WATCH窗口始终可以访问它。通过将这些变量放在头文件顶部的{}之外,您可以让所有下游代码“看到”它们。如果您的程序计数器在{}之外,我认为调试器将不再将它们视为“范围内”。虽然使用kludge-global-at-Cpp-top,尽管它可能是全局的,在你的link-map-pdb-etc中显示,但没有extern语句,其他Cpp文件无法访问它,避免意外耦合。

答案 14 :(得分:0)

没人提出的一件事是编译大文件需要很多的内存。一次编译整个项目需要如此巨大的内存空间,即使你可以将所有代码都放在标题中也是不可行的。

答案 15 :(得分:0)

如果您正在使用模板类,则必须将整个实现放在标题中......

一次编译整个项目(通过单个.cpp文件)应该允许“整个程序优化”或“跨模块优化”之类的东西,这只能在一些高级编译器中使用。如果您将所有.cpp文件预编译为目标文件,然后进行链接,那么使用标准编译器实际上是不可能的。

答案 16 :(得分:0)

面向对象编程的重要哲学在于数据隐藏导致封装类,其实现隐藏在用户之外。这主要是为了提供一个抽象层,其中类的用户主要使用可公开访问的成员函数,例如特定的以及静态类型。然后,如果实现不向用户公开,则类的开发人员可以自由地修改实际的实现。即使实现是私有的并在头文件中声明,更改实现也需要所有依赖的代码库重新编译。然而,如果实现(成员函数的定义)在源代码(非头文件)中,那么库将被更改,并且依赖代码库需要与库的修订版本重新链接。如果该库是动态链接的,就像共享库那样保持功能签名(接口)相同并且实现更改也不需要重新链接。优点?当然。