第十四章 重载运算与类型转换
14.1 基本概念
重载运算符:他们的名字由operator加上运算符号组成。跟其他函数一样,他们也包括返回类型,参数类型和函数体
重载运算符的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个
注意:除了重载函数调用符以外,其他的重载运算符不能含有默认实参。
如果一个运算符函数是成员函数,则它的第一个运算对象绑定到隐式的this指针上,因此,成员运算符函数的参数数量比运算符的运算对象总数少一个
注意:对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。如果都是内置类型,则是错误的。
//错误:不能为int重定义内置的运算符
int operator+(int,int);
这就意味着当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。
下表列出了可以重载的运算符
对于重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。
某些运算符不应该被重载
某些运算符指定了运算对象求值的顺序。
因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。
比如,逻辑与运算符和逻辑或运算符以及逗号运算符的运算对象求值顺序规则无法保留下来。同时,逻辑与和逻辑或运算符的重载版本也无法保留内置运算符的短路求值属性。
因此,对于以上这些运算符,不建议重载他们。
还有一个原因使得我们一般不重载逗号运算符和取地址运算符:c++ 语言已经定义了这两种运算符用于类类型对象时的特殊含义,如果我们再重载他们,则将导致非常诡异的行为。
注意:通常情况下,不应该重载逗号,取地址,逻辑与和逻辑或运算符
- 如果一个类定义了operator==,则该类也应该定义一个operator!=
- 如果类包含一个内在的单序比较操作,则定义operator<,如果类有了operator<,则应该还有其他的关系操作
- 如果含有算数运算符或者位运算符,则最好也提供对应的复合赋值运算符。
- 赋值,下标,调用和成员访问运算符必须是成员函数
- 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增,递减和解引用运算符,通常应该是成员
- 具有对称性的运算符可能转换任意一段的运算对象,例如算数,相等,关系和位运算符等,因此他们通常应该是普通的非成员函数
14.2 输入和输出运算符
14.2.1 重载输出运算符<<
通常情况下,输出运算符的第一形参是非常量的ostream引用,非常量,是因为需要修改ostream对象的内容,引用是,ostream不能复制。
第二个形参一般为一个常量的引用,他是我们想要打印的类类型。为引用是为了避免复制,为常量是因为打印不改变对象大小。
为了保持一致,operatror<<一般返回它的ostream形参。
注意:输出运算符应尽量减少格式化操作,格式化操作由调用者来决定,这样才能够更加灵活
输入输出运算符必须是非成员函数
如果不是非成员函数,那么输出运算符的第一个对象必须是这个类的对象。如下的形式
Sales_data data;
data << cout;//operator<< 是Sales_data的成员函数
而我们正常使用,通常为如下形式:
cout << data;
这就表示,重载的operator还必须是cout的成员函数,然而我们不能向cout添加类成员。
因此,只能定义为非成员函数的形式。当然IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元。
14.2.2 重载输入运算符>>
输入运算符的第一个形参为非常量输入流的引用,因为需要修改流中的状态,所以为非常量,因为不能复制,所以为引用
输入运算符的第二个形参为非常亮的要读入的对象的引用,因为需要读入,所以为非常量,且为引用
输入时错误
在执行输入的时候,可能发生如下的错误:
- 当流中含有错误类型的数据时,读取操作可能失败。
- 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
如果在错误发生前,对象已经读入了部分内容,那么在错误发生后,应该将对象重置为一个合法的状态。这样可以保护使用者受输入错误的影响。
14.3 算术和关系运算符
通常情况下,我们把算术运算符和关系运算符定义为非成员函数以允许对左侧或右侧的运算对象进行转换。
因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用
通常情况下,类定义了一个算术运算符,也会定义一个相应的复合运算符,然后使用这个复合运算符来实现对应的算术运算符
14.3.1 相等运算符
通常情况下,c++中类通过定义相等运算符来检验两个对象是否相等。也就是说,他们会比较对象的每一个数据成员,只有当所有对应的成员都相等时才认为两个对象相等。
-
通常情况下,相等运算符应该具有传递性,如果ab bc,那么a == c
-
如果类定义了operator==,这个类也应该定义operato!=
注意:如果某个类在逻辑上有相等性,则该类应该定义operator==,这样做可以使得用户更容易使用标准库算符来处理这个类
14.3.2 关系运算符
定义了相等运算符的类,也常常包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用。
通常情况下关系运算符应该:
- 定义顺序关系,令其与关联容器中对关键字的要求一致;并且
- 如果类同时含有运算符的话,则定义一种关系令其与保持一致。特别是,如果两个对象是!=的,那么一个对象应该<另外一个对象
注意:如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符
14.4 赋值运算符
注意:我们可以重载赋值运算符。无论形参的类型是什么,赋值运算符都必须定义为成员函数,返回左侧运算符对象的引用。
复合赋值运算符不非得是类的成员函数,不过我们还是倾向于把复合赋值在内的所有赋值运算符都定义在类的内部。
14.5 下标运算符
operator[]
注意:下标运算符必须是成员函数
为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。进一步,我们最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
14.6 递增和递减运算符
在迭代器类中通常会实现递增和递减运算符,这两种运算符使得类可以在元素的序列中前后移动。c++ 语言并不要求递增和递减运算符必须是类的成员,但是因为他们改变的正好是所操作的对象的状态,所以建议将其设定为成员函数。
注意:为了和内置版本保持一致,前置运算符应该返回递增或递减后对象的引用
区分前置和后置运算符
因为普通的重载无法区分,前置和后置的区别。所以,规定后置版本接受一个额外的(不被使用)int类型的形参。
当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。
这个额外的形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要参与运算。
注意:为了保持内置版本的一致,后置运算符应该返回对象的原始值,返回的形式是一个值而非引用
14.7 成员访问运算符
注意:箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
对箭头运算符返回值的限定
对于形如point->mem的表达式来说,point必须指向类对象的指针或者是一个重载了operator->的类的对象。根据point类型的不同,point->mem 分别等价于
(*point).mem;//point是一个内置的指针类型
point.operator()->mem;//point是类的一个对象
除此之外,代码都将发生错误。point->mem的执行过程如下:
-
如果point是指针,则我们应用内置的箭头运算符,表达式等价于(*point).mem 首先解引用该指针,然后从所得的对象中获取指定的成员。如果point所指的类型没有名为mem的成员,程序会发生错误
-
如果point是定义了operator->的类的一个对象,则我们使用point.operator->()的结果来获取mem。其中,如果该结果是一个指针,则执行第一步;如果结果本身含有重载的operator->() ,则重复调用当前步骤。最终,当这一个过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。
注意:重载的箭头运算符必须返回类的指针或者定义了箭头运算符的某个类的对象。
14.8 函数调用运算符
注意:函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
如果类定义了调用运算符,则该类的对象称为函数对象。
14.8.1 lambda是函数对象
当我们编写了一个lambda之后,编译器将表达式翻译成一个未命名类的未命名对象。
而这个未命名的类,就重载了函数调用运算符。
默认情况下,由lambda产生的类当中的函数调用运算符是一个const成员。如果lambda被声明为可变的,则调用运算符就不是const的。
表示lambda及相应捕获行为的类
如我们所知,当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时,引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须再lambda产生的类中将其存储为数据成员。
相反,通过值捕获的变量被拷贝到lambda中。因此,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的值来初始化数据成员。
注意:lambda表达式产生的类不含有默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝、移动构造函数则通常要视捕获的数据成员类型而定,见13.1.6小节
14.8.2 标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符、逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。见下表:
注意:标准库规定其函数对象对于指针同样适用。我们之前曾介绍过比较两个无关指针将产生未定义的行为,然而我们可能会希望通过比较两个指针的内存地址来sort指针的vector。直接这么做将产生未定义的行为,因此,我们可以使用一个标准库函数对象来实现该目的:
vector<string *> nameTable;
//错误:nameTable中的指针彼此之间没有关系,所以<将产生未定义的行为
sort(nameTable.begin(),nameTable.end(),[](string *a,string *b){return a < b;})
//正确:标准库规定指针的less是定义良好的
sort(nameTable.begin(),nameTable.end(),less<string*>());
14.8.3 可调用对象和function
c++中的可调用对象:函数,函数指针,lambda,bind创建的对象,以及重载了函数运算符的类
和其他的对象一样,可调用的对象也有类型.例如,每个lambda有他唯一的类类型;函数及函数指针的类型则由其返回类型和实参类型决定,等等.
然而,两个不同类型的可调用对象却可能共享同一种调用形式.调用形式指明了调用返回类型以及传递给调用的实参类型.一种调用形式对应一个函数类型.例如:
int(int,int)
因为不同的调用对象,可能有不同的类型,但是可以有相同的调用形式,对调用形式的抽象,标准库提供了function模板.如下表
functin模板在创建的时候,需要传递一个调用形式.如下:
function<int(int,int) f = add;//add是一个函数指针
重载的函数与function
考虑下面的代码
int add(int i,int j){return i+j;}
Sales_data add(const Sales_data &,const Sales_data&);
map<string,function<int(int,int)>> binops;
binops.insert({"+",add});//错误,add到底指向哪一个函数????
解决上面问题的一种方法是存储函数指针,而非函数的名字:
int (*fp)(int,int) = add;//指针指向的add是接收两个int的版本
binops.insert({"+",fp});
另外一种解决方法是:
binops.insert({"+",[](int a,int b){return add(a+b);}});
14.9 重载,类型转换与运算符
转换构造函数和类型转换运算符共同定义了类类型转换.
14.9.1 类型转换运算符
类型转换运算符格式:
operator type() const;
其中type表示某种类型.type可以是任意类型(除void类型),只有这种类型可以作为函数的返回类型.所以不允许转换成数组或者函数类型,但允许转换成指针或者引用类型
类型转换运算符,没有形参,没有返回类型,必须为类的成员函数,类型转换运算符通常不应该转换对象的内容,因此,类型转换运算符一般被定义为const成员.
class SmallInt{
public:
SmallInt(int i = 0):val(i){
if(i < 0 || i > 255)
throw std::out_of_range("Bad SamllInt value");
}
operator int() const {return val;}
private:
std::size_t val;
};
SmallInt定义了向类类型转换的规则,也定义了从类类型转换成其他类型的规则.
类型转换运算符可能产生意外结果
在实践中,类很少提供类型转换运算符.在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助.然而这条经验法则则存在一种例外情况,对于类来说,定义向bool类型转换还是比较普遍的现象.
但是会引入一种问题,思考下面的代码
int i = 42;
cin << i;//如果向bool的类型转换不是显示的,则该代码在编译器看来是合法的.
这段程序视图将输出运算符作用于输入流.因为istream本身并没有定义<<,所以本来代码应该产生错误.然而,该代码能使用istream的bool类型转换运算符将cin转换成bool,而这个bool值会被提升为int并作用内置的左移运算符到左侧的运算对象上.
这样提升后的bool值,最终会被左移42个位置.这一结果与我们的预期完全不同.
显式的类型转换运算符
为了防止这种情况发生,c++11新标准引入了显式的类型转换运算符
class SmallInt{
public:
explicit operator int() const{
return val;
}
//...
};
和显式构造函数一样,编译器也不会将一个显式的类型转换运算符用于隐式类型转换:
SmallInt si = 3;
si + 3;//错误:此处需要隐式的类型转换,单类的运算符是显式的
static_cast<int>(si)+3;
该规则存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它.换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式的执行:
- if,while,do语句的条件部分
- for语句头的条件表达式
- 逻辑非运算符,逻辑或运算符,逻辑与运算符的运算对象
- 条件运算符的条件表达式
注意:向bool类型的转换通常用在条件表达式中,因此operator bool 一般定义为explicit的
14.9.2 避免二义性的类型转换
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个即两个以上转换源或转换目标是算数类型的转换
思考下面的例子:
struct B;
struct A{
A() = default;
A(const B&);//把B转换成A
};
struct B{
operator A() const;//也是吧一个B转化成A
//...
};
A f(const A&);
B b;
A a = f(b);//二义性错误:含义是f(B::operator A())
// f(A::A(const B&))?
因为上面存在两种转换方式:1.调用B为参数的A的构造函数;2.也可以使用B中把B转换成A的类型转换运算符.
如果确实需要某个调用,需要显式的使用,如下:
A a1 = f(b.operator A());
A a2 = f(A(b));
二义性与转换目标为内置类型的多重类型转换
另外如果类定义了一组类型转换,他们的转换源类型本身可以通过其他类型转换联系在一起,则同样会产生二义性的问题.最简单也是最困扰我们的例子就是类中定义了多个参数都是算术类型的构造函数,或者转换目标都是算术类型的类型转换运算符.如下:
struct A{
A(int = 0);//最好不要创建两个转换源都是算术类型的类型转换
A(double );
operator int() const;//最好不要创建两个转换对象都是算术类型的类型转换
operator double() const;
};
void f2(long double);
A a;
f2(a);//二义性错误:含义是f(A::operator int())
//还是 f(A::operator double())
long lg;
A a2(lg);//二义性错误:含义是A::A(int)还是A::A(double)
调用f2及初始化a2 的过程之所以产生二义性,根本原因是他们所需的标准类型转换级别一致.当我们使用用户定义的类型转换时,如果转换过程包含标准类型的转换,则标准类型的转换级别将决定编译器选择最佳的匹配
short s = 42;
//把short提升为int,优于把short转换成double的操作.
A a3(s);//使用 A::A(int)
注意:当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个.
提示:类型转换与运算符
要想正确地设计类的重载运算符,转换构造函数以及类型转换函数,必须加倍小心.尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性.以下的经验规则可能对我们有帮助:
- 不要令两个类执行相同的类型转换:如果Foo类有一个接收Bar类对象的构造函数,则不要在Bar类中再定义目标是Foo类的类型转换运算符
- 避免转换目标是内置算术类型的类型转换.特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
a. 不要再定义接受算术类型的重载运算符.如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符.
b. 不要定义转换到多种算术类型的类型转换.让标准类型转换完成向其它算术类型转换的工作.总得来说就是:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些"显然正确"的非显式构造函数.
重载函数与转换构造函数
考虑下面的例子:
struct C{
C(int);
//...
};
struct D{
D(int);
};
void manip(const C&);
void manip(const D&);
manip(10);//二义性,含义是manip(C(10))还是manip(D(10))
注意:当我们调用重载的函数时,如果两个或多个类型转换都提供了同一种可行的匹配,则这些类型转换一样好.
重载函数与用户定义的类型转换
考虑下面的代码
struct E{
E(double);
//其他成员
};
void manip2(const C&);
void manip2(const E&);
manip2(10);//含义是manip2(C(10)),还是manip(E(double(10)))
注意:当调用重载函数时,如果两个用户定义的类型转换都提供了可行匹配,则我们认为这些类型转换一样好.在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别.只有当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换.
函数匹配于重载运算符
考虑下面的代码
class SmallInt{
friend
SmallInt operator+(const SamllInt&,const SmallInt &);
public:
SmallInt(int = 0);
operator int() const {return val;}
private:
std::size_t val;
};
SmallInt s1,s2;
SmallInt s3 = s1 + s2; //使用重载的operator+
int i = s3 + 0;//二义性错误
第二条加法语句具有二义性:因为我们可以把0转换成SmallInt,然后使用SmallInt的+;或者把s3转换成int,然后对两个int执行内置的加法运算.
注意:如果我们对同一个类提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符于内置运算符的二义性问题.
本章完