1.1 C++ RAII惯用法

什么是RAII?容我先卖个关子,给大家讲个故事。

# 1.1.1 版本一 最初的写法

我在刚学习服务器开发的时候公司安排给新人一个练习:在Windows系统上写一个C++程序,该程序的功能是实现一个简单的服务,当客户端连接上来时,给客户端发一条 “HelloWorld” 消息后关闭连接,不用保证客户端一定能收到。

如果你熟悉基础网络编程知识,你会觉得这很容易嘛,这个程序就是TCP网络通信的基本流程,实现流程如下:

  1. 创建socket;

  2. 绑定ip地址和端口号;

  3. 在该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;
}
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
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);
1

WSACleanup();
1

这样的场景我们在实际开发中经常遇到,例如下面这段伪码:

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;
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

这种情形我们可以归纳成:先分配资源,然后进行相关操作,在任一中间步骤中操作出错时对资源进行回收,如果中间步骤没有错误,我们在资源使用完毕之后也要回收相应的资源。上述伪代码片段中释放资源的重要性不言而喻,因为分配了堆内存,否则会造成内存泄露。但是实际在编写这样的代码时太容易出错了,我们必须时刻保持警惕,在任何一个出错步骤中都要记得回收资源,因此也造成了大量重复逻辑。那么有没有方法解决这类问题呢?有,使用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;
}
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
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;
}
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
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;
1
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;
}
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
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;
};
1
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;
1
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
    释放锁;
}
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

这是一段非常常见的逻辑,为了避免死锁,我们必须在每个可能退出的分支上释放锁。随着逻辑写得越来越复杂,我们忘记在某个退出分支加上释放锁的代码的可能性也越来越大。而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
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44

使用了RAII惯用法之后,我们再也不必在每个函数出口处加上释放锁的代码了,因为锁会在函数调用结束后自动释放。

对于上面的代码,有经验的读者可能一眼就看出来了:这不就是C++11 std::lock_guardboost::mutex::scoped_lock的原理嘛,确实是这样,后面的章节我们会详细介绍操作系统和C++11各种锁的用法。

# 1.1.5 小结

资源泄露和死锁等问题具有非常强的隐蔽性,如果将来在生产环境出现,难以复现不说且一旦出现也不太容易排查与定位问题。理解并熟练使用RAII惯用法不仅能让你的代码更加简洁和模块化,而且可以在开发阶段就能避免一部分资源泄漏、死锁问题。

上次更新: 2025/05/19, 16:52:22

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>