1. 概述

在前面两篇文章《面向对象编程(C++篇2)——构造》《面向对象编程(C++篇3)——析构》中,我们论述了C++面向对象中一个比较好的实现,在构造函数中申请动态内存,在析构函数中进行释放。通过这种方式,我们可以实现类对象如何内置数据类型对象一样,自动实现对象的生命周期管理。

其实这个设计早就被c++之父Bjarne Stroustrup提出,叫做RAII(Resource Acquisition Is Initialization),中文的意思就是资源获取即初始化。前文所述的动态内存只是资源的一种,比如说文件的打开与关闭、windows中句柄的获取与释放等等。RAII这个名字取得比较随意,但是这个技术可以说是C++的基石,决定了C++资源管理的方方面面。

2. 详论

2.1. 堆、栈、静态区

更为深入的讲,RAII其实利用的其实程序中栈的特性,实现了对资源的自动管理。我们知道,一般程序中会分成三个内存区域:

  1. 静态内存:用来保存局部static对象,类static数据成员以及任何定义在任何函数之外的变量。
  2. 栈内存:用来保存定义在函数内的非static对象。
  3. 堆内存:用来存储动态分配的对象,例如通过new、malloc等申请的内存对象。

对于分配在静态内存中的对象和栈内存中的对象,其生命周期由编译器自动创建和销毁。而对于堆内存,生存周期由程序显式控制,使用完毕后需要使用delete来释放。我们通过分配在栈中的类对象的RAII机制,来管理分配在堆空间中的内存:

class ImageEx
{
public:
    ImageEx()
    {
        cout << "Execute the constructor!" << endl;
        data = new unsigned char[10];
    }

    ~ImageEx()
    {
        Release();
        cout << "Execute the destructor!" << endl;
    }

private:
    unsigned char * data;

    void Release()
    {
        delete[] data;
        data = nullptr;
    }
};

int main()
{
    {
        ImageEx imageEx;
    }

    return 0;
}

很显然,根据程序栈内存的要求,一旦ImageEx对象离开作用域,就会自动调用析构函数,从而实现了对资源的自动管理。

2.2. 手动管理资源的弊端

远古C/C++需要程序员自己遵循"谁申请,谁释放"的原则细致地管理资源。但实际上,这么做并不是总是能避免内存泄漏的问题。一个很常见的例子如下(这是一个“对图像中的像素进行处理”的函数ImageProcess()):

int doSomething(int* p)
{
    return -1;
}

bool ImageProcess()
{
    int* data = new int[16];
    int error = doSomething(data);
    if (error)
    {
        delete data;
        data = nullptr;
        return false;
    }

    delete data;
    data = nullptr;
    return true;
}

为了避免内存泄漏,我们必须在这个函数中任何可能出错并返回之前的地方进行释放内存的操作。这样做无疑是低效的。而通过RAII技术改写如下:

int doSomething(ImageEx& imageEx)
{
    return -1;
}

bool ImageProcess()
{
    ImageEx imageEx;
    if (doSomething(imageEx))
    {
        return false;
    }

    return true;
}

这时我们可以完全不用关心动态内存资源释放的问题,因为类对象在超出作用域之后,就调用析构函数自动把申请的动态内存释放掉了。无论从代码量还是编程效率来说,都得到了巨大的提高。

2.3. 间接使用

可以确定地是,无论使用何种的释放内存资源的操作(delete、析构函数以及普通释放资源的函数),都会给程序员带来心智负担,最好不要手动进行释放内存资源的操作,如果能交给程序自动管理就好了。对此,现代C++给出地解决方案就是RAII。

在现代C++中,动态内存推荐使用智能指针类型(shared_ptr、unique_ptr、weak_ptr)来管理动态内存对象。智能指针采用了reference count(引用计数)的RAII,对其指向的内存资源做动态管理,当reference count为0时,就会自动释放其指向的内存对象。

而对于动态数组,现代C++更推荐使用stl容器尤其是std::vector容器。std::vector容器是一个模板类,也是基于RAII实现的,其申请的内存资源同样也会在超出作用域后自动析构。

因此,使用智能指针和stl容器,也就是间接的使用了RAII,是我们可以不用再关心释放资源的问题。

2.4. 自下而上的抽象

当然,实际的情况可能并不会那么好。在程序的底层可能仍然有一些资源需要管理,或者需要接入第三方的库(尤其是C库),他们依然是手动管理内存,而且可能我们用不了智能指针或者stl容器。但是我们仍然可以使用RAII,逐级向上抽象封装,例如:

class ImageEx
{
public:
    ImageEx()
    {
        cout << "Execute the constructor!" << endl;
        data = new unsigned char[10];
    }

    ~ImageEx()
    {
        Release();
        cout << "Execute the destructor!" << endl;
    }

private:
    unsigned char* data;

    void Release()
    {
        delete[] data;
        data = nullptr;
    }
};

class Texture
{
public:
    Texture() = default;

private:
    ImageEx imageEx;
};

int main()
{
    {
        Texture texture;
    }

    return 0;
}

可以认为ImageEx是底层类,需要进行动态内存管理而无法使用std::vector,那么我们对其采用RAII进行管理;Texture是高级类,内部有ImageEx数据成员。此时我们可以发现,Texture类已经无需再进行显示析构了,Texture在离开作用域时会自动销毁ImageEx数据成员,调用其析构函数。也就是说,Texture对象已经彻底无需关心内存资源释放的问题。

那么可以得出一个结论:对于底层无法使用智能指针或者stl容器自动管理资源的情况,最多只要一层的底层类采用RAII设计,那么其高层次的类就无需再进行显示析构管理了。这样一个完美无瑕的世界就出现了:程序员确实自己管理了资源,但无需任何代价,或者只付出了微小的代价(实在需要手动管理资源时采用RAII机制),使得这个管理是自动化的。程序员可以像有GC(垃圾回收)机制的编程语言那样,任意的申请资源而无需关心资源释放的问题。

3. 总结

无论对于哪一门编程语言来说,资源管理都是个很严肃的话题。对于资源管理,现代C++给出的答案就是RAII。通过该技术,减少了内存泄漏的可能行,以及手动管理资源的心智负担。同时自动化管理资源,也保障了性能需求。当然,这也是C++"零成本抽象(zero overhead abstraction)"的设计哲学的体现。

4. 参考

  1. C++中的RAII介绍
  2. RAII:如何编写没有内存泄漏的代码 with C++

上一篇
目录
下一篇

03-28 01:20