Smart Pointers in C++

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

1. Raw Pointer and Smart Pointers

在C++中,我们使用 newdelete/delete[] 在堆上实例化和删除对象。当你使用裸指针时,同一个作用域中的 newdelete 的数量总是需要匹配的,不然就可能引起内存泄漏。

在使用裸指针删除对象时,你还需要注意类型的匹配(single-object form using delete, array-object using delete[]),不然就可能引起未定义行为。

此外,即使裸指针的使用天衣无缝,异常的发生也可能导致一些内存问题。(RAII)

尽管裸指针的使用强大且高效,但是人为地管理这些资源的释放是容不得一点粗心的。C++的标准库(C++11/C++14标准)中,我们有四种类型的智能指针,分别是:

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

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

为了避免裸指针给我们带来的可能的内存泄漏。在编写 C++ 程序时,我们应当避免使用裸指针。

2. std::unique_ptr: A Scoped Exclusive-Ownership Pointer

当我们有使用智能指针的需求时,std::unique_ptr该是我们第一个想到的。作为一种scoped pointer,如我们在 RAII and Scope in C++ 中展示的一样,std::unique_ptr的大小和裸指针的大小是一样的。当你使用std::unique_ptr时,所使用的指令和使用裸指针的指令是一样的。

Pasted image 20241023154500.png

2.1 Exclusive-Ownership

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);

2.2 std::unique_ptr<T[]>

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

Pasted image 20241023154839.png

2.3 std::unique_ptr<T, Deleter>

除了模板参数T之外,std::unique_ptr也一直有第二个模板参数,我们称之为Deleter,即删除器。删除器的作用是什么呢?智能指针难道不应该接管一切么?智能指针让我们免于资源泄漏,但是我们需要删除器来释放这些资源。通过智能指针管理非常见的资源(非堆上资源)时,我们就需要定义一个自己的删除器。

Pasted image 20241023155605.png

如果不定义任何的Deleter,unique_ptr就会使用默认的删除器std::default_delete。默认的删除器负责对堆上动态资源进行释放。

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()方法提前释放内存。

2.4 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.

3. std::shared_ptr: A Reference Counted Pointer

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

3.1 Shared Object

std::shared_ptr 的这种共享特性是通过引用计数来实现的。通过引用计数,shared_ptr 就能知道当前有多少个指针在共享资源。这种对对象资源的控制使得 std::shared_ptr 需要额外的控制块来管理对象资源的释放。那么控制块放到哪里?

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

因而,std::make_shared工厂函数总会创建一个控制块,并且 std::shared_ptr的大小是裸指针大小的两倍大(一个指针指向对象,一个指针指向控制块)。而且控制块的内存必须在堆上动态分配。当std::unique_ptr转换为std::shared_ptr时,构造函数也是会生成相应的控制块。

Pasted image 20241024004507.png
要实现引用计数,我们在构造函数中加入reference_count++;,并在拷贝操作时做同样的事情来增加引用计数。并且要在引用计数为0时释放掉对象资源。在对引用计数(弱引用计数同)进行操作时,由于++--是read-modify-write操作指令,所以对引用计数的操作必须是原子性的

下面我们对std::shared_ptr进行拷贝时,我们拷贝了ptr to T和ptr to control block,并使得引用计数增加1。但当我们进行移动操作时,我们不需要对引用计数进行操作,这是因为所有权转移了。

Pasted image 20241024005717.png

3.2 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 实例,所以控制块会指向这个完整的对象。

3.3 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不同,没有指针真正拥有对象的ownership,ownership由控制块管理管理。因此,在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; 

3.4 std::shared_ptr Could Be Optimized

当我们用工厂函数创建好std::shared_ptr了之后,我们在堆上会存在两块空间,我们可以将这两块空间合并起来,以避免外部碎片的产生。大多数的库都支持下面这种实现方式。

如果你使用 make_shared 工厂函数来创建共享指针,那么就会优化成这样:

Pasted image 20241024145414.png

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

Pasted image 20241024145703.png

4. std::weak_ptr: A Non-Owning shared_ptr

在shared_ptr中,我们用ref count来控制对象的生命周期。然而,当两或多个shared_ptr相互持有对方的shared_ptr时,就可能发生循环引用,导致引用计数永远不会降到0,从而导致内存泄漏。为了避免循环引用的情况,我们可以使用weak_ptr,它不会增加引用计数,允许对象正常销毁。

prevent dangling pointer

循环XX

Pasted image 20241214224127.png

shared_ptr共享controlled object,因此其指向对象的生命周期由shared_ptr所控制。而weak_ptr之间共享控制块。当对象已经被销毁,如果这时weak ref count为0,控制块就会被销毁。

Pasted image 20241214224219.png

4.1 A Ticket to Object Ownership

weak_ptr 的内存结构和 shared_ptr 是相同的。在学习shared_ptr时,我们实际上并没有用到weak reference,而这个结构的存在和控制块的生命周期是息息相关的。weak_ptr并不和shared_ptr一样共享其指向的对象,也就是说,你并不能解引用一个weak_ptr,你必须用lock()方法先将其转换成一个shared_ptr,这个方法是线程安全的。

#include <iostream>
#include <memory>

struct test {
    int a = 10;
};

int main() {
    std::shared_ptr<test> t1 = std::make_shared<test>();
    std::weak_ptr<test> t2 = t1;

    if (auto t2_shared = t2.lock()) {
        std::cout << t1->a << " " << t2_shared->a << std::endl;
    } else {
        std::cout << "t2 is expired" << std::endl;
    }
    return 0;
}

5. std::enable_shared_from_this

CRTP