Skip to content

18.2 头文件与源文件组织

基于上一节所讲的过程,C++ 最经典的工程物理隔离模式便应运而生:通常我们会为每一个模块或类构建配对的一套文件:头文件(Header File, .h.hpp源文件(Source File, .cpp.cc

因为 C++ 编译器在工作时具有一个显著特性:每个源文件(.cpp)是一个绝对独立的“编译单元(Translation Unit)”。翻译 A.cpp 时的编译器对于隔壁 B.cpp 里写了啥一无所知。

如果 main.cpp 想使用 math.cpp 里写好的 int add(int a, int b) 逻辑:

  1. main.cpp 不能直接不闻不问就用 add,编译器在第二站“编译站”看到不认识的名字直接就报错拦截。
  2. 我们必须在 main.cpp 首部提前放置一个**「声明」**:int add(int a, int b); 告诉编译器“不要管这是咋做到的,你只要相信世界上存在这么一款收俩个 int 返回一个 int 的东西就行”。
  3. 这样骗过了编译站(留下了空洞让链接站去解决对接)。

但这太繁琐了!如果我们有 50 个 .cpp 文件都要调用这个 add,难道每一个的最开头都要去手动敲一遍它的声明?(更何况可能还得敲个几百行包含的结构体依赖)。

**头文件的本质,就是一个专门用来存放“声明”的仓库档案夹。**将声明写进一个 math.h 里,谁想用它的功能,只需要 #include "math.h",预处理器就会自动把几十行声明代码全部一字不差地帮他复制粘贴进去!

最佳实践准则

  • .h 头文件里装:各种函数署名声明、类的结构宣告、外部包裹定义。
  • .cpp 源文件里装:对应类方法大括号里的实际做事机器码全实现、全局静态变量的真身定存分配!

OD R:单一定义规则(One Definition Rule)

Section titled “OD R:单一定义规则(One Definition Rule)”

C++ 中有一条极其死板的绝对铁律:在整个程序项目的无数个文件之中,任何一个实体函数和非内联全局变量,有且只能有唯一一次带大括号(或分配初值)的实现定调机会!

我们来看看违反者会遭到什么制裁:

如果你偷懒,把 add() 的全包实现(int add() { return 1;} )写在了头文件 math.h 里。 由于 #include 执行的是无脑文本粘贴,如果 A.cppB.cpp 都包含了这个头文件,那么意味着 AB 的独立翻译单元里,双方都分别把 add() 的躯体给完整实现了一次,并带进了各自独立的 .o 暗箱里。

到了最后一站在**链接器(Linker)**的大会上,链接器在搜集汇总材料时大傻眼了: “救命啊!我在 A 身上找到了 add() 的真实尸首,我居然在 B 身上也找到了另外一具同样宣称自己是 add() 的机器码躯壳!到底哪一个才是真身?我该链接填空到哪里引路?” 链接器选择原地摆烂报错: Multiple Definition ( LNK2005: 找到一个或多个多重定义的符号)

解决多重定义的标准方法:**严格把你的实现代码写进 .cpp 里。**因为永远没有别的代码会去 #include “xx.cpp”(除非疯了),这就保证了这个实现的源机器体只会在当前这份 .o 文件里诞生且独此一份。

#pragma once:头文件防卫士 (Include Guards)

Section titled “#pragma once:头文件防卫士 (Include Guards)”

为了防止头文件发生“套娃风暴狂循环嵌套”,或者是在同个 .cpp 树枝状层层包含下,同一份声明被反复贴粘贴了两次引发重宣错误。

所有的头文件最顶端都必须安置防御结界。最万全的老经典是宏卫士:

math.h
#ifndef MATH_H
#define MATH_H
// ... 这里写你的所有代码头文件声明内容 ...
#endif

但如今 99.9% 的现代主流编译器(如 GCC/Clang/MSVC)都支持了一个等效且更清爽简单的指令: 只要在使用任何头文件时,在第一行打上不二之法术

#pragma once
// 一行足矣,这个文件如果被无脑预处理器碰到了第二遍,它自己会物理屏蔽切断后面的后续复制动作。
class User { ... };