C++ Primer 5th 第14章 重载运算与类型转换
当运算符作用域类类型的对象时,可以通过运算符重载来重新定义该运算符的含义。重载运算符的意义在于我们和用户能够更简洁的书写和更方便的使用代码。
基本概念
重载的运算符是具有特殊名字的函数:函数名由关键词operator和跟运算符号组成。
和普通函数相同,重载的运算符也包含返回值、形参列表和函数体。运算符函数的参数和该运算符的作用对象数量一样多。一元运算符只有一个参数,二元运算符有两个。对于二元运算符来说,第一个参数对应运算符左侧运算对象,第二个参数对应运算符右侧运算对象。运算符一律不允许含有默认实参,只有一个是例外,它就是函数调用运算符operator( )。
如果重载的运算符函数是放在类内部作为类的成员函数,那么运算符的第一个运算对象隐式绑定到this指针上,因而重载的类成员函数运算符的参数数量总是比运算符运算对象总数少一。
对于运算符函数来说,要么它是类的成员函数,要么是普通的非类成员函数并且至少有一个类类型的参数。因为我们不能为内置类型重载运算符,所以普通的非类成员函数必须作用在类类型对象上,因此至少要包含一个类类型的参数。
我们只能重载现有的运算符,而不能发明新的运算符,比如不能发明operator**来表示幂运算。
有些运算符既是一元运算符(作用于一个运算对象),又是二元运算符(作用于两个运算对象),我们根据它参数的数量来区分它是一元还是二元。
运算符重载之后,其优先级和结合律保持不变。
以下运算符允许被重载:
双目算术运算符 | + (加),-(减),*(乘),/(除),% (取模) |
关系运算符 | ==(等于),!= (不等于),< (小于),> (大于),<=(小于等于),>=(大于等于) |
逻辑运算符 | ||(逻辑或),&&(逻辑与),!(逻辑非) |
单目运算符 | + (正),-(负),*(指针),&(取地址) |
自增自减运算符 | ++(自增),--(自减) |
位运算符 | | (按位或),& (按位与),~(按位取反),^(按位异或),<< (左移),>>(右移) |
赋值运算符 | =, +=, -=, *=, /= , %= , >>= |
空间申请与释放 | new, delete, new[ ] , delete[] |
其他运算符 | ( )(函数调用),->(成员访问),->*(成员指针访问),,(逗号),[ ](下标) |
上表中,总计有46个运算符,有4个重复的,分别是+,-,*,&,这四个运算符不是前置和后置的区别,而是作用的运算对象数量不同,而自增(++)和自减(--)有前置和后置区别,但运算对象的数量相同。
不能被重载的运算符有5个,分别是:作用域运算符::,成员指针访问运算符.* ,成员访问运算符.,对象大小运算符sizeof ,条件运算符?: 。
虽然前面表中给出了很多允许重载的运算符,但某些运算符不应该进行重载,因为重载后可能会丢失原有的一些属性,比如逻辑与&&运算符具有短路求值属性,重载可能使其丢失该属性,进而出现一些意想不到的问题。
另外,在重载这些运算符,也需要保持与原有内置类型一致的含义,比如你不能把++运算符的行为重载成为--运算符行为。
重载后的运算符方便我们直接对自定义类型进行直接运算,但是由于运算符其实是个特殊名字的函数,所以我们也可以直接用它的函数名来调用,例如:
date1 + data2; operator+(date1, date2);
以上两个是等价的使用方法。
选择作为成员或非成员
前面说过,重载的运算符函数要么至少含有一个类类型参数,要么是成员函数。重载的运算符函数是作为成员还是非成员有一定的准则:有些运算符函数必须作为成员函数或者必须作为非成员函数,而有的运算符函数作为成员函数或者非成员函数则更好。
对于赋值(=)、下标([ ])、调用(( ))、成员访问箭头(->)四个运算符来说,它们必须说成员函数,如果声明为非成员则编译报错,这是语法决定的。
改变对象状态的运算符或者与给定类型密切相关的运算符通常应该是成员函数。
具有对称性的运算符(二元运算符)可能任意对换两端的运算对象,例如算术、关系,它们通常应该是普通的非成员函数。
输入和输出运算符<<、>>
输入和输出运算符必须作为非成员函数,且其通常返回第一个参数的非const引用类型以便可以实现连续输出。
为什么必须是非成员函数?因为第一个参数必须是一个输出流ostream对象,而成员函数的第一个参数隐式绑定到当前对象this指针上,因此不能是成员函数。
为什么要返回非const引用菜鸟实现连续输出?因为输出运算符“<<”是一个运算符,跟算术运算符“+”一样,有着相应的规则,例如“+”运算符要求是左右都有一个运算对象,运算符使用后返回表达式的值,所以你只能写成1+2,不能写成12+或者+12,也可以写成1+2+4,这样1+2运算完后返回3,3再继续作为下一个“+”的左侧运算对象,继续表达式3+4的运算。
同样地,输出运算符“<<”要求左侧必须是一个输出流ostream对象,右侧是一个待写到ostream对象的值,这个运算符运算完毕后返回左侧对象,也就是ostream,这样的话cout<<a<<b就是先输出a,然后返回cout,继续运算就是cout<<b了。
自己重载时,不返回输出流,返回的就不是输出运算符“<<”要求的左侧对象,比如返回是一个值,然后变成‘’值<<b‘’,就不能连续输出,因为左侧不是输出流对象,输入也是这样。至于为什么要返回非const类型是因为流的状态在读取或输入之后就会改变,不存在const流。为什么要引用是因为流不允许拷贝,所以使用引用来直接将原对象返回。
输出运算符通常不应该进行格式化操作,比如输出空格、回车等。因为如果用户需要格式化可以自行添加,但如果不需要就无法删除输出运算符强行增加的格式化了,所以格式化与否交于用户控制。
输入运算符与上所述的输出运算符具有类似的性质,但输入运算符还需要进行读取状态的判断,因为输入运算符可能会失败。
算术和关系运算符
算术和关系运算符通常是被定义为非成员函数的以允许运算符左右两侧的对象进行对换。由于这些运算符不需要改变运算对象的状态,因此它们的形参应该是const引用。
算术运算符通常会计算它的两个运算对象并得到一个新对象,新对象一般是右值属性的临时对象。
相等运算符==
C++中自定义类类型通过定义相等运算符==来比较两个对象是否相等。相等运算符函数通常会逐成员比较。如何类定义了相等运算符==,那么通常也应该定义不等运算符!=,并且相等和不等运算符其中一个应该把工作委托给另一个。
赋值运算符=
赋值运算符已经在第13章中有过详细介绍。赋值运算符一般是同类型对象之间进行赋值运算,但也可以定义更多的赋值运算符进行重载,使得赋值运算符可以用别的类型来作为运算符右侧的运算对象。例如我们的vector:
vector<string> v{"hello", "world"}; v = {"a", "an", "the"};
这里vector类中有一个operator=(initializer_list<T>)的成员函数,通过该方法我们可以使用一个值的列表赋值给容器。
复合赋值运算符+=
复合赋值运算符不必非得是类的成员函数,但它相对非成员函数来说,更适合作为类的成员函数。通常复合赋值运算符返回运算符左侧的引用。
下标运算符[ ]
下标运算符必须是成员函数。表示容器的类通常会定义此方法,为与内置类型保持兼容,下标运算符通常返回元素的引用。这样下标运算符的返回值可以出现在其他运算符的左侧或右侧。另外,下标运算符通常有两个版本,一个返回const引用,一个普通引用。
递增和递减运算符++、--
在迭代器类中通常会这些这两个运算符,这两个运算符使得类可以在元素的序列中前后移动。递增和递减运算符会改变对象的状态,因此它们更应该作为成员函数。
递增和递减运算符分为前置版本后后置版本,两者之间的区分是通过形参数量来识别。前置版本的递增和递减在作为成员函数时是没有参数的,后置版本的递增和递减运算符在作为成员函数时有一个int类型的参数,该int类型参数并无实际意义,仅用于区分前后置版本。
前置和后置版本的递增和递减运算符函数除了在参数数量上不一样,在返回值类型那里也有区别。前置版本需要返回元素的引用,后置版本则返回一个临时右值。
成员访问运算符->、*
迭代器类和智能指针类中通常会定义此方法。通常箭头->运算符并不执行自己的操作,而是调用解引用运算符*来工作。
相比前面介绍的运算符重载来说,箭头访问运算符的重载有着更多的规则,具体可以参考文章:C++的各种初始化方式
函数调用运算符( )
如果类重载了函数调用运算符,我们可以像调用一个函数一样的使用类对象。例如:
struct absInt { int operator()(int val) const { return val < 0 ? -val : val; } }; int main() { int i = -42; absInt absObj; int ui = absObj(i); return 0; }
这里我们实例化了absInt的一个类对象absObj,虽然absObj不是一个函数,但由于absInt重载了函数调用运算符,因此我们可以把absObj对象当做函数一样进行调用运算。像上面代码中那样定义了调用运算符的类的对象被称作函数对象。因为该对象可以使用调用运算符进行调用,所以说这些对象的行为像函数一样。
含有状态的函数对象类
由于我们的函数对象其实是一个重载了调用运算符的类,那么该类不仅可以重载调用运算符,还能包含更多的内容,比如数据成员。我们可以定义一些数据成员,并在重载的调用运算符里使用这些数据成员。
lambda是函数对象
当编写完一个lambda表达式后,编译器会将该表达式翻译成一个匿名类的匿名对象。在编译器翻译合成的类中含有一个重载的函数调用运算符。由于默认情况下lambda不能改变所捕获的变量,因此编译器翻译合成的类中重载的函数调用运算符是const属性的,如果lambda表达式中使用了关键词mutable,那么重载的函数调用运算符则没有const属性。
如果lambda表达式没有捕获任何变量,那么编译器翻译合成的类中只有重载的调用运算符成员函数。
如果lambda表达式使用引用捕获变量,编译器翻译合成的类不需要使用数据成员来存储捕获的变量,可以直接使用变量。
如果lambda表达式使用普通拷贝捕获变量,编译器翻译合成的类则需要使用数据成员来拷贝捕获变量的副本。
标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,对每个类分别定义了一个执行命名操作的调用运算符。比如plus类中有一个重载的函数调用运算符,对plus类对象使用函数调用运算符( ),可以对运算对象执行+操作。这些类被定义为模板使得我们可以对任意类型进行相应的命名操作。
这些类包含在下面的头文件中:
#include <functional>
所有的标准库函数对象在下表中列出
算术 | 关系 | 逻辑 |
plus<T> | equal_to<T> | logical_and<T> |
minus<T> | not_equal_to<T> | logical_or<T> |
multiplies<T> | greater<T> | logical_not<T> |
divides<T> | greater_equal<T> | |
modules<T> | less<T> | |
negate<T> | less_equal<T> |
表示运算符的函数对象类常用来替换算法中的默认运算符。
可调用对象与function
在C++中存在以下几种可以使用调用运算符( )进行调用的对象:函数、函数指针、lambda表达式、bind创建的对象、重载了调用运算符的类。
调用形式:一个调用形式确定一种形式,调用形式包括返回值和传递的参数。
重载、类型转换与运算符
转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换。
类型转换运算符
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数形式如下:
operator type() const;
只要一个类型能作为返回类型,那么类型转换函数可以向除了void之外的任意类型进行定义转换,不能作为返回类型的类型包括数组类型、引用类型。
类型转换运算符没有返回值,也不接受参数,且必须是类的成员函数。由于类型转换不应该改变被转换对象的内容,所以类型转换运算符通常应该const属性的。
举个例子来说:
class SmallInt { public: SmallInt(int i = 7): val{i} { if (i < 0 || i > 255) cout << "wrong value." << endl; } operator int() const { return val; } private: int val; }; si = 4; //将4通过默认构造函数转换SmallInt类型,然后调用合成的拷贝赋值运算符operator= si + 3; //将si通过类型转换运算符转换为int类型
编译器每次只能执行一步类型转换,无法进行两步或更多步的类型转换,例如:
Obj_type::Obj_type(string s); //Obj_type有一个接受string类型的构造函数,该构造函数定义了string类型向Obj_type类型转换的规则 bool Obj_type::combine(Obj_type s); //Obj_type有一个combine的成员函数,该函数接受Obj_type类型参数 Obj_type obj; //obj是Obj_type类型的对象 string str="hello"; obj.combine(str); //正确str是一个string类型,通过构造函数隐式转换 obj.combine("world"); //错误,"world"不是一个string类型,无法一步转换
上述代码中对combine的第一次调用是正确的,因为str是string类型,可以通过构造函数进行隐式转换成一个匿名的Obj_type对象,然后用于combine函数。"world"不是一个string类型,它是字符串字面常量,需要转换成string类型,然后再从string类型转换到Obj_type才能成功调用combine函数,但由于编译器最多进行一次隐式转换,所以第二次的调用是错误的。
虽然编译器只能转换一次,但对于我们定义的类型转换运算符来说,当类型转换运算符定义的是向内置类型转换时,它允许一个例外:算术类型之间能额外隐式转换。举个例子:
SmallInt si = 3.14; //构造函数一步隐式转换 si + 3.14; //si转换为int,int再转换为double,内置算术类型,允许两步转换 si = 6.18; //double转换为int,int再转换为SmallInt,内置算术类型,允许两步转换
对于类型转换运算符函数,它不接受参数,因为它是隐式进行的,不存在调用一说,也就无法传递参数,只是需要进行类型转换时由编译器来使用。
类型转换运算符函数的声明和定义时,其函数签名(函数的类型:返回值类型和参数列表组成)中不含返回值类型,实际上类型转换运算符函数并不是不含返回值类型,只是返回值类型被用作类型转换运算符函数的函数名了。
虽然类类型允许自定义类型转换函数进行类型间的转换,但类型转换运算符往往是令人意外的,而不是让人感觉方便有用的。所以不要轻易使用类类型转换运算符,但有一种情况却是非常有用和普遍的,那就是bool类型的类型转换。但是隐式的类类型转换会带来意想不到的问题,为了防止这种情况,可以使用关键词explicit来进行限定,explicit关键词只能用于类拷贝控制成员和类型转换函数。
当使用了关键词explicit来限制类型转换函数的隐式转换时,如果我们需要进行类型转换,就必须通过显式的强制进行转换了,但存在几个例外允许隐式类型转换,这些例外是:
- if、while即do while语句的条件判断部分
- for循环的条件判断部分
- 逻辑与(&&)、或( || )、非( ! )的判断
- 条件运算符( ? : )的判断部分
总结看来就是当定义了类型转换运算符的类,其对象用于条件判断时,即使该类的类型转换运算符是explicit的也允许隐式转换。
现在我们知道了为什么在前面的章节中,可以把流输入表达式用作循环或if的判断部分了,例如:
while ( cin >> var)
这里的过程是,调用重载的输入流operator>>函数,该函数从cin中读取数据到var中,函数结束后返回输入流cin的引用,然后将cin用于条件判断,而cin中定义了bool类型转换运算符函数,可以返回cin的状态,进而达到输入表达式正确与否的判断。
避免有二义性的类型转换
如果类中含有一个以上的类型转换规则,则需要确保在类类型和目标类型之间只存在唯一的转换方式,否则代码存在二义性无法通过编译。有两种可能的情况会导致二义性,第一个例子如下:
struct B; struct A { A() = default; A(const B&); }; struct B { operator A() const; }; A f(const A&); B b; A a = f(b); //二义性错误
上面代码中对函数f的调用会产生二义性错误,因为不知道该如何从b转换到A类型,既可以使用A的构造函数来转换得到,又可以通过B的类型转换运算符得到,因此产生二义性。
如果我们想要调用f,那么只能显式调用类型转换运算符或者转换构造函数,如下:
A f(const A&); B b; A a = f(b);
上面是不同的类向同一种类型转换带来的二义性,第二种情况是单一类类型中定义了多种类型转换构造函数和类型转换运算符但参数不能精确匹配时带来的二义性,例子如下:
struct A { A(int = 0); A(double); operator int() const; operator double() const; }; void f2(long double); A a; f2(a); A a2(lg);
上面代码中,对f2的调用因为operator int()和operator double()都不能精确匹配,所以产生二义性。对a2的调用因为A(int = 0)和A(double)也都不能精确匹配,也产生了二义性。
以上两种情况都是需要极力避免的,一般的经验规则是:不要令两个及以上的类执行相同的类型转换,避免出现多个转换目标是内置算术类型的类型转换。
重载函数与转换构造函数
当我们定义了一组可以重载的函数,尤其是重载的函数之间的参数都是不同的类类型,并且这些类类型定义了多种类型转换(隐式的类型转换构造函数)会导致重载出现二义性。例子如下:
struct C { C(int); }; struct D { D(int); }; void manip(const C&); void manip(const D&); manip(10); //错误,二义性重载
上述代码会导致二义性重载。
再看一个例子:
struct C { C(int); }; struct D { D(double); }; void manip(const C&); void manip(const D&); manip(10); //错误,二义性重载
上面的代码,看似C是精确匹配,但这里仍然是错误的,还是会导致二义性重载,原因是因为C++规定,对于多个自定义类的类型转换如果都是可行的,则不考虑其转换级别,也就是说,只要多个自定义类类型之间都能转换,不论精不精确都同等对待。只有在单一类的类型转换时,才去考虑谁更精确匹配,多个类的转换比较允许编译器报二义性错误以减少负担。
函数匹配与重载运算符
重载的运算符函数虽然名字特殊,但它依然是一个函数,遵循着函数重载的匹配规则。当一个表达式中含有类类型对象,运算符会进行重载,候选函数集的规模要比直接使用运算符函数调用的规模更大。
在C++中运算符分为:一元运算符、二元运算符、三元运算符。其中三元运算符只有一个,是条件运算符( ? : ),由于该三元运算符不允许重载,因此重载的运算符运算对象一定只有一个或者两个。
对于一元运算符来说,其运算对象只有一个,当一个表达式中只有一个类对象时,该运算符必然不是内置版本,所以重载时无需考虑内置版本,也不需要考虑表达式中类对象的类型转换问题,只需要进行函数精确匹配即可。
对于二元运算符来说,其运算对象有两个,此时运算符重载需要考虑类对象的成员函数版本和非成员函数版本,还要考虑内置版本,因为类对象可能会向内置算术类型进行转换。示例如下:
class SmallInt { friend SmallInt operator+(const SmallInt&, const SmallInt&); public: SmallInt(int = 0); operator int() const { return val; } private: std::size_t val; }; SmallInt s1, s2; SmallInt s3 = s1 + s2; int i = s3 + 0; //错误,二义性重载
上述代码中存在二义性操作,因为不知道该把s3转换成int类型,还是把0转换成SmallInt类型。此处考虑了类的非成员重载版本和算术运算符的内置版本。
如果我们直接调用重载的运算符函数而不是使用运算符符号,具有相同名字的成员函数和非成员函数不会形成重载集合,因为直接调用运算符函数其语法格式对于成员和非成员函数来说并不相同,对于成员函数来说,无法直接调用,必须通过类对象使用点运算符来访问调用。对于非成员函数来说,只能直接调用,而不能通过类对象使用点运算符来访问调用。所以具有相同名字的成员函数和非成员函数不会形成重载集合。