第三章 构造函数,析构函数和赋值操作符
几乎所有的类都有一个或多个构造函数,一个析构函数和一个赋值操作符。这没什么奇怪的 ,因为它们提供的都是一些最基本的功能。构造函数控制对象生成时的基本操作,并保证对 象被初始化;析构函数摧毁一个对象并保证它被彻底清除;赋值操作符则给对象一个新的值 。在这些函数上出错就会给整个类带来无尽的负面影响,所以一定要保证其正确性。本章我 将指导如何用这些函数来搭建一个结构良好的类的主干。
------------------------------------------------------------------------------- -
条款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包含一个指向local string早已删除的内存的指针。
顺便指出,用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),nam edptr也必须实现这些函数(见条款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成员 函数被调用。而在构造函数里赋值时,将有两个被调用。为了理解为什么,请看在声明nam edptr<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 obje cts”提供了对这个技术的另外一些改进)
设想在一个军事应用程序里,有一个表示敌人目标的类:
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等其他语言写的函数中。但如果po int的析构函数为虚,情况就会改变。
实现虚函数需要对象附带一些额外信息,以使对象在运行时可以确定该调用哪个虚函数。对 大多数编译器来说,这个额外信息的具体形式是一个称为vptr(虚函数表指针)的指针。v ptr指向的是一个称为vtbl(虚函数表)的函数指针数组。每个有虚函数的类都附带有一个 vtbl。当对一个对象的某个虚函数进行请求调用时,实际被调用的函数是根据指向vtbl的v ptr在vtbl里找到相应的函数指针来确定的。
虚函数实现的细节不重要(当然,如果你感兴趣,可以阅读条款m24),重要的是,如果po int类包含一个虚函数,它的对象的体积将不知不觉地翻番,从2个16位的short变成了2个1 6位的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的引用,而operato r=要返回的是一个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是bas e的私有成员。所以必须在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的引用,然后对其转换结果赋值。这里只是对deri ved对象的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的拷贝构造函数而不是ba se的缺省构造函数。这很容易做,只要在derived的拷贝构造函数的成员初始化列表里对ba se指定一个初始化值:
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可能相同 //
这些例子刚好都用的是引用,指针也一样。
可以看到,别名可以以各种形式出现,所以决不要忘记它或期望自己永远不会碰到
它。也许你不会碰到,但我们大多数会碰到。而很明显的一条是,处理它会达到事
半功倍的效果。所以任何时候写一个函数,只要别名有可能出现,就必须在写代码
时进行处理
|