什么时候一个空类在实际上并不是空类呢?我们说,在C++处理它的时候。对于一个类来说,如果你不自己手动声明一个复制构造函数、一个赋值运算符、和一个析构函数,编译器就会自动为你声明这些函数。而且,如果你根本没有声明构造函数的话,编译器也将为你声明一个默认构造函数。所有这些函数将是public的并且是inline的(参见条目30)。举例说,如果你编写了:
它在本质上讲与下边这个类是等价的:
class Empty {
public:
Empty() { ... } // 默认构造函数
Empty(const Empty& rhs) { ... } // 拷贝构造函数
~Empty() { ... } // 析构函数
// 下文将分析它是否为虚函数
Empty& operator=(const Empty& rhs) // 赋值运算符
{ ... }
};
这些函数只有在需要的时候才会生成,但是需要他们是经常的事情。以下的代码可以生成每一个函数:
Empty e1; // 默认构造函数
// 析构函数
Empty e2(e1); // 复制构造函数
e2 = e1; // 赋值运算符
现在我们知道编译器为你编写了这些函数,那么这些函数是做什么的呢?默认构造函数和析构函数主要作用是为编译器提供一个放置“幕后代码”的空间,“幕后代码”完成的是诸如对于基类和非静态数据成员的构造函数和析构函数的调用。请注意,对于由编译器生成的析构函数,除非所在的类继承自一个拥有虚析构函数的基类(这个情况下,析构函数的虚拟性继承自它的基类),其他情况均不是虚函数(参见条目7)。
对于复制构造函数和赋值运算符而言,编译器所生成的版本只是简单地把原对象中所有的非静态数据成员复制到目标对象里。请参见下边的NamedObject模板,它让你能够使用名字来访问T类型对象:
template<typename T>
class NamedObject {
public:
NamedObject(const char *name, const T& value);
NamedObject(const std::string& name, const T& value);
...
private:
std::string nameValue;
T objectValue;
};
由于NamedObject中声明了一个构造函数,编译器则不会为你自动生成一个默认构造函数。这一点很重要。这意味着如果这个类已经经过你认真仔细的设计,你认为它的构造函数必须包含参数,这时候你便不需要担心编译器会违背你的意愿,轻率地在你的类中添加一个没有参数的构造函数。
NamedObject没有声明复制构造函数和赋值运算符,所以编译器将会自动生成这些函数(在需要的时候)。请看下面代码中对复制构造函数的应用:
NamedObject<int> no1("Smallest Prime Number", 2);
NamedObject<int> no2(no1); // 调用复制构造函数
由编译器自动生成的这一复制构造函数必须要分别使用no1.nameValue和no1.objectValue来初始化no2.nameVaule和no2.objectValue。nameValue是一个string,由于标准字符串类型带有一个复制构造函数,所以no2.nameValue将通过调用string的复制构造函数(以no1.nameValue作为其参数)得到初始化。另外,NamedObject<int>::ObjectValue是int型的(这是因为对于当前的模板实例来说,T是int型的),而int是一个内建类型,所以no2.objectValue将通过复制no1.objectValue来得到初始化。
由编译器自动生成的NamedObject<int>的拷贝赋值运算符与上述复制构造函数的行为基本一致,但是大体上讲,编译器自动生成的拷贝赋值运算符只有在生成代码合法、有存在的价值时,才会像上文中我所描述的那种方式运行。如果其中任意一条无法满足,编译器将会拒绝为你的类生成一个operator=。
请看下边的示例,如果NamedObject被定义成这样,nameValue是一个指向字符串的引用,而objectValue是一个const T:
template<class T>
class NamedObject {
public:
// 以下的构造函数中的name参数不再是const的了,
// 这是因为现在nameValue是一个指向非const的string的引用。
// char*参数的构造函数已经不复存在了,
// 这是因为这里必须存在一个string引用。
NamedObject(std::string& name, const T& value);
... // 如前所述,假设没有声明任何 operator=
private:
std::string& nameValue; // 现在是一个引用
const T objectValue; // 现在为const的
};
现在请你思考接下来会发生什么事情:
std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2); // 在我最初编写这段代码的时候我们的狗
// Persephone正要度过她的两周岁生日
NamedObject<int> s(oldDog, 36); // 我家的狗Satch(在我小时候养的)
// 如果她现在还活着应该有36岁了
p = s; // 对于p中的数据成员将会发生什么呢?
在赋值之前,p.nameValue和s.nameValue都引用了一个string对象,但不是同一个。那么赋值操作又怎么会影响到p.nameValue呢?在赋值之后,p.nameValue是否应引用由s.nameValue中保存的string应用呢?换句话说,引用是否可以被更改呢?如果可以的话,我们就开创了一个全新的议题,因为C++并没有提供任何方法让一个引用改变其所引用的对象。换个角度说,p.nameValue所引用的string对象是否可以被修改,从而影响到包含指向这个string对象的指针或引用的其他对象(换句话说,此次赋值中未直接涉及到的对象)呢?这是否是编译器自动生成的拷贝赋值运算符应该做的呢?
面对这一难题,C++拒绝编译这类代码。如果你希望让包含引用成员的类支持赋值操作,就必须自己定义拷贝赋值运算符。对于包含const成员的类(比如上文中修改后的objectValue)编译器也会做类似处理。由于修改const成员是非法的,因此编译器无法在一个隐式生成的赋值函数中确定如何处理它们。最终,如果一个基类中的拷贝赋值运算符是声明为private的,那么在派生类中编译器将拒绝隐式生成拷贝赋值运算符。毕竟,编译器为派生类自动生成的拷贝赋值运算符也要处理基类中相应的部分(参见条目12),但是在这些拷贝赋值运算符处理相应的基类部分时,是肯定不能调用派生类中无权调用的数据成员的。
时刻牢记
l 编译器可能会隐式为一个类生成默认构造函数、复制构造函数、拷贝赋值运算符和析构函数。