原文链接:

MEM53-CPP. Explicitly construct and destruct objects when manually managing object lifetime


C++ 中动态分配的对象的构建经历两个阶段. 第一个阶段的职责是分配足够的内存来存储对象, 第二个阶段的职责是根据所创建对象的类型来初始化新分配的内存块.

类似地, C++ 中动态分配的对象的析构也经历两个阶段. 第一个阶段的职责是根据对象类型来终结对象, 第二个阶段的职责是析构被该对象所使用的内存. C++ 标准, [basic.life], 段落 1 [ISO/IEC 14882-2014], 陈述如下:

对象的 生命周期 是对象运行时的一个属性. 如果它是一个类或者聚合类型, 并且它或它的成员通过构造函数而不是平凡 (trivial) 默认构造函数来初始化, 那么该对象被称为拥有非平凡 (non-trivial) 初始化. [: 被一个平凡的拷贝/移动构造函数的初始化是非平凡初始化. — 注尾]

T 对象的生命周期开始于:

— 获取到类 T 的正确对齐和尺寸的存储区域, 并且

— 如果该对象拥有非平凡初始化, 它的初始化已经完成.

T 对象的生命周期结束于:

— 如果 T 是一个有非平凡析构函数的类类型, 析构函数调用开始, 或者

— 该对象占用的存储区域被复用或者释放.

The lifetime of an object is a runtime property of the object. An object is said to have non-trivial initialization if it is of a class or aggregate type and it or one of its members is initialized by a constructor other than a trivial default constructor. [Note: initialization by a trivial copy/move constructor is non-trivial initialization. — end note] The lifetime of an object of type T begins when:

— storage with the proper alignment and size for type T is obtained, and
— if the object has non-trivial initialization, its initialization is complete.

The lifetime of an object of type T ends when:

— if T is a class type with a non-trivial destructor, the destructor call starts, or
— the storage which the object occupies is reused or released.

对于已经动态分配的对象, 这两个阶段通常被 newdelete 运算符自动处理了. 类 Tnew T 表达式会调用 operator new() 来为 T 分配足够的内存. 如果内存被成功分配, T 的默认构造函数会被调用. 表达式的返回结果是一个指向类 T 对象的指针 P . 当该指针被传入到表达式 delete P , 会调用 T 的析构函数. 在析构函数完成后, 产生一次 operator delete() 的调用来释放该内存.

当程序通过某种方式, 而不是 new 运算符来创建了动态分配的对象时, 可以称之为 手动管理 该对象的生命周期. 这种情况发生于当用其他分配策略来获取动态分配的对象的存储空间, 比如使用 分配器对象, allocator object 或者 malloc() . 例如, 自定义容器类可能在 reverse() 函数中分配了一块内存 (a slab of memory), 以供存储后续的对象. 见 MEM51-CPP. Properly deallocate dynamically allocated resources 获取更多关于动态内存分配的信息.

当手动管理对象的生命周期时, 必须调用构造函数里初始化该对象的生命周期. 同样地, 必须调用析构函数来终止该对象的生命周期. 在生命周期之外使用对象是 未定义行为, undefined behavior. 一个对象可以通过显式调用构造函数, 使用 placement new 操作符或者调用分配器对象的 construct() 函数. 一个对象可以通过显式调用析构函数或者调用分配器对象的 destroy() 函数来销毁.

不合规代码示例

在这个不合规的代码示例中, 通过调用 std::malloc() 来创建一个非平凡初始化的类 (由于存在用户定义的构造函数) . 然而, 该对象的构造函数从未被调用, 当该类后续通过 s->f() 访问时产生了 未定义行为, undefined behavior .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <cstdlib>

struct S {
S();

void f();
};

void g() {
S *s = static_cast<S *>(std::malloc(sizeof(S)));

s->f();

std::free(s);
}

合规方案

在这个合规方案中, 构造函数和析构函数都被显式调用了. 进一步地, 为了消除对象在其生命周期之外被使用的可能性, 内存变量与对象隔离.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <cstdlib>
#include <new>

struct S {
S();

void f();
};

void g() {
void *ptr = std::malloc(sizeof(S));
S *s = new (ptr) S;

s->f();

s->~S();
std::free(ptr);
}

不合规代码示例

在这个不合规代码示例中, 自定义容器使用一个分配器对象来获取存储任意元素类型的存储空间. 但是, 当假设 copy_elements() 函数调用元素的拷贝构造函数来将元素移动到新分配的存储空间中, 这个例子没有成功为任何其余被保留的元素显式调用默认构造函数. 如果有这种元素通过 operator[]() 被访问, 这将导致 未定义行为, undefined behavior –依赖类型 T.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <memory>

template <typename T, typename Alloc = std::allocator<T>>
class Container {
T *underlyingStorage;
size_t numElements;

void copy_elements(T *from, T *to, size_t count);

public:
void reserve(size_t count) {
if (count > numElements) {
Alloc alloc;
T *p = alloc.allocate(count); // Throws on failure
try {
copy_elements(underlyingStorage, p, numElements);
} catch (...) {
alloc.deallocate(p, count);
throw;
}
underlyingStorage = p;
}
numElements = count;
}

T &operator[](size_t idx) { return underlyingStorage[idx]; }
const T &operator[](size_t idx) const { return underlyingStorage[idx]; }
};

合规方案

在这个合规方案中, 所有元素都通过调用 T 地拷贝或者默认构造函数被正确地初始化了.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <memory>

template <typename T, typename Alloc = std::allocator<T>>
class Container {
T *underlyingStorage;
size_t numElements;

void copy_elements(T *from, T *to, size_t count);

public:
void reserve(size_t count) {
if (count > numElements) {
Alloc alloc;
T *p = alloc.allocate(count); // Throws on failure
try {
copy_elements(underlyingStorage, p, numElements);
for (size_t i = numElements; i < count; ++i) {
alloc.construct(&p[i]);
}
} catch (...) {
alloc.deallocate(p, count);
throw;
}
underlyingStorage = p;
}
numElements = count;
}

T &operator[](size_t idx) { return underlyingStorage[idx]; }
const T &operator[](size_t idx) const { return underlyingStorage[idx]; }
};

例外

MEM53-CPP-EX1: 如果对象时平凡构造的, 它不需要通过显式调用构造函数来初始化其生命周期. 如果对象是平凡析构的, 它不需要通过显式调用析构函数来初始化其生命周期. 这些属性可以通过调用 <type_traits> 中的 std::is_trivially_constructible()std::is_trivially_destructible() 来检测. 举个例子, 类似 intlong long 的整型不需要调用显式的构造函数和析构函数.

风险预估

没有成功构造或者析构对象将使其内部状态冲突, 这会造成 未定义行为, undefined behavior 和意外信息的暴露.

Rule Severity Likelihood Remediation Cost Priority Level
MEM53-CPP High Likely Medium P18 L1

Automated Detection

Tool Version Checker Description
Helix QAC img C++4761, C++4762, C++4766, C++4767
Parasoft C/C++test img CERT_CPP-MEM53-a Do not invoke malloc/realloc for objects having constructors
PVS-Studio img V630, V749

Search for vulnerabilities resulting from the violation of this rule on the CERT website.

SEI CERT C++ Coding Standard MEM51-CPP. Properly deallocate dynamically allocated resources

Bibliography

[ISO/IEC 14882-2014] Subclause 3.8, “Object Lifetime” Clause 9, “Classes”

SEI CERT C++ Coding Standard > SEI CERT C++ Coding Standard > button_arrow_left.png SEI CERT C++ Coding Standard > SEI CERT C++ Coding Standard > button_arrow_up.png SEI CERT C++ Coding Standard > SEI CERT C++ Coding Standard > button_arrow_right.png