C++ 智能指针:unique_ptr、shared_ptr 和 weak_ptr

618 阅读5分钟

本文译改自 Smart developers use smart pointers 系列文章的前两篇。相较于原文已经进行了大量的内容删减、补充和替换,本文不再标记为【翻译】。

建议对本文感到困惑,以及对该主题内容非常感兴趣的读者阅读原文

滥用指针、糟糕的内存管理通常会把程序变成一团乱麻,全面破坏程序的可读性、可维护性和安全性。小心翼翼地手动管理指针也会明显破坏程序的抽象层次,成为业务逻辑代码中的噪音。

智能指针能在一定程度上解决上述的问题,恰当地使用智能指针能让程序更加简洁易读、安全和高效。

RAII 与智能指针

RAII 是一种在 C++ 用类来包装资源的方法。此处用一个例子来说明如何用 RAII 的方法来包装裸指针:

template <typename T>
class SmartPointer
{
public:
    explicit SmartPointer(T* p): p_(p) {}
    ~SmartPointer() { delete p_; }
    
private:
    T* p_;
}

事实上,C++ 提供的智能指针的原理差不多就是上面这样——将资源包在对象里,并在对象的析构函数中释放资源,以此确保资源不会因为被遗忘而丢失。

智能指针让我们可以在栈上通过指针使用分配在堆上的资源,而且确保能够在出当前作用域时,堆上的资源被自动释放。

智能指针有着指针的行为,当智能指针本身被销毁时,智能指针会自动销毁其所指向的堆上对象。

unique_ptr 、 shared_ptr 和 weak_ptr

std::unique_ptr

unique_ptr 代表着这个指针是一块内存资源的唯一拥有者。unique_ptr 维护一个指针,并在其析构函数中释放该指针。

unique_ptr 的一大优点能够在语义上表达一定的意义和意图。就比如下面这个代码:

std::unique_ptr<House> buildAHouse();

返回值的类型是一个 std::unique_ptr ,这其实就是在告诉你该函数将会给你一个指向房子的指针,并且你就是这个房子的拥有者。除了给你返回这个 unique_ptr 的函数之外,没有任何人能删除该指针。

因为只有你拥有这个指针,所以你也可以放心地修改指针所指向的对象。std::unique_ptr 是工厂函数的首选指针返回类型。

另一方面,std::unique_ptr 也可以用于参数传递,能够表达相同的所有权转移:

class House {
public:
    House(std::unique_ptr<PileOfWood> wood);
    ...
}

在这种情况下,房子会获得 PileOfWood 的所有权。

需要注意的是,即使你收到了一个 unique_ptr ,其实也并不能保证其他人无法访问该指针。如果另一段代码保留了你的 unique_ptr 中包装的指针,并且用那个指针修改其指向的对象,那么你再通过 unique_ptr 修改该对象就会影响到这个“另一段”代码的执行。但由于你是所有者,你就可以安全地修改指针所指向的对象,其他的所有代码设计都应该提前考虑到这个问题。

如果的确还是不希望传回的指针被修改,可以通过加个 const 的方式来实现:

std::unique_ptr<const House> buildAHouse();
// 出于某种原因,我不希望你修改我传给你的房子

为确保只能有一个 unique_ptr 拥有内存资源,std::unique_ptr 是不允许拷贝的。可以通过移动来将所有权从一个 unique_ptr 转移给另一个 unique_ptr ,这也是在将 unique_ptr 作为参数传递和作为返回值返回时用到的方法。

在函数中,移动可以通过以值形式返回 std::unique_ptr 的方式来实现,也可以像下面这样显式实现:

std::unique_ptr<int> p1 = std::make_unique(42);
std::unique_ptr<int> p2 = move(p1);  // 现在 p2 拥有资源,p1 一无所有

如果尝试使用一个已经被 move 过的指针(比如上面代码中的 p1 ),将在运行时出现段错误

std::shared_ptr

shared_ptr 允许同时有多个 shared_ptr 指向同一块内存资源。其内部会维护一个计数器,该计数器计算有多少个 shared_ptr 正指向同一个资源,当该计数器归零时释放对应的资源。

即: shared_ptr 是允许拷贝的,而且其能够通过内部维护的计数器确保资源会被且仅会被释放一次。

这些特性让 shared_ptr 看起来是解决内存管理的终极方案,其让我们可以随意地把指针传来传去——然而事实并非如此。出于下面几种原因,该指针并非首选的智能指针:

  • 让多个所有者同时维护同一个资源,会显著增加系统的复杂度。尽管 std::unique_ptr 并不阻止读取和修改资源,但能确保该指针是这块资源仅有的所有者,我们可以预期这个指针会对资源负责(至少在一定程度上是这样的);
  • 有多个同时能够获取资源的维护者,会加大保证线程安全的难度;
  • 保留引用计数会消耗更多的内存和计算时间

std::weak_ptr

weak_ptr 可以和 shared_ptr 一起维护指向同一个共享对象的引用,但 weak_ptr 并不会增加这个对象的引用计数。这也就意味着当最后一个引用该对象的 std::shared_ptr 被销毁时,无论是否还有 weak_ptr 指向该对象,该对象都会被销毁掉。

正因如此,weak_ptr 需要自己检查其所指向的对象是否存在。想要对对象的存在性进行检查,需要用 weak_ptr 构造 shared_ptr 来实现:

void useMyWeakPointer(std::weak_ptr<int> wp)
{
    if(std::shared_ptr<int> sp = wp.lock()) {
        // 资源仍然存在并可用
    } else {
        // 资源已不可用
    }
}

weak_ptr 的一大用途是打破 shared_ptr 循环引用

#include <memory>
#include <iostream>struct B;
struct A {
  std::shared_ptr<B> b;  
  ~A() { std::cout << "~A()\n"; }
};
​
struct B {
  std::shared_ptr<A> a;
  ~B() { std::cout << "~B()\n"; }  
};
​
void useAnB() {
  std::shared_ptr<A> a = std::make_shared<A>();
  std::shared_ptr<B> b = std::make_shared<B>();
  a->b = b;
  b->a = a;
}
​
int main() {
   useAnB();
   std::cout << "Finished using A and B\n";
}

运行上面的代码,输出如下:

Finished using A and B

输出中没有看到 ~A()~B() ,这说明直到函数返回

这段代码在堆上构建了两个对象,分别用 shared_ptr a 和 b 指向他们。而这两个对象内部又都有一个 shared_ptr 指向对方。虽然 shared_ptr a 、b 在栈上,但它们各自自带的 shared_ptr 都在堆上,这就导致代码执行即使离开了当前作用域, a 、 b 的引用计数器也仍然不为 0 。就这样,我们跟丢了 a 和 b ,预期中应该起作用的智能指针 shared_ptr 在此 “失了智” 。

解决方案就是把 A 、 B 其中一个定义中的 shared_ptr 换为 weak_ptr ,改成下面这样:

struct A {
  std::weak_ptr<B> b;  
  ~A() { std::cout << "~A()\n"; }
};

这样,预期的结果就能够正确发生了