RAII这个词看着很吓人,但其实很easy,这不是一个特性,也不是一个技术,它是一种编程思想(或者说习惯),类似"多写注释有助于以后重新熟悉代码"。
为了解释RAII的优越性,我们先看一段没有使用RAII的代码。
// 检查文件是否符合某种规范
bool CheckFile(const std::string file_path) {
std::string file_content;
ifstream ifs;
ifs.open(file_path, std::ifstream::binary); // 打开文件
// 检查文件是否能打开成功
if (!ifs.is_open())
return false;
// 检查文件大小
int64 file_size = ifs.seekg(0, ifs.end).tellg();
if (file_size > 1024) {
ifs.close();
return false;
}
ifs.seekg(0, ifs.beg);
// 获取文件第一行内容
if (!getline(ifs, file_content)) {
ifs.close();
return false;
}
// 检查文件内容
if (file_content != "this file is legal") {
ifs.close();
return false;
}
ifs.close(); // 关闭文件
return true;
}
注意这段代码是没有bug的,可以正常运行,但看起来非常拧巴,就这几个return前的close,写的罗嗦不说,以后再改代码还容易出bug,随便哪次return时忘了close文件就可能出问题。
为此有很多方式可以将多个return合并为一个,比如:
bool CheckFile(const std::string file_path) {
std::string file_content;
ifstream ifs;
ifs.open(file_path, std::ifstream::binary); // 打开文件
bool is_check_ok = true;
// 检查文件是否能打开成功
if (is_check_ok)
is_check_ok = ifs.is_open();
// 检查文件大小
if (is_check_ok) {
int64 file_size = ifs.seekg(0, ifs.end).tellg();
ifs.seekg(0, ifs.beg);
is_check_ok = file_size <= 1024;
}
// 获取文件第一行内容
if (is_check_ok)
is_check_ok = (bool)getline(ifs, file_content);
// 检查文件内容
if (is_check_ok)
is_check_ok = file_content == "this file is legal";
ifs.close(); // 关闭文件
return is_check_ok;
}
bool CheckFile(const std::string file_path) {
std::string file_content;
ifstream ifs;
ifs.open(file_path, std::ifstream::binary); // 打开文件
bool is_check_ok = false;
do {
// 检查文件是否能打开成功
if (!ifs.is_open())
break;
// 检查文件大小
int64 file_size = ifs.seekg(0, ifs.end).tellg();
ifs.seekg(0, ifs.beg);
if (file_size > 1024)
break;
// 获取文件第一行内容
if (!getline(ifs, file_content))
break;
// 检查文件内容
if (file_content != "this file is legal")
break;
is_check_ok = true;
} while (false);
ifs.close(); // 关闭文件
return is_check_ok;
}
bool CheckFile(const std::string file_path) {
std::string file_content;
ifstream ifs;
ifs.open(file_path, std::ifstream::binary); // 打开文件
// lambda
auto check_file_fun = [&]() -> bool {
// 检查文件是否能打开成功
if (!ifs.is_open())
return false;
// 检查文件大小
__int64 file_size = ifs.seekg(0, ifs.end).tellg();
if (file_size > 1024)
return false;
ifs.seekg(0, ifs.beg);
// 获取文件第一行内容
if (!getline(ifs, file_content))
return false;
// 检查文件内容
if (file_content != "this file is legal")
return false;
return true;
};
bool is_check_ok = check_file_fun();
ifs.close(); // 关闭文件
return is_check_ok;
}
// 仅作演示用,实际工作中不推荐使用goto
bool CheckFile(const std::string file_path) {
std::string file_content;
ifstream ifs;
ifs.open(file_path, std::ifstream::binary); // 打开文件
bool is_check_ok = false;
// 检查文件是否能打开成功
if (!ifs.is_open())
goto Exit;
// 检查文件大小
int64 file_size = ifs.seekg(0, ifs.end).tellg();
ifs.seekg(0, ifs.beg);
if (file_size > 1024)
goto Exit;
// 获取文件第一行内容
if (!getline(ifs, file_content))
goto Exit;
// 检查文件内容
if (file_content != "this file is legal")
goto Exit;
is_check_ok = true;
Exit:
ifs.close(); // 关闭文件
return is_check_ok;
}
哈哈哈抱歉多写了几个。
但这样用起来还是很不方便,这三种方式虽然实现了功能,也杜绝了手误写bug的可能性,但这种代码写法还是不如第一种随时return的方式直观。
如果这个ifs能自动close就好了,像一个普通变量一样,超出作用域就自动释放,这样我们就不需要专门处理它的清理工作了。
那么,有办法实现这个效果吗?答案是有,思路是用一个变量把ifs包裹起来,把ifs当初普通变量使用。比如这样:
// 智能文件类
class SmartFile {
private:
ifstream ifs;
public:
SmartFile(const std::string file_path) {
ifs.open(file_path, std::ifstream::binary); // 构造函数里加载文件
}
~SmartFile() {
ifs.close(); // 析构函数里释放文件
}
ifstream& getIfs() {
return ifs; // 获取ifs
}
};
bool CheckFile(const std::string file_path) {
std::string file_content;
SmartFile smartIfs(file_path); // 使用智能ifs
// 检查文件是否能打开成功
if (!smartIfs.getIfs().is_open())
return false;
// 检查文件大小
int64 file_size = smartIfs.getIfs().seekg(0, smartIfs.getIfs().end).tellg();
if (file_size > 1024)
return false;
smartIfs.getIfs().seekg(0, smartIfs.getIfs().beg);
// 获取文件第一行内容
if (!getline(smartIfs.getIfs(), file_content))
return false;
// 检查文件内容
if (file_content != "this file is legal")
return false;
return true;
}
注意看这个代码,新增一个SmartFile类专门处理ifstream,在构造的时候初始化,在析构的时候close,这样我们就可以像使用普通变量一样使用ifstream了。
无论我们在任何时候(全局变量、函数内、if内)使用SmartFile,只要他超出作用域,SmartFile必然被销毁,析构函数被调用,ifstream自动被close了,无需我们手动处理。
上面代码已经无限接近第一版代码了,非常符合我们日常的思维习惯,但getIfs还是有点绕口,重载运算符来让代码更简洁:
// 智能文件类
class SmartFile {
private:
ifstream ifs;
public:
SmartFile(const std::string file_path) {
ifs.open(file_path, std::ifstream::binary); // 构造函数里加载文件
}
~SmartFile() {
ifs.close(); // 析构函数里释放文件
}
ifstream* operator->() { return &ifs; }
operator ifstream* () { return &ifs; }
};
bool CheckFile(const std::string file_path) {
std::string file_content;
SmartFile smartIfs(file_path); // 使用智能ifs
// 检查文件是否能打开成功
if (!smartIfs->is_open())
return false;
// 检查文件大小
int64 file_size = smartIfs->seekg(0, smartIfs->end).tellg();
if (file_size > 1024)
return false;
smartIfs->seekg(0, smartIfs->beg);
// 获取文件第一行内容
if (!getline(*smartIfs, file_content))
return false;
// 检查文件内容
if (file_content != "this file is legal")
return false;
return true;
}
是不是看起来就顺眼许多啦哈哈哈。
这就是RAII,非常的优雅。
几乎所有一切需要清理资源的,或者需要成对调用的接口,我们都可以使用RAII把它封装起来。
比如CRITICAL_SECTION,使用的时候需要Enter和Leave,可以封装:
class SmartCritSec {
CRITICAL_SECTION* cs;
public:
explicit SmartCritSec(CRITICAL_SECTION* cs) : cs(cs) {
EnterCriticalSection(cs);
}
~SmartCritSec() {
LeaveCriticalSection(cs);
}
};
void Test() {
SmartCritSec scope(&m_access); // m_access为已经初始化过的CRITICAL_SECTION
// work
}
比如打开文件的HANDLE,结束时需要CloseHandle,可以封装:
class SmartHandle {
private:
HANDLE handle;
public:
explicit SmartHandle(HANDLE handle) : handle(handle) { }
~SmartHandle() { CloseHandle(handle); }
operator HANDLE() const { return handle; }
};
void Test() {
ScopedHandle file(CreateFile(file_path, GENERIC_READ | GENERIC_WRITE, OPEN_EXISTING));
// work
}
比如Windows的GDI对象HBRUSH创建完需要DeleteObject,可以封装:
class SmartHBrush {
HBRUSH obj;
public:
SmartHBrush(HBRUSH obj) : obj(obj) { }
~SmartHBrush() { DeleteObject(obj); }
operator HBRUSH() const { return obj; }
};
void Test() {
SmartHBrush brush(CreateSolidBrush(RGB(255, 0, 0)));
// work
}
比如使用com组件时需要CoInitialize和CoUninitialize,可以封装:
class SmartCom {
public:
SmartCom() { (void)CoInitialize(nullptr); }
~SmartCom() { CoUninitialize(); }
};
void Test() {
SmartCom com;
// work
}
再也不用惦记着return前执行关闭操作了。
这时有聪明的同学要问了,我很少用你说的这些handle、com、brush,有没有什么我们更常用的代码用RAII实现的,等等你这个Smart单词看起来很眼熟呀~
眼熟就对了,RAII最大的应用,就是处理指针。
比如我们写一个正常的裸指针代码如下:
bool Check() {
std::string* s = new std::string;
*s = "abc";
// work
if (!s) {
delete s;
return false;
}
if (*s == "") {
delete s;
return false;
}
delete s;
return true;
}
那么我们就可以写一个RAII指针如下:
class SmartString {
private:
std::string* obj;
public:
SmartString(std::string* obj) : obj(obj) {}
~SmartString() { delete obj; }
operator std::string* () const { return obj; }
std::string* operator->() const { return obj; }
};
bool Check() {
SmartString s(new std::string);
*s = "abc";
// work
if (!s)
return false;
if (s->length() == 0)
return false;
if (*s == "")
return false;
return true;
}
是不是就顺眼很多啦~
来,我们把它变成一个模板,让它更通用:
template <class T>
class SmartPtr {
T* obj;
public:
SmartPtr(T* obj) : obj(obj) {}
~SmartPtr() { delete obj; }
operator T* () const { return obj; }
T* operator->() const { return obj; }
};
bool Check() {
SmartPtr<std::string> s(new std::string);
*s = "abc";
SmartPtr<int> i(new int);
SmartPtr<Bitmap> d(new Bitmap);
SmartPtr<MyClassA> c(new MyClassA);
return true;
}
这就是一个最简单的智能指针,其他所有一切的智能指针,核心原理都是这样的。
当然一个智能指针要在各种复杂环境下都正常工作,仅有这些是不够的,还需要处理其他的逻辑操作,包括重载更多运算符,包括获取裸指针等,有兴趣的小伙伴请研究一下主流智能指针的实现源码。
在智能指针的基础上再多谈一点。
我上面实现的这个智能指针,再丰富一下操作接口,就是std::unique_ptr。
假如现在我在A线程有一个智能指针,指向一个对象(即一块内存),那如果我把这个指针传递给B线程时,我需要复制这个指针,也需要复制对象本身,这样两个线程各自持有各自的智能指针,也指向各自的对象。如果不这么操作,两个线程的指针指向同一对象,那当其中一个线程退出时,智能指针将对象销毁,第二个线程访问对象必然出错。
那么,有什么办法来解决这个问题呢,答案是std::shared_ptr,智能指针内部有一个计数器,初始化时为1,每复制一次指针就将计数器+1,而销毁指针时,并不直接销毁对象,而是将计数器-1,当计数器为0时才真正销毁对象。这样不管我复制多少次智能指针,销毁多少次智能指针,只有最后一个智能指针销毁时才真的销毁对象,非常的安全,高效,更优雅了。
那么,shared_ptr有什么缺点吗,当然有的,它的缺点叫循环引用,看下面这段代码(不再详细讲shared_ptr的实现逻辑了):
struct Node {
std::shared_ptr<Node> child_node;
};
void Test() {
std::shared_ptr<Node> item = std::make_shared<Node>();
item->child_node= item;
}
运行一下就会出现内存泄漏,详细解释一下为什么会出问题:
- make_shared时new了Node对象,此时计数器为1
- item->_node = item时将智能指针赋给了child_node,此时既不new也不delete对象,只是计数器变成2
- 当Test执行完成后,智能指针item销毁,然后shared_ptr本来计数器为2,现在item销毁了,计数器就变成1了,shared_ptr一看计数器没有变成0,也就不会真的执行对象的销毁操作,那么第1步new出来的对象就永远存在了。
硬要解释的话就是本来new出来的对象有2个入口,销毁了一个,第二个入口还在,但我们再也拿不到这个入口了,因此造成内存泄露。
那么,有什么办法来解决shared_ptr循环引用的问题吗,当然有的,那就是weak_ptr。
weak_ptr,看名字就知道,它是一个弱引用智能指针,就是为了解决shared_ptr循环引用问题的,请看代码
struct Node {
std::weak_ptr<Node> _node;
};
void Test() {
std::shared_ptr<Node> item = std::make_shared<Node>();
item->_node = item;
}
如此,便不会内存泄露了。
至于shared_ptr和weak_ptr的详细用法,大家还请继续研究。
看上面智能指针的逻辑体系,其实非常的直白,符合我们的日常思维直觉,一点都不高深,哪怕让我们自己设计一套智能指针系统,我们最终写出来的,肯定也是shared_ptr与weak_ptr这一套。
现在C++已经不推荐使用裸指针了,除非万不得已,不然还是老老实实的使用智能指针,能极大的降低bug概率和程序员的心智负担。