第四章 表达式
C++语言提供了一套丰富的运算符,并定义了这些运算符作用于内置类型的运算对象时所执行的操作。同时,当运算对象是类类型时,C++语言也允许有用户指定上诉运算符的含义。
表达式由一个或多个运算对象(operand)组成,对表达式求值将得到一个结果(result)。字面值和变量是最简单的表达式(expression),其结果就是字面值和变量的值。把一个运算符(operator)和一个或多个运算对象组合起来可以生成较复杂的表达式。
4.1 基础
4.1.1 基本概念
左值和右值
C++的表达式要不然是右值(rvalue,读作"are-value"),要不然是左值(lvalue,读作"ell-value")
一个左值表达式的求值结果是一个对象或者一个函数,然而以常量为代表的某些左值实际上不能作为赋值语句的左侧运算对象。此外,虽然某些表达式的求值结果是对象,但它们是右值而非左值。可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
不同的运算符对运算对象的要求各不相同,有的需要左值运算对象,有的需要右值运算对象;返回值也有差异,有的得到左值结果,有的得到右值结果。一个重要的原则是在需要右值的地方可以用左值来代替,但不能把右值当成左值(也就是位置)使用,当一个左值被当成右值使用时,实际使用的是它的内容(值)。
使用关键字decltype的时候,左值和右值也有所不同。如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型。举个例子,假定p的类型是int*,因为解引用运算符生成左值,所以decltype(*p)的结果是int&。另一方面,因为取地址运算符生成右值。所以decltype(&p)的结果是int**,也就是说,结果是一个指向整型指针的指针。
4.1.2 优先级和结合律
一般来说,表达式最终的值依赖于其子表达式的组合方式。高优先级运算符对象要比低优先级运算符的运算对象更为紧密的组合在一起。如果优先级相同,其组合规则由结合率确定。
4.1.3 求值顺序
优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。在大多数情况下,不会明确指定求值的顺序。对于如下表达式
int i = f1() * f2();
我们知道f1和f2一定会在执行乘法之前被调用,因为毕竟相乘的是这两个函数的返回值。但是我们无法知道到底f1和f2谁先调用。
对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义行为。
int i = 0;
cout << i << " " << ++ i << endl; // 未定义的
有四种运算符明确规定了运算对象的求值顺序:&&
、||
、?:
、,
处理复合表达式两条准则
- 拿不准的时候最好用括号还强制让表达式的组合关系符合程序逻辑的要求
- 如果改变了某个运算对象的值,在表达式的其他地方不要再使用这个对象。
第二条规则有一个重要例外,当改变运算对象的子表达式本身就是另外一个子表达式的运算时该规则无效。例如*++iter
。
4.2 算术运算符
取余(%)
根据取余运算的定义,如果m和n是整数且n非0,则表达式(m/n)*n+m%n的求值结果与m相等。隐含的意思是,如果m%n不等于0,则它的符号和m相同。C++语言的早期版本允许m%n的符号匹配n的符号,而且商向负无穷一侧取整,这一方式在新标准中已经被禁止使用了。除了-m导致溢出的特殊情况,其它时候(-m)/n和m/(-n)都等于-(m/n),m%(-n)等于m%n,-(m)%n等于-(m%n)。具体示例如下:
21 % 6; // 3
21 / 6; // 3
21 % 7; // 0
21 / 7; // 3
-21 % -8; // -5
-21 / -8; // 2
21 % -5; // 1
21 / -5; // -4
4.3 逻辑关系运算符
逻辑与运算和逻辑或运算都是先求左侧运算对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会计算右侧运算对象的值。这种策略称为短路求值(short-circuit evaluation)。
相等性测试与布尔字面值
进行比较运算时除非比较的对象是布尔类型,否则不要使用布尔字面值true和false作为运算对象。
4.4 赋值运算符
赋值运算符的左侧运算对象必须是一个可修改的左值。
赋值运算符满足右结合律
赋值运算符满足右结合律,这一点与其他二元运算符不太一样:
int ival, jval;
ival = jval = 0; // 正确:都被赋值为0
因为赋值运算符满足右结合律,所以靠右的赋值运算jval=0作为靠左的赋值运算符的右侧运算对象。又因为赋值运算返回的是其左侧运算对象,所以靠右的赋值运算的结果(即jval)被赋给了ival。
int ival, *pval;
ival = pval = 0; // 错误: 不能把指针的值赋给int
string s1, s2;
s1 = s2 = "OK"; // 字符串字面值"OK"转换成string对象
4.5 递增递减运算符
除非必须,否则不用递增递减运算符的后置版本
前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
4.6 成员访问运算符
箭头运算符作用于一个指针类型的运算对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值;反之,如果成员所属的对象是右值,那么结果是右值。
4.7 条件运算符
在输出表达式中使用条件运算符
条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算子表达式时,通常需要在它的两端加上括号。
cout << ((grade < 60) ? "fail" : "pass"); // 输出pass或者fail
cout << (grade < 60) ? "fail" : "pass"; // 输出1或者0
cout << grade < 60 ? "fail" : "pass"; // 错误:试图比较cout和60
4.8 位运算符
位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。位运算符提供检查和设置二进制位的功能。一种名为bitset的标准库类型也可以表示任意大小的二进制集合,所以位运算符同样能用于bitset类型。
位运算符(左结合率)
运算符 | 功能 | 用法 |
---|---|---|
~ | 位求反 | ~expr |
<< | 左移 | expr1 << expr2 |
>> | 右移 | expr1 >> expr2 |
& | 位与 | expr1 & expr2 |
^ | 位异或 | expr1 ^ expr2 |
| | 位或 | expr1 | expr2 |
关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型。
移位运算符(又叫IO运算符)满足左结合率
移位运算符的优先级不高不低,介于中间:比运算符的优先级低,但比关系运算符、赋值运算符和条件运算符的优先级高。因此在一次使用多个运算符时,有必要在适当的地方加上括号使其满足我们的要求。
cout << 42 + 10; // 正确
cout << (10 < 42); // 正确
cout << 10 < 42; // 错误
4.9 sizeof 运算符
sizeof运算符返回一条表达式或者一个类型名字所占的字节数。sizeof运算符满足右结合率,其所得的值是一个size_t类型的常量表达式。运算符的运算对象有两种形式:
sizeof (type)
sizeof type
在第二章形式中 ,sizeof返回的是表达式结果类型的大小。与众不同的一点是,sizeof并不实际计算其运算对象的值。
Sale_data dat, *p;
sizeof(Sale_dat); // 存储Sales_data类型的对象所占的空间大小
sizeof data; // data类型的大小,即sizeof(Sales_data)
sizeof p; // 指针所占空间的大小
sizeof *p; // p所指类型的空间大小,即sizeof(Sales_data)
sizeof data.revenue; // Sales_data的revenue成员对应类型的大小
sizeof Sale_data::revenue; // 另一种获取revenue大小的方式
因为sizeof不会实际求运算对象的值,所以即使p是一个无效(即未初始化)的指针仍然是一种安全的行为,因为指针实际上并没有被真正使用。sizeof不需要真的解引用指针也能知道它所指对象的类型。
C++11新标准允许使用作用域运算符来获取类成员的大小。通常情况下只有通过类的对象才能访问到类的成员,但是sizeof运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员。
sizeof运算符的结果部分地依赖于其作用的类型
- 对于char或者类型为char的表达式执行sizeof运算,结果得1.
- 对引用类型执行sizeof运算得到被引用对象所占空间的大小
- 对指针执行sizeof运算得到指针本身所占空间的大小。
- 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小,指针不需有效。
- 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有元素各执行一次sizeof运算并将所得结果求和。注意,sizeof运算不会把数组转换成指针来处理。
- 对string对象或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。
4.10 逗号运算符
逗号运算符(comma operator)含有两个运算对象,按照从左向右的顺序依次求值。对于逗号运算符来说,搜先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么最终的求值结果也是左值。
4.11 类型转换
在C++语言中,某些类型之间有关联。如果两种类型有关联,那么当程序需要其中一种类型的运算符对象时,可以用另一种关联类型的对象或值来代替。换句话说,如果两种类型可以互相转换(conversion),那么它们就是关联的。
隐式转换
- 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型。
- 在条件中,非布尔值转换成布尔类型。
- 初始化过程中,初始值会转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型。
- 如果算术运算或者关系运算的运算对象有多种类型,需要转换成同一类型。
- 函数调用时也会发送类型转换
4.11.1 算术转换
**算术转换(arithmetic conversion)**的含义是把一种算术类型转换成另一种算术类型。
无符号类型的运算对象
如果某个运算符的运算对象类型不一致,这些运算对象将转换成同一种类型。但是如果某个运算对象是无符号类型,那么转换的结果就要依赖于机器中某个整数类型的相对大小了。
bool flag;
char cval;
short sval;
unsigned short usval;
int ival;
unsigned int uival;
long lval;
unsigned long ulval;
float fval;
double dval;
3.14159L + 'a'; // 'a'提升为int,然后该int值转换成long double
dval + ival; // ival 转换成double
dval + fval; // fval 转换成double
ival = dval; // dval 转换成int
flag = dval; // 如果dval是0则转换成false,否则则转换成true
cval + fval; // cval提升成int,然后该int值转换为float
sval + cval; // sval 和cval都转换为int
cval + lval; // cval 转换成long
ival + ulval; // ival 转换为unsigned long
usval + ival; // 根据unsigned short和int所占空间的大小进行提升
uival + lval; // 根据unsigned int 和long所占空间的大小进行转换
4.11.2 其它隐式类型转换
数组转换成指针
int ia[10]; // 含有10个整数的数组
int *ip = ia; // ia转换成指向数组首元素的指针
指针的转换
C++还规定了几种其他的指针转换方式,包括常量整数值0或者字面值nullptr能转换成任意指针类型;指向任意非常量的指针能转换成void*;指向任意对象的指针都能转换成const void *。
转换成布尔类型
存在一种从算术类型或指针类型向布尔类型自动转换的机制。如果指针或算术类型的值为0,转换结果是false;否则转换结果是true。
转换成常量
允许将指向非常量类型的指针转换成指向相应的常量类型的指针,对于引用也是这样。也就是说,如果T是一种类型,我们就能将指向T的指针或引用分别转换成指向const T的指针或引用。
int i;
const int &j = i; // 非常量转换成const int的引用
const int *p = &i; // 非常量的地址转换成const的地址
int &r = j, *q = p; // 错误: 不允许const转换成非常量
类类型定义的转换
类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。
while(cin>>s); // 读入成功为true,不成功为false
4.11.3 显式转换
虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的
命名的强制类型转换
一个命名的强制类型转换具有如下形式
cast-name(expression)
其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果是左值。cast-name是static_cast、dynamic_cast、const_cast和reinterpret_cast中的一种。
static_cast
任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。
double slope = static_cast<double(j) / i; // 进行强制类型转换以便执行浮点数除法
当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换告诉程序的读者和编译器:我们知道并且不在乎潜在的精度缺失。一般来说,如果编译器发现了一个较大的算术类型试图赋值给较小的类型,就会给出警告信息;但是当我们执行了显式的类型转换后,警告信息就会被关闭了。
static_cast对于编译器无法自动执行的类型转换也非常有用。
// 使用static_cast找回void指针
void *p = &dval;
double *dp = static_cast<double*>(p);
const_cast
const_cast只能改变运算对象的底层const
cosnt char *pc;
char *p = const_cast<char*>(pc); // 正确,但通过p写值是未定义行为
对于常量对象转换成非常量对象的行为,我们一般称其为"去掉const性质(cast away the const)"。一旦我们去掉了某个对象的const性质,编译器就不再阻止我们对该对象进行写操作了。如果对象本身不是一个常量,使用强制类型转换获得读写权限是合法的行为。而如果对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果。
只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。同样的,也不能用const_cast改变表达式类型。
const char *cp;
// 错误:static_cast不能转换掉const性质
chaar *q = static_cast<char*>(cp);
static_cast<string>(cp); // 正确字符串字面值转换为string类型
const_cast<string>(cp); // 错误:const_cast只改变常量属性
做个小实验(gcc version 9.3.0、ubuntu)
// prog4.cpp
#include<iostream>
using namespace std;
int main(){
int ival = 4;
cout << "ival: " << ival << endl;
const int *cpi = &ival;
cout << "*cpi: " << *cpi << endl;
*cpi = 2;
return 0;
}
// 编译结果
// prog4.cpp: In function ‘int main()’:
// prog4.cpp:8:10: error: assignment of read-only location ‘* cpi’
// 8 | *cpi = 2;
// | ~~~~~^~~
#include<iostream>
using namespace std;
int main(){
int ival = 4;
cout << "ival: " << ival << endl;
const int *cpi = &ival;
cout << "*cpi: " << *cpi << endl;
int *pi = const_cast<int*>(cpi);
*pi = 2;
cout << "ival: " << ival << endl;
return 0;
}
// 编译成功
// 运行结果
// ival: 4
// *cpi: 4
// ival: 2
#include<iostream>
using namespace std;
int main(){
const int ival = 4;
cout << "ival: " << ival << endl;
const int *cpi = &ival;
cout << "*cpi: " << *cpi << endl;
int *pi = const_cast<int*>(cpi);
*pi = 2;
cout << "ival: " << ival << endl;
return 0;
}
// 编译成功
// 运行结果(ival没有改变)
// ival: 4
// *cpi: 4
// ival: 4
reinterpret_cast
reinterpret_cast通常为运算对象的位模式提供了较低层次上的重新解释。
int *ip;
char *cp = reinterpret_cast<char*>(ip);
必须牢记pc所指的真实对象是一个int而非字符,如果把pc当成普通的字符指针使用就可能在运行式发送错误。
string str(pc); // 错误
reinterpret_cast本质上依赖于机器。想要安全地使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。
旧式的强制类型转换
在早期的C++语言中,显示地进行强制类型转换包含两种形式:
type(expr); // 函数形式的强制类型转换
(type)expr; // C语言风格的强制类型转换
根据所涉及的类型不同,旧式的强制类型转换分别具有与const_cast、static_cast或reinterpret_cast相似的行为。当我们在某处执行旧式的强制类型转换时,如果换成const_cast和static_cast也合法,则其行为与对应的命名转换一致,如果替换后不合法,则旧式强制类型转换执行与reinterpret_cast类似的功能。
int *ip;
char *pc = (char*)ip;
效果与使用reinterpret_cast一样。
4.12 运算符优先级表