我最早对 C 默认成员函数这几个字有强烈感知是因为一段看起来人畜无害的代码。类里只有 std::string 和 std::vector我什么都没写编译、链接、运行一切都正常。可程序跑到某个边界条件时突然 double freegdb 一追问题出在一个隐式生成的拷贝动作上。同事扫了一眼就问这个类你拷贝过吗我当时愣了一下拷贝就拷贝呗系统不是会自动处理吗就是这个“自动处理”四个字让我后来踏踏实实重新过了一遍 C 类中默认成员函数的知识。这篇是“类和对象”系列的中篇上篇聊过封装和 this 指针这一篇就聚焦传统说法里的 6 个默认成员函数默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、普通取地址运算符重载、const 取地址运算符重载。同时我也会把 C11 之后移动构造和移动赋值对这套体系带来的冲击一起讲清楚。内容面向两类人一类是正在学 C 的同学需要把这些机制背后的原理补齐另一类是准备面试或者已经在写业务代码的朋友很多线上诡异崩溃追根到底就是这些基础函数没吃透。1. 编译器从未“没干活”默认成员函数的全家福与生成条件1.1 传统六大默认成员到底是哪六个很多新手以为“我没写成员函数这个类就是空的”其实编译器在背后早就给你塞了一堆东西。一个类如果某个成员函数没有显式声明编译器在合适的条件下会隐式生成一个默认版本。传统 C 教材里常说的 6 个默认成员函数我直接列成一张表函数典型调用场景默认行为默认构造函数T obj;调用成员各自的默认构造函数内置类型不初始化析构函数对象生命周期结束调用成员各自的析构函数函数体为空拷贝构造函数T t2(t1);T t2 t1;逐成员拷贝构造拷贝赋值运算符t2 t1;逐成员拷贝赋值取地址运算符operatorobj返回对象真实地址const 取地址运算符operator constconstObjconst 对象上返回真实地址一个最容易答错的点是默认构造函数可不是“编译器一定生成”。如果你自己写了一个带参构造函数默认构造函数就不会自动出现因为编译器觉得你已经接管了对象的初始化策略它没必要再补一个无参版本。下面的代码直接编译失败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”的诡异崩溃。RAIIResource 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 freegdb 栈信息经常指向析构函数的 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. C11 之后的默认成员函数移动语义带来的规则变化5.1 移动构造和移动赋值是不是“第六、第七个默认成员”C11 引入移动语义后类又多了两个可隐式生成的成员函数移动构造函数和移动赋值运算符。于是就有了口径之争默认成员函数到底是六个还是八个传统教材说六个因为早年只有六个而且取地址两个一直被单列。现代 C 视角下移动构造和移动赋值的重要程度远高于取地址重载很多工程团队在代码规范里要求“如有资源管理需求五个特殊成员函数一起考虑”也就是构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值六个核心函数。所以准确的说法是传统默认成员是六其中含两个取地址重载C11 之后理论上的默认成员扩展到八但两个取地址通常不参与资源管理讨论。面试时先把两种口径都摆出来然后按现代 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因为标准容器在扩容时如果移动构造不会抛异常会优先移动元素而不是拷贝一旦拷贝开销大、移动又不 noexceptvector 扩容就会变成性能灾难。这是我建议每个人在项目里盯一条规则所有资源转移型的移动函数除非确实可能抛异常否则都加上 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 默认成员函数这东西平时看不见摸不着但程序崩溃时救你的往往就是这些最基础的概念。写类之前先问自己一句这个类能拷贝吗拷贝了是深还是浅移动安全吗析构会释放谁的内存把这些问题想清楚很多隐蔽的内存问题根本不会走到你面前。
