重载操作符就是让操作符作用域非内置类型时也有自己独特的意义。
对于内置类型,当操作符作用于它们时,编译器会规定操作的意义:两个int型数据相加的结果与数学运算的加法相同。但是对于非内置类型,比如类或者枚举类型,编译器并没有规定操作符作用于它们的意义。有些时候,这样做是合理的,比如对于两个Student类对象(其中的数据成员有姓名、学号),对它们进行加法操作的确没有什么意义;但是有的时候,我们却希望能够像操作普通变量一样用运算符操作它们,比如我们希望可以直接通过cout<<stduent1来实现输出姓名和学号。虽然我们可以通过设计同样功能的函数来实现这样的功能,但是这样远没有cout<<stduent1来的简单方便,尤其是对类的使用者而言,良好地重载操作符可以是他们不用记住许多接口函数。
在前面的章节中,我们接触了赋值操作符,它其实就是重载了“=”操作符,使得它具有让做操作数的数据成员有右操作数的数据成员相等的功能。先看看我们整个篇幅都要使用的类:
- class Student
- {
- private:
- int schoolNumber;
- string name;
- string address;
- static int cnt;
- public:
- //默认构造函数
- Student():schoolNumber(cnt++),name("NoName"),address("xust"){}
- //构造函数
- Student(string nm,string add):schoolNumber(cnt++),name(nm),address(add){ cout<<"Student构造"<<endl;}
就是为学生定义了学号,姓名,地址,然后定义了一个类静态成员cnt来控制新创建的学生的学号是依次递增的。
那么重载输出操作符的方法如下:
- //重载输出操作符
- ostream& operator<<(ostream &os,const Student &sd)
- {
- os<<sd.schoolNumber<<"\t"<<sd.name<<"\t"<<sd.address<<"\t";
- return os;
- }
可以看出,重载操作符其实也是一个函数,其中返回值、形参与一般的函数相同,唯一要注意的是函数名,使用关键字operator加上你要重载的符号来组成函数名。当你定义一个Student 的对象std1时,就可以直接使用cout<<sdt1了。
我们不难发现,其实通过定义函数的方法也能完全解决这个问题:
- void Student::display()
- {
- cout<<schoolNumber<<"\t"<<name<<"\t"<<address<<"\t"<<endl;;
- }
使用调用对象的output函数就可以了。
言归正传,并不是所有操作符都能够重载的。它们分别是:作用域操纵符“::”,成员指针解引操作符“.*”,成员选择操作符“.”,以及条件表达式“?:”。我们也没有必要大量的设计重载操作符函数,除非这个重载后的操作符能很好符合人思维的预期:比如对两笔交易相加的结果,应该是总的交易数量相加,总的交易钱数也相加,而平均交易价格并不是相加,而是总钱数除以总数量的结果。
在重载操作符时,还有一些小的细节需要注意:
1.我们只能“重载”操作符,而不是“创建”操作符,C++规定的操作符无非是加减乘除等等那么60来个,有不少是通过几个操作符组合起来的,你不能标新立异的自己“组合”一些操作符来达到特定的操作,比如试图定义operator**来定义平方操作等等。
2.操作符的操作数至少有一个类或者枚举类型,对于内置类型,操作符的意义是不允许改变的。
3.操作符重载后,尽管它们有了自己的意义,但是它们的优先级不会发生变化:x == y + z不论如何重载,都是先算y+z,在算==。
4.重载的操作符不能保证操作数的求值顺序。举个例子,对于“&&”、“||”等操作符,计算时是“短路”的:如果第一个操作数为假,那么“&&”就不会计算第二个操作数;如果第一个操作数为“真”那么“||”不会计算第二个操作数。
5.重载操作符不能改变操作符的操作数。但这里有一点需要特别注意,如果重载操作定义为成员函数,那么他的形参比操作数数目少一,因为this指针已经指向了调用这个操作的对象了;如果重载操作定义为非成员函数(一般设为友元函数),那么操作数与形参个数相等。
其实,重载的目的就是为了使用起来“顺手”,有一种可以“望文生义”的感觉,如果重载的意义不是很明显,或者这个操作符很少使用,把它定义成一个普通的函数也许更好。这一点编译器做的很好,当你自己不“犯贱”的重载时,编译器会自己定义一些操作符:赋值操作符(对类的数据成员逐个赋值),取地址操作符(返回对象的地址),逗号操作符(从左往右计算,返回最右边的值)。
当然,对于有些类在使用时,你不得不重载一些操作符:比如,对于关联容器,必须支持“<”操作,当关联容器里装的是类时,这个类就必须重载“<”。而且对于一般的类,也应该支持“==”操作,因为很多算法库的很多函数都支持==操作。
还有一个问题,就是应该把类设计成成员函数还是友元函数呢,有一些好的指导原则可以供我们做参考:
1.赋值“=”、下标“[]”、调用“()”成员访问箭头“->”必须定义为成员函数,如果定义为非成员会导致编译错误。
2.复合赋值操作符也应定义为成员函数。但是如果不这样做,不会导致编译错误。
3.改变对象状态或与给定类型紧密联系的操作符,比如自增“++”、自减“--”、解引“*”,也应该定义为成员函数。
4.对称的操作符,比如算数操作符,关系操作符等,最好定义为非成员函数。
下面介绍一些常见的操作符的重载。
首先是输入输出操作符。
先看前面提过的输出操作符:
- //重载输出操作符
- ostream& operator<<(ostream &os,const Student &sd)
- {
- os<<sd.schoolNumber<<"\t"<<sd.name<<"\t"<<sd.address<<"\t";
- return os;
- }
由于是第一个操作符,所以值得细说一下。形参中因为ostream不支持复制,所以必须使用引用;因为输出会改变流的状态,所以不能设为const;输出操作一般不会改变Sales_item对象的内容,所以设为const;设为引用是为了省去实参到形参的复制工作,因为当类很大时,复制也是一笔不小的开销。函数的是调用操作符的ostream对象的引用,这使得我们可以cout<<std1<<sdt2这样连续使用输出操作符。
有一点需要注意,就是对于输出操作,尽量不要使用格式化操作,比如左对齐,换行等等;而应该把这个自由度留给用户,让用户以自己习惯的方式输出。
那么这个操作应该放在内成员还是友元中呢?答案是:必须放在友元中。因为如果放在类成员中,那么第一个操作数必须是类,所以我们就得这样使用<<操作符:sdt1<<cout。这显然不是我们所希望的。
再看输入操作符的重载:
- //重载输入操作符
- istream& operator>>(istream& is,Student &sd)
- {
- is>>sd.schoolNumber>>sd.name>>sd.address;
- if(!is)
- sd = Student();
- return is;
- }
二者在函数的声明上差别不大,需要说明的是因为输入操作会改变类的内容,所以没有使用const修饰。二者的主要区别在于输入操作需要判断输入的内容是否合法!这一点是至关重要,要是对于非法的输入,也会得到一个结果,那么这个操作的鲁棒性就太差了。在这里,我们要求依次输入输入学号、姓名、地址,如果输入有误,那么从重新新建一个空的Student对象,并返回流。对于更好的设计,最好能够指明哪里出了错误,正确的输入格式应该是什么等等,但是这里就统统略去了。
下面看看重载关系运算符:
- //相等操作:判断二人学号是否相等
- bool operator==(const Student& std1,const Student& std2)
- {
- return std1.schoolNumber == std2.schoolNumber&&
- std1.name == std2.name&&
- std1.address == std2.address;
- }
- //利用相等判断不等
- bool operator!=(const Student& std1,const Student& std2)
- {
- return std1 == std2;
- }
只有当两个学生的所有信息都相等时,才判为相等。当定义了相等以后,就可以利用相等定义不等操作了,这就省去了很多事。
- //重载小于操作符:判断学号大小
- bool operator<(const Student& std1,const Student& std2)
- {
- return std1.schoolNumber < std2.schoolNumber;
- }
- //重载大于操作符:利用小于判断大于
- bool operator>(const Student& std1,const Student& std2)
- {
- return std2<std1;
- }
对于关联容器的对象,需要支持<操作,所以我们这里也定义了<操作:通过学号判断大小。一般情况下,如果定义了小于,也要顺便定义大于,这样会是得类的使用者用起来比较顺手。
接下来定义+和+=。
- //重载+=操作符
- Student& Student::operator+=(const Student& rhs)
- {
- schoolNumber += rhs.schoolNumber;
- return *this;
- }
- //利用+=操作符重载+
- //加法操作返回的是右值,而不是引用
- Student operator+(const Student& std1,const Student& std2)
- {
- //新建一个Student对象,并用左操作数初始化
- Student ret(std1);
- ret += std2;
- return ret;
- }
其中+声明为友元,而+=定义为成员函数。可能很多人都不理解,为什么要先定义+=操作,然后用它定义+操作?首先,肯定是可以那样定义的,但是如果那样定义的话,在+=操作中,就会有类似:std1 = std1 +std2这样的代码,而我们知道,当编译器执行这个代码时,会先创建一个临时的Student对象来存储std1 +std2的结果,然后再把结构赋值给std1,然后再撤销这个临时的对象,效率会比较低。
还有一点需要注意,一般+操作返回的都是一个右值,而不是一个引用;而+=操作返回都是一个引用,这样就不需要创建和撤销临时的副本对象了。
接下来是重载下标操作符:
由于我们的Student的数据成员中并没有使用下标的元素,所以我们定义一个新的类Team:
- class Team
- {
- public:
- //构造函数接受一个参数来创建一个多人的team
- Team(size_t nm)
- {
- for(size_t i = 0;i<nm;++i)
- {
- Student std;
- team.push_back(std);
- }
- }
- //重载下标操作符声明
- Student& operator[](const size_t);
- private:
- vector<Student> team;
- };
Team的数据成员是一个vector<Student>,并且接受一个实参来控制每个Team中有多少个Student。我们对重载下标操作符,希望的是它能够返回一个team中的某一个Student成员,想清楚了这个,重载其实并不困难:
- //重载下标操作符
- Student& Team::operator[](const size_t index)
- {
- //return team[index];
- return team.at(index);
- }
在主函数中,可以使用
- Team tm(3);
- tm[1].display();
来显示一个学生的信息。
下面看一个比较难的例子,重载解引操作和箭头操作。
首先我们的类成员中并没有指针,我们本打算定义一个新的类,这个类中的数据成员就是指向Student类对象的指针。但是根据前面提过的“智能指针”以及引用计数原理,我们的程序变成了这样:
- //智能指针
- class StdPtr
- {
- friend class StudentPtr;
- //实际的指针
- Student *sp;
- //引用计数
- size_t use;
- //构造函数
- StdPtr(Student *p):sp(p),use(1){cout<<"StdPtr构造"<<endl;}
- //析构函数
- ~StdPtr(){cout<<"StdPtr析构"<<endl;delete sp;}
- };
- class StudentPtr
- {
- public:
- //复制操作符
- StudentPtr(const StudentPtr& orig):ptr(orig.ptr){++ptr->use;}
- //赋值操作符
- StudentPtr& operator=(const StudentPtr&);
- //构造函数
- StudentPtr(Student *p):ptr(new StdPtr(p)){cout<<"StudentPtr构造"<<endl;}
- //析构函数
- ~StudentPtr()
- {
- cout<<"StudentPtr析构"<<endl;
- if(--ptr->use == 0)
- delete ptr;
- }
- //重载解引操作符:
- Student &operator*(){return *ptr->sp;}
- //重载箭头操作符:
- Student *operator->(){return ptr->sp;}
- //与之对应的const版本
- const Student &operator*()const{return *ptr->sp;}
- const Student *operator->()const{return ptr->sp;}
- private:
- StdPtr *ptr;
- };
我们在StudentPtr中重载了解引和箭头操作。因为StudentPtr本意为指向Student的指针,所以对它解引应该返回一个Student对象的引用,对它的箭头操作应该返回Student的成员。想清楚了这个道理,重载程序实际上就简单了。还有一点要注意,解引操作时一员操作,这里定义为成员函数,所以没有形参;而箭头操作看起来像是二元操作符:接受一个对象和一个成员名,而实际上,箭头操作的右操作数并不是一个表达式,而是类成员的标示符,编译器自动帮你处理了将一个标示符传递给函数以获取类成员的工作。
通常,对于指针和箭头都应该定义两个版本,一个是const,另一个是非const,const版本返回const引用以防止用户改变基础对象,这与STL标准库的思想很像,既有普通迭代器,也有const迭代器。
最后我们看看如何重载自增或者自减操作符。
自增自减操作符常用作迭代器或者的类型,按理说,我们应该定义一个类,它是指向任何一种对象的指针,但是这样需要使用时模版。
这里考虑一种简单的情况:这个类能够处理int型的数组:
- //该类是一个指向数组的指针
- class CheckedPtr
- {
- public:
- //构造函数:必须绑定一个数组对象
- CheckedPtr(int *b,int *e):beg(b),end(e),curr(b){}
- //重构前自增操作符
- CheckedPtr& operator++();
- //重构前自减运算符
- CheckedPtr& operator--();
- //重载后自增运算符:通过调用参数与前自增运算符区别开来
- CheckedPtr& operator++(int);
- //重载下标操作符
- int& operator[](const size_t);
- const int& operator[](const size_t)const;
- //重载解引操作符
- int& operator*();
- const int& operator*() const;
- //重载==操作符
- friend bool inline operator==(const CheckedPtr&,const CheckedPtr&);
- //重载!=操作符
- friend bool inline operator!=(const CheckedPtr&,const CheckedPtr&);
- //重载+操作
- friend CheckedPtr operator+(const CheckedPtr&,const size_t);
- //重载-操作
- friend CheckedPtr operator-(const CheckedPtr&,const size_t);
- private:
- int *beg;
- int *end;
- int *curr;
- };
数据成员只有3个,分别指向数组的第一个数,最后一个数,以及当前指向数组的哪一个数。初始化时,将当前指向初始化为数组的第一个数。这里并没有默认构造函数,因为我们希望CheckedPtr类的对象在建立时,就与某一个数组绑定。
自增操作和自减操作意味着移动指向数组中某一元素的指针curr,想明白了这个道理,其实自增或者自减操作的重载并不困难:
- CheckedPtr& CheckedPtr::operator++()
- {
- if(curr == end)
- throw out_of_range("increment past the end of CheckedPtr");
- ++curr;
- return *this;
- }
- CheckedPtr& CheckedPtr::operator--()
- {
- if(curr == beg)
- throw out_of_range("decrement past the beginning of CheckedPtr");
- --curr;
- return *this;
- }
首先看返回值:定义要与内置类型的操作一致:返回被自增/自减量的引用。其次,要检查指针是否越界。
通过程序,我们能看出来,这里定义的是“前自增”:因为程序中使用的是++curr;那么如何定义后自增呢?问题麻烦再后自增的操作符也是“++”,操作数也是一个,类型也相同。这里,通过接收一个额外的int型参数来区别后自增:
- CheckedPtr& CheckedPtr::operator++(int)
- {
- //保存当前值
- CheckedPtr ret(*this);
- //调用前自增来实现后自增
- ++*this;
- //返回当前值
- return *this;
- }
在使用时,通过ptr.operator++(0)来调用后自增。对于一般的类,如果定义了前自增,也应该定义后自增。这里虽然定义的不好看,但是还是勉强能用。
总而言之,重载操作符的关键是确定你到底想得到一个什么样的效果,这与其说是一门技术,不如说是一门艺术:好的重载能让类的使用者感到很方便,而差劲的重载会让类的使用者摸不着头脑,举一个例子:
还是上面那个类,我们也定义了解引操作符:
- int& CheckedPtr::operator*()
- {
- if(curr == end)
- throw out_of_range("invalid curremt point");
- return *curr;
- }
在主函数中:
- #define RANGE 10
- int main()
- {
- int arr[RANGE] = {0,1,2,3,4,5,6,7,8,9};
- CheckedPtr ptr(arr,arr+10);
- //后自增运算返回的是当前对象,但是对象的curr指针已经指向下一个元素了,所以解引的是下一个元素
- cout<<*ptr.operator++(0)<<endl;
- return 0;
- }
我们希望的效果应该是输出0,然后自增,但是由于后自增运算虽然返回的是当前对象,但是对象的curr指针已经指向下一个元素了,所以解引的是下一个元素,所以会输出1.这个隐蔽的“错误”很难发现。
所以不到万不得以而且确保自己的重载万无一失的话,还是老老实实的调用函数会更好一些。