☰
Rule of Three/Five/Zero:六个特殊成员与编译器的隐式生成陷阱
2026/10/12 3:47:44 网站建设 项目流程

「我的类明明写了std::move,为什么性能没上去?」答案常常藏在一条反直觉的规则里:只要你给类手写了一个析构函数,编译器就不会再隐式生成移动构造/移动赋值,于是std::move后接的「移动」其实是一次昂贵的深拷贝。这是 Rule of Three/Five/Zero 这套规则的核心:理解编译器在何时帮你生成、何时「罢工」,才能既写对又写快。这篇把六个特殊成员(special member functions)和它们的生成规则一次讲清,并用实跑代码把那条性能陷阱钉死。

1. 引子:六个特殊成员函数

C++ 会给类自动生成一组特殊成员函数(special member functions,特殊成员函数)。它们不需要你写,编译器按规则替你合成:

特殊成员签名(典型)作用
默认构造T()无参构造
析构~T()释放资源
拷贝构造T(const T&)用同类型对象构造新对象
拷贝赋值T& operator=(const T&)把同类型对象赋给已存在的对象
移动构造T(T&&)noexcept从同类型「即将消亡」对象搬运资源
移动赋值T& operator=(T&&)noexcept把资源移动到已存在的对象

前四条是 C++98 就有的;后两条(移动语义,move semantics)是 C++11 加入的。围绕「到底要手写哪些」,业界总结出三条规则。

官方文档:cppreference · 特殊成员函数

2. Rule of Three → Rule of Five → Rule of Zero

  • Rule of Three(C++98):如果你手写了一个拷贝构造、拷贝赋值、析构三者之一,那通常三个都得手写——因为「需要管资源」这件事,三个操作必须保持一致。默认生成的版本只会做浅拷贝(bitwise copy),对持有资源的类是错误的。
  • Rule of Five(C++11):移动语义加入后,若你手写资源管理,就该一次性补齐五个:析构 + 拷贝构造 + 拷贝赋值 + 移动构造 + 移动赋值。移动操作标noexcept,才能让std::vector扩容时放心地移动而非拷贝。
  • Rule of Zero(现代首选):什么都不写。让每个成员自己管好资源(std::string、std::vector、std::unique_ptr等都是「自带正确拷贝/移动」的 RAII 类型),编译器自动合成全部六个,且生成的是最优版本。这是 C++ Core Guidelines 最推崇的写法(C.20)。

手写资源管理的「Rule of Five」长这样(仅在封装底层资源时才需要,平时请用 Rule of Zero):

// 片段(演示 Rule of Five 的五段签名;现代代码优先 Rule of Zero,此写法仅用于封装裸资源)classHandle{int*data_;// 自己管的裸资源public:Handle():data_(newint[16]){}~Handle(){delete[]data_;}// 1. 析构Handle(constHandle&o)// 2. 拷贝构造(深拷贝):data_(newint[16]){/* 逐元素拷 */}Handle&operator=(constHandle&o){// 3. 拷贝赋值if(this!=&o){/* 释放旧、深拷新 */}return*this;}Handle(Handle&&o)noexcept// 4. 移动构造:data_(std::exchange(o.data_,nullptr)){}Handle&operator=(Handle&&o)noexcept{// 5. 移动赋值if(this!=&o){delete[]data_;data_=std::exchange(o.data_,nullptr);}return*this;}};

3. 编译器何时生成、何时抑制(核心表)

理解下面这张表,就理解了 90% 的相关 bug:

你手写了一个…编译器还会隐式生成被抑制的
析构函数默认构造、拷贝构造、拷贝赋值移动构造、移动赋值(C++17 起直接不生成,C++11/14 只是「弃用」)
拷贝构造 或 拷贝赋值默认构造、析构移动构造、移动赋值
移动构造 或 移动赋值默认构造、析构拷贝构造、拷贝赋值(所以必须 Five 全写)
什么都不写全部六个(满足 trivial 条件时)无

官方文档:cppreference · 复制消除与隐式声明:声明析构会阻止隐式移动构造。

关键陷阱就是第一行:声明析构 → 移动操作消失 →std::move退化成拷贝。下面用实跑代码验证。

4. 性能陷阱实测:声明析构,std::move 变成拷贝

Name在拷贝/移动构造时都会打印,方便观察Widget到底走了哪条路。Widget只声明了一个析构:

#include<iostream>#include<string>#include<utility>structName{std::string s;Name(constchar*p):s(p){}Name(constName&o):s(o.s){std::cout<<"Name 拷贝构造\n";}Name(Name&&o)noexcept:s(std::move(o.s)){std::cout<<"Name 移动构造\n";}};structWidget{Name name{"widget"};~Widget(){}// 用户声明析构 -> 抑制隐式移动构造};structWidgetFixed{Name name{"widget"};WidgetFixed()=default;// 显式要回默认构造~WidgetFixed(){}WidgetFixed(WidgetFixed&&)noexcept=default;// 显式要回移动WidgetFixed(constWidgetFixed&)=default;};intmain(){Widget a;std::cout<<"--- 有用户析构:std::move 实际走的拷贝 ---\n";Widget b=std::move(a);std::cout<<"b.name = "<<b.name.s<<"\n\n";WidgetFixed c;std::cout<<"--- 显式 default 移动:这次真移动 ---\n";WidgetFixed d=std::move(c);std::cout<<"d.name = "<<d.name.s<<"\n";}
--- 有用户析构:std::move 实际走的拷贝 --- Name 拷贝构造 b.name = widget --- 显式 default 移动:这次真移动 --- Name 移动构造 d.name = widget

Widget那段,std::move(a)本应「搬走」字符串,实际却打印了「Name 拷贝构造」,因为Widget没有移动构造(被析构声明抑制了),重载决议退而求其次用了拷贝构造,于是std::string被深拷贝了一份。WidgetFixed用= default把移动构造要回来后,才真正走移动。

为什么这是性能陷阱:std::vector/std::string的移动是 O(1) 的指针交换,拷贝却是 O(n) 的元素复制。一个只是「想加个日志析构」的类,会让所有std::move静默退化成 O(n) 拷贝,而且编译器通常不报警。

5. 声明移动会抑制拷贝:Rule of Five 必须补齐

反过来也成立:一旦你手写了一个移动操作,编译器就不再生成拷贝操作。这意味着「只写移动、不写拷贝」的类是不可拷贝的——若你本想让它既能移动又能拷贝,就必须五个一起写(Rule of Five):

#include<iostream>#include<string>#include<utility>structName{std::string s;Name(constchar*p):s(p){}Name(constName&o):s(o.s){std::cout<<"Name 拷贝构造\n";}Name(Name&&o)noexcept:s(std::move(o.s)){std::cout<<"Name 移动构造\n";}};structOnlyMove{// 只写了移动构造,没写拷贝Name name{"x"};OnlyMove()=default;OnlyMove(OnlyMove&&)noexcept=default;// 声明移动 -> 拷贝被抑制};intmain(){OnlyMove a;OnlyMove b=std::move(a);// OK,移动std::cout<<"移动成功\n";// OnlyMove c = a; // 取消注释会编译失败:拷贝构造已被抑制(Rule of Five 必须补齐)}
Name 移动构造 移动成功

6. Rule of Zero:什么都不写反而最对

绝大多数类根本不需要碰特殊成员。把资源交给 RAII 成员,编译器替你合成出正确且高效的版本:

#include<iostream>#include<string>#include<utility>#include<vector>structBuffer{// Rule of Zero:没有任何手写特殊成员std::string label;std::vector<int>data;Buffer(constchar*s,std::initializer_list<int>il):label(s),data(il){}voidshow()const{std::cout<<label<<" = ";for(intv:data)std::cout<<v<<" ";std::cout<<"\n";}};intmain(){Buffera("a",{1,2,3});Buffer b=std::move(a);// 编译器自动生成的移动构造:O(1) 交换内部指针,无需手写b.show();// b 完整持有原数据}
a = 1 2 3

Buffer一个特殊成员都没写,却天然拥有正确的拷贝(深拷贝,各管各的数据)与移动(O(1) 交换)。这正是现代 C++ 想要的——把「要不要手写」从默认动作变成例外。

官方文档:C++ Core Guidelines C.20/C.21:优先 Rule of Zero;确需管理资源时按 Rule of Five,移动操作标 noexcept。

7. 决策流程图

这个类需要自己管理资源吗(裸指针 / 文件句柄 / 锁)? │ ├─ 否 ─────► Rule of Zero:什么都不写,成员用 string/vector/智能指针 │ └─ 是 ─────► 真的必须手写吗?(能否把资源封进一个 unique_ptr/vector 成员?) │ ├─ 能 ──► 还是 Rule of Zero(底层资源交给一个 RAII 成员管) │ └─ 不能 ─► Rule of Five:一次性写齐五个 析构 + 拷贝构造 + 拷贝赋值 + 移动构造 + 移动赋值 (移动操作 noexcept;或 =default 让编译器生成)

8. 延伸阅读

  • cppreference · 特殊成员函数:六大特殊成员全表与隐式声明条件。
  • cppreference · 移动构造函数:为何声明析构/拷贝会抑制隐式移动。
  • C++ Core Guidelines · C.20–C.22:Rule of Zero 与 Rule of Five 的规范表述。
  • isocpp.org · 移动语义与 Rule of Five:拷贝/移动赋值的自我赋值与异常安全要点。

本知识库内的相关篇目:

  • 《拷贝构造与拷贝赋值:调用时机、深浅拷贝与复制消除》 —— 这篇用实跑代码厘清 C++ 拷贝构造(copy constructor)的三个触发场景、拷贝构造与拷贝赋
  • 《移动构造与移动赋值:把资源偷过来》 —— 聚焦「怎么正确实现移动操作」而非 std::move 本身。
  • 《手写一个安全的 String 类:把深浅拷贝讲透》 —— 从一个会 double free 的浅拷贝反例入手

9. 一句话总结

六个特殊成员里,声明析构会静默抑制隐式移动(让std::move退化成 O(n) 深拷贝),声明移动又会抑制隐式拷贝,所以要么「什么都不写」交给 RAII 成员(Rule of Zero,首选),要么「五个一起手写」并给移动标noexcept(Rule of Five),千万别只写一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询