拷贝构造

发布时间 2023-11-22 20:56:38作者: ZTer
  • 拷贝构造由拷贝构造函数完成,发生在两个对象之间的赋值操作的时候。

拷贝构造是什么

引例

我们先来看一段代码和它的运行结果:

/* In test91.h */

#ifndef TEST91_H_
#define TEST91_H_

#include <iostream>
using namespace std;

class NumCounter
{
public:
  NumCounter();
  NumCounter(int i);
  ~NumCounter();
  inline void Print(){ cout << "Print() called, data = " << data << endl;}
  static int cnt;
private:
  int data;
};

#endif

/* In test91.cpp */

#include "test91.h"
#include <iostream>

int NumCounter::cnt(0);

NumCounter::NumCounter() : data(0)
{
  cnt ++;
  cout << "Default counstuctor called, data = 0, cnt = " << cnt << endl;
}

NumCounter::NumCounter(int i) : data(i)
{
  cnt ++;
  cout << "Constructor called, data = " << data << ", cnt = " << cnt << endl;
}

NumCounter::~NumCounter()
{
  cnt --;
  cout << "Distructor called, cnt = " << cnt << endl; 
}

/* In main.cpp */

#include "test91.h"
#include <iostream>

using namespace std;

inline void f(NumCounter x)
{
  cout << "f(NumberCounter x) called" << endl;
  x.Print();
  cout << "endof f(NumberCounter x)" << endl;
}

int main()
{
  NumCounter a;
  NumCounter b(10);
  f(b);
  return 0;
}

我知道上面那一坨没有注释的屎山没几个人想看,这里简单说一下这个程序是干啥的

  • 首先我们有一个类叫做 NumberCounter,它有两个构造函数——带一个参数的(Constructor)和不带参数的(Default constructor)、一个析构函数(Destructor)和一个 Print 函数(用来输出 data)。
  • 当构造函数被调用的时候,计数器 cnt 会加一,析构函数被调用的时候,计数器 cnt 会减一。
  • 每当构造或者析构函数被调用的时候,都会输出这个计数器,这样我们就可以实时观测有多少个对象。

我们来运行一下这个程序,结果如下:

首先我们用 Default constructor 和 Constructor 创建了对象 a, b,此时 cnt = 2,正确。

然后是调用 f 函数,带入参数 b,调用 Print 函数,输出了 b 的 data,10。

紧接着奇怪的现象发生了:退出 f 函数之后,析构函数居然连续调用了 3 次——但是构造函数只被调用了两次,而没有被构造的对象是无法被析构的!

这就说明,这一段代码里有一些隐藏的事情发生了,导致两个构造函数被绕开。

拷贝构造

  • 我们知道,在 C++ 中,如果函数的参数是某个对象的话,我们传参之后会发生一次拷贝来创建一个这个做参数对象的副本。这个副本是一个临时变量,由副本参与函数的运算而不改变原本做参数的对象的值。

在上面那个例子中,同样发生了这样的事情,即隐式地发生了 NumberCounter x(b); 这样的操作。

也许你会说:那有什么关系呢,直接把数据迁移过去就好了啊!

但是实际上并没有这样一个构造函数让你可以用 b 去初始化 x,之所以没报错,是因为编译器救了我们。

——它为我们补全了拷贝构造函数。

拷贝构造函数和别的构造函数类似,写法如下:

Number(const Number& x);

注意:

  • 它会在我们尝试两个对象之间赋值或用一个对象去初始化另一个对象的时候自动调用。

  • 拷贝构造函数的参数必须是常量引用。如果不是引用,那么在调用拷贝构造函数的时候又会进行拷贝,而这次拷贝又要调用拷贝构造函数……这样递归循环下去无穷无尽。加上常量的目的是保证做参数的对象不会在拷贝构造里被修改。

让我们为先前的代码补全拷贝构造函数:

/* 在头文件的 public 中声明拷贝构造函数 */
NumCounter(const NumCounter& x);

/* 在 test91 里实现拷贝构造 */
NumCounter::NumCounter(const NumCounter& x) : data(x.data)
//注意 private 只是针对类外而言,同属一类的对象之间可以互相访问 private 成员,所以这里可以直接 x.data
{
  cnt ++;
  cout << "Copy constructor called, data = " << data << ", cnt = " << cnt << endl;
}

再来运行一下之前的程序:

很明显,在执行 f 函数的时候调用了拷贝构造函数,我们构造了三次,析构了三次。

总结一下,在下面代码中,将调用拷贝构造函数。

NumberCounter a;
NumberCounter b(10);

a = b;//Copy constructor called!
NumberCounter c(b);//Copy constructor called!
NumberCounter d = b;//Copy constructor called!

void f(NumberCounter x)//Copy constructor called! 隐式赋值操作
{
  x.Print();
}

请注意,拷贝构造只在初始化的时候会被调用,赋值时不会调用。

你可以简单的通过初始化的前面一定有类型名,而赋值的前面没有类型名来简单的辨别它们:

NumberCounter c = b; 是初始化而 NumberCounter c; c = b; 是先初始化后赋值。

为什么需要拷贝构造

拷贝构造的工作是使用一个对象来初始化另一个对象,而手写的拷贝构造可以使这个过程更具个性化。譬如有时候我们不想拷贝所有的数据,又或者我们需要把拷贝的数据做一下处理,等等。

此外,使用系统默认的拷贝构造函数有一定的风险:

#include <iostream>
#include <cstring>

using namespace std;

class Person
{
public:
  Person(const char* s)
  {
    name = new char[strlen(s) + 1];
    strcpy(name, s);
  }
  ~Person()
  {
    delete[] name;
  }
  inline void Print()
  {
    cout << name << endl;
  }
  char* name;//为了方便,我们公开 name 变量
};

int main()
{
  Person p1("ZTer");
  Person p2(p1);
  printf("p1.name = %s\np2.name = %s", p1.name, p2.name);
  return 0;
}

在上面这段代码中,每个类有一个指针类型 name,结果最后报错了,提示 free(): double free detected in tcache 2

究其原因,是因为拷贝构造函数是一个成员一个成员来进行拷贝的,这样一来,一个 int 就拷贝给一个 int,一个指针就拷贝给一个指针,一个对象就去调用它所属类内的拷贝构造拷贝给一个对象。

但是这样拷贝是有问题的,会导致两个指针指向同一块内存(默认拷贝构造不会聪明到帮我们新申请一块内存)。我们在析构函数中要用 delete 释放内存,这导致同一块内存被释放了两次,于是报错。

所以这时候就需要我们去把拷贝构造写好:

Person(const Person& x)
{
  name = new char[strlen(x.name) + 1];
  strcpy(name, x.name);
}

ps:实际上 char* 是 C 风格的字符串类型,它的本质是一个指针,因此我们需要特别照顾它一下。但C++ STL 提供 string 类型的字符串,string 类型是一个完整的类,包括它自己的安全的拷贝构造。只要我们把上面的 char* 换成 string ,就不用重写拷贝构造了(自动补全的拷贝构造会调用 string 的拷贝构造,因此是安全的)。

编译器的优化行为

因为拷贝构造涉及到很多次赋值,为了提高代码运行速率,有时候编译器会做一些优化:

Person New(char* s)
{
  Person ip(s);
  return ip;
}

/* 在主函数中 */
Person p = New("ZTer");

这时并不会调用拷贝构造函数,因为编译器判断出这个函数实际上就是用 s 来初始化 p,于是它被优化为 Person p("ZTer");

小结

  • 总体

    一旦正经的写一个类,一定要写三个函数:Default Constructor, Virtual Distructor, Copy Constructor。不管当前用没用到,它们涉及类的扩展和派生,相当于为以后修改代码提供方便。