4.1 常用运算符
算术运算符
运算符 | 功能 | 用法 |
---|---|---|
+ | 一元正号 | + expr |
- | 一元负号 | - expr |
* | 乘法 | expr * expr |
/ | 除法 | expr / expr |
% | 求余 | expr % expr |
+ | 加法 | expr + expr |
- | 减法 | expr - expr |
1. 优先级和结合律
一元运算符的优先级最高,然后是乘法和除法,优先级最低的是加法和减法。上面所有的运算符都满足左结合律,意味着优先级相同时满足从左到右的顺序进行组合。
2. 溢出
当计算结果超出该类型所能表示的范围时可能产生溢出,比如最大的short
型数值为32767
,这时候+1
可能输出-32768
(这是因为符号位从0
变为1
,从而变成负值)。当然在别的系统程序的行为可能不同甚至崩溃。
3. 除法与负号
Tips:C++语言的早期版本允许结果为负值的商向上或向下取整,C++11新标准规定商一律向0取整(即直接切除小数部分)。如果两个运算对象的符号相同则商为正,否则商为负。
21 / 6; // 3
-21 / -6; // 3
21 / -6; // -3
-21 / 6; // -3
4. 取余与负号
Tips:如果
m%n
不等于0,那么运算结果的符号和m相同。
21 % 6; // 3
21 % 7; // 0
-21 % -8; // -5
21 % -5; // 1
逻辑运算符
结合律 | 运算符 | 功能 | 用法 |
---|---|---|---|
右 | ! | 逻辑非 | !expr |
左 | < | 小于 | expr < expr |
左 | <= | 小于等于 | expr <= expr |
左 | > | 大于 | expr > expr |
左 | >= | 大于等于 | expr >= expr |
左 | == | 相等 | expr == expr |
左 | != | 不等 | expr != expr |
左 | && | 逻辑与 | expr && expr |
左 | || | 逻辑或 | expr || expr |
1. 逻辑与和逻辑或的短路求值
逻辑与&&
和逻辑或||
都是先求左侧对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果才会计算右侧运算对象的值,这种策略被称为短路求值。基于短路求值的特点,我们可以通过左侧运算对象来确保右侧运算对象求值的正确性和安全性:
// 只能左侧运算对象为真则右侧运算对象才安全
index != s.size() && !isspace(s[index])
2. 不要连写关系运算符
因为关系运算符的求值结果是布尔值,所以将几个关系运算符连写在一起会产生意想不到的结果:
// 错误写法: 用i < j的布尔值结果与k比较
if (i < j < k)
// 正确写法: 使用&&或者||连接
if (i < j && j < k)
赋值运算符
1. 运算对象与返回结果
赋值运算符的左侧运算对象必须是一个可修改的左值,返回的结果是它的左侧运算对象(仍然是左值)。
Tips:注意赋值不等于初始化,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦去,并用一个新值替代它。
int i = 0, j = 0, k = 0; // 初始化而非赋值
const int ci = i; // 初始化而非赋值, 因此左侧运算对象可以是常量
1024 = k; // 错误: 字面值是右值
i + j = k; // 错误: 算数表达式是右值
ci = k; // 错误: ci是常量, 是不可修改的左值
2. 初始化列表赋值
C++11新标准允许使用初始化列表赋值:
// 1) 编译器warning提示窄化转换: narrowing conversion of ‘3.1499999999999999e+0’ from ‘double’ to ‘int’ inside { }
int k;
k = {3.14};
// 2) 无论左侧运算对象类型是什么, 初始值列表都可以为空, 此时编译器创造一个值初始化的临时量并将其赋给左侧运算对象
int i = {}; // i值为0
3. 赋值运算符满足右结合律
在下面的例子中,先执行j = 0
,返回左侧运算对象,再执行i = j
,因此执行结束后两个变量都被赋值为0。
int i, j;
i = j = 0;
4. 赋值运算符优先级较低
由于赋值运算符的优先级低于关系运算符的优先级,因此在条件语句中,赋值部分通常应该加上括号:
int i;
// 如果i = get_value()左右两侧不加括号的话, 含义就截然不同
while ((i = get_value()) != 10 ) {
// do something...
}
递增和递减运算符
编码规范:对于迭代器和其他模板对象使用前缀形式的自增(自减)运算符。不考虑返回值的情况下,前置自增
++i
通常比后置自增i++
效率高,因为后置自增需要对表达式的值i
进行一次拷贝,如果i
是迭代器或其他非数值类型,拷贝的代价是比较大的。
1. 前置版本和后置版本
前置版本会将运算对象加1(或减1),然后将改变后的对象作为求值结果。后置版本也会将运算对象加1(或减1),但是求值结果是运算对象改变之前值的副本。这两种运算符必须作用于左侧运算对象,其中前置版本将对象本身作为左值返回,后置版本将对象原始值的副本的作为右值返回。
Tips:除非必须,否则不用递增递减运算符的后置版本。前置版本的递增运算将值加1之后直接返回该运算对象,但是后置版本需要将原始值存储下来以便于返回这个未修改的内容,如果我们不需要修改前的值的话就是一种性能上的浪费。对于整数和指针类型而言,编译器可能对这种额外的工作进行优化,但是如果是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本习惯,这样不仅不需要担心性能问题,而且不会引入非预期的错误。
int i = 0, j;
j = ++i; // j = 1, i = 1: 前置版本得到递增之后的值
j = i++; // j = 1, i = 2:后置版本得到递增之前的值
2. 后置版本的可能使用场景
后置版本最常用的场景就是在一条语句中混用解引用和递增运算符的后置版本:
auto pbeg = v.begin();
// 输出元素直到遇到第一个负值
while (pbeg != v.end() && *pbeg >= 0)
cout << *pebg++ << endl; // 输出当前值并将pbeg向前移动一个元素
*pbeg++
这种写法非常普遍,会先把pbeg
的值加1,然后返回pbeg
的初始值的副本作为其求解结果,此时解引用的运算对象是pbeg
未增加之前的值。
成员访问运算符
点运算符和箭头运算符都可用于访问成员,ptr->mem
等价于(*ptr).mem
。需要注意的是解引用运算符优先级低于点运算符,所以必须加上括号。
条件运算符
条件运算符满足右结合律,意味着运算对象一般按照从右往左的顺序组合,因此我们使用嵌套条件运算符:
finalgrade = (grade > 90) ? "high pass"
: (grade < 60) ? "fail" : "pass"
注意条件运算符的优先级非常低,所以一条长表达式中嵌套了条件运算子表达式时,通常需要在两端加上括号:
cout << ((grade < 60) ? "fail" : "pass"); // 输出pass或者fail
位运算符(左结合律)
Tips:如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器,而且此时的左移操作可能会改变符号位的值,因此是一种未定义的行为。关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型。
运算符 | 功能 | 用法 |
---|---|---|
~ | 位求反 | ~ expr |
<< | 左移 | expr1 << expr2 |
>> | 右移 | expr1 >> expr2 |
& | 位与 | expr & expr |
^ | 位异或 | expr ^ expr |
| | 位或 | expr | expr |
1. 移位运算符
左移运算符<<
在右侧插入值为0
的二进制位,右移运算符>>
的行为则依赖其左侧运算对象的类型,如果该运算对象是无符号类型,在左侧插入值为0
的二进制位。
// 假定char占8位, int占32位
// 0233是八进制的字面值
// 二进制: 100111011
unsigned char bits = 0233;
// bits被提升为int类型, 然后向左移动8位
// 二进制: 00000000 00000000 10011011 00000000
bits << 8
// 向左移动31位, 左边超出边界的位丢弃掉了
// 二进制: 10000000 00000000 00000000 00000000
bits << 31
// 向右移动3位, 右边超出边界的位丢弃掉了
// 二进制: 00000000 00000000 00000000 00010011
bits >> 3
2. 位求反运算符
对于char
类型的运算对象首先提升为int
类型,提升时运算对象原来的位保持不变,往高位添加0
即可。接下来将提升后的值逐位求反。
// 假定char占8位, int占32位
// 0227是八进制的字面值
// 二进制: 10010111
unsigned char bits = 0227;
// char被提升为int型, 往高位添加0
// 二进制: 00000000 00000000 00000000 10010111
// 逐位求反
// 二进制: 11111111 11111111 11111111 01101000
~bits
3. 位与、位或和位异或
- 位与:两个都是
1
则返回1
,否则为0
- 位或:两个至少有一个为
1
则返回1
,否则为0
- 位异或:两个有且只有一个为
1
则返回1
sizeof运算符
sizeof
运算符返回一条表达式或者一个类型名字所占的字节数,所得的值是一个size_t
类型(一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小)。当传入一条表达式时,sizeof
运算符并不实际计算其运算对象的值。
1. 不同类型的sizeof运算结果
- 对
char
或者类型为char
的表达式执行sizeof
,返回1
- 对引用类型执行
sizeof
运算得到被引用对象所占空间的大小 - 对指针执行
sizeof
得到指针本身所占空间的大小 - 对解引用指针执行
sizeof
运算得到指针你指向的对象所占空间的大小,指针本身不需要有效 - 对数组执行
sizeof
运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof
运算并将所得结果求和 - 对
string
对象或vector
对象执行sizeof
运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间
2. sizeof返回常量表达式
因为sizeof
的返回值是一个常量表达式,因此我们可以用sizeof
的结果声明数组的维度。
3. sizeof中解引用指针
由于sizeof
满足右结合律并且与*
运算符的优先级一样,因此sizeof *p
等价于sizeof (*p)
。另外由于sizeof
不会实际求运算对象的值,所以在sizeof
的运算对象中解引用一个无效指针仍然是一种安全的行为,因为指针实际上并没有被真正地使用。
逗号运算符
逗号运算符含有两个运算对象,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值,如果右侧运算对象是左值,那么最终的求值结果也是左值。
逗号运算符通常被用在for循环中:
vector<int>::size_type cnt = ivec.size();
// 把从size到1的值依次赋给ivec的元素
for (vector<int>::size_type ix = 0; ix != ivec.size(); ++ix, --cnt) {
ivec[ix] = cnt;
}
4.2 运算符优先级
优先级 | 结合律 | 运算符 | 功能 | 用法 |
---|---|---|---|---|
1 | 左 | :: | 全局作用域 | ::name |
1 | 左 | :: | 类作用域 | class::name |
1 | 左 | :: | 命名空间作用域 | namespace::name |
2 | 左 | . | 成员选择 | object.member |
2 | 左 | -> | 成员选择 | pointer->member |
2 | 左 | [] | 下标 | expr[expr] |
2 | 左 | () | 函数调用 | name(epxr_list) |
2 | 左 | () | 类型构造 | type(expr_list) |
3 | 右 | ++ | 后置递增运算 | lvalue++ |
3 | 右 | -- | 后置递减运算 | lvalue-- |
3 | 右 | typeid | 类型ID | typeid(type) |
3 | 右 | typeid | 运行时类型ID | typeid(expr) |
3 | 右 | explicit cast | 类型转换 | cast_name<type>(expr) |
4 | 右 | ++ | 前置递增运算 | ++lvalue |
4 | 右 | -- | 前置递减运算 | --lvalue |
4 | 右 | ~ | 位求反 | ~expr |
4 | 右 | ! | 逻辑非 | !expr |
4 | 右 | - | 一元负号 | -expr |
4 | 右 | + | 一元正号 | +expr |
4 | 右 | * | 解引用 | *expr |
4 | 右 | & | 取地址 | &lvalue |
4 | 右 | () | 类型转换 | (type)expr |
4 | 右 | sizeof | 对象的大小 | sizeof expr |
4 | 右 | sizeof | 类型的大小 | sizeof(type) |
4 | 右 | sizeof... | 参数包的大小 | sizeof...(name) |
4 | 右 | new | 创建对象 | new type |
4 | 右 | new[] | 创建数组 | new type[size] |
4 | 右 | delete | 释放对象 | delete expr |
4 | 右 | delete[] | 释放数组 | delete []expr |
4 | 右 | noexcept | 能否抛出异常 | noexcept(expr) |
5 | 左 | ->* | 指向成员选择的指针 | ptr->*ptr_to_member |
5 | 左 | .* | 指向成员选择的指针 | obj.*ptr_to_member |
6 | 左 | * | 乘法 | expr * expr |
6 | 左 | / | 除法 | expr / expr |
6 | 左 | % | 取模(取余) | expr % expr |
7 | 左 | + | 加法 | expr + expr |
7 | 左 | - | 减法 | expr - expr |
8 | 左 | << | 向左移位 | expr << expr |
8 | 左 | >> | 向右移位 | expr >> expr |
9 | 左 | < | 小于 | expr < expr |
9 | 左 | <= | 小于等于 | expr <= expr |
9 | 左 | > | 大于 | expr > expr |
9 | 左 | >= | 大于等于 | expr >= expr |
10 | 左 | == | 相等 | expr == expr |
10 | 左 | != | 不相等 | expr != expr |
11 | 左 | & | 位与 | expr & expr |
12 | 左 | ^ | 位异或 | expr ^ expr |
13 | 左 | ` | ` | 位或 |
14 | 左 | && | 逻辑与 | expr && expr |
15 | 左 | ` | ` | |
16 | 右 | ? : | 条件 | expr ? expr : expr |
16 | 右 | = | 赋值 | lvalue = expr |
16 | 右 | += 等 | 复合赋值 | lvalue += expr 等 |
17 | 右 | throw | 抛出异常 | throw expr |
18 | 左 | , | 逗号 | expr, expr |
4.3 重载运算符
基本概念
1. 定义
重载的运算符是具有特殊名字的函数,它们的名字由关键字operator
和其后要定义的运算符号共同组成。
我们也可以直接调用一个重载的运算符函数:
// 非成员运算符函数的等价调用
data1 + data2;
operator+(data1, data2);
// 成员运算符函数的等价调用
data1 += data2;
data1.operator+=(data2);
2. 参数数量
重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()
之外,其他重载运算符不能含有默认实参。
Tips:当一个重载符是成员函数时,
this
绑定到左侧运算对象,成员运算符的(显式)参数数量比运算对象的数量少一个。
3. 参数类型
对于一个运算符而言,它要么是类的成员函数,要么至少含有一个类类型的参数。这意味当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。
4. 不应该重载的运算符
Tips:我们可以重载大部分的运算符,但是
::
、.*
、.
和?:
这四个运算符是不能被重载的
-
不建议重载逻辑与运算符
&&
、逻辑或运算符||
和逗号运算符,
:使用重载的运算符本质上是一次函数调用,像逻辑与运算符&&
、逻辑或运算符||
和逗号运算符,
的运算对象求值顺序规则无法保留下来,逻辑与运算符&&
和逻辑或运算符||
的短路求值规则也无法保留下来 -
不建议重载逗号运算符
,
和取地址运算符&
:C++语言定义了这两种运算符用于类类型对象时的特殊含义,重载它们容易导致类的用户无法适应 -
我们只能重载已有的运算符,而不能发明新的运算符
5. 成员运算符函数vs非成员运算符函数
我们定义重载的运算符时,必须首先决定它是声明为类的成员函数还是声明为一个普通的非成员函数:
- 赋值
=
、下标[]
、调用()
和成员访问箭头->
运算符必须是成员函数 - 复合赋值运算符一般来说应该是成员函数,但并非必须
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员函数
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数
输入和输出运算符
IO
库分别使用>>
和<<
执行输入和输出操作,并定义了其读写内置类型的版本,而类需要自定义适合其对象的新版本以支持IO
操作。
1. 重载输出运算符<<
通常情况下,输出运算符的第一个形参是非常量ostream
对象的引用。因为向流写入内容会改变其状态所以不能是常量,另外一个形参是引用是因为我们无法直接复制一个ostream
对象。第二个形参一般是一个常量引用。引用的原因是我们希望避免复制形参,常量是因为打印对象不会改变对象的内容。并且为了和其他输出运算符保持一致,operator<<
一般要返回它的ostream
形参。
#include<iostream>
#include<string>
class Cat {
// 输出运算符访问类的非公有成员数据成员时需要定义成友元函数
friend std::ostream &operator<<(std::ostream &os, const Cat &cat);
public:
Cat(const std::string &name, int age) : name_(name), age_(age) { }
private:
std::string name_;
int age_;
};
std::ostream &operator<<(std::ostream &os, const Cat &cat) {
os << cat.age_ << " " << cat.name_;
return os;
}
int main() {
Cat cat1("tomo", 10), cat2("cola", 8);
std::cout << "cat1: " << cat1 << std::endl;
std::cout << "cat2: " << cat2 << std::endl;
}
// 输出:
cat1: 10 tomo
cat2: 8 cola
需要注意以下几点:
- 输出运算符尽量避免格式化操作,尤其不要打印换行符
- 输入输出运算符必须是非成员函数(否则它的左侧运算对象必须是我们类的一个对象),但是
IO
运算符通常需要读写类的非公有数据成员,所以IO
运算符一般被声明为友元
2. 重载输入运算符>>
通常情况下,输入运算符的一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。和输出运算符<<
不一样的是,输入运算符必须处理输入可能失败的情况,确保对象处于正确的状态。
std::istream &operator>>(std::istream &is, Cat &cat) {
is >> cat.age_ >> cat.name_;
// 发生IO错误时将给定的对象重置为空Cat, 确保对象处于正确的状态
if (!is) {
cat = Cat("", 0);
}
return is;
}
在执行输入运算符时可能发生下列错误:
- 当流含有错误类型的数据时读取操作可能失败,例如输入运算符假定接下来读入的是两个数字数据,但是输入的不是数字数据,则读取数据及后续对流的其他使用都将失败
- 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败
算术和关系运算符
我们一般把算术和关系运算符定义成非成员函数以允许对左侧或者右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量引用。
Cat operator+(const Cat &lhs, const Cat &rhs) {
Cat sum = lhs;
sum += rhs; // 使用对应的复合赋值运算符来实现算数运算符
return sum;
}
注意:
- 一般将累加的值放到一个局部变量,操作完成后返回该局部变量的副本作为结果
- 如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符
1. 相等运算符
- 如果有一个类含有判断两个对象是否相等的操作,那么它应该把函数定义成
operator==
而非一个普通的命名函数,这样用户无须再费时费力去学习并记忆一个全新的函数名字 - 如果类定义了
operator==
,那么该运算符也应该能判断一组给定的对象中是否含有重复数据 - 相等运算应该具有传递性,比如
a==b
且b==c
,那么我们能推出a==c
- 如果类定义了
operator==
,那么也应该定义operator!=
- 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,另一个只是调用真正工作的运算符
2. 关系运算符
通常情况下,关系运算符应该:
- 定义顺序关系,令其与关联容器中对关键字的要求一致
- 如果类同时也有
==
运算符,则定义一种关系令其与==
保持一致,特别是如果两个对象是!=
的,那么一个对象应该<
另一个
赋值运算符
1. 普通赋值运算符
我们之前定义过拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给该类的另一个对象。类还可以定义其他赋值运算符使用别的类作为右侧运算对象。
Tips:我们可以重载赋值运算符,但是无论形参是什么,赋值运算符都必须被定义为成员函数。(Why?)
Cat &Cat::operator=(const std::string &s) {
name_ = s;
return *this;
}
2. 复合赋值运算符
Tips:赋值运算符都必须定义成类的成员,复合赋值运算符通常情况下也应该这么做,这两类运算符都应该返回左侧运算对象的引用。
复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。为了与内置类型的复合赋值保持一致,类中的符合赋值运算符也要返回其左侧运算对象的引用:
// 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
Cat &Cat::operator+=(const Cat &rhs) {
// 假设两个Cat对象的name_相同
age_ += rhs.age_;
return *this;
}
下标运算符
表示容器的类可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]
。
Tips:下标运算符必须是成员函数。如果一个类包含下标运算符,那么它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并返回常量引用。
与下标的原始定义兼容,我们需要确保:
- 下标运算符通常以所访问元素的引用作为返回值,这样下标可以出现在赋值运算符的任意一端
- 最好定义下标运算符的常量和非常量版本,当作用于一个常量对象时下标运算符返回常量引用以确保我们不会给返回的对象赋值
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;
};
递增和递减运算符
定义递增和递减运算符的类应该同时定义前置版本和后置版本,这些运算符通常应该被定义为类的成员(因为它们改变的正好是所操作对象的状态)。同时为了保持与内置版本一致,前置运算符应该返回递增或者递减后对象的引用。
1. 前置版本
// 前置版本:返回递增/递减对象的引用
Cat& Cat::operator++() {
++age_;
return *this;
}
Cat& Cat::operator--() {
--age_;
return *this;
}
后置版本接收一个额外的(不被使用的)int
类型的形参,当我们使用后置运算符时,编译器为这个形参提供一个值为0
的形参。
// 后置版本:递增/递减对象的值但是返回原值
Cat Cat::operator++(int) {
Cat ret = *this;
++*this;
return ret;
}
Cat Cat::operator--(int) {
Cat ret = *this;
--*this;
return ret;
}
成员访问运算符
在迭代器和智能指针类中常常用到解引用运算符*
和箭头运算符->
,其中箭头运算符必须是类的成员呢,解引用运算符往往也是类的成员。
对于形如point->mem
的表达式来说,point
必须是指向类的对象的指针或者是一个重载了operator->
的类的对象。根据point
类型的不同,point->mem
分别等价于:
(*point).mem
:point
是一个内置的指针类型point.operator()->mem
:point
是类的一个对象
函数调用运算符
如果类重载了函数调用运算符,那么我们可以像使用函数一样使用该类的对象,因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。
struct absInt {
int operator()(int val) const {
return val < 0 ? -val : val;
}
};
// 调用
absInt absObj;
int ui = absObj(-42);
函数调用运算符必须是成员函数,一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或者类型上有所区别。
函数对象常常作为泛型算法的实参,比如可以使用for_each
算法和我们的PrintString
类来打印容器的内容:
#include<iostream>
#include<string>
#include<vector>
class PrintString {
public:
explicit PrintString(std::ostream &o = std::cout, char c = ' '): os(o), sep(c) { }
void operator()(const std::string &s) const { os << s << sep; }
private:
std::ostream &os; // 用于写入的目的流
char sep; // 用于将不同输出隔开的字符
};
int main() {
// 在cout中打印字符串, 后面跟一个空格
PrintString printer;
printer("tomocat");
// 在cerr中打印字符串, 后面跟一个换行符
PrintString errors(std::cerr, '\n');
errors("error");
}
使用标准库for_each
算法和我们的PrintString
类来打印容器的内容:
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
类型转换运算符
1. 简介
类型转换运算符conversion operator
是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:
operator type() const;
其中type
表示某种类型,类型转换运算符可以面向任意类型(除了void
之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针以及函数指针)或者引用类型。类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,所以一般被定义为const
成员。
2. 例子
我们定义一个表示0~255
之间一个整数的一个类:
// 类型转换运算符支持将SmallInt对象转化成int
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_;
};
// 首先将4隐式地转换成SmallInt,然后调用SmallInt::operator=
si = 4;
// 首先将si隐式地转换成int,然后执行整数的加法
si + 3;
3. 显式的类型转换运算符
Tips:实践中类很少提供类型转换运算符,所以类型转换运算符常被
explicit
修饰以阻止隐式转换。
在实践中类很少提供类型转换运算符,在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则存在一种例外情况:对于类来说,定义向bool
的类型转换还是比较普遍的现象。但是这种类型转换可能引发意想不到的结果,特别是当istream
含有向bool
的类型转换时,下面的代码仍然编译通过:
// 如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的
// 因为istream本身并没有定义<<运算符,所以本来这段代码应该产生错误
int i = 42;
cin << i;
// 执行过程如下:
// 1) istream的bool类型转换符将cin转换为bool
// 2) bool被提升为int并作为左移运算符的左侧运算对象
// 3) 提升后的bool值(1或0)会被左移42个位置
为了防止这样的异常发生,C++
新标准引入了显式的类型转换运算符:
class SmallInt {
public:
// 转换构造函数: 编译支持int到SmallInt的隐式转换
SmallInt(int i = 0) : val_(i) {
if (i < 0 || i > 255)
throw std::out_of_range("Bad SmallInt value");
}
// 类类型转换运算符: 编译器不支持SmallInt到int的隐式转换
explicit operator int() const { return val_; }
// ...其他成员
private:
int val_;
};
// 正确:SmallInt的构造函数不是显式的
SmallInt si = 3;
// 错误: 此处需要隐式的类型转换,但类的运算符是显式的
si + 3;
// 正确: 显式地请求类型转换
static_cast<int>(si) + 3;
4. 避免有二义性的类型转换
如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则我们编写的代码将很可能会具有二义性。
两种情况下可能存在多重转换路径:
- 第一种情况是两个类提供相同的类型转换:例如
A
类定义了一个接受B
类对象的转换构造函数,同时B
类定义了一个转换目标是A
类的类型转换运算符时,我们就说它们提供了相同的类型转换 - 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身就可以通过其他类型转换联系在一起。最典型的例子就是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型相关的转换规则
Tips:通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换
struct B;
struct A {
A() = default;
A(const B&);
};
struct B {
operator A() const;
};
A f(const A&);
B b;
// 二义性错误: 含义可能是f(B::operator A())或f(A::A(const B&))
A a = f(b);
// 正确: 显式地使用B的类型转换运算符
A a1 = f(b.operator A());
// 正确: 显式地调用A的转换构造函数
A a2 = f(A(b));
函数匹配与重载运算符
Tips:如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
如果a
是一种类类型,那么表达式a sym b
可能是如下两种。这意味着表达式中运算符的候选函数集既应该包含成员函数,也应该包含非成员函数。
a.operatorsym(b); // a有一个operatorsym成员函数
operatorsym(a, b); // operatorsym是一个普通函数
举个例子,我们为SmallInt
类定义一个加法运算符:
class SmallInt {
friend SmallInt operator+(const SmallInt&, const SmallInt&);
public:
// 转换源为int的类型转换
SmallInt(int = 0);
// 转换目标为int的类型转换
operator int() const { return val_; }
private:
std::size_t val_;
};
我们可以将两个SmallInt
对象相加,但如果我们试图执行混合模式的算术运算,就将遇到二义性的问题:
SmallInt s1, s2;
// 使用重载的operator
SmallInt s3 = s1 + s2;
// 二义性错误: 既可以把0转换成SmallInt, 然后使用SmallInt的+; 也可以将s3转换成int, 对int执行内置的加法运算
int i = s3 + 0;