我最早对 C++ 默认成员函数这几个字有强烈感知,是因为一段看起来人畜无害的代码。类里只有 std::string 和 std::vector,我什么都没写,编译、链接、运行,一切都正常。可程序跑到某个边界条件时突然 double free,gdb 一追,问题出在一个隐式生成的拷贝动作上。同事扫了一眼就问:这个类你拷贝过吗?我当时愣了一下:拷贝就拷贝呗,系统不是会自动处理吗?就是这个“自动处理”四个字,让我后来踏踏实实重新过了一遍 C++ 类中默认成员函数的知识。
这篇是“类和对象”系列的中篇,上篇聊过封装和 this 指针,这一篇就聚焦传统说法里的 6 个默认成员函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、普通取地址运算符重载、const 取地址运算符重载。同时我也会把 C++11 之后移动构造和移动赋值对这套体系带来的冲击一起讲清楚。内容面向两类人:一类是正在学 C++ 的同学,需要把这些机制背后的原理补齐;另一类是准备面试或者已经在写业务代码的朋友,很多线上诡异崩溃,追根到底就是这些基础函数没吃透。
1. 编译器从未“没干活”:默认成员函数的全家福与生成条件
1.1 传统六大默认成员到底是哪六个
很多新手以为“我没写成员函数,这个类就是空的”,其实编译器在背后早就给你塞了一堆东西。一个类如果某个成员函数没有显式声明,编译器在合适的条件下会隐式生成一个默认版本。传统 C++ 教材里常说的 6 个默认成员函数,我直接列成一张表:
| 函数 | 典型调用场景 | 默认行为 |
|---|---|---|
| 默认构造函数 | T obj; | 调用成员各自的默认构造函数,内置类型不初始化 |
| 析构函数 | 对象生命周期结束 | 调用成员各自的析构函数,函数体为空 |
| 拷贝构造函数 | T t2(t1);T t2 = t1; | 逐成员拷贝构造 |
| 拷贝赋值运算符 | t2 = t1; | 逐成员拷贝赋值 |
取地址运算符operator& | &obj | 返回对象真实地址 |
const 取地址运算符operator& const | &constObj | const 对象上返回真实地址 |
一个最容易答错的点是:默认构造函数可不是“编译器一定生成”。如果你自己写了一个带参构造函数,默认构造函数就不会自动出现,因为编译器觉得你已经接管了对象的初始化策略,它没必要再补一个无参版本。下面的代码直接编译失败:
class A { public: A(int x) : m_x(x) {} private: int m_x; }; int main() { A a; // 错误:没有默认构造函数 A b(10); // 正确 }1.2 编译器生成这些函数的“按需触发”原则
这里的“按需触发”是指:编译器只在代码实际用到某个成员函数时才生成对应版本,而不是无脑地把六个全生成一遍。你定义一个空类:
class Empty { // 什么都没写 };然后写下Empty e1; Empty e2(e1); e2 = e1;,程序能编过能跑,就是因为编译器分别生成了默认构造、拷贝构造、拷贝赋值。如果你从头到尾没复制过对象,拷贝构造函数可能就不会被实例化,但这属于实现细节,不需要过度纠结。
真正需要理解的是:默认生成的行为是“逐成员操作”。对于类类型成员,会调用成员自己的构造函数、析构函数、拷贝构造或拷贝赋值;对于 int、double 这类内置类型成员,默认构造函数里不初始化它们,这也是为什么裸指针成员很容易变成野指针的根源。
1.3 什么时候编译器会“罢工”不再生成
编译器不是永远老实干活,当它觉得默认版本无法保证语义正确时,就会拒绝生成,甚至把隐式生成的函数标记为 deleted。常见的“罢工”场景有:
- 成员里有
const对象或引用类型时,默认拷贝赋值运算符会被删除,因为让 const 成员或引用成员重新赋值,语言层面就不允许。 - 类显式声明了带参构造函数,默认构造函数不会自动生成(前面已经说过)。
- 基类或成员对象的析构函数是私有的,派生类或包含者无法生成默认析构函数。
- 基类的拷贝构造/拷贝赋值是私有的,派生类对应的默认版本同样生成不出来。
这些规则本质上是一句话:编译器不能保证默认行为合法时,宁可让你编译失败,也不悄悄生成一个错误版本。这个设计比很多程序员想象的更保守,但也更安全。理解这一点,以后看到编译错误提示“use of deleted function”就不会一脸懵。
2. 构造函数和析构函数:对象生命周期里的两个锚点
2.1 初始化列表的三种“个股知识”
构造函数是对象创建时的入口,但真正的初始化发生在初始化列表里。初始化列表经常被新手忽略,尤其这三条:
第一条,const成员、引用成员没有默认值,只能在初始化列表里初始化,不能放到函数体里赋值。我见过不少同学写出“先默认构造,再在构造函数体内赋值”的方案,结果明明语法上不对还跟编译器较劲。
第二条,初始化顺序不是按初始化列表的书写顺序,而是按成员在类中的声明顺序。下面这段代码就是经典的“隐藏炸弹”:
class B { public: B(int x) : m_b(x), m_a(m_b) {} private: int m_a; int m_b; };m_a被m_b初始化时,m_b还没初始化,所以m_a拿到的是一个未定义值。编译器开-Wreorder会警告这种顺序问题,一定要重视。
第三条,用初始化列表能少一次默认构造。如果成员是类类型,先在初始化列表里直接构造,成员只被构造一次;如果放到函数体里赋值,成员先被默认构造,再被赋值一次,多了一次开销。对性能敏感的场景,这个差异会被放大。
2.2 析构顺序与 RAII:很多人用错了释放时机
析构函数是对象生命周期结束时最后调用的成员函数。它的调用时机经常被问成面试题:局部对象离开作用域时析构,堆对象 delete 时析构,临时对象在完整表达式结束时析构。析构顺序也要背熟:
- 同一个作用域里的局部对象,按声明顺序的逆序析构,先声明的后死。
- 类内部的成员对象,按声明顺序的逆序析构,后声明的先死。
- 有继承关系时,先执行派生类析构函数体,再析构成员,最后调用基类析构。
这些顺序一旦记反,组装一个包含多个成员对象的类时,很容易出现“A 成员已经释放了,B 成员析构时还要用 A”的诡异崩溃。
RAII(Resource Acquisition Is Initialization)是析构函数最重要的应用。把堆内存、文件句柄、互斥锁在构造函数里获取,在析构函数里释放,那么无论函数是正常 return 还是中途抛异常,只要栈展开发生,析构函数都会被调用,资源一定被释放。现代 C++ 里 std::string、std::vector、std::unique_ptr 都是靠这套机制工作的,这也是为什么“零法则”能成立的前提。
2.3 虚析构:多态删除前的最后一道保险
一个类如果要被继承,而且你很可能通过基类指针 delete 派生类对象,基类析构函数必须声明为 virtual。否则 delete 时只调用基类析构,派生类成员对象的析构函数不会被调用,资源就泄漏了。更严格地说,这是未定义行为,实践表现通常是泄漏加上各种莫名其妙的崩溃。
反过来,如果类不打算作为基类,就没有必要加 virtual 析构。加了 virtual 意味着类里多一个虚函数表指针,对象体积变大,拷贝、内存布局都会受影响。所以我的建议很简单:打算多态基类就加 virtual 析构,不打算继承就别加。不要“为了防止万一”每个类都加,那是没有意义的防御。
3. 拷贝构造函数与拷贝赋值运算符:双胞胎也有性格差异
3.1 触发时机的边界:初始化是构造,再赋才是赋值
拷贝构造函数和拷贝赋值运算符经常被混为一谈,但触发场景完全不同。看这段代码:
String s1("hello"); String s2(s1); // 拷贝构造:s2 正在被创建 String s3 = s1; // 拷贝构造:s3 正在被创建,这里是初始化,不是赋值 s3 = s1; // 拷贝赋值:s3 已存在,现在重新赋值区分标准就是一个对象是否“刚从无到有”。正在创建的那个对象,无论语法上是String s2(s1)还是String s3 = s1,走的都是拷贝构造。只有两边都已经存在,才走拷贝赋值。这个边界在函数传参和返回值时也一样:值传递参数、vector 扩容、按值返回,都会触发拷贝操作。如果类禁掉了拷贝,很多看似正常的代码直接就编译不过。
3.2 浅拷贝引发的 double free:一条血泪回溯
默认生成的拷贝构造函数和拷贝赋值运算符,对内置类型成员直接按位拷贝,对类类型成员调用成员的拷贝函数,这就是浅拷贝。浅拷贝本身不是问题,问题出在指针成员上。看这个简化的例子:
class String { public: String(const char* str) : m_data(new char[std::strlen(str) + 1]) { std::strcpy(m_data, str); } ~String() { delete[] m_data; } private: char* m_data; };如果让编译器默认生成拷贝构造,String s2(s1)会把s2.m_data直接复制成跟s1.m_data相同的地址值。两个对象里的指针指向同一块堆内存,谁先析构谁就把这块内存 delete 了,后析构的那个再 delete 一次,double free 就发生了。这有点像两个人拿同一把钥匙开同一扇门,一个走了顺手把门锁了,另一个人再掏钥匙就失灵了。
一旦出现 double free,gdb 栈信息经常指向析构函数的 delete,如果没意识到是拷贝引起的问题,排查方向会跑偏很久。我在实际项目里见过太多次这种 case,所以每次看到手写了析构函数的类,第一反应就是检查拷贝构造函数和拷贝赋值运算符是不是也手写了。
3.3 深拷贝的完整写法与异常安全设计
解决浅拷贝的标准思路是深拷贝:让每个对象都拥有一份独立的内存。手写一个完整 String 类时,拷贝构造要重新开一块同样大小的内存再拷贝数据;拷贝赋值要处理得更细一些:
String& operator=(const String& other) { if (this != &other) { String tmp(other); // 拷贝构造临时对象 std::swap(m_data, tmp.m_data); // 交换指针 std::swap(m_size, tmp.m_size); } return *this; }这里用了 copy-and-swap 手法。先构造一个临时对象,如果构造过程抛异常,原对象完全不受影响;交换后临时对象持有了旧数据,函数结束时会自动析构释放旧内存。这样既处理了自赋值,也保证了异常安全。很多同学喜欢先delete[] m_data;再 new 新内存,但一旦 new 抛异常,原对象数据已经丢了,这种写法在异常安全上是减分的。
说到自赋值,String s; s = s;这种代码不多,但函数调用链条里很容易出现两个引用指向同一对象的情况。传统写法里if (this != &other)是必须的,copy-and-swap 则天然安全,不需要显式判断,代码更简洁。
4. 被低估的 operator&:两个取地址重载的隐藏用途
4.1 为什么标准库要专门造一个 std::addressof
传统六大默认成员函数里,最容易被人遗忘的两个是普通取地址运算符重载和 const 取地址运算符重载。因为在绝大多数代码里,&obj就是拿对象真实地址,不需要特殊处理。但 C++ 允许你重载operator&,一旦某个类真的重载了它,&obj返回的就不再是真实地址,而是重载版本里返回的东西。
标准库很多地方需要拿到对象真实的地址,比如std::addressof这个函数就是专门干这个的。它的存在本身就是一个信号:C++ 默认了“有人会写出重载取地址的类”,标准库必须给出一条绕开它的路。这也是默认成员函数的一个特例——你知道它的存在,不是为了天天重载它,而是为了理解为什么会有std::addressof。
4.2 const 版本与非 const 版本如何共存
operator&可以像普通成员函数一样根据 const 限定符区分两个版本:
class Guarded { public: Guarded() = default; Guarded* operator&() { return nullptr; } const Guarded* operator&() const { return nullptr; } }; int main() { Guarded g; const Guarded cg; Guarded* p1 = &g; // 调用非 const 版本,p1 是 nullptr const Guarded* p2 = &cg; // 调用 const 版本,p2 是 nullptr Guarded* real = std::addressof(g); // 真实地址 return 0; }这段代码里的&g返回 nullptr,看起来完全不讲武德,但确实合法。这种重载在实际项目中并非完全没有用途,某些调试框架、序列化框架或代理类会用它拦截取地址操作,返回一个代理对象来控制对真实对象的访问。不过这属于高级玩法,绝大多数业务代码真的用不上。
4.3 业务代码到底该不该重载取地址
我的结论是:业务代码尽量不要重载operator&,除非你在写某种框架、调试工具或非常特殊的代理结构。原因有两点:
第一,重载取地址运算符会破坏直觉,团队里其他人读代码时默认&obj就是地址,一旦返回别的什么,很容易埋雷。第二,标准库、第三方模板代码在内部取地址时通常用std::addressof,但并不是所有代码都这么严谨,你重载了它就等于在给所有使用这个类的地方增加风险。
理解这两个默认成员函数的价值在于:当面试官问“六大默认成员函数是哪些”时,你能把最后这两个取地址重载讲明白,并且能解释标准库为什么要提供std::addressof,这就已经超过大多数只会背定义的候选人了。
5. C++11 之后的默认成员函数:移动语义带来的规则变化
5.1 移动构造和移动赋值是不是“第六、第七个默认成员”
C++11 引入移动语义后,类又多了两个可隐式生成的成员函数:移动构造函数和移动赋值运算符。于是就有了口径之争:默认成员函数到底是六个还是八个?
传统教材说六个,因为早年只有六个,而且取地址两个一直被单列。现代 C++ 视角下,移动构造和移动赋值的重要程度远高于取地址重载,很多工程团队在代码规范里要求“如有资源管理需求,五个特殊成员函数一起考虑”,也就是构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值,六个核心函数。
所以准确的说法是:传统默认成员是六,其中含两个取地址重载;C++11 之后理论上的默认成员扩展到八,但两个取地址通常不参与资源管理讨论。面试时先把两种口径都摆出来,然后按现代 C++ 的视角重点讲移动相关的规则,会更有说服力。
5.2 一个自定义析构函数如何“连坐”抑制移动函数
移动构造和移动赋值的自动生成规则非常严格。只要类显式声明了析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符中的任何一个,编译器就可能不再生成移动函数。更常见的现象是:你只是写了一个自定义析构函数,移动构造和移动赋值就静默消失了,然后std::move操作会退化为拷贝。
这个规则让很多人踩坑:老代码里为了打印日志给类加了析构函数,性能敏感路径上的拷贝突然变多。所以现代 C++ 实践里有一条铁律:如果类需要自定义析构函数,大概率拷贝构造、拷贝赋值、移动构造、移动赋值都需要一起考虑,这就是所谓的“三/五法则”。如果不需要资源管理,那就一个特殊成员函数都别写,全部交给默认生成,这是“零法则”。
5.3 = default 与 = delete:把选择权拿回自己手里
显式要求编译器生成默认版本,用= default;显式禁止某个成员函数,用= delete。这两个语法大量出现在现代 C++ 项目里:
class NonCopyable { public: NonCopyable() = default; NonCopyable(const NonCopyable&) = delete; NonCopyable& operator=(const NonCopyable&) = delete; }; class Defaulted { public: Defaulted() = default; Defaulted(Defaulted&&) = default; Defaulted& operator=(Defaulted&&) = default; };= delete很适合表达“这个类不能拷贝”的意图,比如锁对象、文件句柄、单例资源。它比把拷贝函数放到 private 里不定义的做法更明确,错误信息也更友好。= default则适合在自定义了一个构造函数后,仍然希望保留默认构造能力时使用。
移动构造和移动赋值通常应标记为noexcept,因为标准容器在扩容时如果移动构造不会抛异常,会优先移动元素而不是拷贝;一旦拷贝开销大、移动又不 noexcept,vector 扩容就会变成性能灾难。这是我建议每个人在项目里盯一条规则:所有资源转移型的移动函数,除非确实可能抛异常,否则都加上 noexcept。
6. 面试与实战中最常翻车的默认成员函数细节
6.1 空类 sizeof = 1 背后的对象模型要求
经典面试题:class Empty {};的sizeof(Empty)是多少?答案通常不是 0,而是 1。原因是 C++ 标准要求同一个类型的两个不同对象必须有不同地址,如果空类大小为 0,那数组Empty arr[10]里所有对象的地址都一样,整个对象模型就崩了。所以编译器给空类填充了 1 字节占位。
如果类里有虚函数,情况又不一样。只要有一个 virtual 函数,对象内部通常多一个虚函数表指针 vptr,在 64 位平台上是 8 字节。所以“空类大小 1”只适用于没有任何非静态数据成员、也没有虚函数的类。这些细节单独看都不复杂,但组合在一起问,还是能筛掉不少基础不牢的人。
6.2 const/引用成员如何让默认拷贝赋值直接消失
我在第一节提到过:成员中有 const 或引用时,默认拷贝赋值运算符会被删除。更完整地说:
| 成员情况 | 默认构造函数 | 拷贝赋值运算符 |
|---|---|---|
| 普通 int、指针 | 生成但内置类型不初始化 | 生成,逐成员赋值 |
| const 成员 | 生成,但要想办法初始化 const 成员 | 不生成,被 delete |
| 引用成员 | 不生成,引用必须初始化 | 不生成,被 delete |
一个包含引用成员的类,默认构造函数无法生成,因为语言层面没有“引用默认值”这种东西;拷贝赋值也无法生成,因为赋值不会重新绑定引用。这种类要么自己定义构造函数、放弃拷贝,要么改成用指针或std::reference_wrapper持有对象,按实际需求重新设计。
6.3 一套实用的特殊成员函数决策流程
讲了这么多,落到项目里怎么用?我的习惯是给团队立这样一套判断流程:
第一,问这个类是否需要管理资源。如果成员都是std::string、std::vector、std::unique_ptr这类自带 RAII 的对象,那一个特殊成员函数都不要手写,遵循零法则,编译器生成的默认版本就是最优解。
第二,如果类确实管理裸资源,比如自研内存池、底层句柄封装,那就要把析构、拷贝构造、拷贝赋值、移动构造、移动赋值五个函数当作一个整体来设计,不要只补一个析构函数。
第三,类不打算被拷贝,比如锁、文件句柄、单例资源,直接= delete拷贝构造和拷贝赋值,把意图写在代码里,别让编译器生成隐藏的坑。
第四,涉及多态基类时,一定检查析构函数是否 virtual,同时注意拷贝和移动是否要禁止,避免基类不小心被切片拷贝。
这些规则是我排查线上问题时的底层依据。有一次服务崩溃,定位到最后就是某个类自定义了析构函数,但忘了处理移动构造,导致 vector 扩容时走了浅拷贝式的“移动”,两个对象共享了同一块堆内存,随后 double free。如果当时类能严格遵守三/五法则或者干脆用智能指针管理资源,这个事故完全不会发生。
C++ 默认成员函数这东西,平时看不见摸不着,但程序崩溃时,救你的往往就是这些最基础的概念。写类之前先问自己一句:这个类能拷贝吗?拷贝了是深还是浅?移动安全吗?析构会释放谁的内存?把这些问题想清楚,很多隐蔽的内存问题根本不会走到你面前。