从 RAII 到 smartPtr:智能指针的原理及实现
RAII 的基本概念
什么是堆
在内存管理的范畴中,我们将程序中能够动态分配内存的区域称为堆 - heap,C/C++ 都提供了操作 heap 的函数或运算符:
-
C 使用
malloc/free
操作 heap -
C++ 使用
new/delete
操作 heap
类似于下面的代码就会在堆上分配内存:
auto vecPtr = new std::vector<int>();
在堆上分配了内存之后的另一个问题就是释放内存,和 Java 的垃圾收集机制不同,C++ 的内存分配和释放都由内存管理器来操作,通常都不使用垃圾收集,我们需要做的只是把 new
出来的内存再通过 delete
释放掉即可,但是事实上真的这么简单吗?
什么是栈
函数在运行过程中调用数据、生成数据时使用的内存区域称为栈 - stack,它和数据结构中的栈类似,都是 LIFO。以 x86 为例,栈的增长方向是由高地址向低地址增长,函数之间调用时,调用函数会将自己的参数压入栈中,同时把自己下一行的指令也压入栈中,再跳转到新的函数并调整栈指针,新的函数在执行完之后会根据栈中保存的调用函数的地址重新回到调用函数未执行的地方继续执行。
由此可见,栈上空间的分配和释放逻辑都十分简单,只要移动栈指针即可,具体分配空间的时候,每个函数分到的属于自己的那一份称为栈帧 - stack frame,即使是有构造函数和析构函数的情况下,C++ 的编译器也会在栈帧的适当位置添加对构造函数和析构函数的调用。这个过程中,就算函数抛出了异常,编译器也会自动调用析构函数,来看一个例子:
class Obj{
public:
Obj(){ puts("Obj()"); }
~Obj(){ puts("~Obj()"); }
};
void func(int n){
Obj obj;
if (n > 0)
throw "func error";
}
int main() {
try{
func(0);
func(1);
}
catch (const char* str){
puts(str);
}
return 0;
}
Output:
Obj()
~Obj()
Obj()
~Obj()
func error
也就是说,func(1)
在将异常抛出之前就已经调用了自身的析构函数,而这点正是 RAII 的基础,也是最重要的部分。
什么是 RAII
RAII - Resource Acquisition Is Initialization,资源获取即初始化,RAII 是一种资源管理方式,它通过栈和析构函数来管理包括堆在内的所有资源,主流的编程语言中只有 C++ 是使用 RAII 来管理资源的,下面来看一个 C++ 工厂方法的简单示例,为了防止发生对象切片的错误(C++ 的值语义特点所带来的的编码陷阱:函数返回类型为父类对象,实际返回的为子类对象),工厂方法需要返回一个父类对象的指针或者是引用:
// C++11 后建议使用强类型枚举
enum class animalType {
cat,
dog,
fox,
...
};
class animal {...};
class cat : public animal {...};
class dog : public animal {...};
class fox : public animal {...};
animal* buyNewAnimal(animalType ani_type){
switch(ani_type) {
case animalType::cat :
return new cat();
case animalType::dog :
return new dog();
case animalType::fox :
return new fox();
}
}
此时返回的是一个 animal
类型的父类指针,指向的是子类对象,问题来了,buyNewAnimal
函数返回 animal
指针后便退出了,堆上已经开辟出空间 animal
子类的对应空间,如何才能保证这一部分内存不泄漏?
智能指针的基本概念
C 和 C++ 的老程序员都曾大量接触过裸指针,都曾一边享受着指针带来的便利一边不停地给自己挖坑埋坑,在介绍智能指针之前,先看看使用指针的时候常见的坑有哪些:
-
忘记
delete/free
导致内存泄露 -
同一个指针释放多次,程序崩溃
-
逻辑 bug,写了
delete/free
结果没有执行到 -
delete/free
之前抛出了异常
前面已经介绍过了,C++ 没有自动内存回收机制,new
出来的必须要自己 delete
掉,智能指针的引入,让程序员可以不再关注资源的释放,它能够保证程序无论正常或异常,在到期的时候都能通过 RAII 机制成功回收。
来看上一节中 buyNewAnimal
函数返回的 animal
指针,只需要将这个返回值放入一个本地变量中,确保这个变量析构的时候会删除上面的对象即可:
class smartAnimalPtr {
public:
explicit smartAnimalPtr(animal* animalPtr) : animalPtr_(animalPtr){}
// delete nullptr is legal operation
~smartAnimalPtr(){
delete animalPtr_;
}
animal* getPtr() const {
return animalPtr_;
}
private:
animal* animalPtr_{nullptr};
};
void func(){
smartAnimalPtr newAnimalPtr(buyNewAnimal(dog));
}
func
调用的 buyNewAnimal
函数返回值被 newAnimalPtr
接管,使用者可以直接调用 newAnimalPtr.getPtr()
使用原指针,当 func
函数结束时,newAnimalPtr
析构,animalPtr_
所指向的区域被释放,整个过程不需要手动调用 delete
,而是通过 RAII 巧妙地交给编译器处理了。
除了 delete
之外,在本地变量的析构函数中还可以执行以下操作:
- 释放同步锁,如下面的多线程累加范例:
std::mutex sum_lock;
void add(int& sum, int& num){
while(true){
// 结束一轮 while 后 sum_lock 自动释放
std::lock_guard<std::mutex> lock(sum_lock);
if (num < 100) {
num += 1;
sum += num;
}
else {
break;
}
}
}
int main(){
int sum = 0, num = 0;
std::vector<std::thread> tdVec;
for(int i = 0; i < 10; ++i){
std::thread td = std::thread(add, std::ref(sum), std::ref(num));
tdVec.emplace_back(std::move(td));
}
// std::mem_fn can generate an object for pointers to members as well as ref and pointers to an object
std::for_each(tdVec.begin(), tdVec.end(), std::mem_fn(&std::thread::join));
std::cout << sum << std::endl;
return 0;
}
- 关闭文件,如
fstream
析构时会调用close
以防止流对象销毁后还与打开的文件相关联
上面实现的 smartAnimalPtr
已经可以算是一个简单的智能指针啦。
auto_ptr
我们将 smartAnimalPtr
模板化,并添加一些常用的指针操作:
template <typename T>
class smartAutoPtr {
public:
explicit smartAutoPtr(T* ptr) : ptr_(ptr) {}
~smartAutoPtr() { delete ptr_; }
T* getPtr() const { return ptr_; }
T* operator->() const { return ptr_; }
T& operator*() const { return *ptr_; }
// 重载 bool 转换运算符
operator bool() const { return ptr_; }
private:
T* ptr_{nullptr};
};
上面的代码存在一个很严重的问题,当 smartAutoPtr
拷贝构造或者被赋值时,因为有两个指针指向同一片区域,RAII 会让这片区域释放两次!解决这个问题的第一种方法、也是最简单的方法是禁用这两个函数:
smartAutoPtr(const smartAutoPtr<T>&) = delete;
smartAutoPtr<T>& operator=(const smartAutoPtr<T>&) = delete;
第二种方法,我们可以在拷贝构造和赋值时转移指针的所有权,而这也正是 auto_ptr
的处理方式:
template <typename T>
class smartAutoPtr {
public:
explicit smartAutoPtr(T* ptr) : ptr_(ptr) {}
smartAutoPtr(const smartAutoPtr& _Right) {
ptr_ = _Right.release();
}
// copy and swap
// _Right --> smartAutoPtr(_Right) --> _New
smartAutoPtr& operator=(const smartAutoPtr& _Right) {
smartAutoPtr(_Right).reset(*this);
return *this;
}
~smartAutoPtr() { delete ptr_; }
T* getPtr() const { return ptr_; }
T* operator->() const { return ptr_; }
T& operator*() const { return *ptr_; }
operator bool() const { return ptr_; }
// 剥夺原 smartAutoPtr 对指针的所有权给新 smartAutoPtr
T* release() {
T* _Tmp = ptr_;
ptr_ = nullptr;
return _Tmp;
}
void reset(smartAutoPtr& _New) {
using std::swap;
swap(ptr_, _New.ptr_);
}
private:
T* ptr_{nullptr};
};
赋值运算符的重载利用了 copy and swap 技术,即先通过拷贝构造创建一个新的对象,再交换它们的指针,这样能够保证强安全性,构造是否成功完全不会破坏赋值运算符两边的数据。
由上面的代码不难看出,auto_ptr
在拷贝构造和赋值运算时都会完全剥夺原对象对指针的所有权,也就是说除了最后一个 auto_ptr
,其余所有的 auto_ptr
都变成了 nullptr
,全部失效了,这样会带来几个问题:
-
STL 对容器类型的要求是要有值语义,即可以复制和赋值。
auto_ptr
的复制和赋值经过了release
和reset
的处理,因此auto_ptr
对象不能作为 STL 的容器元素。 -
将
auto_ptr
作为函数参数按值传递时,函数会在其作用域中生成该auto_ptr
的拷贝,此时的指针所有权已经转移给了这个临时对象,当函数退出时,该临时对象析构,原auto_ptr
所指向的对象也被删除了,如果不得不使用auto_ptr
作为函数参数时,最好使用const auto_ptr&
的方式。
值得注意的是,auto_ptr
已经从 C++17 标准中删除了。
unique_ptr
unique_ptr
与 auto_ptr
在特性上相差不大,同一时刻只能有唯一的一个 unique_ptr
指向给定的对象,unique_ptr
在处理智能指针拷贝构造和赋值时发生的浅拷贝问题上采用了 smartAutoPtr
所使用的方法一,即直接将拷贝构造和赋值运算禁用,此外,unique_ptr
还引入了移动构造,在 C++11 之前,如果想要将源对象的状态转移到目标对象只能通过拷贝构造,C++11 开始,我们不再需要复制对象,只需要移动对象即可,也就是把源对象的资源控制权转交给目标对象,这个过程中不再需要多余的复制操作。我们将上面的 auto_ptr
稍微修改可以得到一个简化版的 unique_ptr
:
template <typename T>
class smartUniquePtr {
public:
explicit smartUniquePtr(T* ptr) : ptr_(ptr) {}
// 移动构造函数不会在类中默认生成
smartUniquePtr(smartUniquePtr&& _Right) {
ptr_ = _Right.release();
}
// 类型转换移动构造
template <typename U>
smartUniquePtr(smartUniquePtr<U>&& _Right) {
ptr_ = _Right.release();
}
// necessary
smartUniquePtr(const smartUniquePtr&) = delete;
template <typename U>
smartUniquePtr(const smartUniquePtr<U>&) = delete;
// new _Right --> *this
smartUniquePtr& operator=(smartUniquePtr _Right) {
_Right.reset(*this);
return *this;
}
~smartUniquePtr() { delete ptr_; }
T* getPtr() const { return ptr_; }
T* operator->() const { return ptr_; }
T& operator*() const { return *ptr_; }
operator bool() const { return ptr_; }
T* release() {
T* _Tmp = ptr_;
ptr_ = nullptr;
return _Tmp;
}
void reset(smartUniquePtr& _New) {
using std::swap;
swap(ptr_, _New.ptr_);
}
private:
T* ptr_{nullptr};
};
上面的代码需要注意,在定义了移动构造函数的情况下,如果没有提供拷贝构造函数,会自动禁用拷贝构造函数。
但是,编译器并不会把 smartUniquePtr(smartUniquePtr<U>&& _Right) {}
看作是移动构造,换句话说,编译器不会把所有的模板函数看作构造函数,如果想消除代码重复,仍然需要将拷贝构造函数手动禁用。
再看 smartUniquePtr
的使用:
// buyNewAnimal 返回的是一个临时变量,调用移动构造函数
smartUniquePtr<animal> ptr1{ buyNewAnimal(animalType::dog) };
smartUniquePtr<animal> ptr2{ nullptr };
// ptr1 是一个左值,需要转换成右值
// ptr1 --> temp rv obj --> ptr3
smartUniquePtr<animal> ptr3 = std::move(ptr1);
smartUniquePtr<animal> ptr4{ std::move(ptr3) };
unique_ptr
除了能够支持管理堆上分配的内存,还能够通过移动语义使 unique_ptr
对象与容器兼容。
但 unique_ptr
仍然有一些不足,它仍然无法避免重复释放的问题,使用移动语义之后的源对象也仍然失去了对原指针的所有权,无法再次使用。
要避免上述这些情况,最好的办法是使用带有引用计数功能的智能指针。
shared_ptr
在 unique_ptr
中,一个对象只能被一个 unique_ptr
拥有,这在大部分场合是无法满足要求的,更常见的是多个智能指针同时拥有一个对象,只有当所有的指针都失效了,才会删除该对象,这样的智能指针就是 shared_ptr
。
shared_ptr
的底层通过引用计数来进行空间管理,每当有一个新的指针指向这块空间时,引用计数加一,反之减一,直到引用计数为零时才释放空间。
来看一下 shared_ptr
的简单实现,和 unique_ptr
的不同之处在于它需要实现共享的引用计数,因此我们在堆上 new
一个 ref_count
出来,并在 shared_ptr
中保存指向它的指针,完整的操作需要包含 add
、reduce
和 get
三种行为:
class ref_count {
public:
ref_count() noexcept : cnt_(1) {}
void add() noexcept { ++cnt_; }
long reduce() noexcept { return --cnt_; }
long get() const noexcept { return cnt_; }
private:
long cnt_;
};
template <typename T>
class smartSharedPtr {
public:
template <typename U>
friend class smartSharedPtr;
explicit smartSharedPtr(T* ptr = nullptr) : ptr_(ptr) {
if (ptr_) {
refptr_ = new ref_count();
}
}
// copy constructor ref_count add
smartSharedPtr(const smartShared& _Right) {
ptr_ = _Right.ptr_;
if (ptr_) {
_Right.refptr_->add();
refptr_ = _Right.refptr;
}
}
// template copy constructor
template <typename U>
smartSharedPtr(const smartSharedPtr<U>& _Right) noexcept {
ptr_ = _Right.ptr_;
if (ptr_) {
_Right.refptr_->add();
refptr_ = _Right.refptr;
}
}
// template move constructor
template <typename U>
smartSharedPtr(smartSharedPtr<U>&& _Right) noexcept {
ptr_ = _Right.ptr_;
if (ptr_) {
refptr_ = _Right.refptr_;
_Right.ptr_ = nullptr;
}
}
// overload assignment operator
smartSharedPtr& operator=(smartSharedPtr _Right) noexcept {
_Right.swap(*this);
return *this;
}
~smartSharedPtr() {
// 每次析构时都需要将 ref_count 减一
if (ptr_ && !refptr_->reduce()) {
delete ptr_;
delete ref_count_;
}
}
// get ref_count
long getRefCnt() const {
if (ptr_) {
return refptr_->get();
}
else {
return 0;
}
}
T* getptr() const noexcept { return ptr_; }
T& operator*() const noexcept { return *ptr_; }
T* operator->() const noexcept { return ptr_; }
operator bool() const noexcept { return ptr_; }
void swap(smartSharedPtr& _New) noexcept {
using std::swap;
swap(ptr_, _New.ptr_);
swap(refptr_, _New.refptr_);
}
private:
T* ptr_;
ref_count* refptr_;
};
template <T>
void swap(smartSharedPtr<T>& _Left, smartSharedPtr<T>& _Right) noexcept {
_Left.swap(_Right);
}
在上面的代码中,需要注意以下几点:
-
为了让编译器优化代码,需要为移动构造函数添加
noexcept
声明 -
refptr_
为每个类的私有成员,是不能在smartSharedPtr
对象之间共享的,需要添加友元类的声明 -
按照现代 C++ 标准需要设计支持移动的对象,即对象不仅要有
swap
成员函数与另一个对象交换,在其所属名空间下还需要需要有一个全局swap
函数提供给其他对象使用 -
智能指针还需要实现类型转换的函数模板,这里给出
dynamic_cast
,其他实现与此类似:
// for pointer type conversion
template <typename U>
smartSharedPtr(const smartSharedPtr<U>& _Right, T* ptr) {
ptr_ = ptr;
if (ptr_) {
_Right.refptr_->add();
refptr_ = _Right.refptr_;
}
}
// 取出 _Right 的 ptr_ 转换成 T 类型后将 ref_count++ 并包装返回
template <typename T, typename U>
smartSharedPtr<T> dynamic_pointer_cast(const smartSharedPtr<U>& _Right) {
T* _Tmp = dynamic_cast<T*>(_Right.getptr());
return smartSharedPtr<T>(_Right, _Tmp);
}
weak_ptr
shared_ptr
通常被称为强智能指针,而 weak_ptr
被称为弱智能指针,介绍 weak_ptr
之前先看下面的代码:
class B;
class A {
public:
A () {}
~A () {}
shared_ptr<B> ptrb;
};
class B {
public:
B () {}
~B () {}
shared_ptr<A> ptra;
};
int main() {
// ref_count of A is 1
shared_ptr<A> ptra(new A());
// ref_count of B is 1
shared_ptr<B> ptrb(new B());
// ref_count of B is 2
ptra->ptrb = ptrb;
// ref_count of A is 2
ptrb->ptra = ptra;
std::cout << ptra.use_count() << ptrb.use_count() << std::endl;
return 0;
}
main
结束时,两个对象的引用计数分别减到 1,因为不等于 0,不满足对象的空间释放条件,最终导致内存泄露,这个问题被称为 shared_ptr
的循环引用问题。
那么怎么解决这个问题呢?当然是通过 weak_ptr
,weak_ptr
对 shared_ptr
管理的对象存在非拥有性引用,换句话说,它表达的是一种临时所有权 - 如果某个对象只有存在的时候才需要被访问,而且随时可能会被删除导致指针失效时,就使用 weak_ptr
跟踪该对象,当 weak_ptr
需要使用该对象时,需要将其升级为 shared_ptr
,如果原 shared_ptr
销毁,这个对象的生命周期则会延长到这个升级的 shared_ptr
被销毁为止。
weak_ptr
的特点可以总结为下面几点:
-
weak_ptr
中没有提供一般的指针操作,如果要访问资源必须通过lock
将其升级为shared_ptr
-
定义对象的时候使用
shared_ptr
,引用对象的时候使用weak_ptr
-
weak_ptr
不会改变对象的引用计数,它只是一个观察者的角色
上面的代码使用 weak_ptr
可以修改为:
class B;
class A {
public:
A () {}
~A () {}
weak_ptr<B> ptrb;
};
class B {
public:
B () {}
~B () {}
weak_ptr<A> ptra;
};
int main() {
// ref_count of A is 1
shared_ptr<A> ptra(new A());
// ref_count of B is 1
shared_ptr<B> ptrb(new B());
// ref_count of B is 1
ptra->ptrb = ptrb;
// ref_count of A is 1
ptrb->ptra = ptra;
std::cout << ptra.use_count() << ptrb.use_count() << std::endl;
return 0;
}
智能指针的使用
Deleter
unique_ptr
的声明为:std::unique_ptr<T, Deleter>::unique_ptr
前面已经说过,智能指针在析构的时候除了 delete 堆上空间,还可以关闭文件、释放同步锁等,unique_ptr
可以让我们自定义指针释放资源的方式,方法是传入一个函数对象,我们用 lambda 表达式来简单实现如下:
int main() {
std::unique_ptr<FILE, std::function<void(FILE*)>> openFile(fopen("config.yml", "w"), [](FILE* fp)->void{ fclose(fp); });
return 0;
}
shared_ptr
的 Deleter
的使用方法与 unique_ptr
稍有不同:
struct foo {
foo() {}
~foo() {}
};
struct deleter {
void operator()(func* fp) const {
delete fp;
}
};
int main() {
std::shared_ptr<foo> sp(new foo, deleter());
return 0;
}
多线程场景下的智能指针
多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
shared_ptr/weak_ptr
的线程安全可以分为两个部分讨论:
-
shared_ptr/weak_ptr
管理的对象是否是线程安全的 -
shared_ptr/weak_ptr
对象本身是否是线程安全的
第一个问题,shared_ptr/weak_ptr
能够实现多线程下其管理的对象是线程安全的,多线程下,一个常见的错误是当一个线程已经将堆上的对象析构了,此时另一个线程去访问已经析构了的对象,产生未定义行为。
muduo 网络库中的 Observable 类给出了一个解决方法:通过 weak_ptr 探查对象的生死:
class Observable {
public:
void register_(const weak_ptr<Observer> x);
void notifyObservers();
private:
mutable MutexLock mutex_;
std::vector<weak_ptr<Observer>> observers_;
typedef std::vector<weak_ptr<Observer>>::iterator Iterator;
};
void Observable::notifyObservers() {
MutexLockGuard lock(mutex_);
Iterator it = observers_.begin();
while (it != observers_.end()) {
// 遍历 vector 依次尝试 promote weak_ptr
// lock 是线程安全的
shared_ptr<Observer> obj(it->lock());
if (obj) {
obj->update();
++it;
}
else {
// promote 失败说明对象已经销毁,从 vector 中删除 weak_ptr
it = observers_.erase(it);
}
}
}
第二个问题,shared_ptr
本身并不是线程安全的,它的引用计数是安全无锁的,但是对象的读写不是,因为 shared_ptr
有两个数据成员,读写操作不能原子化。
如果要通过多个线程读写同一个 shared_ptr
对象,需要加锁。
继续看一个 muduo 网络库中的例子:
void read() {
shared_ptr<foo> localPtr;
{
MutexLockGuard lock(mutex);
localPtr = globalPtr;
}
doit(localPtr);
}
void write() {
shared_ptr<foo> newPtr(new foo);
{
MutexLockGuard lock(mutex);
globalPtr = newPtr;
}
doit(newPtr);
}
参考文献: