【C++】 浅析深浅拷贝

  C++中深拷贝和浅拷贝的问题是很值得我们注意的知识点,如果编程中不注意,可能会出现疏忽,导致bug。本文就详细讲讲C++深浅拷贝的种种。

  我们知道,对于一般对象:

    int a = 1;
    int b = 2;

  这样的赋值,复制很简单,但对于类对象来说并不一般,因为其内部包含各种类型的成员变量,在拷贝过程中就会出现问题

例如:

#include <iostream>
using namespace std;

class String
{
public:

    String(char str = "")
        :_str(new char[strlen(str) + 1]) // +1是为了避免空字符串导致出错
    {
        strcpy(_str , str);
    }
    
    // 浅拷贝
    String(const String& s)
        :_str(s._str)
    {}
    
    ~String()
    {
        if(_str)
        {
            delete[] _str;
            _str = NULL;
        }
        cout<<"~String()"<<endl;
    }
    
    void Display()
    {
        cout<<_str<<endl;
    }
    
private:
    char *_str;
};

void Test()
{
    String s1("hello");
    String s2(s1);
    String.Display();
    
}

int main()
{
    Test();
    
    return 0;
}

运行结果:

wKiom1bqRJ7CC1j7AABxvgeu1pI402.png

我们发现,编译通过了,但是崩溃了 =  =ll ,这就是浅拷贝带来的问题。


   事实是,在对象拷贝过程中,如果没有自定义拷贝构造函数,系统会提供一个缺省的拷贝构造函数,缺省的拷贝构造函数对于基本类型的成员变量,按字节复制,对于类类型成员变量,调用其相应类型的拷贝构造函数。原型如下:

String(const String& s)
     {}

但是,编译器提供的缺省函数并不是十全十美的。


      缺省拷贝构造函数在拷贝过程中是按字节复制的,对于指针型成员变量只复制指针本身,而不复制指针所指向的目标--浅拷贝。


用图形象化为:


wKioL1bqSOax7GQiAAAb-81vW_4778.png

  在进行对象复制后,事实上s1、s2里的成员指针 _str 都指向了一块内存空间(即内存空间共享了),在s1析构时,delete了成员指针 _str 所指向的内存空间,而s2析构时同样指向(此时已变成野指针)并且要释放这片已经被s1析构函数释放的内存空间,这就让同样一片内存空间出现了 “double free” ,从而出错。而浅拷贝还存在着一个问题,因为一片空间被两个不同的子对象共享了,只要其中的一个子对象改变了其中的值,那另一个对象的值也跟着改变。所以这不是我们想要的结果,同事也不是真正意义上的复制。


为了解决浅拷贝问题,我们引出深拷贝,即自定义拷贝构造函数,如下:

String(const String& s)
        :_str(new char[strlen(s._str) + 1])
    {
        strcpy(_str , s._str);
    }

这样在运行就没问题了。


那么,程序中还有没有其他地方用到拷贝构造函数呢?

答案:当函数存在对象型的参数(即拷贝构造)或对象型的返回值(赋值时的返回值)时都会用到拷贝构造函数。


而拷贝赋值的情况基本上与拷贝复制是一样的。只是拷贝赋值是属于操作符重载问题。例如在主函数若有:String s3; s3 = s2; 这样系统在执行时会调用系统提供的缺省的拷贝赋值函数,原型如下:


void operator = (const String& s) 
    {}

我们自定义的赋值函数如下:

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


  但是这只是新手级别的写法,考虑的问题太少。我们知道对于普通变量来讲a=b返回的是左值a的引用,所以它可以作为左值继续接收其他值(a=b)=30,这样来讲我们操作符重载后返回的应该是类对象的引用(否则返回值将不能作为左值来进行运算),如下:

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

 

  而上面这种写法其实也有问题,因为在执行语句时,_str 已经被构造已经分 配了内存空间,但是如此进行指针赋值,_str 直接转而指向另一片新new出来的内存空间,而丢弃了原来的内存,这样便造成了内存泄露。应更改为:

String& operator=(const String& s)
    {
        if(_str != s._str)  
        {
            delete[] _str;
            _str = new char[strlen(s._str) + 1];
            strcpy(_str,s._str);
        }
        return *this;
    }

同时,也考虑到了自己给自己赋值的情况。


  可是这样写就完善了吗?是否要再仔细思索一下,还存在问题吗?!其实我可以告诉你,这样的写法也顶多算个初级工程师的写法。前面说过,为了保证内存不泄露,我们前面 delete[]  _str,然后我们在把new出来的空间给了_str,但是这样的问题是,你有考虑过万一 new 失败了呢?!内存分配失败,m_psz没有指向新的内存空间,但是它却已经把旧的空间给扔掉了,所以显然这样的写法依旧存在着问题。一般高级工程师的写法会是这样的:

String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            char *tmp = new char[strlen(s._str) + 1];
            strcpy(tmp , s._str);
            delete[] _str;
            _str = tmp;
        }
        return *this;
    }

这样写就比较全面了。


但是!!!还有元老级别的大师写的更加简便的拷贝构造和赋值函数,我们一睹为快:

<元老级拷贝构造函数>

    String(const String& s)
        :_str(NULL)
    {
        String tmp = s._str;
        swap(_str , tmp._str);
    }

<元老级赋值函数>

// 1.
   String& operator=(const String& s)
   {
     if(_str != s._str)
     {
       String tmp = s._str;
       swap(_str , tmp._str);
     }
     return *this;
   }
// 2.
    String& operator=(String& s)//在此会拷贝构造一个临时的对象s
   {
     if(_str != s._str)
     {
       swap(_str ,s._str);// 交换this->_str和临时生成的对象数据成员s._str,离开作用域会自动析构释放
     }
     return *this;
   }

看出端倪了么?


  事实上,这是借助了以上自定义的拷贝构造函数。定义了局部对象 tmp,在拷贝构造中已经为 tmp 的成员指针分配了一块内存,所以只需要将 tmp._str 与this->_str交换指针即可,简化了程序的设计,因为 tmp 是局部对象,离开作用域会调用析构函数释放交换给 tmp._str 的内存,避免了内存泄露。

这是非常值得我们学习和借鉴的。


这是本人对C++深浅拷贝的理解,若有纰漏,欢迎留言指正 ^_^


附注整体代码:

#include <iostream>
using namespace std;

class String
{
public:

    String(char *str = "")
        :_str(new char[strlen(str) + 1])
    {
        strcpy(_str , str);
    }
    
    // 浅拷贝
    String(const String& s)
        :_str(s._str)
    {}


    //赋值运算符重载
    //有问题,会造成内存泄露。。。
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            strcpy(_str,s._str);
        }
        return *this;
    }

    // 深拷贝  <传统写法>
    String(const String& s)
        :_str(new char[strlen(s._str) + 1])
    {
        strcpy(_str , s._str);
    }
    
    //赋值运算符重载
    //一.  这种写法有问题,万一new失败了。。
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            delete[] _str;
            _str = new char[strlen(s._str) + 1];
            strcpy(_str,s._str);
        }
        return *this;
    }

    //二.  对上面的方法改进,先new后delete,如果new失败也不会影响到_str原来的内容
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            char *tmp = new char[strlen(s._str) + 1];
            strcpy(tmp , s._str);
            delete[] _str;
            _str = tmp;
        }
    }

    // 深拷贝  <现代写法>
    String(const String& s)
        :_str(NULL)
    {
        String tmp = s._str;
        swap(_str , tmp._str);
    }
    
    //赋值运算符的现代写法一:
    String& operator=(const String& s)
    {
        if(_str != s._str)
        {
            String tmp = s._str;
            swap(_str , tmp._str);
        }
        return *this;
    }
    //赋值运算符的现代写法二:
    String& operator=(String& s) //在此会拷贝构造一个临时的对象s
    {
        if(_str != s._str)
        {
            swap(_str ,s._str);//交换this->_str和临时生成的对象数据成员s._str,离开作用域会自动析构释放
        }
        return *this;
    }

    ~String()
    {
        if(_str)
        {
            delete[] _str;
            _str = NULL;
        }
        cout<<"~String()"<<endl;
    }

    void Display()
    {
        cout<<_str<<endl;
    }

private:
    char *_str;
};

void Test()
{
    String s1;
    String s2("hello");
    String s3(s2);
    String s4 = s3;

    s1.Display();
    s2.Display();
    s3.Display();
    s4.Display();

}

int main()
{
    Test();

    return 0;
}


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章