为什么我不应该包含cpp文件而是使用标题?

时间:2009-11-06 08:30:08

标签: c++ header-files

所以我完成了我的第一个C ++编程作业,并获得了我的成绩。但根据评分,我失去了including cpp files instead of compiling and linking them的分数。我不清楚这意味着什么。

回顾一下我的代码,我选择不为我的类创建头文件,但是在cpp文件中做了所有事情(它看起来没有头文件......)。我猜这个评分者意味着我写了'#include'mycppfile.cpp“;'在我的一些文件中。

我对cpp文件#include的推理是: - 应该进入头文件的所有东西都在我的cpp文件中,所以我假装它就像一个头文件 - 在Monkey-see-monkey做的时候,我看到文件中的其他头文件是#include'd,所以我对我的cpp文件做了同样的事情。

那究竟我做错了什么,为什么不好呢?

14 个答案:

答案 0 :(得分:153)

据我所知,C ++标准在头文件和源文件之间没有区别。就语言而言,任何带有合法代码的文本文件都与其他任何文本文件相同。但是,尽管不是非法的,包括源文件到您的程序中将几乎消除您从一开始就分离源文件所带来的任何好处。

基本上,#include所做的是告诉预处理器获取您指定的整个文件,并在编译器之前将其复制到活动文件中得到它的手。因此,当您将项目中的所有源文件包含在一起时,您所做的事情与创建一个巨大的源文件之间根本没有区别,而且根本没有任何分离。

“哦,这没什么大不了的。如果它运行,那很好,”我听到你哭了。从某种意义上说,你是对的。但是现在你正在处理一个微小的小程序,以及一个很好且相对无阻碍的CPU来为你编译它。你永远不会那么幸运。

如果你曾经深入研究严肃的计算机编程领域,那么你将看到的项目的行数可以达到数百万,而不是数十。那是很多台词。如果您尝试在现代台式计算机上编译其中一个,则可能需要几个小时而不是几秒钟。

“哦不!这听起来很可怕!但我可以防止这种可怕的命运吗?!”不幸的是,你无能为力。如果编译需要数小时,则编译需要数小时。但这只是第一次真正重要 - 一旦你编译了一次,就没有理由再次编译它。

除非你改变了什么。

现在,如果你有两百万行代码合并成一个庞大的庞然大物,并且需要做一个简单的错误修复,例如x = y + 1,这意味着你必须再次编译所有200万行为了测试这个。如果你发现你打算做一个x = y - 1,那么再过两百万行编译等着你。这浪费了很多时间,可以更好地做其他事情。

“但我讨厌没有效果!如果只是某种方式编译我的代码库的不同部分,之后链接他们在一起!” 理论上一个好主意。但是如果你的程序需要知道不同文件中发生了什么呢?除非你想要运行一堆微小的.exe文件,否则不可能完全分离你的代码库。

“但肯定一定是可能的!编程听起来像是纯粹的折磨!如果我找到某种方法将界面与实现分开怎么办?通过从这些不同的代码中获取足够的信息来说段,以便将它们标识到程序的其余部分,并将它们放入某种标头文件中?这样,我可以使用#include 预处理程序指令只引入编译所需的信息!“

嗯。你可能会在那里做点什么。让我知道这对你有用。

答案 1 :(得分:41)

这可能是一个比你想要的更详细的答案,但我认为一个合适的解释是合理的。

在C和C ++中,一个源文件被定义为一个翻译单元。按照惯例,头文件包含函数声明,类型定义和类定义。实际的函数实现驻留在翻译单元中,即.cpp文件。

这背后的想法是函数和类/结构成员函数被编译和汇编一次,然后其他函数可以从一个地方调用该代码而不会重复。您的函数原型隐式声明为“extern”。

/* Function prototype, usually found in headers. */
/* Implicitly 'extern', i.e the symbols is visible everywhere, not just locally.*/
int add(int, int);

/* function body, or function definition. */
int add(int a, int b) 
{
   return a + b;
}

如果您希望某个功能对于翻译单元是本地的,则将其定义为“静态”。这是什么意思?这意味着如果您包含具有extern函数的源文件,您将获得重定义错误,因为编译器不止一次地遇到相同的实现。因此,您希望所有翻译单元都能看到函数原型,而不是函数体

那么最后这一切怎么会被捣碎在一起呢?这是链接器的工作。链接器读取汇编程序阶段生成的所有目标文件并解析符号。正如我之前所说,符号只是一个名称。例如,变量或函数的名称。当调用函数或声明类型的转换单元不知道这些函数或类型的实现时,这些符号被认为是未解析的。链接器通过连接保存未定义符号的转换单元和包含该实现的转换单元来解析未解析的符号。唷。对于所有外部可见符号都是如此,无论它们是在代码中实现还是由其他库提供。库实际上只是一个包含可重用代码的存档。

有两个值得注意的例外情况。首先,如果你有一个小功能,你可以使它内联。这意味着生成的机器代码不会生成外部函数调用,而是按字面顺序连接。由于它们通常很小,所以尺寸开销并不重要。你可以想象它们在工作方式上是静态的。因此,在头文件中实现内联函数是安全的。类或结构定义中的函数实现通常也由编译器自动内联。

另一个例外是模板。由于编译器在实例化时需要查看整个模板类型定义,因此无法将实现与定义分离,就像独立函数或普通类一样。嗯,也许现在可能这样,但是对“export”关键字的广泛编译器支持需要很长时间。因此,如果不支持“导出”,翻译单元将获得自己的实例化模板化类型和函数的本地副本,类似于内联函数的工作方式。由于支持“出口”,情况并非如此。

对于这两个例外,有些人发现将内联函数,模板化函数和模板化类型的实现放在.cpp文件中“更好”,然后#include .cpp文件。这是标头还是源文件并不重要;预处理器并不关心,只是一种约定。

从C ++代码(几个文件)到最终可执行文件的整个过程的快速摘要:

  • 运行预处理器,它解析所有以'#'开头的指令。例如,#include指令将包含的文件与劣质文件连接起来。它还进行宏替换和令牌粘贴。
  • 实际的编译器在预处理器阶段之后在中间文本文件上运行,并发出汇编代码。
  • 汇编程序在程序集文件上运行并发出机器代码,这通常称为目标文件,并遵循相关操作系统的二进制可执行格式。例如,Windows使用PE(可移植可执行格式),而Linux使用带有GNU扩展的Unix System V ELF格式。在此阶段,符号仍标记为未定义。
  • 最后,运行链接器。所有前面的阶段都按顺序在每个翻译单元上运行。但是,链接器阶段适用于汇编程序生成的所有生成的目标文件。链接器解析符号并做很多魔术,比如创建段和段,这取决于目标平台和二进制格式。程序员一般不需要知道这一点,但在某些情况下肯定会有所帮助。

同样,这比你要求的要多得多,但我希望这些细节可以帮助你看到更大的图景。

答案 2 :(得分:9)

典型的解决方案是仅使用.h个文件进行声明,并使用.cpp个文件进行实现。如果需要重用实现,则将相应的.h文件包含到.cpp文件中,其中包含必要的类/函数/使用的任何内容,并链接到已编译的.cpp文件(或者.obj文件 - 通常在一个项目中使用 - 或.lib文件 - 通常用于从多个项目重用)。这样,如果仅实现更改,则无需重新编译所有内容。

答案 3 :(得分:6)

将cpp文件视为黑盒子,将.h文件视为如何使用这些黑盒子的指南。

可以提前编译cpp文件。这不适用于你#include他们,因为它需要在每次编译时将代码“包含”到你的程序中。如果只包含标题,则可以使用头文件来确定如何使用预编译的cpp文件。

虽然这对你的第一个项目没什么影响,但如果你开始编写大型cpp程序,人们会讨厌你,因为编译时间会爆炸。

另请阅读:Header File Include Patterns

答案 4 :(得分:6)

头文件通常包含函数/类的声明,而.cpp文件包含实际的实现。在编译时,每个.cpp文件都被编译成一个目标文件(通常是扩展名.o),链接器将各种目标文件组合成最终的可执行文件。链接过程通常比编译快得多。

这种分离的好处:如果要重新编译项目中的一个.cpp文件,则不必重新编译所有其他文件。您只需为该特定.cpp文件创建新的目标文件。编译器不必查看其他.cpp文件。但是,如果要调用当前.cpp文件中的函数,这些函数是在其他.cpp文件中实现的,则必须告诉编译器它们采用了哪些参数;这是包含头文件的目的。

缺点:编译给定的.cpp文件时,编译器无法“看到”其他.cpp文件中的内容。因此,它不知道如何实现这些功能,因此无法积极地进行优化。但我认为你现在还不需要关心(:

答案 5 :(得分:5)

仅包含标头的基本思想和仅编译cpp文件。一旦你有很多cpp文件,这将变得更有用,并且当你只修改其中一个文件时重新编译整个应用程序将会太慢。或者当文件中的函数相互依赖时。因此,您应该将类​​声明分离到头文件中,将实现保留在cpp文件中并编写Makefile(或其他东西,具体取决于您使用的工具)来编译cpp文件并将生成的目标文件链接到程序中。 / p>

答案 6 :(得分:3)

如果#include程序中其他几个文件中的cpp文件,编译器会尝试多次编译cpp文件,并会产生错误,因为会有多个相同方法的实现。

如果在#included cpp文件中进行编辑,编译将花费更长时间(这对大型项目来说会成为一个问题),然后强制重新编译任何文件#including.

只需将您的声明放入头文件中并包含那些(因为它们本身并不实际生成代码),链接器将使用相应的cpp代码(然后只编译一次)连接声明。

答案 7 :(得分:2)

虽然可以像你一样做,但标准做法是将共享声明放入头文件(.h),将函数和变量的定义 - 实现 - 放入源文件(.cpp)。

作为一种惯例,这有助于明确所有内容的位置,并明确区分模块的接口和实现。这也意味着你永远不必检查一个.cpp文件是否包含在另一个文件中,然后向它添加一些东西,如果它是在几个不同的单位中定义的话可能会破坏。

答案 8 :(得分:2)

可重用性,架构和数据封装

这是一个例子:

假设您创建了一个cpp文件,其中包含一个简单形式的字符串例程,所有这些都在类mystring中,您将类decl放在mystring.h中,将mystring.cpp编译为.obj文件

现在在你的主程序(例如main.cpp)中包含头文件和mystring.obj的链接。 在你的程序中使用mystring你不关心细节如何实现mystring,因为标题是它可以做什么

现在,如果一个伙伴想要使用你的mystring类,你给他mystring.h和mystring.obj,他也不一定需要知道它是如何工作的。只有它工作。

稍后如果你有更多这样的.obj文件,你可以将它们组合成.lib文件并链接到它。

您还可以决定更改mystring.cpp文件并更有效地实现它,这不会影响您的main.cpp或您的好友程序。

答案 9 :(得分:2)

如果它适合你,那么它没有任何问题 - 除了它会惹恼那些认为只有一种方法可以做事的人的羽毛。

这里给出的许多答案都解决了大型软件项目的优化问题。这些都是值得了解的好事,但优化小项目就好像它是一个大型项目 - 这就是所谓的“过早优化”。根据您的开发环境,设置构建配置以支持每个程序的多个源文件可能会带来很大的额外复杂性。

如果随着时间的推移,您的项目不断发展并且您发现构建过程花费的时间太长,那么然后您可以refactor使用多个源文件来实现更快的增量构建。

有几个答案讨论了将接口与实现分离。但是,这不是包含文件的固有特性,#include直接包含其实现的“头”文件是很常见的(即使C ++标准库在很大程度上也是如此)。

关于你所做的事情,唯一真正“非常规”的是命名你所包含的文件“.cpp”而不是“.h”或“.hpp”。

答案 10 :(得分:1)

编译和链接程序时,编译器首先编译各个cpp文件,然后链接(连接)它们。除非首先包含在cpp文件中,否则标题永远不会被编译。

通常标头是声明,cpp是实现文件。在标题中,您可以为类或函数定义接口,但是忽略了实际实现细节的方式。这样,如果在一个cpp文件中进行更改,则不必重新编译每个cpp文件。

答案 11 :(得分:1)

我建议你通过Large Scale C++ Software Design by John Lakos。在大学里,我们通常会编写小项目,在这些项目中我们不会遇到这些问题。本书强调了分离接口和实现的重要性。

头文件通常具有不应经常更改的接口。 类似地,查看Virtual Constructor idiom等模式将帮助您进一步掌握这一概念。

我仍然像你一样学习:)

答案 12 :(得分:1)

就像写一本书一样,你只想打印出完成的章节

假设你正在写一本书。如果您将章节放在单独的文件中,那么只需更改章节即可打印出章节。在一章上工作不会改变任何其他章节。

但是从编译器的角度来看,包括cpp文件就像在一个文件中编辑本书的所有章节一样。然后,如果你改变它,你必须打印整本书的所有页面,以便打印修改后的章节。目标代码生成中没有“打印选定页面”选项。

回到软件:我有Linux和Ruby src。粗略衡量代码行......

     Linux       Ruby
   100,000    100,000   core functionality (just kernel/*, ruby top level dir)
10,000,000    200,000   everything 

这四个类别中的任何一个都有很多代码,因此需要模块化。这种代码库是真实世界系统的典型代码。

答案 13 :(得分:-1)

有时候,非常规编程技术实际上非常有用,可以解决其他困难(如果不是不可能的问题)。

如果 C 源代码是由 lexx 和 yacc 等第三方应用程序生成的,它们显然可以单独编译和链接,这是通常的方法。

然而,有时这些来源会导致与其他不相关来源的链接问题。如果发生这种情况,您有一些选择。重写冲突的组件以适应 lexx 和 yacc 源。修改 lexx 和 yacc 组件以适应您的来源。 '#Include' 需要的 lexx 和 yacc 源。

如果更改很小并且组件被理解为开始时(即:您不移植其他人的代码),则重写组件是好的。

只要构建过程不不断从 lexx 和 yacc 脚本重新生成源代码,就可以修改 lexx 和 yacc 源代码。 如果您觉得需要,您可以随时恢复到其他两种方法中的一种。

添加单个 #include 并修改 makefile 以删除 lexx/yacc 组件的构建以解决您的所有问题,这很快很有吸引力,并为您提供了证明代码完全有效的机会,而无需花费时间重写代码并询问是否如果代码现在不起作用,它本来可以运行的。

当两个 C 文件一起包含时,它们基本上是一个文件,链接时不需要解析外部引用!