重载运算与类型转换
当运算符被用于类类型的对象时,c++允许我们为其指定新的含义;同时,我们也能自定义类类型之间的转换规则。和内置类型一样,类类型转换可以隐式地将一种类类型的对象转换成另一种我们所需的类类型的对象。
当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义,合理的使用运算符重载能够使我们的程序更易于编写和阅读。
1.基本概念
1.重载的运算符是具有特殊名字的函数它们的名字由关键字operator和要定义的运算符共同组成,和其他函数一样,重载运算符也包含返回类型、参数列表以及函数体,如=的重载::
T operator=(参数列表){
//函数体
return *this;
}
重载运算符可以分为是成员函数和不是成员函数两种情况:
- 如果不是成员函数,重载运算符函数的参数数量与该运算符的运算对象一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参。
- 如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式地this指针上,因此成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。
对于一个运算符来说,它或者是类的成员,或者至少含有一个类类型的参数,这意味着当运算符作用于内置类型的运算对象时,我们无法改变运算符的含义。
//错误:不能为int重定义内置的运算符
int operator+(int,int);
2.直接调用一个重载的运算符函数
我们可以将运算符作用于类型正确的实参,**从这种方式间接调用重载运算符的运算符函数。**也可以像调用普通函数一样直接调用运算符函数。如:
//一个非成员运算符函数的等价调用
data1+data2; //普通的表达式
operator+(data1,data2); //等价的函数调用
如果重载运算符函数是类内函数,我们可以像调用其他成员函数一样显式地调用成员运算符函数。如:
data1+=data2; //基于“调用的表达式”
data1.operator+=(data2); //对成员运算符函数的等价调用
//调用了成员函数operator+=,将this绑定到data1的地址、将data2作为实参传入了函数。
3.某些运算符不应该被重载
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
4.使用与内置类型一致的含义
当在内置的运算符和我们自己的操作之间存在逻辑映射关系时,运算符重载的效果最好。此时,使用重载的运算符显然比另起一个名字更自然也更直观。不过,过分滥用运算符重载也会使我们的类变得难以理解。
在实际编程中,一般没有特别明显的滥用运算符重载的情况。例如,一般来说没有人会定义operator+执行减法操作。然而经常发生的另一种情况是,程序员可能会强行扭曲了运算符的:“常规”含义使得其适应某种给定的类型,这是我们不希望发生的。建议:只有当操作的含义对于用户来说清晰明了时才使用运算符。如果用户对运算符可能有几种不同的理解,则使用这样的运算符会产生二义性。
5.赋值和复合赋值运算符
赋值运算符的行为和复合赋值运算符版本的类似:赋值之后,左侧对象和右侧对象的值相等,并且运算符==应该返回它左侧运算对象的一个引用。==重载的赋值运算符应该跟内置版本含义一样。
***如何判断一个运算符应该定义为成员函数还是非成员函数:***
- 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员函数。
- 复合赋值运算符一般来说是成员,但并非必须。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符(一元运算符)通常是成员函数。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们应该是普通的非成员函数。
当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。例如:
string s="world";
string t=s+"!"; //正确:我们能把一个const char*加到一个string对象中
string u="hi"+s; //如果+是string的成员,则会产生错误
因为string将+定义成了普通的非成员函数,因此“hi”+s等价于operator+(“hi”,s)。
2.输入和输出运算符
IO库分别使用>>和<<来执行输入和输出的操作。对于这两个运算符来说,IO库定义了用其读写内置类型的版本,而类则需要自定义合适其对象的新版本以支持IO操作。
2.1重载输出运算符<<
*输出运算符的第一个形参是一个非常量ostream对象的引用。**之所以ostream是非常量是因为对流写入内容会改变其状态;而改形参是引用是因为我们无法拷贝一个ostream对象。*第二个形参一般来说是一个常量的引用,该常量使我们想要打印的类型。第二个形参是引用的原因是我们希望避免拷贝实参;而之所以该形参可以使常量是因为打印对象不会改变对象的内容。
为了与其他输出运算符保持一致,operator<<一般要返回它的ostream形参。
//Sales_data的输出运算符
ostream& operator<<(ostream& os,const Sales_data& item){
os<<item.isbn()<<" "<<item.unit_sold<<" "
<<item.revenue<<:" "<<item.avg_price();
return os;
}
1.输出运算符尽量减少格式化操作
通常,重载输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。如果运算符打印了换行符,则用户就无法在对象的同一行内接着打印一些描述性的文本了。相反,令输出运算符尽量减少格式化操作可以使用户有权控制输出的细节。
2.输入输出运算符必须是非成员函数
与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类成员函数。否则,它们的左侧运算对象将是我们类的一个对象:
Sales_data data;
data<<cout; //如果operator<<是Sales_data的成员
因此,如果我们希望为类自定义IO运算符,则必须将其定义为非成员函数。
2.2重载输入运算符>>
输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到(非常量)对象的引用。
//Sales_data的输入运算符
istream& operator>>(istream&is,Sales_data&item){
doulbe price; //不需要初始化,因为我们将先读入数据到price,之后才使用它
is>>item.bookNo>>item.units_sold>>price;
if(is){ //检查输入是否成功
item.revenue=item.units_sold*price;
}else{
item=Sales_data(); //输入失败:对象被赋予默认的状态
}
}
除了if语句之外,这个定义与之前的read完全一样。if语句检查读取操作是否成功,如果发生了IO错误,则运算符将给定的对象置为空Sales_data,这样可以确保对象处于正确的状态。
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
输入时的错误:
-
当流含有错误类型的数据时读取操作可能失败。例如在读取完bookNo后,输入运算符假定接下来读入的时两个数字数据,一旦输入的不是数字数据,则读取操作级后续对流的其他使用都将失败。
-
当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
3.算数和关系运算符
通常情况下,我们把算数和关系运算符定义成非成员函数以允许对左侧或右侧的运算符对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
算数运算符通常会计算它的两个运算对象并得到一个新值,这个值有别于任意一个运算对象,常常位于一个局部变量之内,操作完成后返回该局部变量的副本作为其结果。如果类定义了算数运算符,则它一般也会定义一个对应的复合赋值运算符。此时最有效的方式是使用复合赋值运算符来定义算数运算符:
//假设两个对象指向同一本数
Sales_data
operator+(const Sales_data& lhs,const Sales& rhs){
Sales_data sum+=lhs; //把lhs的数据成员拷贝给sum
return sum; //将rhs加到sum中
}
3.1相等运算符
c++中的类通过定义相等运算符来检验两个对象是否相等。**也就是说它们会比较对象的每一个数据成员,只有当所有对应的成员都相等时才认为两个对象相等。**依据这一思想我们的Sales_data类的相等运算符不但应该比较bookNo,还应该比较具体的销售数据:
bool operator==(const Sales_data& lhs,const Sales_data&rhs){
return lhs.isbn()==rhs.isbn()&&
lhs.units_sold==rhs.units_solod&&
lhs.revenue==rhs.revenue;
}
bool operator!=(const Sales_data& lhs,const Sales_data &rhs){
return!(lhs==rhs);
}
从这些函数中体现出来的设计准则:
- ==如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成operator == 而非一个普通的命名函数:因为用户肯定希望能使用 == 比较对象,所以提供 == 就意味着用户无需再费时费力地学习并记忆一个全新的函数的名字。此外,类定义了 运算符之后也更容易使用标准库容器和算法。
- 如果类定义了operator==,则运算符应该能判断一组给定的对象中是否含有重复数据。
- 通常情况下,相等运算符应该具有传递性,也就是说,如果a==b和b ==c都为真,则a ==c为真也应该为真。
- 如果类定义了operator==,则这个类也应该定义operator!=。对于用户来说当他们能使用 ==时肯定也希望能使用!=,反之亦然。
- 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用哪个真正工作的运算符。
==如果某个类在逻辑上有相等性的含义,则该类应该定义operator ,这样做可以使得用户更容易使用标准库算法来处理这个类。
3.2关系运算符
定义了相等于运算符的类也尝尝包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用。
通常情况下关系运算符应该:
-
定义顺序关系,令其对关联容器中对关键字的要求一致并且;
-
如果类同时也含有==运算符的话,则定义一种关系令其与 == 保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另一个。
==如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含 == ,则当且仅当<的定义和 产生的结果一致时才定义<运算符。
4.赋值运算符
之前介绍的拷贝赋值和移动赋值运算符可以把类的一个对象赋值给类的另一个对象。此外,类还可以定义其他赋值运算符使用别的类型作为右侧运算对象。例如:vector类定义了第三种赋值运算符,接受花括号内的元素列表作为参数。
vector<string>v;
v={"a","an","the"};
//同样可以把这个运算符添加到StrVec类中
class StrVec{
public:
StrVec&operator=(std::initializer_list<std::string>);
//其他成员
};
StrVec&StrVec::operator=(initializer_list<string>i1){
//alloc_n_copy分配内存空间并从给定范围内拷贝元素
auto data=alloc_n_copy(i1.begin(),i1.end());
free(); //销毁对象中的元素并释放内存空间
elements=data.first;//更新数据成员使其指向新空间
first_free=cap=data.second;
return *this;
}
和拷贝赋值及移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间再释放一片新空间。不同之处是,这个运算符无须检查对象自身的赋值,这是因为它的形参initializer_list确保i1和this所指的不是一个对象。
我们可以重载赋值运算符,不论形参的类型是什么,赋值运算符都必须定义为成员函数。
5.下标运算符
表示容器的类通常可以通过元素在容器的位置访问元素,这些类一般会定义下标运算符operator[].下标运算符必须是成员函数。
下标运算符通常以访问元素的引用作为返回值,这样做的好处是可以出现在赋值运算符的任意一端。我们最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。
class StrVec{
public:
std::string&operaotr[](std::size_t n){
return elements[n];
}
const std::string&operator[](std::size_t n)const{
return elements[n];
}
//其他成员
private:
std::string* elements; //指向数组首元素的指针。
};
StrVec svec;
const StrVec cvec=svec; //吧svec的元素拷贝到cvec中
if(svec.size()&&svec[0].empty()){
svec[0]="zero"; //正确:下标运算符返回string的引用
cver[0]="Zip"; //错误:对cvec取下标返回的常量引用
}
6.递增和递减运算符
在迭代器中通常会实现递增运算符(++)和递减运算符(–),这两种运算符使得类可以在元素的序列中前后移动。建议将++运算符重载设置为成员函数。
对于内置类型来说,递增和递减运算符机油前置版本也有后置版本。同样,我们也应该为类定义两个版本的递增和递减版本。
1.定义前置递增/递减运算符
为了说明递增递减运算符,我们在StrBlobPtr类中定义它们:
class StrBlobPtr{
public:
StrBlobPtr():curr(0){}
StrBlobPtr(StrBlob& a,size_t sz=0):
wptr(a.data),curr(sz){}
//递增和递减运算符
StrBlobPtr&operator++(){
//如果curr已经指向了容器的尾后位置,则无法递增它
check(curr,"increment past end of StrBlobPtr");
++curr;
return *this;
}
StrBlob&operator--(){
//如果curr是0,则继续递减它产生一个无效下标
--curr; //将curr在当前状态下向后移动一个元素
check(curr,"decrement past begin of StrBlobPtr");
return *this;
}
private:
//若检查成功,check返回一个指向vector的shared_ptr
std::shared_ptr<std::vector<std::string>>
check(std::size_t,const std::string&)const;
//保存一个weak_ptr,意味着底层vector可能被销毁
std::weak_ptr<std::vector<std::string>>wptr;
std::size_t curr; //在数组中的当前位置
};
std::shared_ptr<std::vector<std::string>>
StrBlobPtr::check(std::size_t i.const std::string &msg)const{
auto ret=wptr.lock();
if(!ret){
throw std::runtime_error("unbound StrBlobPtr");
if(i>=ret->size())
shrow std::out_of_range(msg);
return ret; //否则,返回指向vector的shared_ptr
}
}
为了与内置版本一直,前置运算符应该返回递增或递减后对象的引用。
递增和递减运算符的工作原理非常相似:它们首先调用check函数检验StrBlobPtr是否有效,如果是,接着检查给定的索引值是否有效。如果check函数没有抛出异常,则运算符返回对象的引用。
2.区分前置和后置运算符
前置和后置版本使用的是同一个符号,意味着其重载版本所用的名字将是相同的,并且运算对象的数量和类型也相同。为了解决这个问题,后置版本接受一个额外的(不被使用的)int类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。这个形参的唯一作用就是区分前置版本和后置版本的函数。
class StrBlobPtr{
public:
//递增和递减运算符
StrBlobPtr operator++(int); //后置运算符
StrBlobPtr operator--(int); //
//其他成员
}
//后置版本:递增/递减对象的值但是返回原值
StrBlobPtr StrBlobPtr::operator++(int){
//此处无须检查有效性,调用前置递增运算时才需要检查
StrBlobPtr ret=*this; //记录当前的值
++*this; //向前移动一个元素,前置++需要检查递增的有效性
return ret; //返回之前记录的状态
}
StrBlobPtr StrBlobPtr::operator--(int){
//此处无须检查有效性,调用前置递减运算时才需要检查
StrBlobPtr ret=*this; //记录当前的值
--*this; //向后移动一个元素,前置--需要检查递减的有效性
return ret; //返回之前记录的状态
}
7.成员访问运算符
在迭代器类及智能指针类中常常用到解引用运算符(*)和箭头运算符(->).我们以如下形式想StrBlobPtr类添加这两种运算符:
class StrBlobPtr{
public:
std::string& operator*()const{
auto p=check(curr,"dereference past end");
return(*P)[curr]; //(*p)是对象所指的vector
}
std::string* operator->()const{
//将实际工作委托给解引用运算符
return &this->operator
}
}
我们将这两个运算符定义成了const成员,这是因为与递增和递减运算符不一样,获取以一个元素并不会改变StrBlobPtr对象的状态。同时,它们返回值分别是非常量string的引用或指针,因为一个StrBlobPtr只能绑定到非常量的StrBLob对象。
8.函数调用运算符
如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。例如:
//absInt的struct含有一个调用运算符,然后返回其参数的绝对值:
struct absInt{
int operator()(int val)const{
return val<0?-val:val;
}
};
int i=-42;
absINt absObj; //含有函数调用运算符的对象
int ui=absObj(i); //将i传递给absObj.operator();
我们使用调用运算符的方式是令一个absInt对象作用于一个实参列表,这一过程看起来非常像调用函数的过程。
调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。如果类定义了调用运算符,则该类的对象称作函数对象,因为可以调用这种对象,所以我们说这些对象的行为像函数一样。
含有状态的函数对象类
和其他类一样,函数对象除了operator()之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。如:
//定义一个打印string实参内容的类
class PrintString{
public:
PrintString(ostream&o=cout,char c=' '): //有参构造函数
os(o),sep(c){}
void operator()(const string&s)const{
os<<s<<sep;
}
private:
ostream &os; //用于写入的目的流
char sep; //用于将不同输出隔开的字符
};
PrintString printer; //使用默认值
printer(s); //在cout中打印s,后面跟一个空格
PrintString errors(cerr,'\n');
errors(s); //在cerr中打印s,后面跟一个空格
for_each(vs.begin(),vs.end(),PrintString(cerr,'\n'));
我们类有一个构造函数,接受一个输出流的引用以及一个用于分割的字符,这两个形参的默认实参分别是cout和空格。之后的函数调用运算符使用这些成员协助其打印给定的string。
for_each的第三个实参是类型PrintString的一个临时对象。当程序调用for_each是,将会把vs中的每个元素一次打印到cerr中,元素之间以换行符分隔。