“Better code, better life. ”
C++ RAII 惯用法
什么是RAII?我先卖个关子,且听我说一说我的故事。
刚学习服务器开发时,我接到这样一个任务,在 Windows 系统上写一个 C++ 程序,该程序的功能是实现一个简单的服务器程序,当客户端连接上来后,给客户端发一条 “HelloWorld” 消息后关闭连接。(不用保证客户端一定收到,后文中具体介绍)。
版本一 最初的写法
如果你熟悉基本的网络通信,你会觉得这很容易,这个程序就是服务器程序网络通信的基本流程,大致思路如下:
-
创建socket
-
绑定ip地址和端口号
-
在该ip地址和端口号上开启侦听,然后循环等待客户端连接的到来,有客户端连接成功后,发送一条“helloworld”消息,然后断开连接。
由于在Windows操作系统上,所以我们还要在程序启动之初使用 WSAStartup 函数初始化一下socket库,在程序结束时,使用 WSACleanup 函数释放socket库资源。
代码我很快就写出来了:
#include "stdafx.h"
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
int main(intargc, char* argv[])
{
//加载套接字库
WORD wVersionRequested = MAKEWORD(1, 1);
WSADATA wsaData;
int err = WSAStartup(wVersionRequested, &wsaData);
if (err != 0)
return 1;
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)
{
WSACleanup();
return 1;
}
//创建用于监听的套接字
SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0);
if (sockSrv == -1)
{
WSACleanup();
return 1;
}
SOCKADDR_IN addrSrv;
addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(6000);
//绑定套接字,在6000端口上监听
if (bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)) == -1)
{
closesocket(sockSrv);
WSACleanup();
return 1;
}
//将套接字设为监听模式,准备接受客户请求
if (listen(sockSrv, 15) == -1)
{
closesocket(sockSrv);
WSACleanup();
return 1;
}
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
char msg[] = "HelloWorld";
while (true)
{
//等待客户请求到来,如果有客户端连接,则接收连接
SOCKET sockClient = accept(sockSrv, (SOCKADDR*)&addrClient, &len);
//给客户端发送HelloWorld发送数据
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end while-loop
closesocket(sockSrv);
WSACleanup();
return 0;
}
上面的代码满足了我们的任务要求,但是有些地方非常不令人满意,代码中到处都是为了考虑出错情形的资源回收的冗余代码。这种情况我们在实际开发中也经常遇到,如下面这段伪码:
char* p = newchar[1024];
if (操作1不成功)
{
delete[] p;
p = NULL;
return;
}
if (操作2不成功)
{
delete[] p;
p = NULL;
return;
}
if (操作3不成功)
{
delete[] p;
p = NULL;
return;
}
delete[] p;
p = NULL;
这种情形我们可以归纳成:先分配资源,然后进行相关操作,在任何一步中间步骤中出错我们都要把资源释放掉,如果中间步骤没有错误,我们在资源使用完毕之后也要释放响应的资源。这里的伪码释放资源(代码中是堆内存)的重要性不言而喻,否则会造成内存泄露。但是这样的代码太容易出错了,我们在编码时必须时刻提高警惕,任何一个出错步骤中都要记得清理资源。代码冗余累赘且容易出错,那么有没有一种好一点的方法呢?有,使用 goto 语句。
版本二 使用goto语句
我们还是以上面那段网络通信的代码为例,如果使用goto语句,我们的代码可以简化成如下形式:
#include "stdafx.h"
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
int main(intargc, char* argv[])
{
//加载套接字库
WORD wVersionRequested = MAKEWORD(1, 1);
WSADATA wsaData;
int err = WSAStartup(wVersionRequested, &wsaData);
if (err != 0)
return 1;
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)
{
goto cleanup2;
return 1;
}
//创建用于监听的套接字
SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0);
if (sockSrv == -1)
{
goto cleanup2;
return 1;
}
SOCKADDR_IN addrSrv;
addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(6000);
//绑定套接字,在6000端口上监听
if (bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)) == -1)
{
goto cleanup1;
return 1;
}
//将套接字设为监听模式,准备接受客户请求
if (listen(sockSrv, 15) == -1)
{
goto cleanup1;
return 1;
}
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
char msg[] = "HelloWorld";
while (true)
{
//等待客户请求到来,如果有客户端连接,则接收连接
SOCKET sockClient = accept(sockSrv, (SOCKADDR*)&addrClient, &len);
//给客户端发送HelloWorld发送数据
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end while-loop
cleanup1:
closesocket(sockSrv);
cleanup2:
WSACleanup();
return 0;
}
使用了 goto 语句之后,我们一旦中间某个步骤出错,则跳转到统一的清理点出进行资源释放操作。但是,这样的代码还是令我们很忧伤,我们当年在学编程语言的第一堂时老师就告诉我们慎用 “goto” 语句,它是魔鬼,会让我们的程序结构变得混乱和难以维护,而且各种编程书籍在介绍 goto 语句时也重复表达着同样的意思。(这里姑且不评论这种经验规则到底是不是必须遵守的金科玉律)那么有没有更好的方式呢?有的,且看版本三。
版本三 使用do…while(0)循环
我们先看下使用do…while(0)循环改进后的代码:
#include "stdafx.h"
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
int main(intargc, char* argv[])
{
//加载套接字库
WORD wVersionRequested = MAKEWORD(1, 1);
WSADATA wsaData;
int err = WSAStartup(wVersionRequested, &wsaData);
if (err != 0)
return 1;
SOCKET sockSrv = -1;
do
{
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)
break;
//创建用于监听的套接字
sockSrv = socket(AF_INET, SOCK_STREAM, 0);
if (sockSrv == -1)
break;
SOCKADDR_IN addrSrv;
addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(6000);
//绑定套接字,在6000端口上监听
if (bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)) == -1)
break;
//将套接字设为监听模式,准备接受客户请求
if (listen(sockSrv, 15) == -1)
break;
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
char msg[] = "HelloWorld";
while (true)
{
//等待客户请求到来,如果有客户端连接,则接收连接
SOCKET sockClient = accept(sockSrv, (SOCKADDR*)&addrClient, &len);
//给客户端发送HelloWorld发送数据
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end inner-while-loop
} while (0); //end outer-while-loop
if (sockSrv != -1)
closesocket(sockSrv);
WSACleanup();
return 0;
}
上面的代码利用 do…while(0) 循环中的 break 特性巧妙地将资源清理集中在一个地方。类似的for循环也能达到这个目的,对于上文中提到的那个堆内存分配与释放的操作,同样可以改写成do…while(0)的形式:
char* p = NULL;
do
{
p = newchar[1024];
if (操作1不成功)
break;
if (操作2不成功)
break;
if (操作3不成功)
break;
} while (0);
delete[] p;
p = NULL;
这是 do…while(0) 的一个妙用,当我参加工作第一次在项目中遇到这种写法时很费解,后来就去网络搜索,等搞明白了觉得这样的写法确实很巧妙。然而故事到这里并没有完结,上面这种写法是 C 语言面向过程思维,有了 C++ 之后,我们有更好的写法来代替 do…while(0) 这种写法,这就是本节我们要介绍的 RAII 技术。哎呀,绕了一个大圈子,主角终于上场了。那么什么是 RAII 呢?
版本四 RAII 惯用法
RAII 是英文 Resource Acquisition Is Initialization 的缩写,翻译成中文是“资源获取就是初始化”,这个翻译还是令人费解。我来通俗地解释一下,所谓 RAII 就是资源在你拿到时就已经初始化好了,一旦你不再需要这个资源,其可以自动释放。
对于 C++ 这门语言来说,资源在构造函数中初始化(可以在构造函数中调用单独的初始化函数),在析构函数中释放或清理。常见的情形就是函数调用中,创建 C++ 对象时分配资源,当出了这个函数作用域时该 C++ 对象中的资源自动清理和释放(不管这个这个对象是如何出作用域的——无论是中间某个步骤不满足return掉还是正常走完全部流程return)。
还是以上面网络通信的例子来说,程序初始化时我们需要分配两种资源:
- 资源一: 初始化好Windows socket网络库;
- 资源二: 创建一个用于侦听的socket。
而我们在程序结束时,我们需要反初始化上述两种资源。
所以我们的代码可以这样改写:
#include "stdafx.h"
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
classServerTest
{
public:
ServerTest()
{
m_bInit = false;
m_ListenSocket = -1;
}
~ServerTest()
{
if (m_ListenSocket != -1)
::closesocket(m_ListenSocket);
if (m_bInit)
::WSACleanup();
}
bool DoInit()
{
//加载套接字库
WORD wVersionRequested = MAKEWORD(1, 1);
WSADATA wsaData;
int err = WSAStartup(wVersionRequested, &wsaData);
if (err != 0)
return false;
m_bInit = true;
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)
return false;
//创建用于监听的套接字
m_ListenSocket = socket(AF_INET, SOCK_STREAM, 0);
if (m_ListenSocket == -1)
return false;
return true;
}
bool DoBind(constchar* ip, shortport)
{
SOCKADDR_IN addrSrv;
addrSrv.sin_addr.S_un.S_addr = inet_addr(ip);
addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons(port);
if (::bind(m_ListenSocket, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)) == -1)
return false;
returntrue;
}
bool DoListen(intbacklog = 15)
{
if (listen(m_ListenSocket, backlog) == -1)
returnfalse;
returntrue;
}
bool DoAccept(boolrun)
{
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
char msg[] = "HelloWorld";
while (run)
{
//等待客户请求到来,如果有客户端连接,则接收连接
SOCKET sockClient = accept(m_ListenSocket, (SOCKADDR*)&addrClient, &len);
//给客户端发送HelloWorld发送数据
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end inner-while-loop
returnfalse;
}
private:
bool m_bInit;
SOCKET m_ListenSocket;
};
int main(intargc, char* argv[])
{
ServerTest test;
if (!test.DoInit())
returnfalse;
if (!test.DoBind("0.0.0.0", 6000))
return false;
if (!test.DoListen(15))
return false;
if (!test.DoAccept(true))
return false;
return 0;
}
上面的代码,我们没有在构造函数中分配资源,而是单独使用一个 DoInit() 方法来初始化资源,并在析构函数中释放相应的资源。而在 main 函数中,我们不用担心任何中间步骤的失败忘记了释放资源,因为一旦出了main函数的作用域 ServerTest 对象会自动调用其析构函数帮我们释放相应资源。这就是 RAII 的原理!我希望你能理解这种C++惯用法,因为它在 C++ 中实在太常见了。比如上面分配堆内存的例子,我们也可以使用 RAII 技术改写:
class HeapObjectWrapper
{
public:
HeapObjectWrapper(intsize)
{
if (size <= 0 || size > 1024 * 1024 * 1024)
size = 1024;
m_p = newchar[size];
}
~HeapObjectWrapper()
{
delete[] m_p;
m_p = NULL;
}
private:
char* m_p;
};
HeapObjectWrapper heapObj(1024);
if (操作1不成功)
return;
if (操作2不成功)
return;
if (操作3不成功)
return;
这样,一旦出了作用域,heapObj 就会自动调用其析构函数释放堆内存。当然,这里的资源分配和释放,可以延伸开来成各种外延和内涵来,如锁的获取和释放,我们常常会遇到以下场景:
void SomeFunction()
{
得到某把锁;
if (条件1)
{
if (条件2)
{
某些操作1
释放锁;
return;
}
else (条件3)
{
某些操作2
释放锁;
return;
}
}
if (条件3)
{
某些操作3
释放锁;
return;
}
某些操作4
释放锁;
}
非常常见的操作,我们为了避免死锁,必须在每个可能退出的分支上释放锁,随着我们逻辑写着越来越复杂,我们忘记在某个分支加上释放锁的代码的可能性就越来越大。而 RAII 正好解决了这个问题,我们可以将锁包裹成一个对象,在构造函数中获取锁,在析构函数中释放锁,伪码如下:
class SomeLockWrapper
{
public:
SomeLockGuard()
{
//加锁
m_lock.lock();
}
~SomeLockGuard()
{
//解锁
m_lock.unlock();
}
private:
SomeLock m_lock;
};
void SomeFunction()
{
SomeLockWrapper lockWrapper;
if (条件1)
{
if (条件2)
{
某些操作1
return;
}
else (条件3)
{
某些操作2
return;
}
}
if (条件3)
{
某些操作3
return;
}
某些操作4
}
对于上面的代码,熟悉的读者可能一眼就看出来,这不就是 C++ 11 std::lock_guard 和 boost::mutex::scoped_lock 的原理嘛,确实是这样,后面的章节我们会详细介绍操作系统和C++11各种锁。
小结
总而言之,理解并熟练使用 RAII 惯用法不仅能让你的代码更加简洁和模块化,同时可以在开发阶段就能避免一部分资源泄漏、死锁等问题,这些问题具有非常强的隐蔽性,如果将来在生产环境出现,难以复现不说且一旦出现也不太容易排查与定位。