1. 类内声明到底是什么:先把概念掰开揉碎
“类内声明”这四个字,我在带新人的时候被问过不下十次。刚上手写 C++ 的朋友经常一脸困惑地指着代码问:这行写在 class 大括号里的void draw();后面跟个分号,到底是啥?它和下面那个void Widget::draw() { ... }是什么关系?为什么有的函数直接在类里就把大括号写了,有的非得搬到外面去?
先用一句话给结论:所谓类内声明,绝大多数场景下指的是把成员函数的原型(返回类型、名字、参数列表)写在 class 的大括号内部,函数体留到类外去写。与之相对的是类内定义——大括号和函数体全写在类里面。这两种写法在语法上长得像,语义上却差着好几层,牵扯到内联、链接性、编译依赖、符号表,甚至模板实例化。
这个话题看起来是语法细节,实际上它是理解 C++ 编译模型的一把钥匙。搞不清这件事的人,会在遇到undefined reference to 'Widget::draw()'的时候一脸茫然,会在头文件里写函数体然后被“重复定义”报错打脸,会在模板类上折腾半天才明白为什么实现必须塞进头文件。而搞清楚之后,你写代码时对“这段东西该放在 .h 还是 .cpp”会形成近乎本能的手感。
下面我按自己的理解顺序来铺:先把概念本身澄清,再解释 C++ 为什么要设计成这样,然后给出一套判断标准,接着是能直接抄的工程化写法,最后是踩坑实录。适合刚学完类与对象、准备写第一个多文件项目的朋友,也适合写了几年但一直凭感觉放代码、没系统想过为什么的人。
1.1 三种写法长得像,语义差得远
很多人把“类内”和“类外”当成一个二元选择,其实在成员函数这件事上有三种常见形态,各自的语义完全不同。
第一种,类内声明 + 类外定义:
// widget.h class Widget { public: void draw(); // 只有原型,没有函数体 int size() const; // 同上 private: int n_ = 0; };// widget.cpp #include "widget.h" void Widget::draw() { // 类外定义,必须带 Widget:: 限定 // ... } int Widget::size() const { // const 不能漏,漏了就变成另一个函数 return n_; }第二种,类内定义:
class Widget { public: void draw() { /* ... */ } // 大括号直接写在类里 int size() const { return n_; } // 同上 private: int n_ = 0; };第三种,类内声明 + 类外= default/= delete:
class Widget { public: Widget() = default; // 类内写,隐式 inline Widget(const Widget&) = delete; // 同上 ~Widget(); // 先声明 }; inline Widget::~Widget() = default; // 类外写,必须手动加 inline这三种写法在“能不能调用”上没区别,编译器都认。但它们在链接行为和编译依赖上的差别,会直接决定你的项目在规模变大之后是顺利编译还是报一屏红字。第三种尤其容易被忽略——= default写在类内是隐式 inline,写到类外就不自动 inline 了,这时候如果又放在头文件里,多个编译单元包含进来就是重复定义。
提示:这三种形态我用不同的颜色标在项目模板里,新人进来第一天就要能分清。分不清的后果不是写错代码,而是出问题时完全不知道往哪查。
还有一个更基础的混淆点:非静态数据成员的声明。你在类里写int n_;,这是声明还是定义?严格按标准说,它属于类定义的一部分,决定了对象的内存布局,但真正分配存储是在创建对象的时候。所以平时说话不用抠这么细,只要记住:类内写成员变量的那一行,不能给它“再定义一次”的机会,也不涉及链接问题。真正需要纠结类内类外的,是函数和静态数据成员。
1.2 编译器在类体内看到的是一张成员清单
要理解“类内声明”到底做了什么,得站在编译器的视角看一遍。当它读到class Widget { ... };这一整块时,做的事情其实是收集一张成员清单:有哪些数据成员、各自的类型和顺序、有哪些成员函数、访问权限是什么、有没有虚函数、有没有基类。这张清单决定了sizeof(Widget)、决定了对象的内存布局、决定了名字查找的规则。
函数原型写在这张清单里,编译器就记下“Widget 有一个叫 draw 的成员函数,无参数,返回 void,public”。这时候它不生成任何机器码,因为这只是一份契约。等到链接阶段,某个编译单元里必须真的存在Widget::draw这个函数的实现,否则链接器就会报undefined reference。
这里有个特别值得说的细节:类内定义的成员函数,其函数体在名字查找上享受“完整类上下文”待遇。意思是,函数体里可以引用在它后面才声明的成员:
class Widget { public: int compute() const { return n_ * factor(); } // factor 和 n_ 都在后面才声明 private: int factor() const { return 2; } int n_ = 3; };这段代码能编过,因为在类内定义的函数体、默认实参、异常说明符、默认成员初始化器这些位置,编译器会把整个类看作已经完整。这个规则在类外定义时也一样成立(函数体在类完成后才编译),但如果你在返回类型位置用了类内才有的类型别名,那就必须在类外写完整限定:
class Widget { public: using Handle = void*; Handle raw(); // 返回类型用了类内别名 }; Widget::Handle Widget::raw() { return nullptr; } // 返回类型要写 Widget::Handle // auto Widget::raw() -> Handle { return nullptr; } // 尾置返回类型在类作用域内,可以直接写 Handle这个坑我在实际项目里见过不止一次:有人把返回类型写成Handle,编译器报“未知类型名”,然后开始怀疑人生。原因就是Widget::这个限定符出现在返回类型之后,返回类型位置还在全局作用域里,看不到类内的别名。改成尾置返回类型或者写全Widget::Handle就好了。
2. 为什么 C++ 非要拆成声明和定义
理解了“是什么”,下一个问题自然是“为什么”。Java 和 C# 里根本没有这个概念——方法体直接写在类里,编译器自己搞定一切。C++ 偏偏要让你在头文件写一堆分号结尾的原型,再到 .cpp 里重复一遍签名加个大括号,看起来纯属自找麻烦。
这个设计不是拍脑袋定的,它跟 C++ 的分离编译模型死死绑在一起。搞懂这层因果关系,你就不会再问“为什么不能像 Java 那样”,而是会问“什么时候该利用这个机制,什么时候该躲开它的代价”。
2.1 分离编译模型决定的必然结果
C++ 的编译流程是这样的:每个 .cpp 文件(翻译单元)独立编译成 .o 目标文件,最后由链接器把一堆 .o 拼成可执行文件。编译器在处理一个 .cpp 的时候,对别的 .cpp 里的东西一无所知。它唯一的信息来源就是#include进来的头文件。
那么问题来了:A.cpp 里调用Widget::draw(),编译器怎么知道这个函数存在、参数对不对、返回类型是什么?答案就是头文件里的那份声明。声明的作用是告诉编译器“有这么个东西,签名长这样,你先按这个编译,具体实现在别处,链接器会处理”。
如果 C++ 采用 Java 那种“函数体必须在类里”的模型,那头文件里就会包含所有函数的实现。A.cpp 包含了 widget.h,B.cpp 也包含了 widget.h,两份实现就都进了各自的目标文件,链接的时候就是multiple definition。为了绕开这个问题,要么所有函数都强制 inline,要么编译器得做全局分析——这两条路在当时(甚至现在)都不现实。
所以 C++ 的选择是:头文件只放声明,实现放 .cpp,一个函数在整个程序里只定义一次。这条规则有个正式名字叫 ODR(One Definition Rule,单一定义规则)。类内定义之所以被允许放在头文件里,是因为标准给它开了后门:在类定义内部定义的成员函数,自动成为 inline 函数。inline 函数允许多个翻译单元里有相同的定义,链接器会挑一份留下,其余的丢弃。
这个“后门”非常关键。它解释了为什么下面这段代码放进头文件被一万个 cpp 包含也不会报错:
// math_utils.h class MathUtils { public: static int square(int x) { return x * x; } // 隐式 inline,安全 static int cube(int x); // 只有声明 }; inline int MathUtils::cube(int x) { return x * x * x; } // 手动 inline,也安全而把inline去掉,cube立刻变成定时炸弹:
int MathUtils::cube(int x) { return x * x * x; } // 头文件里这么写,多 TU 包含就炸2.2 头文件里的函数体为什么会被打上 inline 标签
很多人背过“类内定义等于 inline”这条规则,但没想过编译器为什么要这么干。其实逻辑很直白:标准必须保证“类定义可以出现在多个翻译单元里”这件事是安全的,而类内定义的函数体是类定义的一部分,所以它也必须能安全地重复出现。唯一能同时满足“可以重复出现”和“链接器不报错”的语言机制,就是 inline。
理解了这层,你就能推出几条实用结论。
第一条,内联只是一种“允许重复定义”的许可,不是“一定展开”的命令。现代编译器对是否真正内联有完全的自主权,你写 inline 它可能不展开,你不写它也可能展开。所以别为了“性能”把函数硬塞进类里,这个年代这么做基本没意义。真正的理由应该是“这个函数就一行、放外面纯属浪费”。
第二条,虚函数在类内定义,依然可能被内联,但要看调用方式。通过对象或引用调用,编译期能确定动态类型,可以去虚化后内联;通过基类指针调用,编译期不知道实际类型,只能走虚表,内联不了。所以把虚函数塞类里并不保证性能,别在这上面做文章。
第三条,= default的位置很讲究。写在类内是隐式 inline,写在类外就不是:
class Widget { public: Widget() = default; // 隐式 inline,头文件里安全 ~Widget(); // 声明 Widget& operator=(const Widget&); }; inline Widget::~Widget() = default; // 手动补 inline,安全 inline Widget& Widget::operator=(const Widget&) = default; // 同上我见过有人把头文件里的类外= default忘了加 inline,单文件测试一切正常,一拆成多文件立刻multiple definition of Widget::~Widget()。这个错误特别隐蔽,因为报错信息指向的是默认生成的函数,你甚至没写过它的实现。
提示:我的习惯是,
= default能写在类内就写在类内。确实需要放到 .cpp 里的(比如析构函数依赖不完整类型),就在 .cpp 里写,既不加 inline 也不会重复。
2.3 从符号表看两种写法的差别
理论说了这么多,怎么验证?最直接的办法是看编译出来的目标文件符号表。这段命令我在给新人做 code review 的时候经常现场敲:
g++ -std=c++17 -c widget.cpp -o widget.o nm -C widget.o | grep Widget假设源码是这样的:
// widget.h class Widget { public: void draw(); // 类外定义 int size() const { return n_; } // 类内定义 static int total_; private: int n_ = 0; };// widget.cpp #include "widget.h" int Widget::total_ = 0; void Widget::draw() { ++total_; }nm的输出大致会是:
0000000000000000 T Widget::draw() 0000000000000000 B Widget::total_ 0000000000000000 W Widget::size() const三个字母代表三种命运。T是代码段里的强符号,说明draw在这个目标文件里有一份唯一的实现;B是 BSS 段,说明total_在这里分配了存储;W是弱符号(weak),说明size是 inline 的,多个目标文件里出现同名弱符号,链接器会挑一个留下,不报错。
反过来,如果你把draw也写成类内定义,它在符号表里也会变成W。如果某个翻译单元只包含了头文件、没链接 widget.o,又调用了draw,链接器找不到强符号,就会报undefined reference。这说明一个容易被忽略的点:类内定义的函数,如果从来没被调用过,可能连符号都不会生成。所以你不能靠“头文件里有函数体”来判断实现是否存在。
3. 判断标准:什么情况下必须搬到类外
前面讲的是“为什么”,这一节讲“怎么办”。实际写代码的时候,脑子里的决策应该很快:这段东西放类内还是类外?我把它压缩成几条硬规则,遇到判断不了的情况就对照着看。
3.1 静态数据成员:类内那行只是声明
这是最经典的一个坑,也是面试高频题。看这段代码:
class Counter { public: static int count; // 这是声明,不是定义 static const int kMax = 100; // 类内初始化,但 C++17 前若被 ODR-use 仍需类外定义 };static int count;写在类里,编译器只是记下“Counter 有个静态成员 count,类型 int”。它不分配存储。真正的存储在类外:
int Counter::count = 0; // 定义,分配存储,可以给初值这行必须写在一个且仅一个 .cpp 文件里。忘了写,链接时报undefined reference to 'Counter::count';写在头文件里,多个 TU 包含就报multiple definition。
C++17 之后有了inline变量,可以在类内一步到位:
class Counter { public: inline static int count = 0; // C++17,类内定义,安全 static constexpr int kMax = 100; // C++17 起 constexpr static 隐式 inline };我自己的取舍是:新项目直接上 C++17 的话,优先用inline static,代码集中、不容易漏;需要兼容老标准(C++11/14)的代码库,老老实实类内声明、类外定义。别小看这一个分号的区别,我见过一个跨平台项目因为静态成员定义漏在某个平台的编译单元里,导致只有那个平台的构建失败,排查了大半天。
还有一个细节值得单独拎出来:static const int kMax = 100;这种整型常量在类内初始化,C++17 之前如果只是拿来当编译期常量用(比如数组大小),不需要类外定义;但一旦被 ODR-use(比如取地址、绑定到 const 引用),就必须有类外定义。这个规则在 C++17 之后因为隐式 inline 而消失了,但老代码里仍然会撞上。
3.2 模板成员与显式实例化
模板是“必须类内定义”的重灾区。原因还是分离编译:编译器在看到Box<int>的时候,需要知道put函数的完整定义才能生成代码。如果定义在 .cpp 里,另一个翻译单元实例化Box<int>时看不到定义,就只能生成一个外部符号引用,然后链接失败。
// box.h template <typename T> class Box { public: void put(const T& v); // 声明 private: T value_{}; }; template <typename T> void Box<T>::put(const T& v) { // 定义,必须也在头文件里 value_ = v; }标准的做法是把定义放在同一个头文件的类后面。如果你非常在意编译时间,可以走显式实例化这条路:把定义放 .cpp,然后在 .cpp 末尾写template class Box<int>;,头文件里再写extern template class Box<int>;。这样只有这一个 .cpp 负责生成Box<int>的代码,其他地方只看到声明。
// box.cpp #include "box.h" template <typename T> void Box<T>::put(const T& v) { value_ = v; } template class Box<int>; // 显式实例化,只生成 int 版本 template class Box<double>;// box.h 末尾 extern template class Box<int>; // 告诉编译器:别在这里实例化 extern template class Box<double>;这套组合拳能把模板的编译开销压下来,代价是你必须提前枚举所有会用到的类型。类型集合不固定的泛型库没法这么干,所以标准库的模板基本还是全头文件。
3.3 构造函数初始化列表和默认实参的归属
有两个语法元素跟着“定义”走,不能分家。
初始化列表只能在函数定义处写。你在类内声明构造函数,就不能在类内给初始化列表;想给初始化列表,要么构造函数整个在类内定义,要么整个在类外定义:
class Widget { public: Widget(int n); // 声明,这里写不了 : n_(n) private: int n_; }; Widget::Widget(int n) : n_(n) {} // 初始化列表只能在这里这条规则很硬,没有变通。所以如果你的构造函数需要初始化列表(几乎都需要),就得先决定它在哪定义,然后一次写完。
默认实参只能在一处指定,通常放声明处。同一个作用域内,同一个参数不能重复给默认值:
class Widget { public: void resize(int w, int h = 100); // 默认实参放声明处 }; void Widget::resize(int w, int h) { // 定义处不能再写 = 100 // ... }为什么放声明?因为调用方看到的只有声明。默认实参是给调用方用的信息,写在定义处,调用方根本看不见。这个规则在普通函数上一样适用,很多人第一次遇到default argument given for parameter 2的报错就是因为在头文件和 .cpp 里各写了一遍。
3.4 一张判断表收个尾
把上面的规则汇总成表,遇到拿不准的情况直接查:
| 代码元素 | 能否类内 | 能否类外 | 强制要求 |
|---|---|---|---|
| 普通成员函数 | 可以,隐式 inline | 可以 | 二选一 |
| 虚函数 | 可以 | 可以 | 二选一,看是否想去虚化内联 |
| 构造函数/析构函数 | 可以 | 可以 | 初始化列表跟定义走 |
= default | 可以,隐式 inline | 可以,需手动 inline | 放头文件的类外版本必须补 inline |
| 非 inline 静态数据成员 | 只能声明 | 必须定义(且仅一处) | 强制类外 |
inline static数据成员(C++17) | 可以定义 | 可以 | 二选一 |
static constexpr数据成员(C++17) | 可以定义,隐式 inline | 可以 | 二选一 |
| 模板成员函数 | 推荐 | 可以,需显式实例化 | 通常必须头文件 |
| 类内类型别名 | 可以 | 使用处需完整限定 | 看返回类型位置 |
表里最下面那行值得再强调一次:类内定义的函数体享受完整类上下文,类外定义时返回类型位置不享受。这个差异不是理论问题,是会在真实代码里卡住你的东西。
4. 实操:一套可以直接抄的工程化写法
规则讲完了,接下来是能落地的部分。我把自己项目里常用的头文件/源文件骨架整理出来,覆盖绝大多数场景,你新建文件时照着改就行。
4.1 头文件骨架与实现文件骨架
先看头文件。原则是:只放必要的东西,能前向声明就别 include。
// widget.h #ifndef PROJECT_WIDGET_H_ #define PROJECT_WIDGET_H_ #include <cstddef> #include <string> namespace project { class Widget { public: Widget(); explicit Widget(std::size_t initial); ~Widget(); Widget(const Widget&) = delete; Widget& operator=(const Widget&) = delete; Widget(Widget&&) noexcept; Widget& operator=(Widget&&) noexcept; void reset(); void resize(std::size_t width, std::size_t height = 100); std::size_t size() const noexcept { return size_; } // 一行完事的,类内 const std::string& name() const noexcept { return name_; } static std::size_t alive_count() noexcept; // 声明 private: std::size_t size_ = 0; std::string name_; inline static std::size_t alive_ = 0; // C++17,类内定义 }; } // namespace project #endif // PROJECT_WIDGET_H_对应实现文件:
// widget.cpp #include "widget.h" #include <utility> namespace project { Widget::Widget() { ++alive_; } Widget::Widget(std::size_t initial) : size_(initial) { ++alive_; } Widget::~Widget() { --alive_; } Widget::Widget(Widget&& other) noexcept : size_(other.size_), name_(std::move(other.name_)) { ++alive_; other.size_ = 0; } Widget& Widget::operator=(Widget&& other) noexcept { if (this != &other) { size_ = other.size_; name_ = std::move(other.name_); other.size_ = 0; } return *this; } void Widget::reset() { size_ = 0; name_.clear(); } void Widget::resize(std::size_t width, std::size_t height) { size_ = width * height; } std::size_t Widget::alive_count() noexcept { return alive_; } } // namespace project几个设计意图值得说明。第一,size()和name()只有一行,放类内让编译器有内联机会,同时也省去 .cpp 里的重复签名。第二,构造函数、析构函数、移动操作全放 .cpp,因为这些函数往往会随着类长大而变复杂,放外面以后改动不需要重新编译所有包含头文件的翻译单元。第三,alive_用inline static在类内定义,避免了“忘了在某个 .cpp 里补定义”这个经典问题。
4.2 用编译器和 nm 验证你的假设
纸上谈兵没意义,实际验证一下。假设你写了个只有头文件的库,想确认某个函数到底有没有生成强符号:
# 只编译,不链接 g++ -std=c++17 -c main.cpp -o main.o # 看所有和 Widget 相关的符号 nm -C main.o | grep Widget # 看未定义符号(U 开头),这些是链接器要去找的 nm -C -u main.o | grep Widget如果draw()是类外定义且没有链接 widget.o,你会看到:
U Widget::draw()如果它被正确地实现在 widget.o 里并被链接进来,U会消失,变成T。
还有个更实用的排查手段:用-fno-inline关掉优化,看看符号的变化,能帮你判断某个“找不到符号”的问题是不是因为 inline 引起的。另外,objdump -d可以反汇编看实际生成了什么,虽然日常排查用不着,但理解 inline 到底做没做的时候很直观。
提示:我给团队定的规矩是,遇到链接错误先跑一遍
nm -C -u,把未定义符号列出来。九成的链接问题在这一步就能定位——是压根没定义,还是签名对不上,还是忘了把 .o 加进链接命令。
4.3 Pimpl:把编译依赖关在门外
类内声明和类外定义的差别,在编译时间上体现得最明显。头文件里改一行函数体,所有包含它的翻译单元全部重编。项目大了之后,一次全量编译几分钟,改个日志语句等半分钟,这种体验非常劝退。
Pimpl(Pointer to Implementation)就是解决这个的经典手法:头文件里只留一个指向实现的指针,真正的成员和实现全藏在 .cpp 里。
// engine.h #ifndef PROJECT_ENGINE_H_ #define PROJECT_ENGINE_H_ #include <memory> namespace project { class Engine { public: Engine(); ~Engine(); Engine(Engine&&) noexcept; Engine& operator=(Engine&&) noexcept; void start(); int status() const; private: struct Impl; // 只声明,不定义 std::unique_ptr<Impl> impl_; }; } // namespace project #endif// engine.cpp #include "engine.h" namespace project { struct Engine::Impl { int state = 0; int retries = 0; void doStart() { state = 1; retries = 0; } }; Engine::Engine() : impl_(std::make_unique<Impl>()) {} Engine::~Engine() = default; Engine::Engine(Engine&&) noexcept = default; Engine& Engine::operator=(Engine&&) noexcept = default; void Engine::start() { impl_->doStart(); } int Engine::status() const { return impl_->state; } } // namespace project这里有两个必须注意的点。析构函数一定要在 .cpp 里定义,因为unique_ptr<Impl>的析构需要Impl是完整类型,而头文件里Impl只有声明。如果你在头文件里写~Engine() = default;,编译器会在每个包含头文件的地方尝试生成析构代码,然后报 “incomplete type”。移动操作同理,也得放 .cpp 里= default。
代价是每个对象多一次堆分配、多一层间接访问。对性能敏感的类(比如容器里的元素、频繁创建的小对象)不适合 Pimpl,对配置类、服务类、跨模块接口这类“创建次数少、改动频率高”的类型非常划算。我自己的经验是,一个中等规模的项目引入 Pimpl 之后,增量编译时间能砍掉一半以上,改实现的收益远大于那点运行时开销。
5. 常见问题排查实录
这一节是我这些年攒下来的错题本,按报错信息归类,你遇到问题时可以直接对号入座。
5.1 undefined reference 的五种长相
链接错误里最常见的就这一句,但背后的原因至少有五种,排查方向完全不同。
第一种,声明了没定义。头文件里写了void draw();,.cpp 里忘了写实现。这种最容易发现,因为符号名对得上,缺的就是那份实现。
第二种,定义了但没参与链接。实现好好地写在 widget.cpp 里,但编译命令或者构建脚本里漏了这个文件。表现是符号在nm -u里是U,但nm遍所有 .o 都找不到T。检查一下 CMakeLists 或者 Makefile 里的源文件列表。
第三种,签名不一致。声明是int size() const;,定义写成了int size();,漏了 const。这俩在 C++ 里是两个不同的函数,不是重载关系就是完全不同的符号。链接器找Widget::size() const,你只提供了Widget::size(),自然找不到。这种错误特别隐蔽,尤其当代码风格不统一、有人习惯 const 有人不习惯的时候。
// 声明 int size() const; // 定义(错的) int Widget::size() { return n_; } // 编译能过,链接报错 // 定义(对的) int Widget::size() const { return n_; }第四种,命名空间没对上。声明在namespace project里,定义的时候忘了加命名空间,或者反过来。符号名会带上命名空间前缀,对不上就是找不到。
第五种,静态成员变量没定义。前面说过的static int count;,只有类内声明没有类外定义。这种错误的报错信息通常是undefined reference to 'Counter::count',看到静态成员的名字就该条件反射想到这条。
5.2 重复定义与 ODR 违规
跟undefined reference相对的是multiple definition,通常出现在这几类情况。
头文件里写了非 inline 的函数定义。不管是类外的普通函数,还是类外的= default,只要没有 inline 又没有放 .cpp,被两个以上翻译单元包含就会炸。
头文件里定义了非 inline 的全局变量。C++17 之前,头文件里写int g_counter = 0;是经典错误;C++17 之后可以用inline int g_counter = 0;。
同一个类在不同翻译单元里定义不一致。这是 ODR 违规里最难查的一种,因为它不报错,只是行为诡异。典型场景是用宏来控制类内定义:
// config.h struct Config { int level() const { return LEVEL; } // LEVEL 是宏 };A.cpp 编译时LEVEL是 1,B.cpp 编译时LEVEL是 2,两份Config::level都是 inline 弱符号,链接器随便挑一份留下。结果就是两个翻译单元对同一个函数的调用行为不同,而且没有任何编译警告。这类问题只能靠纪律来防:类的定义里不要依赖会变化的宏。
5.3 一张速查表收个尾
把常见现象、原因、处理方式整理成表,出问题时扫一眼:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
undefined reference to 'X::f()' | 声明了没定义 | 补 .cpp 实现 |
| 同上,但符号名少了 const | 签名不一致 | 检查 const、引用、参数类型 |
| 同上,符号名带命名空间 | 定义时漏了命名空间 | 补namespace X { ... } |
multiple definition of 'X::f()' | 头文件里非 inline 定义 | 改为类内定义或加 inline,或移到 .cpp |
multiple definition of 'X::~X()' | 类外= default漏了 inline | 补 inline,或移到 .cpp |
| 行为随翻译单元不同 | ODR 违规,宏导致类定义不一致 | 移除类定义里的宏依赖 |
incomplete type报在析构 | Pimpl 析构写在了头文件 | 析构移到 .cpp |
default argument given | 默认实参写了两遍 | 只保留声明处那一份 |
| 修改一行函数体导致全量重编 | 函数体在头文件里 | 移到 .cpp,必要时上 Pimpl |
最后分享一个我自己的习惯:新建一个类的时候,先把头文件写完,函数全部写成类内声明,然后在 .cpp 里一次性把实现补齐。补的过程中如果发现某个函数只有一两行、没有成员依赖、也不涉及头文件包含,再把它挪回类内。这个“先声明后反推”的顺序能有效避免“深思熟虑写了一堆 inline 结果发现某个函数需要 include 一堆头文件”的返工。
类内声明和类外定义这件事,说到底是在编译速度、代码组织、链接安全三者之间找平衡。没有哪种写法永远正确,只有当前项目阶段更合适的取舍。把规则吃透之后,你写下的每一个分号都是有意为之,而不是照着模板抄。