Week10 Copy and Move
拷贝(Copying)与拷贝构造函数(Copy Constructor)¶
拷贝¶
拷贝指的是从现有对象创建新对象的副本,比如常见的值传递、初始化等等都是这么进行的,可以看下面这个例子:
void func(Currency p){
cout << "X=" << p.dollars(); /* 这里函数func接受一个Currency类型的参数p,以值传递的方式传递;
而值传递就意味着参数p是调用者传入对象的一个副本,并非原始对象*/
}
由于是值传递,在调用func
的时候,C++会为p
创建一个新对象,通过Currency
类的拷贝构造函数(如果没有定义的话,就会使用编译器生成的默认拷贝构造函数)从传入的对象拷贝数据,拷贝构造函数的典型签名是Currency::Currency(const Currency&)
,这里的const Currency &
是常量引用,避免不必要的拷贝且允许临时对象
创建了一个Currency
对象bucks
,func(bucks)
调用func
,将bucks
作为参数传递,由于func
使用值传递,C++会调用Currency
的拷贝构造函数,基于bucks
创建一个新对象p
,p
是bucks
的副本,包含相同的数据,但是位于不同的内存地址。
拷贝构造函数¶
拷贝构造函数的典型签名是T::T(const T&)
,用于实现拷贝。需要注意的是,拷贝构造函数的第一个参数必须是同类对象的引用,并且无返回类型,像以下几个都是不对的:
第一个有返回类型,故错误;第二个参数按值传递,调用自身会导致无限递归,也错误;第三个参数是指针,而拷贝构造函数要求引用类型。
如果我们没有显式定义拷贝构造函数的话,编译器会自动生成逐成员的拷贝构造函数(我们称之为默认拷贝构造函数),但是这样的拷贝构造函数是存在一定风险的,如果类包含指针,那么就可能导致浅拷贝(指针共享内存),这会导致比较严重的内存问题。比如我们看下面这个例子:
对于这个例子来说,如果Person
类没有定义拷贝构造函数的话,那么编译器生成的默认拷贝构造函数就会直接拷贝name
指针的值,结果是两个name
对象(原对象和副本)的name
指针指向同一块内存(浅拷贝),那么就会导致当其中一个对象被销毁的时候,其析构函数已经释放了name
指向的内存,此时另一个对象的name
指针就会变成悬空指针(指向已释放的内存),后续访问或释放会导致未定义行为。
这里有必要先简单介绍一下拷贝的两种类型——浅拷贝和深拷贝,浅拷贝是在拷贝指针;而深拷贝是拷贝整个数据块。具体来说,浅拷贝仅仅拷贝了指针的值,新对象和原对象的指针指向的是同一块内存,这样会导致内存共享,析构时可能双重释放或产生悬空指针;深拷贝则是为新对象分配新内存,并复制指针指向的数据。
拷贝构造函数何时被调用¶
- 对象初始化:即使用现有对象初始化新对象的时候,如下面的例子:
无论是用=
还是括号进行初始化,都会调用拷贝构造函数
- 值传递:函数参数按值传递的时候会调用拷贝构造函数,比如下面的例子:
- 函数返回对象:函数返回对象(非引用)的时候,可能触发拷贝构造函数(尽管编译器可能做一些优化,如RVO),比如下面这个例子:
构造(Construction)和赋值(Assignment)¶
- 每个对象只会被构造一次(通过构造函数或者拷贝构造函数等)
- 每个对象只会被销毁一次(通过析构函数),如果没有正确调用
delete
,则可能导致内存泄漏;如果多次调用delete
,则可能导致未定义行为 - 一旦对象构造完成之后,就可以被多次赋值(通过赋值运算符
=
),这与构造是不同的过程
Tip
最后我们总结一下拷贝构造函数的几个小tips:
- 如果类中没有指针成员,我们通常不需要显式地去定义拷贝构造函数,这是因为默认拷贝构造函数(逐成员的)就已经足够了
- 通常情况下我们最好显式地定义拷贝构造函数,以确保行为是符合预期的
- 不要依赖编译器生成的默认拷贝构造函数,最好自己编写拷贝构造函数,尤其是当类涉及到动态分配的资源(比如指针)的时候,以避免浅拷贝导致的问题
- 如果类不需要拷贝构造函数,我们可以在类中声明一个私有的拷贝构造函数(不提供定义),这会阻止编译器生成默认拷贝构造函数,并且如果我们尝试按值传递对象的话,编译器会报错
函数参数和返回值¶
参数的传入方式有三种:
void f(Student i)
,即按值传递,会调用拷贝构造函数创建新对象i
,开销比较大void f(Student *p)
,即传递指针,允许操作指向的对象或动态分配的对象,但是需要手动管理内存void f(Student &p)
,即按引用传递,直接操作传入的对象,避免拷贝带来的开销(如果不修改的话可以用Const Student& p
,这样既可以防止函数修改传入的对象,同时也能避免拷贝)
返回值的方式也有对应的三种:
Student f()
,返回一个新对象,会调用拷贝构造函数。适合返回独立的对象,但是可能有拷贝开销。Student* f()
,返回指向Student
的指针,但是我们需要负责释放内存,很容易导致内存泄漏或者悬空指针。Student &f()
,返回引用,通常引用函数内部的局部对象或传入的对象。返回局部对象的引用会导致未定义行为。