跳转至

第四章 表达式

表达式由一个或多个运算对象(operand)组成,对表达式求值将得到一个结果(result)。字面值和变量是最简单的表达式,其结果就是字面值和变量的值。把一个运算符(operator)和一个或多个运算对象组合起来可以生成较复杂的表达式。


基础

以下几个基础概念涉及大多数表达式。

基本概念

C++定义了一元运算符(unary operator)和二元运算符(binary operator)。作用于一个运算对象的运算符是一元运算符;作用于两个运算对象的运算符是二元运算符。函数调用是一种特殊的运算符,它对运算对象的数量没有限制。

重载运算符

C++语言定义了运算符作用于内置类型和复合类型的运算对象时所执行的操作。当运算符作用于类类型的运算对象时,用户可以自行定义其含义,称之为重载运算符(overloaded operator)。

我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。

左值和右值

C++的表达式要不然是右值(rvalue),要不然就是左值(lvalue)。左值可以位于赋值语句的左侧,右值则不能。当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(内存中的位置)。

求值结果的临时值是一种右值。

Foo &revVal(); // 函数调用返回一个左值
Foo retVal();  // 函数调用返回一个右值

优先级与结合律

复合表达式(compound expression)是指含有两个或多个运算符的表达式。求复合表达式的值需要首先将运算符和运算对象合理地组合在一起,优先级和结合律决定了运算对象组合的方式。

一般来说,高优先级运算符的运算对象要比低优先级运算符的运算对象更为紧密地组织在一起。如果优先级相同,则其组合规则由结合律确定。

表达式中的括号无视上述规则,程序员可以使用括号将表达式的某个局部括起来使得其得到优先运算。

p147页罗列出了全部的运算符和其优先级、结合律的信息。

求值顺序

优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。在大多数情况下,不会明确求值的顺序。如:

int i = f1() * f2();

f1和f2一定在乘法之前被调用,但是谁先调用无从得知。

对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。比如下面的表达式是未定义的:

int i = 0;
cout << i << " " << ++i << endl;

编译器可能先求++i的值,再求i的值,所以结果无法预知。

有4种运算符明确规定了运算对象的求值顺序:逻辑与(&&)、逻辑或(||)、条件运算符(?:)和逗号运算符(,)。

求值顺序、优先级、结合律

以下两条经验准则对书写复合表达式有益:

  1. 拿不准的时候最好用括号来强制让表达式的组合关系符合程序逻辑的要求。

  2. 如果改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。

第二条有一个重要例外,当改变运算对象的子表达式本身就是另外一个子表达式的运算对象时该规则无效。例如,*++iter,递增运算符改变iter的值,iter(已经改变)的值又是解引用运算符的运算对象。此时,求值的顺序不会成为问题。

my note:上面这句话还是挺绕口的,反正这种类似写法的运算不会成为问题。

算术运算符

算术运算符中,一元运算符的优先级最高,然后是乘法和除法,优先级最低的是加法和减法。p124页列出的算术运算符满足左结合律,意味着当优先级相同时,按照从左向右的顺序进行组合。

算术运算符的运算对象和求值结果都是右值。在表达式求值之前,小整数类型的运算对象被提升成较大的整数类型,所有运算对象最终会转换成同一类型。

提示:溢出和其他算术运算异常

算术表达式有可能产生未定义的结果。一部分原因是数学性质本身:例如除数是0的情况;另外一部分则源于计算机的特点:例如溢出,当计算结果超出该类型所能表示的范围时就会产生溢出。

整数相除的结果还是整数,也就是说,如果商含有小数部分,直接抛弃。

运算符%俗称“取余”或“取模”运算符,负责计算两个整数相除所得的余数。参与取余运算的运算对象必须是整数类型。

关于正负号的运算,除了-m导致溢出的特殊情况,其他时候,(-m)/n和m/(-n)都等于-(m/n),m%(-n)等于m%n,(-m)%n等于-(m%n)。

逻辑和关系运算符

关系运算符作用于算术类型或指针类型,逻辑运算符作用于任意能转换成布尔值的类型。逻辑运算符和关系运算符的返回值都是布尔类型。值为0的运算对象表示假,否则表示真。这两种运算符的运算对象和求值结果都是右值。

my note: 本节的概念比较简单,故而不做更多笔记。更多细节可查阅书本p127。

赋值运算符

赋值运算符的左侧运算对象必须是一个可修改的左值。赋值运算的结果是它左侧的运算对象,并且是一个左值。如果赋值运算符的左右两个运算对象类型不同,则右侧运算对象将转换成左侧运算对象的类型。

C++11新标准允许使用花括号括起来的初始值列表作为赋值语句的右侧运算对象:

int k = 0;
k = {3.14};    // 错误,窄化转换

如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,且该值即使转换的话其所占空间也不应该大于目标类型的空间。

对于类类型来说,赋值运算的细节由类本身决定。

无论左侧运算对象的类型是什么,初始值列表都可以为空。此时,编译器创建一个值初始化的临时量并将其赋给左侧运算对象。

赋值运算满足右结合律。

my note: 还有一些概念比较简单,见书本p130。

递增和递减运算符

递增和递减运算符有两种形式:前置版本和后置版本。前置版本首先将运算对象加1(or减1),然后将改变后的对象作为求值结果。后置版本也会将对象加1(or减1),但是求值结果是运算对象改变之前那个值的副本。

这两种运算符必须作用于左值运算对象。前置版本将对象本身作为左值返回,后置版本则将对象原始值的副本作为右值返回。

建议:除非必须,否则不用后置版本

这是因为后置版本将原始值存储下来以便于返回,而前置版本避免了这个工作。尤其是对于迭代器类型,这种额外的工作消耗巨大。

在一条语句中混用解引用和递增运算符

可以使用后置的递增运算符来控制循环输出一个vector对象内容:

auto pbeg = v.begin();
while (pbeg != v.end)
    cout << *pbeg++;

后置递增运算符的优先级高于解引用运算符,因此*pbeg++等价于*(pbeg++)。这是一种被广泛使用的、有效的写法。

成员访问运算符

点运算符和箭头运算符都可以用于访问成员,其中,点运算符获取类对象的一个成员;箭头运算符与点运算符有关,表达式ptr->mem等价于(*ptr).mem

因为解引用运算符的优先级低于点运算符,所以要向上面那样加上括号。

箭头运算符作用于一个指针类型的运算对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果就是左值;反之,如果成员所属的对象是右值,那么结果是右值。

my note: 上面左值右值的讨论不是很能理清,但若是结合实际情况,应该不会很难懂。

条件运算符

条件运算符(?:)允许我们把简单的if-else逻辑嵌入到单个表达式当中,条件运算符按照如下形式使用:

cond ? expr1:expr2

条件运算符的执行过程是:首先求cond的值,如果为真则对expr求值并返回该值,否则对expr2求值并返回该值。举例:

string finalgrade = (grade < 60) ? "fail" : "pass";

当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值;否则是右值。

位运算符

位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。

运算符 功能 用法
~ 位求反 ~expr
<< 左移 expr1 << expr2
>> 右移 expr1 >> expr2
& 位与 expr1 & expr2
^ 位异或 expr1 ^ expr2
| 位或 expr1 | expr2

一般来说,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。运算对象可以是带符号的,也可以是无符号的。如果运算对象是带符号的且它的值为负,那么位运算如何处理运算对象的“符号位”依赖于机器。

Warning

强烈建议将位运算符用于处理无符号类型。

my note: 一个提升例子就是,如果对char做位运算,它会先被提升为int。

移位运算符

<<和>>运算符的内置含义是对其运算对象执行基于二进制位的移动操作。首先令左侧运算对象的内容按照右侧运算对象的要求移动指定位数,然后将经过移动的(可能还进行了提升)左侧运算对象的拷贝作为求值结果。其中,右侧的运算对象一定不能为负,而且必须严格小于结果的位数,否则就会产生未定义的行为。移出边界之外的位数被舍弃掉了。

左移运算符<<在右侧插入值为0的二进制位。右移运算符>>的行为依赖于左侧运算对象的类型:如果是无符号的,在左侧插入值为0的二进制位;如果是带符号的,在左侧插入符号位的副本或值为0的二进制位,如何选择视具体环境而定。

sizeof运算符

sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof运算符满足右结合律,其所得的值是一个size_t类型的常量表达式。它有两种形式:

  • sizeof(type)
  • sizeof expr

my note: 常量表达式意味着在编译期间就能得到计算。

第二种形式中,sizeof返回的是表达式结果类型的大小。

sizeof运算符的结果部分地依赖于其作用的类型:

  • 对char或者类型为char的表达式执行sizeof运算,结果得1。

  • 对引用类型执行sizeof运算得到被引用对象所占空间大小。

  • 对指针执行sizeof运算得到指针本身所占空间的大小。

  • 对解引用指针执行sizeof运算得到指针指向对象所占空间的大小,指针不需要有效。

  • 对数组执行sizeof运算得到整个数组所占空间大小。

  • 对string对象或vector执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。

逗号运算符

逗号运算符(comma operator)含有两个运算对象,按照从左向右的顺序依次求值。

它首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么结果也是左值。

类型转换

考虑下面这条表达式,它的目的是将ival初始化为6:

int ival = 3.541 + 3;

C++语言不会直接将两个不同类型的值相加,而是先根据类型转换规则设法将运算对象的类型统一后再求值。上述转换是自动进行的,它们被称作隐式转换(implicit conversion)。

很多时候,如果表达式中既有整数类型的运算对象也有浮点数类型的运算对象,整型会转换成浮点型。

何时发生隐式转换

在下面这些情况下,编译器会自动地转换运算对象的类型:

  • 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型。

  • 在条件中,非布尔值转换成布尔类型。

  • 初始化过程中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型。

  • 如果算术运算或关系运算对象有多种类型,需要转换成同一种类型。

算术转换

算术转换(arithmetic conversion)的含义是把一种算术类型转换成另外一种算术类型。

整数提升(integral promotion)负责把小整数转换成较大的整数类型。

my note: 这里的概念很细,感觉没有必要一一记录,因为书中已经有了明晰的介绍,可参阅p142。

其他隐式转换

数组转换成指针:在大多数用到数组的表达式中,数组自动转换成指向数组首元素的指针。

指针的转换:常量整数值0或者字面值nullptr能转换成任意指针类型;指向对象的指针可以转换成void*指针。

转换成布尔类型:如果指针或算术类型的值为0,转换的结果是false,否则是true。

转换成常量:允许将指向非常量类型的指针转换成底层const版本的指针,对于引用也是一样。

int i;
const int *p = &i;    // 非常量的地址转换成const的地址

类类型定义的转换:类类型能定义由编译器自动执行的转换。

string s = "a value";    // 字符串字面量转换成string类型
while (cin >> a);        // while的条件部分把cin转换成布尔值

显式转换

命名的强制类型转换

一个命名的强制类型转换有如下形式:

cast-name(expression);

其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果是左值。cast-name是static_cast, dynamic_cast, const_cast和reinterpret_cast中的一种。dynamic_cast支持运行时识别,直到19章(p730)才会讲解。

static_cast

任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。

// 进行强制类型转换以便执行浮点数除法
double slope = static_cast<double>(j) / i;

当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换表示,我们知道并且不在乎潜在的精度损失。

static_cast对于编译器无法自动执行的类型转换也非常有用。例如,我们可以使用static_cast找回存在于void*的指针中的值:

void *p = &d;    // 正确,任何非常量对象的地址都能存入void*

// 正确,将void*转换回初始的指针类型
double *dp = static_cast<double*>(p);

必须确保转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。

const_cast

const_cast只能改变运算对象的底层const:

const char *pc;
char *p = const_cast<char*>(pc);    // 正确,但是通过p写值是未定义的行为

如果对象本身是一个非常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,执行写操作就会产生未定义的后果。

const_cast常常用于有函数重载的上下文中,这将在第6章介绍(p208)。

reinterpret_cast

reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。比如:

int *ip;
char *pc = reinterpret_cast<char*>(ip);

我们必须牢记pc所指的真实对象是一个int而非字符。

my note: reinterpret_cast非常危险,书中建议尽量避免使用。因为它本质上依赖于机器。且没有介绍应用场景。另外,书中也建议尽量避免其他的强制类型转换,强制类型转换应当在其合适的应用场景中使用。

旧式的强制类型转换

在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:

type(expr);   // 函数形式的强制类型转换
(type)expr;   // C语言风格的强制类型转换

根据所涉及的类型不同,旧式的强制类型转换分别具有const_cast, static_cast或reinterpret_cast相似的行为。

Warning

与命名的强制类型转换相比,旧式的强制类型转换从表现形式上来说不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。