14.1基本概念
重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。
重载运算符有以下几个要遵守的原则:
- 重载运算符函数的参数数量与该运算符作用的运算对象数量一样多(比如重载加号运算符,则参数个数应该为两个)。
- 除了重载函数调用运算符()之外,其他的重载运算符函数均不能含有默认形参。
- 如果一个重载运算符函数是类的成员函数,则它的第一个运算对象(即函数的第一个参数)会绑定到this指针上。
- 对于一个重载运算符函数,它或者是类的成员(此时第一个运算对象绑定到this指针,也是类类型),或者至少有一个类类型的参数,不能全部是内置类型。
- 我们只能重载已有的运算符而无权发明新的运算符。比如operator**是错误的。
- 对于一个重载的运算符来说,其优先级和结合律与对应的内置类型一致。
有些运算符可以被重载,有些不可以,大部分运算符都可以被重载,不能被重载的运算符有四种(p491表格):
:: | .* | .(点号) | ? : |
---|
直接调用一个重载的运算符函数
对于重载运算符,我们可以有以下两种使用方式
data1 + data2; //间接调用
operator+(data1, data2); //重载运算符如果不是类的成员函数,则直接通过函数名调用
data1.operator+(data2); //重载运算符如果是类的成员函数,则通过对象调用器成员函数
某些运算符不应该被重载
要特别注意,重载运算符函数无法保留原运算符的求值顺序,所以对于有求值顺序的运算符,均不应该对其重载(虽然编译器不会禁止你重载它),这类运算符有:逻辑与&&,逻辑或||,逗号运算符。特别是逻辑与&&和逻辑或||,重载它们不但会失去求值顺序,而且也无法保留他们的短路求值属性。
另外我们也不应该重载取址运算符和逗号运算符的原因,是c++语言已经定义了其对于类类型的特殊含义,比如&data就是取得对象的地址,如果再重载取值运算符,将可能导致类的用户错误的使用此类。
总结来说,我们不应该重载逗号运算符、取值运算符、逻辑或、逻辑与。
使用与内置类型一致的含义
虽然我们可以随意重载运算符,但是为了类更好被使用,我们最好应该使重载的运算符的行为与其对内置类型的行为一致。比如重载加号+,则应该使其用于两个对象的加法(比如复数类型的加法),而不是用于减法。
通常有以下几个关于重载运算符的良好习惯:
- 如果某个类重载了==,则也应该为其重载!=操作符。
- 如果一个类重载了一个单续比较操作比如<,则最好也应该为它重载其他关系操作符。
- 定义了算数运算(或位运算)与赋值操作,则最好也定义一个复合赋值操作
- 重载逻辑运算符应该让其返回bool值,重载算数运算符应该让其返回同类型的对象,重载赋值或者复合赋值运算应该让它返回左侧运算对象的一个引用。
选择作为成员还是非成员
将重载运算符定义为类的友元函数还是定义为类的成员函数,可以遵循下面的准则:
- 赋值(=)下标([ ])调用(( ))和成员访问箭头(->)运算符必须是类的成员函数。
- 复合赋值运算符一般应该是成员函数,但是这并不是强制的,这与赋值运算符不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,比如递增、递减、解引用,通常应该定义为类的成员。
具有对称性的运算符可能转换任意一段的运算对象,例如算数、关系和位运算符等,都应该定义为类的非成员函数。
值得注意的一点是,如果把重载操作符定义为非成员函数,其必须有一个形参是类类型。
14.2输入与输出操作符
我们可以为自定义类型重载<< 和>>操作符,使其可以更好进行IO操作。
14.2.1重载输出操作符
为了与其他输出运算符保持一致,通常重载输出操作符的第一个形参应该是非const的ostream对象的引用(ostream类型无法被拷贝,所以必须是引用类型,要进行输出改变流状态,所以是非const的),所以重载输出操作符一定是类的非成员函数。另外返回值应该也是一个ostream的引用类型,这样,才可以满足cout << s1 << s2的输出方式。所以重载输出操作符的形式通常如下:
struct Test
{
friend ostream &operator<<(ostream &, const Test &t);
void display();
int i = 0;
double d = 0.0;
string s = "test";
};
ostream &operator<<(ostream &output, const Test &t)
{
output << t.i << " " << t.d << " " << t.s;
return output;
}
Test t1, t2, t3;
cout << t1 << endl << t2 << endl << t3; //可以向普通的IO操作一样输出Test类型
另外需要注意的一点是,在重载输出运算符时应该尽量减少格式化操作,这样才更便于类的使用者更好的控制格式化输出。
重载输出运算符>>
基于类似的原因,重载输入运算符的第一个形参也必须是流的引用,第二个形参是需要读入到的非常量对象的引用,所以此函数也一定是类的非成员函数;而且其返回值也应该是流的引用。但是其与输出运算符重载最大的不同在于,它要处理输入可能失败的情况,当读取发生错误时,输入运算符应该负责从错误中恢复。下面是一个简单的事例:
istream &operator>>(istream &input, Test &t)
{
input >> t.i >> t.d >> t.s;
//如果输入失败,则将t置为空值,这只是一种简单的处理方法
if (!input)
t = Test();
return input;
}
14.3算数与关系运算符
算数运算符
算数运算符通常会计算它的两个运算对象并得到一个新值,这个值有别于任意一个运算对象,所以它的返回值通常应该是非引用类型。
相等运算符
通常相等运算符和应该具有传递性,即a==b,b==c,则应该也有a==c。另外==和!=应该同时定义,且其中一个运算符应该把工作委托给另一个运算符函数。
关系运算符
一帮定义了相等运算符的类也会定义关系运算符,特别是小于运算符(<)。而且通常定义了一种单序关系操作符,则也应该其他关系操作符。
特别注意的一点时,关系运算符的逻辑必须与相等运算符匹配,如果两个对象是不相等的,那么其中一个对象必然小于另一个。
相等运算符与关系运算符通常应该定义为类的非成员函数。
以上三种运算符的重载示例如下:
struct Test
{
friend Test operator+(const Test &lht, const Test &rht);
friend bool operator==(const Test &lht, const Test &rht);
friend bool operator!=(const Test &lht, const Test &rht);
friend bool operator<(const Test &lht, const Test &rht);
void display();
int i = 0;
double d = 0.0;
string s = "test";
};
//重载+操作符,其返回值为非引用类型
Test operator+(const Test &lht, const Test &rht)
{
Test ret = lht;
ret.i += rht.i;
ret.d += rht.d;
ret.s += rht.s;
return ret;
}
//重载==操作符
bool operator==(const Test &lht, const Test &rht)
{
return lht.i == rht.i && lht.d == rht.i && lht.s == rht.s;
}
//重载!=操作符,其将工作委托给operator==函数来实现
bool operator!=(const Test &lht, const Test &rht)
{
return !(rht == lht);
}
//重载小于操作符,其逻辑与==的逻辑相匹配
bool operator<(const Test &lht, const Test &rht)
{
return lht.i < rht.i;
}
14.4赋值运算符
赋值运算符必须定义类的成员函数。其应该返回左侧运算对象的引用。另外赋值运算可以使用别的类型作为右侧运算对象,因为只需要有一个参数为类类型就可以了
重载了赋值运算符和算数运算符(或位运算符),通常也应该重载相应的复合赋值运算符,且复合赋值运算符通常应该定义为类的成员函数(非强制)。
代码示例如下:
struct Test
{
friend Test operator+(const Test &lht, const Test &rht);
Test & operator=(const Test &rht);
Test & operator=(std::tuple<int, double, string> t);
Test & operator+=(const Test &rht);
void display();
int i = 0;
double d = 0.0;
string s = "test";
};
//重载加法运算符
Test operator+(const Test &lht, const Test &rht)
{
Test ret = lht;
ret.i += rht.i;
ret.d += rht.d;
ret.s += rht.s;
return ret;
}
//重载赋值运算符,返回值为左侧对象的引用
Test & Test::operator=(const Test &rht)
{
i = rht.i;
d = rht.d;
s = rht.s;
return *this;
}
//重载赋值运算符,注意其右侧对象是一个tuple类型
Test & Test::operator=(std::tuple<int, double, string> t)
{
i = get<0>(t);
d = get<1>(t);
s = get<2>(t);
return *this;
}
//重载相应复合赋值运算符
Test & Test::operator+=(const Test &rht)
{
i += rht.i;
d += rht.d;
s += rht.s;
return *this;
}
Test t1, t2;
t1 = t2;
t1 = make_tuple(0, 1.3, "test1" ); //正确,可以用tuple类型对Test对象进行赋值
t1 += t2;
14.5 下标运算符
重载下标运算符必须是类的成员函数。下标运算符通常以访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值语句的任何一端。进一步,我们希望定义下标运算符的常量和非常量版本。
class StrVec
{
public:
std::string &operator[](std::size_t n) { return elements[n]; }
//定义了下标操作的const版本,这样就无法对const对象进行赋值操作
const std::string &operator[](std::size_t n) const { return elements[n]; }
private:
std::string *elements; //为一个指向数组首元素的指针
};
14.6 递增和递减运算符
由于递增与递减操作符改变的是对象的内部状态,所以通常应该将重载操作符定义为类的成员函数。另外要注意的一点是,定义递增和递减操作符的类应该同时定义前置版本和后置版本。
定义前置递增/递减运算符
为了与内置版本一致,前置运算符应该返回改变状态后对象的引用,其通常用于功能类似迭代器的类中。另外要注意的一点是:通过递增递减操作符改变对象状态之前应该先确定对象状态改变后是否合法(比如是否溢出或者越界)。
class StrVecPtr
{
public:
StrVecPtr& operator++();
StrVecPtr& operator--();
private:
size_t _curr; //下标位置
};
StrVecPtr& StrVecPtr::operator++()
{
//确认下标是否到了超出末端位置
check(_curr);
++_curr;
return *this;
}
StrVecPtr& StrVecPtr::operator--()
{
//确认下标是否为首元素
check(_curr);
--_curr;
return *this;
}
区分前置和后置运算符
后置版本为了与前置版本进行区分,其额外接受一个int类型的形参。这个形参的唯一作用是区分前置版本和后置版本的函数,而不是真的要在函数中参与计算,所以通常在函数定义中不会给它命名。而他们使用后置运算符时,编译器会为这个形参提供一个值为0的实参。
为了与内置版本的行为一致,后置版本运算符应该返回对象的原值,返回的形式是一个值而非引用。而且通常后置版本的运算符应该通过前置版本的函数来实现,这样做还有另一个好处就是无需在后置版本进行状态检查,只需在前置版本中进行检查就可以了。
//因为不会使用到此形参,所以不给其命名
StrVecPtr StrVecPtr::operator++(int)
{
StrVecPtr ret = *this;
//无需进行状态检查,因为前置版本会进行检查
++*this;
return ret;
}
StrVecPtr StrVecPtr::operator--(int)
{
StrVecPtr ret = *this;
//无需进行状态检查,因为前置版本会进行检查
--*this;
return ret;
}
14.7成员访问运算符
在迭代器类及智能指针类中常常用到解引用运算符(*)和箭头运算符(->)。这两个运算符通常一起定义,而且箭头运算符通常通过解引用运算符来实现,它会返回解引用结果的地址。箭头运算符必须是类的成员,解引用运算符没有此强制要求,但通常也应该定义为类的成员函数。另外要注意的一点是,这两个运算符通常应该定义为const成员,因为获取一个元素并不会改变一个迭代器类的状态。
class StrVecPtr
{
public:
std::string &operator*() const
{
//先确认现在状态是否合法,否则无法解引用
check(_curr);
return (*p)[_curr];
}
std::string *operator->() const
{
//调用解引用操作,返回的是指针
return &this->operator*();
}
private:
size_t _curr; //下标位置
vector<string> *p; //指向迭代器所指向的容器
};
对箭头运算符返回值的限定
当我们重载箭头运算符时,可以改变的是箭头从哪个对象当中获取成员,而箭头获取成员这一事实永远不会改变。
对于形如point->mem的表达式来说point必须是一个指向类对象的指针或者是一个重载了operator->的类的对象。根据类的不同,point->mem分别等价于一下形式:
(*point).mem; //
point.operator->()->mem; //**要特别注意理解此种形式**
如果point是一个定义了operator->的类的一个对象,则我们使用point.operator->()的结果来获取mem。其中如果该结果是一个指针,则直接对指针执行->操作符获取mem。如果该结果本身含有重载的operator->()操作,则重复上述的步骤,知道返回所需的内容或者返回一些表示程序错误的信息为止。
14.8 函数调用运算符
如果类重载了函数调用操作符,则我们可以像使用函数一样使用该类的对象。因为这样的类同时也可以存储状态,所以与普通函数相比它们更加灵活。调用操作符必须是类的成员函数,一个类可以定义多个不同版本的调用运算符,这些函数应该在参数列表上有所区别。如果类定义了函数调用操作符,则该类的对象可以称作为函数对象, 函数对象常常作为泛型算法的实参。
struct AbsInt
{
//注意重载函数调用符的函数声明格式,有两个括号,因为**没有改变对象的内部状态,所以此函数声明为const**
void operator()(int &val) const
{
//将val取绝对值
val = val < 0 ? -val : val;
}
}
//将对象作为实参传给泛型算法
for_each(vs.begin(),vs.end, AbsInt());
14.8.1 lambda是一个函数对象
当我们编写一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象,而其可以作为一个函数对象来使用。
我们所知,当一个lambda表达式通过引用来捕获变量时,将由程序负责确保lambda执行时引用的对象确实存在,因此编译器可以直接使用该引用而无需在lambda生成的对象中将其存储为数据成员。而与此对应的是,lambda产生的类会为每个值捕获的变量生成对应的数据成员,同时创建构造函数。
size_t sz = 0;
auto func = [sz](const string &s){ return s.size() >= sz; };
//对于上面的lambda,编译器会生成类似下面的类SizeComp
class SizeComp
{
SizeComp(size_t n) :sz(n){}
bool operator()(const string& s)
{
return s.size < sz;
}
private:
size_t sz;
};
lambda表达式生成的类不包含默认构造函数、赋值运算符及默认析构函数。
14.8.2 标准库定义的函数对象
标准库定义了一组表示算数运算符、关系运算符和逻辑运算符的类,每个类定义了一个相应功能的调用运算符。这些类型定义在functional头文件中。(p510表格)
14.8.3可调用对象与function
c++中有几种可调用的对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。与其他对象一样,可调用的对象也有类型。两个不同类型的可调用对象可能共享一种调用形式,调用形式指明了调用返回的类型以及传递给调用的实参类型。例如:int(int,int)。
如果几个可调用对象共享一种调用形式,有时我们会希望把他们看成相同的可调用对象类型。
//加法
int add(int i, int j) { return i + j; }
//取余
auto mod = [](int i, int j){return i % j; };
//除法
struct divide
{
int operator()(int denominator, int divisor)
{
return denominator / divisor;
}
};
比如对于上面三种可调用对象,我们希望可以把他们存储在一个函数表(c++通常用map实现)中以在一个计算器程序中使用,但是虽然它们的调用形式相同,但是它们的类型却各不相同,无法存储在一个map中。而c++11中,我们可以使用名为function的新的标准库来解决上述问题。
标准库function类型
function定义在头文件functional中。function是一个模板,当创建一个具体的function类型时,我们需要传递该类型表示对象的调用形式。
//建立三个可调用对象
function<int(int, int)> f1 = add;
function<int(int, int)> f2 = mod;
function<int(int, int)> f3 = divide();
//建立functionl列表
map<string, function<int(int, int)>> funcMap;
funcMap.insert({ "add", f1 });
funcMap.insert({ "mod", f2 });
funcMap.insert({ "divide", f3 });
int i = 5, j = 6;
//依次调用函数列表中的可调用对象
for (auto funcPair : funcMap)
{
cout << funcPair.second(i, j) << endl;
}
重载的函数与function
我们不能把重载函数名直接存入functction对象中,会产生二义性。解决方法是使用函数指针代替函数名。
int add(int i, int j) { return i + j; }
double add(double i, double j){ return i + j; }
//错误,add为重载函数,将产生二义性
function<int(int, int)> f2 = add;
//使用函数指针代替函数名,正确
int(*fp)(int, int) = add;
function<int(int, int)> f1 = fp;
14.9 重载、类型转换与运算符
转换构造函数和类类型转换运算符共同定义了类类型转换。
14.9.1 类型转换运算符
类型转换运算符是类的一种特殊函数,它负责将一个类类型转换为其他类型。类型转换运算符的一般形式如下:
operator type() const; //注意其没有返回值说明,因为返回值会默认设置为你要转换的类型,参数列表为空,type表示要转换到的类型名
类型转换运算符可以面向任意类型(void类型除外)定义,只要该类型可以作为函数返回值。所以我们不允许吧类类型转换为数组或者函数类型,但是我们可以把它转换为指向数组首元素的指针或者函数指针。
类型转换函数没有显式的返回类型,也没有形参,而且必须定义为类的成员函数。而且类型转换函数通常不应该改变对象的内在状态,所以一般被定义为const成员函数。
struct SmallInt
{
//定义了int到SmallInt的转换
SmallInt(int i) :_i(i){}
//定义了SmallInt到int的转换
operator int(){ return _i; }
private:
int _i;
};
类类型转换在使用时有一个重要原则:编译器一次性只能执行一次隐式类类型转换,若需要两次以上的隐式类类型转换则会报错,但是其可以和多个标准内置类型转换一起使用。
struct SmallInt
{
SmallInt() = default;
//int可以转换到SmallInt
SmallInt(int i) :_i(i){}
operator int(){ return _i; }
int _i = 0;
};
struct BigInt
{
BigInt() = default;
//SmallInt可以转换到BigInt
BigInt(SmallInt si) :_i(si._i){}
int _i = 0;
};
int leftInt = 0, rightInt = 0;
SmallInt leftSi, rightSi;
//错误,int到BigInt需要两次类类型转换,编译报错
add(leftInt, rightInt);
//正确
add(leftSi, rightSi);
类型转换运算符有时可能产生意外的结果
在实践中,类很少提供类型转换运算符,然而定义向bool的类型转换还是很常见的。
在c++的早期版本中,定义向bool类型的转换存在一个问题:bool类型算算数类型,所以类类型的对象转换为bool后就能被用在任何需要算数类型的地方,这有时会引发意想不到的错误。特别是istream还有向bool类型的转换时,下面的代码将通过编译。
int i = 42;
//cin被转换为bool,bool被提升为int,然后左移42位,所以编译正确
cin << i;
为了防止这样的异常发生,c++11标准引入了显式的类型转换运算符(explicit,原来只可用来修饰转换构造函数)。但是注意,该规定存在一个例外,即如果该类型被用于条件,则编译器会将显示的类型转换自动应用于它。(牢记)
当表达式出现在下列位置,显示的类型转换将被隐式地执行(不仅限于到bool类型的转换,到所有内置类型都可以隐式进行):
- if、while、及do语句的条件部分
- for语句头的条件表达式
- 逻辑非、或、与的运算对象
- 条件运算符的条件表达式
14.9.2 避免有二义性的类型转换
如果类中包含一个或多个类型转换,则容易造成二义性。通常情况下,不要为多个类定义相同的类型转换,也不要在类中定义两个或两个以上转换源或转换目标是算术类型的转换。有以下三种情况都可能产生二义性问题。
两个类类型中定义了相同的类型转换
产生二义性的第一种情况多发生于两个类提供了相互的类型转换:
struct B;
struct A
{
A() = default;
//B类型可以隐式转换为A类型
A(const B&){}
};
struct B
{
//也是将B类型转换为A类型
operator A(){ return A(); }
};
A f(const A& a)
{
return a;
}
int _tmain(int argc, _TCHAR* argv[])
{
B b;
//错误,编译器将无法判断是使用A中的转换函数还是使用B中转换函数,从而造成二义性
A a = f(b);
}
另外我们无法用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性。
二义性与转换目标为内置类型的多重类型转换
第二种产生二义性的情况是不同的类类型转换,他们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,则会产生二义性。比如A可以转换为B,也可以转换为C,而B和c类型又可以通过另一类型D进行相互转换。最常见的情况是对一个类类型定义了两种以上到内置类型的转换,而这些内置类型之间又可以互相转换,如下:
struct A
{
A(const B&){}
A(int = 0);
A(double);
operator int() const;
operator double() const;
};
A f(const A& a)
{
return a;
}
void f(long double);
int _tmain(int argc, _TCHAR* argv[])
{
A a;
//错误,存在二义性,因为a有两种转换形式A->int->long double;或者A-double-long double,编译器无法确定使用哪一种
f(a);
}
类类型转化与重载运算符产生二义性
如果我们为一个类定义了转换目标是内置类型的类型转化,也提供了重载的运算符,则会产生二义性。
class SmallInt
{
public:
friend SmallInt operator+(const SmallInt &lhs, const SmallInt &rhs);
SmallInt(int i = 0) :_i(i){}
operator int() { return _i; }
private:
int _i = 1;
};
int _tmain(int argc, _TCHAR* argv[])
{
SmallInt s1, s2;
//错误,将产生二义性,因为我们可以把s2转换为int,执行内置类型的加法,也可以把4转换为SmallInt类型,执行重载的加法,编译器无法确定使用哪种
s1 = s2 + 4;
}
总结
要想正确的设计类的重载运算符、转换构造函数以及类型转换函数,必须加倍小心,同时定义类型转换运算符及重载运算符特别容易产生二义性。有一条好的建议:除了显式的定义向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能的限制那些非显式构造函数。
重载函数的匹配与类类型转换
标准类型转换的匹配优先级高于类类型转化,在调用重载函数时匹配时,如果多个函数需要额外的类类型转换,且调用的是同一个类类型转换,则函数匹配由其他的标准类型来决定。如果多个函数需要不同的类类型转换,则不管其他标准类型转换,编译器会直接认为该调用具有二义性。