返回

[Effective C++ 笔记]条款 13. 使用对象管理资源

简介

  • 为了防止资源泄漏,使用 RAII 对象管理资源,以保证他们在构造函数中获得资源并在析构函数释放资源
  • c++98 中两个常用的 RAII 对象为 std::auto_ptrstd::tr1::shared_ptr
  • c++11 之后尽量使用 std::unique_ptr, std::shared_ptrstd::weak_ptr

引子

考虑以下例子:我们需要建立一个对投资行为(例如股票、债券等等)建模的程序库。其中各种投资类型继承自以下 class Investment:

class Investment {  ...   };    //  "投资类型" 继承体系中的 root class

另外假设我们在使用时是通过一个工厂函数来获取我们特定的 Investment 对象:

Investment* createInvestment();     // 返回一个指针指向 Invenstment 继承体系中的动态分配对象,由用户进行管理

用户有责任在使用完动态分配的 Investment 对象后进行删除,如下列函数所示:

void f() {
    Investment* pInv = createInvestment();  // 调用 factory 函数

    //  使用该 Investment 对象

    delete pInv;                            // 使用完后释放 pInv 所指对象
}

上述函数看上去没什么问题,但是有两种情况下可能会导致 delete 语句被略过:

  • delete pInv 之前,有 return 语句被提前执行。这种情况通常发生在 pInv 的创建和释放发生在一个循环中,而该循环由于 continue 或者 goto 提前结束导致 delete 没有执行
  • 在使用 pInv 中有异常被抛出。

无论是哪种情况,总之说明了 f() 中的 delete 是有可能被略过而导致程序发生内存泄漏的情况的。因此,有必要将动态分配的资源放入对象中,以此利用该对象的析构函数来对资源进行释放。

使用对象管理资源

使用对象管理资源主要包括以下两种关键思想:

  • 获得资源后立刻放入放进管理对象中。这种观念也被称为:资源获取时即初始化时刻(Resource Acquisition Is Initialization, RAII)。这一点在下面使用智能指针的方法体现在我们在获取资源时便利用该资源进行智能指针的初始化;
  • 管理对象运用析构函数确保资源被释放。这一点在智能指针也能获得保证。当然在析构函数中也有可能会抛出异常,但此类相关问题我们已经在条款 8 中讨论过并提出对应的解决方案了,这里不再赘述。

下面列举三种采用智能指针管理资源的方法。前两种是原书中包含的;而由于原书介绍的方法是基于 c++98 的,所以很多特性当时还没有。第三种则是简单提及了一下在 c++11 之后使用更为广泛的三种智能指针。

使用 std::auto_ptr 管理对象

在 c++98 中,我们可以用 <memory> 中的 auto_ptr 来管理资源。 auto_ptr 有两个特性:

  • 对象离开作用域时,会调用其内部析构函数对其所管理的资源内存进行释放(即调用 delete
  • 由于对每一个 auto_ptr 都有可能释放内存,因此如果不对资源所属权加以管理,很有可能会出现一个资源被释放两次的情况。所以 auto_ptr 保证在每一时刻只会有一个对象管理资源。如果我们用 copy 构造函数或者 copy assignment 对对象进行拷贝,则资源所属管理权会移交至新的 auto_ptr 对象,原有对象则变成 null

auto_ptr 的特性参考以下例子,(完整代码见 ex1):

首先我们先在 Investment 的构造和析构函数中添加输出方便我们观察其调用次数:

class Investment {
public:
    Investment() {
        std::cout << "Investment Constructed." << std::endl;
    }
    ~Investment() {
        std::cout << "Investment Destructed." << std::endl;
    }

然后利用以下方式进行测试:

{
    std::auto_ptr<Investment> pInv(createInvestment());
    std::auto_ptr<Investment> pInv2;
    std::cout << "pInv's address " << (pInv.get()) << std::endl;
    std::cout << "pInv2's address: " << (pInv2.get()) << std::endl;
    pInv2 = pInv;
    std::cout << "pInv's address " << (pInv.get()) << std::endl;
    std::cout << "pInv2's address: " << (pInv2.get()) << std::endl;
}

在这段局部函数中,我们首先利用 factory 函数创建了一个动态分配的 Investment 对象,然后以此初始化 pInv。接下来将其拷贝给 pInv2。编译输出结果如下:

Investment Constructed.
pInv's address 0x56050276feb0
pInv2's address: 0
pInv's address 0
pInv2's address: 0x56050276feb0
Investment Destructed.

可以发现:1. 将 pInv 赋值给 pInv2 之后,pInv 就丧失了对 Investment 对象的管理权,表现为两个指针指向的地址互换了;2. 最后资源的构造次数和析构次数都为 1,符合要求。

使用 tr1::shared_ptr 管理对象

使用 auto_ptr 虽然可以保证资源安全释放,但是也强制规定了我们只能有一个指针指向该资源,有时候会比较麻烦。这个时候我们还可以采用引用计数型智能指针reference-counting smart pointer, RCSP)。这种指针内部持续追踪当前有多少个指针指向同一个资源,直到发现无人指向它时才会调用 delete 对该资源进行释放。例如下面例子中使用的 tr1::shared_ptr:(完整例子见 ex2

    {
        std::tr1::shared_ptr<Investment> pInv(createInvestment());
        {
            std::tr1::shared_ptr<Investment> pInv2 = pInv;
            std::cout << "pInv's address " << (pInv.get()) << std::endl;
            std::cout << "pInv2's address: " << (pInv2.get()) << std::endl;
        }
        std::cout << "pInv's address " << (pInv.get()) << std::endl;
    }

编译运行输出如下所示:

Investment Constructed.
pInv's address 0x55857aa2eeb0
pInv2's address: 0x55857aa2eeb0
pInv's address 0x55857aa2eeb0
Investment Destructed.

可以发现,在利用 pInv 创建了 pInv2 之后,两个指针同时指向一个资源。而当 pInv2 脱离作用域后,由于引用计数不为 0 (pInv 还指向该资源)所以不会释放。知道 pInv 也脱离作用域后才释放该资源。从头到尾资源的构造和析构次数均为 1,符合我们的需求。

但是注意,由于 auto_ptrtr1::shared_ptr 内部只是在析构函数中做 delete 操作而非 delete[] 操作。所以这两种指针并不适用于动态分配得到的 std::vectorstd::string 等资源。

在 c++11 之后使用 std::unique_ptr, std::shared_ptrstd::weak_ptr 管理资源

上面提到的两种方法各自有各自的缺点:

  • auto_ptr 中所有权可以被转移,如例子中的 pInv 在所有权被转移之后变成了空指针,而我们不一定会注意到这一点。这是十分危险的,因为 pInv 变成了一个野指针 (dangling pointer)
  • tr1::shared_ptr 虽然用引用计数功能,但是假如两个没有被使用的对象互相引用,这种环状引用将无法被打破,因此资源不会被回收,而归根结底这是因为我们没有强调资源所有权的概念。

在 c++11 之后提出的三个指针很好地解决了这两个问题:

  • unique_ptr: 跟 auto_ptr 类似,但是不允许所有权被转移。即下列操作是不被允许的:
std::unique_ptr<Investment> pInv1(createInvestment());
std::unique_ptr<INvestment> pInv2 = pInv2;      // compile fail!
  • shared_ptrweak_ptr:也是利用引用计数来判断是否该释放资源,而 weak_ptr 虽然也可以指向资源,但是只拥有资源的使用权而非所有权,因此不占引用计数,因此可以解决上述提到的环状引用的问题。

结论

  • 为了防止资源泄漏,使用 RAII 对象管理资源,以保证他们在构造函数中获得资源并在析构函数释放资源
  • c++98 中两个常用的 RAII 对象为 std::auto_ptrstd::tr1::shared_ptr
  • c++11 之后尽量使用 std::unique_ptr, std::shared_ptrstd::weak_ptr

其他

Built with Hugo
Theme Stack designed by Jimmy