Effective C++ 2e: 构造函数,析构函数和赋值操作符

构造函数,析构函数和赋值操作符

几乎所有的类都有一个或多个构造函数,一个析构函数和一个赋值操作符。这没什么奇怪的,因为它们提供的都是一些最基本的功能。构造函数控制对象生成时的基本操作,并保证对象被初始化;析构函数摧毁一个对象并保证它被彻底清除;赋值操作符则给对象一个新的值。在这些函数上出错就会给整个类带来无尽的负面影响,所以一定要保证其正确性。本章我将指导如何用这些函数来搭建一个结构良好的类的主干。

条款11: 为需要动态分配内存的类声明一个拷贝构造函数和一个赋值操作符

看下面一个表示String对象的类:

// 一个很简单的String类
class String {
public:
  String(const char *value);
  ~String();

  ...                           // 没有拷贝构造函数和operator=

private:
  char *data;
};

String::String(const char *value)
{
  if (value) {
    data = new char[strlen(value) + 1];
    strcpy(data, value);
  }
  else {
    data = new char[1];
    *data = '/0';
  }
}

inline String::~String() { delete [] data; }

请注意这个类里没有声明赋值操作符和拷贝构造函数。这会带来一些不良后果。

如果这样定义两个对象:

String a("Hello");
String b("World");

其结果就会如下所示:

a:  data——> "Hello/0"
b:  data——> "World/0"

对象a的内部是一个指向包含字符串"Hello"的内存的指针,对象b的内部是一个指向包含字符串"World"的内存的指针。如果进行下面的赋值:

b = a;

因为没有自定义的operator=可以调用,C++会生成并调用一个缺省的operator=操作符(见条款45)。这个缺省的赋值操作符会执行从a的成员到b的成员的逐个成员的赋值操作,对指针(a.data和b.data) 来说就是逐位拷贝。赋值的结果如下所示:

a:  data --------> "Hello/0"
            /
b:  data --/       "World/0"

这种情况下至少有两个问题。第一,b曾指向的内存永远不会被删除,因而会永远丢失。这是产生内存泄漏的典型例子。第二,现在a和b包含的指针指向同一个字符串,那么只要其中一个离开了它的生存空间,其析构函数就会删除掉另一个指针还指向的那块内存。

String a("Hello");                 // 定义并构造 a

{                                  // 开一个新的生存空间
  String b("World");               // 定义并构造 b

  ...

  b = a;                           // 执行 operator=,
                                   // 丢失b的内存

}                                  // 离开生存空间, 调用
                                   // b的析构函数

String c = a;                      // c.data 的值不能确定!
                                   // a.data 已被删除

例子中最后一个语句调用了拷贝构造函数,因为它也没有在类中定义,C++以与处理赋值操作符一样的方式生成一个拷贝构造函数并执行相同的动作:对对象里的指针进行逐位拷贝。这会导致同样的问题,但不用担心内存泄漏,因为被初始化的对象还不能指向任何的内存。比如上面代码中的情形,当c.data用a.data的值来初始化时没有内存泄漏,因为c.data没指向任何地方。不过,假如c被a初始化后,c.data和a.data指向同一个地方,那这个地方会被删除两次:一次在c被摧毁时,另一次在a被摧毁时。

拷贝构造函数的情况和赋值操作符还有点不同。在传值调用的时候,它会产生问题。当然正如条款22所说明的,一般很少对对象进行传值调用,但还是看看下面的例子:

void doNothing(String localString) {}

String s = "The Truth Is Out There";

doNothing(s);

一切好象都很正常。但因为被传递的localString是一个值,它必须从s通过(缺省)拷贝构造函数进行初始化。于是localString拥有了一个s内的指针的拷贝。当doNothing结束运行时,localString离开了其生存空间,调用析构函数。其结果也将是:s包含一个指向localString早已删除的内存的指针。

顺便指出,用delete去删除一个已经被删除的指针,其结果是不可预测的。所以即使s永远也没被使用,当它离开其生存空间时也会带来问题。

解决这类指针混乱问题的方案在于,只要类里有指针时,就要写自己版本的拷贝构造函数和赋值操作符函数。在这些函数里,你可以拷贝那些被指向的数据结构,从而使每个对象都有自己的拷贝;或者你可以采用某种引用计数机制(见条款 M29)去跟踪当前有多少个对象指向某个数据结构。引用计数的方法更复杂,而且它要求构造函数和析构函数内部做更多的工作,但在某些(虽然不是所有)程序里,它会大量节省内存并切实提高速度。

对于有些类,当实现拷贝构造函数和赋值操作符非常麻烦的时候,特别是可以确信程序中不会做拷贝和赋值操作的时候,去实现它们就会相对来说有点得不偿失。前面提到的那个遗漏了拷贝构造函数和赋值操作符的例子固然是一个糟糕的设计,那当现实中去实现它们又不切实际的情况下,该怎么办呢?很简单,照本条款的建议去做:可以只声明这些函数(声明为private成员)而不去定义(实现)它们。这就防止了会有人去调用它们,也防止了编译器去生成它们。关于这个俏皮的小技巧的细节,参见条款27。

关于本条款中所用到的那个String类,还要注意一件事。构造函数体内,在两个调用new的地方都小心地用了[],尽管有一个地方实际只需要单个对象。正如条款5所说,在配套使用new和delete时一定要采用相同的形式,所以这里也这么做了。一定要经常注意,当且仅当相应的new用了[]的时候,delete才要用[]。

条款12: 尽量使用初始化而不要在构造函数里赋值

看这样一个模板,它生成的类使得一个名字和一个T类型的对象的指针关联起来。

template<class T>
class NamedPtr {
public:
  NamedPtr(const string& initName, T *initPtr);
  ...

private:
  string name;
  T *ptr;
};

(因为有指针成员的对象在进行拷贝和赋值操作时可能会引起指针混乱(见条款11),NamedPtr也必须实现这些函数(见条款2))

在写NamedPtr构造函数时,必须将参数值传给相应的数据成员。有两种方法来实现。第一种方法是使用成员初始化列表:

template<class T>
NamedPtr<T>::NamedPtr(const string& initName, T *initPtr  )
: name(initName), ptr(initPtr)
{}

第二种方法是在构造函数体内赋值:

template<class T>
NamedPtr<T>::NamedPtr(const string& initName, T *initPtr)
{
  name = initName;
  ptr = initPtr;
}

两种方法有重大的不同。

从纯实际应用的角度来看,有些情况下必须用初始化。特别是const和引用数据成员只能用初始化,不能被赋值。所以,如果想让NamedPtr<T>对象不能改变它的名字或指针成员,就必须遵循条款21的建议声明成员为const:

template<class T>
class NamedPtr {
public:
  NamedPtr(const string& initName, T *initPtr);
  ...

private:
  const string name;
  T * const ptr;
};

这个类的定义要求使用一个成员初始化列表,因为const成员只能被初始化,不能被赋值。

如果NamedPtr<T>对象包含一个现有名字的引用,情况会非常不同。但还是要在构造函数的初始化列表里对引用进行初始化。还可以对名字同时声明const和引用,这样就生成了一个其名字成员在类外可以被修改而在内部是只读的对象。

template<class T>
class NamedPtr {
public:
  NamedPtr(const string& initName, T *initPtr);
  ...

private:
  const string& name;               // 必须通过成员初始化列表
                                    // 进行初始化

  T * const ptr;                    // 必须通过成员初始化列表
                                    // 进行初始化
};

然而前面最初的类模板不包含const和引用成员。即使这样,用成员初始化列表还是比在构造函数里赋值要好。这次的原因在于效率。当使用成员初始化列表时,只有一个string成员函数被调用。而在构造函数里赋值时,将有两个被调用。为了理解为什么,请看在声明NamedPtr<T>对象时都发生了些什么。

对象的创建分两步:
1. 数据成员初始化。(参见条款13)
2. 执行被调用构造函数体内的动作。

(对有基类的对象来说,基类的成员初始化和构造函数体的执行发生在派生类的成员初始化和构造函数体的执行之前)

对NamedPtr类来说,这意味着string对象name的构造函数总是在程序执行到NamedPtr的构造函数体之前就已经被调用了。问题只在于:string的哪个构造函数会被调用?

这取决于NamedPtr类的成员初始化列表。如果没有为name指定初始化参数,string的缺省构造函数会被调用。当在NamedPtr的构造函数里对name执行赋值时,会对name调用operator=函数。这样总共有两次对string的成员函数的调用:一次是缺省构造函数,另一次是赋值。

相反,如果用一个成员初始化列表来指定name必须用initName来初始化,name就会通过拷贝构造函数以仅一个函数调用的代价被初始化。

即使是一个很简单的string类型,不必要的函数调用也会造成很高的代价。随着类越来越大,越来越复杂,它们的构造函数也越来越大而复杂,那么对象创建的代价也越来越高。养成尽可能使用成员初始化列表的习惯,不但可以满足const和引用成员初始化的要求,还可以大大减少低效地初始化数据成员的机会。

换句话说,通过成员初始化列表来进行初始化总是合法的,效率也决不低于在构造函数体内赋值,它只会更高效。另外,它简化了对类的维护(见条款M32),因为如果一个数据成员以后被修改成了必须使用成员初始化列表的某种数据类型,那么,什么也不用变。

但有一种情况下,对类的数据成员用赋值比用初始化更合理。这就是当有大量的固定类型的数据成员要在每个构造函数里以相同的方式初始化的时候。例如,这里有个类可以用来说明这种情形:

class ManyDataMbrs {
public:
  // 缺省构造函数
  ManyDataMbrs();

  // 拷贝构造函数
  ManyDataMbrs(const ManyDataMbrs& x);

private:
  int a, b, c, d, e, f, g, h;
  double i, j, k, l, m;
};

假如想把所有的int初始化为1而所有的double初始化为0,那么用成员初始化列表就要这样写:

ManyDataMbrs::ManyDataMbrs()
: a(1), b(1), c(1), d(1), e(1), f(1), g(1), h(1), i(0),
  j(0), k(0), l(0), m(0)
{ ... }

ManyDataMbrs::ManyDataMbrs(const ManyDataMbrs& x)
: a(1), b(1), c(1), d(1), e(1), f(1), g(1), h(1), i(0),
  j(0), k(0), l(0), m(0)
{ ... }

这不仅仅是一项讨厌而枯燥的工作,而且从短期来说它很容易出错,从长期来说很难维护。

然而你可以利用固定数据类型的(非const, 非引用)对象其初始化和赋值没有操作上的不同的特点,安全地将成员初始化列表用一个对普通的初始化函数的调用来代替。

class ManyDataMbrs {
public:
  // 缺省构造函数
  ManyDataMbrs();

  // 拷贝构造函数
  ManyDataMbrs(const ManyDataMbrs& x);

private:
  int a, b, c, d, e, f, g, h;
  double i, j, k, l, m;

  void init();        // 用于初始化数据成员
                     
};

void ManyDataMbrs::init()
{
  a = b = c = d = e = f = g = h = 1;
  i = j = k = l = m = 0;
}

ManyDataMbrs::ManyDataMbrs()
{
  init();

  ...

}

ManyDataMbrs::ManyDataMbrs(const ManyDataMbrs& x)
{
  init();

  ...

}

因为初始化函数只是类的一个实现细节,所以当然要把它声明为private成员。

请注意static类成员永远也不会在类的构造函数初始化。静态成员在程序运行的过程中只被初始化一次,所以每当类的对象创建时都去“初始化”它们没有任何意义。至少这会影响效率:既然是“初始化”,那为什么要去做多次?而且,静态类成员的初始化和非静态类成员有很大的不同,这专门有一个条款M47来说明。

条款13: 初始化列表中成员列出的顺序和它们在类中声明的顺序相同

顽固的Pascal和Ada程序员会经常想念那种可以任意设定数组下标上下限的功能,即,数组下标的范围可以设为10到20,不一定要是0到10。资深的C程序员会坚持一定要从0开始计数,但想个办法来满足那些还在用begin/end的人的这个要求也很容易,这只需要定义一个自己的Array类模板:

template<class T>
class Array {
public:
  Array(int lowBound, int highBound);
  ...

private:
  vector<T> data;               // 数组数据存储在vector对象中
                                // 关于vector模板参见条款49

  size_t size;                  // 数组中元素的数量

  int lBound, hBound;           // 下限,上限
};

template<class T>
Array<T>::Array(int lowBound, int highBound)
: size(highBound - lowBound + 1),
  lBound(lowBound), hBound(highBound),
  data(size)
{}

构造函数会对参数进行合法性检查,以保证highBound至少要大于等于lowBound,但这里有个很糟糕的错误:即使数组的上下限值合法,也绝对没人会知道data里会有多少个元素。

“这怎么可能?”我听见你在叫。“我小心地初始化了size后才把它传给vector的构造函数!”但不幸的是,你没有——你只是想这样做,但没遵守游戏规则:类成员是按照它们在类里被声明的顺序进行初始化的,和它们在成员初始化列表中列出的顺序没一点关系。用上面的Array模板生成的类里,data总会被首先初始化,然后是size, lBound和hBound。

看起来似乎有悖常理,但这么做是有理由的。看下面这种情况:

class Wacko {
public:
  Wacko(const char *s): s1(s), s2(0) {}
  Wacko(const Wacko& rhs): s2(rhs.s1), s1(0) {}

private:
  string s1, s2;
};

Wacko w1 = "Hello world!";
Wacko w2 = w1;

如果成员按它们在初始化列表上出现的顺序被初始化,那w1和w2中的数据成员被创建的顺序就会不同。我们知道,对一个对象的所有成员来说,它们的析构函数被调用的顺序总是和它们在构造函数里被创建的顺序相反。那么,如果允许上面的情况(即,成员按它们在初始化列表上出现的顺序被初始化)发生,编译器就要为每一个对象跟踪其成员初始化的顺序,以保证它们的析构函数以正确的顺序被调用。这会带来昂贵的开销。所以,为了避免这一开销,同一种类型的所有对象在创建(构造)和摧毁(析构)过程中对成员的处理顺序都是相同的,而不管成员在初始化列表中的顺序如何。

实际上,如果你深究一下的话,会发现只是非静态数据成员的初始化遵守以上规则。静态数据成员的行为有点象全局和名字空间对象,所以只会被初始化一次(详见条款47)。另外,基类数据成员总是在派生类数据成员之前被初始化,所以使用继承时,要把基类的初始化列在成员初始化列表的最前面。(如果使用多继承,基类被初始化的顺序和它们被派生类继承的顺序一致,它们在成员初始化列表中的顺序会被忽略。使用多继承有很多地方要考虑。条款43关于多继承应考虑哪些方面的问题提出了很多建议。)

基本的一条是:如果想弄清楚对象被初始化时到底是怎么做的,请确信你的初始化列表中成员列出的顺序和成员在类内声明的顺序一致。

条款14: 确定基类有虚析构函数

有时,一个类想跟踪它有多少个对象存在。一个简单的方法是创建一个静态类成员来统计对象的个数。这个成员被初始化为0,在构造函数里加1,析构函数里减1。(条款M26里说明了如何把这种方法封装起来以便很容易地添加到任何类中,“my article on counting objects”提供了对这个技术的另外一些改进)

设想在一个军事应用程序里,有一个表示敌人目标的类:

class EnemyTarget {
public:
  EnemyTarget() { ++numTargets; }
  EnemyTarget(const EnemyTarget&) { ++numTargets; }
  ~EnemyTarget() { --numTargets; }

  static size_t numberOfTargets()
  { return numTargets; }

  virtual bool destroy();       // 摧毁EnemyTarget对象后
                                // 返回成功

private:
  static size_t numTargets;     // 对象计数器
};

// 类的静态成员要在类外定义;
// 缺省初始化为0
size_t EnemyTarget::numTargets;

这个类不会为你赢得一份政府防御合同,它离国防部的要求相差太远了,但它足以满足我们这儿说明问题的需要。

敌人的坦克是一种特殊的敌人目标,所以会很自然地想到将它抽象为一个以公有继承方式从EnemyTarget派生出来的类(参见条款35及M33)。因为不但要关心敌人目标的总数,也要关心敌人坦克的总数,所以和基类一样,在派生类里也采用了上面提到的同样的技巧:

class EnemyTank: public EnemyTarget {
public:
  EnemyTank() { ++numTanks; }

  EnemyTank(const EnemyTank& rhs)
  : EnemyTarget(rhs)
  { ++numTanks; }

  ~EnemyTank() { --numTanks; }

  static size_t numberOfTanks()
  { return numTanks; }

  virtual bool destroy();

private:
  static size_t numTanks;         // 坦克对象计数器
};

(写完以上两个类的代码后,你就更能够理解条款M26对这个问题的通用解决方案了。)

最后,假设程序的其他某处用new动态创建了一个EnemyTank对象,然后用delete删除掉:

EnemyTarget *targetPtr = new EnemyTank;

...

delete targetPtr;

到此为止所做的一切好象都很正常:两个类在析构函数里都对构造函数所做的操作进行了清除;应用程序也显然没有错误,用new生成的对象在最后也用delete删除了。然而这里却有很大的问题。程序的行为是不可预测的——无法知道将会发生什么。

C++语言标准关于这个问题的阐述非常清楚:当通过基类的指针去删除派生类的对象,而基类又没有虚析构函数时,结果将是不可确定的。这意味着编译器生成的代码将会做任何它喜欢的事:重新格式化你的硬盘,给你的老板发电子邮件,把你的程序源代码传真给你的对手,无论什么事都可能发生。(实际运行时经常发生的是,派生类的析构函数永远不会被调用。在本例中,这意味着当targetPtr 删除时,EnemyTank的数量值不会改变,那么,敌人坦克的数量就是错的,这对需要高度依赖精确信息的部队来说,会造成什么后果?)

为了避免这个问题,只需要使EnemyTarget的析构函数为virtual。声明析构函数为虚就会带来你所希望的运行良好的行为:对象内存释放时,EnemyTank和EnemyTarget的析构函数都会被调用。

和绝大部分基类一样,现在EnemyTarget类包含一个虚函数。虚函数的目的是让派生类去定制自己的行为(见条款36),所以几乎所有的基类都包含虚函数。

如果某个类不包含虚函数,那一般是表示它将不作为一个基类来使用。当一个类不准备作为基类使用时,使析构函数为虚一般是个坏主意。请看下面的例子,这个例子基于ARM(“The Annotated C++ Reference Manual”)一书的一个专题讨论。

// 一个表示2D点的类
class Point {
public:
  Point(short int xCoord, short int yCoord);
  ~Point();

private:
  short int x, y;
};

如果一个short int占16位,一个Point对象将刚好适合放进一个32位的寄存器中。另外,一个Point对象可以作为一个32位的数据传给用C或FORTRAN等其他语言写的函数中。但如果Point的析构函数为虚,情况就会改变。

实现虚函数需要对象附带一些额外信息,以使对象在运行时可以确定该调用哪个虚函数。对大多数编译器来说,这个额外信息的具体形式是一个称为vptr(虚函数表指针)的指针。vptr指向的是一个称为vtbl(虚函数表)的函数指针数组。每个有虚函数的类都附带有一个vtbl。当对一个对象的某个虚函数进行请求调用时,实际被调用的函数是根据指向vtbl的vptr在vtbl里找到相应的函数指针来确定的。

虚函数实现的细节不重要(当然,如果你感兴趣,可以阅读条款M24),重要的是,如果Point类包含一个虚函数,它的对象的体积将不知不觉地翻番,从2个16位的short变成了2个16位的short加上一个32位的vptr!Point对象再也不能放到一个32位寄存器中去了。而且,C++中的Point对象看起来再也不具有和其他语言如C中声明的那样相同的结构了,因为这些语言里没有vptr。所以,用其他语言写的函数来传递Point也不再可能了,除非专门去为它们设计vptr,而这本身是实现的细节,会导致代码无法移植。

所以基本的一条是,无故的声明虚析构函数和永远不去声明一样是错误的。实际上,很多人这样总结:当且仅当类里包含至少一个虚函数的时候才去声明虚析构函数。

这是一个很好的准则,大多数情况都适用。但不幸的是,当类里没有虚函数的时候,也会带来非虚析构函数问题。 例如,条款13里有个实现用户自定义数组下标上下限的类模板。假设你(不顾条款M33的建议)决定写一个派生类模板来表示某种可以命名的数组(即每个数组有一个名字)。

template<class T>                // 基类模板
class Array {                    // (来自条款13)
public:
  Array(int lowBound, int highBound);
  ~Array();

private:
  vector<T> data;
  size_t size;
  int lBound, hBound;
};

template<class T>
class NamedArray: public Array<T> {
public:
  NamedArray(int lowBound, int highBound, const string& name);
  ...

private:
  string arrayName;
};

如果在应用程序的某个地方你将指向NamedArray类型的指针转换成了Array类型的指针,然后用delete来删除Array指针,那你就会立即掉进“不确定行为”的陷阱中。

NamedArray<int> *pna =
  new NamedArray<int>(10, 20, "Impending Doom");

Array<int> *pa;

...

pa = pna;                // NamedArray<int>* -> Array<int>*

...

delete pa;               // 不确定! 实际中,pa->arrayName
                         // 会造成泄漏,因为*pa的NamedArray
                         // 永远不会被删除

现实中,这种情形出现得比你想象的要频繁。让一个现有的类做些什么事,然后从它派生一个类做和它相同的事,再加上一些特殊的功能,这在现实中不是不常见。NamedArray没有重定义Array的任何行为——它继承了Array的所有功能而没有进行任何修改——它只是增加了一些额外的功能。但非虚析构函数的问题依然存在(还有其他问题,参见M33)

最后,值得指出的是,在某些类里声明纯虚析构函数很方便。纯虚函数将产生抽象类——不能实例化的类(即不能创建此类型的对象)。有些时候,你想使一个类成为抽象类,但刚好又没有任何纯虚函数。怎么办?因为抽象类是准备被用做基类的,基类必须要有一个虚析构函数,纯虚函数会产生抽象类,所以方法很简单:在想要成为抽象类的类里声明一个纯虚析构函数。

这里是一个例子:

class AWOV {                // AWOV = "Abstract w/o
                            // Virtuals"
public:
  virtual ~AWOV() = 0;      // 声明一个纯虚析构函数
                           
};

这个类有一个纯虚函数,所以它是抽象的,而且它有一个虚析构函数,所以不会产生析构函数问题。但这里还有一件事:必须提供纯虚析构函数的定义:

AWOV::~AWOV() {}           // 纯虚析构函数的定义

这个定义是必需的,因为虚析构函数工作的方式是:最底层的派生类的析构函数最先被调用,然后各个基类的析构函数被调用。这就是说,即使是抽象类,编译器也要产生对~AWOV的调用,所以要保证为它提供函数体。如果不这么做,链接器就会检测出来,最后还是得回去把它添上。

可以在函数里做任何事,但正如上面的例子一样,什么事都不做也不是不常见。如果是这种情况,那很自然地会想到将析构函数声明为内联函数,从而避免对一个空函数的调用所产生的开销。这是一个很好的方法,但有一件事要清楚。

因为析构函数为虚,它的地址必须进入到类的vtbl(见条款M24)。但内联函数不是作为独立的函数存在的(这就是“内联”的意思),所以必须用特殊的方法得到它们的地址。条款33对此做了全面的介绍,其基本点是:如果声明虚析构函数为inline,将会避免调用它们时产生的开销,但编译器还是必然会在什么地方产生一个此函数的拷贝。

条款15: 让operator=返回*this的引用

C++的设计者Bjarne Stroustrup下了很大的功夫想使用户自定义类型尽可能地和固定类型的工作方式相似。这就是为什么你可以重载运算符,写类型转换函数(见条款M5),控制赋值和拷贝构造函数,等等。他做了这么多努力,那你最少也该继续做下去。

让我们看看赋值。用固定类型的情况下,赋值操作可以象下面这样链起来:

int w, x, y, z;

w = x = y = z = 0;

所以,你也应该可以将用户自定义类型的赋值操作链起来:

string w, x, y, z;               // string是由标准C++库
                                 // “自定义”的类型
                                 // (参见条款49)

w = x = y = z = "Hello";

因为赋值运算符的结合性天生就是由右向左,所以上面的赋值可以解析为:

w = (x = (y = (z = "Hello")));

很值得把它写成一个完全等价的函数形式。除非是个LISP程序员,否则下面的例子会很令人感到高兴,因为它定义了一个中缀运算符:

w.operator=(x.operator=(y.operator=(z.operator=("Hello"))));

这个格式在此很具有说明性,因为它强调了w.operator=, x.operator=和y.operator=的参数是前一个operator=调用的返回值。所以operator=的返回值必须可以作为一个输入参数被函数自己接受。在一个类C中,缺省版本的operator=函数具有如下形式(见条款45):

C& C::operator=(const C&);

一般情况下几乎总要遵循operator=输入和返回的都是类对象的引用的原则,然而有时候需要重载operator=使它能够接受不同类型的参数。例如,标准string类型提供了两个不同版本的赋值运算符:

string&                            // 将一个string
operator=(const string& rhs);      // 赋给一个string

string&                            // 将一个char*
operator=(const char *rhs);        // 赋给一个string

请注意,即使在重载时,返回类型也是类的对象的引用。

C++程序员经常犯的一个错误是让operator=返回void,这好象没什么不合理的,但它妨碍了连续(链式)赋值操作,所以不要这样做。

另一个常犯的错误是让operator=返回一个const对象的引用,象下面这样:

class Widget {
public:
  ...                                           
  const Widget& operator=(const Widget& rhs);   
  ...                                           
};

这样做通常是为了防止程序中做象下面这样愚蠢的操作:

Widget w1, w2, w3;

...

(w1 = w2) = w3;         // w2赋给w1, 然后w3赋给其结果
                        // (给operator=一个const返回值
                        // 就使这个语句不能通过编译)

这可能是很愚蠢,但固定类型这么做并不愚蠢:

int i1, i2, i3;

...

(i1 = i2) = i3;                // 合法! i2赋给i1
                               // 然后i3赋给i1!

这样的做法实际中很少看到,但它对int来说是可以的,对我和我的类来说也可以。那它对你和你的类也应该可以。为什么要无缘无故地和固定类型的常规做法不兼容呢?

采用缺省形式定义的赋值运算符里,对象返回值有两个很明显的候选者:赋值语句左边的对象(被this指针指向的对象)和赋值语句右边的对象(参数表中被命名的对象)。哪一个是正确的呢?

例如,对String类(假设你想在这个类中写赋值运算符,参见条款11中的解释)来说有两种可能:

String& String::operator=(const String& rhs)
{

  ...

  return *this;            // 返回左边的对象
}

String& String::operator=(const String& rhs)
{

  ...

  return rhs;              // 返回右边的对象
}

对你来说,这好象是拿六个一和十二的一半来比较一样为难。实际上他们有很大的不同。

首先,返回rhs的那个版本不会通过编译,因为rhs是一个const String的引用,而operator=要返回的是一个String的引用。当要返回一个非const的引用而对象自身是const时,编译器会给你带来无尽的痛苦。看起来这个问题很容易解决——只用象这样重新声明operator=:

String& String::operator=(String& rhs)   { ... }

这次又轮到用到它的应用程序不能通过编译了!再看看最初那个连续赋值语句的后面部分:

x = "Hello";                     // 和x.op=("Hello");相同

因为赋值语句的右边参数不是正确的类型——它是一个字符数组,不是一个String——编译器就要产生一个临时的String对象(通过Stirng构造函数——参见条款M19)使得函数继续运行。就是说,编译器必须产生大致象下面这样的代码:

const String temp("Hello");      // 产生临时String

x = temp;                        // 临时String传给operator=

编译器一般会产生这样的临时值(除非显式地定义了所需要的构造函数——见条款19),但注意临时值是一个const。这很重要,因为它可以防止传递到函数内的临时值被修改。否则,程序员就会很奇怪地发现,只有编译器产生的临时值可以修改而他们在函数调用时实际传进去的参数却不行。(关于这一点是有事实根据的,早期版本的C++允许这类的临时值可以被产生,传递,修改,结果很多程序员感到很奇怪)

现在我们就可以知道如果String的operator=声明传递一个非const的Stirng参数,应用程序就不能通过编译的原因了:对于没有声明相应参数为const的函数来说,传递一个const对象是非法的。这是一个关于const的很简单的规定。

所以,结论是,这种情况下你将别无选择:当定义自己的赋值运算符时,必须返回赋值运算符左边参数的引用,*this。如果不这样做,就会导致不能连续赋值,或导致调用时的隐式类型转换不能进行,或两种情况同时发生。

条款16: 在operator=中对所有数据成员赋值

条款45说明了如果没写赋值运算符的话,编译器就会为你生成一个,条款11则说明了为什么你会经常不喜欢编译器为你生成的这个赋值运算符,所以你会想能否有个两全其美的办法,让编译器生成一个缺省的赋值运算符,然后可以有选择地重写不喜欢的部分。这是不可能的!只要想对赋值过程的某一个部分进行控制,就必须负责做赋值过程中所有的事。

实际编程中,这意味着写赋值运算符时,必须对对象的每一个数据成员赋值:

template<class T>          // 名字和指针相关联的类的模板
class NamedPtr {           // (源自条款12)
public:
  NamedPtr(const string& initName, T *initPtr);
  NamedPtr& operator=(const NamedPtr& rhs);

private:
  string name;
  T *ptr;
};

template<class T>
NamedPtr<T>& NamedPtr<T>::operator=(const NamedPtr<T>& rhs)
{
  if (this == &rhs)
    return *this;              // 见条款17

  // assign to all data members
  name = rhs.name;             // 给name赋值

  *ptr = *rhs.ptr;             // 对于ptr,赋的值是指针所指的值,
                               // 不是指针本身

  return *this;                // 见条款15
}

初写这个类时当然很容易记住上面的原则,但同样重要的是,当类里增加新的数据成员时,也要记住更新赋值运算符函数。例如,打算升级NamedPtr模板使得名字改变时附带一个时间标记,那就要增加一个新的数据成员,同时需要更新构造函数和赋值运算符。但现实中,因为忙于升级类的具体功能和增加新的成员函数等,这一点往往很容易被忘记。

当涉及到继承时,情况就会更有趣,因为派生类的赋值运算符也必须处理它的基类成员的赋值!看看下面:

class Base {
public:
  Base(int initialValue = 0): x(initialValue) {}

private:
  int x;
};

class Derived: public Base {
public:
  Derived(int initialValue)
  : Base(initialValue), y(initialValue) {}

  Derived& operator=(const Derived& rhs);

private:
  int y;
};

逻辑上说,Derived的赋值运算符应该象这样:

// erroneous assignment operator
Derived& Derived::operator=(const Derived& rhs)
{
  if (this == &rhs) return *this;    // 见条款17

  y = rhs.y;                         // 给Derived仅有的
                                     // 数据成员赋值

  return *this;                      // 见条款15
}

不幸的是,它是错误的,因为Derived对象的Base部分的数据成员x在赋值运算符中未受影响。例如,考虑下面的代码段:

void assignmentTester()
{
  Derived d1(0);                      // d1.x = 0, d1.y = 0
  Derived d2(1);                      // d2.x = 1, d2.y = 1

  d1 = d2;         // d1.x = 0, d1.y = 1!
}

请注意d1的Base部分没有被赋值操作改变。

解决这个问题最显然的办法是在Derived::operator=中对x赋值。但这不合法,因为x是Base的私有成员。所以必须在Derived的赋值运算符里显式地对Derived的Base部分赋值。

也就是这么做:

// 正确的赋值运算符
Derived& Derived::operator=(const Derived& rhs)
{
  if (this == &rhs) return *this;

  Base::operator=(rhs);    // 调用this->Base::operator=
  y = rhs.y;

  return *this;
}

这里只是显式地调用了Base::operator=,这个调用和一般情况下的在成员函数中调用另外的成员函数一样,以*this作为它的隐式左值。Base::operator=将针对*this的Base部分执行它所有该做的工作——正如你所想得到的那种效果。

但如果基类赋值运算符是编译器生成的,有些编译器会拒绝这种对于基类赋值运算符的调用(见条款45)。为了适应这种编译器,必须这样实现Derived::operator=:

Derived& Derived::operator=(const Derived& rhs)
{
  if (this == &rhs) return *this;

  static_cast<Base&>(*this) = rhs;      // 对*this的Base部分
                                        // 调用operator=
  y = rhs.y;

  return *this;
}

这段怪异的代码将*this强制转换为Base的引用,然后对其转换结果赋值。这里只是对Derived对象的Base部分赋值。还要注意的重要一点是,转换的是Base对象的引用,而不是Base对象本身。如果将*this强制转换为Base对象,就要导致调用Base的拷贝构造函数,创建出来的新对象(见条款M19)就成为了赋值的目标,而*this保持不变。这不是所想要的结果。

不管采用哪一种方法,在给Derived对象的Base部分赋值后,紧接着是Derived本身的赋值,即对Derived的所有数据成员赋值。

另一个经常发生的和继承有关的类似问题是在实现派生类的拷贝构造函数时。看看下面这个构造函数,其代码和上面刚讨论的类似:

class Base {
public:
  Base(int initialValue = 0): x(initialValue) {}
  Base(const Base& rhs): x(rhs.x) {}

private:
  int x;
};

class Derived: public Base {
public:
  Derived(int initialValue)
  : Base(initialValue), y(initialValue) {}

  Derived(const Derived& rhs)      // 错误的拷贝
  : y(rhs.y) {}                    // 构造函数

private:
  int y;
};

类Derived展现了一个在所有C++环境下都会产生的bug:当Derived的拷贝创建时,没有拷贝其基类部分。当然,这个Derived对象的Base部分还是创建了,但它是用Base的缺省构造函数创建的,成员x被初始化为0(缺省构造函数的缺省参数值),而没有顾及被拷贝的对象的x值是多少!

为避免这个问题,Derived的拷贝构造函数必须保证调用的是Base的拷贝构造函数而不是Base的缺省构造函数。这很容易做,只要在Derived的拷贝构造函数的成员初始化列表里对Base指定一个初始化值:

class Derived: public Base {
public:
  Derived(const Derived& rhs): Base(rhs), y(rhs.y) {}

  ...

};

现在,当用一个已有的同类型的对象来拷贝创建一个Derived对象时,它的Base部分也将被拷贝了。

条款17: 在operator=中检查给自己赋值的情况

做类似下面的事时,就会发生自己给自己赋值的情况:

class X { ... };

X a;

a = a;                     // a赋值给自己

这种事做起来好象很无聊,但它完全是合法的,所以看到程序员这样做不要感到丝毫的怀疑。更重要的是,给自己赋值的情况还可以以下面这种看起来更隐蔽的形式出现:

a = b;

如果b是a的另一个名字(例如,已被初始化为a的引用),那这也是对自己赋值,虽然表面上看起来不象。这是别名的一个例子:同一个对象有两个以上的名字。在本条款的最后将会看到,别名可以以大量任意形式的伪装出现,所以在写函数时一定要时时考虑到它。

在赋值运算符中要特别注意可能出现别名的情况,其理由基于两点。其中之一是效率。如果可以在赋值运算符函数体的首部检测到是给自己赋值,就可以立即返回,从而可以节省大量的工作,否则必须去实现整个赋值操作。例如,条款16指出,一个正确的派生类的赋值运算符必须调用它的每个基类的的赋值运算符,所以在派生类中省略赋值运算符函数体的操作将会避免大量对其他函数的调用。

另一个更重要的原因是保证正确性。一个赋值运算符必须首先释放掉一个对象的资
源(去掉旧值),然后根据新值分配新的资源。在自己给自己赋值的情况下,释放旧的资源将是灾难性的,因为在分配新的资源时会需要旧的资源。

看看下面String对象的赋值,赋值运算符没有对给自己赋值的情况进行检查:

class String {
public:
  String(const char *value);    // 函数定义参见条款11
                                // 

  ~String();                    // 函数定义参见条款11
                                //
  ...

  String& operator=(const String& rhs);

private:
  char *data;
};

// 忽略了给自己赋值的情况
// 的赋值运算符
String& String::operator=(const String& rhs)
{
  delete [] data;    // delete old memory

  // 分配新内存,将rhs的值拷贝给它
  data = new char[strlen(rhs.data) + 1];
  strcpy(data, rhs.data);

  return *this;      // see Item 15
}

看看下面这种情况将会发生什么:

String a = "Hello";

a = a;               // same as a.operator=(a)

赋值运算符内部,*this和rhs好象是不同的对象,但在现在这种情况下它们却恰巧是同一个对象的不同名字。可以这样来表示这种情况:

*this  data ------------> "Hello/0"
                  /
                 /
rhs    data -----

赋值运算符做的第一件事是用delete删除data,其结果将如下所示:

*this  data ------------> ???
                   /
                  /
rhs    data -----

现在,当赋值运算符对rhs.data调用strlen时,结果将无法确定。这是因为data被删除的时候rhs.data也被删除了,data,this->data 和rhs.data 其实都是同一个指针!从这一点看,情况只会越变越糟糕。

现在可以知道,解决问题的方案是对可能发生的自己给自己赋值的情况先进行检查,如果有这种情况就立即返回。不幸的是,这种检查说起来容易做起来难,因为你必须定义两个对象怎么样才算是“相同”的。

你面临的这个问题学术上称为object identity,它在面向对象领域是个很有名的
论题。本书不是讲述object identity的地方,但有必要提到两个解决这个问题的基本方法。

一个方法是,如果两个对象具有相同的值,就说它们是相同的(具有相同的身份)
。例如,两个String对象如果都表示的是相同顺序的字符序列,它们就是相同的:

String a = "Hello";
String b = "World";
String c = "Hello";

a和c具有相同值,所以它们被认为是完全相同的;b和它们都不同。如果把这个定义用到String类中,赋值运算符看起来就象这样:

String& String::operator=(const String& rhs)
{
  if (strcmp(data, rhs.data) == 0) return *this;

  ...

}

值相等通常由operator==来检测,所以对于一个用值相等来检测对象身份的类C来说,它的赋值运算符的一般形式是:

C& C::operator=(const C& rhs)
{
  // 检查对自己赋值的情况
  if (*this == rhs)            // 假设operator=存在
    return *this;

  ...

}

注意这个函数比较的是对象(通过operator=),而不是指针。用值相等来确定对象身份和两个对象是否占用相同的内存没有关系;有关系的只是它们所表示的值。

另一个确定对象身份是否相同的方法是用内存地址。采用这个定义,两个对象当且仅当它们具有相同的地址时才是相同的。这个定义在C++程序中运用更广泛,可能是因为它很容易实现而且计算很快,而采用值相等的定义则不一定总具有这两个优点。采用地址相等的定义,一个普通的赋值运算符看起来象这样:

C& C::operator=(const C& rhs)
{
  // 检查对自己赋值的情况
  if (this == &rhs) return *this;

  ...

}

它对很多程序都适用。

如果需要一个更复杂的机制来确定两个对象是否相同,这就要靠程序员自己来实现。最普通的方法是实现一个返回某种对象标识符的成员函数:

class C {
public:
  ObjectID identity() const;    // 参见条款36

  ...

};

对于两个对象指针a和b,当且仅当 a->identity() == b->identity()的时候,它们所指的对象是完全相同的。当然,必须自己来实现ObjectIDs的operator==。

别名和object identity的问题不仅仅局限在operator=里。在任何一个用到的函数里都可能会遇到。在用到引用和指针的场合,任何两个兼容类型的对象名称都可能指的是同一个对象。下面列出的是别名出现的其它情形:

class Base {
  void mf1(Base& rb);          // rb和*this可能相同                     
  ...

};

void f1(Base& rb1,Base& rb2);  // rb1和rb2可能相同
                               //

class Derived: public Base {
  void mf2(Base& rb);          // rb和*this可能相同
                               //
  ...

};

int f2(Derived& rd, Base& rb); // rd和rb可能相同
                               //

这些例子刚好都用的是引用,指针也一样。

可以看到,别名可以以各种形式出现,所以决不要忘记它或期望自己永远不会碰到它。也许你不会碰到,但我们大多数会碰到。而很明显的一条是,处理它会达到事半功倍的效果。所以任何时候写一个函数,只要别名有可能出现,就必须在写代码时进行处理。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值