在一个设计良好的面向对象系统中,对象的所有内在部分都会被封装起来,只有两个函数是用来复制对象的:即拷贝构造函数和拷贝赋值运算符,这两个函数的功能恰如其名,我们将它们称为拷贝函数。条目5中详细讲述了编译器将会在必要时自动生成拷贝函数,然后该条目还说明了编译器生成的版本可以精确的按你所预期的执行:当前正在复制的对象的所有数据都会得到复制。
当你声明你自己的拷贝函数时,你就向编译器表明,默认实现方式中有一些内容你不喜欢。编译器会把这种做法视为对它的冒犯,它会以一个古怪的方式来报复你:当你的实现几乎一定要出现错误时,它就是不告诉你。
下面示例中是一个表示顾客的类,其中拷贝函数是手动编写的,以便将对它们的调用记入日志:
void logCall(const std::string& funcName);
// 创建一个日志记录
class Customer {
public:
...
Customer(const Customer& rhs);
Customer& operator=(const Customer& rhs);
...
private:
std::string name;
};
Customer::Customer(const Customer& rhs)
: name(rhs.name) // 复制rhs中的数据
{
logCall("Customer copy constructor");
}
Customer& Customer::operator=(const Customer& rhs)
{
logCall("Customer copy assignment operator");
name = rhs.name; // 复制rhs中的数据
return *this; // 参见条目10
}
这里看上去一切正常,而且实际上一切确实是正常的——但当另一个数据成员添加入Customer时,意外就发生了:
class Date { ... }; // 记录日期
class Customer {
public:
... // 同上
private:
std::string name;
Date lastTransaction;
};
现在,前面的拷贝函数将进行部分复制:它们会复制出顾客的姓名(name),但是不会复制最后一次交易(lastTransaction)。迄今为止大多数编译器对此视而不见,即使将警告调至最大模式也不会报出任何信息(另请参见条目53)。如果你自己编写拷贝函数,这便是这些编译器对你的“复仇”。由于你拒绝了编译器提供的拷贝函数,那么编译器就拒绝在你的代码不完整时通知你。结果很明显:如果你为一个类添加了一个数据成员,你必须确保更新相应的拷贝函数。(同时你也需要更新所有的构造函数(参见条目4和条目45),以及类中所有的非标准格式的operator=(参见条目10中的示例)。如果你忘记了,编译器也不会及时提醒你。)
通过继承,这一问题可以带来更加严重却隐蔽的危害,请考虑下边的示例:
class PriorityCustomer: public Customer { // 一个派生类
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和lastTransation进行默认的初始化。
对于PriorityCustomer的拷贝赋值运算符而言,情况有小小的不同。在任何情况下它都不会尝试去修改其基类的数据成员,所以这些数据成员不会得到更新。
一旦你亲自为一个继承类编写了拷贝函数,你必须同时留心其基类的部分。当然这些部分通常情况下是私有的,所以你无法直接访问它们。取而代之的是,派生类的拷贝函数必须调用这些私有数据在基类中相关的函数:
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), // 调用基类的拷贝构造函数
priority(rhs.priority)
{
logCall("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
logCall("PriorityCustomer copy assignment operator");
Customer::operator=(rhs); // 为基类部分赋值
priority = rhs.priority;
return *this;
}
本条目标题中的“不要遗漏任何部分”在这里就显得很清晰了。当你编写拷贝函数时,要确认:(1)复制所有的局部数据成员,(2) 对所有的基类调用适当的拷贝函数。
从实践角度讲,这两个拷贝函数通常会很相似,这似乎会怂恿你去尝试让一个函数去调用另一个来避免代码重复。你避免代码重复的渴望之心是值得称赞的,但是让一个拷贝函数调用另一个并不是一个好的实现方式。
让拷贝赋值运算符调用拷贝构造函数是没有任何意义的,这是因为你是在尝试构造一个已经存在的对象。这实在是毫无意义,甚至没有语法支持这样做。即使存在语法看上去可以让你这样做,但事实上你达不到预期的目的;同时存在语法确实可以迂回实现,但是却会在一些情况下破坏你的对象。所以我不会向你介绍这些语法。你只需清楚让拷贝赋值运算符去调用拷贝构造函数不是一个好主意就可以了。
让我们逆向考虑此问题——让拷贝构造函数去调用拷贝赋值运算符——这样做同样是毫无意义的。一个构造函数初始化新的对象,但是一个赋值运算符仅仅可以应用于已经初始化的对象。对于一个正在构造中的对象而言,对其进行赋值操作就意味着对未初始化的对象进行操作(但是这些操作仅对已初始化的对象起作用)。这是很荒唐的,请不要尝试。
如果你发现你的拷贝构造函数和拷贝赋值运算符很相似时,如果你希望排除重复代码,可以通过创建第三个成员函数供两者调用来取代上面的方法。通常情况下,这样的函数应该是私有的,一般将其命名为init。这样的策略是安全的,排除拷贝构造函数和拷贝赋值运算符中的代码重复,这是一个经过证实安全有效的方法。
时刻牢记
l 要确保拷贝函数拷贝对象的所有的数据成员,及其基类的所有部分,不要有遗漏。
l 不要尝试去实现一个拷贝函数来供其它的拷贝函数调用。你应该把公共部分放入一个“第三方函数”中共所有拷贝函数调用。