目录
在C++中,通常将代码分为头文件(.h 或 .hpp)和源文件(.cpp)两种类型。
首先介绍一下.h和.cpp文件的作用。
.h与.cpp的作用和关系
头文件(.h 或 .hpp)
- 声明:头文件通常包含类、函数或变量的声明。
- 模板定义:类模板、函数模板的定义通常也会放在头文件中。
- 宏定义:常量、宏定义以及内联函数的实现通常也会放在头文件中。
- 接口定义:类的公共接口、成员变量声明等也属于头文件的范畴。
- 防止重复包含:使用预处理指令
#ifndef
、#define
、#endif
来避免头文件的重复包含。
示例:
// example.h
#ifndef EXAMPLE_H
#define EXAMPLE_H
class Example {
public:
Example(); // Constructor declaration
void doSomething(); // Function declaration
private:
int data; // Member variable declaration
};
#endif
源文件(.cpp)
- 实现:源文件包含类、函数或变量的实现部分。
- 函数定义:函数成员的具体实现通常在源文件中。
- 类成员函数实现:类的成员函数的实现也在源文件中。
- 全局变量定义:全局变量的定义通常也在源文件中。
示例:
// example.cpp
#include "example.h"
Example::Example() {
data = 0;
}
void Example::doSomething() {
// Implementation of the doSomething function
}
在实际开发中,头文件主要用于暴露接口和声明,而源文件用于实现具体的功能。通过这种分离,可以提高代码的可读性、可维护性,并且可以减少编译时间,因为只有源文件发生改变时才需要重新编译。
上述的头文件与源文件的作用在很多平台都能找到类似的答案。
但是我在学习过程中经常会疑惑一个问题:
头文件“a.h”声明了一系列数,“b.cpp”中实现了这些函数,那么如果我想在“c.cpp”中使用“a.h”中声明的这些在“b.cpp”中实现的函数,通常都是在“c.cpp”中使用#include“a.h”,那么c.cpp是怎样找到b.cpp中的实现呢?
逐步解释过程
假设有以下三个文件:
a.h (头文件,声明函数)
// a.h文件
#ifndef A_H
#define A_H
void myFunction();
#endif
b.cpp (源文件,实现函数)
// b.cpp文件
#include "a.h"
#include <iostream>
void myFunction() {
std::cout << "Function implementation in b.cpp" << std::endl;
}
c.cpp (源文件,想要使用myFunction
函数)
// c.cpp文件
#include "a.h"
int main() {
myFunction(); // 调用在b.cpp中定义的函数
return 0;
}
现在讨论一下编译过程:
1、预处理
在编译之前,预处理器处理所有预处理指令,例如#include
。在你的例子中,#include "a.h"
告诉编译器在编译b.cpp
和c.cpp
之前,将a.h
文件的内容插入到这些源码中。该过程仅涉及文本替换,并不关心函数的实际实现位置。
2、编译
在编译过程中,通常只有源文件(.cpp
文件)才会被编译成目标文件(object file),而头文件(.h
文件)不会被直接编译成目标文件。头文件的作用是在编译时将声明(包括函数原型、类声明等)插入到相应的源文件中,以便编译器在编译时能够正确理解和处理源文件中的代码。
每个源文件(b.cpp
和c.cpp
)分别被编译成目标文件。例如:
g++ -c b.cpp -o b.o
g++ -c c.cpp -o c.o
g++
:调用 C++ 编译器。-c
:表示编译源文件但不进行链接。b.cpp
:要编译的源文件。-o b.o
:生成的目标文件名为b.o
。
这条命令会编译 b.cpp
文件并生成一个名为 b.o
的目标文件,该目标文件可以在后续的链接过程中使用。
编译器会检查在c.cpp
中使用的符号(如myFunction
)的声明,这是通过包含a.h
得到的。但是在编译阶段,并不检查这些函数在哪里实现,只要求有相应的声明即可。
3、链接
链接器(Linker)的工作是合并所有目标文件(.o
或.obj
文件)并且解决符号引用,最终生成可执行文件。
在链接过程中,符号引用(Symbol Reference)和符号定义(Symbol Definition)是两个重要的概念。
-
符号引用:指的是在代码中使用的符号,例如函数调用、全局变量等。当编译器在编译源代码时遇到符号引用时,它会生成对该符号的引用,但并不知道该符号在内存中的确切位置。编译器会生成对应的符号表,记录下这些符号引用。
-
符号定义:指的是在代码中定义的符号,例如函数的实现、全局变量的定义等。当编译器在编译源代码时遇到符号定义时,它会生成对该符号的定义,并确定该符号在内存中的位置。编译器也会将符号定义的信息保存到符号表中。
在链接阶段,链接器的主要任务之一就是解析符号引用和符号定义,并将它们关联起来。具体来说:
- 链接器会查找目标文件和库文件中的符号定义,并确定它们在内存中的位置。
- 对于目标文件中的符号引用,链接器会查找对应的符号定义,并将其关联起来。
- 如果找不到符号的定义,链接器会报错,并指出找不到符号的定义位置。
通过解析符号引用和符号定义,链接器能够确定程序中各个符号的实际位置,从而生成最终的可执行文件。
例如:
g++ b.o c.o -o program
这条命令的含义是将目标文件 b.o
和 c.o
链接在一起,生成一个名为 program
的可执行文件。
在这个例子中,b.o
中包含了myFunction
的定义(实现),c.o
包含了对myFunction
的调用。链接器会解析c.o
中的符号引用,找到b.o
中的定义。
这个命令是在编译器的后端进行的,其中可能包括以下步骤:
-
收集目标文件: 链接器收集所有提供给它的目标文件。在这个案例中,是
b.o
和c.o
。这些文件包含编译后的代码和数据,以及一个符号表,该符号表映射了每个文件中使用和定义的符号(例如函数、变量等)。 -
符号解析: 链接器检查每个目标文件的符号表,将每个外部符号的引用与其定义匹配。在这个例子中,
c.o
中会有一个未解析的引用myFunction
,而b.o
提供了myFunction
的定义。 -
重定位: 链接器将所有的目标文件合并到一个单一的可执行文件中,并调整代码和数据的地址,使它们指向正确的位置。
-
解决符号: 如果有多个目标文件引用或定义相同的符号,链接器会根据一定的规则(比如优先级或者指定了哪些目标文件被链接)来确定哪个定义将被使用。
如果链接器在提供给它的目标文件中找不到某个符号的定义,将会产生一个链接错误,比如“undefined reference to SymbolName
”。
所以实际上,链接器并不是自动知道应该去哪个文件找到定义,而是根据你告诉它的目标文件列表来解析和建立符号之间的关联。这是通过链接阶段你提供给它的参数(目标文件列表)来完成的。如果你没有指定b.o
,则myFunction
将会是未定义的,导致链接错误。
此外,.cpp
或.cc
作为源文件的扩展名的确没有硬性规定,只是习惯上C++源文件使用.cpp
,C源文件使用.c
。编译器和链接器关心的是在编译和链接过程中引用的符号和它们的实现是否可解析,并不关心扩展名。