文章目录
- 基本概念
- 直接调用一个重载的运算符函数
- 某些运算符不应该被重载
- 使用与内置类型一致的含义
- 选择作为成员或者非成员
- 输入和输出运算符
- 重载输出运算符<<
- 输出运算符尽量减少格式化操作
- 输入输出运算符必须是非成员函数
- 重载输入运算符>>
- 算术和关系运算符
- 相等运算符
- 关系运算符
- 赋值运算符
- 复合赋值运算符
- 下标运算符
- 递增和递减运算符
- 区分前置和后置运算符
- 显式地调用后置运算符
- 成员访问运算符
- 函数调用运算符
- 含有状态的函数对象类
- lambda是函数对象
- 表示lambda及相应捕获行为的类
- 标准库定义的函数对象
- 在算法中使用标准库函数对象
- transform函数
- 可调用对象与function
- 不同类型可能具有相同的调用形式
- 重载的函数与function
- 重载、类型转换与运算符
- 类型转换运算符
- 定义含有类型转换运算符的类
- 显式的类型转换运算符
- 避免有二义性的类型转换
基本概念
如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的this指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数:
错误,不能为int重定义内置的运算符
int operator+(int,int);
我们可以重载大多数运算符,但不是全部。我们只能重载已有的运算符,而无权发明新的运算符号。
对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。
直接调用一个重载的运算符函数
一个非成员运算符函数的等价调用
data1+data2; //普通的表达式
operator+(data1,data2); // 等价的函数调用
调用成员运算符函数
data1+=data2; //基于“调用”的表达式
data1.operator+=(data2); // 对成员运算符函数的等价调用
某些运算符不应该被重载
通常情况下,不应该重载逗号、取地址、逻辑与、逻辑或等运算符。
使用与内置类型一致的含义
选择作为成员或者非成员
当我们定义重载的运算符时,必须首先决定是将其声明为类的成员函数还是声明为一个普通的非成员函数。在某些时候我们别无选择,因为有的运算符必须作为成员;另一些情况下,运算符作为普通函数比作为成员更好。
%通常定义为非成员
%=通常定义为类成员,因为它会改变对象的状态
++通常定义为类成员,因为它会改变对象的状态
->必须定义为类成员,否则编译会报错
<<通常定义为非成员
&&通常定义为非成员
==通常定义为非成员
()必须定义为类成员,否则编译会报错
输入和输出运算符
IO标准库分别使用>>和<<执行输入和输出操作。对于这两个运算符来说,IO库定义了用其读写内置类型的版本,而类则需要自定义适合其对象的新版本以支持IO操作。
重载输出运算符<<
通常情况下,输出运算符的第一个形参是一个非常量ostream对象的引用。之所以ostream是非常量是因为向流写入内容会改变其状态,而该形参是引用是因为我们无法直接复制一个ostream对象。
第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。第二个形参是引用的原因是我们希望避免复制实参,而之所以该形参可以是常量是因为(通常情况下)打印对象不会改变对象的内容。
为了与其他输出运算符保持一致,operator<<一般要返回它的ostream形参。
示例代码:
ostream& operator<<(ostream&os,const Sales_data &item){//输出内容os<<item.isbn()<<" "<<item.units_sold;//返回ostreamreturn os;
}
输出运算符尽量减少格式化操作
用于内置类型的输出运算符不太考虑格式化操作,尤其不会打印换行符,用户希望类的输出运算符也像如此行事。如果运算符打印了换行符,则用户就无法在对象的同一行内接着打印一些描述性的文本了。相反,令输出运算符尽量减少格式化操作可以使用户有权控制输出的细节。
输入输出运算符必须是非成员函数
与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,它们的左侧运算对象将是我们的类的一个对象:
Sales_data data;
data<<cout; //如果operator<<是Sales_data的成员
因此,如果我们希望为类自定义IO运算符,则必须将其定义为非成员函数。IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元。
重载输入运算符>>
通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。 第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
输入时的错误:
- 当流含有错误类型的数据时读取操作可能失败。
- 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。
通常情况下,输入运算符只设置failbit。除此之外,设置eofbit表示文件耗尽,而设置badbit表示流被破坏。
算术和关系运算符
通常,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
算术运算符通常会计算它的两个运算对象并得到一个新值,这个值有别于任意一个运算对象,常常位于一个局部变量之内,操作完成后返回该局部变量的副本作为其结果。如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时最有效的方式是使用复合赋值来定义算术运算符:
Sales_data operator+(const Sales_data &lhs,const Sales_data &rhs){Sales_data sum = lhs;//把lhs的数据成员拷贝给sumsum+=rhs; //使用复合赋值运算符将rhs加到sum中return sum;
}
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。
相等运算符
通常情况下,c++中的类通过定义相等运算符来检验两个对象是否相等。也就是说,它们会比较对象的每一个数据成员,只有当所有对应的成员都相等时才认为两个对象相等。
关系运算符
定义了相等运算符的类也常常(但不总是)包括关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义operator<会比较有用。
通常情况下,关系运算符应该:
- 定义顺序关系,令其与关联容器中对关键字的要求一致。
- 如果类同时也含有 == 运算符的话,则定义一种关系令其与 == 保持一致。特别是,如果两个对象是 != 的,那么一个对象应该 < 另外一个。
如果存在唯一一种逻辑可靠的 < 定义,则应该考虑为这个类定义 < 运算符。如果类同时还包含 == ,则当且仅当 < 的定义和 == 产生的结果一致时才定义 < 运算符。
赋值运算符
我们可以重载赋值运算符,不论形参的类型是什么,赋值运算符都必须定义为成员函数。
示例代码:
复合赋值运算符
复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。为了与内置类型的复合赋值保持一致,类中的复合赋值运算符也要返回其左侧运算对象的引用。
示例代码:
赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。
下标运算符
表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[ ]。
下标运算符必须是成员函数。
为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。进一步,我们最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
示例代码:
递增和递减运算符
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
区分前置和后置运算符
前置和后置版本使用的是同一个符号,意味着其重载版本所用的名字将是相同的,并且运算对象的数量和类型也相同。为了解决这个问题,后置版本接受一个额外的(不被使用的)int类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。尽管从语法上来说,后置函数可以使用这个额外的形参,但是在实际过程中通常不会这么做。这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
后置版本示例代码:
显式地调用后置运算符
StrBlobPtr p(a1);
p.operator++(0); //调用后置版本的operator++
p.operator++(); //调用前置版本的operator++
成员访问运算符
箭头运算符 -> 必须是类的成员。解引用运算符 * 通常也是类的成员。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
函数调用运算符
如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。
函数调用运算符必须是成员函数,一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
如果类定义了调用运算符,则该类的对象称作函数对象。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。
class absInt{int operator()(int val)const{return val < 0 ? -val : val;}
}int i=-42;
absInt absObj; //含有函数调用运算符的对象
int ui = absObj(i); //将i传递给absObj.operator()
即使absObj只是一个对象而非函数,我们也能“调用”该对象。调用对象实际上是在运行重载的调用运算符。该例中,该运算符接受一个int值并返回其绝对值。
含有状态的函数对象类
和其他类一样,函数对象类除了operator()之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。
示例代码:
#ifndef PRINTSTRING_H
#define PRINTSTRING_H#include<iostream>
#include<string>
using namespace std;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;
};
#endif
测试代码:
void testPrintString() {PrintString p1;p1("hello");PrintString p2(cout, '!');p2("hello");
}
输出结果:
hello hello!
示例代码:
//IntCompare类
class IntCompare {
public:IntCompare(int v) :val(v) {}bool operator()(int v) { return val == v; }
private:int val;
};//测试代码vector<int>vec = { 1,2,3,2,1 };int oldVal = 2;int newVal = 200;IntCompare icmp(oldVal);replace_if(vec.begin(),vec.end(),icmp,newVal);for (auto a:vec) {cout << a << " ";}
输出结果:
1 200 3 200 1
lambda是函数对象
在lambda表达式产生的类中含有一个重载的函数调用运算符,例如:
[](const string & a , const string & b){return a.size()<b.size();}
其行为类似于下面这个类的一个未命名对象:
class ShortString{
public:bool operator()(const string & a , const string & b) {return a.size()<b.size();}
}
该类可被如下调用:
stable_sort(words.begin(),words.end(),ShortString());
当stable_sort内部的代码每次比较两个string时就会“调用”这一对象,此时该对象将调用运算符的函数体,判断第一个string的大小小于第二个时返回true。
表示lambda及相应捕获行为的类
当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用所引的对象确实存在。因此编译器可以直接使用该引用而无须在lambda产生的类中将其存储为数据成员。
如果通过值捕获的变量被拷贝到lambda中,这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
示例如下:
[sz](const string & a){return a.size()>=sz;}
该lambda表达式产生的类将形如:
class SizeComp{
public:SizeComp(size_t n):sz(n){} //该形参对应捕获的变量//该调用运算符的返回类型、形参和函数体都与lambda一致bool operator(){const string & s}const{return s.size()>=sz;}
private:size_t sz;//该数据成员对应通过值捕获的变量
}
这个类含有一个数据成员以及一个用于初始化该成员的构造函数。这个合成的类不含有默认构造函数,因此要想使用这个类必须提供一个实参:
auto wc = find_if(words.begin(),words.end(),SizeComp(sz));
lambda表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝 / 移动构造函数则通常要视捕获的数据成员类型而定。
标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,plus类定义了一个函数调用运算符用于对一对运算对象执行+的操作;modulus类定义了一个调用运算符执行二元的%操作;equal_to类执行==。
示例代码
plus<int>intAdd;negate<int>intNegate;//negate<int>可对int取反int sum = intAdd(10,20);cout << sum << endl;sum = intNegate(intAdd(10, 20));cout << sum << endl;sum = intAdd(10, intNegate(20));cout << sum << endl;
输出结果:
30
-30
-10
标准库函数对象,下表所列的类型定义在functional头文件中
在算法中使用标准库函数对象
表示运算符的函数对象常用来替换算法中的默认运算符。例如,默认情况下,排序算法使用operator<将序列按照升序排列,如果要执行降序排列的话,我们可以传入一个greater类型的对象。该类将产生一个调用运算符并负责执行待排序类型的大于运算。例如,如果svec是一个vector<string>
sort(svec.begin(),svec.end(),greater<string>());
示例代码:
vector<int>vec{ 1,3,5,7,9,2,4,6,8,10 };//统计大于4的值有多少个int num = count_if(vec.begin(),vec.end(),bind2nd(greater<int>(),4));cout << num << endl;vector<string>sv{"hello","hello","hi","nihao","nihao"};//找到第一个不等于hello的字符串auto its = find_if(sv.begin(),sv.end(), bind2nd(not_equal_to<string>(), "hello"));cout << *its << endl;//将所有的值乘以2transform(vec.begin(), vec.end(), vec.begin(), bind2nd(multiplies<int>(), 2));for (auto a:vec) {cout << a << " ";}cout << endl;
输出结果:
6
hi
2 6 10 14 18 4 8 12 16 20
transform函数
transform函数的作用是:将某操作应用于指定范围的每个元素。transform函数有两个重载版本:
transform(first,last,result,op);
first是容器的首迭代器,last为容器的末迭代器,result为存放结果的容器,op为要进行操作的一元函数对象或sturct、class。
transform(first1,last1,first2,result,binary_op);
first1是第一个容器的首迭代 器,last1为第一个容器的末迭代器,first2为第二个容器的首迭代器,result为存放结果的容器,binary_op为要进行操作的二元函数 对象或sturct、class。
可调用对象与function
c++语言中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。
和其他对象一样,可调用的对象也有类型。例如,每个lambda有它自己唯一的(未命名)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定,等等。
不同类型可能具有相同的调用形式
上面这些可调用对象分别对其参数执行了不同的算术运算。尽管它们的类型各不相同,但是共享同一种调用形式:int(int,int)
我们可以定义一个函数表用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。
函数表可以很容易的通过map来实现。我们的map可以定义成如下形式:
//构建从运算符到函数指针的映射关系,其中函数接受两个int、返回一个int
map<string,int(*)(int,int)>binops;
我们可以按照下面的形式将add的指针添加到binops中:
binops.insert({"+",add});//{"+",add}是一个pair
但是我们不能将mod或者divide存入binops中,因为mod是个lambda表达式,而每个lambda有它自己的类类型,该类型于存储在binops中的类型不匹配。
binops.insert({"%",mod});//错误:mod不是一个函数指针
我们可以使用一个名为function的新的标准库类型解决上述问题,function定义在functional头文件中,下表列出了function定义的操作:
function是一个模板,和我们使用过的其他模板一样,当创建一个具体的function类型时我们必须提供额外的信息。示例如下:
function<int(int,int)>
在这里我们声明了一个function类型,它可以表示接受两个int、返回一个int的可调用对象。
function<int(int,int)>f1 = add;
function<int(int,int)>f1 = divide();
function<int(int,int)>f1 = [](int i,int j){return i*j};//f1(4,2): 6
//f2(4,2): 2
//f3(4,2): 8
使用这个function我们可以重新定义map:
map<string,function<int(int,int)>>binops;
我们能把所有可调用对象,包括函数指针、lambda或者函数对象在内,都添加到这个map中:
map<string,function<int(int,int)>>binops={
{"+",add}, //函数指针
{"-",std::minus<int>()}, //标准库函数对象
{"/",divide()}, //用户定义的函数对象
{"*",[](int i,int j){return i*j}}, //未命名的lambda
{"%",mod}, //命名的lambda
}
调用操作:
binops["+"](10,5); //调用add(10,5)
重载的函数与function
我们不能(直接)将重载函数的名字存入function类型的对象中。
重载、类型转换与运算符
类型转换运算符
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:
operator type()const;
其中type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。因此我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。
定义含有类型转换运算符的类
class SmallInt {
public:SmallInt(int i = 0) :val(i) {}operator int()const { return val; }void print() { cout << val << endl; }
private:size_t val;
};//测试代码:SmallInt s1;s1 = 4;s1.print();cout << s1 + 5 << endl;s1 = 3.5;s1.print();cout << s1 + 5 << endl;
输出结果:
4
9
3
8
因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:
显式的类型转换运算符
当类型转换运算符是显式的时候,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。
class SmallInt {
public:SmallInt(int i = 0) :val(i) {}explicit operator int()const { return val; }void print() { cout << val << endl; }
private:size_t val;
};//测试代码SmallInt s1;s1 = 4;s1.print();cout << int(s1) + 5 << endl;s1 = 3.5;s1.print();cout << int(s1) + 5 << endl;
如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
向bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。
避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码很可能会具有二义性。
在两种情况下,可能产生多重转换路径。
- 两个类提供相同的类型转换,例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。
- 类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。