第四章 表达式

171 阅读16分钟

表达式(expression)由运算符(operator)和运算对象(operand)组成,对其求值将得到一个结果(result)。字面量和变量是最简单的表达式,其结果就是字面量和变量的值。

基础

基本概念

按照运算对象的个数,运算符可分为一元运算符(unary operator)、二元运算符(binary operator)、三元运算符。函数调用也是一种特殊的运算符。

复杂表达式的含义依赖于运算符的优先级(precedence)、结合律以及运算对象的求值顺序

表达式求值过程中,运算对象常常由一种类型转换成另一种类型。小整数类型(如:boolcharshort 等)通常会被提升(promoted)成较大的整数类型(主要是 int)。

运算符作用于类类型的运算对象时,可以自定义其含义。这种为已存在的运算符赋予另一层含义,称为重载运算符(overloaded operator)。IO 库的 >><<,以及 stringvector、迭代器使用的运算符都是重载运算符。重载运算符的运算对象类型和返回值类型由该运算符定义,但是运算对象个数、运算符优先级、结合律均无法改变。

C++ 表达式可分为左值(lvalue)和右值(rvalue)。左值表达式的求值结果是一个对象或一个函数,常量对象等左值不能作为赋值语句左侧运算对象。有些表达式的求值结果是对象,但它们是右值而非左值。一个对象用作右值时,用的是对象的值(内容),用作左值时,用的是对象本身(在内存中的位置)。

左值、右值这两个名词从 C 语言继承来,愿意为:左值可以放在赋值语句左侧,右值不能。

不同运算符的运算对象和运算结果对左右值的要求不同。除了特殊情况之外,需要右值的地方可以用左值代替(此时用的是值),但不能把右值当成左值使用。下面是几种熟悉的运算符:

  • 赋值 =
    • 左侧运算对象:左值;非常量。
    • 运算结果:左值。
  • 取地址 &
    • 运算对象:左值。
    • 运算结果:右值;指向运算对象的指针。
  • 内置解引用 *下标 [],迭代器解引用 *stringvector 下标索引 []
    • 运算对象:左右值均可。
    • 运算结果:左值。
  • 内置类型和迭代器的递增 ++递减 --
    • 运算对象:左值。
    • 前置形式的运算结果:左值。
    • 后置形式的运算结果:右值。

decltype 作用在左值表达式上返回引用类型,作用在右值表达式上返回该值的类型。

优先级和结合律

复合表达式(compound expression)是指含有两个或多个运算符的表达式。优先级和结合律决定了运算对象的组合方式,决定了表达式中每个运算符对应的运算对象来自于表达式的哪一部分。算术运算符满足左结合律,即如果优先级相同,按从左向右顺序组合运算。

括号括起来的部分作为一个独立单元单独求值。

求值顺序

优先级和结合律决定了表达式的含义,但并没有完全确定表达式中各部分的计算顺序。

int i = f() + g() * h() + j();
cout << i << " " << ++i << endl;

对于没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,结果将不确定。有 4 种运算符明确规定了运算对象的求值顺序:逻辑与 &&逻辑或 ||条件 ?:逗号 ,

对于大多数二元运算符,C++ 没有明确规定求值顺序,这给编译器留下了空间,并在代码效率和潜在缺陷之间做了权衡。

书写复合表达式的经验准则:

  1. 不确定的地方就用括号来限制表达式的组合关系;
  2. 若改变了某运算对象的值,表达式的其它地方不要再使用该运算对象,除非有明确的先后顺序。

算术运算符

arithmetic-operator.png

算术运算符:

  • 运算对象:右值;任意算术类型以及能转为算术类型的任意类型。
  • 运算结果:右值。

表达式求值前,小整数类型运算对象被提升为较大的整数类型,运算对象都转为同一类型。

一元正号运算符、加法、减法可以作用于指针。一元正号运算符作用于指针或算术值时,返回运算对象值提升后的一个副本。

一元负号运算符返回的是提升并取负后的副本。对于大多数运算符,布尔值将被提升为 int 类型。

bool b = true;
bool b2 = -b; // b2 为 true

算术表达式可能产生不确定的结果:

  1. 一部分是数学性质本身,比如:除以 0;
  2. 另一部分是计算机的特点,比如:溢出,即计算结果超过该类型所能表示的范围。很多系统在编译和运行时不报溢出错误,不同系统对溢出的处理方式可能不同,有些系统用环绕(wrapped around)来处理溢出。

整数相除其实就是求商,即:忽略小数部分,向 0 取整。取余 % 的运算对象必须是整数,且忽略除数的负号,即 m % nm % (-n) 相等,余数与被除数同号。只要 n 非零,(m / n) * n + m % nm 相等。

逻辑和关系运算符

logic-relation-operator.png

关系运算符:

  • 运算对象:右值;算术类型或指针类型。
  • 运算结果:右值;布尔型。

逻辑运算符:

  • 运算对象:右值;任何能转换为布尔值的类型。

    值为 0 的算术类型或指针类型转为 false,否则转为 true

  • 运算结果:右值;布尔型。

逻辑与 &&逻辑或 || 都是先算左侧运算对象,无法确定表达式结果时(&& 左侧为 true|| 左侧为 false)才计算右侧运算对象,这种策略称为短路求值。逻辑运算符和条件表达式处理的是布尔值,因此会将参与运算的算术对象或指针对象转为相应的真值。

关系运算符(包括 ==!=)处理的是某一类型的对象(比如数字、指针等),因此会将参与运算的对象进行类型转换,比如布尔值转为数字。

除非比较的对象是布尔型,否则不要使用布尔字面量 truefalse 作为比较运算的对象。

赋值运算符

赋值运算符 =

  • 左侧运算对象:左值,非常量。
  • 运算结果:左值;返回左侧运算对象。

如果左右两侧运算对象的类型不同,右侧运算对象将转换为左侧运算对象的类型。

C++ 11 引入了 {} 列表赋值。内置类型的列表赋值最多只能包含一个值,类类型的赋值细节由类本身决定。列表为空时,编译器将会创建一个值初始化的临时量赋给左侧运算对象。

int k = 0;
k = {3.14};
vector<int> ivec;
ivec = {1, 2, 3};

赋值运算符满足右结合律。多重赋值语句中的每个对象,要么和右侧对象类型相同,要么可由右侧对象类型转换得到。

指针无法转为整型。

string s1, s2;
s1 = s2 = "Hello";

赋值语句优先级较低,在条件中通常需要加括号来实现需求。

int i;
while ((i = get_value()) != 42) {
  // ...
}

算术运算符和位运算符都有相应的复合赋值运算符:

  1. +=-=*=/=%=
  2. <<=>>=&=^=|=

复合赋值运算符等价于:

a = a op b

差别仅在于,普通赋值需要对左侧运算对象求值两次,复合赋值只需对左侧运算对象求值一次,性能上略有差异,但可以忽略。

递增递减运算符

递增 ++递减 -- 有前置、后置两种形式:

  • 运算对象:左值。
  • 运算结果
    • 前置:左值;运算后的对象。
    • 后置:右值;运算前对象副本。

优先使用前置形式,前置形式返回运算后的对象,避免了不必要的工作。后置形式需要保存原始值,这可能造成一定的浪费。对于整数和指针,编译器可能对这一额外工作进行一定优化,但是对于相对复杂的迭代器类型,这种额外工作消耗巨大。

*iter++ 写法很普遍,表示先将迭代器 iter 向前推进一步,再解引用迭代前的值。

由于 ++-- 改变了运算对象的值,因此必须注意复合表达式中因为求值顺序产生的问题。

成员访问运算符

箭头运算符 ->

  • 运算对象:左右值均可;指向类对象的指针。
  • 运算结果:左值;类对象的成员。

点运算符 .

  • 运算对象:左右值均可。
  • 运算结果:左右值与成员所属对象相同;返回类对象的成员。

解引用 * 的优先级低于点运算符。

条件运算符

条件运算符 ?: 先计算 cond,再计算 expr1/expr2

cond ? expr1 : expr2;
  • 运算对象:左右值均可;cond 是判断条件的表达式,expr1expr2 两者类型相同或可转为某个公共类型。
  • 运算结果:expr1expr2 都是左值或能转换为同一左值类型时,返回左值,否则返回右值。

条件运算符可以嵌套,即条件表达式可以作为另一个条件运算符的 condexpr条件运算符满足右结合律。条件运算符优先级很低,通常需要加括号来提升优先级。

条件运算符嵌套过多会导致可读性下降,因此嵌套层数最好不超过 2 或 3 层。

位运算符

bit-operator.png

位运算符:

  • 运算对象:右值;整型和 bitset 标准库类型。
  • 运算结果:右值。

小整型运算对象会被自动提升为较大的整型。运算对象可以有符号,也可以无符号。位运算符如何处理符号位依赖于机器,而且左移可能会改变符号位的值,因此结果是不确定的。建议位运算符仅用于处理无符号类型

移位运算符根据右侧运算对象对左侧运算对象的拷贝移动指定位数,然后将移位后左侧运算对象的拷贝作为求值结果,移出边界的位将被舍掉。右侧运算对象不能为负,而且值必须小于结果的位数,否则行为不确定。

左移 << 在右侧补 0。对于右移 >>,左侧运算对象如果是无符号型,将在左侧补 0;如果是有符号型,在左侧补符号位还是补 0 取决于具体环境。

位求反 ~位与 &位或 |位异或 ^ 也都会对运算对象提升。

异或就是当两者中有且仅有一个为 1 时,返回 1,否则返回 0。

移位运算符(也称 IO 运算符)满足左结合律

sizeof 运算符

sizeof 运算符满足右结合律

  • 运算对象:类型或表达式。
  • 运算结果:一个类型为 size_t 的常量表达式,表示该类型或该表达式结果类型所占的字节数。
sizeof(type);
sizeof expr;

sizeof 不会计算运算对象的值,因此即使表达式中的值无效(比如无效指针),也可以得到结果,只要能确定类型即可。此外,还可以通过作用域运算符 :: 获取类成员大小。

Sales_data data, *p;
sizeof(Sales_data);
sizeof p;
sizeof *p;
sizeof Sales_data::revenue;

constexpr size_t sz = sizeof(arr)/sizeof(*arr);
int arr1[sz] = {};

sizeof 的结果依赖于所作用的对象类型:

  • 作用于引用类型,得到引用对象所占空间大小;
  • 作用于指针,得到指针本身所占空间大小;
  • 作用于解引用指针,得到指针所指对象所占空间大小,指针可以无效;
  • 作用于数组,得到整个数组所占空间大小,不会把数组转为指针再处理;
  • 作用于 stringvector,得到该类型固定部分的大小,不会计算对象中元素实际占用空间。

逗号运算符

逗号运算符 , 按从左向右的顺序依次求值:

  • 运算对象:任意表达式。
  • 运算结果:返回右侧表达式的结果,左右值取决于该结果。

逗号运算符常用于 for 循环中。

类型转换

运算符的运算对象类型不一致时,会将运算对象转为同一类型。自动执行的类型转换称为隐式转换。算术类型间的隐式转换会尽可能避免损失精度。隐式转换的时机:

  • 大多数表达式中,比 int 小的整型首先提升至较大的整型。
  • 条件表达式中,非布尔型转为布尔型。
  • 初始化时,初始值转为变量类型;赋值时,右侧运算对象转为左侧运算对象的类型。
  • 算术运算、关系运算的运算对象如果有多种类型,则会转为同一种类型。
  • 函数调用时也会发生类型转换。

隐式转换

算术转换的含义是将一种算术类型转为另一种算术类型。算术转换规则定义了一套类型转换的层次,运算符的运算对象将转为最宽的类型。如果表达式中同时有浮点数和整数,整数将转为浮点数。

整型提升(integral promotion)会将小整型转为大整型:

  • 对于 boolcharsigned charunsigned charshortunsigned short,首先尝试转为 int,如果 int 无法 “容纳” 该类型,则会转为 unsigned int
  • 对于较大的字符型 wchar_tchar16_tchar32_t,会转为 intunsigned intlongunsigned longlong longunsigned long long 中能 “容纳” 该类型的最小的那个类型。

整型提升后如果类型仍不一致,将进一步转换类型:

  • 对于运算对象都有符号或都无符号的情况,小类型转为大类型。
  • 对于一个有符号、另一个无符号的情况,如果有符号类型可以 “容纳” 无符号类型,就把无符号类型转为有符号类型,否则将有符号类型转为无符号类型。

这里,可以 “容纳” 某类型,指的是可以存放该类型中所有的值。

由于有些类型的大小依赖于机器,因此上述类型转换与机器有关。

除了算术转换之外,还有其它类型的隐式转换:

  • 数组转为指针

  • 指针转换

    常量整数值 0、字面量 nullptr 可以转为任意指针类型。指向任意非常量的指针可以转为 void*,指向任意对象的指针可以转为 const void*

  • 转为布尔型

    指针或算术类型的值为 0 时,转为 false,否则为 true

  • 转为常量

    指向 T 类型的指针或引用可以转为指向 const T 类型的指针或引用。

  • 类的自定义转换

    类类型可以定义由编译器自动执行的转换,编译器每次只能执行一种类类型转换。比如:C 风格字符串转为 stringistream 转为布尔型。

显式转换

显式转换也称强制类型转换(cast)。命名的强制类型转换具有如下形式:

强制类型转换本质上非常危险。

cast-name<type>(expression);

expression 是要转换的值,type 是目标转换类型,如果 type 是引用类型,则结果为左值。cast-name 有以下几种:

  1. static_cast

    任何明确定义的、不包含底层 const 的类型转换都可以使用 static_caststatic_cast 可用于将较大的算术类型转为较小的算术类型,从而消除编译器对潜在精度损失的警告。static_cast 对于编译器无法自动执行的类型转换也很有用,这需要程序员自己保证类型的正确性,否则结果不确定。指针转换前后所包含的地址不变。

    double slope = static_cast<double>(i)/j;
    void *p = &slope;
    double *dp = static_cast<double*>(p);
    

    “明确定义” 指的是类型转换具有一定的合理性(或者说类型有关联,类型相近),可以明确地知道类型转换的结果,比如:整型与浮点型间的转换是明确的,而整型与指针间的转换则没有明确定义。

  2. dynamic_cast

    dynamic_cast 支持运行时类型识别。

  3. const_cast

    只能改变指针、引用的底层 const,不能改变表达式的类型。其它形式的命名强制类型转换无法改变表达式的常量属性。const_cast 常用于函数重载上下文中。

    const char *pc;
    char *p = const_cast<char*>(pc);
    static_cast<string>(pc);
    
  4. reinterpret_cast

    reinterpret_cast 为运算对象的位模式提供较低层次的重新解释。使用 reinterpret_cast 非常危险,因为编译器不会给出任何警告或错误信息,但是运行时可能引发严重后果。而且排查这类问题非常困难。

    reinterpret_cast 本质上依赖于机器,对它的安全使用必须对涉及的类型和编译器实现转换的过程都非常了解。

    int *ip;
    char *cp = reinterpret_cast<char*>(ip);
    string str(cp); // cp 虽为 char* 类型,但是指向的仍是 int
    

强制类型转换干扰了正常的类型检查,强烈建议避免使用强制类型转换,reinterpret_cast 尤其如此。非函数重载上下文中使用 const_cast 也会产生缺陷,dynamic_caststatic_cast 也都不应该频繁使用。强制类型转换应考虑使用其他方式代替,实在无法避免,应尽可能限制类型转换值的作用域,并记录对相关类型的所有假定,减少错误产生。

早期 C++ 有两种显式类型转换:

type (expr); // 函数形式
(type) expr; // C 语言风格

根据涉及类型不同,它们与 const_caststatic_castreinterpret_cast 行为相似。如果所执行的类型转换用 const_caststatic_cast 替换后合法,则行为也一致,否则与 reinterpret_cast 功能一致。

与命名强制类型转换相比,旧式强制类型转换形式不太清晰,容易看漏,追踪问题更困难。

运算符优先级

operator-priority-1.png

operator-priority-2.png