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


147

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

回顾一下我的代码,我选择不为我的类创建头文件,而是在cpp文件中完成所有操作(如果没有头文件,它似乎可以正常工作...)。我猜这是一年级学生的意思,是我写了“ #include“ mycppfile.cpp”;“ 在我的一些文件中。

#include对cpp文件进行处理的原因是:-应该放在头文件中的所有内容都在我的cpp文件中,所以我假装它像头文件一样-以monkey-see-monkey do fashion的方式,我看到了其他头文件被#include放在文件中,所以我对cpp文件也做了同样的操作。

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


36
这是一个非常好的问题。我希望很多C ++新手都会对此有所帮助。
米娅·克拉克

Answers:


174

据我所知,C ++标准不了解头文件和源文件之间的区别。就语言而言,任何具有合法代码的文本文件都与其他文件相同。但是,尽管这不是非法的,但是将源文件包含在程序中将几乎消除您将源文件分离开来所具有的任何优势。

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

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

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

“哦,不!听起来太可怕了!但是我能阻止这种可怕的命运吗?!” 不幸的是,您对此无能为力。如果要花费几个小时进行编译,则需要花费数小时进行编译。但这只是第一次真正重要-一旦编译一次,就没有理由再次编译。

除非您更改某些内容。

现在,如果您有200万行代码合并到一个巨型庞然大物中,并且需要做一个简单的bug修复,例如x = y + 1,这意味着您必须再次编译全部200万行才能进行测试。而且,如果您发现自己打算x = y - 1改行,那么再次,两百万行的编译正在等待您。那浪费了很多时间,可能会花更多的时间做其他事情。

“但是我讨厌效率低下!如果只有一种方法可以分别编译我的代码库的不同部分,然后以某种方式它们链接在一起!” 从理论上讲是个好主意。但是,如果您的程序需要知道其他文件中发生了什么,该怎么办?除非您要运行一堆微小的.exe文件,否则不可能完全分离您的代码库。

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

嗯 您可能正在那里找东西。让我知道如何为您解决问题。


13
好的,先生。读起来很有趣,而且很容易理解。我希望我的教科书是这样写的。
ialm

@veol搜索Head First系列书籍-但是我不知道它们是否具有C ++版本。headfirstlabs.com
Amarghosh

2
这是(肯定的)迄今为止我所听到或考虑过的最好的措辞。贾斯汀·凯斯(Justin Case)是一位经验丰富的初学者,获得了一个尚未交付的敲击一百万次的项目,并且值得称赞的“第一个项目”在真正的用户群中看到了应用程序的光辉,他已经意识到闭包解决了一个问题。听起来与OP的原始问题定义的高级状态非常相似,减去了“将其编码了近一百次,并且在没有使用异常编程的情况下,无法弄清楚null(作为无对象)与null(作为侄子)的区别”。
尼古拉斯·乔丹,

当然,对于模板来说,这一切都是分散的,因为大多数编译器不支持/实现'export'关键字。
KitsuneYMG,2009年

1
另一点是,您拥有许多仅使用标头类的最先进的库(如果想到BOOST的话)……Ho,等等?为什么经验丰富的程序员不将接口与实现分开?答案的一部分可能是Blindly所说的,另一部分可能是一个文件比两个文件好,而另一部分是链接的开销可能会很高。我已经看到,在直接包含源代码和优化编译器的情况下,程序运行速度提高了十倍。因为链接主要阻碍优化。
kriss

45

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

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

其背后的思想是,函数和类/结构成员函数仅编译和组装一次,然后其他函数可以从一个位置调用该代码而无需重复。您的函数隐式声明为“外部”。

/* 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;
}

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

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

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

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

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

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

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

同样,这绝对比您要求的要多,但是我希望具体细节可以帮助您看到更大的图景。


2
感谢您的详尽解释。我承认,这对我来说还不是很有意义,我想我需要再读一遍你的答案。
ialm

1
+1是一个很好的解释。太糟糕了,它可能会吓跑所有C ++新手。:)
goldPseudo

1
呵呵,不要感到难受。在堆栈溢出时,最长的答案很少是最佳答案。

int add(int, int);是一个函数声明。它的原型部分就是int, int。但是,C ++中的所有函数都有一个原型,因此该术语仅在C中才有意义。我已编辑了您对此效果的答案。
melpomene

export模板的for模板已在2011年从该语言中删除。编译器从未真正支持它。
melpomene

10

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


6

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

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

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

也请阅读以下内容:头文件包含模式


感谢您提供更具体的示例。我尝试阅读您的链接,但现在我很困惑...显式包括标头和正向声明之间有什么区别?
ialm

这是一篇很棒的文章。Veol,这里包含头文件,编译器需要这些头文件有关类大小的信息。仅使用指针时使用前向声明。
pankajt

前向声明:int someFunction(int neededValue); 注意类型信息的使用,并且(通常)没有大括号。如所给出的,它告诉编译器在某个时候您将需要一个带有int并返回int的函数,编译器可以使用此信息为其保留一个调用。这将称为前向声明。Fancier编译器应该能够在不需要此功能的情况下找到函数,包括标头可能是声明一堆前向声明的便捷方法。
尼古拉斯·乔丹,

6

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

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

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


5

基本仅包含标头且仅编译cpp文件的基本思想。一旦拥有许多cpp文件,此功能将变得更加有用,而仅修改其中一个文件时重新编译整个应用程序将太慢。或者何时文件中的功能将彼此依赖。因此,您应该将类​​声明分隔到头文件中,将实现保留在cpp文件中,并编写Makefile(或其他方式,具体取决于您使用的是什么工具)来编译cpp文件并将结果对象文件链接到程序中。


3

如果您在程序中的其他多个文件中#include一个cpp文件,则编译器将尝试多次编译cpp文件,并且由于相同方法的多个实现,因此会产生错误。

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

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


因此,除了要更长的编译时间之外,当我#cpp文件包含在许多使用包含的cpp文件中的功能的不同文件中时,我还会遇到问题?
ialm

是的,这称为名称空间冲突。这里有趣的是,针对库的链接是否会引入名称空间问题。总的来说,我发现编译器在翻译单元范围(全部在一个文件中)中产生了更好的编译时间,这会引入名称空间问题-导致再次分离....您可以在每个翻译单元中包含include文件(应该)甚至有一个pragma(一次#pragma)可以强制执行此操作,但这是一个栓剂假设。请注意,不要在没有强制执行32位链接的地方盲目依赖lib(.O文件)。
尼古拉斯·约旦,

2

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

按照惯例,这有助于弄清楚所有内容在哪里,并在模块的接口和实现之间进行清楚的区分。这也意味着您无需再检查.cpp文件是否包含在另一个文件中,然后再向其中添加某些内容(如果以几个不同的单位定义该文件可能会损坏)。


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或伙伴程序。


2

如果它对您有用,那么它没什么问题-只是它会打乱那些认为只有一种做事方法的人的羽毛。

此处给出的许多答案都针对大型软件项目的优化。这些都是要了解的好东西,但是像小型项目一样优化小型项目毫无意义-这就是所谓的“过早优化”。根据您的开发环境,在设置构建配置以支持每个程序多个源文件时,可能会涉及很多额外的复杂性。

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

几个答案讨论了将接口与实现分开。但是,这不是包含文件的固有功能,直接包含其实现的#include“标头”文件是很常见的(即使C ++标准库也可以做到这一点)。

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


1

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

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


如果您将实现保留在头文件之外,对不起,但这听起来像Java接口,对吗?
gansub

1

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

头文件通常具有不应被频繁更改的接口。同样,深入研究诸如Virtual Constructor惯用语之类的模式将有助于您进一步理解该概念。

我仍然像你一样学习:)


谢谢你的建议。我不知道我是否会进入制作大规模C ++程序的阶段……
ialm

编写大型程序很有趣,而且面临许多挑战。我开始喜欢它:)
pankajt

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 

这四个类别中的任何一个都有很多代码,因此需要模块化。这种代码库出人意料地是实际系统中的典型代码。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.