本文主要记录下 C++ 智能指针的实现原理和相关分析,以及使用的相关细节;本文大致依据 Scott Meyers 的 Effective Modern C++ 1为参考。

原始指针

如我在之前的一篇文章" 地址空间:由 C 至 C++ " 2中所提及的,使用原始指针导致的问题数不胜数。原始指针在实际编程中带来的缺点主要如下:

  1. 从原始指针的声明中看不出其指涉的是单个对象还是一个数组;

    该问题最常出现的场景是,某个函数的实参是一个数组类型,当形参则会退化成一个数组指针类型。

  2. 原始指针的所有权问题,使用完以后,无法确定是否该对指针只想的对象进行析构;

  3. 难以确定如何去对指针指向的对象进行析构;

    例如,是要直接使用 delete 关键字进行析构,还是需要调用某个具体的析构器中?

  4. 析构的时候,无法快速确定该使用 delete 关键字还是 delete[] 关键字,用错的话则会导致未定义行为

  5. 析构的时候,需要保证在所有代码路径都只执行一次;

    如果某个分支少执行了一次,则会导致资源泄露;如果某个分支执行了两次,则会导致未定义行为。

  6. 难以检测指针是否空悬。

    例如,两个指针 p1,p2 分别指向同一个对象的时候,假如通过指针 p1 将对象析构了,但是 p2 就成了空悬指针,再次使用 p2 的时候指向的对象已经不存在了。

C++ 11 中,移动语义的引入,结合 RAII ,采用代理模式的思想,设计了三个智能指针,分别为 std::unique_ptr,std::shared_ptr,std::weak_ptr,用于管理动态分配对象的生命周期,通过这三个智能指针能使动态分配的对象能在适当的时机以恰当的方式进行析构,防止资源泄露。

std::unique_ptr

std::unique_ptr 是具有专属所有权语义(exclusive ownership semantics)的智能指针,即一个 move-only 类型。默认情况下,std::unique_ptr 和原始指针的大小都一样,大多数的操作,其和原始指针底层的指令也相同,其运行速度也基本和原始指针相差不大(能产生的开销来自于其对内部指针的操作重载在不能内联情况下所产生的函数调用开销,特指对成员运算符,解引用运算等操作)。

因此,std::unique_ptr 大多用于以下场景:

  1. 给动态生存期对象提供异常安全;

    C++ 98 中,为写出异常安全的代码,需要采用 try catch 的方式来保证。

    C++ 11 的 std::unique_ptr 依托 RAII 来保证异常发生时,资源能正确释放。

  2. 能通过函数调用传递动态生存期对象的独占权;

  3. 从函数中获取动态生存期对象的独占权;

  4. 可移动的特性,使其能存储在容器中,突破了 std::auto_ptr 的缺陷。

实现细节

大致实现细节如下代码所示:

namespace std {
    template <typename T, typename D = default_delete<T>>
    class unique_ptr {
    public:
        explicit unique_ptr(T* p) noexcept;
        ~unique_ptr() noexcept;    
        T& operator*() const;
        T* operator->() const noexcept;
        unique_ptr(const unique_ptr &) = delete;
        unique_ptr& operator=(const unique_ptr &) = delete;
        
        unique_ptr(unique_ptr &&) noexcept;
        unique_ptr& operator=(unique_ptr &&) noexcept;

    private:
        T* _ptr;
    };
}

其主要实现细节为:

  • 存储了模板类型 T 的原始指针;
  • 重载了 operator* 和 operator-> 成员函数,是其能像原始指针的行为一样,能进行解引用和成员选择运算,这采用了代理模式;
  • 删除了拷贝构造函数和拷贝赋值函数;
  • 提供了移动构造函数和移动复制函数。

自定义析构器

默认情况下,非空的 std::unique_ptr 对资源的析构是对其内部保存的原始指针,调用 delete 操作来完成的。但是,我们亦能通过指定其模板参数类型来实现自定义析构器,从而完成对资源的销毁。

在使用默认析构器的情况下,std::unique_ptr 和原始指针的尺寸相同(因为此时 std::unique_ptr 类模板中仅存在一个指针成员 )。自定义析构器出现以后,会需要空间来存储析构器的信息:

  • 如果析构器为函数指针,那么 std::unique_ptr 的尺寸会增加一到两个字;
  • 如果析构器是函数对象,带来的尺寸变化取决于函数对象中存储的状态(无捕获的 lambda 不会导致额外的存储)

因此,在考虑使用自定义析构器时,一定需要考量自定义析构器给 std::unique_ptr 带来的空间开销。

参考3 中有一个针对自定义析构器的内存占用大小的测试,可以检验不同类型的析构器带来的空间开销的不同。

工厂方法与智能指针

通常,std::unique_ptr 常常和工厂方法一起使用,用于构造一个类型为基类对象的智能指针。

例如,类型 D 派生于类型 B,则 std::unique_ptr<D> 能隐式转换 std::unique_ptr<B>,但是其默认的析构器还是 B 的 operator delete,如果直接使用的话会导致未定义行为。所以,在使用工厂方法时候,类型 B 的析构函数一定需要是虚构函数。具体样例如下代码:

class Base {
public:    
    virtual ~Base() {};
};

class Derived1 : public Base {
};

class Derived2 : public Base {
};

auto cerate(int type) {
    unique_ptr<Base> p(nullptr);
    switch(type) {
        case 1 :
            p.reset(new Derived1);
            break;
        case 2:
            p.reset(new Derived2);
            break;
        default:
            p.reset(new Base);
    }
    return p;
}

此外,std::unique_ptr 能够转换为 std::shared_ptr,这样使得专属所有权语义能够转换为共享所有权语义,使得工厂方法能直接返回较高效 std::unique_ptr,且能转换为其他类型的智能指针。

std::shared_ptr

std::shared_ptr 是具有共享语义所有权的一个智能指针,一个动态分配的对象可以在多个 std::shared_ptr 之间共享。

实现细节

  1. std::shared_ptr 内部包含了两个指针,一个指向动态分配的对象,一个指向动态分配的控制块;
  2. 动态分配的控制块包括了引用计数,弱引用计数,自定义的析构器等等数据;

这样一来,std::shared_ptr 能够通过访问引用计数来确定自身是否是最后一个指向该对象的,如果是,则析构该对象,否则将引用计数减一。

引用计数的引入带来了如下性能影响:

  1. std::shared_ptr 的尺寸是原始指针的两倍,因为其内部保存了一个指向对象的原始指针,以及一个指向动态分配的内存控制块的弱指针;
  2. 用于引用计数的内存控制块必须动态分配,相比于使用自动变量,动态分配的速度更慢;
  3. 为保证线程安全,引用计数的增减必须是原子操作,带来额外的开销。

自定义析构器

对于 std::unique_ptr 来说,其自定义的析构器是其类模板的参数;

对于 std::shared_ptr 来说,其自定义的析构器是动态分配的内控控制块的一部分。

例如,如下代码中:

auto del1 = [](A* p) {...};
auto del2 = [](A* p) {...};

std::shared_ptr<A> p1(new A, del1);
std::shared_ptr<A> p2(new A, del2);

这样一来,由于 p1 和 p2 的类型相同,这两个智能指针能放在该类型的容器中,也能进行相互复制等操作。由于该实现,std::shared_ptr 不像 std::unique_ptr 为 array new 形式提供了偏特化版本,其能通过容器来实现该操作。

使用陷阱

不能使用同一个原始指针来构造多个 std::shared_ptr,如下情况,

auto p = new A();

std::shared_ptr<A> p1(p);
std::shared_ptr<A> p2(p);

如此行为会导致每次构造 std::shared_ptr 的时候,都会动态创建一个内存控制块,导致存在多个内存控制块,而在销毁对象的时候,会导致对象被销毁多次,从而引发未定义行为。

正确的做法是如下:

  • 直接使用 std::make_shared 来进行构造,但是这种情况无法自定义析构器;

  • 如果想要使用原始指针来构造 std::shared_ptr,那么一旦使用了该原始指针,程序下文就不该再使用该原始指针;

  • 更好的一种做法是,直接将 new operator 的结果传递 std::shared_ptr 的构造函数,同时也能传递自定义析构器。

    例如,std::shared_ptr<A> p1(new A, del);

    此外,不同的初始化方法带来的开销也不一样。使用 std::make_shared 来创建 std::shared_ptr 会更高效,因为 std::make_shared 仅使用一次动态内存分配,即分配一块连续的堆空间来容纳对象和控制块;相反,采用其余两种方法涉及到了两次动态内存分配。

但是,对于 this 指针的情况,就没有那么简单了,一不小心就可能使用 this 指针来构造多个 std::shared_ptr 导致创建了多个内存控制块。具体例子如下,

std::vector<std::shared_ptr<A>> vec;

class A {
public:
	void process(std::vector<std::shared_ptr<A>>& vec);
};

void A::process(std::vector<std::shared_ptr<A>>& vec) {
	vec.emplace_back(this);
}

上述情况中,每次调用 process 函数会创建一个控制块,在析构的时候,会进行多次析构从而产生未定义行为。

解决该问题的方法是使用 std::enable_shared_from_this 作为基类模板,该类模板属于奇妙递归模板模式(The Curiously Recurring Template Pattern,CRTP)。具体实现如下代码,

std::vector<std::shared_ptr<A>> vec;

class A : public enable_shared_from_this<A> {
public:
	void process(std::vector<std::shared_ptr<A>>& vec);
};

void A::process(std::vector<std::shared_ptr<A>>& vec) {
	vec.emplace_back(shared_from_this());
}

shared_from_this() 能查询当前对象的控制块,并且创建一个指涉到该控制块的新的 std::shared_ptr,这样的设计依赖于调用shared_from_this() 之前需要确保对象被 std::share_ptr 所持有,要使得对象被 std::shared_ptr 所持有,对象必须先构造完成, 只有这样才能找到其已经分配好的控制块,因此,不能在对象的构造函数中调用 shared_from_this()。

为了保证正确的使用,通常采用工厂方法来使用此特征,将构造函数声明在 private 中,通过一个工厂方法来返回 std::shared_ptr。

std::weak_ptr

std::weak_ptr 是针对 std::shared_ptr 功能的扩展, 不能解引用,也不能检查是否为空,主要作用是用于观察 std::shared_ptr 的内部状态,查看其引用计数,查看指针是否空悬,是一种具有临时所有权语义的智能指针。

StackOverflow 4上有一个很好的例子来解释 std::weak_ptr 的作用,代码如下:

#include <iostream>
#include <memory>

int main()
{
    // OLD, problem with dangling pointer
    // PROBLEM: ref will point to undefined data!

    int* ptr = new int(10);
    int* ref = ptr;
    delete ptr;

    // NEW
    // SOLUTION: check expired() or lock() to determine if pointer is valid

    // empty definition
    std::shared_ptr<int> sptr;

    // takes ownership of pointer
    sptr.reset(new int);
    *sptr = 10;

    // get pointer to data without taking ownership
    std::weak_ptr<int> weak1 = sptr;

    // deletes managed object, acquires new pointer
    sptr.reset(new int);
    *sptr = 5;

    // get pointer to new data without taking ownership
    std::weak_ptr<int> weak2 = sptr;

    // weak1 is expired!
    if(auto tmp = weak1.lock())
        std::cout << *tmp << '\n';
    else
        std::cout << "weak1 is expired\n";

    // weak2 points to new data (5)
    if(auto tmp = weak2.lock())
        std::cout << *tmp << '\n';
    else
        std::cout << "weak2 is expired\n";
}

检查空悬指针

  • 直接使用 expired() 成员函数进行检验。该方法线程不安全,因为在调用 expired() 判断空悬和转换为 std::shard_ptr 是一个分离的操作,可能存在 data race,在这个期间另外一个线程可能重新赋值或者析构最后一个指向该对象的 std::shared_ptr,这样导致对象析构,从而造成未定义行为。

因此,我们需要使用原子操作来完成 std::weak_ptr 是否失效, 以及在未失效的条件下将 std::weak_ptr 转换为 std::shard_ptr。有如下两种方式来实现上述操作。

  1. std::weak_ptr::lock 返回一个 std::shared_ptr,如果 std::weak_ptr 已经失效,则 std::shared_ptr 为空。

    auto sp = std::make_shared<A>();
    std::weak_ptr<A> wp(sp);
    
    std::shared_ptr<A> sp1 = wp.lock();
    auto sp2 =  wp.lock();
    
  2. 使用 std::weak_ptr 作为实参来构造std::shared_ptr,如果 std::weak_ptr 失效得话,则会抛出异常。

    std::shared_ptr<A> sp3(wp);
    

解决 std::shared_ptr 造成的循环引用

使用 std::shared_ptr 是容易造成循环引用,循环引用是指 std::shared_ptr 创建的两个对象,同时它们内部的 std::shared_ptr 指向对方,如下代码,

#include <memory>
#include <iostream>
using namespace std;
struct A;
struct B;

struct A {
    shared_ptr<B> b;
    ~A() {
        cout << "~A()" << endl;
    }
};

struct B {
    shared_ptr<A> a;
    ~B() {
        cout << "~B()" << endl;
    }    
};

int main()
{
    auto pa = make_shared<A>();
    auto pb = make_shared<B>();
    pa->b = pb;
    pb->a = pa;
    return 0;
}

这种情况下,main 函数结束后,A 和 B 对象的引用计数都为1,两个对象无法正常销毁,导致内存泄露的情况。

为了避免这种情况,可以使用 std::weak_ptr 来解决循环引用的问题,将 A 中或 B中的包含的 std::shared_ptr 改成 std::weak_ptr 即可,如下代码,

#include <memory>
#include <iostream>
using namespace std;
struct A;
struct B;

struct A {
    shared_ptr<B> b;
    ~A() {
        cout << "~A()" << endl;
    }
};

struct B {
    weak_ptr<A> a;
    ~B() {
        cout << "~B()" << endl;
    }    
};

int main()
{
    auto pa = make_shared<A>();
    auto pb = make_shared<B>();
    pa->b = pb;
    pb->a = pa;
    return 0;
}

这样,便能解决循环引用的问题。整个析构过程如下:

  • main 函数退出前,B 对象的引用计数为2,A 对象的引用计数为 1;
  • pb 指针销毁,B 对象的引用计数变为 1;
  • pa 指针销毁,A 对象的引用计数变为0,A 对象立刻析构,A 对象析构的过程中会导致其包含的 b 指针被销毁,从而导致 B 对象的引用计数变为0,使得 B 对象也被正常析构。

效率分析

作为 std::shared_ptr 的补充,std::weak_ptr 和 std::shared_ptr 的本质是一样的,对象尺寸相同,且具有相同的动态分配的控制块,只不过 std::weak_ptr 是使用的控制块中的弱引用计数;因此,std::weak_ptr 和 std::shared_ptr 的效率基本一致。

使用 std::make_shared 和 std::make_unique 来代替 new

使用 std::make_shared 和 std::make_unique 的好处如下:

  • 对象类型只需声明一次,代码更加简洁

    具体区别如下,

    auto p1(std::make_shared<A>());
    
    std::shared_ptr<A> p2(new A());
    
  • 提供异常安全保证,防止内存泄露

    如下代码中,编译器可能会对指令进行重排,使得指令执行步骤如下:

    1. new Widget;
    2. computePriority();
    3. 构造 std::shared_ptr<Widget>

    这样类型的代码,可能在步骤 2 中发生异常,导致无法步骤 1 动态分配的对象无法回收,导致内存泄露;

      processWidget(std::shared_ptr<Widget>(new Widget),  // potential
                    computePriority());                   // resource
                                                          // leak!
    
  • 效率更高,只涉及一次内存分配

    使用 std::make_shared 仅仅涉及到了一次动态内存分配,而使用 new operator + std::unique_ptr 构造的话存在两次动态内存分配。动态内存分配很有可能导致系统调用的发生,从而进而内核态,开销增加。

Reference


  1. Effective Modern C++, Scott Meyers ↩︎

  2. https://supwills.com/post/address-space/ “地址空间:由 C 至 C++” ↩︎

  3. https://gist.github.com/willhunger/83e91d3b13a9ed234b76244c3745ea15 “C++ smart pointer” ↩︎

  4. https://stackoverflow.com/questions/12030650/when-is-stdweak-ptr-useful “When is std::weak_ptr useful?” ↩︎