第12章 运算符类型与运算符重载
关键字class让您不仅能够封装数据和方法,还能封装运算符,以简化对对象执行的操作。通过使用这些运算符,可以像第 5 章处理整数那样,对对象执行赋值或加法运算。与函数一样,运算符也可以重载。
在本章中,您将学习:
• 使用关键字operator;
• 单目运算符与双目运算符;
• 转换运算符;
• C++11新增的移动复制运算符;
• 不能重新定义的运算符。
12.1 C++运算符
从语法层面看,除使用关键字 operator 外,运算符与函数几乎没有差别。运算符声明看起来与函数声明极其相似:
其中 operator_symbol 是程序员可定义的几种运算符类型之一。可以是+(加)、&&(逻辑 AND)等。编译器可根据操作数区分运算符。那么,C++在支持函数的情况下为何还要提供运算符呢?
来看封装了年、月、日的实用类Date:
如果要将这个Date对象指向下一天(2011年12月26日),下面两种方法哪种更方便、更直观呢?
方法1(使用运算符):
方法2(使用虚构的函数Date::Increment()):
显然,方法1优于方法2。基于运算符的机制更容易使用,也更直观。通过在Date类中实现运算符<,将可以像下面这样对两个日期进行比较:
运算符并非仅能用于管理日期的类。想想程序清单9.9所示的实用字符串类MyString吧,加法运算符(+)让您能够轻松地拼接字符串:
要实现相关运算符,需要做额外的工作,但类使用起来将更容易,因此值得这样做。
C++运算符分两大类:单目运算符与双目运算符。
12.2 单目运算符
顾名思义,单目运算符只对一个操作数进行操作。实现为全局函数或静态成员函数的单目运算符的典型定义如下:
作为类成员的单目运算符的定义如下:
可重载(或重新定义)的单目运算符如表12.1所示。
表12.1 单目运算符
要在类声明中编写单目前缀递增运算符(++),可采用如下语法:
而后缀递增运算符(++)的返回值不同,且有一个输入参数(但并非总是使用它):
前缀和后缀递减运算符的声明语法与递增运算符类似,只是将声明中的++替换成了—。程序清单12.1是一个简单的Date类,让您能够使用运算符++对日期进行递增。
程序清单12.1 一个处理日、月、年的日历类,可对日期执行递增和递减操作
输出:
分析:
我们感兴趣的代码是第 16~27 行,它们递增和递减运算符的实现,这些运算符让您能够将 Date对象存储的日期向前或向后推一天,如main()中的第44、51和52行所示。前缀递增运算符先执行递增操作,再返回指向当前对象的引用。
这个版本的Date类做了最大程度的简化,只阐述了如何实现前缀递增运算符(++)和前缀递减运算符(—)。这里假定每个月都包含30天,且没有考虑导致月份甚至年份加1的情形。
要支持后缀递增和递减运算符,只需在Date类中添加如下代码:
这样,就可像下面这样使用Date对象了:
在上述后缀运算符的实现中,首先复制了当前对象,再将对当前对象执行递增或递减运算,最后返回复制的对象。
换句话说,如果只想执行递增运算,可使用++ object,也可使用object ++,但应选择前者,这样可避免创建一个未被使用的临时拷贝。
在程序清单12.1的main()中,如果添加下述代码行:
将导致这样的编译错误:error: binary ‘<<’ : no operator found which takes a right-hand operand of type
‘Date’ (or there is no acceptable conversion)。这种错误表明,cout不知道如何解读Date实例,因为Date类不支持相关的运算符。
然而,cout能够很好地显示 const char *:
因此,要让 cout能够显示Date对象,只需添加一个返回 const char*的运算符:
程序清单12.2提供了该运算符的简单实现。
程序清单12.2 使用转换运算符将Date转换为 const char*
输出:
分析:
第20~27行实现了将Date转换为 const char的运算符,main()中的第35行演示了这样做的好处。现在,可在 cout语句中直接使用Date对象,因为 cout能够理解 const char。编译器自动将合适运算符(这里只有一个)的返回值提供给 cout,从而在屏幕上显示日期。在转换为 const char*的运算符中,使用std::ostringstream将整型成员转换成了一个std::string对象,如第23~25所示。原本也可直接返回formattedDate.str(),但没有这样做,而将其拷贝存储在私有成员Date::DateInString中,如第25行所示。这是因为formattedDate是一个局部变量,将在运算符返回时被销毁,因此运算符返回时,通过str()获得的指针将无效。
这个运算符让您能够以新的方式使用Date类。现在,您甚至可以将Date对象直接赋给string对象:
应根据类的可能用法编写尽可能多的运算符。如果应用程序需要Date对象的整数表示,可编写如下转换运算符:
这样便可将Date对象当做整数使用:
解除引用运算符(*)和成员选择运算符(->)在智能指针类编程中应用最广。智能指针是封装常规指针的类,旨在通过管理所有权和复制问题简化内存管理。在有些情况下,智能指针甚至能够提高应用程序的性能。智能指针将在第26章详细讨论,这里只简要地如何重载运算符,以帮助智能指针完成其工作。
请看程序清单12.3中std::unique_prt的用法,它使用了运算符*和->,让您能够像使用普通指针那样使用智能指针类。
程序清单12.3 使用智能指针 std:: unique_prt管理动态分配的Date对象
输出:
分析:
第26行声明了一个指向int的智能指针,它演示了智能指针类unique_ptr的模板初始化语法。同样,第32行声明了一个指向Date对象的智能指针。这里的重点是模式,请暂时不要考虑细节。
如果这种模板语法看起来很难理解,也不用担心,因为第14章将讨论模板。
这个示例表明,可像使用普通指针那样使用智能指针,如第 30 和 36 行所示。第 30 行使用了pDynamicAllocInteger来显示指向的int值,而第36行使用了pHoliday->DisplayData(),就像这两个变量的类型分别是int和Date。其中的秘诀在于,智能指针类std::unique_ptr实现了运算符和->。程序清单12.4是一个简单而基本的智能指针类的实现。
程序清单12.4 在一个简单的智能指针类中实现运算符*和->
输出:
分析:
这是程序清单12.3的翻版,但使用了自己的smart_pointer类,它是在第3~21行定义的。这里使用了模板声明语法,以便能够对该智能指针进行定制,以指向任何类型,如int(第42行)或Date(第45 行)。这个智能指针类包含一个私有成员,其类型与指向的数据的类型相同;该成员是在第 7 行声明的。基本上,该智能指针类旨在自动管理该成员指向的资源,包括在析构函数中自动释放它,如第10行所示。这个析构函数确保即使您使用new创建了指向的对象,也无需对其调用delete,且不会导致内存泄露。请将重点放在运算符的实现上,如第12~15行所示。其返回类型为T&,即一个引用,指向具体化该模板时指定的类型。该实现返回一个引用,指向智能指针指向的实例。同样,运算符->(如第17~20行所示)的返回类型为T,即一个指针,指向具体化该模板时指定的类型。在运算符->的实现中,第19行返回了成员指针。总之,这两个运算符让smart_pointer类隐藏了对原始指针进行内存管理的方式,让您能够像使用普通指针一样使用它,因此是智能指针。
与普通指针相比,除能够在指针离开作用域后释放其占用的内存外,智能指针还有很多其他功能,这将在第26章详细讨论。
如果读者对程序清单12.3中unique_prt的用法感到好奇,可参考编译器或IDE提供的头文件<memory>中的unique_prt实现,以了解它在幕后所做的工作。
12.3 双目运算符
对两个操作数进行操作的运算符称为双目运算符。以全局函数或静态成员函数的方式实现的双目运算符的定义如下:
以类成员的方式实现的双目运算符的定义如下:
以类成员的方式实现的双目运算符只接受一个参数,其原因是第二个参数通常是从类属性获得的。
表12.2列出了可在C++应用程序中重载或重新定义的双目运算符。
表12.2 可重载的双目运算符
与递增/递减运算符类似,如果类实现了双目加法和双目减法运算符,便可将其对象加上或减去指定类型的值。再来看看日历类Date,虽然前面实现了将Date递增以便前移一天的功能,但它还不支持增加5天的功能。为实现这种功能,需要实现双目加法运算符,如程序清单12.5中的代码所示。
程序清单12.5 实现了双目加法运算符的日历类
输出:
分析:
第14~25行是双目运算符+和-的实现,让您能够使用简单的加法和减法语法,如main()中的第41和45行所示。
对字符串类来说,双目加法运算符也很有用。第9章分析了简单的字符串包装类MyString,它封装了一个C风格字符串,并提供了内存管理、复制等功能,如程序清单9.9所示。但这个类不支持使用如下语法将两个字符串拼接起来:
不用说,实现运算符+后,MyString使用起来将非常容易,值得去实现它:
程序清单9.9中添加上述代码,并提供实现为空的私有默认构造函数MyString()后,便可使用加法语法了。本章后面的程序清单12.12提供了一个MyString类,它实现了+等运算符。
运算符提高了类的可用性,但实现的运算符必须合理。对于Date类,您实现了加法和减法运算符,但对于MyString类,只实现了加法运算符(+)。这是因为对字符串执行减法运算的可能性极少,实现这样的运算符很可能是在浪费时间。
加并赋值运算符支持语法a+=b;,这让程序员可将对象a增加b。这样,程序员可重载加并赋值运算符,使其接受不同类型的参数b。程序清单12.6让您能够给Date对象加上一个整数。
程序清单12.6 定义运算符+=和-=,以便将日历向前或向后翻整型输入参数指定的天数
输出:
分析:
运算符+=和-=是在第 14~24 行定义的。这些运算符让您能够加上或减去指定的天数,如 main()中的下述代码所示:
运算符+=和-=接受一个int参数,让您能够给Date对象加上或减去指定的天数,就像处理的是整数一样。您还可提供运算符+=的重载版本,让它接受一个虚构的CDays对象作为参数:
乘并赋值运算符(*=)、除并赋值运算符(/=)、求模并赋值运算符(%=)、减并赋值运算符(−=)、左移并赋值运算符(<<=)、右移并赋值运算符(>>=)、异或并赋值运算符(^=)、按位或并赋值运算符(|=)以及按位与并赋值运算符(&=)的语法都与程序清单12.6所示的加并赋值运算符类似。
虽然重载运算符的最终目标是让类更直观,更易于使用,但很多时候实现这些运算符并没有意义。例如,前面的日历类 Date 绝对不会用到按位与并赋值运算符&=。这个类的用户应该不会想通过greatDay &= 20;等操作获得有用的结果。
如果像下面这样将两个Date对象进行比较,结果将如何呢?
由于还没有定义等于运算符,编译器将对这两个对象进行二进制比较,并仅当它们完全相同时才返回true。在有些情况下(包括现在的Date类),这是可行的。然而,如果类有一个非静态字符串成员,它包含字符串值(char *),如程序清单 9.9所示的MyString,则比较结果可能不符合预期。在这种情况下,对成员属性进行二进制比较时,实际上将比较字符串指针,而字符串指针并不相等(即使指向的内容相同),因此总是返回false。
因此,正确的做法是定义比较运算符。等于运算符的通用实现如下:
实现不等运算符时,可重用等于运算符:
不等运算符的结果与等于运算符相反(逻辑非)。程序清单12.7列出了日历类Date定义的比较运算符。
程序清单12.7 运算符==和!=
输出:
分析:
等于运算符(==)的实现很简单,它在年、月、日都相同时返回true,如第14~19行所示。实现不等运算符时,重用了等于运算符的代码,如第 23 行所示。有了这两个运算符后,就可对两个 Date对象(Holiday1和Holiday2)进行比较了,如main()中的第42和47行所示。
程序清单12.7所示的代码让Date类足够聪明,能够判断两个Date对象是否相等。然而,如果要使用该类执行类似下面的条件检查,该如何办呢?
或
或
或
如果能够使用这个日历类来比较两个日期,确定哪个在前、哪个在后,将很有用。编写这类的程序员应实现这种比较,让这个类对用户来说尽可能友好和直观,如程序清单12.8所示。
程序清单12.8 实现运算符<、>、<=和>=
输出:
分析:
这里要讨论的运算符是在第21~52行实现的。注意到实现这些运算符时,重用了其他运算符的代码。
在main()函数的第75~84行,使用了这些运算符,以演示这些运算符使得使用Date类简单而直观。
有时候,需要将一个类实例的内容赋给另一个类实例,如下所示:
如果您没有提供复制赋值运算符,这将调用编译器自动给类添加的默认复制赋值运算符。根据类的特征,默认复制赋值运算符可能不可行,具体地说是它不复制类管理的资源。与复制构造函数一样,为确保进行深复制,您需要提供复制赋值运算符:
如果类封装了原始指针,如程序清单9.9所示的MyString类,则确保进行深复制很重要。如果没有实现赋值运算符,编译器将提供默认的复制赋值运算符,但它只复制 char* Buffer包含的地址,而不复制指向的内存中的内容。这与没有提供复制构造函数时出现的情况相同。为确保赋值时进行深复制,应定义复制赋值运算符,如程序清单12.9所示。
程序清单12.9 对程序清单9.9所示的MyString类进行改进,添加了复制赋值运算符
输出:
分析:
在这个示例中,笔者故意省略了复制构造函数,旨在减少代码行(但您编写这样的类时,应添加它,详情请参阅程序清单9.9)。复制赋值运算符是在第25~39行实现的,其功能与复制构造函数很像。它首先检查源和目标是否同一个对象。如果不是,则释放成员Buffer占用的内存,再重新给它分配足以存储复制源中文本的内存,然后使用strcpy()进行复制,如第36行所示。
相比于程序清单 9.9,程序清单 12.9的另一个细微差别在于,使用返回 const char*的转换运算符替代了函数GetString(),如第53~56行所示。该运算符让MyString类使用起来更容易,如第 68 行所示——使用一条 cout 语句显示了两个 MyString实例的内容。
如果您编写的类管理着动态分配的资源(如C风格字符串char*)、动态数组等,除构造函数和析构函数外,请务必实现复制构造函数和复制赋值运算符。
如果没有考虑对象被复制时出现的资源所有权问题,您的类就是不完整的,使用时甚至会有危险。
要创建不允许复制的类,可将复制构造函数和复制赋值运算符都声明为私有的。只需这样声明(甚至都不用提供实现)就足以让编译器在遇到试图复制对象(将对象按值传递给函数或将一个对象赋给另一个对象)的代码时引发错误。
下标运算符让您能够像访问数组那样访问类,其典型语法如下:
编写封装了动态数组的类(如封装了 char* Buffer的MyString)时,通过实现下标运算符,可轻松地随机访问缓冲区中的各个字符:
程序清单12.10是一个简单的示例,演示了下标运算符([])让用户能够使用常规数组语法来遍历MyString实例包含的字符。
程序清单12.10 在MyString类中实现下标运算符,以便随机访问MyString::Buffer包含的字符
输出:
分析:
这个程序很有趣,它接受用户输入的句子,并使用它创建一个 MyString 对象,如第 61 行所示;接下来,在一个 for 循环中,使用下标运算符([])和数组语法逐字符地打印该字符串,如第 64~65行所示。下标运算符([])是在第31~35行实现的,它首先确保指定的位置没有超出char*Buffer末尾,然后返回指定位置处的字符。
实现运算符时,应使用关键字const,这很重要。在程序清单12.10中,将下标运算符([])的返回类型声明成了 const char&。即便没有关键字 const,该程序也能通过编译。这里使用它旨在禁止使用下面这样的代码:
通过使用const,可禁止从外部通过运算符[]直接修改成员MyString::Buffer。除将返回类型声明为const外,还将该运算符的函数类型设置成为const,这将禁止该运算符修改类的成员属性。一般而言,应尽可能使用const,以免无意间修改数据,并最大限度地保护类的成员属性。
实现下标运算符时,可在程序清单 12.10 所示版本的基础上进行改进。这个版本只实现了一个下标运算符,它可用于读写动态数组的元素。
然而,也可实现两个下标运算符,其中一个为const函数,另一个为非const函数:
编译器很聪明,能够在读取MyString对象时调用const函数,而在对MyString执行写入操作时调用非const函数。因此,如果愿意,可在两个下标函数中实现不同的功能。例如,一个运算符记录对容器的写入操作,而另一个记录对容器的读取操作。还有其他双目运算符可被重定义或重载(如表 12.2所示),但本章不打算介绍它们。这些运算符的实现与已讨论的运算符类似。
如果其他运算符(如逻辑运算符和按位运算符)有助于改善您编写的类,就应实现它们。显然,诸如Date等日历类没有必要实现逻辑运算符,但处理字符串和数字的类可能需要实现它们。
应根据类的目标和用途重载运算符或实现新的运算符。
12.4 函数运算符operator()
operator()让对象像函数,被称为函数运算符。函数运算符用于标准模板库(STL)中,通常是 STL算法中。其用途包括决策。根据使用的操作数数量,这样的函数对象通常称为单目谓词或双目谓词。下面分析一个非简单的函数对象,如程序清单12.11所示,以便理解使用如此有意思的名称的原因!
程序清单12.11 一个使用operator()实现的函数对象
输出:
分析:
第8~11行实现了operator(),然后在main()函数的第18行使用了它。注意,之所以能够在第18行将对象mDisplayFuncObject用作函数,是因为编译器隐式地将它转换为对函数operator()的调用。
因此,这个运算符也称为operator()函数,对象CDisplay也称为函数对象或functor。第21章将详尽地讨论这个主题。
C++11
用于高性能编程的移动构造函数和移动赋值运算符
移动构造函数和移动赋值运算符乃性能优化功能,属于C++11标准的一部分,旨在避免复制不必要的临时值(当前语句执行完毕后就不再存在的右值)。对于那些管理动态分配资源的类,如动态数组类或字符串类,这很有用。
1.不必要的复制带来的问题
请看程序清单12.5实现的加法运算符,注意到它创建并返回一个拷贝;减法运算符亦如此。使用下面的语法创建新的MyString实例时,情况将如何呢?
这种方式非常直观,它使用双目加法运算符(+)将三个字符串拼接起来。该运算符的实现类似于下面这样:
这个加法运算符(+)让您能够使用直观的表达式轻松地拼接字符串,但也可能导致性能问题。创建sayHello时,需要执行加法运算符两次,而每次都将创建一个按值返回的临时拷贝,导致执行复制构造函数。复制构造函数执行深复制,而生成的临时拷贝在该表达式执行完毕后就不再存在。总之,该表达式导致生成一些临时拷贝(准确地说是右值),而它们在当前语句执行完毕后就不再需要。这一直是C++带来的性能瓶颈,直到最近才得以解决。
C++11 解决了这个问题:编译器意识到需要创建临时拷贝时,将转而使用移动构造函数和移动赋值运算符——如果您提供了它们。
2.声明移动构造函数和移动赋值运算符
移动构造函数的声明语法如下:
从上述代码可知,相比于常规赋值构造函数和复制赋值运算符的声明,移动构造函数和移动赋值运算符的不同之处在于,输入参数的类型为MyClass&&。另外,由于输入参数是要移动的源对象,因此不能使用const进行限定,因为它将被修改。返回类型没有变,因为它们分别是构造函数和赋值运算符的重载版本。
在需要创建临时右值时,遵循 C++的编译器将使用移动构造函数(而不是复制构造函数)和移动赋值运算符(而不是复制赋值运算符)。移动构造函数和移动赋值运算符的实现中,只是将资源从源移到目的地,而没有进行复制。程序清单12.12演示了如何使用这两项C++11新增功能对MyString类进行优化。
程序清单 12.12 除复制构造函数和复制赋值运算符外,还包含移动构造函数和移动赋值运算符的MyString类
输出:
没有移动构造函数和移动赋值构造函数(将第95~119行注释掉)时的输出:
添加移动构造函数和移动赋值构造函数后的输出:
分析:
这个代码示例很长,但大部分都在本书前面介绍过。在该程序清单中,最重要的部分是第95~119行,其中实现了移动构造函数和移动赋值运算符。这些C++11新增功能生成的输出使用粗体表示。注意到相比于没有这两个实体时,输出变化很大。如果您查看移动构造函数和移动赋值运算符的实现,将发现移动语义基本上是通过接管移动源中资源的所有权实现的,如移动构造函数的第 101 行和移动赋值运算符的第114行所示。接下来,将移动源指针设置为NULL,如第102和115行所示。这样,移动源被销毁时,通过析构函数(第16~20行)调用的delete什么也不会做,因为所有权已转交给目标对象。注意到在没有移动构造函数时,将调用复制构造函数,它对指向的字符串进行深复制。总之,移动构造函数避免了不必要的内存分配和复制步骤,从而节省了大量的处理时间。
移动构造函数和移动赋值运算符是可选的。不同于复制构造函数和复制赋值运算符,如果您没有提供移动构造函数和移动赋值运算符,编译器并不会添加默认实现。
对于管理动态分配资源的类,可使用C++11新增的这项功能对其进行优化,避免在只需临时拷贝的情况下进行深复制。
12.5 不能重载的运算符
虽然C++提供了很大的灵活性,让程序员能够自定义运算符的行为,让类更易于使用,但C++也有所保留,不允许程序员改变有些运算符的行为。表12.3列出了不能重新定义的运算符。
表12.3 不能重载或重新定义的运算符
12.6 总结
本章介绍了如何各种运算符,让类更易于使用。编写管理资源(如动态数组或字符串)的类时,除析构函数外,还需至少提供复制构造函数和复制赋值运算符。对于管理动态数组的实用类,如果包含移动构造函数和移动赋值运算符,就可避免将包含的资源深复制给临时对象。最后,您学习了.、.*、::、?:和sizeof等不能重新定义的运算符。
12.7 问与答
问:我编写的类封装了一个动态整型数组,请问我至少应该实现哪些函数和方法?
答:编写这样的类时,必须明确定义下述情形下的行为:通过赋值直接复制对象或通过按值传递给函数间接复制对象。通常,应实现复制构造函数、复制赋值运算符和析构函数。另外,如果想改善这个类在某些情况下的性能,还应实现移动构造函数和移动赋值运算符。
问:假设有一个类对象object,而我希望支持语法 cout << object;,请问需要实现哪个运算符?
答:您需要实现一个转换运算符,让类能被解读为 std::cout 支持的类型。一种解决方案是,像程序清单 12.2那样定义运算符 char* ()。
问:自己编写智能指针类时,至少需要实现哪些函数和运算符?
答:智能指针必须能够像常规指针那样使用,如pSmartPtr或pSmartPtr->Func()。为此,需要实现运算符和->。要确保它足够智能,还需合理地编写析构函数,以自动释放/归还资源;另外,还需实现复制构造函数和复制赋值运算符,以明确定义复制和赋值的方式(也可将复制构造函数和复制赋值运算符声明为私有的,以禁止复制和赋值)。
12.8 作业
作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 的答案。在继续学习下一章前,请务必弄懂这些答案。
1.可以像下面这样,编写两个版本的下标运算符,一个的返回类型为const,另一个为非const吗?
2.可以将复制构造函数或复制赋值运算符声明为私有的吗?
3.给Date类实现移动构造函数和移动赋值运算符有意义吗?
1.为Date类编写一个转换运算符,将其存储的日期转换为整数。
2.DynIntegers类以int*私有成员的方式封装了一个动态分配的数组,请给它编写移动构造函数和移动赋值运算符。