条款十:令operator=返回一个reference to *this
这个条款很简单的说,先解释一下为什么赋值后还要有返回值,这是因为我们可以这样用连等:
int x, y, z;
x = y = z;
注意等号是右结合的,所以上述运算实际上等同于:
x = (y = z);
如果没有返回值,上述代码就不能通过编译。
至于为什么返回的是一个引用,这并不是编译器强制的,但有三个原因让你觉得这样做是明智的:
(1) 返回引用可以节省资源,不必要为返回值调用构造函数了;
(2) 形如(x = y) = 3这样的连等,编译器也能接受了,因为这样的写法要求赋值运算符返回的是可以修改的左值;
(3) STL和boost库中的标准代码都是这样写的,为了与它们相兼容,还是放弃你的标新立异吧
类似地,+=、-=等与赋值相关的运算,最好也返回自身的引用。
class Widget {
public:
Widget& operator=(const Widget& rhs) {
...
return *this;
}
};
class Widget {
public:
...
Widget& operator+=(const Widget& rhs) { // 这个协议适用于+=,-=,*=等等
...
return *this;
}
Widget& operator=(int rhs) { // 此函数也适用,即使参数类型不符规定
...
return *this;
}
};
一句话:令赋值操作符返回一个reference to *this。
条款十一:在operator=中处理自我赋值
直观的operator=是这样定义的:
class SampleClass
{
private:
int a;
double b;
float* p;
public:
SampleClass& operator= (const SampleClass& s)
{
a = s.a;
b = s.b;
p = s.p;
return *this;
}
};
定义了一个WebBrower的类,里面执行对浏览器的清理工作,包括清空缓存,清除历史记录和清除Cookies,现在需要将这三个函数打包成一个函数,这个函数执行所有的清理工作,那是将这个清理函数放在类内呢,还是把他放在类外呢?
如果放在类内,那就像这样:
class SampleClass
{
private:
int a;
double b;
float* p;
public:
SampleClass& operator= (const SampleClass& s)
{
a = s.a;
b = s.b;
delete p;
p = new float(*s.p);
return *this;
}
};
大致思路就是删除指针所指向的旧内容,而后再用这个指针指向一块新的空间,空间的内容填充s.p所指向的内容。但有两件事会导致这段代码崩溃,其一就是本条款所说的“自我赋值”。读者不妨想想看,如果这样:
SampleClass obj;
obj = obj;
所发生的事情。在赋值语句执行时,检测到obj.p已经有指向了,此时会释放掉obj.p所指向的空间内容,但紧接着下一句话就是:
p = new float(*s.p);
注意*s.p
会导致程序崩溃,因为此时s.p也就是obj.p,对其取值obj.p(根据优先级,这相当于(obj.p)),obj.p已经在前一句话被释放掉了,所以这样的操作会有bug。
也许读者不以为意,认为用户不可能傻到会写obj = obj这样的代码出来。事实上也确实如此,明显的错误不大可能会犯,但万一写一个:
SampleClass obj;
…
SampleClass& s = obj;
…
s = obj;
SmapleClass* p = &obj;
…
*p = obj;
这种错误就不那么直观了,甚至*pa = *pb也有可能出问题,因为pa与pb大有可能指向的是同一个地址空间。自我赋值一个不小心就会发生,决不要假设用户不用写出自我赋值的语句来。
解决自我赋值只要一句话:
class SampleClass
{
private:
int a;
double b;
float* p;
public:
SampleClass& operator= (const SampleClass& s)
{
if(this == &s) return *this; // 解决自我赋值的一句话
a = s.a;
b = s.b;
delete p;
p = new float(*s.p);
return *this;
}
};
但是,如下程序
class SampleClass
{
private:
int a;
double b;
float* p;
public:
SampleClass& operator= (const SampleClass& s)
{
if(*this == s) return *this; // 注意条件判断的不同,这样写有问题!
a = s.a;
b = s.b;
delete p;
p = new float(*s.p);
return *this;
}
};
这样是不对的,因为==经常是用于对象内每一个成员变量是否相同的判断,而不是地址是否重叠的判断。所以用this == &s才能从地址上来捕捉到是否真的是自我赋值。
这样做确实能解决上面所说的第一问题:自我赋值。事实上还可能出现另一个问题导致代码崩溃,试想,如果p = new float(*s.p)不能正常分配空间怎么办,突然抛出了异常怎么办,这将导致原有空间的内容被释放,但新的内容又不能正常填充。有没有一个好的方法,在出现异常时,还能保持原有的内容不变呢?(可以提升程序的健壮性)
这有两种思路,书上先给出了这样的:
SampleClass& operator= (const SampleClass& s)
{
if(this == &s) return *this; //可以删掉
a = s.a;
b = s.b;
float* tmp = p; // 先保存了旧的指针
p = new float(*s.p); // 再申请新的空间,如果申请失败,p仍然指向原有的地址空间
delete tmp; // 能走到这里,说明申请空间是成功的,这时可以删掉旧的内容了
return *this;
}
大致的思路是保存好旧的,再试着申请新的,若申请有问题,旧的还能保存。这里可以删掉第一句话,因为“让operator具备异常安全往往自动获得自我赋值安全的回报”。
还有一种思路,就是先用临时的指针申请新的空间并填充内容,没有问题后,再释放到本地指针所指向的空间,最后用本地指针指向这个临时指针,像这样:
SampleClass& operator= (const SampleClass& s)
{
if(this == &s) return *this; //可以删掉
a = s.a;
b = s.b;
float* tmp = new float(*s.p); // 先使用临时指针申请空间并填充内容
delete p; // 若能走到这一步,说明申请空间成功,就可以释放掉本地指针所指向的空间
p = tmp; // 将本地指针指向临时指针
return *this;
}
上述两种方法都是可行,但还要注意拷贝构造函数里面的代码与这段代码的重复性,试想一下,如果此时对类增加一个私有的指针变量,这里面的代码,还有拷贝构造函数里面类似的代码,都需要更新,有没有可以一劳永逸的办法?
本书给出了最终的解决方案:
SampleClass& operator= (const SampleClass& s)
{
SampleClass tmp(s);
swap(*this, tmp);
return *this;
}
这样把负担都交给了拷贝构造函数,使得代码的一致性能到保障。如果拷贝构造函数中出了问题,比如不能申请空间了,下面的swap函数就不会执行到,达到了保持本地变量不变的目的。
一种进一步优化的方案如下
SampleClass& operator= (const SampleClass s)
{
swap(*this, s);
return *this;
}
注意这里去掉了形参的引用,将申请临时变量的任务放在形参上了,可以达到优化代码的作用。
最后总结一下:
(1) 确保当对象自我赋值时operator=有良好的行为,其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap;
(2) 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。
条款十二:复制对象时勿忘其每一个成分
这句话包含两部分的意思:第一部分是要考虑到所有成员变量,特别是后加入的,相应的拷贝构造函数和赋值运算符要及时更新;第二部分是在存在继承时,不要遗忘基类部分的复制。先看第一部分的意思,举个例子:
class SampleClass
{
private:
int a;
public:
SampleClass(const SampleClass& s):a(s.a)
{}
};
这里只举了一个拷贝构造函数的例子,赋值运算符与之类似,如果这个时候又加了一个成员变量,比如double b,拷贝构造函数和赋值运算符就要相应地更新(构造函数当然也要更新,只是构造函数一般不会被忘记,而拷贝构造函数和赋值运算符却常常被遗忘)。像这样:
class SampleClass
{
private:
int a;
double d;
public:
SampleClass(const SampleClass& s):a(s.a),d(s.d)
{}
};
再看第二部分的意思,当存在继承关系时:
class Derived: public SampleClass
{
private:
int derivedVar;
public:
Derived(const Derived& d):derivedVar(d.derivedVar){}
};
像这样,很容易就会漏掉基类的部分,导致基类部分没有得到正常的拷贝,应该修改为如下:
class Derived: public SampleClass
{
private:
int derivedVar;
public:
Derived(const Derived& d):SampleClass(d), derivedVar(d.derivedVar){}
};
对于赋值运算符的重载,应该写成这样:
Derived& operator=(const Derived& d)
{
SampleClass::operator=(d);
derivedVar = d.derivedVar;
return *this;
}
可以看到,赋值运算符重载与拷贝构造函数的代码具有很高的相似性,但书上说“不要尝试以某个copying函数实现另一个copying函数”。我觉得这里有争议,上一个条款中,书上已经做到了在赋值运算符中调用拷贝构造函数了,像这样:
Derived& operator=(const Derived& d)
{
Derived tmp(d);
swap(*this, tmp);
return *this;
}
这就是一个在赋值运算符内调用拷贝构造函数的例子,也许在有些情况下,它的效率看上去不那么高,但却为代码的一致性提供了很好的保障,也能有效提供异常安全性。所以,在这个地方,我不大认同书上所说的,如果你有什么想法,可以留言共同讨论。书上所说的定制一个共有的private函数,比如init(),分别在拷贝构造函数和operator=中调用当然也是可以的。
最后总结一下:
Copying函数应该确保复制“对象内的所有成员变量”及“所有基类成分”。