Smart Pointers in C++

Do this at first: RAII and Scope in C++

Raw Pointer and Smart Pointers

在 C++ 中,我们一般会使用 newdelete/delete[] 在堆上实例化和删除对象。如果你使用裸指针时,同一个作用域中的 newdelete 的数量必须严格匹配,不然就可能引起内存泄漏或是未定义行为。

在使用裸指针删除对象时,你还需要注意类型的匹配:单个对象使用 delete,数组对象使用 delete[]。如果使用错误的释放方式,就可能引起未定义行为。而通过裸指针的,声明你并不能确定其到底是一个单个的对象还是一个数组。

此外,如果有多个裸指针指向同一个对象,一当该对象被销毁,那么其他没有被置为 nullptr 的指针就会变成悬挂指针 (dangling pointers),而且对于裸指针,你没法知道到底指针有没有悬挂。更何况裸指针还不支持 RAII,即使裸指针的使用天衣无缝,异常的发生也可能导致一些资源泄漏问题。尽管裸指针的使用强大高效,但是人为地管理这些资源的释放是容不得一点粗心的。

为了避免以上问题,C++ 为我们提供了智能指针。智能指针是对裸指针的封装抽象,而通过运算符重载,智能指针的行为又和普通裸指针别无二致。

C++11/C++14标准中,我们有四种类型的智能指针,分别是:

  1. std::auto_ptr
    C++98: 初次引入,独占所有权的智能指针。
    C++11: 被标记为过时 (Deprecated)。
    C++17: 被移除。
  2. std::unique_ptr
    C++11: 引入作为 auto_ptr 的替代品,独占所有权的智能指针。
    C++14: 添加了 make_unique 工厂函数。
  3. std::shared_ptr
    C++11: 引入引用计数智能指针。和 make_shared<T> 工厂函数。
    C++17: 增添对数组类型的支持。
    C++20: 添加了对 make_shared<T[]> 的支持。
  4. std::weak_ptr
    C++11: 引入“弱”引用智能指针,用于解决循环引用问题。

std::auto_ptr 后来被 std::unique_ptr 所替代,原因是 C++98 并没有移动语义,而 std::auto_ptr 又提供对资源的独占权语义,所以 std::auto_ptr 智能通过拷贝的语义来模拟移动。看起来是拷贝,但实际上是移动,导致被拷贝的指针变为 null,这种行为在 C++98 是很迷惑人的。C++11 引入移动语义后,std::unique_ptr 就来替代原先语义矛盾的 std::auto_ptr

这些智能指针通过对裸指针的包装,使得我们可以放心地申请资源而不担心发生任何的资源泄漏。这些智能指针会管理动态申请对象的生命周期,并在合适的适合销毁对象(包括异常事件)。

std::unique_ptr: A Scoped Exclusive-Ownership Pointer

当你想使用智能指针来自动管理资源时,你第一个该想到的就是 std::unique_ptr。作为一种作用域指针,像我们在 RAII and Scope in C++ 中展示的一样,std::unique_ptr的大小和裸指针的大小是一样的。当你使用std::unique_ptr时,所使用的代码和使用裸指针时的是一样的。

Pasted image 20241023154500.png

Exclusive-Ownership

由于 std::unique_ptr 提供独占所有权的特性,在任何时候,std::unique_ptr 都能保证只能有一个指针拥有资源。所以我们不能对 std::unique_ptr 进行任何拷贝操作,只能进行移动操作。试想,如果我们对 std::unique_ptr 进行了拷贝,那就会有两个拥有对象所有权的指针,这与其的设计初衷相悖。当 std::unique_ptr 移动时,所有权转移,原来的指针会丢失对资源的所有权(nullptr),从而保持独占所有权的特性。

Pasted image 20241023153930.png

std::unique_ptr移动的操作示例:

#include <memory>
// Create a unique pointer using make_unique factory function.
std::unique_ptr<Entity> uptr1 = std::make_unique<Entity>();
// or `std::unique_ptr<Entity> uptr1 = std::unique_ptr<Entity>(new Entity);`

// Change ownership to uptr2.
std::unique_ptr<Entity> uptr2 = std::move(upre1);

std::unique_ptr<T[]>

当你需要一个指向数组的 unique pointer 时,我们就要用到 std::unique_ptr<T[]>。这种智能指针在析构时会调用 delete[],以正确释放数组内存。

Pasted image 20241023154839.png

std::unique_ptr<T, Deleter>

除了模板参数T之外,std::unique_ptr也一直有第二个模板参数,我们称之为 deleter,即删除器。删除器的作用是什么呢?智能指针难道不应该接管一切么?智能指针让我们免于资源泄漏,但是我们需要删除器来释放这些资源。

如果我们不自定义 deleter,那 std:unique_ptr就会使用默认的删除器std::default_delete (和 delete 关键字的行为一样)。默认的删除器只负责对堆上动态资源进行释放。如果想要通过智能指针管理非常见的资源(非堆上资源),或是在释放资源时打印一些 logging,那我们就需要定义一个自己的删除器。

Pasted image 20241023155605.png

如果 deleter 是一个函数指针,那么 std::unique_ptr<T, deleter> 就会变成两个裸指针的大小。而如果 deleter 是一个函数对象(如 lambda ),那么根据其 capture list,deleter 的大小也会随之改变,如果 lambda 无状态,那么 deleter 将不会额外占用空间。

template<class T, class Deleter = std::default_delete<T>>
class unique_ptr {
    T *p_ = nullptr;
    Deleter d_;
public:
    explicit unique_ptr(T* p = nullptr, Deleter d = Deleter()) : p_(p), d_(d) {}
    ~unique_ptr() {
        if (p_) d_(p_);
    }
    // Copy is not allowed.
    unique_ptr(const unique_ptr&) = delete;
    unique_ptr& operator=(const unique_ptr&) = delete;
    // Object is movable.
    unique_ptr(unique_ptr&& other) noexcept : p_(other.p_), d_(std::move(other.d_)) {
        other.p_ = nullptr;
    }
    unique_ptr& operator=(unique_ptr&& other) noexcept {
        if (this != &other) {
            if (p_) d_(p_);
            p_ = other.p_;
            d_ = std::move(other.d_);
            other.p_ = nullptr;
        }
        return *this;
    }
    T* get() const { return p_; }
    T* release() { T* tmp = p_; p_ = nullptr; return tmp; }
    void reset(T* p = nullptr) {
        if (p_ != p) {
            if (p_) d_(p_);
            p_ = p;
        }
    }
};
template<class T>
struct default_delete {
    void operator()(T *p) const { delete p; }
};

如果我们想在作用域内释放内存,我们可以使用reset()方法提前释放内存,相当于原先 delete 关键字的作用,或者置一个 std::unique_ptr 指向一个新的资源。

Deleter: Database Example

文件句柄、网络连接、设备句柄等这些资源可能并不是在堆上分配的,我们并不能用delete删除这些资源。Deleter的作用就显现出来了,对那些不能delete的资源进行管理。智能指针可以通过自定义删除器来正确释放它们。假设我们有一个数据库连接:

struct DBConnection{
// All kinds of resources.
// All kinds of methods.
};
struct DBDeleter{
	void operator()(DBConnection* db){
		std::cout << "Closing Database Connection.\n";
		dbClosing(db);
	}
};
class DBManager{
	std::unique_ptr<DBConnection, DBDeleter> dbConnect_;
public:
	explicit DBManager(DBConnection* dbConnect) : dbConnect_(dbConnect){}
	void query(const std::string& q){
		if(dbConnect_){
			db_query(dbConnection_.get(), q.c_str());
		}
	}
	void close(){
		dbConnection_.reset();
	}
};

int main(){
	DBConnection* db = dbOpen("example.db");
	DBManager dbManager(db);
	dbManager.query("SELECT * FROM Student");
	// dbManager.close(); // <-Close the database now.
	return 0;
} // <- Close the database by using DBDeleter.

std::shared_ptr: A Shared-Ownership Pointer

std::shared_ptr 是使用上最接近裸指针的智能指针。不同于上一节中的 std::unique_ptr,使用 std::shared_ptr 创建的对象的所有权是由所有指向该对象的 std::shared_ptr 共享的。只有当最后一个 std::shared_ptr 停止指向对象时,对象的资源才会被释放。

Shared Object via Reference Counting

std::shared_ptr 的这种共享特性是通过原子类型的引用计数来实现的。通过引用计数,就能知道当前有多少个 std::shared_ptr 指针在共享资源,在析构时,只需要检查当前引用计数是否为 1 就可以控制对象资源的释放。这种对对象资源的控制使得 std::shared_ptr 需要额外的控制块来管理对象资源的释放。那么控制块放到哪里?

控制块通常会放在堆上,与对象一起分配。这样做的好处是确保控制块的生命周期与对象的生命周期一致。当所有指向对象的 shared_ptr 被销毁时(并且这时如果weak ref count = 0),控制块也会被自动释放。如果控制块放在栈上,当函数返回或栈帧被销毁时,控制块就会被销毁,而不管是否还有 shared_ptr 指向对象。这将导致引用计数失效,可能在某些情况下导致对象被提前销毁,或者造成资源泄漏。

因而,构造 std::shared_ptr 时除了指向资源的指针外还会额外需要一个指向控制块的指针,因而,std::shared_ptr 的大小是裸指针大小的两倍大。当std::unique_ptr转换为std::shared_ptr时,构造函数也会生成相应的控制块。

Pasted image 20241024004507.png
要实现引用计数,我们在构造函数和进行拷贝时中加入reference_count + 1; 的操作。并且要在析构函数或在 std::shared_ptr 等于 nullptr 时将引用计数减一。当引用计数为 0 时释放掉对象资源。因为在对引用计数(弱引用计数也一样)进行操作时,++--是 read-modify-write 操作指令,所以对引用计数的操作必须是原子性的

下面我们对 std::shared_ptr 进行拷贝时,我们拷贝了 ptr to T 和 ptr to control block,并使得引用计数增加1。但当我们进行移动操作时,我们不需要对引用计数进行操作,这是因为所有权转移了。另外,在下面第二种情况下,控制原先对象(ReadBuffer)的控制块中的引用计数需要减 1。

std::shared_ptr<Resource> pResource1 = new Resource;
std::shared_ptr<Resource> pResource2;
pResource2 = pResource1; // Ref count + 1

std::shared_ptr<ReadBuffer> pReadBuf = new ReadBuffer;
pReadBuf = pResource1;  // ref count for shared_ptr<Resource> + 1
						// ref count for shared_ptr<ReadBuffer> - 1

Pasted image 20241024005717.png

So Many Pointers

我们已经有一个指向对象的指针了,为什么控制块中还有指向对象的指针?假设我有这几个类:

struct Fruit { int juice; }; 
struct Vegetable { int fiber; }; 
struct Tomato : Fruit, Vegetable { int sauce; };

当我创建了:

std::shared_ptr<Tomato> tomato = make_shared<Tomato>;
std::shared_ptr<Fruit> fruit = tomato;
std::shared_ptr<Vegetable> fruit = tomato;

当我们创建一个指向 Tomato 实例的 std::shared_ptr<Fruit> 或 std::shared_ptr<Vegetable>,这些指针会指向 Tomato 对象内的 Fruit 或 Vegetable 部分。由于控制块仍然是共享的,它们最终管理的是同一个 Tomato 实例,所以控制块会指向这个完整的对象。

而且由于 shared_ptr 共享对象的所有权,所以只有控制块中的指针才能够控制对象的生命周期,即释放其资源。即使引用计数为 1 (当前独占所有权),你也不能提前删除对象。

Deleter in std::shared_ptr

和 std::unique_ptr 一样,std::shared_ptr 也使用 delete 作为其默认的资源销毁机制(Resource-destruction mechanism)。但是不同的是,每个 std::unique_ptr 独立持有对所指对象的控制权,而 std::shared_ptr 的控制权是共享的。对象资源的控制由控制块来完成。

Pasted image 20241024015023.png

这种由于控制权的不同,就使得std::unique_ptr本身有权力来管理对象。所以std::unique_ptr中,deleter 是智能指针的一部分。

struct CustomDeleter {
    void operator()(int* p) const {
        delete p;
    }
};
// Deleter type is part of the smart pointer.
std::unique_ptr<int, CustomDeleter> p1(new int); 

std::shared_ptr不同,没有指针真正拥有对象的所有权,所有权由控制块管理管理。因此,在std::shared_ptr中,deleter 并不作为智能指针的一部分。而控制块又只有一个,所以即使我们有两个std::shared_ptr,只要指向资源相同,deleter就是相同的:

struct CustomDeleter {
    void operator()(int* p) const {
        delete p;
    }
};
// Deleter is not part of the smart pointer.
std::shared_ptr<int> p(new int, CustomDeleter()); 
std::shared_ptr<int> p2 = p; 

std::shared_ptr Could Be Optimized

当我们用工厂函数创建好std::shared_ptr了之后,我们在堆上会存在两块空间,这就会产生两次内存申请的开销。但我们可以将这两块空间合并起来,以避免外部碎片的产生。可庆的是,大多数的编译器都支持这种实现方式。如果你使用 make_shared 工厂函数来创建共享指针,那么堆内存两块空间就会一块申请,而其空间布局就会优化成这样:

Pasted image 20241024145414.png

由于控制块和对象资源在同一块内存块中,编译器甚至能够将控制块中的指针优化掉。

Pasted image 20241024145703.png

但是这样也有弊端,那就是对象的生命周期将和控制块一致,而现实中,控制块的生命周期通常比对象的生命周期要长。

Forbid Any Raw Pointers

在构造 std::shared_ptr<A> 时,你可以传入 std::unique_ptr<A>std::shared_ptr<B>std::weak_ptr<B> 等智能指针,但你需要避免使用裸指针来创建 std::shared_ptr 对象。一是因为你不能确保裸指针不被误用,而且还可能发生:

auto ptr = new A;
{
	std::shared_ptr<A> sptrA1(ptr); // create 1st control block for *ptr
	std::shared_ptr<A> sptrA2(ptr); // 2nd control block is created...
} // double delete

如果有特殊的需求,没有裸指针就手痒得不行,那你可以直接用裸指针来创建共享型智能指针,或者先创建 std::unique_ptr,然后再把其转换成 std::shared_ptr

std::shared_ptr<A> sptrA(new A);

std::unique_ptr<A> uptrA(new A);
std::shared_ptr<A> sptrA = std::move(uptrA);

std::enable_shared_from_this

接着上面关于裸指针的讨论,除了 new 出来的裸指针外,我们还需要注意非常容易被忽略的裸指针——this指针。由于智能指针能够自动管理对象声明周期,所以在一些资源并非严格要求互斥的情况下,我们会使用 std::shared_ptr 来自动地管理对象的声明周期。比如下面的例子:

std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget{
private:
...
public:
...
	void process();
...
};

void Widget::process() {
...
// After process
processedWidgets.emplace_back(this); // Compiles, but disaster.
}

还是这里同样的问题,使用 this 构造 std::shared_ptr 后,C++ 就会为这个 this 新建一个对 *this 资源的控制块,和上面裸指针的毛病是一样的。

为了让类成员函数通过 this 指针来安全地创建指向自身的 std::shared_ptrstd::shared_ptr API 提供 std::enable_shared_from_this<T>,这是一个辅助性质的基类模板,而类型参数总是派生类的类型。如:

std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget : public std::enable_shared_from_this<Widget> {
private:
...
public:
...
	void process();
...
};

这种基类使用派生类作为模板类型参数的行为看似很奇怪,但是这样的确可以让基类得到派生类的类型信息,这种类背后的设计模式叫 The Curiously Recurring Template Pattern (CRTP)。std::enable_shared_from_this 提供一个我们要用到的成员函数:std::shared_from_this(),它的作用就是为当前对象安全地创建 std::shared_ptr,问题解决了。

采用 std::shared_from_this() 后,我们就不需要担心双重释放等问题了。如:

void Widget::process() {
	...
	// After process
	processedWidgets.emplace_back(std::shared_from_this());
}

当使用 std::shared_from_this() 时,它会查找有没有指向当前对象的 std::shared_ptr,然后复用这个智能指针。如果找不到任何管理当前对象的 std::shared_ptr,那么 std::shared_from_this() 就会抛出异常。

为确保构造对象时已经有 std::shared_ptr 指向该对象,通常将构造函数放在 private 域中,然后提供 public 的工厂函数,在工厂函数中构造对象并让 std::shared_ptr 指向该对象(使用 std::make_shared())。比如:

class Widget : public std::enable_shared_from_this<Widget> {
private:
    Widget() {}

public:
    static std::shared_ptr<Widget> create() {
        return std::make_shared<Widget>();
    }
    void process() {
        auto self = shared_from_this();
    }
};

std::weak_ptr: A Non-Owning Resource Observer

std::shared_ptr 可以自动管理对象的生命周期,每次 std::shared_ptr 的拷贝都会使得引用计数加一,进而延长资源的存活时间。而在实现某些设计模式时,我们可能想拥有行为类似于 std::shared_ptr ,但又不参与资源所有权的智能指针。这种非拥有型的智能指针允许其指向对象被销毁(悬空),并根据观察对象的状态做出不同的反应。简而言之,我们想要一个资源观察者:如果资源仍在,我们就安全地使用它,如果资源已被销毁,则采取其他策略。

C++11 引入的 std::weak_ptr 弱指针就是这样一个资源的观察者:行为上类似 std::shared_ptr,但又不参与对所有权的管理。std::weak_ptrstd::shared_ptr 底层使用的数据结构是相同的,所以 std::weak_ptr 的创建依赖于已经存在的 std::shared_ptr

Pasted image 20241214224127.png

std::weak_ptr 可以由 std::shared_ptrstd::weak_ptr 拷贝赋值。在创建 std::weak_ptr 时,管理对象生命周期的引用计数并不会增加,而弱引用计数 (weak count) 会增加(表示有多少个 weak_ptr 在观察资源),而且当 std::shared_ptr 销毁后,std::weak_ptr 仍会指向原先资源的控制块。(控制块会在弱引用计数清零后销毁)

Pasted image 20241214224219.png

std::weak_ptr API 提供 expired() 方法来检测资源是否已经被销毁:

auto sptr = std::make_shared<A>();
std::weak_ptr<A> wptr(sptr);

sptr.reset(); // resource released safely

if (wptr.expired()) {
	// wptr now dangle
} else {
	// can we use it?
}

假如我们检测时资源没有被销毁,是否意味着我们可以在 else 分支中使用其指向的资源呢?因为弱指针不拥有对资源的所有权,所以 std::weak_ptr 天生就不能解引用。即使可以解引用,这种分开的检测+解引用的模式也会导致数据竞争问题。所以我们需要原子化地检测+解引用的方式,为解决这一问题 API 给我们提供了 lock() 方法:

auto sptr = std::make_shared<A>();
std::weak_ptr wptr(sptr);
...
auto sptr2 = wptr.lock();
if (sptr2) {
	// sptr2 not null, the resource has not been destroyed
} else {
	// is null
}

由于 std::weak_ptr 不拥有对所指向资源的所有权,所以实际上 lock() 方法会返回一个 std::shared_ptr 对象。而且 lock() 操作是原子化的,所以不必担心数据竞争的问题。如果 std::weak_ptr 已经 expired 了,那么 lock() 就会返回 nullptr

虽然你可以直接使用 std::weak_ptr 构造 std::shared_ptr,但如果那时已经 expired 了,那么就会抛出。

auto sptr = std::make_shared<A>();
std::weak_ptr wptr(sptr);
...
std::shared_ptr<A> sptr2(wptr); // throw std::bad_weak_ptr if expired

Weak Ptr for Design Patterns

最开始就说在实现某些设计模式时,我们会想要一个“资源观察者”:如果资源仍在,我们就安全地使用它,如果资源已被销毁,则采取其他策略。假设我们要设计一个缓存系统,资源以唯一 ID 的方式存储在数据库中。为了让缓存的资源让所有缓存系统的 client 共享,避免在内存中出现多份资源拷贝,所以我们不能用 std::unique_ptr,因为它具有独占所有权,不支持共享访问。

另外,虽然作为 C++ 的垃圾回收机制的 std::shared_ptr 支持多个引用共享资源,但它的问题在于:只要还有任何一个 shared_ptr 指向该资源,资源就不会被释放。这对于缓存系统来说并不理想,因为我们希望在没有任何用户使用资源时自动释放它,以节省内存。

这就是引入 std::weak_ptr 的意义之一了,通过结合 shared_ptrweak_ptr,我们就可以满足:

  • 保证资源只存在一份 (shared_ptr)
  • 支持多用户安全访问 (std::weak_ptr::lock())
  • 无人使用时自动释放资源 (weak_ptr 不拥有资源,支持资源的悬空)
  • 可以检测资源是否已被销毁 (weak_ptr 能够检测资源状态)

下面是一个用 std::weak_ptr 实现的简单的缓存系统,在有人使用时,就加载缓存。没人使用时(出作用域)就释放资源。

std::shared_ptr fastLoadWidget(WidgetID id) { 
	static std::unordered_map<WidgetID, std::weak_ptr<Widget> cache; 
	auto objPtr = cache[id].lock(); // objPtr is std::shared_ptr 
									// to cached object (or null 
									// if object's not in cache) 

	if (!objPtr) {                  // if not in cache, 
		objPtr = loadWidget(id);    // load it 
		cache[id] = objPtr;         // cache it 
	} 
	return objPtr; 
}

Preventing the std::shared_ptr Cycles

weak_ptr 还有一个很重要的应用就是避免 std::shared_ptr 的循环引用问题。假如我们现在有三个对象 A, B 和 C,然后 A 和 C 共享 B 的生命周期:

std::shared_ptr

std::shared_ptr

A

C

B

如果我们需要让 B 中的一个指针指向 A,那么我们需要什么指针?

std::shared_ptr

???

std::shared_ptr

A

C

B

裸指针是用不成的,因为引入裸指针可能造成一下未定义行为。比如 A 在某时刻释放掉了,但是 C 仍指向 B,这时 B 指向 A 的裸指针就说一个悬挂指针了,而你不能解引用一个悬挂指针。

现在看智能指针:

  • unique_ptr:使用 unique_ptr 一般来说不会有问题,但你需要保证 A 资源所有权的独占。因为 unique_ptr 是独占所有权的指针,如果不能确定 A 是否还被其他对象所引用,那 B 就无法独占 A,即不能使用 unique_ptr
  • shared_ptr:这种模式下,A 和 B 就各有一个 shared_ptr 指向对方,形成 shared_ptr 的循环引用。在作用域结束时,两个 shared_ptr 都会阻止对象资源的释放(即引用计数为一,不会降为零)。一旦循环引用发生,资源就会泄漏,除非程序退出,否则无法访问泄漏的资源。
  • weak_ptr:如果你不能确定 A 资源所有权是独占的,那么 weak_ptr 就是你唯一的选择了。因为 weak_ptr 可以检测底层资源的状态,所以不用担心指针的悬挂;此外,由于 weak_ptr 还不拥有对资源的所有权,所以也不需要担心循环引用问题。(如果需要使用 lock(),也可以保证 lock() 后获得的 shared_ptr 先释放)

这里可能有疑问,那就是既然 lock() 操作会返回一个 shared_ptr,那这个返回的指针不会造成循环引用么?答案是不会,因为使用 lock() 创建的 shared_ptr 是栈上的临时变量,会随着栈帧销毁而释放。