Skip to content

C++语言导学(3): RAII与智能指针

引言

C++提供开发者自定义类型的能力,而对于自定义类型的资源管理是一大难题。

对于一个自定义类型或者class来说,可以直接创建它的对象,也可以通过new为它创建指针。

对于对象和指针的资源管理,分别可以使用RAII原则和智能指针来处理。

RAII: 资源请求即初始化

在构造函数中请求资源,然后在析构函数中释放它们的技术被称为资源请求即初始化(RAII, Resource Acquisition Is Initialization)。

当我们创建(请求)一个对象时,通过构造函数它已经自动初始化好了资源;当对象离开作用域时,通过析构函数它自动释放了资源。

整个过程中对象的资源不再由调用者负责管理,而是由开发者在对象内部设计好。

如果对象内部实现有申请内存,却又不是在析构函数中释放资源,那么当这个对象离开作用域被销毁时,可能会因为资源没有得到完全释放而造成内存泄漏。

应该尽量避免在一般代码中直接使用new和delete,而是要隐藏在类型内部实现,这样可以确保资源得到及时的释放。

通过RAII原则可以确保资源总是能够通过析构函数得到释放。

比如:

class Vector{
public:
    Vector(int s): elem{new elem[s]}, sz{s}  // 分配资源
    {
        for(int i=0; i != s; i++)  // 初始化
           elem[i] = 0;
    }

   ~Vector(){ delete[] elem; }   // 析构函数释放资源

   double& operator[](int i);
   int size() const;
private:
   double *elem;
   int sz;
};

那么在使用的时候就可以当对象离开作用域时自动调用析构函数完成资源的释放:

void fct(int n)
{
    Vector v(n);
    {
        Vector v2(2*n);
    } // v2在此销毁
} // v在此销毁

构造函数与析构函数这套RAII机制是大多数C++通用资源管理技术的基础。

智能指针

new运算符是从一块名为自由存储或动态内存(dynamic memory)的区域中分配内存。

在自由存储中分配的对象独立于它创建时所处的作用域,会一直“存活”到使用delete运算符销毁它为止。

而使用“裸”new和delete很容易出差,因为会出于各种原因忘记使用delete或者程序逻辑到不了delete语句就已经返回了。

这个时候不是RAII原则不起作用,而是没有自动调用析构函数来实现RAII原则,可以通过智能指针来让它自动调用析构函数。

比如:

void user(int x)
{
    Shape *p = new Circle{Point{0,0}, 10};
    //...
    if(x < 0) throw Bad_x{};  // 存在泄漏危险
    if(x < 0) return;         // 存在泄漏危险
    //...
    delete p;
}

在x不是正数的时候,函数直接返回了,而指针p并没有执行到delete语句,造成“裸指针”问题。

如果说有一种方法,在指针p离开这个作用域的时候知道自动销毁指针p的话,那么就能够避免这个问题,这就是智能指针:

  • std::unique_ptr:唯一所有权,管理具备专属所有权的资源
  • std::shared_ptr:共享所有权,管理具备共享所有权的资源
  • std::weak_ptr: 对于类似std::shared_ptr但有可能空悬的指针

std::unique_ptr

class Smiley: public Circle {
    //...
private:
    vector<unique_ptr<Shape>> eyes;
    uniq_ptr<Shape> mouth;
};

unique_ptr<Shape> read_shape(istream& is)
{
    //....
    return unique_ptr<Shape>{new Circle{p,r}};
}

void user()
{
   vector<unique_ptr<Shape>> v;
   while(cin)
      v.push_back(read_shape(cin));
   //...
}

当对象被unique_ptr所拥有后,但不再需要这个对象时,比如对象的unique_ptr离开作用域,它会自动调用delete释放对象。

std::unique_ptr只能移动(std::move)不能拷贝:

void f1()
{
    auto p = make_unique<int>(2);
    auto q = p;            // 错误,不能拷贝
    auto q = std::move(p); // 之后p等于nullptr,后续p不能再使用了
}

std::shared_ptr

shared_ptr很多方面和unique_ptr非常相似,唯一的区别是shared_ptr的对象是使用拷贝操作,而unique_ptr使用移动语义。

比如:

void f(shared_ptr<fstream>);
void g(shared_ptr<fstream>);

void user(const string& name, ios_base::openmode mode)
{
    shared_ptr<fstream> fp{new fstream(name, mode)};

    if(!*fp) throw No_file{};

    f(fp);
    g(fp);
    //...
}

每次调用都拥有这个共享智能指针的一份拷贝,当最后一个拷贝销毁的时候就释放对应的资源了。

make_unique, make_shared

auto p1 = make_unique<S>(2,"Oz",7.62);
auto p2 = make_shared<S>(1,"Ankh Morpork",4.65);

通过标准库memory中提供的函数自动获取对应的智能指针,从而防止使用了new却又忘记交给智能指针来管理。

何时使用智能指针?

  • 当需要智能指针时,基本上std::uniq_ptr是首选
  • 当共享某个对象时,需要指向共享对象的指针或引用,选择shared_ptr
  • 当引用一个多态对象时,很难知道对象到底是什么类型,则需要一个指针,此时unique_ptr是必然选择
  • 共享的多态对象通常需要shared_ptr
  • 优先使用make_unique和make_shared,而不是自己来new

总结

  • 对于自定义类型,使用RAII原则管理资源的申请与释放,内部实现中构造函数和析构函数分别使用new和delete
  • 当需要使用指针时,优先使用智能指针,并且使用make_unique和make_shared创建智能指针,一般代码里面不需要出现new和delete