[关闭]
@fcxxzux 2016-03-20T22:17:25.000000Z 字数 6130 阅读 1811

Learning a Part of C++(for ACM/ICPC) (3) 运算符重载

我们接着前一篇留下来的坑,继续讲:
前一篇中,我们提到:(A.add(B)).num_multiply(10.0)这样的形式看上去冗长,还不够自然。有一个办法解决,那就是:运算符重载

1、初识运算符重载——普通二元运算符重载

首先我们明确一下我们想要什么:
我们希望,已知点ABCD,向量AB+向量CD,能写成:(B-A)+(D-C)
那么首先,我们先来试试看,把上面这个向量的加减运算实现一下:

Exp 3.01
编译运行以下代码,观察结果,理解operator+()operator-()函数

  1. #include <stdio.h>
  2. #include <math.h>
  3. struct Point;
  4. typedef Point Vector;
  5. struct Point{
  6. double x;
  7. double y;
  8. Point(){}
  9. Point(double _x,double _y):x(_x),y(_y){}
  10. Point operator +(Point b){
  11. return Point(x+b.x,y+b.y);
  12. }
  13. Point operator -(Point b){
  14. return Point(x-b.x,y-b.y);
  15. }
  16. };
  17. int main(){
  18. Point A(3.0,3.0);
  19. Point B(2.5,-1.9);
  20. Point C(1.0,6.0);
  21. Point D(-2.5,2.9);
  22. Vector ans=(B-A)+(D-C);
  23. printf("%f %f\n"
  24. ,ans.x
  25. ,ans.y);
  26. return 0;
  27. }

可以发现,之前的add()minus()在这里,函数名改写成了operator +operator -。这种写法,我们称作运算符重载

二元运算符的重载
格式:<返回值类型> operator <需要重载的运算符>(<参数列表>)
使用方法:<这个类的对象实体> <运算符> <参数列表里匹配的类型的值>(大白话的说,这种类型,和另一种相应的提到的类型,我们可以直接进行运算了)

这两点我们都在上面的例子中展示过了。

Tips 3.01
事实上,运算符重载,本质仍然成员函数,调用的时候,本质是函数调用,A+B会被编译器等价视作A.operator+(B),因此,以下代码和(B-A)+(D-C)等价,且也可以通过编译:

  1. Vector ans=(B.operator-(A)).operator+(D.operator-(C));

当然,我们可以继续加强一下我们的这个Vector类,补上数乘和数除:

  1. Point operator *(double b){
  2. return Point(x*b,y*b);
  3. }
  4. Point operator /(double b){
  5. return Point(x/b,y/b);
  6. }

但是,请注意:这个定义方法,有一个问题:
你在真的写数乘的时候:

  1. Point A(3.0,3.0);
  2. Vector ans= A * 5.0; //编译通过,没问题
  3. Vector ans2=5.0 * A; //编译失败,提示找不到相应运算符

因为我们上面的定义办法,所定义的运算符,它要求运算的格式是这个类型的对象 * 参数列表里的类型,或者说,在这里只有Point * double,但没有定义double * Point

怎么办?2种办法:
1、强迫自己适应那一种写法
2、实现double * Point的运算符

就方法2,我们有2种实现办法

  1. struct Point{
  2. //其他省略
  3. /* 方法1 */
  4. Point friend operator*(double a,Point b){
  5. return b*a;
  6. }
  7. };
  8. /* 方法2 */
  9. Point operator*(double a,Point b){
  10. return b*a;
  11. }

在这个结构体内部实现的话,我们要加上friend关键词,作为友元函数,这样这种二元运算符重载才是你可以手工指定前后2个运算数的。
在这个结构体外部实现的时候,则不要加这个friend关键词了,参数列表里也要同样地,指定前后2个运算数。
请注意,这里顺序绝对不能对调!实际的数学运算中,就有不满足交换律的运算,C++里考虑到这点,参与运算的值的前后顺序也是,你定义了哪种,你才能做相应的那种运算。

Tips 3.02
有人要问了:我扯到现在,为什么并没有提到,向量的点乘和叉乘
(我们假设,都是指二维的向量运算)
我们先看看向量的点乘和叉乘,如果都用 * 这个运算符的话,会写成什么样:
点乘:Vector * Vector => double
二维叉乘:Vector * Vector => double
那么,运算符重载的时候,我们会得到2个函数名一致、参数一致、返回值一致的重载函数。

还记得我们在第一篇里说过什么吗?
函数的重载,要求有相同的函数名,不同的参数列表,这样才能通过函数的调用形式来区分到底要执行哪个。
相同的函数名,还完全一样的参数列表,只是不一样的返回值类型,编译器并没有办法区分这几个重载函数,会出现编译错误。

至于叉乘和点乘,到底哪个用 * 这个符号重载,这个决定权就留给你们自己了。(另一个用普通成员函数实现就行了)


Tips 3.03
也许你现在想起来,要把这种写法照搬到矩阵类上去。
但是,也请注意,直接返回一个巨大的结构体会有栈溢出的问题,而且表现形式还会很奇怪。
比如,(在Windows 7环境下,用mingw 4.7.2编译)编译运行以下的代码,猜猜会发生什么?

  1. #include <stdio.h>
  2. #include <string.h>
  3. struct Matrix{
  4. int a[805][805];
  5. Matrix(){
  6. memset(a,0,sizeof(a));
  7. }
  8. Matrix operator*(Matrix &b){
  9. Matrix ans;
  10. //计算过程,省略
  11. return ans;
  12. }
  13. }sample,sample2,sample3;
  14. int main(){
  15. puts("start");
  16. int T;
  17. for(scanf("%d",&T);T--;){
  18. puts("here");
  19. sample3=sample*sample2;
  20. }
  21. return 0;
  22. }

你会惊奇的发现,一开始运行就直接报了RE,连start都没有输出
说实话,具体原因我也说不清楚,但是就算输出了那个start,也注定要在之后RE的,因为——
1、函数计算的返回值,实际上是要通过栈来返回的。
2、何况在乘法运算符的函数里,还创建了一个巨大的局部变量,这也是压在栈里面的。
本来栈就小,压入2个巨大的结构体肯定要压爆炸了。

一般有2种绕过办法:
1、不用运算符重载来实现这些需要返回矩阵的运算,改用普通函数。比如,声明成:void Matrix_Multiply(Matrix &a,Matrix &b,Matrix &ans),其中a和b是前后两个参与运算的矩阵,答案写入ans中(推荐)
2、修改Matrix结构体,不要里面直接放一个巨大的数组,通过动态内存分配,改而放在堆空间里

  1. struct Matrix{
  2. int (*a)[805];
  3. Matrix(){
  4. a=new int[805][805];
  5. memset(a,0,sizeof(a));
  6. }
  7. Matrix operator*(Matrix &b){
  8. Matrix ans;
  9. for(int i=0;i<100;i++){
  10. for(int j=0;j<100;j++){
  11. for(int k=0;k<100;k++){
  12. ans.a[i][j]+=a[i][k]*b.a[k][j];
  13. }
  14. }
  15. }
  16. return ans;
  17. }
  18. };

2、小于号的重载与STL的一些algorithm中的函数的使用方法

我们从最基本的排序(sort)开始。
sort这件事情,任何时候都可能要来一发,C++ STL的sort实现又是很高效的(不是傻乎乎快速排序到底那么简单,还兼顾CPU特性和工业界实践经验做出的统筹安排)。不会用怎么行?

0、准备工作

首先,include 需要的头文件:

  1. #include <algorithm>
  2. using namespace std;

其中,第二行的作用是,全局使用std这个命名空间(STL的所有算法等都在这个命名空间下)。

1、sort的两种原型以及效果

sort函数有两种原型:

  1. template<class RanIt>
  2. void sort(RanIt first, RanIt last); //--> 1)
  3. template<class RanIt, class Pred>
  4. void sort(RanIt first, RanIt last, Pred pr); //--> 2)

看上去看不懂,换一个实用的就好懂多了——

  1. const int N = 30;
  2. int a[N];
  3. //对a输入以后
  4. sort(a,a+N);//第一种
  5. sort(a,a+N,cmp);//第二种

提醒:
1、用sort的时候数组可以直接上
2、用sort排序只能排序1维数组或者多维数组的最大维度

先讲第一种
第一种就是对数组a从a[0]开始到a[N-1](0到N-1,刚好N个)进行排序,排完序以后是升序的(从小到大的)

那如果我排序的结果要降序(从大到小)呢?如果我有更多的排序需求呢?
这个时候第二种的第3个参数就有存在意义了
第3个参数,就直接打一个函数名
这个函数的作用就是,自定义排序规则

比如,我们需要对一个整数数组降序排列,定义函数【专业术语:二元谓词】:

  1. int cmp(int a,int b){
  2. return a>b;
  3. }

从上面这个比较函数的样例中,我们可以得知:
1、需要接受2个传入参数,前一个可能在数组中靠前,后一个在数组中靠后
2、如果根据比较规则,前一个就应该在后一个前面(即两者前后顺序正确),返回真,否则返回假。
(不需要把两者相等的情况给特殊照顾,相等的情况交换和不交换等效)

2、更多的要求——结构体数组和比较函数的一点复杂化

考虑下面的要求:
给出n(<=100)个学生的姓名(<=10字节)和期末绩点(无顺序,假设没有绩点完全相同的两人),要求按照绩点从高到低排序,输出排序后的结果。
如果把姓名和绩点分两个数组存储……
绩点排了序,姓名对不上号了!
那我们要考虑一下把姓名和绩点捆绑在一起。
结构体!
因此,做出如下定义

  1. const int N = 100;
  2. struct Student{
  3. char name[30];
  4. double grade;
  5. }student[N+1];

然后,定义排序规则:

  1. int cmp(const Student& a,const Student& b){
  2. return a.grade>b.grade;
  3. }

最后,调用排序

  1. int n;
  2. //读入n个之后
  3. sort(student,student+n,cmp);

还是很显然的吧?

Tips 3.04
为什么我们在定义排序规则的时候,要写参数类型是const的,还是引用类型?
答:
1、STL内部的sort在对待你的对象的时候,是把你的对象当做常对象对待的,对常对象,不能修改——也就是说,不能调用常对象的非const成员函数,也不能把const对象作为参数传给非const参数。
2、使用引用类型的原因很简单,为了效率。
如果直接传对象的值进去,这会复制一份,导致你的sort用时*2 甚至 *3,本来能过的直接TLE,这个惨痛的教训请自己去问一下陈松扬的故事。

3、多关键词排序——比较函数的进一步讨论

在之前的问题基础上,进一步的,考虑以下情况:
如果有同学的绩点一样,要求按名字的ASCII从小到大排序。
——也就是说,这是一个多关键词排序的问题了……

这次定义不用变,调用方法当然也不变,重头戏在排序规则上。
先分析一下:

  1. 如果a.grade!=b.grade
  2. {
  3. 如果a.grade>b.grade,返回1
  4. 如果a.grade<b.grade,返回0
  5. }
  6. 否则(即a.grade==b.grade),比较名字,名字前者小返回1,否则返回0

于是,写出以下代码:

  1. int cmp(const Student& a,const Student& b)
  2. {
  3. if (a.grade!=b.grade)
  4. return a.grade>b.grade;
  5. return strcmp(a.name,b.name)<0;
  6. }

至于更多的关键词的比较,这里提一下思路,希望大家能自行尝试。
先判断第一关键词是否相等,不相等则可以做判断并返回结果
如果第一关键词相等,则比较第二关键词,相等就看第三关键词,不等就返回比较结果,以下以此类推。

4、用于一切的更优雅方式

STL里面要用这样小于序比较规则的不只是一个地方哦!
比如排序排完了,经常还要有二分查找(lower_bound、upper_bound)配合
还有一些容器(map、set),他们可是依赖于元素比较并进行特定的组织后才能做到每次插入和删除都是O(logn)的时间复杂度的
为二分查找定义小于还好,可是,为那些容器定义小于……
非常丧病的需要你给的不是一个二元谓词函数,而是一个实现了operator()——这个operator()是二元谓词——的类或结构体……

  1. template<typename KeyType>
  2. struct Predicate
  3. {
  4. bool operator()(const KeyType& key1,const KeyType& key2)
  5. {
  6. //定义一下你自己的排序规则
  7. }
  8. };

——我不想这么麻烦怎么办啊?!

如果你的这些结构体元素不需要进行多次排序规则不同的排序(比如首先按姓名排一遍,然后又要按成绩排一遍)
那你可以——直接为这个结构体重载小于号

  1. struct Student
  2. {
  3. char name[30];
  4. double grade;
  5. bool operator<(const Student& b)const{
  6. //排序规则
  7. }
  8. }student[N+1];

注意到这里这个小于号的运算符重载的时候必须要是常成员函数,引用的对象也要是常对象,否则编译过不去的
之后在要sort的时候轻松愉快:

  1. sort(student,student+n);

Tips 3.05
请注意:使用STL时,如果这个东西,依赖元素的顺序(包括sort、二分查找、之后的set和map2个容器类型),请定义严格的小于号(即,不能相等的时候返回true,相等的时候必须返回false)!
不然出现的错误包括但不限于:TLE、栈溢出。

3、前置++运算符与后置++运算符的重载

这个部分这里只做简单介绍,ACM里一般不会去实现这个东西,
但是正确的使用姿势很重要。

  1. struct Value{
  2. int myNum;
  3. Value(){}
  4. Value(int a):myNum(a){}
  5. //前缀++运算符
  6. Value& operator++(){
  7. ++myNum;
  8. return *this;
  9. }
  10. //后缀++运算符
  11. Value operator++(int){
  12. Value Copy(myNum);
  13. ++myNum;
  14. return Copy;
  15. }
  16. };

还记得前缀++和后缀++的区别吧?一个是修改后返回修改后的值,一个是修改后返回修改前的值。
那么在一般的类的实现中,前缀++运算符是修过当前对象的值,并返回当前对象的引用,而后缀++运算符是复制一份当前对象,修过当前对象的值,返回那个复制。
所以:

Tips 3.06
对对象操作的时候,如果没有必要,请尽量避免使用后缀++/--,否则会有常数*2 甚至 *3的效果。
(这在之后的STL容器中,遍历的时候,会很常用的)

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注