前言
早期编写的C++是有缺陷的,举些例子。比如裸指针满天飞,多线程的数据竞争,双重释放等等。但如今的C++正在努力改善这些缺陷,RAII范式的编程在C++比重逐步增加。RAII(Resource Acquisition Is Initialization)是C++之父Bjarne Stroustrup在设计C++的时候就引入了。即:资源获取即初始化。通俗点,在对象创建的时候获取资源,在对象销毁的时候释放资源。确保内存的安全性。指针shared_ptr就是其中的杰作,下面也会讲到。
本篇除了RAII之外,还会分析下其析构函数的关联。代码部分,经过C++20测试,均可跑通,可直接用。
RAII操作例子
一个非常简单的RAII操作,我们初始化对象的时候打开了文件资源。然后在离开对象的作用域的时候,会调用析构函数释放(关闭)文件资源,例子如下:
//filename:RAII.c
//compile:g++ -g -static -o RAII RAII.c
#include <iostream>
#include <memory>
class File {
public:
File(const std::string& filename) {
// 在构造函数中打开文件
std::cout << "Opening file: " << filename << std::endl;
file_ = fopen(filename.c_str(), "r");
if (!file_) {
throw std::runtime_error("Failed to open file");
}
}
~File() {
// 在析构函数中关闭文件
if (file_) {
std::cout << "Closing file." << std::endl;
fclose(file_);
}
}
private:
FILE* file_;
};
int main() {
try {
// 创建 File 对象,RAII 确保文件在生命周期结束时自动关闭
File f("example.txt");
} catch (const std::exception& e) {
std::cerr << e.what() << std::endl;
}
return 0;
}
File对象的构造函数里面打开文件,上面代码运行的结果如下:
图片
在File对象f离开作用域也即是try块的结尾大括号处,会调用析构函数,关闭文件。
关于这点我们lldb验证下,且简略分析下其原理。在~File()析构函数下断,其堆栈是在RAII.c:36也即是try块大括号结尾的地方调用了析构函数。
如下:
(lldb) b ~File()
Breakpoint 2: where = RAII`File::~File() + 16 at RAII.c:17:13, address = 0x000000000040582c
(lldb) r&c
Process 4510 resuming
Opening file: example.txt
Process 4510 stopped
* thread #1, name = 'RAII', stop reason = breakpoint 2.1
frame #0: 0x000000000040582c RAII`File::~File(this=0x00007fffffffe208) at RAII.c:17:13
14
15 ~File() {
16 // 在析构函数中关闭文件
-> 17 if (file_) {
18 std::cout << "Closing file." << std::endl;
19 fclose(file_);
20 }
(lldb) bt
* thread #1, name = 'RAII', stop reason = breakpoint 2.1
* frame #0: 0x000000000040582c RAII`File::~File(this=0x00007fffffffe208) at RAII.c:17:13
frame #1: 0x00000000004055ef RAII`main at RAII.c:36:5
frame #2: 0x00000000004b7ec8 RAII`__libc_start_call_main + 104
frame #3: 0x00000000004ba090 RAII`__libc_start_main + 624
frame #4: 0x0000000000405475 RAII`_start + 37
当我们运行到try块收尾大括号处,看此时程序刚好调用了File::~File
(lldb) n
Opening file: example.txt
Process 4552 stopped
* thread #1, name = 'RAII', stop reason = step over
frame #0: 0x00000000004055e3 RAII`main at RAII.c:33:5
30 File f("example.txt");
31
32 // 文件操作...
-> 33 } catch (const std::exception& e) {
34 std::cerr << e.what() << std::endl;
35 }
36
(lldb) di -s $pc
RAII`main:
-> 0x4055e3 <+110>: lea rax, [rbp - 0x58]
0x4055e7 <+114>: mov rdi, rax
0x4055ea <+117>: call 0x40581c ; File::~File at RAII.c:18:5
0x4055ef <+122>: mov eax, 0x0
0x4055f4 <+127>: mov rdx, qword ptr [rbp - 0x18]
0x4055f8 <+131>: sub rdx, qword ptr fs:[0x28]
也即是代码:
0x4055ea <+117>: call 0x40581c ; File::~File at RAII.c:18:5
RAII风格指针
现代C++的几个指针
- std::unique_ptr:独占所有权的智能指针。一个 unique_ptr 只能有一个指针指向资源,因此它不支持复制,只支持转移所有权。
- std::shared_ptr:共享所有权的智能指针。多个 shared_ptr 可以共享对资源的所有权,只有最后一个指针被销毁时,资源才会被释放。
- std::weak_ptr:一种不影响资源生命周期的智能指针,用来打破循环引用的问题
我们也来观察下RAII指针自动调用析构函数释放的例子
//filename:zhizhen.c
//compile:g++ -std=c++20 -g -static -o zhizhen zhizhen.c
#include <iostream>
#include <memory>
class Resource {
public:
Resource(const std::string& name) : name_(name) {
std::cout << name_ << " acquired!" << std::endl;
}
~Resource() {
std::cout << name_ << " released!" << std::endl;
}
void use() {
std::cout << "Using " << name_ << std::endl;
}
private:
std::string name_;
};
void demonstrateWeakPtr() {
// 创建 shared_ptr 管理 Resource 对象
std::shared_ptr<Resource> sharedResource = std::make_shared<Resource>("Resource1");
// 创建 weak_ptr 观察 shared_ptr
std::weak_ptr<Resource> weakResource = sharedResource;
// weak_ptr 不增加引用计数,它只是观察资源
std::cout << "Weak pointer created, but it does not affect resource's reference count." << std::endl;
// 使用 weak_ptr 的 lock 方法来获取 shared_ptr
if (auto lockedResource = weakResource.lock()) {
lockedResource->use(); // 使用资源
} else {
std::cout << "Failed to lock weak pointer, resource is not available." << std::endl;
}
// 当 shared_ptr 离开作用域时,资源会被释放
}
int main() {
demonstrateWeakPtr(); // 资源由 shared_ptr 管理,weak_ptr 只是观察
return 0;
}
它的结果如下,同样的析构函数在离开作用域释放
图片
结尾
RAII并不是一个新鲜的特性,而是古早就有的一种范式。上面例子展示了对象创建的时候获取资源,对象销毁的时候释放资源的例子。
我们只需要写好代码的规范,其它的编译器都给做了,比如析构函数的调用等。这种操作,有效的防范了部分内存泄露的可能性。