深入分析C++中的深拷贝与浅拷贝

前些日子写过一篇C++拷贝控制与资源管理的文章,是看过第一遍《C++ primer》之后的总结与分析。最近被问到类似的问题,还是答不上来。所以,最后,知菜后勇。又google了好多博客,体会颇深,故作此篇,共勉。

何时调用

先看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A{
public:
A(){cout<<"constructor"<<endl;}
A(const A &p){cout<<"copy constructor"<<endl;}
A& operator=(const A &p){cout<<"assign"<<endl; return *this;}
~A(){cout<<"destroy"<<endl;}
private:
int a;
string s;
};
void f1(A a){return ;}
A f2(){
A a;
return a;
}
int main(){
A a1; //1
A a2 = a1; //2
A a3 //3
a3 = a1; //4
f1(a1); //5
a3 = f2(); //6
A a4 = f2(); //7
}

运行结果如下:

分析下结果:

  1. 不用说,调用构造函数创建a1,默认初始化.
  2. 用已有对象a1创建不存在的对象a2,虽然用了=号,但是确实是创建了一个新的对象,用a1给它初始化。所以调用的是拷贝构造函数。
  3. 调用构造函数创建a3,默认初始化。
  4. 此时a3已经有了初始值,此处的等号是用a1给a3赋值,所以调用的是拷贝赋值运算符。
  5. 形参没有初始化,使用拷贝构造函数将实参传递给形参。f1()return时局部对象a被销毁,调用析构函数。
  6. f2()中的局部对象a被创建(调用构造函数),默认初始化。return a时拷贝a的值到临时对象(调用的是拷贝赋值运算符)。然后销毁局部对象a(调用析构函数)。
  7. (与想象中的差别挺大,应该是编译器优化了。学习中…)
    总的来说,当用一个已存在的对象创建一个新的对象时,是调用拷贝构造函数。如果是用=号改变当前对象的值时,调用的是拷贝赋值运算符。

深拷贝与浅拷贝

通常,默认生成的拷贝构造函数和赋值运算符,只是简单的进行值的复制,在上例中类的成员intstring,在拷贝或者赋值时进行值复制创建的出来的对象和源对象也是没有任何关联,对源对象的改变不会影响到创建出来的对象。然而,如果有一个成员int *,同样只是进行了值拷贝,此时的源对象与拷贝创建出来的对象中的int *都指向同一个位置,如果改变一个对象的值,也会影响到另一个对象的值。这就是浅拷贝。这时候应该自定义深拷贝的拷贝构造函数来解决这种问题。这有一个通用的原则:

  1. 如果类的成员中有指针或动态分配的内存,应该自定义拷贝构造函数。
  2. 在自定义拷贝构造函数的同时应该也自定义拷贝赋值运算符。

在定义深拷贝的拷贝构造函数时,要注意以下几点:

  1. 对于值类型的成员,应该进行值拷贝。
  2. 对于指针和动态分配的内存成员,应该重新分配内存进行拷贝。

下面实现一下浅拷贝和深拷贝,这里的浅拷贝解决了同一块内存析构两次的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//浅拷贝
class A{
private:
int num;
char *str;
int *use; //引用计数
public:
//构造函数
A(int _num,char *_str):num(_num),str(new char(*_str)),*use(1){ }
A(const &a); //拷贝构造函数
A &operator=(const A &a); //拷贝赋值运算符
~A();
};
A::A(const A &a){
num = a.num;
str = a.str;
++*use;
}
A& A::operator=(const A &a){
++*a.use;
if(--*use == 0){
delete str;
delete use;
}
num = a.num;
str = a.str;
use = a.use;
return *this;
}
A::~A(){
if(--*use == 0){
delete s;
delete use;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//深拷贝
class A{
private:
int num;
char *str;
public:
A(){ }
A(int _num,char *_str):num(_num),str(new char(*_str)){}
A(const &a):num(a.num),str(new char(*a.str)){ }
A& operator=(const A&a);
};
A& A::operator=(const A &a){
if(&a != this){
num = a.num;
char *tmp = new char(*a.str);
delete str;
str = tmp;
}
return *this;
}

补充

  • 对于拷贝构造函数和拷贝赋值运算符的参数,都用的是const类型的引用,用const是因为一来可以提高效率,告诉编译器这部分内容不会修改,从而优化代码;二来是因为,如果形参写为const,那么实参无论是const还是非const都可以调用,而如果形参写成非const,那么调用它的只能是非const,const实参调用会发生编译错误。用引用是因为这样可以避免不必要的拷贝,提高运行效率。

  • 拷贝赋值运算符返回的是一个引用,return *this,这样做是因为可以避免返回时的不必要的拷贝,提高运行效率;其次最重要的一点是因为,返回引用可以实现连续赋值。比如类似(a = b) =c的运算,如果返回的是普通的值类型,那么执行a = b时,将b的值拷贝到一个临时的副本(匿名对象)中,然后把这个匿名对象赋值给a,也就是说a得到的是一个右值,在执行=c时会引发错误。

  • 拷贝赋值运算符函数只能是类的一个非静态成员函数,而不可以是静态成员函数或者友元函数,因为静态成员函数只能操作类的静态成员,显然这样是不行的。不能声明为友元函数是因为,如果声明为友元,类内没有声明以本类或类的引用作为参数的拷贝赋值运算符函数,此时编译器会自动生成一个拷贝赋值运算符函数。现在如果调用拷贝赋值运算符函数,应该调用声明的友元版本还是调用编译器生成的默认版本,会引发二义性,所以C++规定只能声明为类的非静态成员函数。

  • 拷贝赋值运算符函数不可以继承。这是因为如果可以继承的话,在派生类不定义自己的拷贝赋值运算符的情况下调用基类版本。只会负责拷贝基类的部分,而在继承中派生类会额外定义自己的成员变量,此时这部分成员没有拷贝。所以C++规定拷贝赋值运算符函数不可以继承。


参考资料

《C++ primer 5th》
blog1
blog2