好用滴很!

前言

本文仅供学习探讨之用,如果侵犯了您的权益请联系我删除。

工具

  1. Visual Studio Code

需求分析

先放一段代码我们来看一下实际操作时可能会遇到的一些场景,并分析为什么我们会需要finally语法。

上代码

#include <WinSock2.h>
#include <Windows.h>

#include <iostream>

std::string lastErrorMessage(int code = 0)
{
    if (0 == code)
        code = ::WSAGetLastError();

    std::string result(1024, 0);
    auto size = ::FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM, nullptr, code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), result.data(), result.size(), nullptr);
    result.resize(size);

    return result;
};

void test01()
{
    WSADATA wsaData{};

    // initialization
    auto errorCode = ::WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (errorCode)
        throw std::runtime_error(lastErrorMessage(errorCode));

    // create socket fd
    auto fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (INVALID_SOCKET == fd)
    {
        auto message = lastErrorMessage();
        ::WSACleanup();
        throw std::runtime_error(std::move(message));
    }

    // configure server
    SOCKADDR_IN serverConfig{};
    serverConfig.sin_family = PF_INET;
    serverConfig.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    serverConfig.sin_port = htons(11451);

    // connecting to the server
    if (connect(fd, reinterpret_cast<SOCKADDR *>(&serverConfig), sizeof(SOCKADDR)))
    {
        auto message = lastErrorMessage();
        ::closesocket(fd);
        ::WSACleanup();
        throw std::runtime_error(std::move(message));
    }

    ::closesocket(fd);
    ::WSACleanup();
}

int main()
{
    std::cout << "[=] start test" << std::endl;
    try
    {
        test01();

        std::cout << "[+] test succeeded" << std::endl;
    }
    catch (const std::exception &e)
    {
        std::cout << "[-] " << e.what() << std::endl;
    }

    return 0;
}

代码中我们以常规socket连接服务器的操作流程来演示。

可以看到在创建socket通信句柄与连接服务器失败时我们都需要对已初始化/创建的资源进行释放,并且这一过程还伴随着诸多的冗余。例如WSACleanup函数就需要多次书写,而这还只是一个小例子,在一个庞大的系统中可能会有非常多这样的冗余。

我们对三次调用WSACleanup的地方进行分析,不难看出其都是在当前函数即将跳出作用域时才会执行。这就让我们联想到了RAII对吧。

而基于RAII实现的智能指针刚好就可以实现我们finally的这个想法,我们可以利用std::unique_ptr提供的自定义deleter来实现finally代码块。需要注意的是同为智能指针的std::shared_ptrstd::weak_ptr并不支持自定义的deleter,所以没法用来实现finally

简易实现

接下来我们先来用std::unique_ptr实现一个简单的finally,上代码

void test01()
{
    WSADATA wsaData{};
    SOCKET fd{};

    // construction the finally block
    auto finallyBlock = [&](uint8_t *data)
    {
        if (nullptr != data)
            delete data;

        if (0 != fd && INVALID_SOCKET != fd)
            ::closesocket(fd);

        ::WSACleanup();

        std::cout << "[=] finally block" << std::endl;
    };

    std::unique_ptr<uint8_t, decltype(finallyBlock)> defer(new uint8_t, finallyBlock);

    // initialization
    auto errorCode = ::WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (errorCode)
        throw std::runtime_error(lastErrorMessage(errorCode));

    // create socket fd
    fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (INVALID_SOCKET == fd)
        throw std::runtime_error(lastErrorMessage(errorCode));

    // configure server
    SOCKADDR_IN serverConfig{};
    serverConfig.sin_family = PF_INET;
    serverConfig.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    serverConfig.sin_port = htons(11451);

    // connecting to the server
    if (connect(fd, reinterpret_cast<SOCKADDR *>(&serverConfig), sizeof(SOCKADDR)))
    {
        std::cout << "[-] connection failed" << std::endl;
        throw std::runtime_error(lastErrorMessage());
    }
}

我们对test01函数进行一些修改,添加finally实现与一些信息输出。

代码执行后效果如下

代码执行结果

可以看到代码的执行顺序与我们预期的一样,顺序为

  1. 输出”connection failed”信息。
  2. throw连接服务器的异常信息。
  3. 局部变量超出函数作用域RAII开始执行,智能指针defer开始销毁所管理的裸指针uint8_t*
  4. 因为我们设置了自定义销毁函数(deleter),所以会进入finallyBlock这个lambda中。
  5. 执行释放资源的操作并输出信息”finally block”。
  6. 回到外部调用函数的地方(main)并处理异常,输出异常信息。

这样我们就实现了一个简易的finally

完整改进实现

上面说到的实现中虽然能用,但基本是不符合我们所设想的情况的。

首先他不是一个”新语法”,而是一种写法。

其次它的运行每次需要在堆中申请一个字节大小的内存并最终释放,如果编译器不能很好的优化的话这将造成很大的性能瓶颈。

newdelete的代价可不便宜啊,对于我们想单单执行一个lambda表达式来说有点过于浪费了。

那么既然大家都是基于RAII的话,何不自己造呢,老规矩先上代码后分析

#ifndef FINALLY_H //! FINALLY_H
#define FINALLY_H

#include <type_traits>
#include <utility>

template <typename callable_t>
class __FinallyCaller
{
public:
    __FinallyCaller(callable_t callable)
        : m_callable(std::move(callable))
    {
    }

    ~__FinallyCaller() noexcept
    {
        m_callable();
    }

private:
    callable_t m_callable;
};

struct __Helper
{
    template <typename callable_t>
    constexpr __FinallyCaller<callable_t> operator+(const callable_t &&callable)
    {
        static_assert(noexcept(callable), "The finally block cannot throw");

        return {callable};
    }
};

#define _MAKELAMBDA(x, y) x##y
#define MAKELAMBDA(x, y) _MAKELAMBDA(x, y)
#define finally auto &&MAKELAMBDA(lambda, __COUNTER__) = __Helper{} + [&]() noexcept

#endif //! FINALLY_H

首先我们来看__FinallyCaller这个模板类,它的构造函数接收一个callable的参数并存入成员变量中,并在析构函数中去调用这个callable

即当局部变量类型为__FinallyCaller而又开始执行RAII时,传进来的callable会被调用,可以是函数也可以是lambda

那么我们来看一下__FinallyCaller的使用方法

int main()
{
    __FinallyCaller defer = [&]
    {
        std::cout << "[=] finally block" << std::endl;
    };

    std::cout << "[=] main end" << std::endl;

    return 0;
}

/out:test_finally.exe
test_finally.obj
[=] main end
[=] finally block

可以看到流程与我们预想的一样。

但这种写法有一个问题,就是只支持c++17及以上的版本,因为在c++17才加入了类模板参数推导,而__Helper就是解决这个问题的方法之一。

来看一下用法

int main()
{
    auto defer = __Helper{} + [&]
    {
        std::cout << "[=] finally block" << std::endl;
    };

    std::cout << "[=] main end" << std::endl;

    return 0;
}

/out:test_finally.exe
test_finally.obj
[=] main end
[=] finally block

没有问题,在c++11 c++14中都能正常编译运行。

在这些用例中我们都还需要自己声明变量与构造,达不到我们finally的需求,那么再来看看剩余的3个宏定义的工作。

首先是#define _MAKELAMBDA(x, y) x##y,这个没啥好说的就是一个宏连接,将传入的xy进行字面上的连接,比如12就会变成12

然后是#define MAKELAMBDA(x, y) _MAKELAMBDA(x, y),辅助展开,这个具体有啥用下面说明。

最后是#define finally auto &&MAKELAMBDA(lambda, __COUNTER__) = __Helper{} + [&]() noexcept

这条宏的本质是声明一个变量,来简化展开看一下就是auto lambda0 = __Helper{} + [&],其中这个变量名lambda0后面跟的0是由宏__COUNTER__提供的一个全局计数器,使用MAKELAMBDA进行连接而成。

这就要说到第二个宏辅助展开的作用,这里的__COUNTER__如果不进行两层的MAKELAMBDA包装的话是无法成功展开的,就会变成lambda__COUNTER__这样的情况,这就与我们的预期相违,所以需要加一层。

那么有了这3条宏的加持我们基本上可以写出符合我们预期的代码了,我们直接对test01函数进行修改来看一下最终效果

void test01()
{
    WSADATA wsaData{};
    SOCKET fd{};

    // construction the finally block
    finally
    {
        if (0 != fd && INVALID_SOCKET != fd)
            ::closesocket(fd);

        ::WSACleanup();

        std::cout << "[=] finally block" << std::endl;
    };

    // initialization
    auto errorCode = ::WSAStartup(MAKEWORD(2, 2), &wsaData);
    if (errorCode)
        throw std::runtime_error(lastErrorMessage(errorCode));

    // create socket fd
    fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (INVALID_SOCKET == fd)
        throw std::runtime_error(lastErrorMessage(errorCode));

    // configure server
    SOCKADDR_IN serverConfig{};
    serverConfig.sin_family = PF_INET;
    serverConfig.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    serverConfig.sin_port = htons(11451);

    // connecting to the server
    if (connect(fd, reinterpret_cast<SOCKADDR *>(&serverConfig), sizeof(SOCKADDR)))
    {
        std::cout << "[-] connection failed" << std::endl;
        throw std::runtime_error(lastErrorMessage());
    }
}

/out:test_finally.exe
test_finally.obj
[=] start test
[-] connection failed
[=] finally block
[-] 由于目标计算机积极拒绝,无法连接。 

可以看到finally的书写一下就变得简单了,没有那么多繁杂的东西,只有一个finally关键字与{};,大括号中即是我们需要最终执行的代码。

这个方法相比简易方法实现的最大的好处就是跟堆内存没有关系,这意味着在编译器的优化下可以被完全展开,这对于吃性能的场景是非常友好的。

唯一的缺点就是最后还要跟个;,毕竟这是一条语句。如果能去掉这个;的话就更好了。

结语

那么其实就这样了,也没有什么特别的难点,就是好用!

那就这样了,有缘再见~