《C++Primer 第五版》——第十四章 重载运算与类型转换
当运算符被用于类类型的对象时,C++允许我们为其指定新含义;同时,我们也能自定义类类型之间的转换规则(之前是通过转换构造函数)。和内置类型的转换一样,类类型转换隐式地将一种类型的对象转换成另一种所需类型的对象。
当运算符作用于类类型的运算对象时,可以通过重载运算符以重新定义该运算符的含义。
14.1 基本概念
重载的运算符是具有特殊名字的函数:
它们的名字由关键字 operator 和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体 。
重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。
- 一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。
- 除了重载的函数调用运算符
operator() 之外,其他重载运算符不能含有默认实参。 - 如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的 this 指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。
对于一个 运算符函数 来说,它或者是类的成员,或者至少含有一个类类型的参数——这一约定意味着当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。
我们可以重载大多数(但不是全部)运算符:
运算符 |
---|
可以被重载的运算符 |
---|
+ | - | * | / | % | ^ | & | | | ~ | ! | , | = | < | > | <= | >= | ++ | -- | << | >> | == | != | && | || | += | -= | /= | %= | ^= | &= | |= | *= | <<= | >>= | [] | () | -> | ->* | new | new[] | delete | delete[] | 不能被重载的运算符 |
---|
:: | .* | . | ?: |
我们只能重载已有的运算符,而无权发明新的运算符号。
有四个符号(+ 、- 、* 、& )既是一元运算符也是二元运算符,所有这些运算符都能被重载,从参数的数量来推断到底定义的是哪种运算符。
对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。 不考虑运算对象类型的话:
x == y + z;
永远等价于 x == (y + z); 。
直接调用一个重载的运算符函数
通常情况下,我们将运算符作用于类型正确的实参,从而以这种间接方式“调用”重载的运算符函数。
然而,我们也能像调用普通函数一样直接调用运算符函数,先指定函数名字,然后传入数量正确、类型适当的实参:
data1 + data2;
operator+(data1, data2);
同样的,我们还可以像调用其他成员函数一样显式地调用成员运算符函数。具体做法是,首先指定运行函数的对象(或指针)的名字,然后使用点运算符(或箭头运算符)访问希望调用的函数:
data1 += data2;
data1.operator+=(data2);
某些运算符不应该被重载
运算符不应该被重载的原因一般有两个:
① 因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。
特别是,逻辑与运算符、逻辑或运算符和逗号运算符的运算对象求值顺序规则无法保留下来。除此之外,&& 和 || 运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。
因为上述运算符的重载版本无法保留求值顺序和/或短路求值属性,因此不建议重载它们。当代码使用了这些运算符的重载版本时,用户可能会突然发现他们一直习惯的求值规则不再适用了。
② 还有一个原因使得我们一般不重载逗号运算符和取地址运算符:C++ 已经定义了这两种运算符用于类类型对象时的特殊含义,这一点与大多数运算符都不相同。因为这两种运算符已经有了内置的含义,所以一般来说它们不应该被重载,否则它们的行为将异于常态,从而导致类的用户无法适应。
Best Practices:
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
使用与内置类型一致的含义
当你开始设计一个类时,首先应该考虑的是这个类将提供哪些操作。在确定类需要哪些操作之后,才能思考到底应该把每个类操作设成普通函数还是重载的运算符。如果某些操作在逻辑上与运算符相关,则它们适合于定义成重载的运算符:
- 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致;
- 如果类的某个操作是检查相等性,则定义
operator== ;如果类有了 operator== ,意味着它通常也应该有 operator!= ; - 如果类包含一个内在的单序比较操作,则定义
operator< ;如果类有了 operator< ,则它也应该含有其他关系操作; - 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容: 逻辑运算符和关系运算符应该返回bool,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。
提示:尽量明智地使用运算符重载
每个运算符在用于内置类型时都有比较明确的含义。以二元+运算符为例,它明显执行的是加法操作。因此,把二元+运算符映射到类类型的一个类似操作上可以极大地简化记忆。例如对于标准库类型string来说,我们就会使用+把一个 string对象连接到另一个后面,很多编程语言都有类似的用法。
当在内置的运算符和我们自己的操作之间存在逻辑映射关系时,运算符重载的效果最好。此时,使用重载的运算符显然比另起一个名字更自然也更直观。不过,过分滥用运算符重载也会使我们的类变得难以理解。
在实际编程过程中,一般没有特别明显的滥用运算符重载的情况。例如,一般来说没有哪个程序员会定义 operator+ 并让它执行减法操作。然而经常发生的一种情况是,程序员可能会强行扭曲了运算符的“常规”含义使得其适应某种给定的类型,这显然是我们不希望发生的。因此我们的建议是:只有当操作的含义对于用户来说清晰明了时才使用运算符。如果用户对运算符可能有几种不同的理解,则使用这样的运算符将产生二义性。
只有当操作的含义对于用户来说清晰明了时才使用运算符。
赋值和复合赋值函数
赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧运算对象的一个引用。重载的赋值运算应该继承而非违背其内置版本的含义。
如果类含有算术运算符或者位运算符,则最好也提供对应的复合赋值运算符。 比如+=运算符的行为显然应该与其内置版本一致,先执行+,再执行=。
选择重载运算符作为成员或者非成员
当我们定义重载的运算符时,必须首先决定是将其声明为类的成员函数还是声明一个普通的非成员函数。 在某些时候我们别无选择,因为有的运算符必须作为成员;另一些情况下,运算符作为普通函数比作为成员更好。
下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:
- 赋值(
= )、下标([] )、调用(() )和成员访问箭头(-> )运算符必须是成员。 - 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。(对称性的运算符是指——运算符的两侧运算对象任意一个都可以是左侧运算对象或是右侧运算对象,比如12+3.0和3.0+12是一样的)
- 如果我们想提供含有类对象的混合类型表达式,则运算符必须定义成非成员函数。
当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。 例如:(注意第三行)
string s = "world";
string t = s + "!";
string u = "hi" + s;
如果 operator+ 是 string 类的成员,则上面的第一个加法等价于 s.operator+("!")。同样的,“hi” + s 等价于 “hi”.operator+(s)。显然 “hi” 的类型是 const char*,这是一种内置类型,根本就没有成员函数。 因为 string 将 + 定义成了普通的非成员函数,所以 “hi” + s 等价于 operator+(“hi”, s)。和任何其他函数调用一样,每个实参都能被转换成形参类型。唯一的要求是至少有一个运算对象是类类型,并且两个运算对象都能准确无误地转换成 string。
14.2 输入和输出运算符
IO标准库分别使用>>和<<执行输入和输出操作。对这两个运算符来说,IO库定义了用其读写内置类型的版本,而我们的自定义类则需要自己来定义适合其对象的新版本以支持IO操作。 比如string类就重载了输入输出操作。
14.2.1 重载输出运算符
通常情况下,输出运算符的形参类型和返回类型情况如下:
- 第一个形参是一个非常量 ostream 对象的引用。 之所以 ostream 是非常量是因为向流写入内容会改变其状态;而该形参是引用是因为我们无法直接复制一个 ostream 对象。
- 第二个形参一般来说是一个常量的引用 ,该常量是我们想要打印的类类型。
- 为了与其他输出运算符保持一致,operator<< 一般要返回它的 ostream 形参。
第二个形参是引用的原因是我们希望避免复制实参。而之所以该形参可以是常量是因为(通常情况下)打印对象不会改变对象的内容。
输出运算符尽量减少格式化操作
用于内置类型的输出运算符不太考虑格式化操作,尤其不会打印换行符,用户希望类的输出运算符也像如此行事。如果运算符打印了换行符,则用户就无法在对象的同一行内接着打印一些描述性的文本了。相反,令输出运算符尽量减少格式化操作可以使用户有权控制输出的细节。
Note:
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。
输入输出运算符必须是非成员函数
与 iostream 标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。 否则,它们的左侧运算对象将是我们的类的一个对象。
IO 运算符通常需要读写类的非公有数据成员,所以 IO 运算符一般被声明为友元。
14.2.2 重载输入运算符
通常情况下,输入运算符的形参类型和返回类型情况如下:
- 第一个形参是运算符将要读取的流的引用;
- 第二个形参是将要读入到的(非常量)对象的引用。
- 该运算符通常会返回某个给定流的引用。
第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。
Sales_data 的输入运算符
istream &operator>>(istream &is,Sales_data &item)
{
double price;
is >> item.boolNo >> item.units_sold >> price;
if(is)
item.revenue = item.units_sold * price;
else
item = Sales_data;
return is;
}
Note:
- 当读取操作发生错误时,输入运算符应该负责从错误中恢复。
- 输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
输入时的错误
在执行输入运算符时可能发生下列错误:
- 当流含有错误类型的数据时读取操作可能失败。
- 当读取操作到达文件末尾或者遇到流的其他错误时也会失败。
当读取操作发生错误时,输入运算符应该负责从错误中恢复
14.3 算术和关系运算符
通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。
算术运算符通常会计算它的两个运算对象并得到一个新值,这个值有别于任意一个运算对象,经常位于一个局部变量之内,操作完成后返回该局部变量的副本作为其结果。
如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值运算符来定义算术运算符:
Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs;
sum += rhs;
return sum;
}
Tip:
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。
14.3.1 相等运算符
通常情况下,C++中的类通过定义相等运算符来检验两个对象是否相等。也就是说,它们会比较对象的每一个数据成员,只有当所有对应的成员都相等时才认为两个对象相等。
重载的相等运算符的设计准则:
- 如果一个类含有判断两个对象是否相等的操作,则它显然应该把普通函数定义成
operator== 而非一个普通的命名函数:因为用户肯定希望能使用 == 比较对象,所以提供了 == 就意味着用户无须再费时费力地学习并记忆一个全新的函数名字。此外,类定义了 == 运算符之后也更容易使用标准库容器和算法。 - 如果类定义了
operator== ,则该运算符应该能判断一组给定的对象中是否含有重复数据。 - 通常情况下,相等运算符应该具有传递性,换句话说,如果 a == b 和 b == c 都为真,则 a == c 也应该为真。
- 如果类定义了
operator== ,则这个类也应该定义 operator!= 。对于用户来说,当他们能使用 == 时肯定也希望能使用 !=,反之亦然。 - 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。
14.3.2 关系运算符
定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义 operator< 会比较有用。
通常情况下重载的关系运算符应该满足两个条件:
- 定义顺序关系 ,令其与关联容器中对关键字(参见11.2.2节)的要求一致;
- 并且,如果类同时也含有 == 运算符的话,则定义一种关系令其与 == 保持一致。特别是,如果两个对象是 != 的,那么一个对象应该 < 另外一个。
尽管我们可能会认为Sales_data类应该支持关系运算符, 但事实证明并非如此,其中的缘由比较微妙,值得读者深思。一开始我们可能会认为应该像compareisbn那样定义<,该函数通过比较ISBN来实现对两个对象的比较。然而,尽管compareisbn提供的顺序关系符合要求1, 但是函数得到的结果显然与我们定义的==不一致,因此它不满足要求2。 如果两笔交易的ISBN号相同但销售数量和收入不同经比较是不相等的,但实际情况是,任何一个都不比另一个情况小,如果任意一个都不比另一个小按道理来说应该是相等的。 因此,对于Sales_data类来说,不存在一种逻辑可靠的<定义,这个类不定义<运算符更好。
Best Practices:
如果存在唯一一种逻辑可靠的 < 定义,则应该考虑为这个类定义 < 运算符。如果类同时还包含 == ,则当且仅当 < 的定义和 == 产生的结果一致时才定义 < 运算符。
14.4 赋值运算符
之前已经介绍过拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给该类的另一个对象。此外,类还可以定义其他赋值运算符以使用别的类型作为右侧运算对象。 比如:
举个例子,在拷贝赋值和移动赋值运算符之外,标准库vector类还定义了第三种赋值运算符,该运算符接受花括号内的元素列表作为参数。我们可以像如下形式一样使用该运算符:
vector<string> v;
v = {"a", "an", "the"};
同样, 也可以把这个运算符添加到StrVec类(参见13.5节)中:
class StrVec {
public:
StrVec &operator=(std::initializer_list<std::string>);
};
为了与内置类型的赋值运算符保持一致(也与我们已经定义的拷贝赋值和移动赋值运算一致),这个新的赋值运算符将返回其左侧运算对象的引用:
StrVec &StrVec::operator=(std::initializer_list<string> i1)
{
auto data = alloc_n_copy(i1.begin(), i1.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
不同的是,这个运算符无须检查对象向自身的赋值,这是因为它的形参类型 std::initializer_list<string> 可以确保
与拷贝赋值和移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间(左边或右边),再创建一片新空间(注意:移动赋值运算符并没有创建新空间,而是接管了右侧运算对象的资源)。
Note:我们可以重载赋值运算符。但不论形参的类型是什么,赋值运算符都必须定义为成员函数。
复合赋值运算符
复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。为了与内置类型的复合赋值保持一致,类中的复合赋值运算符也要返回其左侧运算对象的引用。
Best Practices:
赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。
14.5 下标运算符
表示容器的类通常可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符 operator[] 。
Note:
下标运算符必须是成员函数。
- 为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。
- 进一步,我们 最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
举个例子,我们按照如下形式定义 StrVec 的下标运算符:
class StrVec
{
public:
std::string &operator[](std::size_t n)
{
return elements[n];
}
const std::string &operator[](std::size_t n) const
{
return elements[n];
}
private:
std::string *elements;
};
上面这两个下标运算符的用法类似于 vector 或者数组中的下标。因为下标运算符返回的是元素的引用,所以当 StrVec 是非常量时,我们可以给元素赋值;而当我们对常量对象取下标时,不能为其赋值:
const StrVec cvec = svec;
if (svec.size() && svec[0].empty())
{
svec[0] = "zero";
cvec[0] = "Zip";
}
通过区分成员函数是否是 const 的,我们可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用 const 成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。
14.6 递增和递减运算符
C++并不要求递增和递减运算符必须是类的成员,但是因为它们改变的正好是所操作对象的状态,所以建议将其设定为成员函数。
Note:定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
定义前置递增/递减运算符
Note:为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
区分前置和后置运算符
如何同时定义前置和后置运算符:
要想同时定义前置和后置运算符,必须首先解决一个问题——即普通的重载形式无法区分这两种情况。前置和后置版本使用的是同一个符号,意味着其重载版本所用的名字将是相同的,并且运算对象的数量和类型也相同。
为了解决这个问题,后置版本接受一个额外的(不被使用的)int 类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为 0 的实参。尽管从语法上来说后置函数可以使用这个额外的形参,但是在实际过程中通常不会这么做。这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。
Note:为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),而且返回的形式是一个值而非引用。
对于后置版本来说,在递增对象之前需要首先记录对象的状态:
StrBlobPtr StrBlobPtr::operator++(int)
{
StrBlobPtr ret = *this;
++*this;
return ret;
}
StrBlobPtr StrBlobPtr::operator--(int)
{
StrBlobPtr ret = *this;
--*this;
return ret;
}
Note: 因为我们不会用到 int 形参,所以无须为其命名。
显式地调用重载的后置运算符
如果我们想通过函数调用的方式调用后置版本,则必须为它的整型参数传递一个值:
StrBlobPtr p(a1);
p.operator++(0);
p.operator++();
尽管传入的值通常会被运算符函数忽略,但却必不可少,因为编译器只有通过它才能知道应该使用后置版本。
14.7 成员访问运算符
在迭代器类及智能指针类中常常用到解引用运算符(* )和箭头运算符(-> )
Note: 箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
Note: 重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
14.8 函数调用运算符
如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。
举个简单的例子,下面这个名为 absInt 的 struct 含有一个调用运算符,该运算符负责返回其参数的绝对值:
struct absInt
{
int operator()(int val) const { return val < 0 ? -val : val; };
};
这个类只定义了一种操作:函数调用运算符,它负责接受一个 int 类型的实参,然后返回该实参的绝对值。
我们使用调用运算符的方式是令一个 absInt 对象作用于一个实参列表,这一过程看起来非常像调用函数的过程:
int i = -42;
absInt absObj;
int ui = absObj(i);
即使 absObj 只是一个对象而非函数,我们也能“调用”该对象。调用对象实际上是在运行重载的调用运算符。 在此例中,该运算符接受一个 int 值并返回其绝对值。
Note:函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
如果类定义了调用运算符,则该类的对象称作 函数对象(function object) 。函数对象是可调用对象。
和其他类一样,函数对象类除了 operator() 之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。
函数对象常常作为泛型算法的实参。
例如,可以使用标准库 for_each 算法和我们自己的 PrintString 类来打印容器的内容:
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
14.8.1 lambda 是函数对象
当我们编写了一个 lambda 后,编译器将该 lambda 表达式翻译成一个未命名类的未命名对象,在 lambda 表达式产生的类中含有一个重载的函数调用运算符。
例如,对于我们传递给 stable_sort 作为其最后一个实参的 lambda 表达式来说:
stable_sort(words.begin(), words.end(),
[](const string &a, const string &b)
{ return a.size() < b.size(); });
其行为类似于下面这个类的一个未命名对象
class ShorterString
{
public:
bool operator()(const string &s1, const string &s2) const
{
return s1.size() < s2.size();
}
};
产生的类只有一个函数调用运算符成员,它负责接受两个 string 并比较它们的长度,它的形参列表和函数体与 lambda 表达式完全一样。
默认情况下 lambda 不能改变它捕获的变量。因此在默认情况下,由 lambda 产生的类当中的函数调用运算符是一个 const 成员函数。如果 lambda 被声明为可变的(关键字 mutable),则调用运算符就不是 const 的了。
用这个类替代 lambda 表达式后,我们可以重写并重新调用 stable_sort:
stable_sort(words.begin(), words.end(), ShorterString());
第三个实参是新构建的 ShorterString 对象,当 stable_sort 内部的代码每次比较两个 string 时就会“调用”这一对象,此时该对象将调用运算符的函数体,判断第一个 string 的大小小于第二个时返回 true。
表示 lambda 及相应捕获行为的类
当一个 lambda 表达式通过引用捕获变量时,将由程序(即程序员自己编写时)负责确保 lambda 执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用,而无须在 lambda 产生的类中将其存储为数据成员。
通过值捕获的变量被拷贝到 lambda 中。因此,这种 lambda 产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
lambda 表达式的构造函数有哪些:
- lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;
- lambda 表达式是否含有默认的拷贝/移动构造函数,则通常要视捕获的数据成员类型而定。
14.8.2 标准库定义的函数对象
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
例如,标准库 plus 类定义了一个函数调用运算符用于对一对运算对象执行 + 的操作;标准库 modulus 类定义了一个调用运算符执行二元的 % 操作;标准库 equal_to 类执行 ==,等等。
这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。
例如,plus 令 string 加法运算符作用于 string 对象;plus 的运算对象是 int;plus<Sales_data> 对 Sales_data 对象执行加法运算,以此类推。
标准库函数对象 |
---|
算术 | 关系 | 逻辑 |
---|
plus< Type > | equal_to< Type > | logical_and< Type > | minus< Type > | not_equal_to< Type > | logical_or< Type > | multiplies< Type > | greater< Type > | logical_not< Type > | divides< Type > | greater_equal< Type > | | modulus< Type > | less< Type > | | negate< Type > | less_equal< Type > | | 都属于头文件 functional,都重载了 operator() |
---|
在算法中使用标准库函数对象
表示运算符的函数对象类常用来替换算法中的默认运算符。 如我们所知,在默认情况下标准库排序算法使用 operator< 将序列按照升序排列。
如果要执行降序排列的话,我们可以传入一个 greater 类型的对象。该类将产生一个调用运算符并负责执行待排序类型的大于运算。例如,如果 svec 是一个 vector<string> :
sort(svec.begin(), svec.end(), greater<string>());
则上面的语句将按照降序对 svec 进行排序。第三个实参是 greater<string> 类型的一个未命名的对象,因此当 sort 比较元素时,不再是使用默认的 < 运算符,而是调用给定的 greater 函数对象。该对象负责在 string 元素之间执行 > 比较运算。
需要特别注意的是,标准库规定其函数对象对于内置指针同样适用。 我们之前曾经介绍过比较两个无关指针将产生未定义的行为(参见3.5.3节或C++标准[expr.rel#4]),因此我们可以使用一个标准库函数对象来实现该目的。
然而我们可能会希望通过比较指针的内存地址来 sort 一个存储 string 指针的 vector。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的:
vector<string *> nameTable;
sort(nameTable.begin(), nameTable.end(),
[](string *a, string *b)
{ return a < b; });
sort(nameTable.begin(), nameTable.end(), less<string *>());
在上例中,关联容器使用 less<string *> 对元素排序,因此我们可以定义一个指针的 set 或者在 map 中使用指针作为关键值而无须直接声明 less。
14.8.3 可调用对象与 function
C++ 语言中有几种可调用对象(Called Object):函数、函数指针、lambda 表达式、bind 创建的对象以及重载了函数调用运算符的类(即函数对象)。
和其他对象一样,可调用的对象也有类型。
例如,每个 lambda 有它自己唯一的(未命名)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定,等等。
然而,两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。
- 调用形式指明了调用返回的类型以及传递给调用的实参类型。
- 一种调用形式对应一个函数类型。
例如:
int(int, 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;
}
};
上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式:
int(int, int)
我们可能希望使用这些可调用对象构建一个简单的桌面计算器。
为了实现这一目的,需要定义一个函数表(function table) 用于存储指向可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。
在 C++ 中,函数表很容易通过 map 来实现,值类型是指向某一类型可调用对象的指针类型。
对于此例来说,我们使用一个表示运算符符号的 string 对象作为关键字;使用实现运算符的函数作为值。当我们需要求给定运算符的值时,先通过运算符索引 map,然后调用找到的那个元素。 假定我们的所有函数都相互独立,并且只处理关于 int 的二元运算,则 map 可以定义成如下的形式:
map<string, int (*)(int, int)> binops;
我们可以按照下面的形式将 add 的指针添加到 binops 中:
binops.insert({"+", add});
但是我们不能将 mod 或者 divide 存入 binops:
binops.insert({"%", mod});
问题在于 mod 是个 lambda 表达式,而每个 lambda 有它自己的类类型,该类型与存储在 map 中的值的类型不匹配。解决方法在下面。
标准库 function
可以使用一个名为 function 的新的标准库类型解决上述问题,function 类定义在 functional 头文件中 :
function 的操作 |
---|
function< T > f; | f 是一个用来存储可调用对象的空 function,这些可调用对象的调用形式应该与函数类型 T 相同(即 T 是 retType(args) ) | function< T > f(nullptr); | 显式地构造一个空 function | function< T > f(obj); | 在 f 中存储可调用对象 obj 的副本 | f | 将 f 作为条件:当 f 含有一个可调用对象时为真;否则为假 | f(args) | 调用 f 中的对象,参数是 args | 定义为 function< T > 的成员类型 |
---|
result_type | 该 function 类型的可调用对象返回的类型 | argument_type | 当 T 有一个或两个实参时定义的类型。 如果 T 只有一个实参,则 argument_type 是该类型的同义词; 如果 T 有两个实参,则 first_argument_type 和 second_argument_type 分别代表两个实参的类型 | first_argument_type | second_argument_type |
function 是一个模板类,和我们使用过的其他模板一样,当创建一个具体的 function 类型时我们必须提供额外的信息。
通过将 function 模板类设为形参类型,我们可以实现调用形式相同的所有可调用对象都能与它匹配。
之前因为 lambda 表达式的类型是它对应的类类型,所以无法与调用形式的函数指针类型相匹配。
在此例中,所谓额外的信息是指该 function 类型能够表示的对象的调用形式。参考其他模板,我们在一对尖括号内指定类型:
function<int(int, int)>
在这里我们声明了一个 function 类型,它可以表示接受两个 int、返回一个 int 的可调用对象。
function<int(int, int)> f1 = add;
function<int(int, int)> f2 = divide();
function<int(int, int)> f3 = [](int i, int j) { return i * j; };
cout << f1(4, 2) << endl;
cout << f2(4, 2) << endl;
cout << f3(4, 2) << endl;
使用这个 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; }},
{"8", mod}};
一如往常,当我们索引 map 时将得到关联值的一个引用。如果我们索引 binops,将得到 function 对象的引用。function 类型重载了调用运算符,该运算符接受它自己的实参然后将其传递给存好的可调用对象:
binops["+"](10, 5);
binops["-"](10, 5);
binops["/"](10, 5);
binops["*"](10, 5);
binops["%"](10, 5);
我们依次调用了 binops 中存储的每个操作。在第一个调用中,我们获得的元素存放着一个指向 add 函数的指针,因此调用 binops["+"](10, 5) 实际上是使用该指针调用 add,并传入 10 和 5。在接下来的调用中,binops["-"] 返回一个存放着 std::minus<int> 类型对象的 function,我们将执行该对象的调用运算符。
重载的函数与 function
值得注意的是,因为二义性,我们不能(直接)将重载函数的名字存入 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});
- 解决上述二义性问题的一条途径是存储函数指针而非函数的名字:
int (*fp)(int, int) = add;
binops.insert({"+", fp});
binops.insert({"+", [](int a, int b) { return add(a, b); }});
lambda 内部的函数调用传入了两个 int,因此该调用只能匹配接受两个 int 的 add 版本,而这也正是执行 lambda 时真正调用的函数。
Note:
新标准库中的 function 类与旧版本中的 unary_function 和 binary_function 没有关联,后两个类已经被更通用的 bind 函数替代了(参见10.3.4节)。
14.9 重载、类型转换与运算符
在7.5.4节介绍了有一个实参调用的非显式构造函数定义了一种隐式的类型转换,这种构造函数(转换构造函数)将实参类型的对象转换成类类型。
我们同样能定义对于类类型的类型转换,通过定义类型转换运算符可以做到这一点: 转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。
14.9.1 类型转换运算符
类型转换运算符(conversion operator) 是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
类型转换运算符(类型转换函数)的一般形式如下所示:
operator type() const;
- 其中 type 表示某种类型。类型转换运算符可以面向任意类型(只要该类型能作为函数的返回类型,除了 void 类型之外)进行定义。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。
- 类型转换运算符必须既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成 const 成员。
定义含有类型转换运算符的类
举个例子,我们定义一个比较简单的类,令其表示 0 到 255 之间的一个整数:
class SmallInt
{
public:
SmallInt(int i = 0) : val(i)
{
if (i < 0 || i > 255)
throw std::out_of_range("Bad SmallInt value");
}
operator int() const { return val; }
private:
std::size_t val;
};
我们的 SmallInt 类既定义了向类类型的隐式转换,也定义了从类类型向其他类型的转换。 其中,构造函数将算术类型的值转换成 smallInt 对象,而类型转换运算符将 SmallInt 对象转换成 int:
SmallInt si;
si = 4;
si + 3;
尽管编译器一次只能执行一个用户定义的类型转换,但是用户定义的隐式类类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。
因此,我们可以将任何算术类型传递给 SmallInt 的构造函数。类似的,我们也能使用类型转换运算符将一个 SmallInt 对象转换成 int,然后再将所得的 int 转换成任何其他算术类型:
SmallInt si = 3.14;
si + 3.14;
因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责显式指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:
class SmallInt;
operator int(SmallInt &);
class SmallInt
{
public:
int operator int() const;
operator int(int = 0) const;
operator int *() const { return 42; }
};
提示:避免过度使用类型转换函数
类型转换运算符可能会产生意外结果
在实践中,类很少提供类型转换运算符。在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则则存在一种例外情况:
对于类来说,定义向 bool 的类型转换还是比较普遍的现象。
在C++的早期版本中,如果类想定义一个向bool的类型转换,则它常常遇到一个问题:
因为bool是一种算术类型,所以类类型的对象转换成bool后就能被用作任何需要算术类型的上下文中。但是这种类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,下面的代码仍然编译通过:
int i = 42;
cin << i;
这段程序试图将输出运算符作用于输入流,因为istream本身并没有定义<<,所以本来这段代码应该产生错误。 然而该代码能使用istream的bool类型转换运算符将cin转换为bool,而这个bool值将会被提升为int并用作内置的左移运算符的左侧运算对象。这样一来提升后的bool值(1或0)会被左移42个位置。
显式的类型转换运算符
为了防止上面这种异常情况发生,C++11 新标准引入了显式的类型转换运算符(explicit conversion operator):
class SmallInt
{
public:
explicit operator int() const { return val; }
};
和显式的构造函数一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换:
SmallInt si = 3;
si + 3;
static_cast<int>(si) + 3;
当类型转换运算符是显式的时,我们也能执行类型转换,不过必须显式的转换——即强制类型转换才可以。 除了下面几种情况:
该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。 换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
- if、while 及 do 语句的条件部分
- for 语句头的条件表达式
- 逻辑非运算符(
! )、逻辑或运算符(|| )、逻辑与运算符(&& )的运算对象 - 条件运算符(
? : )的条件表达式。
转换为 bool
在C++标准库的早期版本中,IO类型定义了向 void* 的转换规则,以求避免上面提到的问题。在C++11新标准中,IO标准库通过定义一个向bool的显式类型转换实现同样的目的。
无论我们什么时候在条件中使用流对象,都会使用为 IO 类型定义的 operator bool。
例如:
while (std::cin >> value)
while 语句的条件执行输入运算符,它负责将数据读入到 value 并返回 cin。为了对条件求值,cin 被 istream operator bool 类型转换函数隐式地执行了转换。如果 cin 的条件状态是 good,则该函数返回为真;否则该函数返回为假。
Best Practices: 向 bool 的类型转换通常用在条件部分,因此 operator bool 一般定义成 explicit 的。
14.9.2 避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则我们编写的代码将可能会具有二义性。
在两种情况下可能产生多重转换路径。
例如,当 A 类定义了一个接受 B 类对象的转换构造函数,同时 B 类定义了一个转换目标是 A 类的类型转换运算符时,我们就说它们提供了相同的类型转换。
- 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。
最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。
WARNING: 通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
实参匹配和相同的类型转换
在下面的例子中,我们定义了两种将 B 转换成 A 的方法:一种使用 B 的类型转换运算符、另一种使用 A 的以 B 为参数的构造函数:
struct B;
struct A
{
A() = default;
A(const B &);
};
struct B
{
operator A() const;
};
A f(const A &);
B b;
A a = f(b);
因为同时存在两种由 B 获得 A 的方法,所以造成编译器无法判断应该运行哪个类型转换,也就是说,对 f 的调用存在二义性。 该调用可以使用以 B 为参数的 A 的构造函数,也可以使用 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);
long lg;
A a2(lg);
在对 f2 的调用中,哪个类型转换都无法精确匹配 long double。然而这两个类型转换都可以使用,只要后面再执行一次生成 long double 的标准类型转换即可。因此,在上面的两个类型转换中哪个都不比另一个更好,调用将产生二义性。 当我们试图用 long 初始化 a2 时也遇到了同样问题,哪个构造函数都无法精确匹配 long 类型。它们在使用构造函数前都要求先将实参进行类型转换:
- 先执行 long 到 double 的标准类型转换,再执行 A(double)
- 先执行 long 到 int 的标准类型转换,再执行 A(int)
编译器没办法区分这两种转换序列的好坏,因此该调用将产生二义性。
调用 f2 及初始化 a2 的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致。当我们使用用户定义的类型转换时,如果转换过程包含标准类型转换,则标准类型转换的级别将决定编译器选择最佳匹配的过程:
short s = 42;
A a3(s);
在此例中,把 short 提升成 int 的操作要优于把 short 转换成 double 的操作,因此编译器将使用 A::A(int) 构造函数构造 a3,其中实参是 s(提升后)的值。
Note:
当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个。
提示:除了显式地向 bool 类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。
重载函数与转换构造函数
当我们调用重载的函数时,从多个类型转换中进行选择将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好(产生二义性)。
举个例子,当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:
struct C
{
C(int);
};
struct D
{
D(int);
};
void manip(const C &);
void manip(const D &);
manip(10);
此时可以通过显式地构造正确的类型从而消除重载函数的二义性:
manip(C(10));
重载函数与用户定义的隐式类类型转换
当调用重载函数时,如果两个(或多个)用户定义的隐式类类型转换都提供了可行匹配,则我们认为这些类型转换一样好。
什么时候考虑标准类型转换的级别呢?
在开头第一句话的过程中,我们不会考虑任何可能出现的标准类型转换的级别。 只有当重载函数能通过同一个类类型的转换函数(即同一个类型中的两个及更多转换函数)得到匹配时,我们才会考虑其中出现的标准类型转换的级别。
例如当我们调用 manip 时,即使其中一个类定义了需要对实参进行标准类型转换的构造函数,这次调用仍然会具有二义性:
struct E
{
E(double);
};
void manip2(const C &);
void manip2(const E &);
manip2(10);
在此例中,C 有一个转换源为 int 的类型转换,E 有一个转换源为 double 的类型转换。对于 manip2(10) 来说,两个 manip2 函数都是可行的:
- manip2(const C &) 是可行的,因为 C 有一个接受 int 的转换构造函数,该构造函数与实参精确匹配。
- manip2(const E &) 是可行的,因为 E 有一个接受 double 的转换构造函数,而且为了使用该函数我们可以利用标准类型转换把 int 转换成所需的类型。
因为调用重载函数所请求的用户定义的类型转换不止一个且彼此不同,所以该调用具有二义性。即使其中一个调用需要额外的标准类型转换而另一个调用能精确匹配,编译器也会将该调用标示为错误。
Note:
在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。
14.9.3 函数匹配与重载运算符
重载的运算符也是重载的函数。因此,通用的函数匹配规则(参见6.4节)同样适用于判断在给定的表达式中到底应该使用内置运算符还是重载的运算符。 不过当运算符函数出现在表达式中,候选函数集的规模要比我们使用调用运算符调用函数时更大。
如果a时一种类类型,则表达式a sym b 可能是:
a.operatorsym(b);
operatorsym(a,b);
和普通函数调用不同,我们不能通过调用的形式来区分当前运算符函数调用的,是成员函数还是非成员函数。
当我们使用重载运算符作用于类类型的运算对象时:
- 候选函数中 包含该运算符的普通非成员版本和内置版本。
- 除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。
当我们调用一个命名的函数时,具有相同名字的成员函数和非成员函数不会彼此重载,这是因为我们用来调用命名函数的语法形式对于成员函数和非成员函数来说是不相同的。
- 当我们通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。
- 而当我们在表达式中使用重载的运算符时,无法判断正在使用的是成员函数还是非成员函数,因此二者都应该在考虑的范围内。
Note:
表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
比如:
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;
WARNING:
如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
|