
前言「默认成员函数」是 C 类机制里最容易被跳过、又最容易出事的一块。很多人写完一个带new的类只补了一个析构函数就以为万事大吉结果程序一跑就是双重释放也有人听说过「三法则」「五法则」却说不清到底哪个函数在什么条件下会被删除。先把标题里的三个概念对齐一下。默认成员函数defaulted member function指的是编译器在满足条件时隐式声明implicitly declared的那几个特殊成员函数C98 时代常说的是四个默认构造、拷贝构造、拷贝赋值、析构C11 加入移动语义后常被一起讨论的是六个再加上移动构造、移动赋值。它们和「用 default显式要求的函数」不是一回事——后者是用户声明的只是让编译器生成定义。三个最常见的误解「只要我写了析构函数编译器就不会再生成拷贝构造」——错。写了析构函数只是让拷贝构造的隐式声明被弃用deprecated它仍然会被生成行为是逐成员拷贝。真正会让拷贝构造被删除的是「声明了移动构造或移动赋值」。「std::move会把对象搬走」——std::move只是一个到右值引用的类型转换它本身不做任何事搬不搬由被调用的函数决定。「自定义析构函数不影响性能」——析构函数的存在会抑制移动构造的隐式声明导致容器扩容时退化成拷贝。本文按「隐式声明的规则 → 三/五/零法则 → 拷贝与移动的行为差异 → 完整可编译示例」的顺序讲清这些规则最后汇总 8 个真会踩的坑。代码基准C17。一、隐式声明规则什么时候生成什么时候删除这是全篇最需要记住的一张表。表中的「用户声明」包括 default和 delete也包括手写定义。隐式声明的函数生成条件被定义为删除的情况默认构造类中没有用户声明的任何构造函数不属于「删除」机制不能生成时不会隐式声明析构函数总是隐式声明基类或成员的析构不可访问或被删除拷贝构造没有用户声明的拷贝构造、移动构造、移动赋值声明了移动构造或移动赋值某基类/成员的拷贝构造不可用拷贝赋值没有用户声明的拷贝赋值、移动构造、移动赋值声明了移动构造或移动赋值某基类/成员不可赋值如const成员、引用成员移动构造没有用户声明的拷贝构造、拷贝赋值、移动构造、移动赋值、析构某基类/成员的移动构造不可用移动赋值没有用户声明的拷贝构造、拷贝赋值、移动赋值、移动构造、析构某基类/成员不可移动赋值从这张表能读出三条「反直觉」的结论写析构函数抑制的是移动不是拷贝。这是最经典的陷阱一个管理资源的类写了析构函数移动构造就不再被隐式声明于是std::vector扩容时只能调用拷贝构造因为移动构造不存在重载决议会退回到拷贝构造。写移动杀掉拷贝。一旦显式写了移动构造或移动赋值拷贝构造和拷贝赋值都被定义为删除deleted此时Buffer b a;会直接编译报错——编译器不想在你明确表达了「要移动」之后还悄悄替你拷贝。const成员和引用成员会让拷贝赋值被删除因为赋值运算符无法给它们重新绑定/赋值。二、三法则、五法则与零法则三法则Rule of Three如果一个类需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个那它多半三个都需要。理由很直接需要自定义析构说明类持有需要手工释放的资源裸指针、文件句柄、锁。而编译器生成的拷贝是逐成员拷贝会把那个指针按位复制一份——于是两个对象指向同一块内存析构两次就是双重释放。五法则Rule of FiveC11 之后补上移动构造和移动赋值道理与三法则相同——需要拷贝语义的类通常也需要移动语义。零法则Rule of Zero能用现成的 RAII 类型std::string、std::vector、std::unique_ptr、std::shared_ptr就不要手写裸指针成员。这样编译器生成的六个默认函数全是对的一个都不用写。这是现代 C 最推荐的形态。法则适用场景需要手写几个函数零法则成员全是 RAII 类型0三法则自己管理资源且不需要移动语义3析构、拷贝构造、拷贝赋值五法则自己管理资源且需要移动语义5再加移动构造、移动赋值 default和 delete是两个关键工具 default放在类内函数是「用户声明但非用户提供not user-provided」可以保持类为平凡类型trivial放在类外定义行为略有不同。 delete用来主动禁用——比如拷贝构造 delete表示这个类型只能移动。写 delete比「声明为 private 且不定义」的老写法好因为报错发生在编译期而非链接期而且错误信息清楚得多。三、拷贝与移动行为差异与 noexcept隐式定义的拷贝构造和拷贝赋值是逐成员memberwise操作对每个基类和每个非静态数据成员调用它自己的拷贝构造拷贝赋值。隐式定义的移动构造和移动赋值也是逐成员的但每个成员会被当成右值去构造赋值。这一点对内置类型如int*没有区别——拷贝指针和移动指针都是复制地址区别在于它不会把源指针置空。所以如果你自己管理裸指针只依赖隐式移动构造是不够的源对象仍然指向那块内存之后两个对象析构同一个指针同样是双重释放。手写移动构造时必须显式把源对象置为可析构的空状态。关于noexcept移动构造函数应当标记noexcept。原因不是「更快」而是容器的选择策略。标准规定std::vector在重新分配时使用std::move_if_noexcept语义当元素的移动构造可能抛异常、而拷贝构造可用时实现会选择拷贝而不是移动以获得强异常安全保证。libstdc、libc、MSVC STL 都遵循这个策略。所以static_assert(std::is_nothrow_move_constructibleBuffer::value, 移动构造必须是 noexcept否则 vector 扩容会退化成拷贝);维护后对象的约定移动之后的源对象处于「有效但未指定valid but unspecified」状态——它必须能被安全地析构和赋值但你不应该读它的具体值。标准只保证这么少所以「std::move之后还是原值」这种假设不可移植。实战五法则的完整示例下面这个Buffer自己管理一块int数组把五法则写全包含自我赋值保护、异常安全与noexcept移动。#include algorithm #include cstddef #include iostream #include type_traits #include utility #include vector class Buffer { public: Buffer() noexcept default; explicit Buffer(std::size_t n) : data_(n ? new int[n]() : nullptr), size_(n) {} ~Buffer() { delete[] data_; } // 1) 析构 // 2) 拷贝构造先分配再复制保证异常安全 Buffer(const Buffer other) : data_(nullptr), size_(0) { if (other.size_ 0) { data_ new int[other.size_]; // 若抛异常data_ 仍是 nullptr std::copy(other.data_, other.data_ other.size_, data_); size_ other.size_; } } // 3) 拷贝赋值拷贝并交换copy-and-swap天然自我赋值安全 Buffer operator(const Buffer other) { if (this ! other) { Buffer tmp(other); // 先做可能抛异常的部分 swap(tmp); // 再交换不抛异常 } return *this; } // 4) 移动构造必须 noexcept并且要把源对象置空 Buffer(Buffer other) noexcept : data_(other.data_), size_(other.size_) { other.data_ nullptr; other.size_ 0; } // 5) 移动赋值先释放自己的资源再接管 Buffer operator(Buffer other) noexcept { if (this ! other) { delete[] data_; data_ other.data_; size_ other.size_; other.data_ nullptr; other.size_ 0; } return *this; } void swap(Buffer other) noexcept { std::swap(data_, other.data_); std::swap(size_, other.size_); } std::size_t size() const noexcept { return size_; } int operator[](std::size_t i) { return data_[i]; } const int operator[](std::size_t i) const { return data_[i]; } private: int* data_ nullptr; std::size_t size_ 0; }; static_assert(std::is_nothrow_move_constructibleBuffer::value, Buffer 的移动构造必须是 noexcept); int main() { Buffer a(3); a[0] 10; a[1] 20; a[2] 30; Buffer b a; // 拷贝构造 Buffer c std::move(a); // 移动构造a 此后处于有效但未指定状态 std::cout b.size b.size() c.size c.size() \n; b b; // 自我赋值必须安全 std::cout self-assign ok, b.size b.size() \n; std::vectorBuffer v; v.reserve(1); v.push_back(Buffer(2)); // 触发一次扩容走 noexcept 的移动构造 v.push_back(Buffer(2)); std::cout v.size v.size() \n; return 0; }编译与运行g -stdc17 -Wall -Wextra -O2 buffer.cpp -o buffer clang -stdc17 -Wall -Wextra -O2 buffer.cpp -o buffer一个值得知道的标准细节C17 起返回纯右值时有保证的拷贝省略guaranteed copy elision。例如函数体里写return Buffer(2);或return {};时不会调用任何拷贝移动构造对象直接在调用方的存储上构造。这意味着即使把拷贝构造和移动构造都 delete这种写法依然能编译。C17 之前这不是保证只是编译器可以做的优化copy elision / RVO标准并不强制。常见坑点1. 只写析构不写拷贝构造❌class Buffer { public: explicit Buffer(std::size_t n) : p_(new int[n]) {} ~Buffer() { delete[] p_; } // 没有拷贝构造隐式生成的是逐成员拷贝 private: int* p_; }; // Buffer a(4); Buffer b a; - b.p_ 与 a.p_ 指向同一块内存析构两次这是UB标准不保证任何行为典型症状是double free or corruption。✅ 按三法则补齐拷贝构造与拷贝赋值或者改用std::vectorint成员零法则。2. 移动构造忘了把源对象置空❌Buffer(Buffer other) noexcept : data_(other.data_), size_(other.size_) {} // 没有 other.data_ nullptr; 两个对象都指向同一块内存✅ 置空other.data_ nullptr; other.size_ 0;。3. 移动构造没标 noexcept❌Buffer(Buffer other) : data_(other.data_), size_(other.size_) { ... }✅Buffer(Buffer other) noexcept : ...。没有noexcept时std::vector扩容会退回到拷贝标准规定的std::move_if_noexcept策略且这个退化是静默的不会报错。4. 拷贝赋值漏掉自我赋值检查❌Buffer operator(const Buffer o) { delete[] data_; // 如果 o this数据已经没了 data_ new int[o.size_]; // 读的是已释放内存UB return *this; }✅ 用拷贝并交换copy-and-swap或者先判断if (this ! other)。5. 拷贝构造参数按值传递❌Buffer(Buffer other) { ... }—— 编译错误GCC 报 copy constructor must pass its first argument by reference。按值传参本身就要调用拷贝构造会无限递归。✅Buffer(const Buffer other)。要不要const取决于你是否需要窃取源对象的内容绝大多数情况都该加const。6. 多态基类没有虚析构函数❌struct Base { ~Base() {} }; struct Derived : Base { std::vectorint big; }; Base* p new Derived(); delete p; // UB只调用了 Base 的析构Derived 的成员没被析构这是UB标准不保证任何行为症状是内存泄漏或堆结构损坏。✅virtual ~Base() default;。反过来也一样如果析构函数不是虚的就不要通过基类指针delete。7. 值传递造成对象切片❌void handle(Base b) { } // 按值传参Derived 的部分被切掉 Derived d; handle(d); // 只拷贝了 Base 子对象✅ 形参用引用或指针void handle(const Base b)。如果确实需要拷贝用虚的clone()之类的手段显式要一份多态副本。8. 用 memset / memcpy 处理非平凡类型❌Buffer b(4); Buffer c; std::memcpy(c, b, sizeof b); // 含非平凡成员时是 UB对含有非平凡拷贝构造析构的类型标准里称为非平凡可复制类型用memcpy/memset复制或清零是UB标准不保证任何行为。✅ 用拷贝构造或赋值运算符。只有在类型满足「平凡可复制trivially copyable」时memcpy才是合法的。总结要点结论隐式声明默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值共六个关键规则声明移动 → 拷贝被删除声明析构 → 移动不再隐式声明拷贝行为逐成员拷贝若成员是不可赋值的const/引用拷贝赋值被删除移动行为逐成员移动但不会自动把源指针置空必须自己写noexcept移动构造标noexcept否则vector扩容按标准退化为拷贝三/五/零法则管理资源就写五法则不管理资源就写零法则移动后状态「有效但未指定」只能析构或重新赋值不要读值C17 变化返回纯右值时有保证的拷贝省略连 delete的拷贝构造也能通过判断一个类该写哪些默认成员函数只需要问一句话这个类持有需要手工释放的资源吗如果答案是「不」那就是零法则什么都别写如果是「是」就按五法则一次写全并且把移动构造标上noexcept、把源对象置空。至于「哪个函数会被隐式删除」上面那张表可以当速查表用——真正的坑几乎都出在「写了析构却期待移动还在」这一条上。