0%

[Effective C++ 笔记]条款11. 在 `operator=` 中处理自我赋值

简介

  • 确保对象在自我赋值时不会出现出错(资源丢失)的情况,常见的解决方案有:identity test(检查赋值对象和被赋值对象的地址是否相同),重新排列赋值顺序以及 copy and swap。
  • 确定任何函数操作一个以上对象时,其中多个对象是一个对象的情况下行为依然正确。

引子

自我赋值指的是对象被赋值给自己的时候,如下例所示:

1
2
3
4
class Widge { ... };
Widge w;
...
w = w; // 自我赋值

看似这个操作没有意义,但是我们在设计类时需要考虑到这样子的情况。虽然可能像上例一样的操作大部分人不会做,但是以下例子虽然不太容易看出来不过也是在进行自我赋值。

1
2
3
4
5
// 利用数组下标赋值
a[i] = a[j];

// 利用指针赋值
*py = *px;

上述的例子有一个共同点,我们并不是在直接操作对象,而是利用了“别名”(数组下标或者指针,引用也是别名的一种)。在使用了别名的情况下,我们也有可能在隐蔽地甚至我们都没有操作的情况下进行了自我赋值。另一点需要注意的是,如果将继承特性也考虑在内的话,一个对象的别名甚至不需要是同类型的,如下例所示(完整代码见
ex1):

1
2
3
class Base { ... };
class Derived: public Base { ... };
void doSomething(const Base& rb, Derived* rd); // 此处 rb 和 *rd 有可能是同一对象

如果我们没有特意考虑过自我赋值这一情况时,有时候不会引发错误。但是在某些类中,如果我们在类中让其自行管理资源时,我们有可能会不经意间在停止使用该资源之前就意外地将其释放掉了。考虑以下例子:

1
2
3
4
5
6
class Bitmap { ... };
class Widget {
...
private:
Bitmap* pb; // 指向一个堆上的 Bitmap 对象
}

上例 Widge 对象中保存一个指针用来管理动态分配的 BitMap。以下是该类的 oprator= 定义:

1
2
3
4
5
Widget& Widget::operator=(const Widget& rhs) {
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}

上面的定义看似没有问题,但是假如 rhs 和当前使用的 operator= 的对象是同一对象的话,实际上该对象的 pb 在赋值过程中已经被 delete 了,因此赋值过后,rhs.pb
实际上指向的是一个已经被删除的对象!我们在 BitMap 的构造函数和析构函数加上输出并为其添加一个成员变量 a(该变量会在构造时根据给定值初始化,并在析构时重置为0 ),并用下面例子进行演示:完整代码见
ex1):

1
2
3
4
Widget w(2);
w.test();
w = w;
w.test();

编译输出结果如下:

1
2
3
4
5
6
BitMap Created.
a = 2
BitMap Deleted.
BitMap Created.
a = 0
BitMap Deleted.

可以发现,虽然我们只是进行了一次自我赋值,理论上我们不希望 w 发生任何改变,但是中间经历了一次析构再重新构造过的过程之后哦,w.pb 实际上已经指向了一个被删除的对象,其中的a
已经被重置为 0 (相当于资源已经丢失),因此这种情况是需要我们避免的。

处理自我赋值的方法

operator= 进行 identity test

这个方法本质上就是在 operator= 中检验 operator=
右端的对象与本身是否为同一对象,如果相同则不做任何操作直接返回。如下例所示:完整代码见
ex2):

1
2
3
4
5
6
Widget& operator=(const Widget& rhs) {
if (this == &rhs) return *this; // identity test
delete pb;
pb = new BitMap(*rhs.pb);
return *this;
}

通过这个操作,此时我们再重复上例中的操作,编译运行结果如下:

1
2
3
BitMap Created.
a = 2
a = 2

可以发现,加上了 identity test 之后,在自我赋值时不会出现多余的析构和构造过程,资源也没有丢失,基本可以解决问题。

operator= 的异常安全性

但是如果我们还要考虑异常安全性的情况下,这个版本的 operator= 的定义还是有问题的。假设在 new BitMap()
的过程中,抛出了异常(内存空间不足,或者 copy 构造函数本身抛出异常),如果我们不加以处理(直接 catch)的话,经过赋值过后的 Widget 对象的 pb 指针还是会指向一块被删除的空间,以下述例子为例(这里我在 BitMap
的构造函数中手动抛出了异常):完整代码见
ex3):

1
2
3
4
5
6
7
8
9
10
Widget w(2);
Widget w1(3);
w.test();
w1.test();

try {
w = w1;
} catch(...) {
std::cout << "exception caught." << std::endl;
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
BitMap Created.
BitMap Created.
a = 2
a = 3
BitMap Deleted.
BitMap Created.
exception caught.
a = 0
BitMap Deleted.
BitMap Deleted.
free(): double free detected in tcache 2

可以发现,假如赋值过程中抛出了异常,实际上赋值过程是不能完成的。假如我们强行接住了该异常而不做任何处理,会导致 w.pb 指向一块被删除的空间。当然我们可以选择不接住该异常,让程序在该处停止运行。但是有没有一种更好的实现方法来保证
operator= 的异常安全性 (exception safety) 呢?我们可以通过下面的例子来进行实现:完整代码见
ex4):

1
2
3
4
5
6
Widget& operator=(const Widget& rhs) {
BitMap* pOrig = pb; // 保存当前 pb 的副本
pb = new BitMap(*rhs.pb);
delete pOrig; // 保证在 new 操作成功后再删除原有 pb
return *this;
}

通过这种实现,我们再重新进行上面的例子进行测试:

1
2
3
4
5
6
7
8
BitMap Created.
BitMap Created.
a = 2
a = 3
BitMap Created.
exception caught.
a = 2
BitMap Deleted.

可以发现,经由这个操作之后,我们可以安全的接住 new BitMap()
抛出的异常。同时保证了在这种情况下赋值操作不会导致原有对象资源的丢失(即赋值操作的失败不会对原有对象造成影响)。我们将这种方法也放在了这一个条款里介绍是因为通过这种实现,我们即保证了异常安全性,同时也保证了自我赋值可能出现的问题。当然我们还可以将一种实现和这一种结合起来,即在
operator= 中进行 identity test。理论上来说这会让代码的效率有一点提高,但相对应的,这样也会使得代码更大(源码以及目标码),并且也会由于增加了一个控制分支,所以在处理非自我赋值的情况下性能会有一点下降,这也是我们需要考虑的。

copy and swap

这一部分涉及到 条款 29 的内容,所以我准备在完成条款 29 之后再继续这一部分的补充。

完整可运行代码地址:Effective-Cpp-Reading-Note
Efftive C++ 阅读笔记:Effective C++