[C++]C++Primer Chapter 14

重载运算与类型转换

14.1 基本概念

重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。

除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参(参见6.5.1节,第211页)。

如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上(参见7.1.2节,第231页)。

直接调用一个重载的运算符函数

某些运算符不应该被重载

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

某些运算符指定了运算对象求值的顺序。因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。特别是,

  • 逻辑与运算符
  • 逻辑或运算符(参见4.3节,第126页)
  • 逗号运算符(参见4.10节,第140页)

的运算对象求值顺序规则无法保留下来。

除此之外,&&和||运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。

还有一个原因使得我们一般不重载逗号运算符和取地址运算符:C++语言已经定义了这两种运算符用于类类型对象时的特殊含义,这一点与大多数运算符都不相同。

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

  • 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致。
  • 如果类的某个操作是检查相等性,则定义operator==;如果类有了operator==,意味着它通常也应该有operator!=。
  • 如果类包含一个内在的单序比较操作,则定义operator<;如果类有了operator<,则它也应该含有其他关系操作。
  • 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:逻辑运算符和关系运算符应该返回bool,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。

赋值和复合赋值运算符

赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧运算对象的一个引用。

选择作为成员或者非成员

  • 赋值(=)、下标([ ])、调用(( ))和成员访问箭头(->)运算符必须是成员。
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。

14.2 输入和输出运算符

14.2.1 重载输出运算符<<

通常情况下:

  • 第一个形参:非常量 ostream 的引用
  • 第二个形参:常量类类型的引用
  • 返回值:返回第一个 ostream 的形参的引用

输出运算符尽量减少格式化操作

输入输出运算符必须是非成员函数

IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元(参见7.2.1节,第241页)。

14.2.2 重载输入运算符>>

通常情况下:

  • 第一个形参:将要读取的流的引用
  • 第二个形参:将要读入到的对象的引用
  • 返回值:返回第一个形参的引用

输入运算符必须处理输入可能失败的情况,而输出运算符不需要。

输入时的错误

  • 当流含有错误类型的数据时读取操作可能失败。例如在读取完bookNo后,输入运算符假定接下来读入的是两个数字数据,一旦输入的不是数字数据,则读取操作及后续对流的其他使用都将失败。
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。

标示错误

一些输入运算符需要做更多数据验证的工作。例如,我们的输入运算符可能需要检查bookNo是否符合规范的格式。在这样的例子中,即使从技术上来看IO是成功的,输入运算符也应该设置流的条件状态以标示出失败信息(参见8.1.2节,第279页)。通常情况下,输入运算符只设置failbit。除此之外,设置eofbit表示文件耗尽,而设置badbit表示流被破坏。最好的方式是由IO标准库自己来标示这些错误。

14.3 算术和关系运算符

通常情况下:

  • 算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换(参见14.1节,第492页)。
  • 因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。

如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符

14.3.1 相等运算符

  • 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成operator==而非一个普通的命名函数:因为用户肯定希望能使用==比较对象,所以提供了==就意味着用户无须再费时费力地学习并记忆一个全新的函数名字。此外,类定义了==运算符之后也更容易使用标准库容器和算法。
  • 如果类定义了operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
  • 通常情况下,相等运算符应该具有传递性,换句话说,如果a==b和b==c都为真,则a==c也应该为真。·
  • 如果类定义了operator==,则这个类也应该定义operator!=。对于用户来说,当他们能使用==时肯定也希望能使用!=,反之亦然。
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。

14.3.2 关系运算符

定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用。

通常情况下关系运算符应该

  • 定义顺序关系,令其与关联容器中对关键字的要求一致(参见11.2.2节,第378页)
  • 如果类同时也含有==运算符的话,则定义一种关系令其与==保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个。

尽管我们可能会认为Sales_data类应该支持关系运算符,但事实证明并非如此,其中的缘由比较微妙,值得读者深思。

一开始我们可能会认为应该像compareIsbn(参见11.2.2节,第379页)那样定义<,该函数通过比较ISBN来实现对两个对象的比较。然而,尽管compareIsbn提供的顺序关系符合要求1,但是函数得到的结果显然与我们定义的==不一致,因此它不满足要求2。

对于Sales_data的==运算符来说,如果两笔交易的revenue和units_sold成员不同,那么即使它们的ISBN相同也无济于事,它们仍然是不相等的。如果我们定义的<运算符仅仅比较ISBN成员,那么将发生这样的情况:两个ISBN相同但revenue和units_sold不同的对象经比较是不相等的,但是其中的任何一个都不比另一个小。然而实际情况是,如果我们有两个对象并且哪个都不比另一个小,则从道理上来讲这两个对象应该是相等的。

基于上述分析我们也许会认为,只要让operator<依次比较每个数据元素就能解决问题了,比方说让operator<先比较isbn,相等的话继续比较units_sold,还相等再继续比较revenue。然而,这样的排序没有任何必要。根据将来使用Sales_data类的实际需要,我们可能会希望先比较units_sold,也可能希望先比较revenue。有的时候,我们希望units_sold少的对象“小于”units_sold多的对象;另一些时候,则可能希望revenue少的对象“小于”revenue多的对象。因此对于Sales_data类来说,不存在一种逻辑可靠的<定义,这个类不定义<运算符也许更好。

如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。

14.4 赋值运算符

我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。

之前已经介绍过拷贝赋值和移动赋值运算符(参见13.1.2节,第443页和13.6.2节,第474页),它们可以把类的一个对象赋值给该类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。举个例子,在拷贝赋值和移动赋值运算符之外,标准库vector类还定义了第三种赋值运算符,该运算符接受花括号内的元素列表作为参数(参见9.2.5节,第302页)。

复合赋值运算符

复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。为了与内置类型的复合赋值保持一致,类中的复合赋值运算符也要返回其左侧运算对象的引用。

14.5 下标运算符

下标运算符必须是成员函数。

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]。

为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。进一步,我们最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。

14.6 递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。

C++语言并不要求递增和递减运算符必须是类的成员,但是因为它们改变的正好是所操作对象的状态,所以建议将其设定为成员函数。

定义前置递增/递减运算符

前置运算符应该返回递增或递减后对象的引用。

区分前置和后置运算符

后置版本接受一个额外的(不被使用)int类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。尽管从语法上来说后置函数可以使用这个额外的形参,但是在实际过程中通常不会这么做。这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。

后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。

后置运算符调用各自的前置版本来完成实际的工作。

14.7 成员访问运算符

箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。

箭头运算符不执行任何自己的操作,而是调用解引用运算符并返回解引用结果元素的地址。

对箭头运算符返回值的限定

重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。

14.8 函数调用运算符

函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。

如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。

如果类定义了调用运算符,则该类的对象称作函数对象(function object)。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。

含有状态的函数对象类

函数对象常常作为泛型算法的实参。

14.8.1 lambda是函数对象

当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象(参见10.3.3节,第349页)。在lambda表达式产生的类中含有一个重载的函数调用运算符。

默认情况下lambda不能改变它捕获的变量。因此在默认情况下,由lambda产生的类当中的函数调用运算符是一个const成员函数。如果lambda被声明为可变的,则调用运算符就不是const的了。

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

当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在(参见10.3.3节,第350页)。因此,编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。

相反,通过值捕获的变量被拷贝到lambda中(参见10.3.3节,第350页)。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。

14.8.2 标准库定义的函数对象

标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。

这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。

在算法中使用标准库函数对象

表示运算符的函数对象类常用来替换算法中的默认运算符。

如我们所知,在默认情况下排序算法使用operator<将序列按照升序排列。如果要执行降序排列的话,我们可以传入一个greater类型的对象。该类将产生一个调用运算符并负责执行待排序类型的大于运算。

标准库规定其函数对象对于指针同样适用。我们之前曾经介绍过比较两个无关指针将产生未定义的行为(参见3.5.3节,第107页),然而我们可能会希望通过比较指针的内存地址来sort指针的vector。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的

14.8.3 可调用对象与function

C++语言中有几种可调用的对象:

  • 函数
  • 函数指针
  • lambda表达式(参见10.3.2节,第346页)
  • bind创建的对象(参见10.3.4节,第354页)
  • 重载了函数调用运算符的类。

可调用的对象也有类型。例如,每个lambda有它自己唯一的(未命名)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定,等等。

然而,两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如

是一个函数类型,它接受两个int、返回一个int。

不同类型可能具有相同的调用形式

对于几个可调用对象共享同一种调用形式的情况,有时我们会希望把它们看成具有相同的类型。

上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式。

我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,需要定义一个函数表(function table)用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。

在C++语言中,函数表很容易通过map来实现。

对于此例来说,我们使用一个表示运算符符号的string对象作为关键字;使用实现运算符的函数作为值。当我们需要求给定运算符的值时,先通过运算符索引map,然后调用找到的那个元素。

我们可以按照下面的形式将add的指针添加到binops中

但是我们不能将mod或者divide存入binops:

标准库function类型

可以使用一个名为function的新的标准库类型解决上述问题,function定义在functional头文件中,表14.3列举出了function定义的操作。

function是一个模板,当创建一个具体的function类型时我们必须提供额外的信息,即该function类型能够表示的对象的调用形式。

重载的函数与function

我们不能(直接)将重载函数的名字存入function类型的对象中

解决上述二义性问题的一条途径是存储函数指针(参见6.7节,第221页)而非函数的名字

也能使用lambda来消除二义性

14.9 重载、类型转换与运算符

我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点。转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。

14.9.1 类型转换运算符

类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。

其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型(参见6.1节,第184页)。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。

类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const成员。

定义含有类型转换运算符的类

SmallInt类既定义了向类类型的转换,也定义了从类类型向其他类型的转换。其中,构造函数将算术类型的值转换成SmallInt对象,而类型转换运算符将SmallInt对象转换成int

提示:避免过度使用类型转换函数

和使用重载运算符的经验一样,明智地使用类型转换运算符也能极大地简化类设计者的工作,同时使得使用类更加容易。然而,如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性。

例如,假设某个类表示Date,我们也许会为它添加一个从Date到int的转换。然而,类型转换函数的返回值应该是什么?一种可能的解释是,函数返回一个十进制数,依次表示年、月、日,例如,July 30,1989可能转换为int值19890730。同时还存在另外一种合理的解释,即类型转换运算符返回的int表示的是从某个时间节点(比如January 1,1970)开始经过的天数。显然这两种理解都合情合理,毕竟从形式上看它们产生的效果都是越靠后的日期对应的整数值越大,而且两种转换都有实际的用处。

问题在于Date类型的对象和int类型的值之间不存在明确的一对一映射关系。因此在此例中,不定义该类型转换运算符也许会更好。作为替代的手段,类可以定义一个或多个普通的成员函数以从各种不同形式中提取所需的信息。

类型转换运算符可能产生意外结果

在实践中,类很少提供类型转换运算符。然而这条经验法则存在一种例外情况:对于类来说,定义向bool的类型转换还是比较普遍的现象。

在C++标准的早期版本中,如果类想定义一个向bool的类型转换,则它常常遇到一个问题:因为bool是一种算术类型,所以类类型的对象转换成bool后就能被用在任何需要算术类型的上下文中。这样的类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,下面的代码仍将编译通过

该代码能使用istream的bool类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来,提升后的bool值(1或0)最终会被左移42个位置。这一结果显然与我们的预期大相径庭。

显式的类型转换运算符

为了防止这样的异常情况发生,C++11新标准引入了显式的类型转换运算符(explicit conversion operator)

和显式的构造函数(参见7.5.4节,第265页)一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换

该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:

  • if、while及do语句的条件部分
  • for语句头的条件表达式
  • 逻辑非运算符(!)、逻辑或运算符(||)、逻辑与运算符(&&)的运算对象
  • 条件运算符(? :)的条件表达式。

转换为bool

向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。

14.9.2 避免有二义性的类型转换

如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性。

在两种情况下可能产生多重转换路径

  1. 两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。
  2. 类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。

二义性与转换目标为内置类型的多重类型转换

类当中定义了多个参数都是算术类型的构造函数,或者转换目标都是算术类型的类型转换运算符。

提示:类型转换与运算符

  • 不要令两个类执行相同的类型转换:如果Foo类有一个接受Bar类对象的构造函数,则不要在Bar类中再定义转换目标是Foo类的类型转换运算符。
  • 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
    • 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
    • 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。

一言以蔽之:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。

重载函数与转换构造函数

当我们调用重载的函数时,从多个类型转换中进行选择将变得更加复杂。

如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。

重载函数与用户定义的类型转换

当调用重载函数时,如果两个(或多个)用户定义的类型转换都提供了可行匹配,则我们认为这些类型转换一样好。在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换。

14.9.3 函数匹配与重载运算符

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。


[C++]C++Primer Chapter 14
https://erlsrnby04.github.io/2024/09/22/C-C-Primer-Chapter-14/
作者
ErlsrnBy04
发布于
2024年9月22日
许可协议