Fork me on GitHub

常用运算符重载

前言

当运算符被用于类类型的对象时,C++语言允许我们为其指定新的含义:同时,我们也能自定义类类型之间的转换规则。和内置类型的转换一样,类类型转换隐式地将一种类型的对象转换成另一种我们所需类型的对象。

基本概念

重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。

重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。

如果一个运算符函数是成员函数,这他的第一个(左侧)运算对象绑定到隐式的this指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。

对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数:

1
2
//错误:不能为int重定义内置的运算符
int operator+(int,int);

这一约定意味着当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。

直接调用一个重载的运算符函数

1
2
3
//一个非成员运算符函数的等价调用
data1 + data2; //普通的表达式
operator+(data1,data2); //等价的函数调用

这两次调用是等价的,它们都调用了非成员函数operator+,传入data1作为第一个实参、传入data2作为第二个实参。

1
2
3
//一个成员运算符函数的等价调用
data1 += data2; //基于“调用”的表达式
data1.operator+=(data2); //对成员运算符

这两天语句都调用了成员函数operator+=,将this绑定到data1的地址、将data2作为实参传入了函数。

某些运算符不应该被重载

某些运算符指定了运算对象求值的顺序。因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。

比如:&& 和 || 运算符的重载版本也无法保留内置运算符的短路求值属性,这两个运算对象总是会被求值。当代码使用了这些运算符的重载版本时,用户可能会突然发现他们一直习惯的求值规则不再适用了。

通常情况下:不应该重载逗号、取地址、逻辑与和逻辑或运算符。

赋值和复合赋值运算符

赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧的运算对象的一个引用。重载的赋值运算符应该继承而非违背其内置版本的含义。

如果类含有算术运算符或者位运算符,则最好也提供对应的复合赋值运算符。+=运算符的行为显然与其内置版本一致,即先执行+,再执行=。

选择作为成员或者非成员 (重点)

当我们定义重载的运算符必须首先决定是将其声明为类的成员函数还是声明为一个普通的非成员函数。

下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:

  • 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
  • 具有对称性的运算符可以转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。

例如,我们能求一个int和一个double的和,因为它们中任意一个都可以是左侧运算对象或右侧运算对象,所以加法是对称的。如果我们想提供含有类对象的混合类型表达式,则运算符必须定义成非成员函数。

当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象,例如:

1
2
3
string s = "world";
string t = s + "!"; //正确:我们能把一个const char*加到一个string对象中
string u = "hi" + s; //如果 + 是string的成员,则产生错误

如果operator+是string类的成员,则上面的第一个加法等价于s.operator+(“!”)。同样的,”hi”+s等价于”hi”.operator+(s)。显然”hi”的类型是const char*,这是一种内置类型,根本就没有成员函数。

因为string将+定义成了普通的非成员函数,所以”hi”+s等价于operator+(“hi”,s)。和任何其他函数调用一样,每个实参都能被转换成形参类型。唯一的要求是至少有一个运算对象是类类型,并且两个运算对象都能准备无误地转换成string。

输入和输出运算符

IO标准库分别使用>>和<<执行输入和输出操作。对于这两个运算符来说,IO库定义了用其读写内置类型的版本,而类则需要自定义适合其对象的新版本以支持IO操作。

重载输出运算符<<

通常情况下,输出运算符的第一个形参是一个非常量ostream对象的引用。之所以ostream是非常量是因为向流写入内容会改变其状态;而该形参是引用时因为我们无法直接赋值一个ostream对象。

第二个形参一般来说是一个常量的引用,因为打印对象不会改变对象的内容,为了与其他输出运算符保持一致,operator<<一般要返回它的ostream形参。

1
2
3
4
ostream &operator<<(ostream &os,const Sales_data &item){
os<<item.isbn()<<item.avg_price();
return os;
}

输入输出运算符必须是非成员函数,通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。

重载输入运算符>>

通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。

1
2
3
4
5
6
7
8
9
10
istream& operator>>(istream &is,Sales_data &item){
double price ;
is>>item.bookNo>>item.units_sold>>price;
if(is){ //检查输入是否成功
item.revenue = item.units_sold*price;
}else{
item = Sales_data(); //输入失败:对象被赋予默认的状态
}
return is;
}

note: 输入运算符必须处理输入可能失败的情况,而输出运算符不需要。当读取操作发生错误时,输入运算符应该负责从错误中恢复。

算术和关系运算符

如果类同时定义了算符运算符和相关的复合赋值运算符(+=),则通常情况下应该使用复合赋值来实现算术运算符。

相等运算符 (==)

如果某个类在逻辑上有相等性的含义,则该类应该定义operator == ,这样做可以使得用户更容易使用标准库算法来处理这个类。

关系运算符 (<)

定义了相等运算符的类也常常包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用。

通常情况下关系运算符应该

  • 定义顺序关系,令其与关联容器中对关键字的要求一致。
  • 如果类同时也包含==运算符的话,则定义一种关系令其与 ==保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个。

注意: 如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和 ==产生的结果一致时才定义 <运算符。

赋值运算符

我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数

复合赋值运算符

赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。

1
2
3
4
5
6
7
//作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
//假定两个对象表示的是同一本书
Sales_data& Sales_data::operator+= (const Sales_data &rhs){
this.units_sole += rhs.units_sole;
this.revenue += rhs.revenue;
return *this;
}

下标运算符

表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]。

note: 下标运算符必须是成员函数。

为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。进一步,我们最好定义下标运算符的常量版本和非常量版本,当作用与一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值

定义StrVec的下标运算符:

1
2
3
4
5
6
7
8
class StrVec {
public:
string& operator[](size_t n) {return elements[n];}
const string& operator[](size_t n) const {return elements[n];}
//其他成员定义
private:
string *element; //指向数组首元素的指针
}

当StrVec是非常量时,我们可以给元素赋值;而当我们对常量对象取下标时,不能为其赋值:

1
2
3
4
5
6
7
// 假设svec是一个StrVec对象
const StrVec cvec = svec; //把svec的元素拷贝到cvec中
//如果svec中含有元素,对第一个元素运行string的empty函数
if(svec.size() && svec[0].empty()) {
svec[0] = "zero"; //正确:下标运算符返回string的引用
cvec[0] = "zip"; //错误:对cvec取下标返回的是常量引用,不能再赋值
}

递增和递减运算符 (重点)

C++语言并不要求递增和递减运算符必须是类的成员,但是因为它们改变的正好是所操作对象的状态,所以建议将其设定为成员函数。

定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类成员。

定义前置递增/递减运算符

与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。

1
Integer& operator++(){ ++value; return *this;} //前置运算符

后置运算符

后置版本接受一个额外的(不被使用)int类型的形参,因为我们不会用到int形参,所以无须为其命令。

为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。

1
2
3
4
5
Integer operator++(int) { //后置运算符
Integer ret = *this;
++value;
return ret;
}

显示调用后置运算符

通过函数调用的方式调用后置版本,则必须为它的整型参数传递一个值

1
2
3
Integer INT;
INT.operator++(0); //调用后置版本的operator++
INT.operator++(); //调用前置版本的operator++

完整代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
using namespace std;
class Integer{
public:
Integer(int a = 0):value(a){} //默认值参数,如果不提供参数,调用默认构造函数,提供参数则调用构造函数
Integer(const Integer& Int):value(Int.value){} //拷贝构造函数
Integer& operator=(const Integer &Int) { value = Int.value; return *this;} //拷贝赋值运算符
Integer& operator++(){ ++value; return *this;} //前置运算符
Integer operator++(int) { Integer ret = *this; ++value; return ret;} //后置运算符
int value; //含有一个数据类型
};
int main()
{
Integer a;
cout<<a.value<<endl;
a.value = 10;
Integer b(a); //调用拷贝构造函数
cout<<b.value<<endl;
Integer c;
c = a; //调用拷贝赋值运算符
cout<<c.value<<endl;
++c; //调用重载前置运算符
cout<<c.value<<endl;
cout<<(c++).value<<endl; //调用重载后置运算符
cout<<c.value<<endl;
return 0;
}
-------------本文结束感谢您的阅读-------------

本文地址:http://www.wangxinri.cn/2017/11/18/常用运算符重载/
转载请注明出处,谢谢!

梦想夹带眼泪,咸咸的汗水!