跳转至

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(100,0);
func(bucks);

创建了一个Currency对象bucksfunc(bucks)调用func,将bucks作为参数传递,由于func使用值传递,C++会调用Currency的拷贝构造函数,基于bucks创建一个新对象ppbucks的副本,包含相同的数据,但是位于不同的内存地址。

拷贝构造函数

拷贝构造函数的典型签名是T::T(const T&),用于实现拷贝。需要注意的是,拷贝构造函数的第一个参数必须是同类对象的引用,并且无返回类型,像以下几个都是不对的:

MyClass&(MyClass x);
MyClass(Myclass x);
MyClass(Myclass *x);

第一个有返回类型,故错误;第二个参数按值传递,调用自身会导致无限递归,也错误;第三个参数是指针,而拷贝构造函数要求引用类型。

如果我们没有显式定义拷贝构造函数的话,编译器会自动生成逐成员的拷贝构造函数(我们称之为默认拷贝构造函数),但是这样的拷贝构造函数是存在一定风险的,如果类包含指针,那么就可能导致浅拷贝(指针共享内存),这会导致比较严重的内存问题。比如我们看下面这个例子:

class Person{
public:
    Person(const char *s);
    Person();
    void print();
private:
    char *name
}

对于这个例子来说,如果Person类没有定义拷贝构造函数的话,那么编译器生成的默认拷贝构造函数就会直接拷贝name指针的值,结果是两个name对象(原对象和副本)的name指针指向同一块内存(浅拷贝),那么就会导致当其中一个对象被销毁的时候,其析构函数已经释放了name指向的内存,此时另一个对象的name指针就会变成悬空指针(指向已释放的内存),后续访问或释放会导致未定义行为。

这里有必要先简单介绍一下拷贝的两种类型——浅拷贝深拷贝,浅拷贝是在拷贝指针;而深拷贝是拷贝整个数据块。具体来说,浅拷贝仅仅拷贝了指针的值,新对象和原对象的指针指向的是同一块内存,这样会导致内存共享,析构时可能双重释放或产生悬空指针;深拷贝则是为新对象分配新内存,并复制指针指向的数据。

拷贝构造函数何时被调用

  1. 对象初始化:即使用现有对象初始化新对象的时候,如下面的例子:
Person baby_a("Tom");
Person baby_b = baby_a;
Person baby_c(baby_a);

无论是用=还是括号进行初始化,都会调用拷贝构造函数

  1. 值传递:函数参数按值传递的时候会调用拷贝构造函数,比如下面的例子:
void roster(Person child){...}
Person baby_a("Tom");
roster(baby_a); // 拷贝构造函数调用
  1. 函数返回对象:函数返回对象(非引用)的时候,可能触发拷贝构造函数(尽管编译器可能做一些优化,如RVO),比如下面这个例子:
Person copy_func(){
    Person local("Tom");
    return local;
}

构造(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(),返回引用,通常引用函数内部的局部对象或传入的对象。返回局部对象的引用会导致未定义行为。

移动构造函数


评论