浅拷贝,深拷贝和写时拷贝(string类)

浅拷贝
浅拷贝:编译器只是直接将指针的值拷贝过来,结果多个对象共用了一块内存,当一个对象调用了析构函数将这块内存释放掉之后,另一些对象不知道这块空间已经还给了系统,再次调用析构函数进行释放时发现已经释放了,就会造成程序崩溃。

所以,在类的成员中有指针类型的成员变量的时候,必须对其写出显式的拷贝构造函数和赋值运算符重载函数,否则,默认的拷贝构造函数和赋值运算符重载函数只会对该指针进行浅拷贝(即直接将指针的值拷贝过来),导致多个对象的指针变量实际上指的是同一块空间,会引发一系列的问题甚至是程序崩溃
//string类浅拷贝版本
class String
{
private:
        char* _s;
public:
        String(const char* s = "")
               :_s(new char[strlen(s) + 1])
        {
               strcpy(_s, s);
        }
        ~String()
        {
               if (_s)
               {
                       delete[] _s;
                       _s = NULL;
               }
        }
};
void FunTest()
{
        String s1("Hello world");
        String s2(s1);//调用了拷贝构造函数
        //String s22 = s1 //注意,这里同样是调用的拷贝构造函数,并不是赋值运算符重载函数
        String s3;
        s3 = s2;//调用了赋值运算符重载函数
}

以上代码,就是典型的浅拷贝,我们可以看到s1,s2和s3的指针实际指向的都是同一块地址

如此在最后调用析构函数释放空间的时候就会导致程序崩溃



深拷贝
因为浅拷贝会引发种种的问题,所以这里就引入了深拷贝。深拷贝会在构造其余对象的时候,拷贝一块和被拷贝对象一样大的空间,并将空间内的内容拷贝过来。这样不同的对象就会指向不同的数据块

下面我们对上面的代码进行一些修改,我们实现一个自定义的拷贝构造函数以及一个赋值运算符重载函数
class String
{
private:
        char* _s;
public:
        String(const char* s = "")
               :_s(new char[strlen(s)+1])
                //这里需要多一个位置来存放'\0'结束符,否则会出错
        {
               strcpy(_s, s);
                //strcpy函数会把结束符'\0'一起拷贝过来,所以不用再在结尾添加'\0'
        }
        String(const String& s)
               :_s(new char[strlen(s._s)+1])
        {
               strcpy(_s, s._s);//连'\0'一起拷贝过来
        }
        String& operator=(const String& s)
        {
               if (this != &s)
               {
                       _s = new char[strlen(s._s) + 1];
                       strcpy(_s, s._s);//连'\0'一起拷贝过来
               }
               return *this;
        }
        ~String()
        {
               if (_s)
               {
                       delete[] _s;
                       _s = NULL;
               }
        }
};
void FunTest()
{
        String s;
        String s1("Hello world");
        String s2(s1);//调用了拷贝构造函数
        String s3;
        s3 = s2;//调用了赋值运算符重载函数
}

以上就是深拷贝的模式,我们可以看到s1,s2和s3中的指针分别指向的是不同的地址



在涉及到类中有指针类型的变量时,尤其是要对该类进行相关拷贝的操作时,一定要显式的定义一个拷贝构造函数或是赋值运算符重载,进行深拷贝,不能用浅拷贝!!!

另外深拷贝还有一个简洁版本的,可以避免strcpy的C风格字符串带来的一些容易混淆的地方
//string类深拷贝简洁版本
class String
{
private:
        char* _s;
public:
        String(const char* s = "")
               :_s(new char[strlen(s) + 1])
        {
               strcpy(_s, s);
        }
        String(const String& s)
               :_s(NULL)
        {
               String stmp(s._s);//该临时对象出了这个函数自动调用析构函数析构
               swap(_s, stmp._s);
        }
        String& operator=(const String& s)
        {
               if (this != &s)
               {
                       String stmp(s);//该临时对象出了这个函数自动调用析构函数析构
                       swap(_s, stmp._s);
               }
               return *this;
        }
        ~String()
        {
               if (_s)
               {
                       delete[] _s;
                       _s = NULL;
               }
        }
};
void FunTest()
{
        String s;
        String s1("Hello world");
        String s2(s1);//调用了拷贝构造函数
        String s3;
        s3 = s2;//调用了赋值运算符重载函数
}

写实拷贝版本的string类(引用计数)
    写时拷贝(Copy On Write), 在复制一个对象的时候并不是真正的把原先的对象复制到内存的另外一个位置上,而是在新对象的内存映射表中设置一个指针,指向源对象的位置,并把那块内存的引用计数位加1。
  • 这样,在对新的对象执行读操作的时候,内存数据不发生任何变动,直接执行读操作;
  • 而在对新的对象执行写操作时,将真正的对象复制到新的内存地址中,并修改新对象的内存映射表指向这个新的位置,并在新的内存位置上执行写操作。
    原理:采用引用计数的机制。当一个string对象str1构造时,string的构造函数会根据传入的参数在堆空间上分配内存,当有其他对象通过str1进行拷贝构造的时候,str1的引用计数会+1(即当有其他类需要这块内存的时候,引用计数+1)。当有对象析构时,这个引用计数会-1。直到最后一个对象析构时,引用计数为0,此时程序才会真正释放这块内存(前面的析构并没有释放该内存,而是让引用计数-1)。

下面是写实拷贝版本的string类代码
//string类写时拷贝版本
class String
{
        friend ostream& operator <<(ostream& _cout,const String& s);
        //重载<<运算符,这个写法需要记住,需要设置成友元函数,这样就不属于该类,可以直接调用
private:
        char* _pstr;
        int& GetCount()//这个函数用来得到该对象所属内存空间的引用计数
        {
               return *((int*)(_pstr - 4));
        }
        inline void Release()
        {
               if (--GetCount() == 0 && _pstr)
               //先--引用计数,如果引用计数为0了,表示该内存空间上已经没有对象存在了,就释放这块内存空间
               {
                       delete[](_pstr - 4);//注意要连引用计数所占的空间一起释放了
               }
        }
public:
        String(char* s = "")
               :_pstr(new char[strlen(s) + 1 + 4])//多开辟4个字节存放引用计数
        {
               *((int*)_pstr) = 1;//每次新构造一个对象将前4个字节赋值为1,表示新开辟的空间引用计数为1
               _pstr += 4;//让_pstr下移一个int大小的位置,开始用来存放字符串
               strcpy(_pstr, s);
        }
        String(const String& s)
               :_pstr(s._pstr)//直接进行浅拷贝
        {
               GetCount()++;//将该内存空间上的引用计数+1
        }
        String& operator=(const String& s)
        {
               if (this != &s)
               {
                       Release();
                       //一个对象要对它进行赋值,那么首先,它肯定是已经创建好的
                       //不管它是通过构造函数创建还是拷贝构造函数创建,它的引用计数肯定是大于0的
                       //通过构造函数创建引用计数为1,通过拷贝构造创建引用计数一定大于1
                       //即它自己一个人用一块内存空间或是和别人共用一块内存空间
                       //那么在对它进行赋值的时候,它就不属于原来那份内存空间了,属于一块新的内存空间
                       //这个时候就要对原来的内存空间的引用计数--,对新的内存空间的引用计数++
                       _pstr = s._pstr;
                       ++GetCount();//对新的内存空间的引用计数++
               }
               return *this;
        }
        ~String()
        {
               Release();
        }
        char* C_str()//返回字符串首地址
        {
               return _pstr;
        }
        //写时拷贝,要进行写操作的时候再进行拷贝
        char& operator[](size_t index)
        {
               if (GetCount() > 1)//当引用次数大于1时新开辟空间
               {
                       --GetCount();//原来的空间引用计数-1
                       char* pStr = new char[strlen(_pstr) + 4 + 1];
                       *((int*)pStr) = 1;//下面三步和构造函数的意义相同,将新空间的引用计数置为1
                       pStr += 4;
                       strcpy(pStr, _pstr);
                       _pstr = pStr;//将指向新开辟的空间的指针赋给该对象
               }
               return *(_pstr + index);
        }
};
ostream& operator<<(ostream& _cout, const String& s)
{
        _cout << s._pstr;
        return _cout;
}
void FunTest()
{
        String s1("hello");
        String s2("world");
        String s3(s1);
        s3 = s2;

        printf("s1地址:%x\n", (unsigned int)s1.C_str());
        printf("s2地址:%x\n", (unsigned int)s2.C_str());
        printf("s3地址:%x\n", (unsigned int)s3.C_str());
        cout << endl;

        s3[1] = 'c';//这个时候进行了写操作,编译器这个时候才会为s3开辟新的内存,之前s3是和s2共用一块内存
        cout << "改变s3:" << endl;
        cout << "s1:" << s1.C_str() << " s2:" << s2.C_str() << " s3:" << s3.C_str() <<  endl;
        printf("s3地址:%x\n", (unsigned int)s3.C_str());
        cout << endl;

        s2[1] = 'b';//这个时候进行了写操作,但是该空间上只有s2一个对象了,所以不用重新分配空间,直接改就行
        cout << "改变s2:" << endl;
        cout << "s1:" << s1.C_str() << "  s2:" << s2.C_str() << " s3:" << s3.C_str() <<  endl;
        printf("s2地址:%x\n", (unsigned int)s2.C_str());
        cout << endl;

        s1[1] = 'a';//这个时候进行了写操作,但是该空间上只有s2一个对象了,所以不用重新分配空间,直接改就行
        cout << "改变s1:" << endl;
        cout << "s1:" << s1.C_str() << "  s2:" << s2.C_str() << " s3:" << s3.C_str() <<  endl;
        printf("s1地址:%x\n", (unsigned int)s1.C_str());
}

测试结果如下
s1地址:e0d794
s2地址:e0d4bc
s3地址:e0d4bc

改变s3:
s1:hello s2:world s3:wcrld
s3地址:e0d7cc

改变s2:
s1:hello  s2:wbrld s3:wcrld
s2地址:e0d4bc

改变s1:
s1:hallo  s2:wbrld s3:wcrld
s1地址:e0d794


下图表示创建一块新的内存时所做的事情







版权声明:本文为lvyibin890原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。