杏花疏影里,吹笛到天明。
C++中有许多脍炙人口、妇孺皆知的俗语,其是C++程序设计者间“会心一笑”的载体,更蕴含了C++乃至大量编程语言的设计哲学。
RAII
全称 Resource Acquisition Is Initialization,数据获取即初始化。
其基本概念在于对每个需要“申请”资源(所谓 acquired before
use,即使用前必须显式“请求”,如堆内存、文件标识符、数据库连接、互斥锁等)的C++对象,其均在构造函数中初始化它们,并在析构函数中将之释放。
结合变量的生命周期机制、移动语义等工具,其目标有两点:
- 该对象完成初始化(构造时不抛出异常)后,可认为其使用的资源一定被正确地初始化过而“放心”地使用;
- 无论为何该对象的生命周期结束(包括函数执行结束、提早
return
、抛出异常等等),该对象获取的资源一定能正确地释放,且无需显式管理之。
以内存管理为例,与手动管理动态数组相比,std::vector
的优越性一目了然:
1 | void foo(int*); |
上述代码中,bad
函数在许多代码中堪称司空见惯,但其在内存管理上实际上漏洞百出。
- 若内存申请失败(不论
malloc
或operator new
,虽然后者在多数实现中失败时会抛出异常,但亦需考虑一些行为默认“加了”std::nothrow_t
的场景),空的ptr
就这么成为“漏网之鱼”被继续使用了。类似的还有fopen
(无效的FILE*
)、各种数学运算后(无效结果)不检查errno
;当然错误处理这块在C++23中也被开辟了“第三赛道”,即以std::expected
打头的新时代 sum-types
- 若
foo
函数抛出异常而触发栈回溯(stack rewinding),则ptr
对应的内存空间将不会被delete[]
释放而泄露这里都不考虑;ptr
传进去所有权(ownership)不明确导致的潜在double-free等问题
- 若
ptr[0]<0
,则触发了 early return,在大型的、存在多个出口(return
)的函数中,在每个return
前都分别delete[]
显然不可行,产生的内存漏洞原理同上;
- 只有当程序运行到
delete[] ptr;
时,ptr
对应的内存才被释放。然而如此做依然有诸多漏洞——若ptr
的值中途被更改了,将会如何?若ptr
对应的内存已经被不小心释放过了呢?
相比之下,std::vector
封装了“动态数组”头指针及长度,在构造函数中申请堆内存并检查是否成功,并在析构函数中释放之。如此做保证了以下数点:
std::vector
的对象初始化成功(可用)是该对象底层指针可用的充分条件,无需担心初始化问题(若分配内存失败,则抛出异常并触发栈回溯,该std::vector
的变量即因离开其范围(scope)而不可用;
- 无论以何种方式离开该变量的生命周期(即其所在的函数执行中止,包括正常完成、抛出异常和
early return
等),该
std::vector
都会将自身获得的堆内存释放;
- 通过恰当地声明移动构造函数/赋值运算符,对移动语义的践行到位,使得堆内存资源所有权清晰且易于管理(移动
vector
时,新对象的指针、长度置为当前对象的对应值,当前对象的数据值置空)。
应用侧闲谈
cppreference也给出了在应用侧使得RAII类的语义、作用能正确发挥的实践方法:
- 将之声明为自动存储周期(automatic storage
duration)的变量,即不以
static
、thread_local
等修饰的、自身在栈上的变量;
- 或使之与一个具备自动存储周期/临时对象相绑定(如,作为另一个类的非静态数据成员)。
核心观点在于令其构造(初始化)和析构(销毁)均自动进行;同时应避免手动“接管”资源,不改变其既有所有权的语义(如避免调用智能指针的release()
)。
C++中典型的 RAII-Classes
包括标准库的容器(Containers)类,如std::vector
、std::deque
、std::string
等;还包括它们衍生的
容器适配类(container adaptors)
(在采用的底层容器符合RAII原则的情况下),如std::priority_queue
等;亦有用户自定义资源的适配器,如std::lock_guard
、std::ostream
一族、std::unique_ptr
等。
典型的 Non-RAII Classes
即为仅具有lock()
/unlock()
、open()
/close()
等一对“手动”获取/释放相应资源的类(映射到堆内存即为operator new
/operator delete
等)。RAII-Classes
也可以主动申请/释放资源,但底层过程是被封装、自动调用且安全的,如vector
的reserve()
和clear()
。
设计侧简述
设计侧除了让函数应当满足上述申请/释放资源的要求外,语法上还应具备以下几个特点:
- 构造函数、复制构造函数/赋值运算符若申请资源失败,应当抛出异常以触发顶层函数的栈回溯,进而“阻止”该对象的初始化;
- 析构函数不应抛出异常,即必定为
noexcept
;
- 移动构造函数/赋值运算符亦不应抛出异常(更准确地,遵守完整且正确的移动语义)。
C++中析构函数默认均被标记为
noexcept
,即只要不显式声明/解析为noexcept(false)
的析构函数均为noexcept
。编译器自动生成的移动构造函数(含= default;
)也是noexcept
的,但用户自定义的移动构造函数并不会自动成为noexcept
,应手动声明之以高效利用语言库和工具——详见C++——右值引用和移动语义
自定义一个简单的MyVector
类的示例如下:
1 | class MyVector |
Rule-of-Three/Five/Zero
对右值引用和移动语义尚不熟悉的道友,建议先移步C++——右值引用与移动语义以更好地理解如此设计的必要性。
基本知识
“3”“5”“0”指代对象是类的析构、复制构造、复制赋值、移动构造、移动赋值五个函数。
先看编译器的“默认”处理方式:
从编译器的角度出发,
- 若自定义了复制构造函数/复制赋值运算符,则编译器将不生成移动构造函数/移动赋值运算符;(注意,此时std::move
等对右值的使用不会报错,只是无法发挥移动语义的优势;且此时编译器仍然会默认生成“复制”的另一半)
- 若自定义了移动构造函数/移动赋值运算符,则编译器将不生成复制构造函数/复制赋值运算符,亦不会生成“移动”的另一半;
- 若类存在 任意 自定义构造函数(包括上述的复制/移动构造函数),则编译器将不生成默认构造函数;
重点:显式地将函数主体声明为= default
也算自定义。
上述语言机制对语义上的约束也非常明显:
- 不允许只定义移动/复制构造函数,而不定义(普通)构造函数,即移动/复制语义不能基于缺省的初始化语义;
- 若一个类自定义了复制语义的实现,则应当自定义移动语义的实现(不存在缺省值,若不然,则依靠引用绑定机制,移动语义会退化为复制语义);
- 若一个类自定义了移动语义的实现,则必须自定义复制语义的实现(不存在缺省值,否则该类的复制语义默认不可用,即视为
= delete;
)。
上述分析站从C++的较顶层出发,或较抽象而难以理解,尽可能自行设计、思考模拟一个实例的语义实现,或将之实例化“降维”理解—— Beyond 者皆见仁见智。
回到正题:
- Rule of Three:若类自定义了 (user-defined)
析构、复制构造/赋值运算符三个函数中的任意一个,则语义上其必须定义剩下两个函数(所谓
it almost certainly requires all three)。
- Rule of
Five:若类需要利用移动语义进行优化,则在上述基础上还应定义移动构造函数/赋值运算符,即“凑够五行”。
- Rule of Zero:若可能,尽量不自定义任何的这五类函数,即让编译器自动生成之。
三清——Rule of Three
何为如此?
铺垫:可以认为每个C++的
class
都是资源与数据(可以只有一者)的集合。二者皆无的情况下类的对象不存在OOP意义下的实际意义,仅供语法特性适配之用,如泛滥至极的 tag types(标签类型)和 C++20ranges
中大面积应用的 function object 。
“数据”本身是 trivial (平凡)的,其包括C++中所有的基本类型,即各类普通类型(各种浮点/整型/bool
;enum
/enum class
是特殊的整型)和指针类型等。“数据”的特点是没有特殊的移动语义,即移动与复制相同(简单地进行内存复制,参考汇编中的mov
亦不会改变原地址的值),且不存在“析构”这一概念逝以飘零。
“资源”可以参考上述的描述。
对于 Rule of Three,若 需要
自定义析构函数,说明该类中存在需要手动清理的资源/善后工作;则该类的复制构造/赋值必不可能是平凡(trivial)的——即不可能是像C的struct
那样,简单地将数据复制到新的内存中。若不然,则该类的资源将同时被多个对象所有且不可区分,极易出现资源管理的疏漏。
如下,C++复制构造函数“入门”的经典踩坑实例就完美地诠释了这一点:
1 | class BadString |
BadString
类必须自定义析构函数以释放申请的堆内存。但其未定义复制构造函数,直接导致不能正确地实现复制语义(按照默认的复制构造函数进行,新对象和原对象会指向同一块堆内存),结果自然而然就是
double-free 导致的段错误:
1 | int main() |
因此其必须自定义复制构造函数/复制赋值运算符,正确地为新对象重新分配堆内存资源、执行复制操作等。
一定程度上,复制赋值可以视为对象内部“析构-重新复制构造”的过程。但C++不将之和上述过程混为一谈,除因部分场景下复制赋值可以有更高效的实现外(如两个
vector
大小相同,此时赋值显然直接执行数据复制即可,无需进行释放-再申请的过程),更多地是为了规避开一个非常无趣的应用场景——“自导自演”。
没反应过来咩?欢迎收看2026春节小品之我与我周旋久(bushi)
1 | class MyVector |
反过来,若一个类不需要自定义析构函数也能正确地管理资源(通常通过封装的类实现,或其根本没有资源),则为之编写自定义的复制构造函数和复制赋值运算符也大概率没有意义(均可以通过语言内置的默认复制实现,参考下述的 Rule of Zero 。
五行——Rule of Five
“五行”者对应的动机也很简单,即利用语言的重载匹配机制来让右值能得到优化。
因在 Rule of Three
中,自定义三个函数之一会令编译器不生成默认的移动构造函数/赋值运算符。
尽管此时多数情况下不会出现编译错误除非把复制函数族给,但此时类对右值实参会“退化”到调用复制构造函数/赋值运算符(常引用绑定至右值),错失了优化良机:= delete;
1 | class BadClass |
fun
第二行中,对象y
在初始化时会调用复制构造函数BadClass::BadClass(const BadClass&)
而非移动构造函数BadClass::BadClass(BadClass&&)
。
尽管二者都是默认生成的,但后者在初始化时会对所有变量进行“移动”处理(即适配移动语义,无论其底层实现如何),如对于下图的类模板:
1 | template<class Ty1,class Ty2> |
则对模板类A<Ty1,Ty2>
,其默认生成的复制构造函数和移动构造函数就会形如:
1 | A<Ty1,Ty2>::A(const A& rhs):a(rhs.a),b(rhs.b){} |
严格地后者应为
static_cast<Ty1&&>(rhs.a)
(因语言核心特性不依赖标准库),std::move
只是标准库的提高可读性的wrapper虽然有std::move
的时候没人会闲着没事干让static_cast
满天飞
若定义了A<Ty1,Ty2>::A(const A&)
(包括函数主体声明为= default
),则A<Ty1,Ty2>::A(A&&)
会被隐式删除,进而导致所有本可调用移动构造函数的场景均“改为”调用复制构造函数,导致不必要的复制。
C++11后,类模板内的函数声明可以且“应当”直接使用类模板名(视为一个完整的类型),默认其模板参数和类模板的参数一致,如:
1
2
3
4
5
6
7
8
9 template<class Ty>
class MyVector
{
public:
void swap(MyVector&); //Type of `rhs` is `MyVector<Ty>`, no need to specify
template<class UTy>
void swap(MyVector<UTy>&) = delete; //Can be explicitly specified and instaniated(as a class template)
};
同时不难发现,移动语义的实现是递归的——让一个上层“适配”类实现移动语义,若其并不直接管理资源,则只需令各非静态成员均实现其移动语义即可。
太初——Rule of Zero
If you can avoid defining default operations, do. ——C++ Core Guidelines
对于较上层的实现/适配类,尽可能避免自定义五个函数中的任意一个(令编译器自动补全完整的语义实现),即只定义(普通)构造函数用于初始化这些数据成员。
比如一个自定义字符串类的“托管”实现就可以形如:
1 | class MyString |
由于std::vector
能够胜任内存管理的任务,因而不再需要为MyString
这一上层适配类再自定义“五类函数”。
自定义五类函数不仅麻烦,而且容易在函数主体编写、函数类型声明等方面出现疏漏,导致不能充分利用语言工具乃至出现编译错误。
不能充分利用语言工具的实例可见C++——右值引用与移动语义
附录:
C++(乃至很多程序设计语言)的较好实践之一就是尽可能利用被无数代码不断检验测试过的、多次优化过的、实现统一且准确的标准库实现。不建议在程序设计中“手搓”资源管理、基本算法(如排序、查找等)的代码,尽可能采用既有的标准库实现——手搓费时费力虽然也算是一种乐趣,上述观点更多地用于大型、正经的程序/项目开发且极易出现各种疏漏,且其在基本算法上的效率几乎必定次于被优化检验过的标准库算法。
如C++中的std::(ranges::)sort
,其就会根据待排序目标大小来动态决策采用何种排序方式:
- 子序列长度特别大时,会先采用堆排序(heap sort);
- 子序列长度中等时,采用改良过的快速排序(quick sort);
- 子序列很短时,直接采用插入排序(insert sort)。
更多有关C++设计哲学的解读参考可移步C++ Core Guidelines,其间内容致力于在基本语法要求的前提下,提供一种编写更高效、可读、易于维护与拓展代码的思路。