[关闭]
@guochy2012 2014-06-11T06:40:47.000000Z 字数 3466 阅读 2654

浅谈C++类的复制控制技术

浅拷贝 shallow copy
禁止复制 noncopyable
引用计数 use count
深复制 deep copy
写时复制 copy on write

前言

笔者第一次读《C++ Primer》(第四版)这本经典著作时,对第十三章“类的复制控制”部分,产生一些疑问,如此经典的书为什么没有详述深拷贝和浅拷贝的问题,当时以为这是此书的一个缺憾,后来我逐渐了解到这本书的第三版曾经花了很多篇幅讲述这个问题,在第四版中删除了这部分内容。后者在后面引入了智能指针,这实际上是一种进步,不再局限于深浅拷贝。

实际上,目前为止就笔者了解的而言,类的复制控制一共有五种方法。

问题来源

看下面的这个类:

  1. class PointPtr{
  2. public:
  3. // some member function
  4. private:
  5. Point *ptr_;
  6. };

这个类持有一个Point类型的指针,Point的定义很简单,如下:

  1. class Point{
  2. public:
  3. // some member function
  4. private:
  5. int x_;
  6. int y_;
  7. };

问题究竟出在哪里?

浅拷贝

如果我们没有自定义类的复制构造函数和赋值运算符,那么编译器会自动帮我们合成相应的版本,大体功能如下:

  1. PointPtr(const PointPtr &other)
  2. :ptr(other.ptr_){
  3. }
  4. PointPtr &operator=(const PointPtr &other){
  5. if(this != &other){
  6. ptr_ = other.ptr_;
  7. }
  8. return *this;
  9. }

试想,如果我们复制一份PointPtr:

  1. PointPtr p1;
  2. PointPtr p2 = p1;

那么p1和p2内部的ptr都指向同一个对象,如果我们通过p1改动对象,显然p2也受到了牵连,这显然不是我们想要的。更何况,这里还有一个更严重的问题:如果我们加入析构函数,里面delete掉ptr,那么p1析构后,里面的Point对象已经释放,此时p2再去析构就发生了问题。

这种系统默认的仅仅拷贝指针值的状况,称为浅拷贝(shallow copy)。

第一种解决方案:禁止复制

上面浅拷贝的问题出在对象复制和赋值的时候,最简单的解决方案就是禁用掉类的copy能力。具体做法是将类的拷贝构造函数和赋值运算符设为私有,而且只提供声明,不提供实现(这是为了防止friend成员)。
一个更加通用的方法是写一个noncopyable类,如下:

  1. class noncopyable {
  2. protected:
  3. noncopyable() {
  4. }
  5. ~noncopyable() {
  6. }
  7. private:
  8. noncopyable(const noncopyable&);
  9. noncopyable &operator=(const noncopyable &);
  10. };

以后凡是继承它的类均失去了copy和assign能力。
实际上,boost::noncopyable就是这样实现的。

这种方式看起来简单粗暴,但是大部分情况下它是有效的。

尤其是当我们的类持有系统资源的时候,例如文件描述符、网络套接字、数据库连接,此时禁用掉copy语义,可以帮助我们避免很多潜在的bug。

深拷贝

即然有浅拷贝,那么另一种解决方案自然就是深拷贝。
深拷贝的含义是:对于那些内部持有资源的对象,复制时不是简单的copy指针的值,而是复制指针指向的资源。
具体实现如下:

  1. PointPtr(const PointPtr &other)
  2. :ptr_(new Point(*(other.ptr_))){
  3. }
  4. PointPtr &operator=(const PointPtr &other){
  5. if(this != &other){
  6. ptr_ = new Point(*(other.ptr_));
  7. }
  8. return *this;
  9. }

此时我们就可以放心的进行析构:

  1. ~PointPtr(){
  2. delete ptr_;
  3. }

读者应该注意到,这里的PointPtr自身是一种值语义。

还有一个典型的案例就是String的实现,读者可以自行尝试。

引用计数

前面我们采用深拷贝,解决了问题,但是他有一个很严重的问题,每次都去copyPoint对象,这个带来的开销较大。于是我们采用引用计数。

我们再次回顾下浅拷贝引发的问题:
1. 资源归属不清,多个PointPtr可能持有同一个Point对象
2. 析构时可能造成灾难性的后果

这里我们采用引用计数,实则是默认第一个问题的存在,把第二个问题解决好。
具体将就是我们在Point中(PointPtr中也可以)增加一个use成员,记录下当前总共有多少个PointPtr指向它,这样:
1. 当进行类的copy或assign时,use加1。
2. 析构时,仅仅把use减一,仅当use为0时才真正析构Point对象。

大概实现如下:

  1. class Point{
  2. friend class PointPtr;
  3. public:
  4. // some member function
  5. private:
  6. int x_;
  7. int y_;
  8. int use_; // use count
  9. };
  10. class PointPtr{
  11. public:
  12. // some member function
  13. PointPtr(const PointPtr &other)
  14. :ptr(other.ptr_){
  15. ++ptr_->use_; // use count + 1
  16. }
  17. PointPtr &operator=(const PointPtr &other){
  18. ++other.ptr_->use_; // avoid assign to itself
  19. if(--ptr_->use_ == 0){
  20. delete ptr_;
  21. }
  22. ptr_ = other.ptr_;
  23. return *this;
  24. }
  25. ~PointPtr(){
  26. if(--ptr_->use_ == 0){
  27. delete ptr_; // only delete object when use count equals zero
  28. }
  29. }
  30. private:
  31. Point *ptr_;
  32. };

这里有几个注意点:
1. 在重载=运算符时,要防止自身赋值问题,否则会先析构实际的对象,造成资源无效。这里的解决方案是先将other指向对象的use加一,这样就防止了自身赋值时析构Point的问题。
2. 只有当use为0时才真正析构对象。

这里的PointPtr如果重载了成员操作符,那么它就是一个具有引用计数功能的智能指针。
相对于深复制,引用计数大大减少了Point对象复制的开销。

写时复制

我们简单回顾下前面各种手段的优缺点:
1. 浅拷贝: 毫无疑问,这是错误的。
2. 禁止复制:简单粗暴,没有后患.
3. 深拷贝: 对象之间毫无关联,但是复制成本高
4. 引用计数: 无对象复制开销,但是对象共享资源

有没有一种方式,即可以实现值语义(像深拷贝那样),又可以减少对象复制的开销(像引用计数)呢?
答案就是在引用计数的基础上加入写时复制(Copy On Write).

具体做法是:我们仍然采用引用计数,但是只允许读,一旦我们尝试改动Point对象,那么就自动拷贝一份,此时的PointPtr就单独持有一份Point对象。

以前的代码不变,只是我们添加几个函数:

  1. PointPtr &setX(int x){
  2. if(ptr_->use_ != 1){
  3. ptr_ = new Point(*ptr_);
  4. }
  5. ptr_->setX(x);
  6. return *this;
  7. }

每当我们试图修改Point时,就会自动产生一个新的copy,当然如果是use为1,没有共享的情况,就不必生成新的对象。

这里证明一个事实:使用写时复制技术,引用计数也可以实现值语义

写时复制技术一个最典型的应用就是Linux系统fork进程。在传统的UNIX进程模型中,创建子进程需要完全复制父进程,以确保二者几乎独立(实际就是我们所指的值语义),但是这样做:一是耗费内存空间,二是浪费时间。于是Linux采用COW技术,fork子进程时仅仅复制页表,把复制页表项的时机延迟到Write时。

总结

以上的各种技术除了浅拷贝,均要考虑实际场景。
涉及到系统资源的一般采用禁止复制。
我们自己实现String大部分采用深拷贝,有的实现使用了COW。
对于那些禁止复制的对象,如果想把他们作为参数传递,引用计数型的智能指针是正确选择(例如boost::shared_ptr)。
很多句柄类,采用了COW技术减少开销。

这里讲的五种资源控制的情况,对于理解智能指针和句柄类,尤其是后者,有很大的帮助。句柄类不管多么复杂,采用的实现方式总是上面的其中一种。

参考资料
《C++ Primer》第四版
《C++ 沉思录》英文版
《深入Linux内核架构》 (主要是fork进程部分)

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注