Item 12: 拷贝一个对象的所有组成部分
在设计良好的面向对象系统中,为了压缩其对象内部的空间,仅留两个函数用于对象的拷贝:一般称为拷贝构造函数(copy constructor)和拷贝赋值运算符(copy assignment operator)。我们将它们统称为拷贝函数(copying functions)。Item 5 讲述了如果需要,编译器会生成拷贝函数,而且阐明了编译器生成的版本正象你所期望的:它们拷贝被拷贝对象的全部数据。
当你声明了你自己的拷贝函数,你就是在告诉编译器你不喜欢缺省实现中的某些东西。编译器对此好像怒发冲冠,而且它们会用一种古怪的方式报复:当你的实现存在一些几乎可以确定错误时,它偏偏不告诉你。
考虑一个象征消费者(customers)的类,这里的拷贝函数是手写的,以便将对它们的调用记入日志:
void logCall(const std::string& funcName); // make a log entry
class Customer {
public:
...
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
...
private:
std::string name;
};
Customer::Customer(const Customer& rhs)
: name(rhs.name) // copy rhs's data
{
logCall("Customer copy constructor");
}
Customer& Customer::operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator");
name = rhs.name; // copy rhs's data
return *this; // see Item 10
}
这里的每一件事看起来都不错,实际上也确实不错——直到 Customer 中加入了另外的数据成员:
class Date { ... }; // for dates in time
class Customer {
public:
... // as before
private:
std::string name;
Date lastTransaction;
};
在这里,已有的拷贝函数只进行了部分拷贝:它们拷贝了 Customer 的 name,但没有拷贝它的 lastTransaction。然而,大部分编译器对此毫不在意,即使是在最高的警告级别(maximal warning level)(参见 Item 53)。这是它们在对你写自己的拷贝函数进行报复。你拒绝了它们写的拷贝函数,所以如果你的代码是不完善的,他们也不告诉你。结论显而易见:如果你为一个类增加了一个数据成员,你务必要做到更新拷贝函数。(你还需要更新类中的全部的构造函数(参见 Item 4 和 45)以及任何非标准形式的 operator=(Item 10 给出了一个例子)。如果你忘记了,编译器未必会提醒你。)
这个问题最为迷惑人的情形之一是它会通过继承发生。考虑:
class PriorityCustomer: public Customer { // a derived class
public:
...
PriorityCustomer(const PriorityCustomer& rhs);
PriorityCustomer& operator=(const PriorityCustomer& rhs);
...
private:
int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
priority = rhs.priority;
return *this;
}
PriorityCustomer 的拷贝函数看上去好像拷贝了 PriorityCustomer 中的每一样东西,但是再看一下。是的,它确实拷贝了 PriorityCustomer 声明的数据成员,但是每个 PriorityCustomer 还包括一份它从 Customer 继承来的数据成员的副本,而那些数据成员根本没有被拷贝!PriorityCustomer 的拷贝构造函数没有指定传递给它的基类构造函数的参数(也就是说,在它的成员初始化列表中没有提及 Customer),所以,PriorityCustomer 对象的 Customer 部分被 Customer 的构造函数在无参数的情况下初始化——使用缺省构造函数。(假设它有,如果没有,代码将无法编译。)那个构造函数为 name 和 lastTransaction 进行一次缺省的初始化。
对于 PriorityCustomer 的拷贝赋值运算符,情况有些微的不同。它不会试图用任何方法改变它的基类的数据成员,所以它们将保持不变。
无论何时,你打算自己为一个派生类写拷贝函数时,你必须注意同时拷贝基类部分。那些地方的典型特征当然是 private(参见 Item 22),所以你不能直接访问它们。派生类的拷贝函数必须调用和它们对应的基类函数:
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // invoke base class copy ctor
priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); // assign base class parts
priority = rhs.priority;
return *this;
}
本 Item 标题中的 "copy all parts" 的含义现在应该清楚了。当你写一个拷贝函数,需要保证(1)拷贝所有本地数据成员以及(2)调用所有基类中的适当的拷贝函数。
在实际中,两个拷贝函数经常有相似的函数体,而这一点可能吸引你试图通过用一个函数调用另一个来避免代码重复。你希望避免代码重复的想法值得肯定,但是用一个拷贝函数调用另一个来做到这一点是错误的。
用拷贝赋值运算符调用拷贝构造函数是没有意义的,因为你这样做就是试图去构造一个已经存在的对象。这太荒谬了,甚至没有一种语法来支持它。有一种语法看起来好像能让你这样做,但实际上你做不到,还有一种语法采用迂回的方法这样做,但它们在某种条件下会对破坏你的对象。所以我不打算给你看任何那样的语法。无条件地接受这个观点:不要用拷贝赋值运算符调用拷贝构造函数。
尝试一下另一种相反的方法——用拷贝构造函数调用拷贝赋值运算符——这同样是荒谬的。一个构造函数初始化新的对象,而一个赋值运算符只能用于已经初始化过的对象。借助构造过程给一个对象赋值将意味着对一个尚未初始化的对象做一些事,而这些事只有用于已初始化对象才有意义。简直是胡搞!不要做这种尝试。
作为一种代替,如果你发现你的拷贝构造函数和拷贝赋值运算符有相似的代码,通过创建第三个供两者调用的成员函数来消除重复。这样的函数当然是 private 的,而且经常叫做 init。这一策略是在拷贝构造函数和拷贝赋值运算符中消除代码重复的安全的,被证实过的方法。
Things to Remember
拷贝函数应该保证拷贝一个对象的所有数据成员以及所有的基类部分。不要试图依据一个拷贝函数实现另一个。作为代替,将通用功能放入第三个供双方调用的函数。