为什么我不应该包括 cpp 文件,而是使用一个头?

因此,我完成了我的第一个 C + + 编程作业,并获得了我的成绩。但是根据分数,我失去了 including cpp files instead of compiling and linking them的分数。我不太清楚那是什么意思。

回头看看我的代码,我选择不为我的类创建头文件,而是在 cpp 文件中完成所有工作(没有头文件,它似乎工作得很好...)。我猜,评分器意味着我在一些文件中写了’# include“ mycppfile.cpp”;’。

我对 #include化 cpp 文件的理由是: 所有应该进入头文件的东西都在我的 cpp 文件里,所以我假装它是一个头文件 - 猴子看猴子做时尚,我看到其他头文件的 #include的文件,所以我做了同样的对我的 cpp 文件。

我到底做错了什么,为什么不好?

38811 次浏览

基本思想是只包含头文件,只编译 cpp 文件。一旦您有许多 cpp 文件,这将变得更加有用,并且当您只修改其中一个文件时,重新编译整个应用程序将会太慢。或者文件中的函数什么时候开始相互依赖。因此,您应该将类声明分离到头文件中,将实现保留在 cpp 文件中,并编写一个 Makefile (或者其他东西,取决于您使用的工具)来编译 cpp 文件,并将结果对象文件链接到一个程序中。

典型的解决方案是仅对声明使用 .h文件,对实现使用 .cpp文件。如果您需要重用实现,您需要将相应的 .h文件包含到使用必要的 class/function/whatever 的 .cpp文件中,并链接到已经编译的 .cpp文件(通常在一个项目中使用的 .obj文件)或。Lib 文件-通常用于从多个项目重用)。这样,只要实现发生变化,就不需要重新编译所有内容。

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

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

可以将 cpp 文件看作一个黑盒,而。H 文件作为如何使用这些黑盒的指南。

Cpp 文件可以提前编译。这在你 # include them 中不起作用,因为它需要在每次编译代码的时候实际“包含”代码到你的程序中。如果只包含头文件,那么它只需使用头文件来确定如何使用预编译的 cpp 文件。

虽然这不会对您的第一个项目产生多大影响,但是如果您开始编写大型 cpp 程序,人们将会讨厌您,因为编译时间将会爆炸。

也有一个这样的阅读: 头文件包含模式

如果您在程序中的其他几个文件中包含一个 cpp 文件,编译器将尝试多次编译该 cpp 文件,并将生成一个错误,因为将有多个相同方法的实现。

如果在 # include cpp 文件中进行编辑,然后强制重新编译包含它们的任何文件 # ,编译将花费更长的时间(这在大型项目中会成为一个问题)。

只需将声明放入头文件并包含它们(因为它们本身并不生成代码) ,链接器就会将声明与相应的 cpp 代码连接起来(这些代码只编译一次)。

尽管像您这样做当然是可能的,但标准实践是将共享声明放入头文件(。H) ,以及函数和变量的定义-实现-到源文件(。Cpp).

作为一种约定,这有助于清楚地说明所有内容在哪里,并明确区分模块的接口和实现。它还意味着您永远不必检查。Cpp 文件包含在另一个文件中,然后向其中添加一些内容,如果它是在几个不同的单元中定义的,那么这些内容可能会中断。

头文件通常包含函数/类的声明,而。Cpp 文件包含实际的实现。在编译时,每个。Cpp 文件被编译成一个对象文件(通常是扩展名。O) ,链接器将不同的目标文件组合到最终的可执行文件中。链接过程通常比编译快得多。

此分离的好处: 如果正在重新编译。在您的项目中,您不必重新编译所有其他 cpp 文件。您只需为该特定对象创建新的对象文件。Cpp 文件。编译器不需要查看另一个。Cpp 文件。但是,如果要调用当前。中实现的 cpp 文件。Cpp 文件时,必须告诉编译器它们采用什么参数; 这就是包含头文件的目的。

缺点: 在编译给定的。Cpp 文件中,编译器无法“看到”另一个文件中的内容。Cpp 文件。所以它不知道这些函数是如何实现的,因此不能积极地进行优化。但我认为你现在还不需要担心这个问题(:

可重用性、体系结构和数据封装

举个例子:

假设您创建了一个 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 或好友程序。

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

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

其背后的思想是,函数和 class/struct 成员函数只编译和汇编一次,然后其他函数可以从一个地方调用该代码,而不会产生重复。函数被隐式声明为“ extern”。

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


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

如果希望某个函数对于翻译单元是本地的,可以将其定义为“ static”。这是什么意思?这意味着,如果将源文件包含在外部函数中,就会出现重定义错误,因为编译器不止一次地遇到相同的实现。所以,你希望你所有的翻译单元看到的是 函数声明而不是 功能体

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

有两个明显的例外。首先,如果你有一个小函数,你可以让它内联。这意味着生成的机器代码不生成外部函数调用,而是就地连接。因为它们通常很小,所以开销的大小并不重要。你可以想象它们的工作方式是静态的。因此,在报头中实现内联函数是安全的。类或结构定义中的函数实现通常也由编译器自动内联。

另一个例外是模板。由于编译器在实例化它们时需要看到整个模板类型定义,因此不可能像使用独立函数或普通类那样将实现与定义解耦。好吧,也许现在这是可能的,但是为“ export”关键字获得广泛的编译器支持花了很长很长的时间。因此,如果不支持“ export”,翻译单元将获得实例化模板类型和函数的本地副本,类似于内联函数的工作方式。在支持“出口”的情况下,情况并非如此。

对于这两个异常,有些人认为将内联函数、模板函数和模板类型的实现放入。Cpp 文件,然后 # 包含。Cpp 文件。这是一个头文件还是一个源文件并不重要; 预处理器并不关心,它只是一个约定。

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

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

再说一次,这肯定比你要求的要多,但我希望细节能帮助你看到更大的图景。

据我所知,C + + 标准并不知道头文件和源文件之间有什么区别。就语言而言,任何带有法律代码的文本文件都与其他文件相同。然而,尽管不是非法的,但是将源文件包含到程序中将基本上消除您从最初分离源文件中获得的任何优势。

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

“哦,这没什么大不了的。如果它跑了,就没事了。”我听到你哭了。从某种意义上说,你是对的。但是现在您要处理的是一个非常小的程序,以及一个不错的、相对不受阻碍的 CPU 来为您编译它。你不会一直这么幸运的。

如果你曾经深入研究过严肃的计算机编程领域,你会看到一些项目的行数可以达到几百万,而不是几十个。好多台词。如果你试图在一台现代的桌面电脑上编译一个这样的程序,它可能需要几个小时而不是几秒钟。

“哦,不!听起来太可怕了!我怎样才能阻止这种可怕的命运? !”不幸的是,你对此无能为力。如果需要几个小时来编译,那么就需要几个小时来编译。但这只在第一次才真正重要——一旦编译了一次,就没有理由再次编译了。

除非你改变什么。

现在,如果你有两百万行代码合并成一个庞然大物,并且需要做一个简单的 bug 修复,比如说,x = y + 1,这意味着你必须重新编译所有的两百万行代码来测试这个。如果你发现你打算做一个 x = y - 1代替,然后再次,两百万行编译正在等待你。那么多的时间浪费在做其他事情上会更好。

“但我讨厌没有效率!如果我的代码库中能有一些独立的 编译部分,然后以某种方式将它们整合在一起就好了!”理论上来说,这是个好主意。但是,如果您的程序需要知道在另一个文件中发生了什么,该怎么办呢?要完全分离代码库是不可能的,除非您想要运行一组极小的。而不是 exe 文件。

“但这肯定是可能的!不然的话,编程听起来就是纯粹的折磨!如果我找到分离 实现的接口的方法呢?比如说,从这些不同的代码段中获取足够的信息,将它们识别为程序的其余部分,然后将它们放入某种类型的 标题文件中?这样,我就可以使用 #include 预处理器指令只带来必要的信息进行编译!”

嗯,你可能说到点子上了,告诉我结果如何。

我会建议你通过 John Lakos 设计的大规模 C + + 软件。在大学里,我们通常写一些小项目,这样我们就不会遇到这样的问题。这本书强调了分离接口和实现的重要性。

头文件通常具有不应该如此频繁地更改的接口。 类似地,研究类似于 VirtualConstruction 习惯用法的模式将帮助您进一步理解这个概念。

我仍然像你一样在学习:)

这就像写一本书,你想打印完成的章节只有一次

假设你正在写一本书。如果你把章节放在单独的文件,那么你只需要打印出一个章节,如果你已经改变了它。写一章不会改变其他章节。

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

回到软件: 我手头有 Linux 和 Ruby src。

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

这四个类别中的任何一个都有大量代码,因此需要模块化。这种类型的代码库在现实系统中非常典型。

如果它对你有效,那么它就没有什么问题——除了它会激怒那些认为只有一种方法做事的人。

这里给出的许多答案都涉及到大型软件项目的优化。这些都是值得了解的好东西,但是把一个小项目当作一个大项目来优化是没有意义的——这就是所谓的“过早优化”。根据开发环境的不同,设置构建配置以支持每个程序的多个源文件可能会有显著的额外复杂性。

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

有几个答案讨论了将接口与实现分离。然而,这并不是 include 文件的固有特性,而且很常见的情况是 # include“ header”文件直接合并了它们的实现(即使是 C++标准程式库也在很大程度上做到了这一点)。

唯一真正“非常规”的是命名所包含的文件。“代替”。或者“ H”。Hpp”。

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

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

然而,有时这些源可能会导致与其他不相关源的链接问题。如果发生这种情况,您有一些选择。重写冲突的组件以适应 lexx 和 yacc 源。修改 lexx 和 yacc 组件以适应您的源代码。“ # 包含”所需的 lexx 和 yacc 源代码。

如果更改很小,而且组件一开始就可以理解,那么重新编写组件是可以的(例如: 您没有移植其他人的代码)。

只要构建过程不继续从 lexx 和 yacc 脚本重新生成源代码,修改 lexx 和 yacc 源代码就可以。 如果您觉得有必要,总是可以恢复到另外两个方法之一。

添加一个 # include 并修改 makefile 来删除 lexx/yacc 组件的构建,以克服所有问题,这样做很有吸引力,而且可以让你有机会证明代码完全可以工作,而不用花时间重写代码,也不用考虑当代码现在不能工作的时候,代码是否可以工作。

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