tags:
- Cpp
Smart Pointers in C++
Do this at first: RAII and Scope in C++
在 C++ 中,我们一般会使用 new
和 delete
/delete[]
在堆上实例化和删除对象。如果你使用裸指针时,同一个作用域中的 new
和 delete
的数量必须严格匹配,不然就可能引起内存泄漏或是未定义行为。
在使用裸指针删除对象时,你还需要注意类型的匹配:单个对象使用 delete
,数组对象使用 delete[]
。如果使用错误的释放方式,就可能引起未定义行为。而通过裸指针的,声明你并不能确定其到底是一个单个的对象还是一个数组。
此外,如果有多个裸指针指向同一个对象,一当该对象被销毁,那么其他没有被置为 nullptr
的指针就会变成悬挂指针 (dangling pointers),而且对于裸指针,你没法知道到底指针有没有悬挂。更何况裸指针还不支持 RAII,即使裸指针的使用天衣无缝,异常的发生也可能导致一些资源泄漏问题。尽管裸指针的使用强大高效,但是人为地管理这些资源的释放是容不得一点粗心的。
为了避免以上问题,C++ 为我们提供了智能指针。智能指针是对裸指针的封装抽象,而通过运算符重载,智能指针的行为又和普通裸指针别无二致。
C++11/C++14标准中,我们有四种类型的智能指针,分别是:
std::auto_ptr
std::unique_ptr
auto_ptr
的替代品,独占所有权的智能指针。make_unique
工厂函数。std::shared_ptr
make_shared<T>
工厂函数。make_shared<T[]>
的支持。std::weak_ptr
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
时,所使用的代码和使用裸指针时的是一样的。
由于 std::unique_ptr
提供独占所有权的特性,在任何时候,std::unique_ptr
都能保证只能有一个指针拥有资源。所以我们不能对 std::unique_ptr
进行任何拷贝操作,只能进行移动操作。试想,如果我们对 std::unique_ptr
进行了拷贝,那就会有两个拥有对象所有权的指针,这与其的设计初衷相悖。当 std::unique_ptr
移动时,所有权转移,原来的指针会丢失对资源的所有权(nullptr
),从而保持独占所有权的特性。
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[]
,以正确释放数组内存。
std::unique_ptr<T, Deleter>
除了模板参数T
之外,std::unique_ptr
也一直有第二个模板参数,我们称之为 deleter,即删除器。删除器的作用是什么呢?智能指针难道不应该接管一切么?智能指针让我们免于资源泄漏,但是我们需要删除器来释放这些资源。
如果我们不自定义 deleter,那 std:unique_ptr
就会使用默认的删除器std::default_delete
(和 delete
关键字的行为一样)。默认的删除器只负责对堆上动态资源进行释放。如果想要通过智能指针管理非常见的资源(非堆上资源),或是在释放资源时打印一些 logging,那我们就需要定义一个自己的删除器。
如果 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
指向一个新的资源。
文件句柄、网络连接、设备句柄等这些资源可能并不是在堆上分配的,我们并不能用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 Pointerstd::shared_ptr
是使用上最接近裸指针的智能指针。不同于上一节中的 std::unique_ptr
,使用 std::shared_ptr
创建的对象的所有权是由所有指向该对象的 std::shared_ptr
共享的。只有当最后一个 std::shared_ptr
停止指向对象时,对象的资源才会被释放。
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
时,构造函数也会生成相应的控制块。
要实现引用计数,我们在构造函数和进行拷贝时中加入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
我们已经有一个指向对象的指针了,为什么控制块中还有指向对象的指针?假设我有这几个类:
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 (当前独占所有权),你也不能提前删除对象。
std::shared_ptr
和 std::unique_ptr
一样,std::shared_ptr
也使用 delete
作为其默认的资源销毁机制(Resource-destruction mechanism)。但是不同的是,每个 std::unique_ptr
独立持有对所指对象的控制权,而 std::shared_ptr
的控制权是共享的。对象资源的控制由控制块来完成。
这种由于控制权的不同,就使得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
工厂函数来创建共享指针,那么堆内存两块空间就会一块申请,而其空间布局就会优化成这样:
由于控制块和对象资源在同一块内存块中,编译器甚至能够将控制块中的指针优化掉。
但是这样也有弊端,那就是对象的生命周期将和控制块一致,而现实中,控制块的生命周期通常比对象的生命周期要长。
在构造 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_ptr
,std::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 Observerstd::shared_ptr
可以自动管理对象的生命周期,每次 std::shared_ptr
的拷贝都会使得引用计数加一,进而延长资源的存活时间。而在实现某些设计模式时,我们可能想拥有行为类似于 std::shared_ptr
,但又不参与资源所有权的智能指针。这种非拥有型的智能指针允许其指向对象被销毁(悬空),并根据观察对象的状态做出不同的反应。简而言之,我们想要一个资源观察者:如果资源仍在,我们就安全地使用它,如果资源已被销毁,则采取其他策略。
C++11 引入的 std::weak_ptr
弱指针就是这样一个资源的观察者:行为上类似 std::shared_ptr
,但又不参与对所有权的管理。std::weak_ptr
和 std::shared_ptr
底层使用的数据结构是相同的,所以 std::weak_ptr
的创建依赖于已经存在的 std::shared_ptr
。
std::weak_ptr
可以由 std::shared_ptr
或 std::weak_ptr
拷贝赋值。在创建 std::weak_ptr
时,管理对象生命周期的引用计数并不会增加,而弱引用计数 (weak count) 会增加(表示有多少个 weak_ptr
在观察资源),而且当 std::shared_ptr
销毁后,std::weak_ptr
仍会指向原先资源的控制块。(控制块会在弱引用计数清零后销毁)
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
最开始就说在实现某些设计模式时,我们会想要一个“资源观察者”:如果资源仍在,我们就安全地使用它,如果资源已被销毁,则采取其他策略。假设我们要设计一个缓存系统,资源以唯一 ID 的方式存储在数据库中。为了让缓存的资源让所有缓存系统的 client 共享,避免在内存中出现多份资源拷贝,所以我们不能用 std::unique_ptr
,因为它具有独占所有权,不支持共享访问。
另外,虽然作为 C++ 的垃圾回收机制的 std::shared_ptr
支持多个引用共享资源,但它的问题在于:只要还有任何一个 shared_ptr
指向该资源,资源就不会被释放。这对于缓存系统来说并不理想,因为我们希望在没有任何用户使用资源时自动释放它,以节省内存。
这就是引入 std::weak_ptr
的意义之一了,通过结合 shared_ptr
和 weak_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;
}
std::shared_ptr
Cyclesweak_ptr
还有一个很重要的应用就是避免 std::shared_ptr
的循环引用问题。假如我们现在有三个对象 A, B 和 C,然后 A 和 C 共享 B 的生命周期:
如果我们需要让 B 中的一个指针指向 A,那么我们需要什么指针?
裸指针是用不成的,因为引入裸指针可能造成一下未定义行为。比如 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
是栈上的临时变量,会随着栈帧销毁而释放。