为什么 C++ 有头文件和 .cpp 文件?
9 回答
C++ 编译
C++ 中的编译分两个主要阶段完成:
第一个是将“源”文本文件编译为二进制“目标”文件:CPP 文件是编译后的文件,并且在不了解其他 CPP 文件(甚至库)的情况下进行编译,除非通过原始声明或标题包含。CPP 文件通常编译成 .OBJ 或 .O“对象”文件。
第二个是将所有“对象”文件链接在一起,从而创建最终的二进制文件(库或可执行文件)。
HPP 在所有这些过程中的位置如何?
一个可怜的孤独的CPP文件......
每个 CPP 文件的编译独立于所有其他 CPP 文件,这意味着如果 A.CPP 需要在 B.CPP 中定义的符号,例如:
// A.CPP
void doSomething()
{
doSomethingElse(); // Defined in B.CPP
}
// B.CPP
void doSomethingElse()
{
// Etc.
}
它不会编译,因为 A.CPP 无法知道“doSomethingElse”的存在......除非 A.CPP 中有声明,例如:
// A.CPP
void doSomethingElse() ; // From B.CPP
void doSomething()
{
doSomethingElse() ; // Defined in B.CPP
}
然后,如果您有使用相同符号的 C.CPP,则复制/粘贴声明...
复制/粘贴警告!
是的,有问题。复制/粘贴是危险的,并且难以维护。这意味着如果我们有办法不复制/粘贴,并且仍然声明符号会很酷......我们该怎么做?通过包含一些文本文件,通常以 .h、.hxx、.h++ 或我更喜欢的 C++ 文件后缀 .hpp 为后缀:
// B.HPP (here, we decided to declare every symbol defined in B.CPP)
void doSomethingElse() ;
// A.CPP
#include "B.HPP"
void doSomething()
{
doSomethingElse() ; // Defined in B.CPP
}
// B.CPP
#include "B.HPP"
void doSomethingElse()
{
// Etc.
}
// C.CPP
#include "B.HPP"
void doSomethingAgain()
{
doSomethingElse() ; // Defined in B.CPP
}
如何include
工作?
从本质上讲,包含文件将解析其内容,然后将其内容复制粘贴到 CPP 文件中。
例如,在以下代码中,带有 A.HPP 标头:
// A.HPP
void someFunction();
void someOtherFunction();
...来源B.CPP:
// B.CPP
#include "A.HPP"
void doSomething()
{
// Etc.
}
... 包含后将变为:
// B.CPP
void someFunction();
void someOtherFunction();
void doSomething()
{
// Etc.
}
一件小事——为什么在 B.CPP 中包含 B.HPP?
在当前情况下,这不是必需的,B.HPP 有doSomethingElse
函数声明,B.CPP 有doSomethingElse
函数定义(它本身就是一个声明)。但在更一般的情况下,B.HPP 用于声明(和内联代码),可能没有相应的定义(例如,枚举、普通结构等),因此如果 B.CPP 可能需要包含使用 B.HPP 的那些声明。总而言之,默认情况下包含其标题的源是“好品味”。
结论
因此头文件是必要的,因为 C++ 编译器无法单独搜索符号声明,因此,您必须通过包含这些声明来帮助它。
最后一句话:你应该在你的HPP文件的内容周围放置标题保护,以确保多个包含不会破坏任何东西,但总而言之,我相信上面解释了HPP文件存在的主要原因。
#ifndef B_HPP_
#define B_HPP_
// The declarations in the B.hpp file
#endif // B_HPP_
甚至更简单(虽然不是标准的)
#pragma once
// The declarations in the B.hpp file
好吧,主要原因是为了将接口与实现分开。头文件声明了一个类(或任何正在实现的)将做什么,而 cpp 文件定义了它将如何执行这些功能。
这减少了依赖关系,因此使用标头的代码不一定需要知道实现的所有细节以及仅需要的任何其他类/标头。这将减少编译时间以及当实现中的某些内容发生更改时所需的重新编译量。
它并不完美,您通常会求助于Pimpl Idiom之类的技术来正确分离接口和实现,但这是一个好的开始。
因为这个概念起源的 C 语言已经有 30 年的历史了,在当时,它是唯一可行的将多个文件中的代码链接在一起的方法。
今天,这是一个可怕的 hack,它完全破坏了 C++ 中的编译时间,导致无数不必要的依赖(因为头文件中的类定义暴露了太多关于实现的信息)等等。
因为在 C++ 中,最终的可执行代码不携带任何符号信息,它或多或少是纯机器码。
因此,您需要一种方法来描述一段代码的接口,它与代码本身是分开的。这个描述在头文件中。
因为 C++ 从 C 继承了它们。不幸的是。
因为设计库格式的人不想“浪费”空间来存储很少使用的信息,例如 C 预处理器宏和函数声明。
由于您需要该信息来告诉您的编译器“此函数稍后在链接器执行其工作时可用”,因此他们必须提出第二个文件来存储此共享信息。
C/C++ 之后的大多数语言将此信息存储在输出中(例如 Java 字节码),或者它们根本不使用预编译格式,总是以源代码形式分发并即时编译(Python、Perl)。
它是声明接口的预处理器方式。您将接口(方法声明)放入头文件,并将实现放入 cpp。使用你的库的应用程序只需要知道他们可以通过#include 访问的接口。
通常你会想要一个接口的定义,而不必发布整个代码。例如,如果您有一个共享库,您将附带一个头文件,其中定义了共享库中使用的所有函数和符号。如果没有头文件,您将需要发布源代码。
在单个项目中,使用头文件,恕我直言,至少有两个目的:
- 清晰,即通过将接口与实现分开,更容易阅读代码
- 编译时间。通过在可能的情况下仅使用接口而不是完整的实现,可以减少编译时间,因为编译器可以简单地对接口进行引用,而不必解析实际代码(理想情况下,只需要完成一次)。
这减少了依赖关系,因此使用标头的代码不一定需要知道实现的所有细节以及仅需要的任何其他类/标头。这将减少编译时间,以及当实现中的某些内容发生更改时所需的重新编译量。
另一个原因是标题为每个类提供了唯一的 id。
所以如果我们有类似的东西
class A {..};
class B : public A {...};
class C {
include A.cpp;
include B.cpp;
.....
};
当我们尝试构建项目时,我们会遇到错误,因为 A 是 B 的一部分,使用标题我们可以避免这种头痛......