平时编写只有几十行代码的简单程序时,我们把所有代码都放在同一个 .cpp 文件里是完全没问题的。但在实际项目开发中,代码量动辄成千上万行。如果还把所有函数和类都堆在一个文件里,不仅找起代码来非常费劲,多人协作时也很难复用写好的功能。
为了把大型项目组织得清晰有序,C++ 通常采用 “声明与实现分离” 的方式,将代码拆分到两类文件中管理:头文件(.h)和源文件(.cpp)。
头文件与源文件的分工
在 C++ 中,其他代码想要调用一个函数,只需要先看到它的函数声明就够了,并不需要立刻看到花括号里的具体实现代码。基于这个特点,我们可以把项目中的代码文件划分为以下 2 类:
- 头文件(.h):后缀名通常为
.h,主要用来存放对外共享的 “声明” 和类型结构,比如函数声明、结构体定义、类的声明等。它就像一家餐厅的 “菜单”,只负责告诉外部有哪些功能可以直接调用。 - 源文件(.cpp):后缀名通常为
.cpp,主要用来存放具体的 “定义” 和实现代码,比如函数的函数体、类成员函数的实现等。它就像餐厅的 “后厨”,负责把菜单上的功能真正做出来。

把 “菜单” 和 “后厨” 分开的好处非常明显。当其他程序员想调用你写的函数时,只需要打开简短的 .h 头文件,就能清楚函数名叫什么、需要传入什么参数,不用再去翻几百行的 .cpp 实现代码。
在实际开发中,哪些内容适合放进 .h 头文件,哪些内容适合放进 .cpp 源文件,常见的分工如下表所示。
| 文件类型 | 常见存放内容 | 主要作用 |
|---|---|---|
| 头文件(.h) | 函数声明、struct 结构体定义、class 类声明 | 提供给多个源文件共享的声明与类型结构 |
| 源文件(.cpp) | 普通函数的定义、类成员函数的实现、全局变量的定义 | 存放具体的实现代码与分配内存的变量 |
提示 在 C++ 项目中,头文件的后缀名除了最常见的 .h,有时也会看到代表 “C++ Header” 的 .hpp。这两者在功能上没有任何区别,只是不同团队的命名习惯不同,在实际开发中保持项目内部统一即可。
编写第一个多文件程序
接下来,我们从零搭建一个简单的多文件项目,看看如何把两个数学计算函数 add() 和 subtract() 拆分到头文件与源文件中。
由于在线编译器只支持运行单个文件,涉及多个文件的程序需要在本地编辑器中进行测试。我们可以在电脑上新建一个名为 multi_file_demo 的文件夹,使用 VS Code 打开该文件夹后,在里面新建以下 3 个文件:
multi_file_demo/
├── math_utils.h
├── math_utils.cpp
└── main.cpp建好这 3 个空白文件后,我们依次来编写它们各自的代码。
1. 编写头文件 math_utils.h
首先打开 math_utils.h,在里面写好两个函数的声明,并在首尾加上防止重复包含的保护指令,代码如下:
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
// 函数声明
int add(int a, int b);
int subtract(int a, int b);
#endif可以看到,math_utils.h 里面没有花括号函数体,只有两行以分号结尾的函数声明。至于首尾包裹的 #ifndef、#define 和 #endif,它们用来实现头文件保护,防止同一个头文件重复展开。这里小伙伴们先照着这个固定格式写上即可,后面章节会详细介绍它的工作原理。
2. 编写实现文件 math_utils.cpp
接着打开 math_utils.cpp,先在文件顶部使用 #include 引入刚刚写好的 math_utils.h,再写出两个函数的具体实现代码:
#include "math_utils.h"
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}可能有些小伙伴会问:“math_utils.cpp 里面明明已经写好函数定义了,为什么第 1 行还要再写一句 #include "math_utils.h" 呢?”
其实这是一个非常实用的工程习惯。让 math_utils.cpp 包含它自己对应的头文件,编译器就能把 “头文件里的声明” 和 “源文件里的定义” 放在一起核对。万一我们在写函数体时不小心写错了参数类型或返回值类型,编译器在编译阶段就能直接找出错误并提醒我们。
3. 编写主程序文件 main.cpp
最后打开 main.cpp,在顶部引入 <iostream> 和 "math_utils.h",然后在 main() 函数中调用 add() 和 subtract(),代码如下:
#include <iostream>
#include "math_utils.h"
int main() {
int a = 30;
int b = 12;
std::cout << "30 + 12 = " << add(a, b) << '\n';
std::cout << "30 - 12 = " << subtract(a, b) << '\n';
return 0;
}需要特别注意的是,main.cpp 开头引入的是头文件 math_utils.h,千万不要写成源文件 math_utils.cpp。
编译与运行多文件程序
当项目包含多个 .cpp 源文件时,我们就不能再像以前写单文件程序那样,直接点击 VS Code 右上角的运行按钮了。因为右上角的按钮默认只会单独编译当前打开的那一个 .cpp 文件,漏掉其他源文件就会引发报错。此时,我们需要在终端中把参与功能的所有 .cpp 源文件一起交给编译器。
1. 使用 g++ 编译多个源文件
在终端命令行中,我们可以直接把多个 .cpp 文件名并列传给 g++ 编译器。
语法:
g++ 源文件1.cpp 源文件2.cpp -o 可执行文件名说明:
在这条编译命令中,各部分的作用如下:
- 源文件1.cpp 源文件2.cpp:列出项目中需要参与编译的所有
.cpp源文件,多个文件名之间用空格隔开。 - -o 可执行文件名:
-o参数用来指定生成的可执行程序名称,比如app,在 Windows 系统下会自动生成app.exe。
示例 1:编译并运行多文件程序
在 VS Code 顶部菜单依次点击【终端】→【新建终端】打开终端窗口,确认当前路径位于 multi_file_demo 文件夹下,输入下面的编译与运行命令。如果我们是在 macOS、Linux 或 Git Bash 终端下操作,把第 2 行运行命令改为 ./app 即可:
g++ main.cpp math_utils.cpp -o app
.\app.exe运行结果如下。
30 + 12 = 42
30 - 12 = 18分析:
在这个例子中,我们通过 g++ main.cpp math_utils.cpp -o app 命令将两个源文件一起编译,并组装成了可执行程序。其中,main.cpp 负责发起函数调用,math_utils.cpp 负责提供函数的具体实现,两者配合起来就能正常输出计算结果。
为什么编译命令里只写了 main.cpp 和 math_utils.cpp,却不用写 math_utils.h 呢?因为头文件已经通过 #include "math_utils.h" 在编译前自动展开到源文件中了,所以在命令行编译时只需要列出所有的 .cpp 源文件。
2. 分步编译与链接过程
表面上看,上面的 g++ 命令只用一步就生成了可执行程序。但从底层的编译与运行过程来看,每个 .cpp 源文件其实是各自独立编译的,整个构建过程按顺序分为以下 2 步:
- 各自独立编译:编译器先将
main.cpp单独编译成目标文件main.o,再将math_utils.cpp单独编译成目标文件math_utils.o。 - 统一链接组装:链接器将
main.o和math_utils.o这两个目标文件组装在一起,找到各个函数对应的实现代码,最终生成可执行程序。
我们也可以加上表示 “只编译、不链接” 的 -c 参数,把编译和链接手动拆成两步来执行。对于当前项目来说,下面 2 种方式是等价的。
# 方式 1
g++ main.cpp math_utils.cpp -o app
# 方式 2
g++ -c main.cpp -o main.o
g++ -c math_utils.cpp -o math_utils.o
g++ main.o math_utils.o -o app为什么要设计成 “先各自编译、再统一链接” 呢?因为在实际项目中,一个工程常常包含几十甚至上百个 .cpp 文件。如果每次改一行代码都要把上百个文件全部重新编译一遍,会非常耗时。
拆开之后,假如我们只修改了 main.cpp,就只需要重新编译生成 main.o,再和其余现成的 .o 文件重新链接即可。这样一来,项目的构建速度就会快得多。
将类拆分到头文件与源文件
除了普通函数,我们在开发中编写的 class 类同样经常按照 “头文件 + 源文件” 的方式进行拆分:
- 在头文件(.h)中:只编写
class类的整体结构,包括成员变量,以及构造函数和成员函数的声明。注意函数声明只写函数头并以分号结尾,不写函数体。 - 在源文件(.cpp)中:先引入对应的头文件,再使用 “类名::函数名” 的格式编写构造函数与各个成员函数的具体实现代码。
比如我们定义一个计数器类 Counter,新建一个独立的 counter_demo 文件夹来存放以下 3 个文件:
counter_demo/
├── counter.h
├── counter.cpp
└── main.cpp其中,头文件 counter.h 的代码如下:
#ifndef COUNTER_H
#define COUNTER_H
class Counter {
public:
Counter(int initVal);
void increment();
int getValue() const;
private:
int value;
};
#endif对应的实现文件 counter.cpp 代码如下:
#include "counter.h"
Counter::Counter(int initVal) {
value = initVal;
}
void Counter::increment() {
value++;
}
int Counter::getValue() const {
return value;
}示例 2:拆分 Counter 类并调用
接着打开 counter_demo 文件夹下的 main.cpp,引入 "counter.h" 并创建对象进行测试:
#include <iostream>
#include "counter.h"
int main() {
Counter c(10);
c.increment();
c.increment();
std::cout << "当前计数:" << c.getValue() << '\n';
return 0;
}在终端进入 counter_demo 文件夹,执行 g++ main.cpp counter.cpp -o app 编译并运行后,运行结果如下。
当前计数:12分析:
Counter::Counter(int initVal) 和 void Counter::increment() 都是在类外实现成员函数的标准写法。当我们将构造函数或普通成员函数拿到 class 花括号外面编写时,必须在函数名前面加上 Counter::。
中间的双冒号(::)叫做作用域限定符,用来告诉编译器这个函数属于 Counter 类的成员,而不是普通全局函数,因此它内部可以直接访问私有成员变量 value。
此外,拆分成员函数时还有两个细节需要小伙伴们留意。如果成员函数在头文件中带有 const 修饰,比如 getValue() const,那么在 .cpp 源文件中实现时末尾也必须保留 const。如果构造函数使用了初始化列表,那么 : value(initVal) 只需要写在 .cpp 的函数定义处,头文件声明里不要写。
多文件编程常见报错
在多文件开发中,同一个函数或全局变量可以在多个地方声明,但在整个程序中,它的具体定义必须有且仅有一次。这条规则在 C++ 中叫做 “单一定义规则(One Definition Rule)”。
初学多文件编程的小伙伴,在动手编译时最容易遇到下面这两类链接报错。
1. 漏编译源文件(undefined reference)
很多小伙伴写完多个文件后,习惯性地只对 main.cpp 执行编译命令,却忘了把提供函数实现的 math_utils.cpp 一起加上。
示例 3:漏传源文件引发报错
回到前面的 multi_file_demo 项目,如果在终端只编译 main.cpp,命令如下:
g++ main.cpp -o app运行结果如下。
(报错)undefined reference to `add(int, int)'
(报错)undefined reference to `subtract(int, int)'分析:
从运行结果可以看出,终端报出了 undefined reference 错误,意思是 “未定义的引用”,也就是找不到函数的具体实现。
为什么 main.cpp 开头明明写了 #include "math_utils.h" 还是会报错呢?因为 math_utils.h 里面只有函数声明这张 “菜单”,并没有提供函数体这个 “后厨”。
这虽然能让 main.cpp 通过编译阶段的语法检查并生成 main.o,但到了最后的链接阶段,链接器找遍了参与链接的文件也没找到 add() 和 subtract() 的实现代码,于是直接报错。我们只要把 math_utils.cpp 补进编译命令中就能解决。
2. 重复定义冲突(multiple definition)
看到上面 “找不到函数实现” 的报错后,有些小伙伴会尝试在 main.cpp 开头直接用 #include 引入 .cpp 源文件。下面这种写法就是错误的。
// 错误方式
#include "math_utils.cpp"为什么不能这样写呢?假如我们在 main.cpp 里写了 #include "math_utils.cpp",预处理器就会把里面的函数体复制进 main.cpp。此时再执行 g++ main.cpp math_utils.cpp -o app,main.o 和 math_utils.o 里就会各自生成一份相同的 add() 实现。
同理,如果我们把普通函数的函数体直接写进了 .h 头文件,一旦有两个不同的 .cpp 源文件都包含了它,也会在两个 .o 目标文件里各生成一份相同的函数体。等到链接器组装程序时,发现出现了两个一模一样的 “后厨” 互相撞车,就会报出下面的重复定义错误:
(报错)multiple definition of `add(int, int)'那为什么 struct 结构体和 class 类写在 .h 头文件里,多个源文件同时包含却不会撞车呢?因为它们只是告诉每个源文件 “这个类型长什么样”,并不会直接生成独立的函数机器码。此外,只有内联函数和模板由于编译机制特殊,实现代码需要放在头文件中,普通函数的函数体一律要放在 .cpp 源文件里。
平时编写多文件程序,小伙伴们只需要记住两条基本原则:① #include 后面只写 .h 头文件,千万不要写 .cpp 源文件;② 项目中有几个 .cpp 源文件参与功能,编译时就要把它们全部交给编译器。
跨文件共享全局变量(extern)
如果我们需要在多个源文件之间共享同一个全局变量,同样不能在 .h 头文件里直接写 int maxUsers = 100;,否则多个源文件同时包含它时,也会引发 multiple definition 重复定义报错。
正确的做法是借助 extern 关键字,将全局变量的 “声明” 与 “定义” 拆分开来:
在头文件中声明:比如在 config.h 中写 extern int maxUsers;,只做声明而不赋初值,用来告诉编译器这个全局变量在别处已经定义好了。
在唯一的一个源文件中定义:比如在 config.cpp 中写 int maxUsers = 100;,真正为该全局变量分配内存并赋初值。
这样一来,其他任何 .cpp 源文件只要引入 #include "config.h",就能直接读写同一个 maxUsers 变量,而且不会产生重复定义冲突。
