在第4章中,我们了解到 C++ 定义了大量操作符,并且为内置类型之间的转换定义了自动转换。这些使得程序员可以很方便的写出混合类型的表达式。
同时 C++ 允许我们定义当将操作符运用于类类型对象时的含义。它还允许我们定义类类行之间的转换。类类型转换的使用类似于内置类型转换在需要时将一个类型的对象隐式转为另外一个类型对象。
操作符重载(operator overloading)允许我们定义运用于类类型的操作符的含义。谨慎地使用操作符重载可以使得程序更加容易读和写。如果之前地 Sales_item
类定义了重载的输入、输出和加操作符,那么可以如下操作:
cout << item1 + item2;
如果没有定义这些重载操作符的话,操作就会没有那么简洁:
print(cout, add(data1, data2));
重载操作符是具有特殊名字的函数:关键字 operator
后跟被定义的操作符的符号。与任何别的函数一样,重载操作符有返回值类型、参数列表和函数体。
重载的操作符函数具有与操作符的操作数一样的参数个数。一元操作符只有一个参数;二元操作符有两个参数。在二元操作符中,左边的操作数传递给第一个参数,右边的操作数传递给第二个参数。除了重载函数调用操作符(function-call operator)operator()
之外,重载的操作符没有默认的参数。
如果操作符函数是一个成员函数,第一个(左边)操作数将绑定到隐式的 this 指针。由于第一个操作数隐式绑定到 this,成员操作符函数的显式参数将比操作符的操作数少一个。
注意:当重载操作符是成员函数,this 将绑定到左手边的操作数。成员操作符函数将少一个显式的参数。
一个操作符函数必须要么是类的成员要么至少有一个参数是此类类型:
//错误:不能重新定义内置类型的操作符
int operator+(int, int);
这个限制意味着我们不能改变内置类型的操作符的含义。
我们可以重载大部分但不是全部的操作符。下表将说明哪些是可以重载的,哪些是不能重载的操作符。new
和 delete
将在19.1.1节说明。
可以重载的操作符
+
-
*
/
%
^
&
|
~
!
,
=
<
>
<=
>=
++
--
<<
>>
==
!=
&&
||
+=
-=
/=
%=
^=
&=
|=
*=
<<=
>>=
[]
()
->
->*
new
new[]
delete
delete[]
不能重载的操作符
::
.*
.
?:
我们只能重载已经存在的操作符,而不能发明新的操作符符号(symbol)。对于符号如(+
-
*
和 &
)同时可以作为一元和二元操作符的。两者之一或者两种性的操作符都可以被重载。参数的个数决定哪个操作符被重载。
重载的操作符与内置类型的操作符具有相同的优先级(precedence)和结合性(associativity)。而不管操作数的类型。
直接调用重载操作符函数
通常我们通过使用操作符于合适类型的参数上间接调用重载的操作符函数。然而,我们可以通过调用常规函数的方式直接调用重载操作符函数。我们直接输入函数的名字(operator op
)并传递合适类型的合适数目的参数:
data1 + data2; //常规的表达式
operator+(data1+data2); //相同的函数调用方式
这些调用是相同的:两者都调用非成员函数 operator+
,并传递 data1 作为第一个参数以及 data2 作为第二个参数。
我们调用一个成员操作函数与调用任何别的成员函数的方式是一样的。我们指定对象(或指针)的名字,然后使用点号(或箭头)操作符来获取想要调用的函数:
data1 += data2; //表达式方式的调用
data1.operator+=(data2); //相同的成员函数调用方式
上面两个语句都调用成员函数 operator+=
,将 this
绑定到 data1 的地址上,将 data2 作为参数传递。
有些操作符不应该被重载
回想一个有些操作符保证操作数的求值顺序是固定的。由于重载操作符就是函数调用,这些保证就不能运用于重载的操作符。尤其是,逻辑与(&&)和逻辑或(||)以及逗号操作符的操作数求值顺序就不会保留。特别是,重载版本的 &&
和 ||
操作符不能保留内置操作符的短路求值(short-circuit evaluation)特性。两个操作数将总是被求值。
由于这些操作符的重载版本不会保留求值顺序和/或短路求值,重载它们并不是一个好主意。如果用户习惯的方式被改变了是令人惊讶的。
不要重载逗号操作符的另外一个原因是(同样使用于取地址 &
操作符)是语言定义了当逗号和取地址操作符用于类类型对象时的含义。由于这些操作符有内置的含义,它们通常不应该被重载。
最佳实践 :通常,逗号、取地址和逻辑与以及逻辑或操作符不应该被重载。
使用与内置操作符含义一致的定义
当我们设计类时,我们应该总是第一想到这个类应该提供什么操作。只有当你了解需要哪些操作时,你才能决定是将这个操作定义为常规函数或者是重载的操作符。那些在逻辑上匹配操作符的操作是定义重载操作符的好的候选对象:
- 如果类有 IO 操作,将移位操作符定义地与内置类型的 IO 含义一致;
- 如果类有一个操作可以比较相等性,定义
operator==
,如果类有operator=
,通常需要定义operator!=
; - 如果一个类具有单一的自然顺序(natural ordering)操作,定义
operator<
,如果类有operator<
,它通常需要所有的关系操作符; - 重载操作符的返回值类型通常需要与内置版本的操作符的返回值类型:逻辑和关系操作符应该返回 bool 值,算术操作符应该返回本类类型的值,赋值和复合赋值操作符应该返回左手操作数的引用;
赋值和复合赋值操作符
赋值操作符应该表现地类似于编译器合成的操作符:在赋值之后,左边和右边的操作数应该具有相同的值,并且操作符应该返回左边操作数的引用。重载的赋值操作符应该是内置类型的赋值操作的泛化(generalize)而不是绕过它。
注意:谨慎使用操作符重载
每个操作符在用于内置类型时都有一个固定含义。比如:二元 +
的含义就是表示加法。将二元 +
映射到类类型的类似操作上将提供方便的简化符号。如,标准库 string 类型遵从许多编程语言中共通的约定,用 +
表示拼接,将一个 string 添加到另外一个。
操作符重载在内置操作符能够在逻辑上映射到我们的类型上的操作时时最有用的。使用重载的操作符而不是命名的操作将使得我们程序更加自然和直观。滥用操作符重载则使得类难以理解。
明显的滥用操作符重载在现实中是十分少见的。更加常见的是扭曲一个操作符的“正常”含义来强制适用于一个给定的类型。只有在操作对于用户来说是明确(unambiguous)的时候才应该使用操作符。如果一个操作符看起来好像有多于一个解释,那么这个操作符就是模糊的。
如果一个类具有算术(arithmetic)或者按位(bitwise)操作符,那么同时提供对应的复合赋值操作符是一个好的想法。当然这些重载的操作符应该在行为上与内置操作符的含义一致。
选择作为成员或者非成员实现
当我们定义重载操作符时,我们必须决定使得操作符作为一个类成员还是一个普通的非成员函数。在某些情况下,这是没得选的,一些操作符必须是成员;在另外一些情况,我们又不能让它成为成员函数。
下面的指导方针可以帮助我们决定是否让一个操作符成为成员或者一个普通的非成员函数:
- 赋值(
=
)、下标([]
)、调用(()
)和成员访问箭头(->
)必须被定义为成员函数; - 复合赋值操作符通常应该(ought)是成员,然而,不像赋值操作符,这不是必须的;
- 改变对象状态的操作符或者与这个类类型关系十分密切(closely tied)的操作符应该(should)被定义为成员,如:自增、自减和解引用操作符;
- 对称操作符——它们可以转换任何一个操作数,比如算术运算、相等性比较、关系比较和按位操作符——通常应该(should)被定义为常规的非成员函数;
程序员会期望将对称操作符用于混合类型(mixed types)的表达式中。比如我们可以将 int 和 double 类型值进行相加。加法是对称的,因为我们可以让左边或者右边操作数的类型作为重载操作符的类型。如果我们想要提供类似的混合类型表达式于类对象上,那么操作符就必须被定义为非成员函数。
当我们将一个操作符定义为成员函数时,左边操作数将必须是操作符作为成员的类的对象。如:
string s = "world";
string t = s + "!";
string u = "hi" + s; //如果 + 是 string 的成员,此句将是错误
如果 operator+
是 string 类的一个成员,那么第一个加法将等价于 s.operator+("!")
,同样,"hi" + s
将等价于 "hi".operator+(s)
,然而类型 "hi"
是 const char*
,那个类型是内置类型;它根本没有成员函数。
由于 string 将 +
定义为普通的非成员函数,"hi" + s
等价于 operator+("hi", s)
,与任何函数调用一样,其中任意一个实参都可以转为合适的形参类型。它唯一的要求就是至少有一个操作数是 string 类型,且两个操作数都可以明确地转换为 string。
正如我们所见,IO 标准库使用 >>
和 <<
来表达输入和输出。IO 标准库自身定义如何读写内置类型的这些操作符的版本。如果类需要支持 IO ,那么同样需要定义自己的这些操作符的版本。
通常输出操作符的第一个形参是一个非 const ostream 对象的引用。第二个参数应该是一个我们想要打印的类类型的 const 对象引用。为了以其它的输出操作符一致,operator<<
通常应该返回其 ostream 参数。如:
ostream & operator<<(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
输出操作符通常只做最少的格式化
内置类型的输出操作符只做最少的格式化,特别是不打印任何换行符。用户对于类输出操作符也期待类似的行为。输出操作符只做最少的格式化将让用户控制输出的细节。
IO操作符必须是非成员函数
符合 iostream 标准库中的约定的输入输出操作符应该被定义为常规的非成员函数。这些操作符不能我们自己的类的成员。如果是的话,那么左边的操作数将不得不是我们自己的类类型对象:data << cout;
如果他们必须是哪个类的成员的话,他们最好是 istream 或者 ostream 的成员。然而,这些对象是标准库的一部分,我们是不能添加成员到这些类的。
因而,如果我们想要为我们的类型定义 IO 操作符的话,我们必须将其定义为非成员函数。当然,IO 操作符通常需要读或写非 public 数据成员。因而,IO 操作符通常被声明为友元。
通常输入操作符的第一个参数是输入流对象的引用,第二个参数是写入的对象的非 const 引用。操作符通常返回给定流对象的引用。第二个参数必须是非 const 的,是由于输入操作符的目的就在于将输入写入到此对象中。如:
istream &operator>>(istream &is, Sales_data &item)
{
double price;
is >> item.bookNo >> item.units_sold >> price;
if (is)
item.revenue = item.units_sold * price;
else
item = Sales_data();
return is;
}
if
检查读取是否成功,如果发生了 IO 错误,操作符将给定的对象重置为空的 Sales_data
对象。这样将保证对象处于一致的状态。(Effective C++ 要求的基本异常安全就是让对象不论任何时候都处于一致的状态,而“强烈保证”则是不论发生任何异常,对象处于不变的状态)。
注意:输入操作符必须处理可能出现的输入错误;输出操作符通常没有这样的烦恼;
在输入时发生的错误
在输入操作符中可能发生如下种类的错误:
- 读操作可能会应为流包含了不正确的类型的数据。比如,如果想要读取两个数字类型的数据,但是输入流中包含的不是数字类型的,那么读取和接下来的使用将会失败;
- 任何读操作都可能会遇到到达文件尾部(end-of-file)或者一些别的错误;
相较于每次读都进行检查,我们在读取所有数据之后并在使用这些数据之前进行一次检查。将对象置于有效的状态是非常重要的,因为对象可能会在错误发生前被部分地改变。
将对象置于一种有效的状态,将保护那些忽略了输入错误可能性的用户。对象将依然处于可用的状态,类似的,对象不会导致误导的结果,这是因为数据是内在一致的。
最佳实践输入操作符应该决定在错误发生时采取生么措施进行错误恢复。
指示发生的错误
一些输入操作符需要做一些额外的数据校验。如需要对数据的合法范围进行校验,或者数据是合法的格式。在这种情况下输入操作符需要设置流的条件状态(condition state)来表示错误,即便从技术上来说实际上 IO 是成功的。通常输入操作符只能设置 failbit
。设置 eofbit
将暗含文件被耗尽,设置 badbit
将表示流损坏。这些错误最好是留给 IO 库自己去设置。
通常,我们讲算术和关系运算符定义为非成员函数,这样可以让左边或者右边的操作数可以进行合适的转换。这些操作符不应该改变操作数的状态,所以参数通常是 const 引用类型。
一个算术操作符通常会产生一个新的值,这个值是计算两个操作数所得到的。这个值区别于任何一个参数,并且是在本地变量中计算的。这个操作返回这个本地变量的拷贝作为结果。定义算术操作符的类通常会定义对应的复合赋值操作符。当一个类同时具有这两个操作符时,通常讲算术操作符定义为使用复合赋值操作符时更加高效的,如:
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum = lhs;
sum += rhs;
return sum;
}
提示:同时定义了算术运算符和相应的复合赋值操作符的类应该将算术运算符实现为复合赋值操作。
通常,C++ 的类定义相等操作符来测试两个对象是否相等。它们通常会比较每一个数据成员,只有在对应的所有成员都相等时才会认为是相等。如:
bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
return lhs.isbn() == rhs.isbn() &&
lhs.units_sold == rhs.units_sold &&
lhs.revenue == rhs.revenue;
}
bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
return !(lhs == rhs);
}
这些函数的定义是十分简单的,更为重要的它们所涉及到的设计原则:
- 如果一个类有操作来决定两个对象是否相等,它应该将函数定义为
operator==
而不是具名函数;用户会期待使用==
来进行对象的比较;提供==
意味着它们不需要学习和记住操作的新的名字;并且如果类定义了==
操作符将更加容易使用标准库容器和算法; - 如果一个类定义了
operator==
,那么这个操作符通常应该决定给定对象是否具有相等的数据; - 通常,相等操作符应该是可传递的,意味着如果
a == b
并且b ==c
,那么a == c
应该同样为真; - 如果一个类定义了
operator==
,那么它通常应该定义operator!=
,两者是相互依存的; - 相等或不等操作符应该将其工作交给另外一个去完成。意味着,其中一个操作符将做真正的比较对象的操作,而另外一个应该调用这个来完成其工作;
最佳实践 如果一个类具有逻辑上的相等比较操作通常应该定义 operator=
,类定义 ==
将使得其容易与通用算法一起使用。
定义相等操作符的类同样也会定义关系操作符。特别是由于关联容器和一些算法使用小于操作符,那么定义 operator<
将十分有用。
通常关系运算符应该:
- 定义与作为关联容器中的键的要求一致的顺序关系;并且
- 如果类同时定义了
=
,那么应该定义与==
一致的顺序关系。特别是,如果两个对象有!=
的性质,那么一个对象应该<
另外一个。
对于像 Sales_data
这种没有逻辑上的 <
概念的类型,最好是不要定义关系操作符。
最佳实践如果存在 <
操作的单一逻辑上的定义,那么通常我们应该定义 <
操作符。然而,如果类同时有 ==
,只有在 <
和 ==
操作符产生一致的结果时才重载 <
操作符。
除了可以将相同类型的对象拷贝赋值或移动赋值给另外一个对象之外,一个类还可以定义额外的赋值操作符用于将其它类型的对象作为右边的操作数。
比如,vector 类定义了第三个赋值操作符,其参数是括号包围的元素(a braced list of elements),我们可以按如下方式使用操作符:
vector<string> v;
v = {"a", "an", "the"};
我们可以将这个操作符添加到我们自己的 StrVec 类中:
class StrVec {
public:
StrVec &operator=(std::initializer_list<std::string>);
};
为了与内置类型的赋值操作(并且与已经定义的拷贝赋值和移动赋值操作符一致),我们的新的赋值操作符将返回左操作数的引用。
StrVec &StrVec::operator=(std::initializer_list<string> il)
{
auto data = alloc_n_copy(il.begin(), il.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
与拷贝赋值和移动赋值操作符一样,其它重载的赋值操作符应该释放掉已经存在有元素并且创建新的元素。不同于拷贝赋值和移动赋值操作符,这个操作符不需要检查自赋值(self-assignment)。参数的类型是 initializer_list<string>
意味着 il 不可能与 this 所表示的对象相同。
提示:赋值操作符可以被多次重载。赋值操作符不管参数类型是什么都必须定义为成员函数。
复合赋值操作符
复合赋值操作符并不需要必须是成员。然而,我们倾向于将所有的赋值操作包括复合赋值操作定义在类中。为了与内置复合赋值操作符保持一致,这些操作符将返回左操作数的引用。比如:
Sales_data &Sales_data::operator+=(const Sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
最佳实践赋值操作符必须是成员,复合赋值操作符应该是成员。这些操作符应该返回左边操作数的引用。
表示容器的类型通常会定义下标操作符 operator[]
来通过位置获取元素。重载下标操作符必须是成员函数。
为了兼容常规的下标操作符的含义,重载下表操作符通常返回获取的元素的引用。通过返回引用,下标操作可以用于赋值操作的任何一边。因而,同时定义 const 和非 const 版本的操作符是一个好的主意。当运用于 const 对象时,下标操作应该应该返回一个 const 引用,那么就不能对返回的对象进行赋值。
最佳实践当一个类有下标操作符时,它通常应该定义两个版本:一个返回非 const 引用,另一个是 const 成员并返回 const 引用。如:
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 的,我们就可以对元素进行赋值;如果对 const 对象进行下标操作,我们便不能这样做:
自增(++
)和自减(--
)操作符最常被迭代器类实现。这个操作符让类在序列上的元素之间移动。语言并不要求这些操作符必须是类的成员。然而,由于这些操作符改变了它们操作的对象的状态,我们倾向于让它们成为成员。
对于内置类型,同时存在前置和后置版本的自增和自减操作符。我们同样也能同时为类类型定义前置和后置的版本。
最佳实践定义自增和自减操作符的类应该同时定义前置和后置版本。这些操作符通常应该被定义为成员。
定义前置自增/自减操作符
为了演示,我们给 StrBlobPtr 类定义前置的自增和自减操作符:
class StrBlobPtr {
public:
StrBlobPtr &operator++();
StrBlobPtr &operator--();
};
最佳实践为了与内置类型操作符保持一致,前置操作符应该返回自增后或者自减后的对象的引用。
区别前置和后置操作符
当同时定义前置和后置版本时会遇到一个问题:正常的重载不能区分这两个操作符。前置和后置版本使用相同的符号,意味着重载版本具有相同的名字。它们同时具有相同的数目和类型的操作数。
为了解决这个问题,后置版本有一个额外的(不使用的)int 类型参数。当我们使用后置版本的操作符时,编译器给这个形参提供 0 作为实参。尽管后置版本的函数可以使用这个额外的参数,通常是不应该使用。这个参数本身就是后置操作符正常工作所不需要的。它存在的唯一目的就是让前置版本与后置版本进行区分。如:
class StrBlobPtr {
public:
StrBlobPtr operator++(int);
StrBlobPtr operator--(int);
};
最佳实践为了与内置类型操作符保持一致,后置操作符应该返回旧的(未自增或者未自减)的值。这个值将作为值返回而不是引用。
注意int 参数没有被使用,所以我们没有给其一个名字。
显式调用后置操作符
我们可以显式调用重载的操作符作为另外一种在表达式中使用操作符的方式。如果我们想要用函数调用方式调用后置版本,我们就必须自己提供这个整数参数:
StrBlobPtr p(a1);
p.operator++(0); //调用后置版本的 operator++
p.operator++(); //调用前置版本的 operator++
传递过去的值通常是被忽略的,但是依然需要传递这是为了告知编译器使用的是后置版本。
解引用(*)和箭头(->
)操作符通常用于表示迭代器的类中,以及智能指针类。
class StrBlobPtr {
public:
std::string &operator*() const
{ return (*p)[curr]; }
std::string *operator->() const
{ return & this->operator*(); }
};
箭头操作符通过调用解引用操作符并返回那个操作符的返回元素的地址来避免做任何实际的工作。
注意箭头操作符必须是成员。解引用操作符就没有要求必须是成员,但通常应该被定义为成员。
这里值得注意的是我们将这些操作符定义为 const 成员。不像自增和自减操作符,获取成员不会改变 StrBlobPtr 自身的状态。同样需要注意的是这些操作符返回一个非 const string 对象的引用或指针。它们这样做的原因在于我们知道 StrBlobPtr 只能绑定到非 const StrBlob 对象上。以下是使用过程:
StrBlob a1 = { "hi", "bye", "now" };
StrBlobPtr p(a1);
*p = "okay";
cout << p->size() << endl;
cout << (*p).size() << endl;
箭头操作符的返回值的限制
与绝大多数其它操作符一样,我们可以定义 operator*
做任何我们喜欢的操作,如返回固定值 42 或者打印对象的内容。当箭头操作符的重载不能这么做,箭头操作符不能丢失其成员访问的基本含义。我们不能改变箭头操作符获取成员的事实。
当我们书写 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。如果结果是一个指针,那么从在这个指针上执行步骤1。如果结果是一个自身重载了operator->()
对象,那么步骤2将在那个对象上重复。这个过程一直持续到要么得到一个对象(这个对象有指定的成员)的指针,要么返回一个其它的值,这第二种情况下代码是错误的。
注意重载的箭头操作符必须要么返回一个类类型的指针要么是一个定义了自己的箭头操作符的类类型对象。
重载了调用操作符的类允许这个类型的对象就好像是函数一样使用。由于此类还存储了状态,它们将比常规函数更加的灵活。
作为一个简单的例子,下面的 absInt 就有一个调用操作符返回其参数的绝对值:
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
这个类定义了单一操作:函数调用操作符。这个操作符以 int 类型作为实参,并返回实参的绝对值。我们通过类似函数调用的方式将参数列表运用于 absInt 对象来调用这个 ()
操作符。如
int i = -42;
absInt absObj;
int ui = absObj(i); //将 i 传递给 absObj.operator()
尽管 absObj 是一个对象不是函数,我们可以“调用”这个对象。调用一个对象将运行其重载的调用操作符。在这种情况下,这个操作符取一个 int 值,并返回其绝对值。
注意函数调用操作符必须是成员函数。一个类型可以定义多个调用操作符版本,其中每一个必须在参数的个数或类型不一样。
定义了调用操作符的类对象被称为函数对象(function objects),这种对象“在行为上类似于函数”,因为我们可以调用它们。
具有状态的函数对象类(Function-Object Classes with State)
与别的类一样,函数对象类除了 operator()
外,可以有额外的成员。函数对象类经常包含数据成员用于定制调用操作符。如我们可以在调用函数时提供不同的分割符,我们可以如下定义类:
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;
};
这个类的构造函数以输出流的引用和一个字符作为分割符。它使用 cout 和空格作为默认的实参。调用操作符的函数体则使用这些成员来定义给定的 string 对象。
当我们定义 PrintString 对象时,我们可以使用默认的或者提供我们自己的分割符或者输出流:
PrintString printer;
printer(s);
PrintString errors(cerr, '\n');
errors(s);
函数对象最长用于通用算法的实参。我们可以将 PrintString 的对象传递给 for_each
算法来打印容器中的内容:
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
上面的小节中,我们使用了 PrintString 对象作为实参来调用 for_each
,这个用法类似于我们在 §10.3.2 中使用的 lambda 表达式。当我们写 lambda 时,编译器将其翻译成一个匿名类的匿名对象(unnamed object of an unnamed class)。这个类从 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 为参数并比较它们的长度。如我们在 §10.3.3 中所见,默认情况下 lambda 不会改变其捕获变量。因而,默认情况下由 lambda 生成的类的函数调用操作符是一个 const 成员函数。如果 lambda 被声明为 mutable,那么调用操作符将不是 const 的。
表示具有捕获值的 lambda 的类
正如我们所见,当一个 lambda 按照引用捕获一个变量时,将有程序保证被引用的变量在 lambda 执行时依然存在。这样编译器就允许直接使用引用而不需要将引用作为数据成员存储在生成的类中。
作为比较,如果变量是按照值捕获的则被拷贝到 lambda 中。因而,从 lambda 中生成的类将有数据成员与每个值捕获的变量对应。这些类还有一个构造函数来初始化这些数据成员,其值来自于捕获的变量。如:
auto wc = find_if(words.begin(), words.end(), [sz](const string &a) { return a.size() > sz; });
将产生如下类的代码:
class SizeComp {
public:
SizeComp(size_t n): sz(n) { }
bool operator()(const string &s) const {
return s.size() >= sz;
}
private:
size_t sz;
};
不像我们的 ShorterString 类,这个类有一个数据成员以及一个构造函数来初始化这个成员。这个合成的类没有默认构造函数;为了使用这个类,我们必须传递参数:
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
从 lambda 表达式中生成的类有一个被删除的默认构造函数、被删除的赋值操作符以及默认析构函数。类是否有默认或删除的拷贝/移动构造函数取决于捕获的数据成员的类型,这与普通类的规则是一样的。
标准库定义一系列类来表示算术、关系和逻辑操作符。每个类定义了一个调用操作符以运用其类名所表示的操作。比如,plus
类有一个调用操作符来运用 +
于一对操作数;modulus
类定义了一个调用操作符以运用 %
操作符;equal_to
类运用 ==
;等等。
这些类都是需要提供一个类型的类模板。这些类型指定了调用操作符的参数的类型。比如,plus<string>
运用 string 的加操作符于 string 对象;plus<int>
的操作数是 int;plus<Sales_data>
将 +
运用于 Sales_data
对象;等等;
plus<int> intAdd; //可以对两个 int 值做加法的函数对象
negate<int> intNegate; //可以对一个 int 取反的函数对象
int sum = intAdd(10, 20); // == 30
sum = intNegate(intAdd(10, 20)); // == -30
sum = intAdd(10, intNegate(10)); // == 0
以下是定义于 functional 头文件中的类型:
算术运算
plus<Type>
minus<Type>
multiplies<Type>
divides<Type>
modulus<Type>
negate<Type>
关系比较
equal_to<Type>
not_equal_to<Type>
greater<Type>
greater_equal<Type>
less<Type>
less_equal<Type>
逻辑运算
logical_and<Type>
logical_or<Type>
logical_not<Type>
使用标准库函数对象于通用算法
表示操作符的函数对象类经常被用于重载算法所使用的默认操作符。如我们所知,默认情况下,排序算法使用 operator<
来将序列排序为升序序列。为了按照降序排列,我们可以传递一个 greater 类型的对象。这个类将生成一个调用操作符以调用底层元素类型的 operator>
,如:
sort(svec.begin(), svec.end(), greater<string>());
这里第三个参数是一个类型为 greater<string>
的匿名对象。当 sort 比较元素时,不是使用 operator<
而调用给定 greater 函数对象。那个对象将运用 string 元素的 >
操作符。
这些库中的函数对象的一个重要方面就是库保证它们可以工作于指针上。回想以下比较两个不相关的指针是未定义(undefined)的。然而,我们也许想基于在内存中的地址对一个指针 vector 进行 sort,标准库函数对象就可以做到:
vector<string *> nameTable;
//错误:nameTable 中的指针是不相关的,所以 < 是未定义的
sort(nameTable.begin(), nameTable.end(),
[](string *a, string *b) { return a < b; });
//ok:库保证 less 在指针类型上工作良好
sort(nameTable.begin(), nameTable.end(), less<string*>());
值得说明的是关联容器使用 less<key_type>
来排序元素。因而,我们可以定义指针的 set 或者使用指针作为 map 中的键而不用直接指定 less 对象。
C++ 有多种可调用对象:函数和函数指针,lambdas,由 bind 创建的对象,重载函数调用操作符的类。
与任何别的对象一样,可调用对象是有类型的。如,每个 lambda 有一个自己的唯一的匿名类类型。函数和函数指针类型根据它们的返回值类型和参数类型的不同而有所不同,等等。
然而,两个不同类型的可调用对象也许会有相同的调用签名(call signature)。这个调用签名说明了调用此对象时返回的类型以及必须传递的参数类型。下面是函数类型的调用签名:int(int, int)
表示一个以两个 int 为参数返回一个 int 的函数类型。
不同的类型可以有相同的调用签名
有时我们希望将有着同一个调用前面的几个可调用对象看做是同一个类型。如考察下面的不同类型的可调用对象:
//函数
int add(int i, int j) { return i + j; }
//lambda
auto mod = [](int i, int j) { return i % j; };
//函数对象类
struct div {
int operator()(int denominator, int divisor) {
return denominator / divisor;
}
};
尽管它们的类型不一样,它们的调用签名是一样的:int(int, int)
。我们也许想用这些可调用对象来创建一个简单的计算器。为了达到目的,我们需要定义一个函数表(function table)来存储这些可调用对象的“指针”。如果我们将函数表定义为如下:
map<string, int(*)(int, int)> binops;
我们可以将 add 以 binops.insert({"+", add});
添加进去,但是我们不能添加 mod
,因为 mod 是 lambda,然而每个 lambda 都有自己的类类型。这与 binops 中的值的类型是不一致。
标准库 std::function 类型
我们通过一个定义在 functional 头文件中的新的标准库类 std::function
来解决此问题;下表列举了定义在 function 中的操作:
function<T> f;
f 是一个空的 function 对象,其可以存储 T 所表示的调用签名的可调用对象(T 是形如retType(args)
的格式);function<T> f(nullptr);
显式构建一个空的 function;function<T> f(obj);
存储可调用对象 obj 的一份拷贝到 f 中;f
将 f 作为条件使用;如果 f 中持有一个可调用对象返回 true,否则返回 false;f(args)
传递 args 去调用 f 中的对象;
定义为function<T>
的成员类型
result_type
这个 function 类型的可调用对象的返回值类型;argument_type
first_argument_type
second_argument_type
当 T 只有一个或两个参数时的参数类型。如果 T 只有一个参数,argument_type
就是那个类型。如果 T 有两个参数,first_argument_type
和second_argument_type
分别是哪些参数类型。
function 是模板。与其它模板一样,当我们创建 function 类型对象时我们必须提供额外的信息,在这里是调用签名,如:
function<int(int, int)>
我们可以用上面的 function 类型来表示可调用对象:接收两个 int 参数并返回一个 int 结果。如:
function<int(int, int)> f1 = add;
function<int(int, int)> f2 = div();
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;
我们可以按照这个方式重新定义我们的 map:
map<string, function<int(int, int)>> binops = {
{"+", add}, //函数指针
{"-", std::minus<int>()}, //库函数对象
{"/", div()}, //用户定义函数对象
{"*", [](int i, int j) { return i * j; }}, //匿名 lambda
{"%", mod} //具名 lambda
};
我们的 map 有五个元素,尽管其底层类型都不一样,我们可以将其存储到同一个调用签名的 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}); //错误:哪一个 add?
一种解决这种二义性的方式是存储函数指针而不是函数的名字:
int(*fp)(int, int) = add;
binops.insert({"+", fp});
或者使用 lambda 包装一下:
binops.insert({"+", [](int a, int b){return add(a, b);}});
注意:新标准库中 function 类与之前版本中的 unary_function
和 binary_function
是不相关的。这些类已经被更加通用的 bind
函数给取代了。
在 §7.5.4 中我们看到非 explicit 的具有一个参数的构造函数定义了隐式转换(implicit conversion)。这种构造函数将参数类型的对象转换到类类型对象。我们还可以定义从类类型到别的类型的转换。我们通过定义一个转换操作符来定义从类类型的转换。转换构造函数(converting constructor)和转换操作符(conversion operators)定义类类型转换(class-type conversions)。这些转换也被称为是用户定义的转换(user-defined conversions)。
转换操作符(conversion operator)是一种特殊的成员函数,可以将一个类类型的值转为一个其它类型的值。转换函数通常由这样一种通用的形式:operator type() const;
其中 type
表示一个类型。转换操作符可以为任何可以被函数返回的类型(除了 void)定义。转换成数组或者函数类型是被禁止的。转换成指针类型(数据和函数指针)以及引用类型是允许的。
转换操作符没有显式说明返回值类型并且没有参数,它们必须被定义为成员函数。转换操作符通常不应该改变发生转换的对象的状态。因而,转换操作符通常被定义为 const 成员。
注意转换函数必须是成员函数,并且不指定返回值类型,其参数列表必须是空的。这个函数通常应该是 const 的。
定义一个具有转换操作符的类
如下代码:code/conversion_operator.cc,其中构造函数将算术类型转为 SmallInt,转换操作符将 SmallInt 转为 int:
SmallInt si;
si = 4; //隐式将 4 转为 SmallInt,然后调用 SmallInt::operator=
si + 3; //隐式将 si 转为 int,然后执行整数加法
尽管编译器一次只会执行一个用户定义的转换 §4.11.2,隐式用户定义的转换在内置标准转换之前或之后发生。因而,我们可以传递任何算术类型给 SmallInt 的构造函数。通常,我们可以使用转换操作符将 SmallInt 转为 int 然后将结果 int 值转为别的算术类型:
SmallInt si = 3.14;
si + 3.14;
由于转换操作符是隐式运用的,没有任何途径去传递参数给这些函数。因而,转换操作符不能被定义为接收参数。尽管转换函数没有指定其返回值类型,每个转换函数都必须返回一个与其名字所表示的类型一样的值。
注意:避免滥用转换函数
与使用重载操作符一样,谨慎地使用转换操作符可以极大地简化类设计者的工作,并且使得类更加容易使用。然而,一些转换是容易被误导的。当没有明显的在类类型与要转换的类型之间的单一映射,转换操作符将是误导的。
比如,考虑表示日期的 Date
类,我们可能会认为在 Date
和 int 之间提供转换是一个好主意。然而,这个转换函数应该返回什么呢?这个函数也许会返回一个年月日的值,也可以返回从 1970年1月1日起走过的秒数。这两种方式都可以有效表示,当问题是没有单一的一对一的映射,所以最好是不要定义转换操作符。相反,类应该定义一个或多个常规的成员函数来提取这些信息。
转换操作符可能会产生令人惊讶的结果
在实践中,类极少提供转换操作符。用户经常会惊讶于一个转换自动就发生了,而不是显式进行转换。然而,有一个重要的例外是:定义从类类型到 bool 值的转换并不少见。
在标准的早期版本中,类想要定义到 bool 的转换会面临一个问题:由于 bool 是算术运算符,类类型对象如果被转为 bool ,那么它可以用于任何算术类型被期待的上下文中。这种转换可能会发生在令人吃惊的地方。特别是,比如 istream 有一个转为 bool 的转换符,下面的代码将是合法的:
int i = 42;
cin << i; //cin隐式转为 bool,之后将发生移位操作
显式转换操作符
为了阻止上面的问题,新标准中引入了 explicit 转换操作符(explicit conversion operators):
class SmallInt {
public:
explicit operator int() const { return val; }
};
与 explicit 构造函数一样,编译器不会自动运用 explicit 转换操作符进行隐式转换:
SmallInt si = 3;
si + 3; //错误:用到了隐式转换,但是重载的操作符是显式的
static_cast<int>(si) + 3; //ok:显式调用转换
如果转换操作符是 explicit 的,我们依然可以做转换。然而,除了一个例外之外,我们必须使用 cast 进行显式转换。
这个例外是编译器会将 explicit 转换用在条件中,如下:
- 如果条件是一个 if, while 或者 do 语句;
- 如果条件表达式在 for 语句的头部;
- 如果作为逻辑非(
!
)、或(|
)或者与(&&
)操作符的操作数; - 条件操作符(
?:
)的条件表达式部分中;
转换为 bool 值
在早期版本的标准中,IO 类型定义了 void*
类型的转换。这样就避免了上面的问题。在新标准下,IO 库定义了 explicit 转换为 bool 的方法。
最佳实践 将类转为 bool 通常是用于条件,因而,operator bool
通常应该被定义为 explicit
的。
如果一个类有一个或多个转换,重要是确保从类类型到目标类型只有一条路径。如果有超过一条路径来执行转换,那么想要写出无二义性的代码将会很难。
有两种方式会导致多中转换路径。第一种是两个类都提供了相互的转化。如,相互转换存在于当类 A 定义了转换构造函数其接收类 B 的对象作为参数,并且 B 自身定义了一个转换操作符用于转为类型 A。
第二中方式是定义了多个从或到相互之间可以转换的类型的转换。最明显的例子就是内置算术类型。一个给定的类应该最多定义一个从或到算术类型的转型。
警告通常,给类之间定义相互转换,或者定义从或到两个以上的算术类型的转换是个坏主意。
参数匹配和相互转换
下面的例子将定义两种方式用于从 A 到 B 的转换:通过 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); //错误的二义性:f(B::operator A()) 或 f(A::A(const B&))
由于有两种方式从 B 得到一个 A,编译其不知道该运行哪个转换;调用 f 便是模糊的。这个调用可以使用 A 的构造函数并接收 B 作为参数,或者可以使用 B 的转换操作符将 B 转为 A。由于两个函数是一样好的,这个调用便是错误的。
如果我们想要使用这个调用,我们不得不显式调用转换操作符或者构造函数:
A a1 = f(b.operator A());
A a2 = f(A(b));
注意我们不能用 cast 来解决二义性,这是由于 cast 本身就有相同的二义性问题。
二义性和多重转换到内置类型
二义性还会发生在类定义了多个转换到(或者从)相互之间相关的类型。最简单的方式是定义转换到(或从)算术类型。
如下面的类型定义两个从不类型的算术类型的转换构造函数,已经两个到不同类型的算术类型的转换操作符:
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 的调用中,两个转化都不是精确匹配 long double
类型。然而,两个转化都可以使用,并在之后跟者一个标准转换从而得到一个 long double
。因此,没有一个转换是由于另外一个的;这个调用便是模糊的。
当我们尝试用 long 初始化 a2 时遇到了通常的问题。没有一个构造函数是精确匹配 long 的。每个都需要参数在被构造函数前进行转换,要么是从 long 到 double 的转换,要么是从 long 到 int 的转换。这两个转换序列都是可行的,因而调用是模糊的。
调用 f2 以及初始化 a2 是模糊的,这是由使用到的标准转换具有相同的优先级。当用户定义的转换被使用时,标准转换的优先级将被用于选择最佳的匹配,如:
short s = 42;
A a3(s); //将 short 提升为 int 要优于将 short 转为 double,因而使用的 A::A(int)
注意当两个用户定义的转换被使用时,标准转换的优先级如果有的话(在转换函数之前或之后运用),将被用于选择最佳匹配。
重载函数和转换构造函数
在多个转换中选择一个在我们调用重载函数时将更为复杂。如果两个或多个转换提供可行匹配,那么这些转换被认为是一样好。例如,当我们调用重载函数其参数是不同的类类型,但是定义了相同的转换构造函数时会发生二义性错误:
struct C {
C(int);
};
struct D {
D(int);
};
void manip(const C&);
void manip(const D&);
manip(10); //二义性错误:manip(C(10)) 或 manip(D(10))
C 和 D 都有构造函数接收 int 类型的参数。任何一个都可以用于匹配 manip 的一个版本。因此,调用是模糊的。可以通过显式构建正确的类型对象来消除二义性。如:manip(C(10));
在调用重载函数的过程中需要使用构造函数或者 cast 来转换参数通常意味着差的设计。
警告:转换和操作符
正确设计重载操作符,转换构造函数以及转换函数需要小心。特别是,如果类同时定义了转换操作符和重载操作符时容易产生二义性。下面这些规则将会有所帮助:
- 不要定义相互转换类,如果类 Foo 有一个构造函数以类 Bar 的对象为参数,不要在 Bar 中定义转换操作符到类型 Foo;
- 避免转换内置算术类型,特别是如果你已经定义了一个到算术类型的转换,那么:
- 不要定义以算术类型为参数的重载操作符。如果用户需要使用这些操作符,转换操作会将你的类型的对象到内置类型,然后使用内置类型的操作符进行计算;
- 不要定义转换到超过一个算术类型。让标准转换提供到其它算术类型的转换;
最简单的规则是:除了定义 explicit 转换到 bool ,避免定义转换函数,并且限制非 explicit 构造函数。
重载函数和用户定义的转换
如果重载函数的一个调用,有两个或更多的用户定义的转换提供可行匹配,那么转换被认为是一样好的。任何标准转换的优先级都不会被考虑。内置转换是否需要只有重载集合只有在调用匹配时使用相同的转换函数(the same conversion function),意思是只有在都使用相同的用户定义的转换之后才会考虑接下来的标准转换。
以下,manip 即便在其中一个类的构造函数的参数需要标准转换也是二义性调用:
struct E {
E(double);
};
void manip2(const C&);
void manip2(const E&);
manip2(10); //二义性错误:两个不同的用户定义的转换都可以使用:manip2(C(10)) 或 manip2(E(double(10)))
在这个例子中,C 有一个从 int 而来的转换,E 有一个从 double 而来的转换。调用 manip2(10)
,两个 manip2 函数都是可行的:
manip2(const C&)
是可行的,原因是 C 有一个接收 int 的转换构造函数,这个构造函数的参数是精确匹配的;manip2(const E&)
是可行的,原因是 E 有一个接收 double 的转换构造函数,我们可以使用标准转换来转换 int ;
由于重载函数需要不同的用户定义的转换,这个调用就是模糊的。在这里即便其中一个需要标准转换,另外一个是精确匹配,编译器依然认为这个调用是错误的。
注意 在重载函数的调用中,只有在可行函数(viable functions)需要相同的用户定义转换时,其优先级才会被考虑。如果需要不同的用户定义转换,那么这个调用就是模糊的。
重载的操作符是重载的函数。普通的函数匹配用于决定使用哪个操作符(内置的还是重载)运用于给定的表达式。然而,当一个操作符函数被用于表达式中,候选的函数集合要多于常规的函数调用,如果 a 是一个类类型,表达式 a sym b
可能是:
a.operator sym(b); //a 有一个重载操作符sym作为成员函数
operator sym(a, b); //重载操作符sym是常规函数
不同于常规的函数调用,我们不能使用调用的形式来区分我们是使用成员函数还是非成员函数。
当我们将一个重载的操作符用于类类型的操作数时,候选的函数包括常规的非成员版本的操作符,以及内置版本的操作符。如果左边的操作数是类类型,并且如果它定义了成员版本的重载操作符也会被包含进来。
当我们调用一个命名函数时,相同名字的成员和非成员函数并不会彼此重载。这是由于调用成员函数和非成员函数的语法形式是决然不同的。当一个调用是通过类类型对象(或者引用或者指针)时,那么只有那个类的成员函数会被考虑。当我们将重载操作符用于表达式中时,没有任何东西来指示我们是使用成员还是非成员函数。因此,成员和非成员版本都必须被考虑。
注意 在表达式中使用的操作符的候选函数集合同时包括成员和非成员函数。
如:
class SmallInt {
friend SmallInt operator+(const SmallInt &, const SmallInt &);
public:
SmallInt(int = 0);
operator int() const { return val; }
private:
std::size_t val;
};
我们可以把两个 SmallInt 对象做加法,但是当我们想进行混合加法时会陷入二义性问题:
SmallInt s1, s2;
SmallInt s3 = s1 + s2; //使用重载 operator+
int i = s3 + 0; //错误:二义性
第二个加法之所以是模糊的,原因在于我们可以将 0 转为 SmallInt 然后使用 SmallInt 的 operator+
做运算,或者将 s3 转为 int 然后使用内置加法;
警告 为同一个类同时提供转换到算术类型的转换函数和重载操作符可能会在重载操作符和内置操作符之间导致二义性。