重载运算与类型转换
基本概念
它们由关键字operato和其后要定义的运算符号组成,其他和函数相同。当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
我们只能重载一部分的已有运算符。对于重载的运算符,优先级和结合律与对应内置运算符(原先的)保持一致(不考虑运算对象)。
调用重载运算符
1 | //一个非成员运算符函数的等价调用 |
也可以像调用成员函数一样调用它们
1 | datal +=data2; //基于“调用”的表达式 |
某些运算符不应被重载
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
使用与内置类型一致的含义
如果类的操作与运算符相关,才适合重载它们:
- 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致。
- 如果类的某个操作是检查相等性,则定义 operator==;如果类有了operator==,意味着它通常也应该有operator!=。
- 如果类包含一个内在的单序比较操作,则定义 operator<;如果类有了operator<,则它也应该含有其他关系操作。
- 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:逻辑运算符和关系运算符应该返回bool,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。
尽量明智的重载它们,使操作符保持原有的操作逻辑
赋值和复合赋值运算符
例如类中有+和=运算符那么最好也重载+=。
选择成员或非成员
下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:·
- 赋值(=)、下标([ ])、调用(( ))和成员访问箭头(->)运算符必须是成员。
- 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
如果希望在含有混合类型的表达式中使用对称性运算符,例如求int和doubie的和,因为它们都可以是第一个运算对象。所以必须定义为非成员函数。
1 | string s = "world"; |
输入和输出
重载输出运算符<<
输出运算符的第一个形参是非常量的ostream对象的引用。非常量是因为向流写入内容会改变其状态;使用引用是我们无法赋值一个ostream对象。
Sales_data的输出运算符
1 | ostream &operator<<(ostream &os,const Sales_data &item) |
与之前类内的print函数一样,打印一个Sales_data类意味着打印三个数据成员。
输出运算符尽量减少格式化
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。
输入输出运算符必须是非成员函数
与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,它们的左侧运算对象将是我们的类的一个对象:
1 | sales data data; |
重载输入运算符>>
通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。
Sale_data的输入运算符
1 | istream &operator>>(istream &is, Sales_data &item) |
if的判断检查读取操作是否成功,这样如果发生了错误,则运算符将给定的对象重置为空。
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
输入时的错误
执行输入时有可能发生错误:
- 当流含有错误类型的数据时读取操作可能失败。例如在读取完bookNo后,输入运算符假定接下来读入的是两个数字数据,一旦输入的不是数字数据,则读取操作及后续对流的其他使用都将失败。
- 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
前面的函数中,没有诸葛检查读取操作,而是读取了所有数据后赶在使用这些数据前一次性检查。失败后的price的值是未定义的。所以只需要在错误时默认初始化这个对象
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
标示错误(略)
算术和关系运算符
通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符:
1 | //假设两个对象指向同一本书 |
相等运算符
例子:
1 | bool operator==(const Sales_data &lhs,const sales_data &rhs) |
从中体现的设计准则:
- 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成operator==而非一个普通的命名函数:因为用户肯定希望能使用==比较对象,所以提供了==就意味着用户无须再费时费力地学习并记忆一个全新的函数名字。此外,类定义了==运算符之后也更容易使用标准库容器和算法。
- 如果类定义了operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
- 通常情况下,相等运算符应该具有传递性,换句话说,如果a==b和 b==c都为真,则a==c也应该为真。
- 如果类定义了operator==,则这个类也应该定义operator!=。对于用户来说,当他们能使用==时肯定也希望能使用!=,反之亦然。
- 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。
关系运算符
如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。
赋值运算符
除了拷贝赋值和移动赋值外,还可以定义其他赋值运算吧别的对象作为右侧对象。例如vector接受花括号元素列表:
1 | vector<string> v; |
和其他赋值函数一样需要先释放当前内存空间,创建一片新的空间,不同之处是,无需检查对象向自身的赋值。
我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。
复合赋值运算符
复合运算符不一定是类成员,不过倾向于把包括复合在内的所有运至运算都定义在类的内部,与内置类型保持一致。复合赋值也要返回左侧对象的引用:
1 | //作为成员的二元运算符:左侧运算对象绑定到隐式的this指针//假定两个对象表示的是同一本书 |
下标运算符
它必须是成员函数。它通常以访问元素的引用作为返回值,这样可以出现在赋值运算的任意一端。此外,如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。
1 | class strVec { |
上面这两个下标运算符用法类似vector或者数组中的下标,非常量可以赋值,而常量不可以赋值:
1 | //假设svec是一个StrVec对象 |
递增和递减运算符
因为这两个运算符改变的是操作对象的状态,所以建议将其设定为成员函数。
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
定义前置递增/递减
1 | class StrBlobPtr { |
一定要注意检查递增递减后的值是否还有意义,且最好返回引用。
区分前置和后置
因为普通的重载无法区分前置和后置版本,所以后置版本接受一个额外的int类型的形参,使用时编译器提供值为0的形参。
1 | class strBlobPtr{ |
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
1 | //后置版本:递增/递减对象的值但是返回原值 |
后置运算符依然调用前置运算符完成,此外由于不需要用到int形参,所以不需要命名。
显示调用后置运算符
可以通过函数调用方式调用后置版本,但必须传递一个值:
1 | StrBlobPtr p(al); //p指向a1中的vector |
成员访问运算符
1 | class strBlobPtr { |
这两个运算符用法与指针或者vector迭代器操作完全一致,需要检查curr是否在范围内,是的话返回curr所指元素的引用。
箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
对箭头返回值的限定
箭头运算符永远不能丢掉成员访问这个最基本的含义。对于对于形如point->mem的表达式来说,point必须是指向类对象的指针或者是一个重载了operator->的类的对象。根据point类型的不同,point->mem分别等价于
1 | (*point).mem; // point是一个内置的指针类型 |
此外,代码都会发生错误,它的执行过程如下:
- 如果point是指针,则我们应用内置的箭头运算符,表达式等价于(*point) .mem.首先解引用该指针,然后从所得的对象中获取指定的成员。如果point所指的类型没有名为mem的成员,程序会发生错误。
- 如果point是定义了operator->的类的一个对象,则我们使用point.operator->()的结果来获取mem。其中,如果该结果是一个指针,则执行第1步;如果该结果本身含有重载的 operator-> (),则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
函数调用运算符
它使我们可以像调用函数一个调用对象,例如:
1 | struct absInt { |
调用的过程非常像函数的调用:
1 | int i = -42; |
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
含有状态的函数对象类
1 | class PrintString { |
这个类有默认构造函数接受一个输出流引用和用于分隔的字符,之后调用运算符使用这些成员来协助打印给定的string。
当定义PrintString的对象时,对于分隔符及输出流既可以使用默认值也可以提供我们自己的值:
1 | Printstring printer; //使用默认值,打印到cout |
函数对象常常作为泛型算法的实参。例如,可以使用标准库for_each 算法(参见10.3.2节,第348页)和我们自己的 PrintString类来打印容器的内容:
1 | for each(vs.begin (), vs.end(), PrintString (cerr, '\n') ); |
lambda是函数对象
编写lambda后,编译器就将它翻译成一个未命名的对象。
1 | //根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序 |
表示lambda及相应的捕获行为的类
当lambda表达式通过引用捕获变量时,程序确保lambda执行时引用所引用对象存在。这个lambda产生的类未每个值建立对应的数据成员,同时创建构造函数,捕获的值用于初始化变量。
1 | //获得第一个指向满足条件元素的迭代器,该元素满足size() is >= Sz |
标准库定义的函数对象
C++语言中有几种可调用的对象:函数、函数指针、lambda表达式(参见10.3.2节,第346页)、bind创建的对象(参见10.3.4节,第354页)以及重载了函数调用运算符的类
不同类型可以有相同的调用形式
对于几个可调用对象共享同一种调用形式的情况,有时我们会希望把它们看成具有相同的类型。例如,考虑下列不同类型的可调用对象:
1 | //普通函数 |
虽然各不相同但共享一种调用形式:int(int,int)
我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,需要定义一个函数表(function table)用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。
假定我们的所有函数都相互独立,并且只处理关于 int的二元运算,则map可以定义成如下的形式:
1 | //构建从运算符到函数指针的映射关系,其中函数接受两个int、返回一个int |
但是我们不能将mod或者divide存入 binops,因为他们时类类型,所以类型并不匹配。
标准库function类型
我们可以使用一个名为function的新的标准库类型解决上述问题,function定义在functional头文件中。
声明:
1 | function<int (int, int)> |
使用:
1 | function<int (int, int) > f1 = add; //函数指针 |
使用function重新定义map
1 | //列举了可调用对象与二元运算符对应关系的表格 |
把可调用对象都添加到这个map中:
1 | map<string,function<int (int,int)>> binops = { |
最后在这个map中使用索引调用:
1 | binops [ "+"](10,5); //调用add (10,5) |
重载函数与function
我们不可以直接向重载的函数名存入function类型的对象
1 | int add (int i, int j){ return i +j;} |
解决方法是储存一个函数的指针,而不是函数名字
1 | int (*fp)(int,int) = add; //指针所指的add是接受两个int的版本 |
重载、类型转换与运算符
转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。
类型转换运算符
它是类的特殊成员函数,负责将一个类类型转换成其他类型,型式为:operator type() const;
其中 type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型(参见6.1节,第184页)。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。
定义含类型转换的类
令表示0到255之间的整数:
1 | class SmallInt { |
这个类即定义了类类型向其他类型的转换,也有其他类型向类类型的转换(通过内置转为int再转为类类型)。
1 | //内置类型转换将double实参转换成int |
编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。因此,我们可以将任何算术类型传递给SmallInt的构造函数。类似的,我们也能使用类型转换运算符将一个SmallInt对象转换成int,然后再将所得的int转换成任何其他算术类型。
因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:
1 | class SmallInt; |
避免过度使用类行转换函数
类型转换可能产生意外的结果
例如一个像bool类型的转换
1 | int i = 42; |
这段程序试图将输出运算符作用于输入流。因为istream本身并没有定义<<,所以本来代码应该产生错误。然而,该代码能使用istream的 bool类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来,提升后的bool值(1或0)最终会被左移42个位置。这一结果显然与我们的预期大相径庭。
显示的类型转换运算符
为防止上述情况,C++11引入此运算符:
1 | class SmallInt { |
和显示构造函数一样,编译器不会将显示的类型转换用于隐式类型转换,使用时就必须显示的进行强制类型转换:
1 | SmallInt si = 3; //正确:SmallInt的构造函数不是显式的 |
该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
- if、 while及do语句的条件部分
- for语句头的条件表达式
- 逻辑非运算符(!)、逻辑或运算符()、逻辑与运算符(&&)的运算对象
- 条件运算符(?:)的条件表达式。
转换为bool
无论我们什么时候在条件中使用流对象,都会使用为IO类型定义的operatorbool。例如:while (std: :cin >> value)
while语句的条件执行输入运算符,它负责将数据读入到value并返回cin。为了对条件求值,cin被istream operator bool类型转换函数隐式地执行了转换。如果cin的条件状态是good(参见8.1.2节,第280页),则该函数返回为真;否则该函数返回为假。
向bool的类型转换通常用在条件部分,因此 operator bool 一般定义成explicit的。
避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性。
在两种情况下可能产生多重转换路径。
- 第一种情况是两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。
- 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
实参匹配和相同的类型转换
1 | //最好不要在两个类之间构建相同的类型转换 |
代码中同时存在两种B获得A的方法,造成编译器无法判断,此时必须显示调用:
1 | A a1 = f(b.operator A() ) ; //正确:使用B的类型转换运算符 |
但最好的办法就是避免此情况
二义性与转换目标的为内置类型的多重转换
1 | struct A{ |
在对f2的调用中,哪个类型转换都无法精确匹配long double。然而这两个类型转换都可以使用,只要后面再执行一次生成long double的标准类型转换即可。因此,在上面的两个类型转换中哪个都不比另一个更好,调用将产生二义性。
正确操作
要想正确地设计类的重载运算符、转换构造函数及类型转换函数,必须加倍小心。尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性。以下的经验规则可能对你有所帮助:
- 不要令两个类执行相同的类型转换:如果Foo类有一个接受Bar类对象的构造函数,则不要在Bar类中再定义转换目标是Foo类的类型转换运算符。
- 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
- 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
- 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。
一言以蔽之:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。
重载函数与转换构造函数
举个例子,当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:
1 | struct C { |
如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。
重载函数与用户定义的类型转换*
函数匹配与重载运算符
调用对象函数与普通函数不同,如果a时一种类型,则a sym b可能是
1 | a. operatorsym(b); //a有一个operatorsym成员函数 |
但重载的运算符并不能通过调用形式区分成员与非成员函数
当我们使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。
例如:
1 | class SmallInt { |
第二条加法语句具有二义性:因为我们可以把0转换成smallInt,然后使用smallInt 的+,或者把s3转换成int,然后对于两个int执行内置的加法运算。
如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。