C++Primer 第4章
第4章 表达式
C++语言提供了一套丰富的运算符,并定义了这些运算符作用于内置类型的运算对象时所执行的操作。同时,当运算对象是类类型时,C++语言也允许由用户指定上述运算符的含义。本章主要介绍由语言本身定义、并用于内置类型运算对象的运算符,同时简单介绍几种标准库定义的运算符。第14章会专门介绍用户如何自定义适用于类类型的运算符。
表达式由一个或多个运算对象(operand)组成,对表达式求值将得到一个结果(result)。字面值和变量是最简单的表达式(expression),其结果就是字面值和变量的值。把一个运算符(operator)和一个或多个运算对象组合起来可以生成较复杂的表达式。
4.1 基础
4.1.1 基本概念
C++定义了一元运算符(unary operator)和二元运算符(binary operator)。作用于一个运算对象的运算符是一元运算符,如取地址符 (&)和解引用符(*);作用于两个运算对象的运算符是二元运算符,如相等运算符(==)和乘法运算符(*)除此之外,还有一个作用于三个运算对象的三元运算符。函数调用也是一种特殊的运算符,它对运算对象的数量没有限制。
一些符号既能作为一元运算符也能作为二元运算符。以符号*为例,作为一元运算符时执行解引用操作,作为二元运算符时执行乘法操作。一个符号到底是一元运算符还是二元运算符由它的上下文决定。对于这类符号来说,它的两种用法互不相干,完全可以当成两个不同的符号。
组合运算符和运算对象
对于含有多个运算符的复杂表达式来说,要想理解它的含义首先要理解运算符的优先级(precedence)、结合律(associativity)以及运算对象的求值顺序(order of evaluation)。例如,下面这条表达式的求值结果依赖于表达式中运算符和运算对象的组合方式:
5 + 10 * 20/2;
乘法运算符(*)是一个二元运算符,它的运算对象有4种可能:10和20、10和20/2、15和20、15和20/2。下一节将介绍如何理解这样一条表达式。
运算对象转换
在表达式求值的过程中,运算对象常常由一种类型转换成另外一种类型。例如,尽管一般的二元运算符都要求两个运算对象的类型相同,但是很多时候即使运算对象的类型不相同也没有关系,只要它们能被转换 (参见2.1.2节) 成同一种类型即可。
类型转换的规则虽然有点复杂,但大多数都合乎情理、容易理解。例如,整数能转换成浮点数,浮点数也能转换成整数,但是指针不能转换成浮点数。让人稍微有点意外的是,小整数类型(如bool、char、short等)通常会被提升(promoted)成较大的整数类型,主要是int。4.11节将详细介绍类型转换的细节。
重载运算符
C++语言定义了运算符作用于内置类型和复合类型的运算对象时所执行的操作。当运算符作用于类类型的运算对象时,用户可以自行定义其含义。因为这种自定义的过程事实上是为已存在的运算符赋予了另外一层含义,所以称之为重载运算符 (overloaded operator)。IO库的>>和<<运算符以及string对象、vector对象和迭代器使用的运算符都是重载的运算符。
我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。
左值和右值
C++的表达式要不然是右值(rvalue,读作“are-value”),要不然就是左值 (lvalue,读作“ell-value”)。这两个名词是从C语言继承过来的,原本是为了帮助记忆:左值可以位于赋值语句的左侧,右值则不能。
在C++语言中,二者的区别就没那么简单了。一个左值表达式的求值结果是一个对象或者一个函数,然而以常量对象为代表的某些左值实际上不能作为赋值语句的左侧运算对象。此外,虽然某些表达式的求值结果是对象,但它们是右值而非左值。可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
不同的运算符对运算对象的要求各不相同,有的需要左值运算对象、有的需要右值运算对象;返回值也有差异,有的得到左值结果、有的得到右值结果。一个重要的原则(参见13.6节)是在需要右值的地方可以用左值来代替,但是不能把右值当成左值使用。当一个左值被当成右值使用时,实际使用的是它的内容(值)。到目前为止,已经有几种我们熟悉的运算符是要用到左值的。
- 赋值运算符需要一个(非常量)左值作为其左侧运算对象,得到的结果也仍然是一个左值。
- 取地址符(参见2.3.2节)作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值。
- 内置解引用运算符、下标运算符(参见2.3.2节;参见3.5.2节)、迭代器解引用运算符、string和vector的下标运算符(参见3.4.1节;参见3.2.3节;参见3.3.3节)的求值结果都是左值。
- 内置类型和迭代器的递增递减运算符(参见1.4.1节;参见3.4.1节)作用于左值运算对象,其前置版本所得的结果也是左值。
接下来在介绍运算符的时候,我们将会注明该运算符的运算对象是否必须是左值以及其求值结果是否是左值。
使用关键字decltype(参见2.5.3节)的时候,左值和右值也有所不同。如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型。举个例子,假设p的类型是int*,因为解引用运算符生成左值,所以decltype(*p)的结果是int&。另一方面,因为取地址运算符生成右值,所以decltype(&p)的结果是int**。
4.1.2 优先级与结合律
复合表达式(compound expression)是指含有两个或多个运算符的表达式。求复合表达式的值需要首先将运算符和运算对象合理地组合在一起,优先级与结合律决定了运算对象组合的方式。也就是说,它们决定了表达式中每个运算符对应的运算对象来自表达式的哪一部分。表达式中的括号无视上述规则,程序员可以使用括号将表达式的某个局部括起来使其得到优先运算。
一般来说,表达式最终的值依赖于其子表达式的组合方式。高优先级运算符的运算对象要比低优先级运算符的运算对象更为紧密地组合在一起。如果优先级相同,则其组合规则由结合律确定。例如,乘法和除法的优先级相同且都高于加法的优先级。因此,乘法和除法的运算对象会首先组合在一起,然后才能轮到加法和减法的运算对象。算术运算符满足左结合律,意味着如果运算符的优先级相同,将按照从左向右的顺序组合运算对象:
- 根据运算符的优先级,表达式3+4*5的值是23,不是35。
- 根据运算符的结合律,表达式20-15-3的值是2,不是8。
括号无视优先级与结合律
括号无视普通的组合规则,表达式中括号括起来的部分被当成一个单元来求值,然后再与其他部分一起按照优先级组合。
优先级与结合律有何影响
由前面的例子可以看出,优先级会影响程序的正确性,这一点在3.5.3节介绍的解引用和指针运算种也有所体现:
int ia[] = {0,2,4,6,8};
int last = *(ia + 4); //把last初始化成8,也就是ia[4]的值
last = *ia + 4; //last = 4,等价于ia[0] + 4
如果想访问ia+4位置的元素,那么加法运算两端的括号必不可少。一旦去掉这对括号,*ia就会首先组合在一起,然后4再与*ia的值相加。
结合律对表达式产生影响的一个典型示例是输入输出运算,4.8节将要介绍IO相关的运算符满足左结合律。这一规则意味着我们可以把几个IO 运算组合在一条表达式当中:
cin >> v1 >> v2; //先读入v1,再读入v2
4.12节罗列出了全部的运算符,并用双横线将它们分割成若干组。同一组内的运算符优先级相同,组的位置越靠前组内的运算符优先级越高。例如,前置递增运算符和解引用运算符的优先级相同并且都比算术运算符的优先级高。表中同样列出了每个运算符在哪一页有详细的描述,有些运算符之前已经使用过了,大多数运算符的细节将在本章剩余部分逐一介绍,还有几个运算符将在后面的内容中提及。
4.1.3 求值顺序
优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。在大多数情况下,不会明确指定求值的顺序。对于如下的表达式
int i = f1() * f2();
我们知道f1和f2一定会在执行乘法之前被调用,因为毕竟相乘的是这两个函数的返回值。但是我们无法知道到底f1在f2之前调用还是f2在f1之前调用。
对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为(参见2.1.2节)。举个简单的例子,<<运算符没有明确规定何时以及如何对运算对象求值,因此下面的输出表达式是未定义的:
int i = 0;
cout << i << " " << ++i << endl; //未定义的
因为程序是未定义的,所以我们无法推断它的行为。编译器可能先求++i的值再求i的值,此时输出结果是1 1;也可能先求i的值再求++i的值,输出结果是0 1;甚至编译器还可能做完全不同的操作。因为此表达式的行为不可预知,因此不论编译器生成什么样的代码程序都是错误的。
有4种运算符明确规定了运算对象的求值顺序。第一种是3.2.3节提到的逻辑与(&&)运算符,它规定先求左侧运算对象的值,只有当左侧运算对象的值为真时才继续求右侧运算对象的值。另外三种分别是逻辑或(||)运算符(参见4.3节)、条件(?:)运算符(参见4.7节)和逗号(,)运算符(参见4.10节)。
求值顺序、优先级、结合律
运算对象的求值顺序与优先级和结合律无关,在一条形如f()+g()*h()+j()的表达式中:
- 优先级规定,g()的返回值和h()的返回值相乘。
- 结合律规定,f()的返回值先与g()和h()的乘积相加,所得结果再与j()的返回值相加。
- 对于这些函数的调用顺序没有明确规定。
如果 f、g、h 和 j 是无关函数,它们既不会改变同一对象的状态也不执行IO任务,那么函数的调用顺序不受限制。反之,如果其中某几个函数影响同一对象,则它是一条错误的表达式,将产生未定义的行为。
建议:处理复合表达式
以下两条经验准则对书写复合表达式有益:
- 拿不准的时候最好用括号来强制让表达式的组合关系符合程序逻辑的要求。
- 如果改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。
第2条规则有一个重要例外,当改变运算对象的子表达式本身就是另外一个子表达式的运算对象时该规则无效。例如,在表达式*++iter中,递增运算符改变iter的值,iter(已经改变)的值又是解引用运算符的运算对象。此时(或类似的情况下),求值的顺序不会成为问题,因为递增运算(即改变运算对象的子表达式)必须先求值,然后才轮到解引用运算。显然,这是一种很常见的用法,不会造成什么问题。
4.2 算术运算符
表4.1(以及后面章节的运算符表)按照运算符的优先级将其分组。一元运算符的优先级最高,接下来是乘法和除法,优先级最低的是加法和减法。优先级高的运算符比优先级低的运算符组合得更紧密。上面的所有运算符都满足左结合律,意味着当优先级相同时按照从左向右的顺序进行组合。
除非另做特殊说明,算术运算符都能作用于任意算术类型(参见2.1.1节)以及任意能转换为算术类型的类型。算术运算符的运算对象和求值结果都是右值。如4.11节描述的那样,在表达式求值之前,小整数类型的运算对象被提升成较大的整数类型,所有运算对象最终会转换成同一类型。
一元正号运算符、加法运算符和减法运算符都能作用于指针。3.5.3节已经介绍过二元加法和减法运算符作用于指针的情况。当一元正号运算符作用于一个指针或者算术值时,返回运算对象值的一个(提升后的)副本。
一元负号运算符对运算对象值取负后,返回其(提升后的)副本:
int i = 1024;
int k = -i; //k是-1024
bool b = true;
bool b2 = -b; //b2是true!
在2.1.1节,我们指出布尔值不应该参与运算,-b就是一个很好的例子。
对大多数运算符来说,布尔类型的运算对象将被提升为int类型。如上所示,布尔变量b的值为真,参与运算时将被提升成整数值1(参见2.1.2节),对它求负后的结果时-1。将-1再转换回布尔值并将其作为b2的初始值,显然这个初始值不等于0,转换成布尔值后应该为1。所以,b2的值是真!
提示:溢出和其他算术运算异常
算术表达式有可能产生未定义的结果。一部分原因是数学性质本身:例如除数是0的情况;另外一部分则源于计算机的特点:例如溢出,当计算的结果超出该类型所能表示的范围时就会产生溢出。
假设某个机器的short类型占16位,则最大的short数值时32767。在这样一台机器上,下面的复合赋值语句将产生溢出:
short short_value = 32767; short_value += 1; //该计算导致溢出 cout << "short_value:" << short_value << endl;
给short_value赋值的语句是未定义的,这是因为表示一个带符号数32768需要17位,但是short类型只有16位。很多系统在编译和运行时都不报溢出错误,像其他未定义的行为一样,溢出的结果是不可预知的。在我们的系统中,程序的输出结果是:
short_value: -32768
该值发生了“环绕(wrapped around)”,符号位本来是0,由于溢出被改成了1,于是结果变成一个负值。在别的系统中也许会有其他结果,程序的行为可能不同甚至直接崩溃。
当作用于算术类型的对象时,算术运算符+、-、*、/的含义分别是加法、减法、乘法和除法。整数相除结果还是整数,也就是说,如果商含有小数部分,直接弃除:
int ival1 = 21/6; //ival1是3,结果进行了删节,余数被抛弃掉了
int ival2 = 21/7; //ival2是3,没有余数,结果是整数值
运算符%俗称“取余”或“取模”运算符,负责计算两个整数相除所得的余数,参与取余运算的运算对象必须是整数类型:
int ival = 42;
double dval = 3.14;
ival % 12; //正确:结果是6
ival % dval; //错误:运算对象是浮点类型
在除法运算中,如果两个运算对象的符号相同则商为正(如果不为0的话),否则商为负。C++语言的早期版本允许结果为负值的商向上或向下取整,C++11 新标准则规定商一律向0取整(即直接切除小数部分)。
根据取余运算的定义,如果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 逻辑和关系运算符
关系运算符作用于算术类型或指针类型,逻辑运算符作用于任意能转换成布尔值的类型。逻辑运算符和关系运算符的返回值都是布尔类型。值为0的运算对象(算术类型或指针类型)表示假,否则表示真。对于这两类运算符来说,运算对象和求值结果都是右值。
逻辑与和逻辑或运算符
对于逻辑与运算符(&&)来说,当且仅当两个运算对象都为真时结果为真;对于逻辑或运算符(||)来说,只要两个运算对象中的一个为真结果就为真。
逻辑与运算符和逻辑或运算符都是先求左侧运算对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会计算右侧运算对象的值。这种策略称为短路求值(short-circuit evaluation)。
- 对于逻辑与运算符来说,当且仅当左侧运算对象为真时才对右侧运算对象求值。
- 对于逻辑或运算符来说,当且仅当左侧运算对象为假时才对右侧运算对象求值。
逻辑非运算符
逻辑非运算符(!)将运算对象的值取反后返回。下面举一个例子,假设vec是一个整数类型的vector对象,可以使用逻辑非运算符将empty函数的返回值取反从而检查vec是否含有元素:
//输出vec的首元素(如果有的话)
if(!vec.empty())
cout << vec[0];
关系运算符
关系运算符比较运算对象的大小关系并返回布尔值。关系运算符都满足左结合律。
因为关系运算符的求值结果是布尔值,所以将几个关系运算符连写在一起会产生意想不到的结果:
if(i < j < k) //若k大于1则为真!
要想实现我们的目的,其实应该使用下面的表达式:
if(i < j && j < k)
相等性测试与布尔字面值
如果想测试一个算术对象或指针对象的真值,最直接的办法就是将其作为if语句的条件:
if(val) {} //如果val是任意的非0值,条件为真
if(!val) {} //如果val是0,条件为真
有时会试图将上面的真值测试写成如下形式:
if(val == true) { /* ... */} //只有当val等于1时条件才为真!
但是这种写法存在两个问题:首先,与之前的代码相比,上面这种写法较长而且不太直接;更重要的一是点,如果val不是布尔值,这样的比较就失去了原本的意义。
如果val不是布尔值,则代码可以改写成如下形式:
if(val == 1) {/* ... */}
进行比较运算时除非比较的对象是布尔类型,否则不要使用布尔字面值true和false作为运算对象。
4.4 赋值运算符
赋值运算符的左侧运算对象必须是一个可修改的左值。如果给定
int i = 0, j = 0, k = 0; //初始化而非赋值
const int ci = i; //初始化而非赋值
则下面的赋值语句都是非法的:
1024 = k; //错误:字面值是右值
i + j = k; //错误:算术表达式是右值
ci = k; //错误:ci是常量(不可修改的)左值
赋值运算的结果是它的左侧运算对象,并且是一个左值。相应的,结果的类型就是左侧运算对象的类型。如果赋值运算符的左右两个运算对象类型不同,则右侧运算对象将转换成左侧运算对象的类型:
k = 0; //结果:类型是int,值是0
k = 3.14159; //结果:类型是int,值是3
C++11新标准允许使用花括号括起来的初始值列表(参见2.2.1节)作为赋值语句的右侧运算对象:
k = {3.14}; //错误:窄化转换
vector<int> vi; //初始为空
vi = {0,1,2,3,4,5,6,7,8,9}; //vi现在含有10个元素了,值从0到9
如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,而且该值即使转换的话其所占空间也不应该大于目标类型的空间(参见2.2.1节)。
对于类类型来说,赋值运算的细节由类本身决定。对于vector来说,vector模板重载了赋值运算符并且可以接收初始值列表,当赋值发生时用右侧运算对象的元素替换左侧运算对象的元素。
无论左侧运算对象的类型是什么,初始值列表都可以为空。此时,编译器创建一个值初始化(参见3.3.1节)的临时量并将其赋给左侧运算对象。
赋值运算满足右结合律
赋值运算符满足右结合律,这一点与其他二元运算符不太一样:
int ival, jval;
ival = jval = 0; //正确:都被赋值为0
因为赋值运算符满足右结合律,所以靠右的赋值运算jval=0作为靠左的赋值运算符的右侧运算对象。又因为赋值运算返回的是其左侧运算对象,所以靠右的赋值运算的结果(即jval)被赋给了ival。
对于多重赋值语句中的每一个对象,它的类型或者与右边对象的类型相同、或者可由右边对象的类型转换得到(参见4.11节):
int ival, *pval;
ival = pval = 0; //错误:不能把指针的值赋给int
string s1, s2;
s1 = s2 = "OK"; //字符串字面值"OK"转换成string对象
因为ival和pval的类型不同,而且pval的类型(int*)无法转换成ival的类型(int),所以尽管0这个值能赋给任何对象,但是第一条赋值语句仍然是非法的。
与之相反,第二条赋值语句是合法的。这是因为字符串字面值可以转换成string对象并赋给s2,而s2和s1的类型相同,所以s2的值可以继续赋给s1。
赋值运算优先级较低
赋值语句经常会出现在条件当中。因为赋值运算的优先级相对较低,所以通常需要给赋值部分加上括号使其符合我们的原意。下面这个循环说明了把赋值语句放在条件当中有什么用处,它的目的是反复调用一个函数直到返回期望的值 (比如42)为止:
//这是一种形式繁琐、容易出错的写法
int i = get_value(); //得到第一个值
while(i != 42){
//其他处理......
i = get_value(); //得到剩下的值
}
在这段代码中,首先调用get_value 函数得到一个值,然后循环部分使用该值作为条件。在循环体内部,最后一条语句会再次调用get_value 函数并不断重复循环。可以将上述代码以更简单直接的形式表达出来:
int i ;
while((i = get_value()) != 42){
//其他处理...
}
这个版本的while条件更容易表达我们的真实意图:不断循环读取数据直至遇到42为止。其处理过程是首先将 get_value 函数的返回值赋给 i,然后比较i和42是否相等。
如果不加括号的话含义会有很大变化,比较运算符!=的运算对象将是get_value函数的返回值及 42,比较的结果不论真假将以布尔值的形式赋值给i,这显然不是我们期望的结果。
因为赋值运算符的优先级低于关系运算符的优先级,所以在条件语句中,赋值部分通常应该加上括号。
切勿混淆相等运算符和赋值运算符
C++语言允许用赋值运算作为条件,但是这一特性可能带来意想不到的结果:
if(i = j)
此时,if语句的条件部分把j的值赋给i,然后检查赋值的结果是否为真。如果j不为0,条件将为真。然而程序员的初衷很可能是想判断i和j是否相等:
if(i == j)
复合赋值运算符
我们经常需要对对象施以某种运算,然后把计算的结果再赋给该对象。这种操作不仅对加法来说很常见,而且也常常应用于其他算术运算符或者4.8节将要介绍的位运算符。每种运算符都有相应的复合赋值形式:
+= -= *= /= %= //算术运算符
<<= >>= &= ^= |= //位运算符,参见4.8节
任何一种复合运算符都完全等价于
a = a op b;
唯一的区别是左侧运算对象的求值次数:使用复合运算符只求值一次,使用普通的运算符则求值两次。这两次包括:一次是作为右边子表达式的一部分求值,另一次是作为赋值运算的左侧运算对象求值。其实在很多地方,这种区别除了对程序性能有些许影响外几乎可以忽略不计。
4.5 递增和递减运算符
递增运算符(++)和递减运算符(--)为对象的加1和减1操作提供了一种简洁的书写形式。这两个运算符还可应用于迭代器,因为很多迭代器本身不支持算术运算,所以此时递增和递减运算符除了书写简洁外还是必须的。
递增和递减运算符有两种形式:前置版本和后置版本。到目前为止,本书使用的都是前置版本,这种形式的运算符首先将运算对象加1(或减 1),然后将改变后的对象作为求值结果。后置版本也会将运算对象加1(或减1),但是求值结果是运算对象改变之前那个值的副本:
int i = 0,j;
j = ++i; //j = 1, i = 1:前置版本得到递增之后的值
j = i++; //j = 1, i = 2:后置版本得到递增之前的值
这两种运算符必须作用于左值运算对象。前置版本将对象本身作为左值返回,后置版本则将对象原始值的副本作为右值返回。
建议:除非必须,否则不用递增递减运算符的后置版本
有C语言背景的读者可能对优先使用前置版本递增运算符有所疑问,其实原因非常简单:前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
对于整数和指针类型来说,编译器可能对这种额外的工作进行一定的优化;但是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本的习惯,这样不仅不需要担心性能的问题,而且更重要的是写出的代码会更符合编程的初衷
在一条语句中混用解引用和递增运算符
如果我们想在一条复合表达式中既将变量加1或减1又能使用它原来的值,这时就可以使用递增和递减运算符的后置版本。
举个例子,可以使用后置的递增运算符来控制循环输出一个 vector 对象内容直至遇到(但不包括)第一个负值为止:
auto pbeg = v.begin();
//输出元素直至遇到第一个负值为止
while(pbeg != v.end() && *pbeg >= 0)
cout << *pbeg++ << endl; //输出当前值并将pbeg向前移动一个元素
后置递增运算符的优先级高于解引用运算符,因此*pbeg++等价于*(pbeg++)。pbeg++把pbeg的值加1,然后返回pbeg的初始值的副本作为其求值结果,此时解引用运算符的运算对象是pbeg未增加之前的值。最终,这条语句输出pbeg开始时指向的那个元素,并将指针向前移动一个位置。
这种用法完全是基于一个事实,即后置递增运算符返回初始的未加1的值。如果返回的是加1之后的值,解引用该值将产生错误的结果。不但无法输出第一个元素,而且更糟糕的是如果序列中没有负值,程序将可能试图解引用一个根本不存在的元素。
运算对象可按任意顺序求值
大多数运算符都没有规定运算对象的求值顺序(参见4.1.3节),这在一般情况下不会有什么影响。然而,如果一条子表达式改变了某个运算对象的值,另一条子表达式又要使用该值的话,运算对象的求值顺序就很关键了。因为递增运算符和递减运算符会改变运算对象的值,所以要提防在复合表达式中错用这两个运算符。
为了说明这一问题,我们将重写3.4.1节的程序,该程序使用 for 循环将输入的第一个单词改成大写形式:
for(auto it = s.begin(); it != s.end() && !isspace(*it); ++it)
*it = toupper(*it); //将当前字符改成大写形式
在上述程序中,我们把解引用it和递增it两项任务分开来完成。如果用一个看似等价的while循环进行代替
//该循环的行为是未定义的!
while(beg != s.end() && !isspace(*beg))
*beg = toupper(*beg++); //错误:该赋值语句未定义
将产生未定义的行为。问题在于:赋值运算符左右两端的运算对象都用到了beg,并且右侧的运算对象还改变了beg的值,所以该赋值语句是未定义的。编译器可能按照下面的任意一种思路处理该表达式:
*beg = toupper(*beg); //如果先求左侧的值
*(beg + 1) = toupper(*beg); //如果先求右侧的值
也可能采取别的什么方式处理它。
4.6 成员访问运算符
点运算符(参见1.5.2节)和箭头运算符(参见3.4.1节)都可用于访问成员,其中,点运算符获取类对象的一个成员;箭头运算符与点运算符有关,表达式ptr->mem等价于(*ptr).mem:
string s1 = "a string", *p = &s1;
auto n = s1.size(); //运行sring对象s1的size成员
n = (*p).size(); //运行p所指对象的size成员
n = p->size(); //等价于(*p).size()
因为解引用运算符的优先级低于点运算符,所以执行解引用运算的子表达式两端必须加上括号。如果没加括号,代码的含义就大不相同了:
//运行p的size成员,然后解引用size的结果
*p.size(); //错误:p是一个指针,它没有名为size的成员
这条表达式试图访问对象p的 size 成员,但是p本身是一个指针且不包含任何成员,所以上述语句无法通过编译。
箭头运算符作用于一个指针类型的运算对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值;反之,如果成员所属的对象是右值,那么结果是右值。
4.7 条件运算符
条件运算符(?😃允许我们把简单的if-else逻辑嵌入到单个表达式当中,条件运算符按照如下形式使用:
cond ? expr1 : expr2;
其中cond是判断条件的表达式,而expr1和expr2是两个类型相同或可能转换为某个公共类型的表达式。条件运算符的执行过程是:首先求 cond 的值,如果条件为真对 exprl 求值并返回该值,否则对 expr2 求值并返回该值。举个例子,我们可以使用条件运算符判断成绩是否合格:
string finalgrade = (grade < 60) ? "fail" : "pass";
有点类似于逻辑与运算符和逻辑或运算符 (&&和||),条件运算符只对exprl 和expr2 中的一个求值。
当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值;否则运算的结果是右值。
嵌套条件运算符
允许在条件运算符的内部嵌套另外一个条件运算符。也就是说,条件表达式可以作为另外一个条件运算符的cond或expr。举个例子,使用一对嵌套的条件运算符可以将成绩分成三档:优秀(high pass)、合格(pass)和不合格(fail):
finalgrade = (grade > 90) ? "high pass" : (grade < 60) ? "fail" : "pass";
条件运算符满足右结合律,意味着运算对象(一般)按照从右向左的顺序组合。因此在上面的代码中,靠右边的条件运算(比较成绩是否小于60)构成了靠左边的条件运算的:分支。
随着条件运算嵌套层数的增加,代码的可读性急剧下降。因此,条件运算的嵌套最好别超过两到三层。
在输出表达式中使用条件运算符
条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算子表达式时,通常需要在它两端加上括号。例如,有时需要根据条件值输出两个对象中的一个,如果写这条语句时没把括号写全就有可能产生意想不到的结果:
cout << ((grade < 60) ? "fail" : "pass"); //输出pass或者fail
cout << (grade < 60) ? "fail" : "pass"; //输出1或者0!
cout << grade < 60 ? "fail" : "pass"; //错误:试图比较cout和60
在第二条表达式中,grade和60的比较结果是<<运算符的运算对象,因此如果grade<60为真输出1,否则输出0。<<运算符的返回值是cout,接下来cout作为条件运算符的条件。也就是说,第二条表达式等价于
cout << (grade < 60); //输出1或者0
cout ? "fail" : "pass"; //根据cout的值是true还是false产生对应的字面值
因为第三条表达式等价于下面的语句,所以它是错误的:
cout << grade; //小于运算符的优先级低于移位运算符,所以先输出grade
cout < 60 ? "fail" : "pass"; //然后比较cout和60!
4.8 位运算符
位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。位运算符提供检查和设置二进制位的功能,如17.2节将要介绍的,一种名为bitset的标准库类型也可以表示任意大小的二进制位集合,所以位运算符同样能用于bitset类型。
一般来说,如果运算对象是“小整型”,则它的值会被自动提升(参见4.11.1节)成较大的整数类型。运算对象可以是带符号的,也可以是无符号的。如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号位的值,因此是一种未定义的行为。
关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型。
移位运算符
之前在处理输入和输出操作时,我们已经使用过标准IO库定义的<<运算符和>>运算符的重载版本。这两种运算符的内置含义是对其运算对象执行基于二进制位的移动操作,首先令左侧运算对象的内容按照右侧运算对象的要求移动指定位数,然后将经过移动的(可能还进行了提升)左侧运算对象的拷贝作为求值结果。其中,右侧的运算对象一定不能为负,而且值必须严格小于结果的位数,否则就会产生未定义的行为。二进制位或者向左移 (<<) 或者向右移(>>),移出边界之外的位就被舍弃掉了:
左移运算符(<<)在右侧插入值位0的二进制位。右移运算符(>>)的行为则依赖于其左侧运算对象的类型:如果该运算对象是无符号类型,在左侧插入值为0的二进制位;如果该运算对象是带符号类型,在左侧插入符号位的副本或值为0的二进制位,如何选择要视具体环境而定。
位求反运算符
位求反运算符(~)将运算对象逐位求反后生成一个新值,将1置为0、将0置为1:
char类型的运算对象首先提升成int类型,提升时运算对象原来的位保持不变,往高位(high order position)添加0即可。
位与、位或、位异或运算符
与(&)、或(|)、异或(^)运算符在两个运算对象上逐位执行相应的逻辑操作:
对于位与运算符(&)来说,如果两个运算对象的对应位置都是1则运算结果中该位为1,否则为0。对于位或运算符(|)来说,如果两个运算对象的对应位置至少有一个为1则运算结果中该位为1,否则为0。对于位异或运算符(^)来说,如果两个运算对象的对应位置有且只有一个为1则运算结果中该位为1,否则为0。
移位运算符(又叫IO运算符)满足左结合律
尽管很多程序员从未直接用过位运算符,但是几乎所有人都用过它们的重载版本来进行IO操作。重载运算符的优先级和结合律都与它的内置版本一样,因此即使程序员用不到移位运算符的内置含义,也仍然有必要理解其优先级和结合律。
因为移位运算符满足左结合律,所以表达式
cout << "hi" << "there" << endl;
的执行过程实际上等同于
( (cout << "hi") << "there") << endl;
移位运算符的优先级不高不低,介于中间:比算术运算符的优先级低,但比关系运算符、赋值运算符和条件运算符的优先级高。因此在一次使用多个运算符时,有必要在适当的地方加上括号使其满足我们的要求。
cout << 42 + 10; //正确:+的优先级更高,因此输出求和结果
cout << (10 < 42); //正确:括号使运算对象按照我们的期望组合在一起,输出1
cout << 10 < 42; //错误:试图比较cout和42!
4.9 sizeof运算符
sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof 运算符满足右结合律,其所得的值是一个size_t类型(参见3.5.2节)的常量表达式(参见2.4.4节)。运算符的运算对象有两种形式:
sizeof (type)
sizeof expr
在第二种形式中,sizeof返回的是表达式结果类型的大小。与众不同的一点是,sizeof并不实际计算其运算对象的值:
Sales_data data, *p;
sizeof(Sales_data); //存储Sales_data类型的对象所占的空间大小
sizeof data; //data的类型的大小,即sizeof(Sales_data)
sizeof p; //指针所占的空间大小
sizeof *p; //p所指类型的空间大小,即sizeof(Sales_data)
sizeof data.revenue; //Sales_data的revenue成员对应类型的大小
sizeof Sales_data::revenue; //另一种获取revenue大小的方式
这些例子中最有趣的一个是sizeof *p。首先,因为 sizeof 满足右结合律并且与*运算符的优先级一样,所以表达式按照从右向左的顺序组合。也就是说,它等价于sizeof(*p)。其次,因为 sizeof 不会实际求运算对象的值,所以即使p 是一个无效(即未初始化)的指针(参见2.3.2节)也不会有什么影响。在 sizeof 的运算对象中解引用一个无效指针仍然是一种安全的行为,因为指针实际上并没有被真正使用。sizeof不需要真的解引用指针也能知道它所指对象的类型。
C++11 新标准允许我们使用作用域运算符来获取类成员的大小。通常情况下只有通过类的对象才能访问到类的成员,但是sizeof运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员。
sizeof运算符的结果部分地依赖于其作用的类型:
- 对char或者类型为char的表达式执行sizeof运算,结果为1。
- 对引用类型执行sizeof运算得到被引用对象所占空间的大小。
- 对指针执行sizeof运算得到指针本身所占空间的大小。
- 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小,指针不需有效。
- 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof运算并将所得结果求和。注意,sizeof运算不会把数组转换成指针来处理。
- 对string对象或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。
因为执行sizeof运算能得到整个数组的大小,所以可以用数组的大小除以单个元素的大小得到数组中元素的个数:
constexpr size_t sz = sizeof(ia)/sizeof(*ia);
int arr2[sz]; //正确:sizeof返回一个常量表达式,参见2.4.4节
4.10 逗号运算符
逗号运算符(comma opearator)含有两个运算对象,按照从左向右的顺序依次求值。和逻辑与、逻辑或以及条件运算符一样,逗号运算符也规定了运算对象求值的顺序。
对于逗号运算符来说,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么最终的求值结果也是左值。
逗号运算符经常被用在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.11 类型转换
在C++语言中,某些类型之间有关联。如果两种类型有关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联类型的对象或值来替代。换句话说,如果两种类型可以相互转换(conversion),那么它们就是关联的。
举个例子,考虑下面这条表达式,它的目的是将 ival 初始化为 6:
int ival = 3.541 + 3; //编译器可能会警告该运算损失了精度
加法的两个运算对象类型不同:3.541的类型是double,3的类型是int。C++语言不会直接将两个不同类型的值相加,而是先根据类型转换规则设法将运算对象的类型统一后再求值。上述的类型转换是自动执行的,无须程序员的介入,有时甚至不需要程序员了解。因此,它们被称作隐式转换(implicit conversion)。
算术类型之间的隐式转换被设计得尽可能避免损失精度。很多时候,如果表达式中既有整数类型的运算对象也有浮点数类型的运算对象,整型会转换成浮点型。在上面的例子中,3转换成double类型,然后执行浮点数加法,所得结果的类型是double。
接下来就要完成初始化的任务了。在初始化过程中,因为被初始化的对象的类型无法改变,所以初始值被转换成该对象的类型。仍以这个例子说明,加法运算得到的double类型的结果转换成int类型的值,这个值被用来初始化ival。由double向int转换时忽略掉了小数部分,上面的表达式中,数值6被赋给了ival。
何时发生隐式类型转换
在下面这些情况下,编译器会自动地转换运算对象的类型:
- 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型。
- 在条件中,非布尔值转换成布尔类型。
- 初始化过程中,初始值转换成变量的类型:在赋值语句中,右侧运算对象转换成左侧运算对象的类型。
- 如果算术运算或关系运算的运算对象有多种类型,需要转换成同一种类型。
- 如第6章将要介绍的,函数调用时也会发生类型转换。
4.11.1 算术转换
算术转换(arithmetic conversion)的含义是把一种算术类型转换成另外一种算术类型,这一点在2.1.2节中已有介绍。算术转换的规则定义了一套类型转换的层次,其中运算符的运算对象将转换成最宽的类型。例如,如果一个运算对象的类型是long double,那么不论另外一个运算对象的类型是什么都会转换成long double。还有一种更普遍的情况,当表达式中既有浮点类型也有整数类型时,整数值将转换成相应的浮点类型。
整型提升
整型提升(integral promotion)负责把小整数类型转换成较大的整数类型。对于bool、char、signed char、unsigned char、short和unsigned short等类型来说,只要它们所有可能的值都能存在int里,它们就会提升成int类型;否则,提升成unsigned int类型。就如我们所熟知的,布尔值false提升成0、true提升成1。
较大的char类型(wchar_t、char16_t、char32_t)提升成int、unsigned int、long、unsigned long、long long和unsigned long long中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。
无符号类型的运算对象
如果某个运算符的运算对象类型不一致,这些运算对象将转换成同一种类型。但是如果某个运算对象的类型是无符号类型,那么转换的结果就要依赖于机器中各个整数类型的相对大小了。
像往常一样,首先执行整型提升。如果结果的类型匹配,无须进行进一步的转换。如果两个(提升后的)运算对象的类型要么都是带符号的、要么都是无符号的,则小类型的运算对象转换成较大的类型。
如果一个运算对象是无符号类型、另外一个运算对象是带符号类型,而且其中的无符号类型不小于带符号类型,那么带符号的运算对象转换成无符号的。例如,假设两个类型分别是unsigned int和int,则int类型的运算对象转换成unsigned int类型。需要注意的是,如果int型的值恰好为负值,其结果将以2.1.2节介绍的方法转换,并带来该节描述的所有副作用。
剩下的一种情况是带符号类型大于无符号类型,此时转换的结果依赖于机器。如果无符号类型的所有值都能存在该带符号类型中,则无符号类型的运算对象转换成带符号类型。如果不能,那么带符号类型的运算对象转换成无符号类型。例如,如果两个运算对象的类型分别是long和unsigned int,并且int和long的大小相同,则long类型的运算对象转换成unsigned int类型;如果 long 类型占用的空间比 int 更多,则unsigned int类型的运算对象转换成long类型。
理解算术转换
要想理解算术转换,办法之一就是研究大量的例子:
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,则flag是false,否则flag是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 其他隐式类型转换
除了算术转换之外还有几种隐式类型转换,包括如下几种。
数组转换成指针:在大多数用到数组的表达式中,数组自动转换成指向数组首元素的指针。
当数组被用作decltype关键字的参数,或者作为取地址符(&)、sizeof及typeid(第19.2.2节)等运算符的运算对象时,上述转换不会发生。同样的,如果用一个引用来初始化数组(参见3.5.1节),上述转换也不会发生。我们将在6.7节看到,当在表达式中使用函数类型时会发生类似的指针转换。
指针的转换:C++还规定了几种其他的指针转换方式,包括常量整数值0或者字面值nullptr能转换成任意指针类型;指向任意非常量的指针能转换成void*;指向任意对象的指针能转换成const void*。15.2.2节将要介绍,在有继承关系的类型间还有另外一种指针转换的方式。
转换成布尔类型:存在一种从算术类型或指针类型向布尔类型自动转换的机制。如果指针或算术类型的值为0,转换结果是false;否则转换结果是true:
char *cp = get_string();
if(cp) /* ... */ //如果指针cp不是0,条件为真
while(*cp) /* ... */ //如果*cp不是空字符,条件为真
转换成常量:允许将指向非常量类型的指针转换成相应的常量类型的指针,对于引用也是这样。也就是说,如果T是一种类型,我们就能将指向T的指针或引用分别转换成指向const T的指针或引用(参见2.4.1节和2.4.2节):
int i;
const int &j = i; //非常量转换成const int的引用
const int *p = &i; //非常量的地址转换成const的地址
int &r = j, *q = p; //错误:不允许const转换成非常量
相反的转换并不存在,因为它试图删除掉底层const。
类类型定义的转换:类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。在7.5.4节中我们将看到一个例子,如果同时提出多个转换请求,这些请求将被拒绝。
我们之前的程序已经使用过类类型转换:一处是在需要标准库string类型的地方使用C风格字符串(参见3.5.5节);另一处是在条件部分读入 istream:
string s, t = "a value"; //字符串字面值转换成string类型
while( cin >> s ) //while的条件部分把cin转换成布尔值
条件(cin>>s)读入cin的内容并将cin作为其求值结果。条件部分本来需要一个布尔类型的值,但是这里实际检查的是istream类型的值。幸好,IO库定义了从istream向布尔值转换的规则,根据这一规则,cin自动地转换成布尔值。所得的布尔值到底是什么由输入流的状态决定,如果最后一次读入成功,转换得到的布尔值是true;相反,如果最后一次读入不成功,转换得到的布尔值是false。
4.11.3 显式转换
有时我们希望显式地将对象强制转换成另外一种类型。例如,如果想在下面的代码中执行浮点数除法:
int i, j;
double slope = i/j;
就要使用某种方法将i和/或j显式地转换成double,这种方法称作强制类型转换(cast)。
虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的。
命名的强制类型转换
一个命名的强制类型转换具有如下形式:
cast-name<type>(expression);
其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果是左值。cast-name是static_cast、dynamic_cast、const_cast和reinterpret_cast中的一种。dynamic_cast支持运行时类型识别,我们将在19.2节对其做更详细的介绍。cast-name指定了执行的是哪种转换。
static_cast
任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。例如,通过将一个运算对象强制转换成double类型就能使表达式执行浮点数除法:
//进行强制类型转换以便执行浮点数除法
double slope = static_cast<double>(j) / i;
当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换告诉程序的读者和编译器:我们知道并且不在乎潜在的精度损失。一般来说,如果编译器发现一个较大的算术类型试图赋值给较小的类型,就会给出警告信息;但是当我们执行了显式的类型转换后,警告信息就会被关闭了。
static_cast对于编译器无法自动执行的类型转换也非常有用。例如,我们可以使用static_cast找回存在于void*指针(参见2.3.2节)中的值:
void* p = &d; //正确:任何非常量对象的地址都能存入void*
//正确:将void*转换回初始的指针类型
double *dp = static_cast<double*>(p);
当我们把指针存放在void*中,并且使用static_cast将其强制转换回原来的类型时应该确保指针的值保持不变。也就是说,强制转换的结果将与原始的地址值相等,因此我们必须确保转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。
const_cast
const_cast只能改变运算对象的底层const(参见2.4.3节):
const 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性质
char *q = static_cast<char*>(cp);
static_cast<string>(cp); //正确:字符串字面值转换成string类型
const_cast<string>(cp); //错误:const_cast只改变常量属性
const_cast常常用于有函数重载的上下文中,关于函数重载将在6.4节进行详细介绍。
reinterpret_cast
reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。举个例子,假设有如下的转换:
int *ip;
char *pc = reinterpret_cast<char*>(ip);
我们必须牢记pc所指的真实对象是一个int而非字符,如果把pc当成普通的字符指针使用就可能在运行时发生错误。例如:
string str(pc);
可能导致异常的运行时行为。
使用reinterpret_cast是非常危险的,用pc初始化str的例子很好地证明了这点。其中的关键问题是类型改变了,但编译器没有给出任何警告或者错误的提示信息。当我们用一个int的地址初始化pc时,由于显式地声称这种转换合法,所以编译器不会发出任何警告或错误信息。接下来再使用pc时就会认定它的值是char*类型,编译器没法知道它实际存放的是指向int的指针。最终的结果就是,在上面的例子中虽然用pc初始化str没什么实际意义,甚至还可能引发更糟糕的后果,但仅从语法上而言这种操作无可指摘。查找这类问题的原因非常困难,如果将ip强制转换成pc的语句和用pc初始化string对象的语句分属不同文件就更是如此。
reinterpret_cast本质上依赖于机器。要想安全地使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。
建议:避免强制类型转换
强制类型转换干扰了正常的类型检查(参见2.2.2节),因此我们强烈建议程序员避免使用强制类型转换。这个建议对于reinterpret_cast尤其适用,因为此类类型转换总是充满了风险。在有重载函数的上下文中使用const_cast无可厚非,关于这一点将在6.4节中详细介绍;但是在其他情况下使用const_cast也就意味着程序存在某种设计缺陷。其他强制类型转换,比如static_cast和dynamic_cast,都不应该频繁使用。每次书写了一条强制类型转换语句,都应该反复斟酌能否以其他方式实现相同的目标。就算实在无法避免,也应该尽量限制类型转换值的作用域,并且记录对相关类型的所有假定,这样可以减少错误发生的机会。
旧式的强制类型转换
在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:
type (expr); //函数形式的强制类型转换
(type) expr; //C语言风格的强制类型转换
根据所涉及的类型不同,旧式的强制类型转换分别具有与const_cast、static_cast或reinterpret_cast相似的行为。当我们在某处执行旧式的强制类型转换时,如果换成const_cast和static_cast也合法,则其行为与对应的命名转换致。如果替换后不合法,则旧式强制类型转换执行与reinterpret_cast类似的功能:
char *pc = (char*) ip; //ip是指向整数的指针
的效果与使用reinterpret_cast一样。
与命名的强制类型转换相比,旧式的强制类型转换从表现形式上来说不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。
4.12 运算符优先级表
小结
C++语言提供了一套丰富的运算符,并定义了这些运算符作用于内置类型的运算对象时所执行的操作。此外,C++语言还支持运算符重载的机制,允许我们自己定义运算符作用于类类型时的含义。第14章将介绍如何定义作用于用户类型的运算符。
对于含有超过一个运算符的表达式,要想理解其含义关键要理解优先级、结合律和求值顺序。每个运算符都有其对应的优先级和结合律,优先级规定了复合表达式中运算符组合的方式,结合律则说明当运算符的优先级一样时应该如何组合。
大多数运算符并不明确规定运算对象的求值顺序:编译器有权自由选择先对左侧运算对象求值还是先对右侧运算对象求值。一般来说,运算对象的求值顺序对表达式的最终结果没有影响。但是,如果两个运算对象指向同一个对象而且其中一个改变了对象的值,就会导致程序出现不易发现的严重缺陷。
最后一点,运算对象经常从原始类型自动转换成某种关联的类型。例如,表达式中的小整型会自动提升成大整型。不论内置类型还是类类型都涉及类型转换的问题。如果需要,我们还可以显式地进行强制类型转换。