C++基础知识小讲堂(2)——RAII与三/五/零法则

某种程度上来讲,这是C++语言的精髓


上一个blog发出来以后有沙雕网友跟我说我虽然很努力地想讲明白那些内容,但是他还是没有看懂,另外还有人觉得一上来一片代码开幕雷击有点过于震撼。那这回就再来说一说C++基础知识。这也是C++里面最重要的部分之一。

RAII是什么奇怪的缩写

就比如说,我们想要一个int数组,可是编译时不知道大小(需要运行时在堆上开辟空间)。假设我们不知道有std::vector这种东西,那么用最基础的C++应该这样实现:

void array_test(const size_t size)
{
    int* arr = new int[size];
    /* Process the array */
    delete[] arr;
}

程序运行非常完美,申请的内存资源也都悉数返还给系统,皆大欢喜。

结果某一天你遇到了这种问题:

void process(int* arr) noexcept(false);

void array_test(const size_t size)
{
    int* arr = new int[size];
    process(arr);
    delete[] arr;
}

这里处理数组用的process函数可能会抛出,导致函数异常退出,从而跳过了delete[]的执行,造成内存泄漏。当然这里有一个很简单的修补方法,那就是用一个try-catch结构捕获异常之后释放内存。

void array_test(const size_t size)
{
    int* arr = new int[size];
    try
    {
        process(arr);
    }
    catch (...)
    {
        delete[] arr;
        return;
    }
    delete[] arr;
}

当然如果同时有好几个资源需要释放的时候事情会更糟,为了节省下来代码行数甚至可能需要用上让代码逻辑更凌乱的goto语句。

void mess()
{
    FILE* fptr = fopen(file, "r");
    size_t size;
    if (fscanf(fptr, "%zd", &size) && size == 0)
    {
        fclose(fptr);
        return;
    }
    int* arr = new int[size];
    try
    {
        process(arr);
    }
    catch (...)
    {
        delete[] arr;
        fclose(fptr);
        return;
    }
    delete[] arr;
    fclose(fptr);
}

这太野蛮了,有没有什么好的办法能够减轻手动管理这些资源的负担呢?当然有,而这就引出了本次的主题RAII。RAII的全称是资源获取即初始化 (Resource Acquisition Is Initialization),这个名字乍一看上去让人摸不到头脑,其实它应该换一个更好的名字,叫作用域界定的资源管理 (Scope-Bound Resource Management,SBRM)。我们可以利用C++的对象生存期概念以及析构函数这两个工具来实现我们的想法。

比如说上面的动态数组例子,我们可以利用RAII将资源获取也就是申请内存的操作放到对象的初始化中,相应地,把资源回收也就是释放内存的操作放到析构函数中。

struct dyn_array
{
    size_t size = 0;
    int* ptr = nullptr;

    dyn_array() = default; // Default(empty) initialization
    explicit dyn_array(const size_t size) :size(size), ptr(new int[size]) {} // Initialize with a given size
    ~dyn_array() noexcept { delete[] ptr; } // Free the memory
};

再回到前面的例子中,用这样的抽象就可以简化资源管理的操作。因为C++对象有明确的生存期,这个函数无论是正常退出还是因抛出异常而退出,对象darr的作用域(生存期)都将结束,导致其析构函数被调用,资源成功释放,省去了各种手动判断释放资源的步骤。正因此,有人说C++中最强大的字符其实是这个右大括号},因为在这个地方编译器为我们做了很多重复性的,人工来做又易错的工作(即资源释放)。

void array_test(const size_t size)
{
    dyn_array darr(size);
    process(darr.ptr);
} // Memory freed no matter how this function exits

当然,用RAII不仅能管理内存。用同样的方法我们也可以管理其他的需要释放的资源,比如文件或者互斥锁等。这里有兴趣的读者可以写一个类似的小类用来管理C语言的裸文件指针FILE*

三法则和零法则

C++语言,不像Java,其对象具有值语义(value semantic)。在Java以及类似的语言中,直接用等号将一个数组赋值给另一个数组的结果是两个数组都引用了同一片内存区域,这样的对象具有引用语义(reference semantic);而给C++的对象赋值时,其结果是生成了一个原对象的复制,两个对象并不指向同一片区域。

为什么突然讲这个呢?请看前面的数组例子。这里我们还并没有对dyn_array的复制给出实现,所以编译器自己帮我们生成了一个,也就是把每个子对象(这个例子中,就是那个ptr指针)都复制一遍。如果我们将darr复制一份的话:

void array_test(const size_t size)
{
    dyn_array darr(size); // Allocated memory (darr.ptr)
    {
        // Copy initialization, copy.ptr = darr.ptr
        dyn_array copy = darr;
        process(copy.ptr);
    } // copy.ptr is freed
    process(darr.ptr); // Oops, now darr.ptr points to garbage
} // Double oops, the pointer is doubly freed...

直接对指针的复制造成了各种问题。复制品copy跟原数组darr指向了同一片内存,当复制品的生存期结束时,复制品将那个数组销毁了,这时候原数组保留的指针就指向垃圾内存,对原数组进行处理的操作就成了未定义行为。不仅如此,在darr的生存期结束时,它又会释放它保留的指针,这导致了内存的二次释放,同样是未定义行为。

所以,这种情况下依赖编译器自动生成的复制构造函数并不靠谱,我们需要自己实现一个。不能直接复制指针,而需要重新申请一片内存,并将原来数组里面存的数据都复制到新申请的内存中。类似T::T(const T&)这样的特殊构造函数即是复制构造函数。

struct dyn_array
{
    /* ... */
    dyn_array(const dyn_array& other) :size(other.size)
    {
        ptr = new int[other.size];
        std::copy_n(other.ptr, size, ptr);
    }
};

这样,我们之前的代码就不会出现问题了,但是赋值跟构造是两件事情,如果上面的代码是赋值而不是构造的话同样的问题还会出现:

void array_test(const size_t size)
{
    dyn_array darr(size);
    {
        dyn_array copy;
        copy = darr; // Copies the pointer
        process(copy.ptr);
    }
    process(darr.ptr);
}

同样,这里我们需要手动实现复制赋值运算符,类似T& T::operator=(const T&)的特殊函数。

struct dyn_array
{
    dyn_array& operator=(const dyn_array& other)
    {
        if (&other != this)
        {
            delete[] ptr;
            size = other.size;
            ptr = new int[other.size];
            std::copy_n(other.ptr, size, ptr);
        }
        return *this;
    }
};

这里一定要注意对“自赋值”做出特殊处理,不然就会出现各种问题。可以思考一下如果把上面代码的if去掉会有什么后果。

以上就是三法则讲的事情。如果你手动实现了析构函数,那么很有可能编译器自动生成的复制构造/复制赋值运算符并不能达到你想要的效果,所以你需要三个函数全都实现(析构,复制构造,复制赋值)。

与其对应的就是零法则,如果你并不需要自己实现析构函数,那么就一个特殊函数都不要实现,让编译器帮你做完所有事情。

五法则

这一部分与C++11的移动语义有关。移动语义和右值引用是现代C++很重要同时又很难搞懂的一个特性,这里仅仅介绍其最初级的应用。

比如说,看下面的这段代码:

void array_test(const size_t size)
{
    dyn_array darr;
    darr = dyn_array(size);
}

函数的第二行构造了一个dyn_array临时量,之后把这个临时量赋值给darr。如果仅有前面的三法则定义的话,这会调用复制赋值运算符。可是,既然我们知道赋的值是一个临时量,我们为什么非要重新开辟一片内存空间再把临时数组里面的数据复制一遍呢?如果能有一种办法把临时量里面存的指针“偷过来”就好了。

当然有这种方法,这里就要用到移动赋值运算符了。下面的dyn_array&&即是一个右值引用,它跟普通的左值引用dyn_array&类似,是某个值的“别名”。不一样的在于,左值引用绑定到“左值”,也就是有名字的值上;右值引用绑定到“右值”,也就是临时值上。(注:这里的定义非常不准确,但是为简单考虑暂时可以这样理解。)

struct dyn_array
{
    /* ... */
    dyn_array& operator=(dyn_array&& other) noexcept
    {
        delete[] ptr;
        size = other.size;
        // Following line is equivalent to
        //     ptr = other.ptr;
        //     other.ptr = nullptr;
        ptr = std::exchange(other.ptr, nullptr);
        return *this;
    }
};

在复制赋值运算符中我们对“自赋值”的情况做了特殊的处理,而对于移动赋值运算符,我们并不需要进行这种特殊处理。因为并没有办法把一个临时量赋值给自己,一个临时量是没法同时在等号两边出现的。

有移动赋值运算符,自然也就有对应的移动构造函数,实现方法与移动赋值运算符类似。

struct dyn_array
{
    /* ... */
    dyn_array(dyn_array&& other) noexcept :
        size(other.size),
        ptr(std::exchange(other.ptr, nullptr)) {}
};

三法则的三个特殊函数加上这里的移动构造函数和移动赋值运算符,就是五法则所说的五个函数。不实现五法则多出的两个函数通常不会产生逻辑问题,但代价就是对临时值优化的缺失。