*************************************************
** 0) 引言 **
*************************************************
本文旨在解决新手在理解 #include、头文件和源文件交互时遇到的常见问题。文章概述并解释了多项良好实践,以展示如何避免一些棘手的陷阱。后面的章节将深入探讨更高级的主题(内联和模板),因此即使是经验丰富的 C++ 程序员也可能从中受益!
如果您已经熟悉基础知识,请随意跳至第 4 部分。那里将讨论实践和设计策略。
*************************************************
** 1) 为什么我们需要头文件。**
*************************************************
如果您刚开始接触 C++,您可能会想,为什么需要 #include 文件,以及为什么要把一个程序分成多个 .cpp 文件。原因很简单:
(1) 它加快了编译时间。随着程序的增长,代码量也会随之增加,如果所有代码都放在一个文件中,那么每次进行微小的改动都需要重新编译整个文件。对于小型程序来说,这可能看起来无关紧要(确实如此),但在处理中等规模的项目时,编译整个程序可能需要“几分钟”的时间。您能想象每次微小的改动都要等这么久吗?
编译/等待 8 分钟/“糟糕,忘了分号”/编译/等待 8 分钟/调试/编译/等待 8 分钟/等等
(2) 它使您的代码更有条理。如果您将不同的概念分离到特定的文件中,当您想进行修改(或只是查看代码以记住如何使用它或它如何工作)时,会更容易找到您要找的代码。
(3) 它允许您将“接口”与“实现”分离。如果您不理解这是什么意思,请不用担心,在本文中我们将贯穿始终地看到它的实际应用。
这些是优点,但显而易见的缺点是,如果您不理解它是如何工作的,它会使事情变得有点复杂(但实际上,随着项目规模的增长,它的复杂度比其他替代方案要低)。
C++ 程序是分两阶段构建的。首先,每个源文件都“独立编译”。编译器为每个已编译的源文件生成中间文件。这些中间文件通常被称为“目标文件”——但不要将其与您代码中的对象混淆。一旦所有文件都已单独编译,然后“链接”所有目标文件,生成最终的可执行文件(程序)。
这意味着“每个源文件都是与其他源文件“分开编译”的”。因此,在编译方面,“a.cpp”并不知道“b.cpp”内部发生了什么。这是一个简单的例子来说明:
1 2 3 4 5 6 7 8 9 10 11 12 13
|
// in myclass.cpp
class MyClass
{
public:
void foo();
int bar;
};
void MyClass::foo()
{
// do stuff
}
|
1 2 3 4 5 6 7
|
// in main.cpp
int main()
{
MyClass a; // Compiler error: 'MyClass' is unidentified
return 0;
}
|
即使 MyClass 在“您的程序”中已声明,但在 main.cpp 中并未声明,因此当您编译 main.cpp 时会收到该错误。
这就是头文件的用武之地。头文件允许您将“接口”(在本例中是 MyClass 类)对其他 .cpp 文件可见,同时将“实现”(在本例中是 MyClass 的成员函数体)保留在其自己的 .cpp 文件中。再看同一个例子,但稍作调整:
1 2 3 4 5 6 7 8
|
// in myclass.h
class MyClass
{
public:
void foo();
int bar;
};
|
1 2 3 4 5 6
|
// in myclass.cpp
#include "myclass.h"
void MyClass::foo()
{
}
|
1 2 3 4 5 6 7 8
|
//in main.cpp
#include "myclass.h" // defines MyClass
int main()
{
MyClass a; // no longer produces an error, because MyClass is defined
return 0;
}
|
#include 语句基本上就像一个复制粘贴操作。当编译器编译您包含的文件时,“替换” #include 行及其包含的文件的实际内容。
***************************************************************
** 2) .h/.cpp/.hpp/.cc/etc 之间的区别 **
***************************************************************
所有文件在本质上都是相同的,因为它们都是文本文件,但是不同类型的文件应该有不同的扩展名。
-
头文件 应使用 .h__ 扩展名(.h / .hpp / .hxx)。使用哪个并不重要。
-
C++ 源文件 应使用 .c__ 扩展名(.cpp / .cxx / .cc)。使用哪个并不重要。
-
C 源文件 应使用 .c(仅 .c)。
这里 C 和 C++ 源文件分开的原因是,它会影响某些编译器。极有可能(既然您在 C++ 网站上阅读此文),您将使用 C++ 代码,因此请避免使用 .c 扩展名。此外,如果您尝试编译头文件扩展名的文件,编译器可能会忽略它们。
那么头文件和源文件有什么区别?基本上,头文件是 #included 而不是被编译,而源文件是被编译而不是 #included。您可以尝试绕过这些约定,让具有源文件扩展名的文件像头文件一样工作,反之亦然,但您不应该这样做。我不会列出许多不应该这样做的原因(除了我已列出的一些原因)——总之,不要这样做。
唯一的例外是,有时(尽管“非常罕见”)包含源文件很有用。这种情况与模板实例化有关,不属于本文的范围。目前……把它记在脑子里:“不要 #include 源文件”。
*****************************************************
** 3) 包含保护 **
*****************************************************
C++ 编译器本身没有智能,所以它们会完全按照您的指示去做。如果您告诉它们多次包含同一个文件,那么它们就会这样做。如果您处理不当,将会导致一些奇怪的错误。
1 2 3 4 5 6
|
// myclass.h
class MyClass
{
void DoSomething() { }
};
|
1 2 3
|
// main.cpp
#include "myclass.h" // define MyClass
#include "myclass.h" // Compiler error - MyClass already defined
|
您可能会想:“这太愚蠢了,我为什么要两次包含同一个文件?”您信不信,这种情况会经常发生。不过,不像上面展示的那样。通常情况下,这是因为您包含了两个文件,而这两个文件又都包含了同一个文件。例如:
1 2 3 4
|
// a.h
#include "x.h"
class A { X x; };
|
1 2 3 4
|
// b.h
#include "x.h"
class B { X x; };
|
1 2 3 4
|
// main.cpp
#include "a.h" // also includes "x.h"
#include "b.h" // includes x.h again! ERROR
|
正是因为这种情况,许多人被告知不要在头文件中放置 #include。然而,
这是错误的建议,您不应该听信它。不幸的是,有些人甚至在他们“付费”的 C++ 课程中被这样“教导”。如果您的 C++ 老师告诉您不要在头文件中 #include,那么就(不情愿地)按照他的指示去做,以便通过课程,但在您离开他的课程后,请戒掉这个习惯。
事实是,在头文件中 #include 并没有什么问题——事实上,它非常有益。
前提是您要采取两项预防措施。
1) 只 #include 您“需要”包含的内容(下一节介绍)。
2) 使用包含保护来防止意外的多次包含。
包含保护是一种技术,它使用一个唯一的标识符,您在文件顶部 #define 它。例如:
1 2 3 4 5 6 7 8
|
//x.h
#ifndef __X_H_INCLUDED__ // if x.h hasn't been included yet...
#define __X_H_INCLUDED__ // #define this so the compiler knows it has been included
class X { };
#endif
|
如果头文件第一次被包含,`__X_H_INCLUDED__` 就会被 #define,并且在第二次包含 x.h 时,编译器会跳过该头文件,因为 `#ifndef` 检查将失败。
始终 保护您的头文件。永远,永远,永远。这样做没有任何坏处,而且可以避免一些麻烦。在本文的其余部分,假设所有头文件都已包含保护(即使我在示例中没有明确写出)。
您不需要保护您的 .cpp 文件,因为它们不会被 #included(或者至少不应该被 #included……对吧?
对吧?)
*****************************************************
** 4) “正确”的包含方式 **
*****************************************************
您创建的类通常会依赖于其他类。例如,派生类总是依赖于其基类,因为要从基类派生,它必须在编译时就了解它的基类。
您需要注意两种基本类型的依赖关系:
1) 可以前向声明的内容
2) 需要 #include 的内容
例如,如果类 A 使用类 B,那么类 B 是类 A 的依赖项之一。它是否可以被前向声明或需要被包含,取决于 B 在 A 中如何被使用。
- 如果:A 完全不引用 B,则不执行任何操作。
- 如果:对 B 的唯一引用是在 `friend` 声明中,则不执行任何操作。
- 如果:A 包含 B 的指针或引用,则前向声明 B:`B* myb;`
- 如果:一个或多个函数具有 B 对象/指针/引用,则前向声明 B。
作为参数或返回类型:`B MyFunction(B myb);`
- 如果:B 是 A 的基类,则 #include "b.h"。
- 如果:A 包含 B 的对象,则 #include "b.h":`B myb;`
您应该选择尽可能不那么剧烈的选项。如果可以,就什么都不做,但如果不能,就选择前向声明。但如果确实有必要,那么就 #include 其他头文件。
理想情况下,类的依赖关系应该在头文件中列出。以下是“正确”头文件通常的结构:
myclass.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
//=================================
// include guard
#ifndef __MYCLASS_H_INCLUDED__
#define __MYCLASS_H_INCLUDED__
//=================================
// forward declared dependencies
class Foo;
class Bar;
//=================================
// included dependencies
#include <vector>
#include "parent.h"
//=================================
// the actual class
class MyClass : public Parent // Parent object, so #include "parent.h"
{
public:
std::vector<int> avector; // vector object, so #include <vector>
Foo* foo; // Foo pointer, so forward declare Foo
void Func(Bar& bar); // Bar reference, so forward declare Bar
friend class MyFriend; // friend declaration is not a dependency
// don't do anything about MyFriend
};
#endif // __MYCLASS_H_INCLUDED__
|
这显示了两种不同类型的依赖关系以及如何处理它们。由于 MyClass 只使用 Foo 的指针而不是完整的 Foo 对象,因此我们可以前向声明 Foo,而无需 #include "foo.h"。
您应该始终尽可能前向声明,除非必要,否则不要 #include。不必要的 #include 可能会导致问题。
如果您遵循这个系统,您将能避免很多问题,并将 #include 相关的风险降到最低。
*****************************************************
** 5) 为什么那是“正确”的包含方式 **
*****************************************************
注意:在本节中,我将上面概述的“正确方式”称为“我的方式”。虽然我确实是在挣扎了一段时间后自己想出来的——但我不能说我是第一个想到它的人,所以它并不是真正“我的”。但为了本文的目的,我简单地称之为“我的”。
您:“某某人说在头文件中 #include 很危险,但您说不是!为什么您的方法比某某人说的好得多?”
某某人说得部分正确,但解释错了。不必要的、粗心的 #include 确实
可能导致问题。避免这些问题的一种方法是
永远不要在头文件中 #include。所以,某某人的出发点是好的。但最终,采用某某人的方法会给您带来大量的额外工作和头痛。
我正在说明的概念非常面向对象(OO),并且增强了封装。总体的想法是,它使“myclass.h”完全自成一体,并且除了 MyClass 的实现/源文件外,不需要程序中的任何其他区域知道 MyClass 的内部工作原理。如果其他类需要使用 MyClass,它只需 #include "myclass.h" 即可!
替代方法(某某人的方法)将要求您在 #include "myclass.h"
之前 #include MyClass 的所有依赖项,因为 myclass.h 本身无法包含其依赖项。这会带来很多麻烦,因为使用一个类不再那么直接。
这是我方法好的一个例子
1 2 3 4 5 6 7
|
//example.cpp
// I want to use MyClass
#include "myclass.h" // will always work, no matter what MyClass looks like.
// You're done
// (provided myclass.h follows my outline above and does
// not make unnecessary #includes)
|
这是某某人方法糟糕的一个例子
1 2 3 4 5
|
//example.cpp
// I want to use MyClass
#include "myclass.h"
// ERROR 'Parent' undefined
|
某某人:“嗯……好吧……”
1 2 3
|
#include "parent.h"
#include "myclass.h"
// ERROR 'std::vector' undefined
|
1 2 3 4
|
#include "parent.h"
#include <vector>
#include "myclass.h"
// ERROR 'Support' undefined
|
某某人:“搞什么鬼?MyClass 根本
不使用 Support!好吧……”
1 2 3 4 5
|
#include "parent.h"
#include <vector>
#include "support.h"
#include "myclass.h"
// ERROR 'Support' undefined
|
某某人:“饶了我吧!我正在包含它!你还想要什么!”
您信不信,上面
确实会发生。可怜的某某人不知道,“parent.h”使用了 Support,因此您必须在“parent.h”
之前 #include "support.h"。
如果 support.h 需要其他东西怎么办?如果
那个其他东西需要其他东西怎么办?仅使用一个类,我们已经需要 4 个 #include 了!使用某某人的方法,您不仅要记住每个类需要哪些 include,还要记住
包含它们的顺序。这会
非常快地变成一个
巨大的噩梦。
如果您想调整 MyClass 怎么办?比如说,您决定使用 std::list 而不是 std::vector 会更好。使用某某人的方法,您现在必须回溯并更改
每个 #include "myclass.h" 的文件,并将其更改为包含 <list> 而不是 <vector>(根据项目的大小和 MyClass 的使用频率,这可能是几十个文件),而使用我的方法,您只需要更改 "myclass.h" 和可能 "myclass.cpp"。
我上面说明的“正确方式”完全是为了封装。想要使用 MyClass 的文件无需了解 MyClass 使用了什么就能正常工作,并且无需 #include 任何 MyClass 的依赖项。要让 MyClass 工作,您只需要 #include "myclass.h"。仅此而已!头文件被设置为完全自包含。它对 OO 非常友好,非常易于使用,并且非常易于维护。
*****************************************************
** 6) 循环依赖 **
*****************************************************
循环依赖是指两个(或多个)类相互依赖。例如,类 A 依赖类 B,类 B 依赖类 A。
如果您遵循“正确方式”,并在可以前向声明时前向声明,而不是不必要地 #include,这通常不是问题。只要循环在某个点被前向声明打破,您就没问题了。
以下是为什么您应该只 #include 必要内容的完美示例:
1 2 3 4
|
// a.h -- assume it's guarded
#include "b.h"
class A { B* b; };
|
1 2 3 4
|
// b.h -- assume it's guarded
#include "a.h"
class B { A* a };
|
乍一看可能没问题。B 是 A 的依赖项,所以您包含它;A 是 B 的依赖项,所以您包含它。那么有什么问题呢?
这是循环包含(也称为无限包含),它是一个或多个不应该存在的 include 的结果。例如,假设您编译 "a.cpp":
1 2
|
// a.cpp
#include "a.h"
|
编译器将执行以下操作:
1 2 3 4 5 6 7 8 9 10 11 12
|
#include "a.h"
// start compiling a.h
#include "b.h"
// start compiling b.h
#include "a.h"
// compilation of a.h skipped because it's guarded
// resume compiling b.h
class B { A* a }; // <--- ERROR, A is undeclared
|
即使您 #included "a.h",编译器在 B 类编译完成之前也看不到 A 类。这是因为存在循环包含问题。这就是为什么您应该
始终在前向声明时只使用指针或引用。这里,“a.h”不应该 #include b.h,而应该只是前向声明 B。同样,b.h 应该前向声明 A。如果您进行了这些更改,问题就解决了。
如果两个依赖项都是 #include 依赖项(即它们不能被前向声明),循环包含问题可能会持续存在。例如:
1 2 3 4 5 6 7 8
|
// a.h (guarded)
#include "b.h"
class A
{
B b; // B is an object, can't be forward declared
};
|
1 2 3 4 5 6 7 8
|
// b.h (guarded)
#include "a.h"
class B
{
A a; // A is an object, can't be forward declared
};
|
然而,您可能会注意到,这种情况“概念上是不可能的”。存在根本性的设计缺陷。如果 A 有一个 B 对象,而 B 有一个 A 对象,那么 A 包含一个 B,B 包含另一个 A,A 包含另一个 B,B 包含另一个 A,依此类推。您遇到了无限递归问题,而其中一个类根本不可能实例化。解决方案是让一个或两个类包含另一个类的
指针或引用,然后您可以前向声明,从而绕过循环包含问题。
*****************************************************
** 7) 函数内联 **
*****************************************************
内联函数的问题在于,它们的函数体必须存在于调用它们的每个 cpp 文件中,否则您将遇到链接器错误(因为它们不能在链接器过程中进行链接——它们需要在编译器过程中编译到代码中)。
这可能会打开循环引用或其他可能使“正确方式”概述变得复杂的情况。
1 2 3 4 5 6 7 8 9 10
|
class B
{
public:
void Func(const A& a) // parameter, so forward declare is okay
{
a.DoSomething(); // but now that we've dereferenced it, it
// becomes an #include dependency
// = we now have a potential circular inclusion
}
};
|
关键在于,虽然内联函数需要存在于头文件中,但它们
不需要存在于类定义本身中。这允许我们利用一个漏洞:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
// b.h (assume its guarded)
//------------------
class A; // forward declared dependency
//------------------
class B
{
public:
void Func(const A& a); // okay, A is forward declared
};
//------------------
#include "a.h" // A is now an include dependency
inline void B::Func(const A& a)
{
a.DoSomething(); // okay! a.h has been included
}
|
虽然您乍一看可能不这么认为……这是“完全安全”的。即使 a.h 包含 b.h,循环包含问题也会被完全避免。这是因为额外的 #include 要到类 B 完全定义
之后才会出现,因此它们是无害的。
您:“但把函数体放在头文件末尾很丑陋。有没有办法避免这种情况?”
我:“当然!只需将函数体移到另一个头文件即可。”
1 2 3 4 5 6 7
|
// b.h
// blah blah
class B { /* blah blah */ };
#include "b_inline.h" // or I sometimes use "b.hpp"
|
1 2 3 4 5 6 7 8 9 10 11
|
// b_inline.h (or b.hpp -- whatever)
#include "a.h"
#include "b.h" // not necessary, but harmless
// you can do this to make this "feel" like a source
// file, even though it isn't
inline void B::Func(const A& a)
{
a.DoSomething();
}
|
这将在分离接口和实现的同时,仍然允许实现内联。您也可以有一个普通的“b.cpp”文件用于未内联的实现。
*****************************************************
** 8) 前向声明模板 **
*****************************************************
在处理模板类时,对于简单类而言,前向声明相当直接,但情况并非如此简单。考虑以下场景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
// a.h
// included dependencies
#include "b.h"
// the class template
template <typename T>
class Tem
{
/*...*/
B b;
};
// class most commonly used with 'int'
typedef Tem<int> A; // typedef'd as 'A'
|
1 2 3 4 5 6 7 8 9 10 11
|
// b.h
// forward declared dependencies
class A; // error!
// the class
class B
{
/* ... */
A* ptr;
};
|
虽然这看起来完全合乎逻辑,但它不起作用!(尽管逻辑上您确实认为它应该起作用。这是语言的一个令人不快的特性)。因为 'A' 实际上不是一个类,而是 typedef,编译器会报错。另外请注意,由于存在循环依赖问题,我们不能只 #include "a.h"。
为了前向声明 'A',我们需要 typedef 它。这意味着我们需要前向声明它的 typedef。前向声明它的方式如下:
1 2
|
template <typename T> class Tem; // forward declare our template
typedef Tem<int> A; // then typedef 'A'
|
这比 `class A;` 丑陋得多,但仍然是必要的邪恶。然而,这使得模板类的封装变得困难,因为它要求每个前向声明它的类都确切地知道模板的布局。如果将来发生更改,您将面临巨大的清理工作。
这个问题的一个实际解决方案是创建一个备用头文件,其中包含您的模板类及其 typedef 的前向声明。以下是处理上述示例的一种更优雅的方法:
1 2 3 4 5 6 7 8 9 10
|
//a.h
#include "b.h"
template <typename T>
class Tem
{
/*...*/
B b;
};
|
1 2 3 4
|
//a_fwd.h
template <typename T> class Tem;
typedef Tem<int> A;
|
1 2 3 4 5 6 7 8 9
|
//b.h
#include "a_fwd.h"
class B
{
/*...*/
A* ptr;
};
|
这允许 B 包含一个前向声明 A 的头文件,而无需包含整个类定义。