C++ 智能指针

为什么会出现智能指针

什么是智能,其实就是程序自动为我们做一些事情。那么智能指针到底为我们自动地做了 什么了呢?

我们先从指针使用说起,我们知道系统的有些资源产生之后是需要释放的,产生比较容 易,但是释放却往往会出问题,因为事物产生是很清晰的,但是释放则不那么清晰:就 像开门只要有钥匙就没问题,因为不开门就进不了屋子,但是关门则不影响你出门,事 实上我们也经常忘记了关门。在程序中类似的资源有很多,比如:

  • 文件操作(fopen/fclose)
  • 内存操作(new/delete 或 malloc/free)
  • 资源锁操作(lock/unlock)

这些操作,我们往往前者不会忘记,后者在释放的时候往往会忘记。或者即使没有忘记, 但是程序出现异常,中途退出了,也不会执行后面的释放操作。因此可以总结一下在使用 指针的时候不会进行资源释放的原因:

  • 会忘记 delete 你之前 new 的指针;
  • 异常在 delete 之前出现,无法执行 delete.

如何避免这样的情况呢? 如果有一种东西可以在退出之后自动释放那就好了,那就完全不 需要我们去担心忘记或者异常导致资源不能释放的问题了。显然C++中有这样的特性可以 使用,这就是 RAII 的思想。

资源获取即初始化 RAII(Resource Acquisition Is Initialization)

1984-1989年期间,比雅尼·斯特劳斯特鲁普和安德鲁·柯尼希在设计C++异常时,为解决 资源管理时的异常安全性而使用了该用法,后来比雅尼·斯特劳斯特鲁普将其称为RAII。

RAII要求, 资源的有效期持有资源的对象的生命期 严格绑定,即由对象的构 造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只 要对象能正确地析构,就不会出现资源泄露问题。

我们要理解资源的生命期(生和死,生的时候初始化,死的时候销毁资源),要知道资源 在什么时候产生,什么时候死亡(销毁),否则也会用错。

基于此,我们就可以把这些内存操作,锁操作,文件操作等用类的形式封装起来,类就 有 RAII 的特点,他可以在析构函数中释放资源, 避免因为上面提到的原因导致资源没 有被释放。智能指针和智能锁就是使用这种原理的。

智能指针

所谓智能指针就是智能地管理指针所指向的动态资源的释放,也就是说智能指针其实是 对一个已存在的对象的指针的管理。

当然,如果智能指针只是构造函数中初始化,析构函数中销毁那就简单了。因为指针还 有一些复制,赋值操作,这就会引入一些其他的问题,也就 不止一种智能指针

我们得明确智能指针维护的是指针对象。因为要考虑各种类型的数据,因此他是以 模 板 的形式实现的。结合上面的内容,共享指针应该具备一下这些考虑:

  • 拥有RAII的机制来管理指针。对于编译器来说,智能指针实际上是一个栈对象,并非 指针类型,在栈对象生命期即将结束时,智能指针通过析构函数释放它管理的堆内存。
  • 有指针的基本功能。所有智能指针都重载了“*”和“->”操作符,让它可以直接返回对象 的引用以及能用->去操作其指向的对象。若要访问智能指针原来的方法则使用“·”操作 符。
  • 还需要考虑拷贝问题。

智能指针的演变和实现

下面我们来看看各种智能指针的演变过程和实现方法。

auto_ptr

这是98版本引入的智能指针。本质是管理权转移, 当共享指针在进行复制的时候,会把 对象交个新的共享指针,但是旧的共享指针会被置为NULL,这样就不会导致在虚构函数 中对同一对象进行多次销毁。

#include <iostream>
#include <string>
#include <memory>

// 设计一个类做测试.
class CObj {
 public:
  CObj(std::string name) {
    name_ = name;
  }
  ~CObj(){}
  std::string name_;
};

// auto_ptr如何实现?
template <class T>
class SmartPtr {
 public:
  // 构造函数初始化指针.
  SmartPtr(T *ptr) : ptr_(ptr) {}
  // 复制构造函数
  SmartPtr(SmartPtr<T> &p) {
    ptr_ = p.ptr_;
    p.ptr_ = NULL;
  }
  // 复制分配构造函数
  SmartPtr<T> &operator=(SmartPtr<T> &p) {
    if (this != &p) {
      delete ptr_;
      ptr_ = p.ptr_;
      p.ptr_ = NULL;
    }
    return *this;
  }
  // 析构函数销毁指针指向的对象.
  ~SmartPtr() {
    delete ptr_;
  }
  // 操作符返回指针指向的内容的引用.
  T &operator*() {
    return *ptr_;
  }
  // 可以通过->访问成员, 本来使用这里的时候通过->得到只是指针,那么要访问成员就应该是->->,这样可读性太差,编译器就将它优化成->了。
  T *operator->() {
    return ptr_;
  }
 protected:
  T *ptr_;
};

int main() {
  SmartPtr<CObj> ap1(new CObj("jack"));
  std::cout << "ap1 name: " << ap1->name_ << std::endl;  // ok. 通过->访问成员.

  SmartPtr<int> ap2(new int(10));
  std::cout << "before ap2 value: " << *ap2 << std::endl;  // ok. 通过*获取对象内容.
  SmartPtr<int> ap3(ap2);
  std::cout << "after ap3 value: " << *ap3 << std::endl;  // ok. 经过复制构造函
  // 数, 可以访问.
  std::cout << "after ap2 value: " << *ap2 << std::endl;  // error: ap2之前对象
  // 的管理权已经交给了
  // ap3, ap2是空指针,
  // 不能访问.
  return 0;
}

根据前面说的,我们知道在智能指针生命期结束才会释放资源,那么上面的例子中ap2在 将对象管理权交给ap3之后依然还没有结束生命期,这时候如果我们再去使用ap2就会出 问题,因为他已经将管理权交出去了。这就是auto_ptr的缺陷,所以后面有了其他的智 能指针就不建议使用auto_ptr,就算使用了也不要使用 "=" 和拷贝构造,否则交出管理 权的智能指针成了一个僵尸就不好管理了。

unique_ptr (scoped_ptr)

鉴于auto_ptr的缺陷,c++标准又迟迟没有改良这个指针,这期间一些大牛就在一个 boost社区造了一个scoped_ptr指针,知道c++11的时候,c++标准才推出unique_ptr。这 两者思想是一样的,就是暴力的防止用户调用拷贝构造和赋值运算符“=”进行夺权管理。 它直接将拷贝构造和“=”运算符重载函数给封装起来,以此防止用户来调用或恶意的去实 现它。

#include <iostream>
#include <string>
#include <memory>

// 设计一个类做测试.
class CObj {
 public:
  CObj(std::string name) {
    name_ = name;
  }
  ~CObj(){}
  std::string name_;
};

// unique_ptr/scoped_ptr如何实现?
template <class T>
class SmartPtr {
 public:
  // 构造函数初始化指针.
  SmartPtr(T *ptr) : ptr_(ptr) {}
  // 析构函数销毁指针指向的对象.
  ~SmartPtr() {
    delete ptr_;
  }
  // 操作符返回指针指向的内容的引用.
  T &operator*() {
    return *ptr_;
  }
  // 可以通过->访问成员, 本来使用这里的时候通过->得到只是指针,那么要访问成员就应该是->->,这样可读性太差,编译器就将它优化成->了。
  T *operator->() {
    return ptr_;
  }
 protected:
  T *ptr_;
  // 将复制函数和拷贝构造保护起来。
  SmartPtr(SmartPtr<T> &p);
  SmartPtr<T> &operator=(SmartPtr<T> &p);
};

int main() {
  SmartPtr<CObj> ap0(new CObj("jack"));
  SmartPtr<CObj> ap1 = ap0; // error,不能这样赋值.

  SmartPtr<int> ap2(new int(10));
  SmartPtr<int> ap3(ap2);   // error, 不能这样拷贝.
  return 0;
}

现在我们发现ap1不能使用=来拷贝,ap3也不能通过拷贝构造来拷贝。这样避免了管理权 交出导致的问题,也就是让scoped_ptr/unique_ptr独享了管理权,只要初始化之后就不 能被通过 任何复制将管理权交给别人 。虽然避免了这个问题,但是很多时候我们还 是无可避免的要将对象进行拷贝,怎么处理呢? 这就是下面的共享指针shared_ptr。

shared_ptr

这也是c++11才有的,在boost中名字也是一样的。他为了能够更好的执行拷贝等操作, 引入了一个计数器,这个计数器在共享指针初始化的时候为1,后面每次被拷贝都会计数 器+1,当这个共享指针过了生命期之后将计数器-1,当最后一个共享指针退出时,如果 检测到计数器为0,那么就释放资源。

#include <iostream>
#include <string>
#include <memory>

// 设计一个类做测试.
class CObj {
 public:
  CObj(std::string name) {
    name_ = name;
  }
  ~CObj() {}
  std::string name_;
};

// shared_ptr如何实现?
template <class T>
class SmartPtr {
 public:
  // 构造函数初始化指针.
  SmartPtr(T *ptr)
      : ptr_(ptr)
      , count_(new int(1)) {
    std::cout << "init count: " << *count_ << std::endl;
  }
  // 复制构造函数
  SmartPtr(SmartPtr<T> &p) {
    ptr_ = p.ptr_;
    count_ = p.count_;
    ++(*count_);
    std::cout << "cp count: " << *count_ << std::endl;
  }
  // 复制分配构造函数
  SmartPtr<T> &operator=(const SmartPtr<T> &p) {
    if (this != &p) {
      ptr_ = p.ptr_;
      count_ = p.count_;
      ++(*count_);
      std::cout << "op = count: " << *count_ << std::endl;
    }
    return *this;
  }
  // 析构函数销毁指针指向的对象.
  ~SmartPtr() {
    --(*count_);
    std::cout << "~ count: " << *count_ << std::endl;
    if (*count_ == 0) {
      std::cout << "release." << std::endl;
      delete ptr_;
      delete count_;
      ptr_ = NULL;
      count_ = NULL;
    }
  }
  // 操作符返回指针指向的内容的引用.
  T &operator*() {
    return *ptr_;
  }
  // 可以通过->访问成员, 本来使用这里的时候通过->得到只是指针,那么要访问成员就应该是->->,这样可读性太差,编译器就将它优化成->了。
  T *operator->() {
    return ptr_;
  }
 protected:
  T *ptr_;
  int *count_;
};

int main() {
  SmartPtr<CObj> ap1(new CObj("jack"));
  std::cout << "ap1 name: " << ap1->name_ << std::endl;
  SmartPtr<int> ap2(new int(10)); // 创建了共享指针,count=1;
  std::cout << "before ap2 value: " << *ap2 << std::endl;
  SmartPtr<int> ap3(ap2);  // 将ap2通过拷贝构造给ap3,count+1=2;
  std::cout << "after ap3 value: " << *ap3 << std::endl;
  std::cout << "after ap2 value: " << *ap2 << std::endl;
  return 0;
  // main结束之后count在析构ap2的时候-1=1;析构ap3的时候-1=0,最后释放内存资源。
  // ap1同理。
}

可以看出,这些都可以拷贝,而且不影响使用。目前看来,shared_ptr完全解决了前面 提到的问题,堪称完美。但是有一种特殊的情况,使用shared_ptr也会出问题。我们来 看一下:

#include <iostream>
#include <string>
#include <memory>

// 设计一个类做测试.
class CObj {
 public:
  CObj(std::string name) {
    name_ = name;
  }
  ~CObj() {}
  std::string name_;
};

// shared_ptr如何实现?
template <class T>
class SmartPtr {
 public:
  // 构造函数初始化指针.
  SmartPtr(T *ptr)
      : ptr_(ptr)
      , count_(new int(1)) {
    std::cout << "init count: " << *count_ << std::endl;
  }
  // 复制构造函数
  SmartPtr(SmartPtr<T> &p) {
    ptr_ = p.ptr_;
    count_ = p.count_;
    ++(*count_);
    std::cout << "cp count: " << *count_ << std::endl;
  }
  // 复制分配构造函数
  SmartPtr<T> &operator=(const SmartPtr<T> &p) {
    if (this != &p) {
      ptr_ = p.ptr_;
      count_ = p.count_;
      ++(*count_);
      std::cout << "op = count: " << *count_ << std::endl;
    }
    return *this;
  }
  // 析构函数销毁指针指向的对象.
  ~SmartPtr() {
    --(*count_);
    std::cout << "~ count: " << *count_ << std::endl;
    if (*count_ == 0) {
      std::cout << "release." << std::endl;
      delete ptr_;
      delete count_;
      ptr_ = NULL;
      count_ = NULL;
    }
  }
  // 操作符返回指针指向的内容的引用.
  T &operator*() {
    return *ptr_;
  }
  // 可以通过->访问成员, 本来使用这里的时候通过->得到只是指针,那么要访问成员就应该是->->,这样可读性太差,编译器就将它优化成->了。
  T *operator->() {
    return ptr_;
  }
 protected:
  T *ptr_;
  int *count_;
};

struct Node {
  SmartPtr<Node> _prev;
  SmartPtr<Node> _next;

  Node(int x)
      : _prev(NULL)
      , _next(NULL)
  {}
  ~Node() {
    std::cout << "~Node" << std::endl;
  }
};

int main() {
  SmartPtr<Node> cur(new Node(1));
  SmartPtr<Node> next(new Node(2));
  cur->_next = next;
  next->_prev = cur;
  return 0;
}

输出:

init count: 1
init count: 1
init count: 1
init count: 1
init count: 1
init count: 1
op = count: 2
op = count: 2
count: 1
count: 1

我们发现最后count还是1,这样指针的资源就不会被释放,造成内存泄漏。

我们分析一下发现:cur 和 next 初始化之后都 count=1,在cur->_next = next之后, next 的 count+1=2;同样cur也是一样的=2。在main退出的时候析构函数中count=1,就 不会进行释放内存,造成了泄露。

这种情况是共享指针进行了循环引用。_prev和_next相互引用,导致最后都没有释放内 存。这时候怎么解决呢? 这就是后面有产生了一种 weak_ptr的智能指针。

weak_ptr

循环引用一般都会发生在这种"你中有我,我中有你"的情况里面,这里导致的问题就是内存 泄漏,这段空间一直都没有释放,现在很明显引用计数在这里就不是很合适了,但是 shared_ptr除了这里不够完善,其他的地方表现都令我们比较满意,所以boost社区的大牛 们在这里仅是补充了最后一个智能指针weak_ptr。

weak_ptr是为了配合shared_ptr而引入的一种智能指针,它更像是shared_ptr的一个助手而 不是智能指针,因为它不具有普通指针的行为,没有重载operator*和->,它的最大作用在于 协助shared_ptr工作,像监控者一样观测资源的使用情况。具体就是:

  • 首先weak_ptr是专门为shared_ptr 而准备的。
  • 它是 boost::shared_ptr 的监控者对象,作为监控者,那也就意味着weak_ptr它只对 shared_ptr 进行引用却不会去改变其引用计数,当被监控的 shared_ptr 失效后,相 应的weak_ptr 也相应失效,然后它就什么都不管,光是个删,也就是这里的cur 和 next在析构的时候 ,它都不用引用计数减1(因为这里引用计数一直保持是1,可以直 接释放资源) , 直接删除结点就好。这样也就间接地解决了循环引用的问题,当然 week_ptr指针的功能不是只有这一个。但是现在我们只要知道它可以解决循环引用就好。

我们直接看一下使用:

#include <iostream>
#include <string>
#include <memory>

struct Node {
  std::weak_ptr<Node> _prev;
  std::weak_ptr<Node> _next;

  ~Node() {
    std::cout << "~Node" << std::endl;
  }
};

int main() {
  std::shared_ptr<Node> cur(new Node());
  std::shared_ptr<Node> next(new Node());
  cur->_next = next;
  next->_prev = cur;
  return 0;
}

执行之后我们看到资源都会全部释放了。

小结

至此,我们把几个智能指针就解释清楚了。总结一下:

  • 拒绝使用 auto_ptr,因为其不仅不符合 C++ 编程思想。
  • 在确定对象无需共享的情况下,使用 scoped_ptr/unique_ptr。
  • 在对象需要共享的情况下,使用 shared_ptr。
  • 在需要访问 shared_ptr 对象,而又不想改变其引用计数的情况下(循环引用)使用 weak_ptr 辅助。

下面介绍一些常用的智能指针接口函数:

  • x_ptr.get() 返回一个原始的指针;
  • x_ptr.reset() 重新绑定指向的对象,而原来的对象则会被释放;
  • x_ptr.release() 这个函数只是把智能指针赋值为空,但是它原来指向的内存并没有被释放,相当于它只是释放了对资源的所有权;
  • shared_ptr.use_count() 来查看资源的所有者个数;

参考

Comments

comments powered by Disqus