[toc]
Chapter XIV
C++ operator 重载运算符的作用:
主要是代替成员函数的方式为自建类型完成基本任务
当然, 用成员函数完全可以代替operator的功能, 但是使用起来绝对没有operator方便
operator 重载运算符方式:
既然是用于自建类型的运算, 则其可以有两种定义方式:
-
作为自建类型的成员函数, 定义在类的内部
此时operator的参数数目比具体重载的运算符操作数数目少一, 因为此时使用的一个隐含参数为* this,
并将其作为左操作数(第一个操作数)
如果需要将* this作为右操作数, 只能将operator作为友元函数 -
作为自建类型的友元函数, 定义在类的内部或外部
此时operator的参数数目与具体重载的运算符操作数数目相同
对于两种方式的选择, 需根据具体的需求而定
, 但有以下几点准则可以提供参考:
- 有些运算符必须作为类成员被重载(下头具体说明)
- 复合赋值运算符(+=, -=, *=, /=, %=等), 通常来说应该作为类成员, 但C++标准没有强制
- 改变对象状态的运算符, 与给定类型密切相关的运算符(如++递增,–递减, *解引用), 通常作为类成员
- 具有对称性的运算符(即左右操作数互换后不影响重载运算符内部操作的, 如算数运算符, 关系运算符等)通常作为普通函数或友元函数
operator运算符重载限制:
说白了, operator能重载的只有运算符对操作数的操作, 而其他东西基本不能改变:
-
operator重载相应的运算符时仍然需要遵守其原定的语法
, 不能将双目运算符重载为单目运算符(实际上这也是编译器判定参数的一个标准), 也不能修改运算符的优先级 -
operator重载运算符时不能覆盖原有的运算
, 即操作数中必须至少有一个是自建类型, 这虽然限制了一点操作性, 但保护了程序的正常执行 -
不能创建新的运算符
, 只能重载原有的运算符, 如operator ** //非法 -
不能重载特定的运算符
:
其实不能重载的还有挺多的, 但是能够重载的都在这了
-
必须作为类成员的运算符重载:
C++规定,= 赋值运算符, []下标运算符, ()函数调用运算符, ->成员访问运算符
只能是类的非静态的成员函数,不能是静态成员函数,也不能是友元函数
就记着着四个奇葩不能静态不能友元
因为:
对于static静态成员函数, 由于没有this指针, 只能访问类的静态成员, 这导致无法对类对象进行操作
对于友元函数: 编译器在类中寻找是否存在用户自建的operator= 时, 判定条件为是否有显式提供一个以本类或本类的引用为参数的赋值运算符重载函数, 而友元函数不属于这个类, 所以此时编译器相当于没找到, 所以会合成默认的operator=
这样, 在调用的时候会造成冲突, 所以C++限制了operator= 的重载 -
重载运算符无法保留一些运算符原有的一些特性
:- && || , 这三个运算符对算子的操作顺序无法保留
- && || 这两个运算符的重载版本无法保留原有的短路求值的属性
所以不推荐对这些运算符进行重载, 否则可能重载后的使用规则发生变化会导致一些使用上的Bug
不该重载的运算符
:
包括上头的&& || , 还有 & 取地址运算符, 因为在C++中已经定义了其对类对象的操作, 重载该运算符会导致丧失一部分功能, 为类的使用者带来麻烦
基础应用:
重载运算符最需要考虑的即为参数与返回值问题
(这里以operator = 为例):
参数:
一般地,赋值运算符重载函数的参数是函数所在类的const类型的引用, 如
MyStr& operator =(const MyStr& str);
加const是因为:
- 我们不希望在这个函数中对用来进行赋值的“原版”做任何修改。
- 加上const,对于const的和非const的实参,函数就能接受;如果不加,就只能接受非const的实参。
用引用是因为:
- 这样可以避免在函数调用时对实参的一次拷贝,提高了效率
注意:
上面的规定都只是推荐,可以不加const,也可以没有引用,甚至参数可以不是函数所在的对象,正如后面例2中的那样
返回值
一般地,返回值是被赋值者的引用(但有时返回左值还是右值需要相当的考虑),即*this
MyStr& operator =(const MyStr& str);
-
这样在函数返回时避免一次拷贝,提高了效率。
-
更重要的,根据赋值运算符的从左向右的结合律, 可以实现连续赋值,即类似a=b=c
如果返回的是值,则执行连续赋值运算后后头得到的将是一个匿名副本, 为不可更改的右值, 再执行=c就会出错。注意:
这也不是强制的,完全可以将函数返回值声明为void,然后什么也不返回,只不过这样就无法连续赋值
所以具体的返回值与参数的设定完全取决于需求, 是非常值得设计者考量的
调用时机
当使用运算符操作非基本对象时, 编译器会根据调用的运算符和操作数的类型自动查找对应的重载运算符函数, 如果找不到则会Error
需要注意的特殊情况:
class MyStr str2;
str2 = str1;
//注意这两种方式不同, 前者是赋值运算, 后者是拷贝构造
class MyStr str3 = str2;
运算符的操作在类对象的定义中通常被编译器link到类的初始化, 从而与真正的运算符重载函数无缘
以下给出几类运算符的重载
1. 单目运算符:
- 通常将单目运算符设定为类成员函数
对于单目运算符的重载, 也遵循上头的重载运算符限制, 即不能将负号-重载为后置, 编译器也依据此来选择合适的参数
而对于具有前置&后置版本的单目运算符, 如自增运算符++, 其重载有特定的要求:
student& operator++(void){ //前置++重载
++this->num;
return *this;
}
student operator++(int){ //后置++重载
class student temp(*this);
++this->num;
return temp;
}
对于前置++的重载, 要求其参数列表为空
对于后置++的重载,
要求其参数列表有一个参数, 且必须是int
(有博客有说可以是任意类型的, 但我使用Qt的MinGW编译报错), 其作用是为了区别前置++的重载版本, 同时告诉编译器这个运算符后头是有参数的, 这样编译器才知道这是后置版本
编译器在调用后置版本的重载运算符时会给int形参传入一个0, 但是一般不会用到这个值, 所以通常不提供标识符
2. << 和 >> 的重载
由于IO操作通常需要读写类对象的成员, 且自建类成员通常作为右操作数, 所以重载运算符一般设置为friend友元函数
这类重载函数通常需要使用C++ IO库的成员, 而
IO对象无法被拷贝或赋值
, 所有的操作只能通过指针或引用来进行, 所以函数的参数和返回值通常为IO对象的引用
并且, 由于
向流写入或读取内容会导致流的状态发生改变
, 所以重载运算符中无法使用const类型的IO成员
-
针对<<的重载运算符:
通常情况下, 由于输出时不修改右操作符, 所以将<<重载的第二个参数设置为const类型//由于类对象作为右操作数, 所以使用友元函数的形式 friend ostream& operator<<(ostream &o,const student &x){ cout<<"operator <<"<<endl; o << x.num; return o; }
-
针对>>的重载运算符:
由于输入的特殊性, 在重载运算符函数中有必要考虑可能的输入失败的情况并作出补救措施(如重置成初始状态)
判断方法可以通过IO成员内置的标识符来判定(在Chapter. VII中)
常见的错误有:- 流中含有错误类型的数据时读取操作可能失败
- 当读取到文件末尾或遇到流中的其他错误
[拓展: ]好的编程习惯
在类对象的输出中,
应该尽可能少的进行格式化操作
, 而将这个任务交个类的使用者, 使其可以更加自如的使用类 -
3. 圆括号()函数调用运算符的重载:
当重载函数调用运算符时, 并非创造了一种新的调用函数的方式,相反地,这是创建一个可以传递任意数目参数的运算符函数, 而其相应的类被称作
“函数对象”
即可以像使用函数一样直接使用类对象, 并向其传递特定数目和类型的参数, 编译器会调用不同的重载函数完成相应的操作, 并且是
唯一一种支持形参缺省值的运算符重载
通常, 函数对象中的数据成员被用于定制operator()函数调用运算符中的一些操作, 并且函数对象经常作为泛型算法的实参被传递(这个先等等…水很深的…)
总之函数调用运算符的重载拓宽了设计者的创作空间
//类内定义:
void operator () (int n1=0,int n2=0){ //支持提供缺省值的方式
cout<<"Operator () Overload\n";
num=n1+n2;
return ;
}
//使用:
class student stuObj1(student(250));
stuObj1(1,2);
cout<<stuObj1<<endl;
输出结果:
Overload Constructor: int
Operator () Overload
operator <<
3
4. 算数和关系运算符:
- 算数和关系运算符通常为对称性的, 一般情况下重载为非成员函数
- 由于不需要对两个算子做出修改, 所以两个参数一般设置为const常量引用
- 由于其计算结果会产生一个新值, 独立于两个算子之外, 所以通常储存在一个临时的局部变量中, 最后在返回这个变量
-
较为方便的做法是: 先重载相应的复合赋值运算符, 而后在用其来定义算数运算符,
这种复用的思想也可以用在其他的重载运算符中: 通常是在一类运算符中定义了某一个, 而后用其定义其他的重载运算符, 并且也应该定义该类中的其他运算符 -
对于赋值运算符, 类似于类的拷贝构造函数, 有一个非常需要注意的点就是
避免浅拷贝
详见
C++ 类构造函数的种类与调用以及等号创建对象
5. 下标运算符:
- 必须作为类的成员函数重载
- 通常返回所访问元素的引用(与原生版本相同)
- 最好同时定义下标运算符的常量版本和非常量版本, 即返回const常量引用和非常量引用
6. 成员访问运算符
前排提醒: 这个运算符挺坑的
注意, 箭头运算符与解引用运算符一样都是单目运算符, 尽管其看起来像双目运算符, 但其右操作数不是表达式, 而是对应类成员的一个标识符, 编译器将通过此标识符获取特定的成员
- 通常情况下, *解引用运算符与->箭头运算符一同作为成员函数
-
通常将这两个运算符的重载函数设置为底层const, 并将返回值设置为非const的成员引用
因为成员访问通常自身不会修改类对象, 但是调用方有权修改返回给他的值 - 对于箭头运算符的重载, 其返回值必须为该类的某个对象, 或指向该类的指针, 其余的任何情况都会报错
- 使用箭头运算符时有两种情况, 也只有这两种情况, 任何其他类型的左操作数都会报错:
//pointer为指向类对象的指针. 此时两条语句等效
pointer->member; //编译器调用的是-> 的内置版本, 与重载版本无关
*(pointer).member;
//pointer为某个类的对象时:
pointer->member; //此时编译器调用的才是重载运算符版本
//相当于:
pointer.operator->() ->member; //对, 就是这么一个奇葩玩意
//相当于调用了->的重载函数, 并将它的返回值在做了一次->运算
此时有两种情况:
- 第一种情况: 如果->重载函数返回的是指向该类的指针, 则变成上头的第一种情况, 后面的->运算使用内置版本
- 第二种情况: 如果->重载函数返回的是该类的某个成员(有可能是类对象自身的引用), 且这个成员中也重载了operator-> , 则调用他, 否则报错(因为内置->操作数不可以是指针之外的对象)
所以, 想要operator->退出, 只能通过第一种情况, 否则他会在两种情况中不断的变换, 要么无限递归, 要么报错
所以如果operator->返回的是其类对象的引用, 则会无限递归, 编译器报错:
#include <iostream>
class myClass {
public:
myClass& operator->() {
cout<<"Operator -> Overload\n";
return *this;
}
void action() {
//do something...
return;
}
};
int main() {
myClass obj;
obj->action();
return 0;
}
直接报错:
error: circular pointer delegation detected
合适的重载运算符的时机
重载运算符对类服务, 自然也要从类的整体设计上考虑何时应该重载运算符, 何时应使用成员函数:
通常, 当类的某些操作在逻辑上与对应的运算符相关, 则其更应该设置为重载运算符的形式:
- 对类执行IO操作, 通常重载>> 和 << 使其与内置类型的IO保持一致
- 对类执行关系操作, 包括> < >= <= == != 六种
如果没有特殊的需要, 重载这些运算符时参数与返回值的设置最好与内置版本相兼容, 符合用户的使用习惯, 更不容易发生错误
重载运算符最本质的目的是为了方便使用
一切的方便都是建立在对原有的运算符功能的理解上的
如果一个操作在功能上存在一定的二义性, 或者与常规的理解存有一定的偏差, 则此时不应该扭曲原有运算符的含义与逻辑, 转而用一个成员函数并在函数名上给出提示更能方便使用
关于运算符重载函数的匹配:
运算符重载在C++中同样作为函数来调用, 所以仍然遵循函数的匹配模式
但总体上, 运算符重载函数的候选集合比普通重载函数要来的大:
当运算对象中有类类型时, 函数匹配列表中的候选函数应该包括该运算符的非成员重载版本和内置版本, 且如果左侧的运算符是类类型, 则还要包括该类中定义的成员函数重载版本