RAII 惯用法

"RAII 惯用法"

Posted by Simon on July 1, 2020

“Better code, better life. ”

C++ RAII 惯用法

什么是RAII?我先卖个关子,且听我说一说我的故事。

刚学习服务器开发时,我接到这样一个任务,在 Windows 系统上写一个 C++ 程序,该程序的功能是实现一个简单的服务器程序,当客户端连接上来后,给客户端发一条 “HelloWorld” 消息后关闭连接。(不用保证客户端一定收到,后文中具体介绍)。

版本一 最初的写法

如果你熟悉基本的网络通信,你会觉得这很容易,这个程序就是服务器程序网络通信的基本流程,大致思路如下:

  1. 创建socket

  2. 绑定ip地址和端口号

  3. 在该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_guardboost::mutex::scoped_lock 的原理嘛,确实是这样,后面的章节我们会详细介绍操作系统和C++11各种锁。

小结

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