一、运算符的定义
运算符重载就是运算符的“一符多用”。重载运算符是具有特殊名称的函数:保留字 operator 后接需定义的操作符符号。像任意其他函数一样,重载操作符具有返回类型和形参表,每个操作符用于内置类型都有关联的定义,当内置操作符和类型上的操作存在逻辑对应关系时,操作符重载最有用,最直观,使用重载操作符并不是创造命名操作。
二、在哪种情况下使用哪种重载运算符的方式合适?
- 只有一个操作数的运算符(一目)使用类运算符重载方式为好;
- 一般地说,如果运算符要修改操作数(类对象)的状态(值),则应使用类运算符(成员形式)。(在计算中可能改变操作数的值得运算符被称为有副作用的运算符,诸如:=、+=、-=、*=、/=、%=、++、– 等);
- C++规定,运算符=、()、[ ]、-> 只能采用类运算符形式重载;
- 有两个操作数的运算符(二目)使用友元运算符重载方式为好;
- 友元运算符重载方式在操作数的数据类型自动转换方面更为宽容,尤其是第一个操作数希望能够隐式类型转换时,则应采用友元形式;
- 不允许重载的运算符有:&&、||、. 、:: 、 * 、?: 。
三、运算符重载具体讨论(返回值和参数,这里讨论几个常用的运算符)
默认地,重载运算符必须与内置操作符保持一致,也就是说重载后的运算符必须与本来内置操作符保持特性一致。函数最主要的两个就是返回值和形参。
3.1、前缀++类运算符重载函数(前缀–类似)
自增(自减)操作符的前置式定义:累加(递减)而后取出;后置式定义:取出而后累加(递减)。
我们知道,在C语言里整型变量是允许连续前缀++两次的,也叫链式操作。这样为了保证重载运算符与内置操作符++类型一致,就要求前缀++类运算符重载函数也支持连续操作(链式操作),所以前缀++类运算符重载函数的返回值必须是类名的引用。上面第二点也说了,++作为单目运算符,并且会修改操作数的值,则应定义为类运算符,这样重载函数无形参。我们就可申明该前缀++类运算符重载函数如下:
1 | class Zoo |
下面就是前缀++类运算符重载函数的实现了
内置类型前缀++操作符是直接修改了操作数,然后返回修改后的操作数本身(唯一地址),不存在复制的情况,所以重载函数也应遵循这一点:
1 | Zoo& Zoo::operator++() |
由于函数的返回值类型被定义为引用,所以不会发生复制,返回的是操作数本身,完全符合内置前缀++的语法定义。
我们再来考虑错误情况:如果前缀++类运算符重载函数的返回值是类型,也就是返回一个对象,其对应实现如下:
1 | Zoo Zoo::operator++() |
咋一看上面的也实现了前缀++的功能,但是返回值是对象,在函数返回时会发生复制,虽然该函数成功将操作类对象的成员修改了,但是函数返回的是一个复制品,然后再执行++链式操作时,修改的会是这个复制品的值(相当于这个复制品调用前缀++类运算符重载函数),本尊并没有修改,也就是不能成功实现链式操作,不符合内置++的语法定义( C++中,前缀++是可以连续前缀两次以上的,但后缀++则不可以)。
1 | Zoo zoo; |
上面执行后,zoo.lion = 1,zoo.tiger = 1 。并不是期望的2。
至于返回其余类型那就更加错误了。
3.2、后缀++类运算符重载函数(后缀–类似)
与前缀++操作符一样,后缀++也是单目操作符,也会修改操作书本身,所以二者的形参数目和类型相同,为了区别函数,后缀++操作符接受一个额外的(即,无用的)int 型形参。使用后缀++操作符时,编译器提供0作为这个形参的实参。
与前缀++类运算符截然相反的是,后缀++返回值的类型恰恰不能是类的引用,其目的是在返回值时引起复制,即让一个并未自增的替身对象去参加表达式的后续运算,另外C/C++在语法上不允许后缀++连续运算两次以上,也就不要求返回引用,并且必须返回 const 对象。我们看看内置后缀++操作符:
1 | int i = 0; |
内置后缀++操作符,操作数 i 本身已经完成了自增,但是后续的赋值操作并不是将自增后的 i 赋值给j,而是将并未自增的替身参与赋值运算。所以在重载后缀++类运算符的时候,我们应该考虑这点,另外必须返回一个 const 对象:
1 | const Zoo Zoo::operator++(int) |
在已经定义了前缀++类运算符重载函数的情况下,后缀++类运算符重载函数一般这样实现:
1 | const Zoo Zoo::operator++(int) |
3.3、二目运算符重载(+=,-=,+,-)
先说复合赋值操作符,上面“+=”,“-=”也可认为是赋值操作符。内置+=、-=、%= 是允许进行链式操作的(如果不确定是否允许,可以写一个测试程序判断),所以为了与内置类型的操作一致,重载函数毫无疑问是返回一个引用,也避免了创建和撤销结果的临时副本。
但是“+”“-” 等是返回一个新的结果,这就要求算术运算符的重载不能返回一个引用,另外+的表达式也不能作为左值。
1 | int i = 1; |
有了前面分析,不难写出上面的重载函数
1 | Zoo& Zoo::operator+=(Zoo &rhs) |
继而过来讨论“+”“-”:
返回值是一个右值
前面说到了,“+”“-”是返回一个新的结果,算术运算符通常产生一个新值,该值是两个操作数的计算结果,它不同于任一操作数且在一个局部变量中计算,返回对那个变量的引用是一个运行时错误。通俗一点,假如算术运算符重载函数返回一个对象的引用,这个引用是两个操作的计算结果,它的本体就会是一个局部变量(对象),返回一个局部变量的引用,是错误的。所以对于类算术运算符的重载,只能返回一个右值。
1 | Zoo operator+(Zoo &first, Zoo &second) |
二目算术运算符重载通常使用友元运算符重载方式。
从上面也可以看出,类运算符的重载最好与内置运算符保持一致,虽然没硬性规定,但这俨然成了一个默认规定。
另外 !(逻辑反)、~(按位与)、-(负号)等与二目算术运算符有类似之处,那就他们都不会修改原对象数据成员,而是将运算结果交给一个新值,所以在重载时,需要构造一个临时对象作为返回值,返回值也就同样不能是引用。
3.4、输入输出操作符重载
支持I/O操作的类所提供的I/O操作接口,一般应该与标准库iostream为内置类型定义的接口相同。
1、输出操作符 << 的重载
为了与I/O标准库一致,操作符应接受 ostream& 作为第一个形参,对类类型 const 对象的引用作为第二个形参,并返回对 ostream 形参的引用。
重载输出操作符可能相对于比较难理解,这里简单的说下,我们只能以自定义类的友元函数的形式重载这两个运算符,这是因为如果我们用成员函数的形式来重载的话,就要改动系统的流类 istream 和 ostream 定义,这是C++不允许的,如果不定义为友元函数的话,将无法调用类对象成员数据输出。
1 | ostream& operator<<(ostream& stream, const Zoo &object) |
我们来看看上面这个输出操作符重载函数,第一个参数是 ostream 类的引用,而函数的返回值也是 ostream 类的引用。毫无疑问,我们调用这个运算符重载函数时。实参肯定是 cout,这样就造成了这样一种情况:实参 stream 引用 cout,而函数的返回值又引用 stream,等于函数返回值引用的实体还是 cout。这样做的目的是实现了连续的输出操作。当执行下面语句:
1 | cout << zoo_a << zoo_b; |
我们不能将该操作符重载函数定义为类的成员函数,否则,左操作数将只能是该类类型的对象。IO操作符通常要对非公用数据成员进行读写,因此,类通常将IO操作符(输入输出)设为友元。
2、输入操作符 >> 的重载
为了与IO标准库一致,操作符应接受 istream& 作为第一个形参,指向它要读的流,并且返回的也是对同一个流的引用(链式操作)。它的第二个形参是对要读入的对象的非 const 引用,该形参必须为非 const,因为输入操作符的目的是将数据读到这个对象中。
更重要但通常重视不够的是,输入和输出操作符有如下区别:输入操作符必须处理错误和文件结束的可能性。
输入期间的错误:任何读操作都可能因为提供的值不正确而失败;任何读入都可能碰到输入流中的文件结束或其他一些错误。也就需要对输入进行附加检查,发现有这些错误就需要我们进行处理。
3.5、不能重载的运算符 &&、|| 和 , 操作符
和 C 一样,C++ 对于真假值表达式采用所谓骤死式评估方式。意思是一旦该表达式的真假值去顶,纵使表达式中还有部分尚未检验,整个评估工作仍告结束。比如下面这种情况:
1 | char *p; |
你无需担心调用 strlen 时 p 是否为 NULL 指针,因为如果 p 是否为NULL 的测试结果是否定的,strlen 就绝不会被调用。事实上,对一个 NULL 指针调用 strlen,结果未可预期。
回到重载,C++ 允许我们为用户定制型别量身定做各类操作符,包括 && 和 ||,操作符重载语义上是允许的,但是我们要考虑重载会不会改变对应内置操作符的规则。拿 && 和 || 来说,重载则是对 operator && 和 operator || 两函数进行重载工作,值得注意的是,函数调用语义将会取代骤死式语义,也就是说,如果你将operator && 重载,下面这个虱子:
1 | if (expression1 && expression2) …… |
会被编译器视为以下两者之一:
1 | if (expression1.operator&&(expression2)) …… |
上面函数调用语义和所谓骤死式语义有两个重大的不同。第一,当函数调用动作被执行起来,所有参数值都必须评估完成,所以当我们调用 operator&& 和 operator|| 时,两个参数都已评估完成,没有什么骤死式语义。第二,C++ 语言规格并未明定函数调用动作中各参数的评估次序,所以没办法知道 expression1 和 expression2 哪个会先被评估,而内置的真假值表达式,则总是由左向右评估其自变量。
C++ 中,运算符重载的一个重要参考就是:不能修改运算符的内置语义。