【原创】漫谈C++深浅拷贝
对于一般的对象,如:
int a = 10; int b = 20;
它们之间的赋值、复制过程是很简单的。但是对于类对象来说,其内部存在各种类型成员变量,在拷贝过程中会出现问题。如下:
1 #include<iostream> 2 #include<cstring> 3 using namespace std; 4 class String { 5 public: 6 String (const char* psz=NULL) : m_psz(strcpy(new char[strlen(psz?psz:"")+1]),psz?psz:""){ 7 cout << "String构造" << endl; 8 } 9 ~String () { 10 if(m_psz) { 11 delete[] m_psz; 12 m_psz = NULL; 13 } 14 cout << "String析构" << endl; 15 } 16 char* c_str(void) { 17 return m_psz; 18 } 19 private: 20 char* m_psz; 21 }; 22 int main(void) { 23 String s1("hello"); 24 String s2(s1); 25 cout << "s1 " << s1.c_str() << endl; 26 cout << "s2 " << s2.c_str() << endl; 27 s1.c_str()[0] = 'H'; 28 cout << "s1 " << s1.c_str() << endl; 29 cout << "s2 " << s2.c_str() << endl; 30 return 0; 31 }
./a.out
编译通过了,运行后出现一堆的错误,为什么?!这就是浅拷贝带来的问题。
事实是,在对象拷贝过程中,如果没有自定义拷贝构造函数,系统会提供一个缺省的拷贝构造函数,缺省的拷贝构造函数对于基本类型的成员变量,按字节复制,对于类类型成员变量,调用其相应类型的拷贝构造函数。原型如下:
String (const String& that) {}
但凡是编译系统提供的缺省函数,总不是十全十美的。
缺省拷贝构造函数在拷贝过程中是按字节复制的,对于指针型成员变量只复制指针本身,而不复制指针所指向的目标----浅拷贝。
用下图来解释这个问题:
在进行对象复制后,事实上s1,s2里的成员指针m_psz都指向了一块内存空间(即内存空间共享了),在s1析构时,delete了成员指针m_psz所指向的内存空间,而s2析构时同样指向(此时已变成野指针)并且要释放这片已经被s1析构函数释放的内存空间,这就让同样一片内存空间出现了“double free” ,从而出错。而浅拷贝还存在着一个问题,因为一片空间被两个不同的子对象共享了,只要其中的一个子对象改变了其中的值,那另一个对象的值也跟着改变了,正如程序中只改变了s1.c_str()[0] = 'H',然而输出的s1,s2均为
hello,所以这并不是真正意义上的复制。
为了实现深拷贝,往往需要自己定义拷贝构造函数,在源代码里,我们加入自定义的拷贝构造函数如下:
String (const String& that) : m_psz(strcpy((new char[strlen(that.m_psz)+1]),that.m_psz)){ cout << "String拷贝构造" << endl; }
这样再运行就没有问题了。
在程序中,还有哪些情况会用到拷贝构造函数呢?当函数存在对象型的参数或对象型的返回值时都会用到拷贝构造函数。
而拷贝赋值的情况基本上与拷贝复制是一样的。只是拷贝赋值是属于操作符重载问题。例如在主函数若有:String s3;s3 = s2;这样系统在执行时会调用系统提供的缺省的拷贝赋值函数,原型如下:
void operator = (const String& that) {}
我们可以自定义拷贝赋值函数如下:
void operator=(const String& that) { m_psz = strcpy (new char[strlen(that.m_psz)+1],that.m_psz); }
但是这只是新手级别的写法,考虑的问题太少。我们知道对于普通变量来讲a=b返回的是左值a的引用,所以它可以作为左值继续接收其他值(a=b)=30,这样来讲我们操作符重载后返回的应该是类对象的引用(否则返回值将不能作为左值来进行运算),如下:
String& operator=(const String& that){ m_psz = strcpy (new char[strlen(that.m_psz)+1],that.m_psz); }
而 m_psz = strcpy (new char[strlen(that.m_psz)+1],that.m_psz);这种写法其实也有问题,因为在执行语句时,m_psz已经被构造已经分配了内存空间,但是如此进行指针赋值,m_psz直接转而指向另一片新new出来的内存空间,而丢弃了原来的内存,这样便造成了内存泄露。应更改为:
String& operator=(const String& that) { delete[] m_psz; m_psz = strcpy (new char[strlen (that.m_psz)+1],that.m_psz); }
这样就行了吗?在这个世界上不怕没好事就怕没好人,万一他跟你搞一个自赋值(s3=s3)怎么办?
操作符左右两边都是同一个对象,这样先delete[] m_psz,后面又有that.m_psz,这就出现了问题。所以为了防止自赋值,我们一般的写法为:
String& operator=(cosnt String& that) { if(&that != this) { delete[] m_psz; m_psz = strcpy (new char[strlen(that.m_psz)+1],that.m_psz); } return *this; }
可是这样写就完善了吗?是否要再仔细思索一下,还存在问题吗?!其实我可以告诉你,这样的写法也顶多算个初级工程师的写法。前面说过,为了保证内存不泄露,我们前面delete[] m_psz,然后我们在把new出来的空间给了m_psz,但是这样的问题是,你有考虑过万一new失败了呢?!内存分配失败,m_psz没有指向新的内存空间,但是它却已经把旧的空间给扔掉了,所以显然这样的写法依旧存在着问题。一般高级工程师的写法会是这样的:
1 String& operator=(cosnt String& that) { 2 if(&that != this) { 3 char *psz = strcpy (new char[strlen(that.m_psz)+1],that.m_psz);//如果失败会抛出异常,m_psz最后在析构函数里释放 4 delete[] m_psz; 5 m_psz = psz; 6 } 7 return *this; 8 }
这样考虑的问题便比较全面了。
高级工程师高确实高,然而有没有比高级工程师更高的工程师呢?答案是肯定的。对于从事多年C++开发元老级别资深的C++工程师来讲,他们不会这么写,因为有更好更简便的写法,如下:
1 String& operator=(const String& that) { 2 if(&that != this) { 3 String str (that); 4 char *psz = m_psz; 5 m_psz = str.m_psz; 6 str.m_psz = psz; 7 } 8 return *this; 9 }
有人看出来这样写的玄机了吗??
事实上,这是借助了以上自定义的拷贝构造函数。定义了局部对象str,在拷贝构造中已经为str的成员指针分配了一块内存,所以只需要将str.m_psz与this->m_psz交换指针即可,简化了程序的设计,因为str是局部对象,离开作用域会调用析构函数释放交换给str.m_psz的内存,避免了内存泄露。
大家在读完这篇文章后,对于C++的代码设计,是否有一定的感悟了呢?在我们进行C++的代码设计的过程中,一定要多加思索,考虑问题要全面,精益求精,写出来的代码才经得住推敲!