1.1 C++ RAII惯用法
什么是RAII?容我先卖个关子,给大家讲个故事。
# 1.1.1 版本一 最初的写法
我在刚学习服务器开发的时候公司安排给新人一个练习:在Windows系统上写一个C++程序,该程序的功能是实现一个简单的服务,当客户端连接上来时,给客户端发一条 “HelloWorld” 消息后关闭连接,不用保证客户端一定能收到。
如果你熟悉基础网络编程知识,你会觉得这很容易嘛,这个程序就是TCP网络通信的基本流程,实现流程如下:
创建socket;
绑定ip地址和端口号;
在该ip地址和端口号上启动侦听,然后循环等待客户端连接的到来,当客户端连接成功后,发送一条 “HelloWorld” 消息,然后断开连接。
在Windows操作系统上,使用网络通信API之前,需要使用WSAStartup函数初始化一下socket库,在程序结束时,需要使用WSACleanup函数清理下socket库。
我很快就将程序写出来了:
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
int main(int argc, char* argv[])
{
//初始化socket库
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);
if (sockClient == -1)
break;
//给客户端发送”HelloWorld“消息
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end while-loop
closesocket(sockSrv);
WSACleanup();
return 0;
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
上面的代码虽然满足了任务要求,但是有些地方令人不太满意,代码中到处充斥着重复的、为了考虑出错情形的资源清理逻辑:
closesocket(sockSrv);
和
WSACleanup();
这样的场景我们在实际开发中经常遇到,例如下面这段伪码:
char* p = new char[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;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这种情形我们可以归纳成:先分配资源,然后进行相关操作,在任一中间步骤中操作出错时对资源进行回收,如果中间步骤没有错误,我们在资源使用完毕之后也要回收相应的资源。上述伪代码片段中释放资源的重要性不言而喻,因为分配了堆内存,否则会造成内存泄露。但是实际在编写这样的代码时太容易出错了,我们必须时刻保持警惕,在任何一个出错步骤中都要记得回收资源,因此也造成了大量重复逻辑。那么有没有方法解决这类问题呢?有,使用goto语句。
# 1.1.2 版本二 使用goto语句
还是以前面网络通信的代码为例,如果使用goto语句,上述代码可以简化成如下形式:
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
int main(int argc, char* argv[])
{
//由于goto语句不能跳过变量定义,
//所以提前定义下文需要用到的变量
SOCKET sockSrv;
SOCKADDR_IN addrSrv;
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
char msg[] = "HelloWorld";
//初始化socket库
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;
}
//创建用于监听的套接字
sockSrv = socket(AF_INET, SOCK_STREAM, 0);
if (sockSrv == -1)
{
goto cleanup2;
return 1;
}
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;
}
while (true)
{
//等待客户请求到来,如果有客户端连接,则接受连接
SOCKET sockClient = accept(sockSrv, (SOCKADDR*)&addrClient, &len);
if (sockClient == -1)
break;
//给客户端发送”HelloWorld“消息
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end while-loop
cleanup1:
closesocket(sockSrv);
cleanup2:
WSACleanup();
return 0;
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
使用了goto语句之后,我们一旦中间某个步骤出错,则跳转到统一的清理点处进行资源清理操作。
但是,这样的代码还是令我们很忧伤,当年在学编程语言的第一堂课时老师就告诉我们慎用“goto”语句,它是魔鬼,会让我们的程序结构变得混乱和难以维护,各种编程书籍在介绍goto语句时也重复表达着同样的意思。这里姑且不评论老师的话和编程书籍上不建议使用goto语句到底是不是必须遵守的金科玉律。如果不用goto语句,有没有更好的实现方式呢?有的,且看版本三。
# 1.1.3 版本三 使用do...while(0)循环
使用do...while(0)循环改进后的代码:
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
int main(int argc, char* argv[])
{
//初始化socket库
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);
if (sockClient == -1)
break;
//给客户端发送”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;
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
上述代码利用do...while(0)循环中的break特性巧妙地将资源回收操作集中到一个地方,使用for循环也能达到一样的效果。我们同样可以使用do...while(0)形式去改造上文中堆内存分配与释放的示例伪代码:
char* p = NULL;
do
{
p = new char[1024];
if (操作1不成功)
break;
if (操作2不成功)
break;
if (操作3不成功)
break;
} while (0);
delete[] p;
p = NULL;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这是do...while(0)的一个妙用,当我刚参加工作时第一次在项目中遇到这种写法时很费解,等搞明白后觉得这种写法很巧妙。我们的故事到这里还没完结呢,在C++语言中,我们有更好的写法来代替do...while(0),这就是本节我们要介绍的RAII惯用法。哎呀,绕了一个大圈子,主角终于上场了。那什么是RAII惯用法呢?
# 1.1.4 版本四 RAII惯用法
RAII是英文Resource Acquisition Is Initialization的缩写,翻译成中文是“资源获取就是初始化”,这个翻译仍然令人费解。通俗地说,所谓RAII就是资源在你拿到时就已经初始化好了,一旦你不再需要这个资源,该资源可以自动释放。
对于C++语言来说,即资源在构造函数中初始化(可以在构造函数中调用单独的初始化函数),在析构函数中释放或清理。常见的情形就是函数调用中,创建C++对象时分配资源,当C++对象出了作用域时会自动被清理和释放(不管这个这个对象是如何出作用域的——中间某个步骤不满足提前return掉还是正常走完全部流程后 return)。
还是以上面网络通信的例子来说,程序初始化时我们需要分配两种资源:
- 资源一: 初始化好Windows socket网络库;
- 资源二: 创建一个用于侦听的socket。
而我们在程序结束时,我们需要清理上述两种资源。
使用RAII惯用法改进后的代码如下:
#include <winsock2.h>
#include <stdio.h>
//链接Windows的socket库
#pragma comment(lib, "ws2_32.lib")
class ServerSocket
{
public:
ServerSocket()
{
m_bInit = false;
m_ListenSocket = -1;
}
~ServerSocket()
{
if (m_ListenSocket != -1)
::closesocket(m_ListenSocket);
if (m_bInit)
::WSACleanup();
}
bool DoInit()
{
//初始化socket库
WORD wVersionRequested = MAKEWORD(1, 1);
WSADATA wsaData;
int err = WSAStartup(wVersionRequested, &wsaData);
if (err != 0)
return false;
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)
return false;
m_bInit = true;
//创建用于监听的套接字
m_ListenSocket = socket(AF_INET, SOCK_STREAM, 0);
if (m_ListenSocket == -1)
return false;
return true;
}
bool DoBind(const char* ip, short port = 6000)
{
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;
return true;
}
bool DoListen(int backlog = 15)
{
if (listen(m_ListenSocket, backlog) == -1)
return false;
return true;
}
bool DoAccept()
{
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
char msg[] = "HelloWorld";
while (true)
{
//等待客户请求到来,如果有客户端连接,则接受连接
SOCKET sockClient = accept(m_ListenSocket, (SOCKADDR*)&addrClient, &len);
if (sockClient == -1)
break;
//给客户端发送”HelloWorld“消息
send(sockClient, msg, strlen(msg), 0);
closesocket(sockClient);
}// end inner-while-loop
return false;
}
private:
bool m_bInit;
SOCKET m_ListenSocket;
};
int main(int argc, char* argv[])
{
ServerSocket serverSocket;
if (!serverSocket.DoInit())
return false;
if (!serverSocket.DoBind("0.0.0.0", 6000))
return false;
if (!serverSocket.DoListen(15))
return false;
if (!serverSocket.DoAccept())
return false;
return 0;
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
上述代码中,我们没有在构造函数中分配资源,而是单独使用一个DoInit()方法来初始化资源,并在析构函数中回收相应的资源。这样在main函数中,我们不用担心任何中间步骤的失败忘记释放资源,因为一旦main函数调用结束,serverSocket对象会自动调用其析构函数回收相应资源。这就是RAII惯用法的原理!
严格来说,上述代码中ServerSocket的成员变量m_bInit应该被设计成类静态成员,调用WSAStartup和WSACleanup的函数应该被设计成类的静态方法,因为它们分别只需要在程序初始化和退出时调用一次就可以了。
我希望你能理解这种C++惯用法,因为它在C++中实在太常用了。我们也可以使用RAII惯用法再次改写上文中分配堆内存的示例伪代码:
class HeapObjectWrapper
{
public:
HeapObjectWrapper(int size)
{
m_p = new char[size];
}
~HeapObjectWrapper()
{
delete[] m_p;
m_p = NULL;
}
private:
char* m_p;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HeapObjectWrapper heapObj(1024);
if (操作1不成功)
return;
if (操作2不成功)
return;
if (操作3不成功)
return;
2
3
4
5
6
7
8
9
上述代码中heapObj对象一旦出了其作用域,会自动调用其析构函数释放堆内存。当然,RAII惯用法中的对资源的”分配“和”释放“的含义可以延伸出成各种外延和内涵来,如对多线程锁的获取和释放,我们在实际开发中也常常遇到以下情景:
void SomeFunction()
{
得到某把锁;
if (条件1)
{
if (条件2)
{
某些操作1
释放锁;
return;
}
else (条件3)
{
某些操作2
释放锁;
return;
}
}
if (条件3)
{
某些操作3
释放锁;
return;
}
某些操作4
释放锁;
}
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
这是一段非常常见的逻辑,为了避免死锁,我们必须在每个可能退出的分支上释放锁。随着逻辑写得越来越复杂,我们忘记在某个退出分支加上释放锁的代码的可能性也越来越大。而RAII惯用法正好解决了这个问题,我们可以将锁包裹成一个对象,在构造函数中获取锁,在析构函数中释放锁,伪码如下:
class SomeLockGuard
{
public:
SomeLockGuard()
{
//加锁
m_lock.lock();
}
~SomeLockGuard()
{
//解锁
m_lock.unlock();
}
private:
SomeLock m_lock;
};
void SomeFunction()
{
SomeLockGuard lockWrapper;
if (条件1)
{
if (条件2)
{
某些操作1
return;
}
else (条件3)
{
某些操作2
return;
}
}
if (条件3)
{
某些操作3
return;
}
某些操作4
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
使用了RAII惯用法之后,我们再也不必在每个函数出口处加上释放锁的代码了,因为锁会在函数调用结束后自动释放。
对于上面的代码,有经验的读者可能一眼就看出来了:这不就是C++11 std::lock_guard和boost::mutex::scoped_lock的原理嘛,确实是这样,后面的章节我们会详细介绍操作系统和C++11各种锁的用法。
# 1.1.5 小结
资源泄露和死锁等问题具有非常强的隐蔽性,如果将来在生产环境出现,难以复现不说且一旦出现也不太容易排查与定位问题。理解并熟练使用RAII惯用法不仅能让你的代码更加简洁和模块化,而且可以在开发阶段就能避免一部分资源泄漏、死锁问题。