🫑运算符重载
2022-6-5
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 

 

运算符重载

C++ 定义了大量操作符,并且为内置类型之间的转换定义了自动转换。这些使得程序员可以很方便的写出混合类型的表达式。
同时C++允许我们定义当将操作符运用于类类型对象时的含义。它还允许我们定义类类型之间的转换,和内置类型一样,在需要时将一个类型的对象隐式转为另外一个类型对象。
 
操作符重载(operator overloading)允许我们定义运用于类类型的操作符的含义。谨慎地使用操作符重载可以使得程序更加容易读和写。如果之前地 Sales_item 类定义了重载的输入、输出和加操作符,那么可以如下操作:
如果没有定义这些重载操作符的话,操作就会没有那么简洁:
 
重载操作符是具有特殊名字的函数:关键字operator后跟被定义的操作符的符号。与任何别的函数一样,重载操作符有返回值类型、参数列表和函数体。
  • 重载的操作符函数具有与操作符的操作数一样的参数个数。一元操作符只有一个参数,二元操作符有两个参数。除了重载函数调用运算符operator() 之外,其他重载运算符不能含有默认实参。
  • 如果一个操作符函数是成员函数,则它的第一个(左边)操作数将绑定到隐式的this指针上,因此成员操作符函数的显式参数将比操作符的操作数少一个。
 
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数,这意味着当运算符作用于内置类型的运算对象时,我们无法改变运算符的含义:
 
可以重载大部分但不是全部的操作符:
notion image
只能重载已经存在的操作符,不能发明新的操作符符号。对于符号如(+ - * 和 &)同时可以作为一元和二元操作符的。两者之一或者两种性的操作符都可以被重载。参数的个数决定哪个操作符被重载。
重载的操作符与内置类型的操作符具有相同的优先级结合性。
 

直接调用一个重载操作符函数

通常通过使用操作符于合适类型的参数上间接调用重载的操作符函数,也可以通过调用常规函数的方式直接调用重载操作符函数。直接输入函数的名字(operator op)并传递合适类型的合适数目的参数:
 
调用一个成员操作函数与调用任何别的成员函数的方式是一样的。指定对象(或指针)的名字,然后使用点号(或箭头)操作符来获取想要调用的函数:
 

有些操作符不应该被重载

某些运算符指定了运算对象求值的顺序。由于重载操作符本质上就是函数调用,这些关于运算对象求值顺序的规则无法应用到重载的运算符上。尤其是,逻辑与(&&)和逻辑或(||)以及逗号操作符的操作数求值顺序就不会保留。特别是,重载版本的 && 和 || 操作符不能保留内置操作符的短路求值特性,两个操作数将总是被求值。
不要重载逗号操作符和取地址 & 操作符的另外一个原因是:C++定义了当逗号和取地址操作符用于类类型对象时的含义。由于这些操作符有内置的含义,它们通常不应该被重载。
 

使用与内置类型一致的含义

设计类时,我们应该总是第一想到这个类应该提供什么操作。只有当了解需要哪些操作时,才能决定是将这个操作定义为常规函数或者是重载的操作符。那些在逻辑上匹配操作符的操作是定义重载操作符的好的候选对象:
  • 如果类有 IO 操作,将移位操作符定义地与内置类型的 IO 含义一致
  • 如果类有一个操作可以比较相等性,定义 operator==,如果类有 operator=,通常需要定义 operator!=
  • 如果一个类具有单一的自然顺序操作,定义 operator<,如果类有 operator<,它通常需要所有的关系操作符
  • 重载操作符的返回值类型通常需要与内置版本的操作符的返回值类型兼容:逻辑和关系操作符应该返回bool值,算术操作符应该返回本类类型的值,赋值和复合赋值操作符应该返回左侧运算对象的一个引用
 

赋值和复合赋值操作符

赋值操作符应该表现地类似于编译器合成的操作符:在赋值之后,左边和右边的操作数应该具有相同的值,并且操作符应该返回左边操作数的引用。重载的赋值操作符应该是内置类型的赋值操作的泛化(generalize)而不是绕过它。
 
注意:谨慎使用操作符重载
每个操作符在用于内置类型时都有一个固定含义。比如:二元 + 的含义就是表示加法。将二元 + 映射到类类型的类似操作上将提供方便的简化符号。如标准库 string 类型遵从许多编程语言中共通的约定,用 + 表示拼接。
操作符重载在内置操作符能够在逻辑上映射到我们的类型上的操作时时最有用的。使用重载的操作符而不是命名的操作将使得我们程序更加自然和直观。滥用操作符重载则使得类难以理解。
明显的滥用操作符重载在现实中是十分少见的。更加常见的是扭曲一个操作符的“正常”含义来强制适用于一个给定的类型。只有在操作对于用户来说是明确的时候才应该使用操作符。如果一个操作符看起来好像有多于解释,那么这个操作符就是模糊的。
如果一个类具有算术或者按位操作符,那么同时提供对应的复合赋值操作符是一个好的想法。当然这些重载的操作符应该在行为上与内置操作符的含义一致。
 

选择作为成员或者非成员实现

定义重载操作符时,必须决定使得操作符作为一个类成员还是一个普通的非成员函数:
  • 赋值(=)、下标([])、调用(())和成员访问箭头(>)必须被定义为成员函数
  • 复合赋值操作符通常应该是成员,然而,不像赋值操作符,这不是必须的
  • 改变对象状态的操作符或者与这个类类型关系十分密切的操作符应该被定义为成员,如:自增、自减和解引用操作符
  • 对称操作符——它们可以转换任何一个操作数,比如算术运算、相等性比较、关系比较和按位操作符——通常应该被定义为常规的非成员函数
 
程序员会期望将对称操作符用于混合类型的表达式中。比如可以将 intdouble 类型值进行相加。加法是对称的,因为可以让左边或者右边操作数的类型作为重载操作符的类型。如果想要提供类似的混合类型表达式于类对象上,那么操作符就必须被定义为非成员函数。
 
当将一个操作符定义为成员函数时,左边操作数将必须是操作符作为成员的类的对象:
如果 operator+ 是string类的一个成员,那么第一个加法将等价于 s.operator+("!"),同样,"hi" + s 将等价于 "hi".operator+(s),然而类型 "hi" 是 const char*,那个类型是内置类型,没有成员函数。
 
由于string将 + 定义为普通的非成员函数,"hi" + s 等价于operator+("hi", s),与任何函数调用一样,其中任意一个实参都可以转为合适的形参类型。它唯一的要求就是至少有一个操作数是string类型,且两个操作数都可以明确地转换为string
 

输入和输出运算符

IO标准库使用 >> 和 << 来表达输入和输出。IO标准库自身定义如何读写内置类型的这些操作符的版本。如果类需要支持IO ,那么同样需要定义自己的这些操作符的版本

重载输出操作符 <<

通常输出操作符的第一个形参是一个非常量ostream对象的引用。之所以ostream是非常量是因为向流写入内容会改变其状态,而该形参是引用是因为无法直接复制一个ostream对象。
第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。第二个形参是引用的原因是希望避免复制实参,而之所以该形参可以是常量是因为打印对象不会改变对象的内容。
为了与其它的输出操作符一致,operator<< 通常应该返回其 ostream 参数。
 
输出操作符尽量减少格式化操作
内置类型的输出操作符只做最少的格式化,特别是不打印任何换行符。用户对于类输出操作符也期待类似的行为。输出操作符只做最少的格式化将让用户控制输出的细节。
 
IO操作符必须是非成员函数
符合 iostream 标准库中的约定的输入输出操作符应该被定义为常规的非成员函数,而不能是类的成员函数。如果是的话,左边的操作数将不得不是我们自己的类类型对象:
假设输入输出运算符是某个类的成员,它们最好是istream或者ostream的成员。然而,这些对象是标准库的一部分,我们是不能添加成员到这些类的。
因而,如果想要为类自定义IO操作符的话,必须将其定义为非成员函数。当然,IO操作符通常需要读或写非公有数据成员。因而,IO 操作符通常被声明为友元。
 

重载输入操作符 >>

通常输入操作符的第一个参数是输入流对象的引用,第二个参数是写入的对象的非 const 引用。操作符通常返回给定流对象的引用。第二个参数必须是非 const 的,是由于输入操作符的目的就在于将输入写入到此对象中:
if 检查读取是否成功,如果发生了IO错误,操作符将给定的对象重置为空的 Sales_data 对象。
注:输入操作符必须处理可能出现的输入错误,输出操作符通常没有这样的烦恼;
 
在输入时发生的错误
在输入操作符中可能发生如下种类的错误:
  • 读操作可能会因为流包含了不正确的类型的数据。比如,如果想要读取两个数字类型的数据,但是输入流中包含的不是数字类型的
  • 任何读操作都可能会遇到到达文件尾部(end-of-file)或者一些别的错误
相较于每次读都进行检查,在读取所有数据之后并在使用这些数据之前进行一次检查。将对象置于有效的状态是非常重要的,因为对象可能会在错误发生前被部分地改变。
 
指示发生的错误
一些输入操作符需要做一些额外的数据校验。如需要对数据的合法范围进行校验,或者数据是合法的格式。在这种情况下输入操作符需要设置流的条件状态(condition state)来表示错误,即便从技术上来说实际上IO是成功的。通常输入操作符只能设置 failbit。设置 eofbit 将暗含文件被耗尽,设置 badbit 将表示流损坏。这些错误最好是留给IO库自己来标示这些错误。
 
 

算术和关系运算符

通常,把算术和关系运算符定义为非成员函数,这样可以让左边或者右边的操作数可以进行合适的转换。这些操作符不应该改变操作数的状态,所以参数通常是常量的引用。
一个算术操作符通常会产生一个新的值,这个值是计算两个操作数所得到的。这个值区别于任何一个参数,并且是在本地变量中计算的。这个操作返回这个本地变量的拷贝作为结果。定义算术操作符的类通常会定义对应的复合赋值操作符。当一个类同时具有这两个操作符时,通常将算术操作符定义为使用复合赋值操作符是更加高效的:
同时定义了算术运算符和相应的复合赋值操作符的类应该将算术运算符实现为复合赋值操作
 

相等操作符

通常,C++的类通过定义相等运算符来测试两个对象是否相等。它们通常会比较每一个数据成员,只有在对应的所有成员都相等时才会认为是相等:
这些函数的定义是十分简单的,更为重要的它们所涉及到的设计原则:
  • 如果一个类有操作来决定两个对象是否相等,它应该将函数定义为 operator== 而不是一个普通的命名函数
  • 如果一个类定义了 operator==,那么这个操作符通常应该决定给定对象是否具有相等的数据
  • 通常,相等操作符应该是可传递的,意味着如果 a == b 并且 b ==c,那么 a == c 应该同样为真
  • 如果一个类定义了 operator==,那么它通常应该定义 operator!=,两者是相互依存的
  • 相等或不等操作符应该将其工作交给另外一个去完成。意味着,其中一个操作符将做真正的比较对象的操作,而另外一个应该调用这个来完成其工作;
如果一个类在逻辑上有相等性的含义,则该类通常应该定义 operator==,类定义 == 将使得其容易与通用算法一起使用。
 

关系操作符

定义相等操作符的类同样也会定义关系操作符。特别是由于关联容器和一些算法使用小于操作符,那么定义 operator< 将十分有用。
通常关系运算符应该:
  • 定义与作为关联容器中的键的要求一致的顺序关系
  • 如果类同时也含有==运算符的话,则定义一种关系令其与==保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个。
对于像 Sales_data 这种没有逻辑上的 < 概念的类型,最好是不要定义关系操作符。
如果存在 < 操作的单一逻辑上的定义,那么通常应该定义 < 操作符。然而,如果类同时有 ==,只有在 < 和 == 操作符产生一致的结果时才重载 < 操作符
 

赋值运算符

除了可以将相同类型的对象拷贝赋值或移动赋值给另外一个对象之外,一个类还可以定义额外的赋值操作符用于将其它类型的对象作为右边的操作数。
比如,vector类定义了第三种赋值操作符,该运算符接受花括号内的元素列表作为参数,可以按如下方式使用操作符:
 
同样也可以将这个操作符添加到我们自己的StrVec类中:
为了与内置类型的赋值操作(并且与已经定义的拷贝赋值和移动赋值操作符一致),新的赋值操作符将返回左操作数的引用。
与拷贝赋值和移动赋值操作符一样,其它重载的赋值操作符应该释放掉已经存在有元素并且创建新的元素。不同于拷贝赋值和移动赋值操作符,这个操作符不需要检查对象向自身的赋值。参数的类型是 initializer_list<string> 意味着il不可能与this所表示的对象相同。
注:赋值操作符可以被多次重载。赋值操作符不管参数类型是什么都必须定义为成员函数。
 
复合赋值操作符
复合赋值操作符并不需要必须是成员。然而,我们倾向于将所有的赋值操作包括复合赋值操作定义在类中。为了与内置复合赋值操作符保持一致,这些操作符将返回左操作数的引用。
赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。
 
 

下标运算符

表示容器的类型通常会定义下标操作符 operator[] 来通过位置获取元素。重载下标操作符必须是成员函数。
为了兼容常规的下标操作符的含义,重载下表操作符通常返回获取的元素的引用。通过返回引用,下标操作可以用于赋值操作的任何一边。因而,同时定义 const 和非 const 版本的操作符是一个好的主意。当运用于 const 对象时,下标操作应该应该返回一个 const 引用,那么就不能对返回的对象进行赋值。
 
当一个类有下标操作符时,它通常应该定义两个版本:一个返回非const引用,另一个是const成员并返回const引用。
可以按照类似于对vector或数组进行下标操作的方式使用这些曹祖福。由于重载下标操作符返回的是一个元素的引用,如果 StrVec 是非const的,就可以对元素进行赋值;如果对const对象进行下标操作,便不能为其赋值:
 

递增和递减运算符

自增(++)和自减(--)操作符最常被迭代器类实现。这个操作符让类可以在元素的序列中前后移动。C++并不要求这些操作符必须是类的成员。然而,由于这些操作符改变了它们操作的对象的状态,倾向于让它们成为成员函数。
对于内置类型,同时存在前置和后置版本的自增和自减操作符。我们同样也应该为类类型定义前置和后置的版本。
 
StrBlobPtr类定义前置的自增和自减操作符:
为了与内置类型操作符保持一致,前置操作符应该返回自增后或者自减后的对象的引用。
 
区别前置和后置操作符
当同时定义前置和后置版本时会遇到一个问题:正常的重载不能区分这两个操作符。前置和后置版本使用相同的符号,意味着重载版本具有相同的名字。它们同时具有相同的数目和类型的操作数。
为了解决这个问题,后置版本有一个额外的(不使用的)int 类型参数。当使用后置版本的操作符时,编译器给这个形参提供 0 作为实参。尽管后置版本的函数可以使用这个额外的参数,通常是不应该使用。这个参数本身就是后置操作符正常工作所不需要的。它存在的唯一目的就是让前置版本与后置版本进行区分:
为了与内置类型操作符保持一致,后置操作符应该返回旧的(未自增或者未自减)的值。这个值将作为值返回而不是引用。
注:int参数没有被使用,所以没有给其一个名字
 
 
显式调用后置操作符
可以显式调用重载的操作符作为另外一种在表达式中使用操作符的方式。如果想要用函数调用方式调用后置版本,就必须自己提供这个整数参数:
尽管传递过去的值通常是被忽略的,但是依然需要传递,这是为了告知编译器使用的是后置版本
 

成员访问运算符

解引用(*)和箭头(->)操作符通常用于表示迭代器的类中,以及智能指针类:
箭头操作符通过调用解引用操作符并返回那个操作符的返回元素的地址来避免做任何实际的工作。
注:箭头操作符必须是成员。解引用操作符就没有要求必须是成员,但通常应该被定义为成员。
 
这里将这些操作符定义为 const 成员。不像自增和自减操作符,获取成员不会改变 StrBlobPtr 自身的状态。同样需要注意的是这些操作符返回一个非常量string对象的引用或指针。它们这样做的原因在于我们知道 StrBlobPtr 只能绑定到非常量 StrBlob 对象上。
 
箭头操作符的返回值的限制
与绝大多数其它操作符一样,可以定义operator* 做任何我们喜欢的操作,如返回固定值 42 或者打印对象的内容。当箭头操作符的重载不能这么做,箭头操作符不能丢失其成员访问的基本含义,不能改变箭头操作符获取成员的事实。
当书写point->mem 时,point必须要么是类类型对象的指针要么是一个重载了 operator-> 的类对象。根据point的类型,书写 point->mem 等价于:
除此之外的任何含义都是错误。意味着 point->mem 执行以下逻辑:
  1. 如果 point 是指针,那么内置箭头操作符将被运用,意味着这个表达式等价于 (*point).mem,指针被解引用并且指定的成员从结果对象中取出。如果 point 指向的类型没有名字为 mem 的成员,那么代码将发生错误;
  1. 如果 point 是一个定义了 operator-> 的类对象,那么 point.operator->() 的结果将被用于获取 mem。如果结果是一个指针,那么从在这个指针上执行步骤1。如果结果是一个自身重载了 operator->() 对象,那么步骤2将在那个对象上重复。这个过程一直持续到要么得到一个对象(这个对象有指定的成员)的指针,要么返回一个其它的值,这第二种情况下代码是错误的。
注:重载的箭头操作符必须要么返回一个类类型的指针,要么是一个定义了自己的箭头操作符的类类型对象。
 

函数调用运算符

重载了调用操作符的类允许这个类型的对象就好像是函数一样使用。由于此类还存储了状态,它们将比常规函数更加的灵活。
一个简单的例子,下面的absInt就有一个调用操作符返回其参数的绝对值:
通过类似函数调用的方式将参数列表运用于absInt 对象来调用这个 () 操作符
尽管absObj是一个对象不是函数,也能“调用”这个对象。调用一个对象将运行其重载的调用操作符。在这种情况下,这个操作符取一个 int值,并返回其绝对值。
注:函数调用操作符必须是成员函数。一个类型可以定义多个调用操作符版本,其中每一个必须在参数的个数或类型不一样。
 
定义了调用操作符的类对象被称为函数对象(function objects),这种对象“在行为上类似于函数”,因为可以调用它们。

含有状态的函数对象类

与其他类一样,函数对象类除了 operator() 外也可以包含其他的成员。函数对象类经常包含数据成员用于定制调用操作符。如我们可以在调用函数时提供不同的分割符,可以如下定义类:
 
这个类的构造函数以输出流的引用和一个字符作为分割符。它使用cout和空格作为默认的实参。调用操作符的函数体则使用这些成员来定义给定的string对象。
当定义PrintString对象时,可以使用默认的或者提供我们自己的分割符或者输出流:
 
函数对象常常用于泛型算法的实参。例如,可以将PrintString的对象传递给for_each算法来打印容器中的内容:
for_each的第三个实参是类型PrintString的一个临时对象,其中用cerr和换行符初始化了该对象。当程序调用for_each时,将会把vs中的每个元素依次打印到cerr中,元素之间以换行符分隔。
 

Lambdas 是函数对象

使用了PrintString对象作为实参来调用 for_each,这个用法类似于lambda表达式。当写lambda时,编译器将其翻译成一个匿名类的匿名对象。这个类从lambda中产生并包含一个函数调用操作符:
 
将表现得类似于下面的类的匿名对象:
这个生成的类只有一个成员 —— 函数调用操作符,它以两个string为参数并比较它们的长度。默认情况下lambda不会改变其捕获变量。因而,默认情况下由lambda生成的类的函数调用操作符是一个const成员函数。如果lambda被声明为可变的,那么调用操作符将不是const的。
用这个类替代lambda表达式后,可以重写并重新调用stable_sort
 

表示lambda及相应捕获行为的类

当一个lambda按照引用捕获一个变量时,将有程序保证被引用的变量在lambda执行时依然存在。这样编译器就允许直接使用引用而不需要将引用作为数据成员存储在生成的类中。
作为比较,如果变量是按照值捕获的则被拷贝到lambda中。因而,从lambda中生成的类将有数据成员与每个值捕获的变量对应。这些类还有一个构造函数来初始化这些数据成员,其值来自于捕获的变量。
 
lambda表达式产生的类将形如:
不像ShorterString 类,这个类有一个数据成员以及一个构造函数来初始化这个成员。这个合成的类没有默认构造函数;为了使用这个类,必须传递参数:
从 lambda 表达式中生成的类有一个被删除的默认构造函数、被删除的赋值操作符以及默认析构函数。类是否有默认或删除的拷贝/移动构造函数取决于捕获的数据成员的类型,这与普通类的规则是一样的。
 

标准库中的函数对象

标准库定义一系列类来表示算术、关系和逻辑操作符。每个类定义了一个调用操作符以运用其类名所表示的操作。比如,plus 类有一个调用操作符来运用 + 于一对操作数;modulus 类定义了一个调用操作符以运用 % 操作符;equal_to 类运用 ==等等。
 
这些类都是需要提供一个类型的类模板。这些类型指定了调用操作符的参数的类型。比如,plus<string> 运用 string 的加操作符于 string 对象;plus<int> 的操作数是 intplus<Sales_data> 将 + 运用于 Sales_data 对象等等;
以下是定义于 functional 头文件中的类型:
notion image
 
在算法中使用标准库函数对象
表示操作符的函数对象类经常被用于重载算法所使用的默认操作符。默认情况下,排序算法使用 operator< 来将序列排序为升序序列。为了按照降序排列,可以传递一个greater类型的对象。这个类将生成一个调用操作符以调用底层元素类型的 operator> ,如:
这里第三个参数是一个类型为 greater<string> 的匿名对象。当 sort比较元素时,不是使用默认 < ,而是调用给定greater函数对象。那个对象将运用 string 元素的 > 操作符。
 
这些库中的函数对象的一个重要方面就是库保证它们可以工作于指针上。比较两个不相关的指针是未定义的。然而,我们也许想基于在内存中的地址对一个指针 vector进行sort,标准库函数对象就可以做到:
值得说明的是关联容器使用 less<key_type> 来排序元素。因而,可以定义指针的set或者使用指针作为map中的键而不用直接指定 less对象。
 
 

可调用对象和 std::function

C++有多种可调用对象:函数、函数指针,lambdas表达式,bind创建的对象,重载函数调用操作符的类。
与任何别的对象一样,可调用对象是有类型的。如,每个 lambda 有一个自己的唯一的匿名类类型。函数和函数指针类型根据它们的返回值类型和参数类型的不同而有所不同,等等。
然而,两个不同类型的可调用对象也许会有相同的调用形式。这个调用形式说明了调用此对象时返回的类型以及必须传递的参数类型。下面是函数类型的调用签名:int(int, int) 表示一个以两个int 为参数返回一个int的函数类型。
 
不同的类型可以有相同的调用签名
有时我们希望将有着同一个调用前面的几个可调用对象看做是同一个类型。如考察下面的不同类型的可调用对象:
尽管它们的类型不一样,它们的调用签名是一样的:int(int, int)。我们也许想用这些可调用对象来创建一个简单的计算器。为了达到目的,需要定义一个函数表来存储这些可调用对象的“指针”。如果将函数表定义为如下:
可以将 add 以 binops.insert({"+", add}); 添加进去,但是不能添加 mod ,因为 modlambda,然而每个 lambda 都有自己的类类型。这与 binops 中的值的类型是不一致。
 
标准库 std::function 类型
通过一个定义在 functional 头文件中的新的标准库类 std::function来解决此问题;下表列举了定义在function中的操作:
notion image
function是模板。与其它模板一样,当创建 function 类型对象时我们必须提供额外的信息,在这里是调用签名,如:
可以用上面的 function 类型来表示可调用对象:接收两个int参数并返回一个int结果:
 
可以按照这个方式重新定义map
map有五个元素,尽管其底层类型都不一样,可以将其存储到同一个调用签名的 function 类型下。
 
 
重载的函数和 function
不能直接将一个重载的函数的名字存储到function类型的对象中:
一种解决这种二义性的方式是存储函数指针而不是函数的名字:
或者使用lambda来消除二义性:
:新标准库中function类与之前版本中的 unary_function 和 binary_function 是不相关的。这些类已经被更加通用的 bind 函数给取代了。
  • C++
  • 对象移动重载 new 和 delete
    目录