智能指针


持续更新,补充C++新增feature,目录结构遵循《C++ Primer》

动态内存

  • 静态内存:保存局部static对象,类static数据成员,以及定义在任何函数之外的变量
  • 栈内存:保存定在函数内非static对象,
  • 堆内存:动态分配与回收

对于静态内存和栈内存中的对象,编译器负责销毁,对栈对象,栈被回收后即被销毁,static对象在使用之前分配,在程序结束时销毁。对于堆上的对象,由程序来控制,当对象不再使用时,需要代码显式将其删除。

对于动态内存的管理,C++ 11中引入了三类智能指针,定义在<memory>头文件

  • shared_ptr: 允许多个指针指向同一对象
  • unique_ptr: “独占”所指向的对象
  • weak_ptr: 弱引用,指向shared_ptr所管理的对象

当然也可以直接使用newdelete来直接控制内存

new & delete

new在对象上开辟一个具有某类型大小的空间,并且可以对该块内存进行初始化,默认情况下,动态分配的对象时默认初始化的,即调用类的默认构造函数来初始化。

如果在执行new的时候,内存耗尽,则系统会抛出std::bad_alloc的异常,也可以向new传参控制其行为

//pi指向一个动态分配的,未初始化的无名对象
int *p1 = new int; 
//定义并初始化
int *p2(new int(100));
string *p2 = new string(10,'9'); 
//如果内存不足,不抛异常,返回一个空指针
int *p3 = new (nothrow) int; 

对于delete,在实际应用中也会有很多问题,比如下面的情况:

int *p(new int(42));
auto q = p;
delete p;
p = nullptr;

p被delete后,其内存被释放掉,对于q来说,却不知道,仍会继续访问,从而出现异常。类似的问题还有指针的double free,以及将指针作为函数的返回值返回后,使用者忘记delete该指针(或许根本不清楚这个指针怎么来的),而造成内存泄漏

unique_ptr

当需要使用智能指针时,可以首先考虑使用unique_ptr,使用它几乎和使用原始指针一样,并且性能上几乎没有损耗。不同的是一个unique_ptr独占一个资源的控制权(exclusive ownership),指针,你不能用两个unique_ptr来控制同一份资源,即某个时刻只能有一个unique_ptr指向一个给定的对象。使用unique_ptr另一个方便之处是,你不需要担心资源的释放,当unique_ptr对象销毁时,被管理的资源也将自动被销毁。

  • 创建

创建unique_ptr必须采用直接初始化的形式,并且不支持拷贝与赋值操作

//创建一个int指针
unique_ptr<int> p1(new int(42));
//创建一个string[]数组
unique_ptr<string[]> ps(new string[3]);
//不支持拷贝构造和赋值操作
unique_ptr<int> p2(p1); //wrong;
auto p3 = p1; //wrong
  • make_unique

C++ 14引入了make_unique函数,可以用来创建unique_ptr,官方文档也推荐使用这种方式以取代new的方式,但是这种方式也有局限性,其中之一是它不支持自定义的delete函数(后面会介绍)

//使用new
auto ptr = unique_ptr<widget> (new widget(params));
//使用make
auto ptr = make_unique<widget>(params);
  • 控制权转移

一个unique_ptr可以通过release()方法将对某个内置指针的控制权转移给另一个unique_ptr对象,

unique_ptr<string> p1(new string("abc")); //交出控制权后,p1=nullptr
unique_ptr<string> p2(p1.release());
unique_ptr<string> p3(p2.release());
//p1 == nullptr & p2 == nullptr

注意,release操作是切断了unique_ptr和其内部指针之间的联系,release返回的指针通常用来初始化另一个智能指针

p2.release(); //wrong,p2不会释放内存,却丢失了内置指针
auto p = p2.release(); //正确,但是要手动delete p
  • 作为返回值

不能拷贝unique_ptr的规则有一个例外,我们可以拷贝或赋值一个将要销毁的unique_ptr

unique_ptr<int> clone(int p){
  return unique_ptr<int>(new int(p));
}
  • 释放资源

unique_ptr支持自定义析构函数,其析构函数必须将其类型用于构造unique_ptr

//p指向一个类型为objT的对象,并使用一个类型为delT的函数释放资源
unique_ptr<objT, delT> p (new objT, fcn);

void f(destination &d){
  connnection c = connect(&d);
  unique_ptr<connection, decltype(end_connection)* >p(&c,end_connection); 
}

注意上述代码中在decltype(end_connection)后必须加一个*表示函数指针。

shared_ptr

最安全的分配和使用动态内存的方法是使用make_shared<T>函数,头文件定义在<memory>中,make_shared参数和待构造对象的构造函数参数相同,make_shared会将其参数透传给类的构造函数。

//指向一个值为32的int型指针r
shared_ptr<int> p3 = make_shared<int>(42);
cout<<*p3 <<endl; //42
//创建指向值为“ccc”的字符串指针
shared_ptr<string> p4= make_shared<string>(3,'c');
//使用auto
auto p5 = make_shared<vector<string>>();

除了使用make_shared以外,还可以使用new来创建指针,当时用new时,必须使用其explicit的构造函数,而不能通过隐式的类型转换

// /shared_ptr<int> p1 = new int(10); //wrong!
shared_ptr<int> p1(new int(10)); //correct, 使用直接初始化形式
shared_ptr<int> clone(int p){
  //return new int(p); //worng
  return shared_ptr<int>(new int(p)); //correct
}

这里需要注意一种情况,不要混用普通指针和shared_ptr,在创建shared_ptr时就new对象或者使用make_shared,考虑下面情况

shared_ptr<int> p = new(int(10));
int *ptr = p.get(); //返回p管理的指针
{
  shared_ptr<int>q (ptr);
}
//q被释放,导致`ptr`指向的内存被释放,此时p并不知情
int foo = *p; //crash

当使用一个已经初始化的指针赋给q时,会导pq管理同一片内存,并且这种情况下,且各自的引用计数均为1,二者中任何一个被销毁会导致该内存被释放,会有野指针访问或者double free的风险

避免将一个已经初始化的指针来构造`shared_ptr`对象

  • 拷贝和赋值

当对shared_ptr对象进行拷贝时,会影响到其引用计数,每个shared_ptr都关联一个引用计数,来记录有多少个其它的shared_ptr指向相同的对象。当发生拷贝时,引用计数+1,拷贝可以发生在构造函数,函数传参以及函数返回值。

auto p = make_shared<int>(20); //p所指向对象的引用计数 = 1
//可以使用unique()查看被管理的资源是否是自己独有
cout<<p.unique()<<endl; //true;
auto q(p); //q执行拷贝构造,引用计数+1
cout<<p.unique()<<endl; //false, 因为p和q共享该资源
cout<<q.user_count()<<endl; //引用计数为2

当给一个shared_ptr对象赋一个新值或是该对象被销毁时,其管理的引用计数会-1。一旦引用计数为0,则shared_ptr会自动释放对象内存。

auto r = make_shared<int>(19);
r = q; 
//q指向对象的引用计数+1
//r指向对象的引用计数-1
//r的引用计数为0,自动释放内存

使用shared_ptr的一个好处是不需要惦记何时去释放对象,一个例子是用shared_ptr对象做返回值

shared_ptr<Foo> factory(T arg){
  return mark_shared<Foo>(arg);
}

按照以往的经验,使用这种方式来返回一直指针是有风险的,原因在于使用者很有可能不知道怎么处理这个指针;如果使用shared_ptr则可以让使用者无需关心这个问题

void use_factory(T arg){
  auto p = factor(arg);
  return p;
}
//p离开作用于,内存自动释放

再看一个函数传参的例子

void process(shared_ptr<int> ptr){
  //ptr被copy,引用计数+1
  do_some_thing();
  //ptr被释放,引用计数-1
}
shared_ptr<int> p(new int(42));
process(p);
int i=*p;
  • 使用reset

reset的作用是使shared_ptr指针指向一个新的对象:

auto p6 = make_shared<DynamicArray>(10);
p6.reset(new DynamicArray(11));

这里需要注意一个问题,如果p6是当前内存对象的唯一持有者,那么当p6被reset时,内存对象被释放,但是如果p6不是唯一持有者的时,如果p6想要修改该内存对象,右不影响其它持有者,则需要单独拷贝一份内存

if(!p6.unique()){
  p6.reset(new string(*p));
}
*p += newVal;
  • 释放资源

默认情况下当shared_ptr对象析构时,会调用delete来释放内部管理的对象的内存。shared_ptr也提供了自定义释放的方法,可以在构造时,传入一个lambda表达式,函数指针或者Functor来自定义析构逻辑

//使用lambda表达式清理int数组
shared_ptr<int> sp(new int[10],[](*p){
  delete []p;
})
  • API整理
shared_ptr<T> p(q) q是内置指针
shared_ptr<T> p(u) u是unique_ptr,p从u那里接管了对象的所有权,将u置为空
shared_ptr<T> p(q,d) q是内置指针,d是自定义析构函数
shared_ptr<T> p(p2,d) p是shared_ptr p2的拷贝,d是自定义析构函数
reset 若p是唯一指向其对象的shared_ptr, reset会释放此对象
reset(q) p指向内置指针q

weak_ptr

weak_ptr是一种不控制所指对象生命周期的智能指针,它指向由一个shared_ptr管理的对象,将weak_ptr绑定到shared_ptr不会影响改变后者的引用计数。一旦最后一个指向对象的shared_ptr被销毁,对象内存就会被释放,即是有weak_ptr存在,对象依旧会被释放。

创建一个weak_ptr对象,需要传入shared_ptr。使用weak_ptr时,不能直接访问其内置指针,因为对象可能已经被释放,需要先使用lock检查对象是否存在,进而访问

auto p = make_shared<int>(42);
weak_ptr<int> wp(p);
if(shared_ptr<int> np = wp.lock()){ //lock返回一个shared_ptr对象
  cout<<*np<<endl;
}

动态数组

  • new和数组
int *pia = new int[10]; //10个未初始化的int
int *pia2 = new int[10](); //10个初值为0的int
int *pia3 = new int[10] {1,23,3,5,6,7,7,8,9};
int *psa = new string[10]; //10个空string
int *psa2 = new string[10](); //10个空string

//delete
delete [] pia;

在销毁数组中的对象时,析构顺序按照逆序进行,即最后一个元素先被销毁

  • 使用智能指针

new对应,C++ 11中刻意使用std::unique_ptr<int[]>类型来包装动态数组

unique<int[]> up(new int[10]);
up.release(); //自动delete []销毁其指针

由于unique_ptr指向一个数组,因此没有点或者箭头成员运算符,如果想要遍历数组,可以还是用for循环

for(int i=0;i<10;i++){
  up[i] = i; //unique_ptr支持下标运算
}

如果想要使用shared_ptr则需要为其指定析构函数

shared_ptr<int> sp(new int[10], [](int *p){ delete [] p;});

另外,shared_ptr并不支持下标运算,因此想要遍历数组需要通过get()得到指针周再遍历,这不是一种好的选择。

使用allocator

new有一些灵活上的局限性,其中一方面是它将内存分配和对象构造结合在了一起,类似的,delete也将对象析构和内存释放结合在了一起。对于单个对象,这种策略是可行的,但是当分配一大块内存时,我们希望将内存的分配和对象的构造进行分离。C++ 标准库中的allocator提供了可以帮助我们完成这个工作

allocator<string> alloc;
auto const p = alloc.allocate(n);

Resources