为啥我不应该包含 cpp 文件而使用标头?
Posted
技术标签:
【中文标题】为啥我不应该包含 cpp 文件而使用标头?【英文标题】:Why should I not include cpp files and instead use a header?为什么我不应该包含 cpp 文件而使用标头? 【发布时间】:2010-12-13 18:14:08 【问题描述】:所以我完成了我的第一个 C++ 编程作业并获得了我的成绩。但是根据评分,我失去了including cpp files instead of compiling and linking them
的分数。我不太清楚这意味着什么。
回顾一下我的代码,我选择不为我的类创建头文件,而是在 cpp 文件中做了所有的事情(如果没有头文件,它似乎可以正常工作......)。我猜评分员的意思是我写了'#include“mycppfile.cpp”;'在我的一些文件中。
我#include
'ing cpp 文件的理由是:
- 应该进入头文件的所有内容都在我的cpp文件中,所以我假装它就像一个头文件
- 在monkey-see-monkey do fashion中,我看到文件中的其他头文件是#include
'd,所以我对我的cpp文件做了同样的事情。
那么我到底做错了什么,为什么不好?
【问题讨论】:
这是一个非常好的问题。我希望很多 C++ 新手会从中得到帮助。 相关: How can I avoid including class implementation files? 【参考方案1】:据我所知,C++ 标准不知道头文件和源文件之间的区别。就语言而言,任何带有合法代码的文本文件都是一样的。然而,虽然不违法,但将源文件包含到您的程序中几乎会消除您从一开始就分离源文件所获得的任何优势。
基本上,#include
所做的是告诉 预处理器 获取您指定的整个文件,并在 编译器 获取它之前将其复制到您的活动文件中动手吧。因此,当您将项目中的所有源文件包含在一起时,您所做的与仅制作一个巨大的源文件而根本没有任何分离之间根本没有区别。
“哦,这没什么大不了的。如果它运行,那就没问题了,”我听到你哭了。从某种意义上说,你是对的。但是现在您正在处理一个很小的小程序,以及一个不错且相对不受阻碍的 CPU 来为您编译它。你不会总是那么幸运。
如果您曾经深入研究过严肃的计算机编程领域,您会看到项目的行数可以达到数百万,而不是数十。那是很多行。如果您尝试在现代台式计算机上编译其中一个,则可能需要几小时而不是几秒钟。
“哦,不!这听起来很可怕!但是我能阻止这种可怕的命运吗?!”不幸的是,您对此无能为力。如果编译需要几个小时,那么编译需要几个小时。但这只有第一次才真正重要——一旦你编译过一次,就没有理由再次编译它。
除非你改变一些东西。
现在,如果您将 200 万行代码合并为一个庞然大物,并且需要进行简单的错误修复,例如 x = y + 1
,这意味着您必须重新编译所有 200 万行代码来测试一下。如果您发现您打算改为使用x = y - 1
,那么同样有两百万行编译在等着您。那是浪费了很多时间,本可以更好地花在做其他事情上。
“但我讨厌效率低下!如果有办法单独编译我的代码库的不同部分,然后以某种方式链接它们!” 从理论上讲,这是一个绝妙的主意。但是,如果您的程序需要知道不同文件中发生的事情怎么办?除非您想运行一堆微小的 .exe 文件,否则不可能完全分离您的代码库。
“但它肯定是可能的!否则编程听起来像是纯粹的折磨!如果我找到某种方法将接口与实现分开怎么办?比如说从这些不同的代码中获取足够的信息段来向程序的其余部分识别它们,并将它们放在某种 header 文件中?这样,我可以使用#include
预处理器指令只带入编译所需的信息!”
嗯。你可能会在那里做一些事情。让我知道你的效果如何。
【讨论】:
好答案,先生。读起来很有趣,也很容易理解。我希望我的教科书是这样写的。 @veol 搜索 Head First 系列书籍——不过我不知道他们是否有 C++ 版本。 headfirstlabs.com 这是(明确的)迄今为止我听到或想到的最好的措辞。 Justin Case 是一位成功的初学者,他完成了一个击键次数达到 100 万次的项目,但尚未发货,而一个值得称道的“第一个项目”正在看到真正的用户群中的应用,他已经认识到关闭解决的问题。听起来与 OP 的原始问题定义的高级状态非常相似,减去“编码了近一百次,并且在不使用异常编程的情况下无法弄清楚 null(作为无对象)与 null(作为侄子)做什么。” 当然,这一切对于模板来说都是分崩离析的,因为大多数编译器不支持/实现'export'关键字。 另一点是,您有许多最先进的库(如果想到 BOOST),它们只使用标题类......嗬,等等?为什么有经验的程序员不将接口与实现分开?部分答案可能是 Blindly 所说的,另一部分可能是一个文件在可能的情况下优于两个文件,另一部分是链接的成本可能非常高。我已经看到直接包含源代码和编译器优化的程序运行速度快了十倍。因为链接主要是阻止优化。【参考方案2】:这可能是一个比你想要的更详细的答案,但我认为一个体面的解释是合理的。
在 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 格式。在这个阶段,符号仍被标记为未定义。 最后,链接器运行。所有之前的阶段都按顺序在每个翻译单元上运行。但是,链接器阶段适用于由汇编器生成的所有生成的目标文件。链接器解析符号并执行许多魔术,例如创建节和段,这取决于目标平台和二进制格式。程序员通常不需要知道这一点,但在某些情况下肯定会有所帮助。同样,这绝对超出了您的要求,但我希望这些细节能帮助您看到更大的图景。
【讨论】:
感谢您的详尽解释。我承认,这对我来说还不是很有意义,我想我需要一遍又一遍地阅读你的答案。 +1 以获得出色的解释。太糟糕了,它可能会吓跑所有 C++ 新手。 :) 嘿,别难过。在 Stack Overflow 上,最长的答案很少是最好的答案。int add(int, int);
是一个函数声明。它的 prototype 部分就是int, int
。但是,C++ 中的所有函数都有一个原型,所以这个术语真的只在 C 中有意义。我已经编辑了你对这个效果的答案。
export
for templates 已于 2011 年从语言中删除。编译器从未真正支持它。【参考方案3】:
典型的解决方案是仅使用.h
文件进行声明,使用.cpp
文件进行实施。如果您需要重用实现,请将相应的.h
文件包含到.cpp
文件中,其中使用了必要的类/函数/任何内容,并链接到已编译的.cpp
文件(.obj
文件-通常在一个项目中使用 - 或 .lib 文件 - 通常用于从多个项目中重用)。这样,如果只有实现发生变化,您就不需要重新编译所有内容。
【讨论】:
【参考方案4】:将 cpp 文件视为一个黑匣子,将 .h 文件视为如何使用这些黑匣子的指南。
cpp 文件可以提前编译。这在您#include 它们中不起作用,因为它需要在每次编译时将代码实际“包含”到您的程序中。如果只是包含头文件,则可以只使用头文件来确定如何使用预编译的cpp文件。
虽然这对您的第一个项目没有太大影响,但如果您开始编写大型 cpp 程序,人们会讨厌您,因为编译时间会爆炸式增长。
还可以阅读以下内容:Header File Include Patterns
【讨论】:
感谢您提供更具体的示例。我尝试阅读您的链接,但现在我很困惑......显式包含标题和前向声明有什么区别? 这是一篇很棒的文章。 Veol,在这里它们包括编译器需要有关类大小信息的标头。仅使用指针时使用前向声明。 前向声明:int someFunction(int requiredValue);注意类型信息的使用和(通常)没有花括号。如给定的那样,这告诉编译器在某些时候您将需要一个接受 int 并返回 int 的函数,编译器可以使用此信息为其保留调用。这将被称为前向声明。更高级的编译器应该能够在不需要它的情况下找到函数,包括头文件可以是声明一堆前向声明的便捷方式。【参考方案5】:头文件通常包含函数/类的声明,而 .cpp 文件包含实际的实现。在编译时,每个 .cpp 文件都被编译成一个目标文件(通常扩展名为 .o),链接器将各种目标文件组合成最终的可执行文件。链接过程通常比编译快得多。
这种分离的好处:如果您要重新编译项目中的一个 .cpp 文件,则不必重新编译所有其他文件。您只需为该特定 .cpp 文件创建新的目标文件。编译器不必查看其他 .cpp 文件。但是,如果您想调用当前 .cpp 文件中在其他 .cpp 文件中实现的函数,则必须告诉编译器它们采用什么参数;这就是包含头文件的目的。
缺点:在编译给定的 .cpp 文件时,编译器无法“看到”其他 .cpp 文件中的内容。所以它不知道那里的功能是如何实现的,因此无法进行积极的优化。但我认为你现在还不需要担心这一点(:
【讨论】:
【参考方案6】:只包含头文件和只编译cpp文件的基本思想。一旦您有许多 cpp 文件,这将变得更加有用,并且当您仅修改其中一个时重新编译整个应用程序将太慢。或者文件中的功能何时启动取决于彼此。因此,您应该将类声明分离到头文件中,将实现保留在 cpp 文件中并编写 Makefile(或其他内容,具体取决于您使用的工具)来编译 cpp 文件并将生成的目标文件链接到程序中。
【讨论】:
【参考方案7】:如果你在程序的其他几个文件中#include一个cpp文件,编译器会尝试多次编译cpp文件,并且会产生一个错误,因为相同的方法会有多个实现。
如果您在#included cpp 文件中进行编辑,编译将花费更长的时间(这成为大型项目的问题),然后强制重新编译所有文件#include 它们。
只需将您的声明放入头文件并包含这些文件(因为它们本身实际上并不生成代码),链接器会将声明与相应的 cpp 代码挂钩(然后只编译一次)。
【讨论】:
那么,除了编译时间更长之外,当我将我的 cpp 文件#include 到许多使用包含的 cpp 文件中的函数的不同文件中时,我会开始遇到问题吗? 是的,这称为命名空间冲突。这里感兴趣的是链接到库是否会引入命名空间问题。一般来说,我发现编译器为翻译单元范围(全部在一个文件中)产生了更好的编译时间,这引入了命名空间问题 - 这导致再次分离......您可以在每个翻译单元中包含包含文件,(应该)甚至还有一个编译指示( #pragma once )应该强制执行此操作,但这是一个栓剂假设。注意不要盲目依赖任何地方的库(.O 文件),因为不强制执行 32 位链接。【参考方案8】:当然可以按照您的做法进行操作,但标准做法是将共享声明放入头文件 (.h),将函数和变量的定义 - 实现 - 放入源文件 (.cpp)。
作为惯例,这有助于明确所有内容的位置,并明确区分模块的接口和实现。这也意味着您无需检查 .cpp 文件是否包含在另一个文件中,然后再向其中添加一些内容,如果它在多个不同的单元中定义可能会破坏。
【讨论】:
【参考方案9】:可重用性、架构和数据封装
这是一个例子:
假设你创建了一个 cpp 文件,其中包含一个简单形式的字符串例程都在一个类 mystring 中,你将这个类 decl 放在一个 mystring.h 中,将 mystring.cpp 编译为一个 .obj 文件
现在在您的主程序(例如 main.cpp)中包含标题并与 mystring.obj 链接。 在你的程序中使用 mystring 你不需要关心细节 如何 mystring 是因为标题说 what 它可以做
现在如果一个伙伴想要使用你的 mystring 类,你给他 mystring.h 和 mystring.obj,他也不一定需要知道它是如何工作的,只要它可以工作。
稍后如果您有更多这样的 .obj 文件,您可以将它们组合成一个 .lib 文件并链接到该文件。
您也可以决定更改 mystring.cpp 文件并更有效地实施它,这不会影响您的 main.cpp 或您的伙伴程序。
【讨论】:
【参考方案10】:如果它对你有用,那么它没有任何问题 - 除了它会激怒那些认为只有一种方法可以做事的人。
这里给出的许多答案都针对大型软件项目的优化。这些都是值得了解的好东西,但是把一个小项目当作一个大项目来优化是没有意义的——这就是所谓的“过早优化”。根据您的开发环境,设置构建配置以支持每个程序的多个源文件可能会涉及显着的额外复杂性。
如果随着时间的推移,您的项目不断发展,而您发现构建过程耗时过长,那么您可以refactor 您的代码使用多个源文件来更快地进行增量构建。 p>
一些答案讨论了将接口与实现分离。然而,这不是包含文件的固有特性,并且直接合并其实现的#include“头”文件很常见(即使是 C++ 标准库也在很大程度上做到了这一点)。
您所做的唯一真正“非常规”的事情是将包含的文件命名为“.cpp”而不是“.h”或“.hpp”。
【讨论】:
【参考方案11】:当您编译和链接程序时,编译器首先编译各个 cpp 文件,然后它们链接(连接)它们。除非先包含在 cpp 文件中,否则标头将永远不会被编译。
通常标头是声明,cpp 是实现文件。在标题中,您为类或函数定义了一个接口,但您忽略了实际实现细节的方式。这样,如果您对一个 cpp 文件进行更改,就不必重新编译每个文件。
【讨论】:
如果您将实现从头文件中排除,请原谅,但这听起来像是一个 Java 接口,对吗?【参考方案12】:我会建议你通过Large Scale C++ Software Design by John Lakos。在大学里,我们通常会写一些不会遇到此类问题的小项目。这本书强调了分离接口和实现的重要性。
头文件通常具有不应该频繁更改的接口。 同样,查看 Virtual Constructor idiom 之类的模式将有助于您进一步掌握这个概念。
我还在像你一样学习:)
【讨论】:
感谢您的图书建议。我不知道我是否会进入制作大型 C++ 程序的阶段...... 编写大型程序和许多挑战很有趣。我开始喜欢它了:)【参考方案13】:就像写一本书,你只想打印完成的章节一次
假设你正在写一本书。如果将章节放在单独的文件中,则只需在更改章节时打印出来。研究一章不会改变其他任何一章。
但是,从编译器的角度来看,包括 cpp 文件就像在一个文件中编辑本书的所有章节一样。然后,如果您更改它,则必须打印整本书的所有页面才能打印修订后的章节。目标代码生成中没有“打印选定页面”选项。
回到软件:我有 Linux 和 Ruby src。粗略测量代码行数...
Linux Ruby
100,000 100,000 core functionality (just kernel/*, ruby top level dir)
10,000,000 200,000 everything
这四个类别中的任何一个都有大量代码,因此需要模块化。这种代码库在现实世界的系统中非常典型。
【讨论】:
【参考方案14】:有时非常规编程技术实际上非常有用并且可以解决其他困难(如果不是不可能的问题)。
如果 C 源代码由 lexx 和 yacc 等第三方应用程序生成,它们显然可以单独编译和链接,这是通常的方法。
但是,有时这些来源可能会导致与其他不相关来源的链接问题。如果发生这种情况,您有一些选择。重写冲突的组件以适应 lexx 和 yacc 源。修改 lexx 和 yacc 组件以适应您的源。 '#Include' 需要它们的 lexx 和 yacc 源。
如果更改很小并且组件从一开始就被理解(即:您没有移植其他人的代码),那么重写组件就可以了。
只要构建过程不继续从 lexx 和 yacc 脚本重新生成源代码,修改 lexx 和 yacc 源代码就可以了。 如果您觉得有必要,您可以随时恢复使用其他两种方法之一。
添加单个 #include 并修改 makefile 以删除 lexx/yacc 组件的构建以解决您的所有问题,这很有吸引力,并且为您提供了证明代码完全有效的机会,而无需花费时间重写代码和询问是否当代码现在不工作时,它本来可以工作的。
当两个 C 文件一起包含时,它们基本上是一个文件,并且不需要在链接时解析外部引用!
【讨论】:
以上是关于为啥我不应该包含 cpp 文件而使用标头?的主要内容,如果未能解决你的问题,请参考以下文章
为啥 Xcode 4 在每个标头中都包含 iostream?