172

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

回顾一下我的代码,我选择不为我的类创建头文件,而是在 cpp 文件中做了所有的事情(如果没有头文件,它似乎可以正常工作......)。我猜评分员的意思是我写了'#include“mycppfile.cpp”;' 在我的一些文件中。

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

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

4

14 回答 14

223

据我所知,C++ 标准不知道头文件和源文件之间的区别。就语言而言,任何具有合法代码的文本文件都与其他文本文件相同。然而,虽然不是非法的,但将源文件包含到您的程序中几乎会消除您从一开始就分离源文件所获得的任何优势。

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

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

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

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

除非你改变什么。

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

“但我讨厌没有生产力!如果有某种方法可以单独编译我​​的代码库的不同部分,然后以某种方式将它们链接在一起!” 一个绝妙的主意,理论上。但是,如果您的程序需要知道不同文件中发生的事情怎么办?除非您想运行一堆微小的 .exe 文件,否则不可能完全分离您的代码库。

“但它肯定是可能的!否则编程听起来像是纯粹的折磨!如果我找到某种方法将接口与实现分开怎么办?比如说从这些不同的代码段中获取足够的信息来识别它们到程序的其余部分,然后把而是将它们放在某种文件中?这样,我可以使用#include 预处理器指令仅引入编译所需的信息!

唔。你可能会在那里做一些事情。让我知道这对你有什么影响。

于 2009-11-06T09:20:53.253 回答
50

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

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

这背后的想法是函数和类/结构成员函数被编译和组装一次,然后其他函数可以从一个地方调用该代码而不会重复。您的函数被隐式声明为“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;
}

如果您希望翻译单元的函数是本地的,则将其定义为“静态”。这是什么意思?这意味着如果你包含带有外部函数的源文件,你会得到重新定义错误,因为编译器不止一次遇到相同的实现。因此,您希望所有翻译单元都看到函数声明,而不是函数体

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

有两个值得注意的例外。首先,如果你有一个小函数,你可以让它内联。这意味着生成的机器代码不会生成外部函数调用,而是就地串联。由于它们通常很小,因此大小开销无关紧要。您可以想象它们的工作方式是静态的。所以在头文件中实现内联函数是安全的。类或结构定义中的函数实现通常也由编译器自动内联。

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

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

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

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

同样,这绝对超出了您的要求,但我希望细节能帮助您看到更大的图景。

于 2009-11-06T09:12:21.287 回答
12

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

于 2009-11-06T08:35:51.367 回答
9

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

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

尽管这对您的第一个项目没有太大影响,但如果您开始编写大型 cpp 程序,人们会讨厌您,因为编译时间会爆炸。

另请阅读:头文件包含模式

于 2009-11-06T08:38:51.407 回答
8

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

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

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

于 2009-11-06T08:41:17.390 回答
5

仅包含头文件和仅编译 cpp 文件的基本思想。一旦您有许多 cpp 文件,这将变得更加有用,并且当您仅修改其中一个时重新编译整个应用程序将太慢。或者文件中的功能何时启动取决于彼此。因此,您应该将类​​声明分离到头文件中,将实现保留在 cpp 文件中并编写 Makefile(或其他内容,具体取决于您使用的工具)来编译 cpp 文件并将生成的目标文件链接到程序中。

于 2009-11-06T08:34:11.557 回答
3

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

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

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

于 2009-11-06T08:38:53.340 回答
2

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

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

于 2009-11-06T08:39:45.113 回答
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 或您的伙伴程序。

于 2009-11-06T09:07:44.603 回答
2

如果它对你有用,那么它没有任何问题——除了它会激怒那些认为只有一种方法可以做事的人。

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

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

一些答案讨论了将接口与实现分开。然而,这不是包含文件的固有特性,而且直接合并其实现的#include“头”文件很常见(甚至 C++ 标准库在很大程度上也这样做了)。

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

于 2009-11-09T17:44:35.197 回答
1

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

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

于 2009-11-06T08:37:40.987 回答
1

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

头文件通常具有不应该频繁更改的接口。同样,查看诸如 Virtual Constructor idiom 之类的模式将帮助您进一步掌握该概念。

我还在像你一样学习:)

于 2009-11-06T12:48:13.397 回答
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 

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

于 2009-11-08T01:03:15.233 回答
-1

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

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

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

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

只要构建过程不继续从 lexx 和 yacc 脚本重新生成源代码,修改 lexx 和 yacc 源代码就可以了。如果您觉得有必要,您可以随时恢复使用其他两种方法之一。

添加单个 #include 并修改 makefile 以删除 lexx/yacc 组件的构建以克服所有问题,这很有吸引力,并且为您提供了证明代码完全有效的机会,而无需花费时间重写代码和询问代码是否会当它现在不工作时,它曾经工作过。

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

于 2021-04-27T05:25:47.823 回答