Static Code Analysis and the Rules of Zero, Three, and Five

TL;DR · AI 摘要
C++ 中的 0/3/5 规则通过资源管理与特殊成员函数的使用,有效避免内存泄漏和双重释放问题。
核心要点
- C++ 的 0/3/5 规则通过资源管理与特殊成员函数的使用,有效避免内存泄漏和双重释放问题。
- 使用 unique_ptr 可以自动管理资源,避免手动调用 delete 函数。
- 静态代码分析工具如 Qodana 可以自动检测并修复违反 0/3/5 规则的代码。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- C++ 0/3/5 规则
- 资源管理
- 手动管理资源
- 使用 unique_ptr 自动管理资源
- 特殊成员函数
- 复制构造函数
- 析构函数
- 静态代码分析
- Qodana 工具
金句 / Highlights
值得收藏与分享的关键句。
使用 unique_ptr 可以自动管理资源,避免手动调用 delete 函数。
静态代码分析工具如 Qodana 可以自动检测并修复违反 0/3/5 规则的代码。
当复制 TreeVertex 对象时,会导致双重释放和内存泄漏问题。
零、三和五的规则 - Qodana 博客
Qodana
为团队提供的代码质量平台
关注
- 关注:
- X X
- RSS RSS
获取 Qodana
操作指南
静态代码分析与零、三和五的规则
零、三和五的规则,有时也写作 0/3/5,是一组关于资源所有权和特殊成员函数的 C++ 指南。这些规则存在的原因是因为替代方案会导致双重释放、悬空指针和静默损坏的复制。在本文中,我们将通过逐一触发这些错误来探讨这些规则,然后看看如何通过静态分析自动执行这些规则。我们的同事 Anna 将带我们了解她的想法。
#### Anna Zhukova
Anna Zhukova 是 Qodana 的软件开发人员,她带来了后端专业知识、跨平台技能和对静态分析的真正热情。她目前正在构建和维护 Qodana for C++,并推进我们的静态分析工具,以帮助开发人员更快地交付更优质、更安全的代码。
目录
C++ 中的零、三和五规则是什么?
让我们想象一个大多数非平凡代码库都需要解决的常见场景:你正在编写一个类,而该类拥有一个资源。在此上下文中,资源是任何在不再需要时需要清理的东西:文件句柄需要关闭,指针需要释放,等等。乍一看,这似乎适合由析构函数处理:
// 这是 BAD CODE,不应被复制
struct TreeVertex {
vector<TreeVertex*> children;
...
~TreeVertex() {
for (auto& vertex : children) {
delete vertex;
}
}
};看起来很简单。不过请注意,没有任何东西阻止我们复制 TreeVertex 的一个实例。那会发生什么呢?
哦不。
你的程序崩溃了,因为在你复制 TreeVertex 时,隐式地让原始对象和副本都相信它们拥有指向(唯一)一组子节点的指针,并且可以在需要时删除它们。在析构时,第一个被销毁的对象会删除子节点,而第二个对象会导致程序发生段错误,因为它试图释放已经被释放的内存。这被称为“双重释放”错误——这是我们在内存管理中常见的经典错误。
Q: 为什么没有任何东西阻止我们复制 `TreeVertex` 的一个实例?
A: 要复制一个类,你需要有一个复制构造函数。我们从未定义过一个,但幸运的是(哈哈!)如果没有用户定义的复制构造函数,编译器将为我们生成一个。
无论如何,一个看过一两个 CppCon 主题演讲的敏锐读者会立即注意到一个代码异味:我们手动管理了一组指针的生命周期,调用像 delete 这样的低级函数。如果使用 unique_ptr,所有生命问题都会消失吗?
class TreeVertex {
vector<unique_ptr<TreeVertex>> children;
public:
...
// ~TreeVertex() // 甚至不再需要了!
};敏锐的读者在技术上是正确的——这更好,因为地球不再爆炸。然而,现在你的类根本无法被复制,因为 unique_ptr 通过完全删除其复制构造函数来解决两个所有者的问题。
我们好像在谈论它是一件坏事,但在现实生活中,这可能正是你想要的。编写一个复杂的拷贝构造函数来复制资源并不总是正确的选择。比如,如果你的资源是一个操作系统窗口,那么当你不小心通过值传递一个对象时,隐式地打开新窗口是否有意义?
如果你已经决定你的树顶点是不可复制的,并且你将让 unique_ptr 来管理你的内存,那么恭喜你!你刚刚实现了“零法则”(Rule of Zero):如果你可以避免定义默认操作,那就这么做。
不过,假设我们确实想复制我们的顶点。此外,假设我们希望为我们的底层 API 提供一个内部访问函数,这样我们不能使用 unique 指针。
class TreeVertex {
vector<TreeVertex*> children;
public:
TreeVertex** children_data() { return children.data(); }
TreeVertex(TreeVertex const& other) {
children.reserve(other.children.size());
for (auto const& vertex : other.children) {
children.push_back(new TreeVertex(*vertex));
}
}
~TreeVertex() {
for (auto const& vertex : children) {
delete vertex;
}
}
};现在拷贝构造函数可以正常工作了,但如果没有另一个隐藏的陷阱,那就不是 C++ 了。可能一开始并不明显的是,这两行代码调用了两个不同的函数:
TreeVertex my_vertex = other;
my_vertex = other;第一行确实是拷贝构造函数,我们刚刚仔细地定义了它。第二行调用的是拷贝赋值运算符——幸运的是(哈哈!),编译器以与拷贝构造函数类似的方式为我们生成了它:
// 这是 BAD ~~编译器生成的~~ 代码,不应被复制
TreeVertex& operator=(TreeVertex const& other) {
children = other.children;
return *this;
}请注意,仅仅通过定义析构函数并沿着这一系列错误继续下去,我们就不得不定义了三个函数:析构函数、拷贝构造函数和拷贝赋值运算符。最后我们终于到达了……
零法则(Rule of Three)
如果你定义了拷贝构造函数、拷贝赋值运算符或析构函数,你应该定义它们全部。
一个看似正确的拷贝赋值运算符如下所示:
// 这是 BAD 代码,不应被复制
TreeVertex& operator=(TreeVertex const& other) {
// 清除现有的子节点
for (auto const& vertex : children) {
delete vertex;
}
children.clear();
// 复制其他对象的子节点
children.reserve(other.children.size());
for (auto const& vertex : other.children) {
children.push_back(new TreeVertex(*vertex));
}
return *this;
}暂时忘记我们在这个简单的类中重复代码的次数(我们稍后再回来讨论)。如果你想对你的初级开发人员不友好,可以让他们在这里找出一个错误。
这个错误来自于构造函数和赋值运算符之间的基本区别——在调用函数之前,this 对象已经存在了。这意味着你可以写这样的代码:
my_vertex = my_vertex;换句话说,this 和 other 是同一个对象。而我们的拷贝赋值运算符并没有做任何事情,而是直接删除了所有的子节点。
幸运的是,一些聪明的人想出了一个解决自赋值问题和代码重复问题的方法。它被称为“拷贝-交换”(copy-and-swap)惯用法。
交换函数是 C++ 中一种特殊的函数,用于“交换”两个对象的内容。这里并没有编译器的魔法,我们只是共同约定使用这个名字。其函数签名如下:
void swap(T& a, T& b);默认的模板 std::swap 使用一个临时变量来进行通用交换,但如果我们了解自己的类是如何工作的,实际上可以做得更好。让我们定义我们自己的交换函数,作为自由友元函数:
class TreeVertex {
...
public:
friend void swap(TreeVertex& a, TreeVertex& b) noexcept {
using std::swap;
swap(a.children, b.children);
}
};首先,我们将 std::swap 添加到重载解析的函数列表中(在我们的示例中这不是必需的,但这是一个不错的经验法则)。然后我们交换自己的子节点,让编译器找到最合适的 swap 重载,结果发现是一个专门为 std::vector 创建的高效特化版本。
我们现在可以将复制赋值运算符重写如下:
TreeVertex& operator=(TreeVertex other) {
swap(*this, other);
return *this;
}请注意,函数签名已经发生了变化:我们现在通过值传递 other,将复制工作委托给编译器。然后我们交换自己的子节点,让 other 被销毁,将删除工作委托给析构函数。没有代码重复,也没有自赋值的错误。
Q: 为什么要在类中定义交换函数的函数体?
A: 这是另一种被称为“隐藏友元”的惯用法:由于交换函数是一个自由函数,即使你正在交换与 TreeVertex 完全无关的对象,它也可能被考虑用于重载解析。将函数定义为隐藏友元可以防止可能的隐式转换错误,提高编译时间,清理编译错误,并让你的猫爱上你。
由于定义交换函数非常有用,三法则(Rule of Three)有时也被称为三又二分之一法则。
我们几乎已经到达今天讨论的结尾,但还有一个内容需要讨论:C++11 引入的移动语义。如果你的公司尚未采用 C++11,你应该停止阅读这篇博客,去更新你的简历。
与复制不同,移动操作允许从另一个对象“偷取”内容。事实上,标准规定移动操作后,other 会处于一个有效但未指定的状态,通常只适合销毁。允许对象被移动是一种非常强大的优化,因此让我们使用我们惊人的交换函数来定义移动构造函数和移动赋值运算符:
TreeVertex(TreeVertex&& other) noexcept {
swap(*this, other);
}
TreeVertex& operator=(TreeVertex&& other) noexcept {
swap(*this, other);
return *this;
}轻而易举,现在你已经了解了现代版本的三法则,即五法则:
如果你有析构函数或任意一个复制函数(构造函数或赋值运算符),你应该定义两个复制函数和两个移动函数。
*也被称为五又二分之一法则,因为交换函数的存在。*
Q: noexcept 是否必要?它实现了什么?
A: noexcept 是一个重要的性能优化。像 std::vector 这样的标准容器在重新分配期间旨在提供强异常保证,并会完全放弃你的抛出移动操作,而选择一个昂贵但可恢复的复制操作。
这在 std::move_if_noexcept 的注释中有所提及。我们的 swap 也标记为 noexcept。对于我们自己的代码来说,不需要特别这样标记,因为我们所关心的是它不会真正抛出异常。但对于基于 std::is_nothrow_swappable 条件优化的第三方代码来说,正确的标记是重要的,因此这是一个良好的实践。
Q: 如果我没有定义这两个函数,ASan 会第三次对我们发出警告吗?
A: 这次不会!与拷贝构造函数和拷贝赋值运算符不同,如果你定义了拷贝版本的任何一个,移动版本的函数不会被默认生成。
非常感谢 GManNickG 在 Stack Overflow 上的出色帖子,这十年来都是我在这个主题上的参考资料。
静态代码分析与零规则、三规则和五规则的实践
本文中提到的错误,如双重释放、自我赋值错误、静默抛出的移动构造函数等,能够通过代码审查并出现在生产环境中。静态分析可以在提交时就发现这些问题。
Qodana for C++ 包括了 Clang-Tidy(以及其他功能),其中三个检查直接对应我们上面讨论的内容:
cppcoreguidelines-special-member-functions是五规则的来源:如果你定义了析构函数、拷贝构造函数或拷贝赋值运算符,这个检查要求你定义(或使用= default/= delete)其余的成员函数。这是 C++ 核心指南中的 C.21 条款。
bugprone-unhandled-self-assignment检测本文中提到的自我赋值错误:一个没有自我检查也没有拷贝-交换机制的用户自定义拷贝赋值运算符。
performance-noexcept-move-constructor标记非 noexcept 的移动构造函数和移动赋值运算符,这样 std::vector 的重新分配会使用你的移动操作,而不是静默地回退到拷贝。
这三个检查默认是启用的(在 qodana.starter 配置文件中),或者你可以将它们添加到你的 .clang-tidy 文件中以确保:
Checks: >
cppcoreguidelines-special-member-functions,
bugprone-unhandled-self-assignment,
performance-noexcept-move-constructor然后只需运行 Qodana —— 本地使用 qodana scan,或在 CI 中通过 GitHub Actions、GitLab、Jenkins 或 TeamCity 运行。
至于零规则:没有一个单独的检查会说“删除这个析构函数并使用 unique_ptr 代替”——零规则更像是一种设计原则。Qodana 可以引导你实现零规则:cppcoreguidelines-owning-memory 标记原始拥有指针,而 modernize-* 系列(modernize-make-unique、modernize-make-shared、modernize-avoid-c-arrays)则引导代码向 RAII 类型靠拢,这些类型从一开始就消除了对特殊成员函数的需求。只需将它们添加到你的 .clang-tidy 文件中,Qodana 会自动检测它们:
Checks: >
...
modernize-make-unique,
modernize-make-shared,
modernize-avoid-c-arrays,
cppcoreguidelines-owning-memory了解更多关于使用 Qodana 提高代码质量的信息。
补充说明:何时不应该使用拷贝-交换(copy-and-swap)模式
尽管拷贝-交换模式在 99% 的使用场景中是安全的,但它有一个问题:拷贝性能。回想一下我们之前编写的拷贝赋值运算符,它通过值接收 other,这意味着无论是否真的需要,对象及其底层存储都会被复制。更聪明的拷贝赋值可以像这样操作:
TreeVertex& operator=(TreeVertex const& other) {
if (this == &other) return *this; // 自我赋值检查
auto const common_size = std::min(children.size(), other.children.size());for (size_t i = 0; i < common_size; ++i) {
*children[i] = *other.children[i];
}
for (size_t i = common_size; i < children.size(); ++i) {
delete children[i];
}
children.resize(common_size);
children.reserve(other.children.size());
for (size_t i = common_size; i < other.children.size(); ++i) {
children.push_back(new TreeVertex(*other.children[i]));
}
return *this;
}当然,这确实更加复杂,但它实现了重要的目标:已经被复制到的对象中已有的存储空间可以被部分或全部重用,具体取决于谁拥有更多的子节点。就我们的示例而言,收益如下:
给定 N = children.size() 和 M = other.children.size():
教科书式的拷贝和交换
自定义拷贝函数
调用 new 的次数
M
max(0, M - N)
调用 delete 的次数
N
max(0, N - M)
还要注意,这些收益是递归的,因为每个子节点都有自己的子节点。使用自定义拷贝函数将子树赋值给自身是完全免费的。
了解这个注意事项,但请始终记住:过早的优化是万恶之源。
感谢 Anna Zhukova 对博客的贡献!Anna 在 Qodana for C++ 上工作。如果你还没有尝试过,今天就试试看,或者查看文档。
Qodana for C++
C++
CPP
Qodana
- 分享
上一篇帖子
使用 Qodana 修复常见的 TypeScript 问题