12.5 重载赋值符

赋值符常引起C++程序员初学者的混淆。这是毫无疑问的,因为‘=’在编程中是最基本的运算符,是在机器层上拷贝寄存器。另外,当使用‘=’时也能引起拷贝构造函数(第11章内容)调用:

12.5 重载赋值符 - 图1

第2行定义了对象a。一个新对象先前不存在,现在正被创建。因为现在知道了C++编译器关于对象初始化是如何保护的,所以知道在对象被定义的地方构造函数总是必须被调用。但是是调用哪个构造函数呢?a是从现有的MyType对象创建的(b在等号的右侧),所以只有一个选择:拷贝构造函数。所以虽然这里包括一个等号,但拷贝构造函数仍被调用。

第3行情况就不同了。在等号左侧有一个以前初始化了的对象。很清楚,不用为一个已经存在的对象调用构造函数。在这种情况下,为a调用MyType:operator=,把出现在右侧的任何东西作为参数(可以有多种取不同右侧参数的operator=函数)。

对于拷贝构造函数则没有这个限制。在任何时候使用一个“=”代替普通形式的构造函数调用来初始化一个对象时,无论等号右侧是什么,编译器都会寻找一个接受右边类型的构造函数:

12.5 重载赋值符 - 图2

当处理“=”时,记住这个差别是非常重要的:如果对象还没有被创建,初始化是需要的,否则使用赋值operator=。

最好避免编写使用“=”的初始化代码,而是用显式的构造函数形式。等号的两种构造形式变为:

12.5 重载赋值符 - 图3

这个方法可以避免使读者混淆。

12.5.1 operator=的行为

在Integer.h和Byte.h中,可以看到operator=仅是成员函数,它密切地与“=”左侧的对象相联系。如果允许定义operator=为全局的,那么我们就会试图重新定义内置的“=”:

12.5 重载赋值符 - 图4

编译器通过强制operator=为成员函数而避开这个问题。

当创建一个operator=时,必须从右侧对象中拷贝所有需要的信息到当前的对象(即调用运算符的对象)以完成为类的“赋值”,对于简单的对象,这是显然的:

12.5 重载赋值符 - 图5

这里,“=”左侧的对象拷贝了右侧对象中的所有内容,然后返回它的引用,所以还可以创建更加复杂的表达式。

这个例子犯了一个常见的错误。当准备给两个相同类型的对象赋值时,应该首先检查一下自赋值(self-assignment):这个对象是否对自身赋值了?在一些情况下,例如本例,无论如何执行这些赋值运算都是无害的,但如果对类的实现进行了修改,那么将会出现差异。如果我们习惯于不做检查,就可能忘记并产生难以发现的错误。

12.5.1.1 类中指针

如果对象不是如此简单时将会发生什么问题?例如,如果对象里包含指向别的对象的指针将如何?简单地拷贝一个指针意味着以指向相同的存储单元的对象而结束。在这种情况下,就需要自己做簿记。

这里有两个解决办法。当做一个赋值运算或一个拷贝构造函数时,最简单的方法是拷贝这个指针所涉及的一切,这是非常直接的。

12.5 重载赋值符 - 图6

12.5 重载赋值符 - 图7

12.5 重载赋值符 - 图8

Dog是一个简单的类,仅包含一个用来说明dog名字的string成员。但是,由于构造函数和析构函数在它们被调用的时候打印信息,所以就可以知道对Dog进行操作的时间。注意这第二个构造函数有点像拷贝构造函数,除了它的参数是一个指向Dog对象的指针而不是一个引用外,并且它还有第二个参数,是同Dog参数的名字相关联的信息。这被用于帮助追踪程序的执行。

可以看到,无论何时成员函数打印信息,它都不是直接获取这些信息的,而是把*this传送给cout。进而调用ostream operator<<。用这种方式进行操作是值得的,因为如果想重新格式化Dog信息的显示方式(正如通过增加“[”和“]”所做的),仅需要在一处进行操作。

当类中包含指针时,DogHouse含有一个Dog*并说明了需要定义的4个函数:所有必需的普通构造函数、拷贝构造函数、operator=(无论定义它还是不允许它)和析构函数。对operator=当然要检查自赋值,虽然这儿并不一定需要,这实际上减少了改变代码而忘记检查自赋值的可能性。

12.5.1.2 引用计数

在上面的例子中,拷贝构造函数和operator=对指针所指向的内容作了一个新的拷贝,并由析构函数删除它。但是,如果对象需要大量的内存或过高的初始化,我们也许想避免这种拷贝。解决这个问题的通常方法称为引用计数(reference counting)。可以使一块存储单元具有智能,它知道有多少对象指向它。拷贝构造函数或赋值运算意味着把另外的指针指向现在的存储单元并增加引用记数。消除意味着减小引用记数,如果引用记数为0则意味销毁这个对象。

但如果向这个对象(上例中的Dog)执行写入操作将会如何呢?因为不止一个对象使用这个Dog,所以当修改自己的Dog时,也等于也修改了他人的Dog。为了解决这个“别名”问题,经常使用另外一个称为写拷贝(copy-on-write)的技术。在向这块存储单元写之前,应该确信没有其他人使用它。如果引用记数大于1,在写之前必须拷贝这块存储单元,这样就不会影响他人了。这儿提供了一个简单的引用记数和关于写拷贝的例子:

12.5 重载赋值符 - 图9

12.5 重载赋值符 - 图10

12.5 重载赋值符 - 图11

12.5 重载赋值符 - 图12

类Dog是DogHouse指向的对象。包含了一个引用记数及控制和读引用记数的函数。同时这里存在一个拷贝构造函数,所以可以从现有的对象创建一个新的Dog。

函数attach()增加一个Dog的引用记数用以指示有另一个对象使用它。函数detach()减少引用记数。如果引用记数为0,则说明没有对象使用它,所以通过表达式delete this,成员函数销毁它自己的对象。

在进行任何修改(例如为一个Dog重命名)之前,必须保证所修改的Dog没有被别的对象正在使用。这可以通过调用DogHouse:unalias(),它又进而调用Dog:unalias()来做到这点。如果引用记数为1(意味着没有别的对象指向这块存储单元),后面这个函数将返回存在的Dog指针,但如果引用记数大于1(意味着不止一个对象指向这个Dog)就要复制这个Dog。

拷贝构造函数给源对象Dog赋值Dog,而不是创建它自己的存储单元。然后因为现在增加了使用这个存储单元的对象,所以通过调用Dog:attach()增加引用记数。

operator=处理等号左侧已创建的对象,所以它首先必须通过为Dog调用detach()来整理这个存储单元。如果没有其他对象使用它,这个老的Dog将被销毁。然后operator=重复拷贝构造函数的行为。注意它首先检查是否给它本身赋予相同的对象。

析构函数调用detach()有条件地销毁Dog。

为了实现写拷贝,必须控制所有写存储单元的动作。例如成员函数renameDog()允许对这个存储单元修改数值。但它首先必须使用unalias()防止修改一个已别名化了的存储单元(超过一个对象使用的存储单元)。如果想从DogHouse中产生一个指向Dog的指针,首先要对指针调用unalias()。

在main()中测试了几个必须正确实现引用记数的函数:构造函数、拷贝构造函数、operator=和析构函数。在main()中也通过C调用renameDog()测试了写拷贝。

下面是(一部分重新格式化后的)输出结果:

12.5 重载赋值符 - 图13

通过研究输出结果、跟踪源代码和程序的测试,将加深对这些技术的理解。

12.5.1.3 自动创建operator=

12.5 重载赋值符 - 图14

因为将一个对象赋给另一个相同类型的对象是大多数人可能做的事情,所以如果没有创建type:operator=(type),编译器将自动创建一个。这个运算符行为模仿自动创建的拷贝构造函数的行为:如果类包含对象(或是从别的类继承的),对于这些对象,operator=被递归调用。这被称为成员赋值(memberwise assignment)。见如下例子:

12.5 重载赋值符 - 图15

为Truck自动生成的operator=调用Cargo:operator=。

一般我们不会想让编译器做这些。对于复杂的类(尤其是它们包含指针时),应该显式地创建一个operator=。如果真的不想让人执行赋值运算,可以把operator=声明为private函数(除非在类内使用它,否则不必定义它)。