360代码规范二:resource
360代码规范二:resource
Hoshea ZhangR2.1 不可失去对已分配资源的控制
对于动态分配的资源,其地址、句柄或描述符等标志性信息不可被遗失,否则资源无法被访问也无法被回收,这种问题称为“资源泄漏”,会导致资源耗尽或死锁等问题,使程序无法正常运行。
在资源被回收之前,记录其标志性信息的变量如果:
- 均被重新赋值
- 生命周期均已结束
- 所在线程均被终止
相关资源便失去了控制,无法再通过正常手段访问相关资源。
示例:
1 | int fd; |
例中变量 fd 记录文件资源描述符,在回收资源之前对其重新赋值会导致资源泄漏。
R2.2 不可失去对已分配内存的控制
动态分配的内存地址不可被遗失,否则相关内存无法被访问也无法被回收,这种问题称为“内存泄漏(memory leak)”,会导致可用内存被耗尽,使程序无法正常运行。
程序需要保证内存分配与回收之间的流程可达,且不可被异常中断,相关线程也不可在中途停止。
本规则是 ID_resourceLeak 的特化。
示例:
1 | void foo(size_t n) { |
例中局部变量 p 记录已分配的内存地址,释放前在某种情况下函数返回,之后便再也无法访问到这块内存了,导致内存泄露。
又如:
1 | void bar(size_t n) { |
例中 realloc 函数分配失败会返回 NULL,p 未经释放而被重新赋值,导致内存泄露。
R2.3 不可访问未初始化或已释放的资源
访问未初始化或已释放的资源属于逻辑错误,会导致标准未定义的行为。
对于访问未初始化的局部对象,本规则特化为 ID_localInitialization;对于解引用未初始化或已被释放的指针,本规则特化为 ID_wildPtrDeref 、ID_danglingDeref。
示例:
1 | void foo(const char* path, char buf[], size_t n) { |
R2.4 资源应接受对象化管理
使资源接受对象化管理,免去繁琐易错的手工分配回收过程,是 C++ 程序设计的重要方法。
将资源分配的结果直接在程序中传递是非常不安全的,极易产生泄漏或死锁等问题。动态申请的资源如果只用普通变量引用,不受对象的构造或析构机制控制,则称为“无主”资源,在 C++ 程序设计中应当避免。
应尽量使用标准库提供的容器或智能指针,避免显式使用资源管理接口。本规则集合示例中的 new/delete、lock/unlock 意在代指一般的资源操作,仅作示例,在实际代码中应尽量避免。
示例:
1 | void foo(size_t size) { |
例中 foo 和 bar 函数的资源管理方式是不符合 C++ 理念的,baz 函数中的 y 对象负责资源的分配与回收,称 y 对象具有资源的所有权,相关资源的生命周期与 y 的生命周期一致,有效避免了资源泄漏或错误回收等问题。
资源的所有权可以发生转移,但应保证转移前后均有对象负责管理资源,并且在转移过程中不会产生异常。进一步理解对象化管理方法,可参见“RAII(Resource Acquisition Is Initialization)”等机制。
与资源相关的系统接口不应直接被业务代码引用,如:
1 | void foo(const TCHAR* path) { |
例中 Windows API FindFirstFile 返回资源句柄,是“无主”资源,很可能被后续代码误用或遗忘。
应进行合理封装:
1 | class MY_FIND_DATA |
本例将 FindFirstFile 及其相关数据封装成一个类,由 unique_ptr 对象保存 FindFirstFile 的结果,FindClose 是资源的回收方法,将其作为 unique_ptr 对象的组成部分,使资源可以被自动回收。
R2.5 资源的分配与回收方法应成对提供
资源的分配和回收方法应在同一库或主程序等可执行模块、类等逻辑模块中提供。
如果一个模块分配的资源需要另一个模块回收,会打破模块之间的独立性,增加维护成本,而且 so、dll、exe 等可执行模块一般都有独立的堆栈,跨模块的分配与回收往往会造成严重错误。
示例:
1 | // In a.dll |
例中 a.dll 分配的内存由 b.dll 释放,相当于混淆了不同堆栈中的数据,程序一般会崩溃。
应改为:
1 | // In a.dll |
修正后 a.dll 成对提供分配回收函数,b.dll 配套使用这些函数,避免了冲突。
R2.6 资源的分配与回收方法应配套使用
使用了某种分配方法,就应使用与其配套的回收方法,否则会引发严重错误。
示例:
1 | void foo() { |
不同的分配回收方法属于不同的资源管理体系,用 new 分配的资源应使用 delete 回收,malloc 分配的应使用 free 回收。
R2.7 不应在模块之间传递容器类对象
在库或主程序等可执行模块之间传递容器类对象会造成分配回收方面的冲突。
与资源管理相关的对象,如流、字符串、智能指针以及自定义对象均不应在模块间传递。
不同的可执行模块往往具有独立的资源管理机制,跨模块的分配与回收会造成严重错误,而且不同的模块可能由不同的编译器生成,对同一对象的实现也可能存在冲突。
示例:
1 | // In a.dll |
例中容器 v 的初始内存由 b.exe 分配,b.exe 与 a.dll 具有独立的堆栈,由于模板库的内联实现,reserve 函数会调用 a.dll 的内存管理函数重新分配 b.exe 中的内存,造成严重错误。
R2.8 不应在模块之间传递非标准布局类型的对象
非标准布局类型的运行时特性依赖编译器的具体实现,在不同编译器生成的模块间传递这种类型的对象会导致运行时错误。
“标准布局(standard-layout)”类型的主要特点:
- 没有虚函数也没有虚基类
- 所有非静态数据成员均具有相同的访问权限
- 所有非静态数据成员和位域都在同一个类中声明
- 不存在相同类型的基类对象
- 没有非标准布局的基类
- 没有非标准布局和引用类型的非静态数据成员
除非模块均由同一编译器的同一版本生成,否则不具备上述特点的对象不应在模块之间传递。
示例:
1 | // a.dll |
设例中 a.dll 和 b.exe 由不同的编译器生成,b.exe 中定义的 a 对象被传递给了 a.dll 中定义的接口,由于存在虚函数,不同的编译器对 a 对象的内存布局会有不同的解读,从而造成冲突。
R2.9 对象申请的资源应在析构函数中释放
对象在析构函数中释放自己申请的资源是 C++ 程序设计的重要原则,不可被遗忘,也不应要求用户释放。
示例:
1 | class A { |
例中成员 p 与内存分配有关,但析构函数为空,不符合本规则要求。
R2.10 对象被移动后应重置状态再使用
对象被移动后在逻辑上不再有效,如果没有通过清空数据或重新初始化等方法更新对象的状态,不应再使用该对象。
示例:
1 | #include <vector> |
例中容器 b 的数据被移动到容器 a,可能是通过交换的方法实现的,也可能是通过其他方法实现的,标准容器被移动后的状态在 C++ 标准中是未声明的,程序不应依赖未声明的状态。
应改为:
1 | void foo(V& a, V& b) |
R2.11 构造函数抛出异常需避免相关资源泄漏
构造函数抛出异常表示对象构造失败,不会再执行相关析构函数,需要保证已分配的资源被有效回收。
示例:
1 | class A { |
例中内存分配可能会失败,抛出 bad_alloc 异常,在某种条件下还会抛出自定义的异常,任何一种异常被抛出析构函数就不会被执行,已分配的资源就无法被回收,但已构造完毕的对象还是会正常析构的,所以应采用对象化资源管理方法,使资源可以被自动回收。
可改为:
1 | A::A(size_t n) { |
先用 unique_ptr 对象持有资源,完成可能抛出异常的事务之后,再将资源转移给相关成员,转移的过程不可抛出异常,这种模式可以保证异常安全,如果有异常抛出,资源均可被正常回收。对遵循 C++11 及之后标准的代码,建议用 make_unique 函数代替 new 运算符。
示例代码意在讨论一种通用模式,实际代码可采用更直接的方式:
1 | class A { |
保证已分配的资源时刻有对象负责回收是重要的设计原则,可参见 ID_ownerlessResource 的进一步讨论。
注意,“未成功初始化的对象”在 C++ 语言中是不存在的,应避免相关逻辑错误,如:
1 | struct T { |
例中 T 类型的对象在构造时抛出异常,而实际上 p 并不会指向一个未能成功初始化的对象,赋值被异常中断,catch 中的 p 仍然是一个空指针,new 表达式中抛出异常会自动回收已分配的内存。
R2.12 资源不可被重复释放
重复释放资源属于逻辑错误,导致标准未定义的行为。
示例:
1 | void foo(const char* path) { |
R2.13 用 delete 释放对象需保证其类型完整
如果用 delete 释放不完整类型的对象,而对象完整类型声明中有 non-trivial 析构函数,会导致标准未定义的行为。
示例:
1 | struct T; |
例中 delete 作用于不完整类型的指针 p,析构函数不会正确执行,应保证 T 在 foo 之前定义:
1 | struct T { |
R2.14 用 delete 释放对象不可多写中括号
用 new 分配的对象应该用 delete 释放,不可用 delete[] 释放,否则导致标准未定义的行为。
示例:
1 | auto* p = new X; // One object |
R2.15 用 delete 释放数组不可漏写中括号
用 new 分配的数组应该用 delete[] 释放,不可漏写中括号,否则导致标准未定义的行为。
示例:
1 | void foo(int n) { |
在某些环境中,可能只有第一个对象的析构函数被执行,其他对象的析构函数都没有被执行,如果对象与资源分配有关就会导致资源泄漏。
R2.16 非动态申请的资源不可被释放
释放非动态申请的资源会导致标准未定义的行为。
示例:
1 | void foo(size_t n) { |
释放在栈上分配的空间或者局部对象的地址会造成严重的运行时错误。
R2.17 在一个表达式语句中最多使用一次 new
如果表达式语句多次使用 new,一旦某个构造函数抛出异常就会造成内存泄漏。
示例:
1 | fun( |
例中 fun 的两个参数均为 new 表达式,实际执行时可以先为两个对象分配内存,再分别执行对象的构造函数,如果某个构造函数抛出异常,已分配的内存就得不到回收了。
保证一次内存分配对应一个构造函数可解决这种问题:
1 | auto a(shared_ptr<T>(new T)); // Compliant |
这样即使构造函数抛出异常也会自动回收已分配的内存。
更好的方法是避免显式资源分配:
1 | fun( |
用 make_shared、make_unique 等函数代替 new 运算符可有效规避这种问题。
R2.18 流式资源对象不应被复制
FILE 等流式对象不应被复制,如果存在多个副本会造成数据不一致的问题。
示例:
1 | FILE f; |
R2.19 避免使用变长数组
使用变长数组(variable length array)可以在栈上动态分配内存,但分配失败时的行为不受程序控制。
变长数组由 C99 标准提出,不在 C++ 标准之内,在 C++ 代码中不应使用。
示例:
1 | void foo(int n) |
例中数组 a 的长度为变量,其内存空间在运行时动态分配,如果长度参数 n 不是合理的正整数会导致未定义的行为。
另外,对于本应兼容的数组类型,如果长度不同也会导致未定义的行为,如:
1 | void bar(int n) |
R2.20 避免使用在栈上分配内存的函数
alloca、strdupa 等函数可以在栈上动态分配内存,但分配失败时的行为不受程序控制。
示例:
1 | #include <alloca.h> |
例中 alloca 函数在失败时往往会使程序崩溃,对其返回值的检查是无效的。这种后果不可控的函数应避免使用,尤其在循环和递归调用过程中更不应使用这种函数。
R2.21 局部数组不应过大
局部数组在栈上分配空间,如果占用空间过大会导致栈溢出错误。
应关注具有较大数组的函数,评估其在运行时的最大资源消耗是否符合执行环境的要求。
示例:
1 | void foo() { |
在栈上分配空间难以控制失败情况,如果条件允许可改在堆上分配:
1 | void foo() { |
R2.22 避免不必要的内存分配
对单独的基本变量或只包含少量基本变量的对象不应使用动态内存分配。
示例:
1 | bool* pb = new bool; // Non-compliant |
内存分配的开销远大于变量的直接使用,而且还涉及到回收问题,是得不偿失的。
应改为:
1 | bool b = false; // Compliant |
用 new 分配数组时方括号被误写成小括号,或使用 unique_ptr 等智能指针时遗漏了数组括号也是常见笔误,如:
1 | int* pi = new int(32); // Non-compliant |
应改为:
1 | int* pi = new int[32]; // Compliant |
有时可能需要区分变量是否存在,用空指针表示不存在,并通过资源分配创建变量的方式属于低效实现,不妨改用变量的特殊值表示变量的状态,在 C++ 代码中也可使用 std::optional 实现相关功能。
R2.23 避免动态内存分配
标准库提供的动态内存分配方法,其算法或策略不在使用者的控制之内,很多细节是标准没有规定的,而且也是内存耗尽等问题的根源,有高可靠性要求的嵌入式系统应避免动态内存分配。
在内存资源有限的环境中,由于难以控制具体的分配策略,很可能会导致已分配的空间用不上,未分配的空间不够用的情况。而在资源充足的环境中,也应尽量避免动态分配,如果能在栈上创建对象,就不应采用动态分配的方式,以提高效率并降低资源管理的复杂性。
示例:
1 | void foo() { |
例中 vector 容器使用了动态内存分配方法,容量的增长策略可能会导致内存空间的浪费,甚至使程序难以稳定运行。
R2.24 判断资源分配函数的返回值是否有效
malloc 等函数在分配失败时返回空指针,如果不加判断直接使用会导致标准未定义的行为。
在有虚拟内存支持的平台中,正常的内存分配一般不会失败,但申请内存过多或有误时(如参数为负数)也会导致分配失败,而对于没有虚拟内存支持的或可用内存有限的嵌入式系统,检查分配资源是否成功是十分重要的,所以本规则应该作为代码编写的一般性要求。
库的实现更需要注意这一点,如果库由于分配失败而使程序直接崩溃,相当于干扰了主程序的决策权,很可能会造成难以排查的问题,对于有高可靠性要求的软件,在极端环境中的行为是需要明确设定的。
示例:
1 | void foo(size_t n) { |
示例代码未检查 p 的有效性便直接使用是不符合要求的
R2.25 C++ 代码中禁用 C 内存管理函数
在 C++ 代码中不应使用 malloc、free 等 C 内存管理函数,应使用 C++ 内存管理方法。
示例:
1 | void foo(size_t n) { |
应改为:
1 | void foo(size_t n) { |