C++ -- 深浅拷贝

浅拷贝

1.例如对于下面的String类,它包含一个成员变量,一个char*的指针:

class String
{
private:
      char* _str;
};

2.对于String类的拷贝构造函数及operator=函数来说,当用一个String对象拷贝构造或赋值给另一个String对象时,就是将这个对象里的指针的值赋值给另一个对象里的指针。将一个指针值赋值给另一个指针,就会使得两个指针指向同一块空间,这就产生了浅拷贝。
3.下面就是浅拷贝的代码:

class String
{
public:
      String(const char* str)
           :_str(new char[strlen(str) + 1])
      {
           strcpy(_str, str);
      }

      String(const String& s)
           :_str(s._str)
      {}

      String& operator=(const String& s)
      {
           if (this != &s)
           {
                 _str = s._str;
           }
           return *this;
      }

      ~String()
      {
           if (_str)
           {
                 delete[] _str;
           }
           _str = NULL;
      }
private:
      char* _str;
};

4.浅拷贝存在的问题:
①两个(或两个以上)指针指向同一块空间,这个内存就会被释放多次;(例如下面定义了一个String对象s1,以浅拷贝的方式拷贝构造了一个String对象s2,则s1和s2里面的指针_str就会指向同一块空间;当出了作用域,s2先调用析构函数,而上面代码中析构函数里面进行了空间的释放,也就是这个空间就会被s2释放,接下来会调用s1的析构函数,也会去释放这块空间,释放一块已经不属于我的空间就会出错)
这里写图片描述
②另一方面,当两个指针指向同一块空间时,一旦一个指针修改了这块空间的值,另一个指针指向的空间的值也会被修改。

浅拷贝的解决方法

1.深拷贝
2.引用计数的写时拷贝

深拷贝

1.传统写法
①若用一个s1对象拷贝构造或赋值给s2对象,s2(s1)s2 = s1,当涉及到浅拷贝的问题时:

  • 对于拷贝构造函数来说,s2先开一块和s1一样大的空间;而对于赋值运算符重载函数来说s2已经存在,则必须先释放s2的空间然后才让s2开与s1一样大的空间(否则就会导致s2里面的指针没有释放)。
  • 然后让s2指向这块新开的空间,然后将s1里面的数据拷贝至s2指向的空间(自己开空间自己拷数据);

②代码:

      //拷贝构造的传统写法      
      String(const String& s)
      {
           _str = new char[strlen(s._str) + 1];  //开空间
           strcpy(_str, s._str);   //拷数据
      }

     //赋值的传统写法
      String& operator=(const String& s)   //必须要返回引用,为了连续的赋值
      {
           if (this != &s)
           {
                 delete[] _str;
                 _str = NULL;
                 _str = new char[strlen(s._str) + 1];
                 strcpy(_str, s._str);
           }
           return *this;
      }

2.现代写法(调用其他的函数来实现自己的功能)
①本质:让别人去开空间,去拷数据,而我将你的空间与我交换就可以。
②实现:例如用s1拷贝构造一个s2对象s2(s1),可以通过构造函数将s1里的指针_str构造一个临时对象tmp(构造函数不仅会开空间还会将数据拷贝至tmp),此时tmp就是我们想要的哪个对象,然后将新tmp的指针_ptr与自己的指针进行交换。
对于构造函数来说,因为String有一个带参数的构造函数,则用现代写法写拷贝构造时可以调用构造函数,而对于没有无参的构造函数的类只能采用传统写法(开空间然后拷数据)。

 //拷贝构造的现代写法      
      String(const String& s)
           :_str(NULL)  
      {
           String tmp(s._str);   //调用构造函数,则tmp就是我们需要的

           swap(_str, tmp._str);   //将_str与tmp的_str指向的空间进行交换,tmp._str就会指向_str的空间,出了这个作用域,tmp就会调用析构函数,但是tmp里面的_str值可能不确定,所以在初始化列表中将_str置空,这样tmp._str=NULL
      }

     //赋值的现代写法
      String& operator=(const String& s)
      {
           if (this != &s)
           {
                 String tmp(s._str);    //调用构造函数
                 swap(_str, tmp._str);   //tmp是局部对象,出了这个作用域就会调用析构函数,就会将tmp里面的指针指向的空间释放掉,
           }
           return *this;
      }

引用计数的写时拷贝

1.常用场景:
①有时会多次调用拷贝构造函数,但是拷贝构造的对象并不会修改这块空间的值;
②如果采用深拷贝,每次都会重复的开空间,然后拷数据,最后再释放这块空间,这会花费很大的精力。
③我们想到浅拷贝不用重复的开空间,但是会有问题;为了解决释放多次的问题可以采用引用计数,当有新的指针指向这块空间的时候,我们可以增加引用计数,当这个指针需要销毁时,就将引用计数的值减1,当引用计数的值为1时才去释放这块空间;
④当有一个指针指需要修改其指向空间的值时,才去开一块新的空间(也就是写时拷贝);
⑤这相当于一个延缓政策,如果不需要修改,则不用开新的空间,毕竟开空间需要很大的消耗。
⑥引用计数解决了空间被释放多次的问题,写时拷贝解决了多个指针指向同一块空间会修改的问题。

2.String写时拷贝的的三种方案的选择:
①如果将引用计数定义为int 。
这里写图片描述
缺点:每个对象的引用计数之间是独立的,如果增加指向这块空间的指针,也只会修改新增这个指针所在对象的引用计数,就会使得每块空间对应引用计数不相同;(例如下面的代码中s1与s2指向同一块空间,但s1的引用计数为1,s2的引用计数为2,两个引用计数不相同)
这里写图片描述
②如果将引用计数定义为static int
这里写图片描述
缺点:因为静态成员为该类的所有对象所共享,就会使得利用String类创建的所有对象哪怕他们指针指向不同的空间,但是这些对象的引用计数都相等;

③经过如上分析,我们可以将引用计数定义为int*的指针,该指针指向一块空间,这块空间里面存放的是引用计数。当用s1拷贝构造s2时,s1与s2里面的_str指向同一块空间,s1与s2的引用计数也存放在同一块空间里。
这里写图片描述
例如,创建3个String对象,s1与s2指向同一块空间,则s1与s2的引用计数都为2,s3指向另一块空间,则s3的引用计数为1。
这里写图片描述
3.采用引用计数写时拷贝的String类

class String
{
public:
      String(char* str="")
           :_str(new char[strlen(str)+1])
           , _refCount(new int(1))   //引用计数的初始值为1
      {
           strcpy(_str, str);
      }

      //拷贝构造 s1(s2),采用浅拷贝
      String(const String& s)
           :_str(s._str)
           , _refCount(s._refCount)
      {
           ++(*_refCount);
      }

      //s1=s2(先将_str指向空间的引用计数--,然后看那个空间的引用计数是否为0,如果为0,则将该空间释放,然后将s的值和引用计数赋值给_str
      String& operator=(const String& s)
      {
           if (_str != s._str)
           {
                 if (--(*_refCount) == 0)
                 {
                      delete[] _str;
                      delete _refCount;
                 }
                 _str = s._str;
                 _refCount = s._refCount;
                 ++(*_refCount);
           }

           return *this;
      }

      ~String()    //先将自己的引用计数--,然后看该引用计数是否为0,如果为0,则将该空间释放
      {
           if (--(*_refCount) == 0)
           {
                 delete[] _str;
                 delete _refCount;
           }
      }

      void CopyOnWrite()  //只有当引用计数大于1时才开空间,如果引用计数等于1,说明当前空间只有自己一个对象指向它,直接可以对这个空间进行操作
      {
           if (*_refCount > 1)
           {
                 char* newstr = new char[strlen(_str) + 1];
                 strcpy(newstr, _str);
                 _str = newstr;
                 _refCount = new int(1);
           }
      }
private:
      char* _str;
      int* _refCount;
};

引用计数写时拷贝的改进

1.为什么引用计数的写实拷贝需要改进?

  • 如果将引用计数单独的定义为一个int*的指针,它占4个字节,每次创建一个String对象,都会为其向操作系统申请4个字节的内存,这样就会经常申请许多小块内存,会造成内存碎片,也会对效率造成影响。

2.这时可以考虑将_str与引用计数放在一起,就在_str的头上4个字节存放引用计数,当我们取引用计数时,只用将*((int*)(_str-4))

class String
{
public:
      String(char* str = "")
           :_str(new char[strlen(str)+5])    //因为多开了4个字节给引用计数,所以这里加5,上面引用计数和_str独立加的是1,只有这里的_str包含引用计数,后面的_str都不包含引用计数
      {
           _str += 4;   //从_str+4才表示有效的字符,前面是引用计数
           strcpy(_str, str);
           GetRefCount() = 1;    //将引用计数置为1
      }

      // s2(s1)
      String(const String& s)
           :_str(s._str)
      {
           ++(GetRefCount());
      }

      //s2 = s1
      String& operator=(const String& s)
      {
           if (_str != s._str)
           {
                 if (--(GetRefCount()) == 0)
                 {
                      delete[] (_str-4);
                 }
                 _str = s._str;
                 ++GetRefCount();
           }
           return *this;
      }

      ~String()
      {
           if (--GetRefCount() == 0)
           {
                 delete[] (_str - 4);
           }
      }

      int& GetRefCount()
      {
           return *((int*)(_str - 4));
      }

      const char* c_str()
      {
           return _str;
      }

      void CopyOnWrite()
      {
           if (GetRefCount() > 1)
           {
                 char* newstr = new char[strlen(_str) + 5];
                 newstr += 4;
                 strcpy(newstr, _str);
                 --GetRefCount();
                 _str = newstr;
                 GetRefCount() = 1;
           }
      }

      char& operator[](size_t pos)
      {
           CopyOnWrite();
           return _str[pos];
      }

private:
      char* _str;  // 引用计数放在_str的头上4个字节处
};

引用计数的写时拷贝,读有时也会拷贝

例如对于String类,如果想要取出里面的某个字符或者修改某个对应位置上的字符需要重载operator[ ]:
(1)因为operator[ ]既可以读也可以修改,为了统一,无论读写,都需要重新拷贝;
(2)代码如下:

      char& operator[](size_t pos)
      {
           CopyOnWrite();
           return _str[pos];
      }

(3)测试代码:(当运行完s1=s3时,s1与s3里面的_str指向同一块空间,当运行s3[0]=’m’后,s3重新开辟了一块空间)
这里写图片描述

探究VS和Linux操作系统里标准库里面的string是采用何种方式进行拷贝

1.可以通过构造,拷贝构造,赋值运算符定义3个string对象,查看这三个string对象里面_str的地址,如果相同,则采用的是引用计数的写时拷贝;如果不同,则采用的是深拷贝;
2.在VS操作系统下,string采用的是深拷贝,在Linux操作系统下,string采用的是引用计数的写时拷贝;
3.验证:
①在vs下:
这里写图片描述
可以看出s1,s2,s3的地址不相同,说明vs是深拷贝。
②在Linux下,string采用的是引用计数的写时拷贝

猜你喜欢

转载自blog.csdn.net/xu1105775448/article/details/80546950