表达式

表达式由运算对象运算符组成。对表达式求值会得到一个结果。

最简单的表达式有:

  • 字面值
  • 变量

例如:

1
2
3
42
x
x + 1

表达式基础

运算符的种类

按运算对象个数分类:

  • 一元运算符:作用于一个运算对象
    &*!++
  • 二元运算符:作用于两个运算对象
    +-==
  • 三元运算符:作用于三个运算对象
    如条件运算符 ?:

此外,函数调用也可以看作一种特殊运算形式。

左值和右值

C++ 表达式要么是左值,要么是右值

  • 左值:表示对象的身份,可理解为“有位置的对象”
  • 右值:表示对象的值,通常不能出现在赋值号左侧

例如:

1
2
3
int i = 10;
i = 20; // i 是左值
10 = i; // 错误,10 是右值

可以简单记为:

  • 用作左值时,关注的是对象的“位置”
  • 用作右值时,关注的是对象的“内容”

优先级与结合律

复杂表达式中,运算符如何组合由:

  • 优先级
  • 结合律

共同决定。

优先级

优先级高的先结合。

例如:

1
3 + 4 * 5

先算 4 * 5,再算 3 + ...

结合律

当优先级相同时,看结合方向。

左结合

从左到右组合:

1
a - b - c

等价于:

1
(a - b) - c

大多数二元运算符都左结合。

右结合

从右到左组合:

1
a = b = c

等价于:

1
a = (b = c)

常见右结合运算符:

  • 赋值运算符
  • 条件运算符 ?:
  • 一元运算符

括号

括号优先级最高,可强制改变组合方式。

1
(a + b) * c

求值顺序

优先级和结合律只决定“怎么组合”,不决定“先算谁”。

例如:

1
f() + g()

即使加号是二元运算符,也不表示一定先算 f() 还是先算 g()

未指定求值顺序的风险

如果一个表达式中:

  • 既修改同一个对象
  • 又以其他方式访问该对象
  • 且没有明确的求值顺序

就可能产生未定义行为

例如:

1
2
int i = 0;
std::cout << i << " " << ++i << std::endl; // 未定义行为

明确规定求值顺序的运算符

下面这些运算符明确规定了运算对象的求值顺序:

  • &&
  • ||
  • ?:
  • ,

算术运算符

算术运算符用于数值计算。

运算符 含义
+ 一元正号 / 加法
- 一元负号 / 减法
* 乘法
/ 除法
% 求余

说明:

  • 大多数算术运算符是左结合
  • 一元 +- 的优先级高于二元 +-

整数除法

若两个运算对象都是整数,则结果仍是整数,小数部分被舍弃:

1
5 / 2   // 结果是 2

求余运算

% 只能用于整数类型:

1
7 % 3   // 结果是 1

逻辑运算符和关系运算符

关系运算符

用于比较大小或相等关系,结果是 bool

运算符 含义
< 小于
<= 小于等于
> 大于
>= 大于等于
== 相等
!= 不相等

关系运算符通常用于:

  • 算术类型
  • 指针类型(部分比较合法)

逻辑运算符

逻辑运算符作用于能转换成 bool 的类型,结果也是 bool

运算符 含义
! 逻辑非
&& 逻辑与
`

说明:

  • ! 是一元运算符,右结合
  • &&|| 是左结合
  • &&|| 具有短路求值特性

例如:

1
if (p && *p == 10)

p 为空指针,*p == 10 不会执行。

赋值运算符

赋值运算符要求:

  • 左侧必须是可修改的左值
1
2
int i;
i = 10; // 正确

特点

  • 赋值表达式的结果是左侧对象
  • 结果本身还是左值
  • 赋值运算符是右结合

例如:

1
2
int a, b, c;
a = b = c = 0;

等价于:

1
a = (b = (c = 0));

类型转换

如果左右类型不同,右侧会转换成左侧类型:

1
2
int i;
i = 3.14; // i 得到 3

列表赋值

C++11 允许使用花括号赋值:

1
2
int i;
i = {42};

但会禁止某些可能丢失信息的窄化转换。

递增和递减运算符

递增 ++ 和递减 -- 用于加 1、减 1。

分为:

  • 前置:先修改,再返回
  • 后置:先返回旧值,再修改

前置与后置

1
2
3
4
int i = 0, j;

j = ++i; // i = 1, j = 1
j = i++; // j = 1, i = 2

区别:

  • ++i:返回修改后的对象
  • i++:返回修改前的副本

返回值性质

  • 前置版本返回左值
  • 后置版本返回右值

因此一般来说:

  • 需要结果时再用后置
  • 不需要旧值时优先使用前置

尤其对迭代器,前置通常更高效。

优先级注意

后置 ++ 的优先级高于解引用 *

1
*pbeg++

等价于:

1
*(pbeg++)

含义:

  • 先取当前指针位置的值
  • 再让指针向后移动一位

成员访问运算符

成员访问有两种形式:

运算符 用途
. 对象访问成员
-> 指针访问成员

例如:

1
2
3
4
5
6
string s = "hello";
string *p = &s;

auto n1 = s.size();
auto n2 = (*p).size();
auto n3 = p->size();

其中:

1
p->size()

等价于:

1
(*p).size()

优先级注意

. 的优先级高于 *,所以:

1
*p.size()

会被解释成:

1
*(p.size())

这是错误的,因为 p 是指针,没有 size() 成员。

正确写法:

1
(*p).size()

条件运算符

条件运算符形式:

1
cond ? expr1 : expr2

含义:

  • cond 为真,结果是 expr1
  • 否则结果是 expr2

例如:

1
2
int a = 10, b = 20;
int max = (a > b) ? a : b;

特点

  • 条件运算符是右结合
  • 只会计算 expr1expr2 中被选中的那一个

可以嵌套:

1
2
3
grade = score > 90 ? 'A' :
score > 80 ? 'B' :
score > 70 ? 'C' : 'D';

条件运算符适合简单逻辑,复杂情况建议写成 if-else

位运算符

位运算符对整数类型进行逐位操作。

运算符 含义
~ 按位取反
<< 左移
>> 右移
& 按位与
^ 按位异或
` `

说明

按位取反 ~

把每一位:

  • 10
  • 01

左移 <<

右边补 0

右移 >>

  • 对无符号数:左边补 0
  • 对有符号数:结果依赖实现,需谨慎

按位与 &

两位都为 1,结果才为 1

按位异或 ^

两位不同,结果为 1

按位或 |

两位中有一个为 1,结果就为 1

位运算常用于标志位、权限控制、底层优化等场景。

sizeof 运算符

sizeof 返回某个类型或表达式所占的字节数

形式:

1
2
sizeof(type)
sizeof expr

例如:

1
2
sizeof(int)
sizeof x

特点

  • 结果是一个常量表达式
  • 返回类型是 size_t
  • 不会真正计算表达式的值

例如:

1
2
int *p = nullptr;
sizeof(*p); // 合法,不会真的解引用 p

示例

1
2
3
4
5
6
7
Sales_data data, *p;

sizeof(Sales_data); // 类型大小
sizeof data; // data 的类型大小
sizeof p; // 指针大小
sizeof *p; // p 所指对象类型大小
sizeof data.revenue; // 成员类型大小

逗号运算符

逗号运算符形式:

1
expr1, expr2

执行过程:

  1. 先计算左侧表达式
  2. 丢弃左侧结果
  3. 再计算右侧表达式
  4. 整个表达式结果为右侧结果

例如:

1
2
int i = 0, j = 1;
int k = (i++, j++);

结果:

  • i 变为 1
  • j 变为 2
  • k 的值是 1

类型转换

当表达式需要某种类型,而给出的运算对象是另一种相关类型时,编译器可能自动进行转换。

这种转换叫:

  • 隐式类型转换

例如:

1
int ival = 3.14; // ival 得到 3

算术转换

算术类型之间会自动转换,一般转换到“更宽”的类型。

例如:

1
double d = 3 + 4.5; // 3 先转成 double

整型提升

较小整数类型通常先提升为 intunsigned int,如:

  • bool
  • char
  • signed char
  • unsigned char
  • short
  • unsigned short

例如:

1
2
char c = 'a';
int i = c; // c 提升为 int

布尔值提升规则:

  • false -> 0
  • true -> 1

其他常见隐式转换

数组转指针

多数情况下,数组名会转换成首元素指针:

1
2
int arr[3];
int *p = arr;

指针转换

  • 0nullptr 可转换为空指针
  • 任意对象指针可转换为 const void*
  • 非常量对象指针可转换为 void*

转换为 bool

  • 0、空指针 -> false
  • 0、非空指针 -> true

转换为常量类型

允许:

1
2
int *p;
const int *cp = p;

不允许反向自动转换,因为会去掉底层 const

类类型转换

类类型可以自定义转换规则,但编译器一次最多只会自动应用一种类类型转换

显式类型转换

显式转换由程序员主动指定。

C++ 推荐使用命名的强制类型转换

1
cast-name<type>(expression)

其中 cast-name 是以下四种之一:

  • static_cast
  • const_cast
  • reinterpret_cast
  • dynamic_cast

static_cast

用于一般性、定义明确的转换。

例如:

1
2
double d = 3.14;
int i = static_cast<int>(d); // i = 3

适合:

  • 算术类型转换
  • void* 与具体类型指针间某些转换
  • 明确合法的转换

不能去掉底层 const

const_cast

只能改变对象的底层 const 属性。

1
2
const char *pc;
char *p = const_cast<char*>(pc);

说明:

  • 语法上合法
  • 但如果原对象本来就是常量,通过 p 修改它会产生未定义行为

换句话说:

  • 原对象非常量:可能安全
  • 原对象本身是常量:修改就是未定义行为

reinterpret_cast

按底层位模式重新解释对象。

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

特点:

  • 非常底层
  • 与机器和实现强相关
  • 风险大,不建议随意使用

dynamic_cast

主要用于继承体系中做运行时类型转换,依赖运行时类型识别(RTTI)。

常用于基类指针/引用转换为派生类指针/引用。

旧式强制类型转换

C++ 仍保留 C 风格写法,但不推荐使用

1
2
type(expr)   // 函数式
(type)expr // C 风格

例如:

1
2
int i = (int)3.14;
int j = int(3.14);

问题:

  • 不够明确
  • 不容易看出转换目的
  • 可能混合多种转换行为

现代 C++ 更推荐使用命名转换,如 static_cast<int>(3.14)

易错点总结

优先级不等于求值顺序

1
f() + g()

谁先算不一定。

不要在一个表达式里同时修改并读取同一对象

1
cout << i << ++i; // 危险/未定义

前置和后置 ++ 不同

1
2
++i  // 先改后用
i++ // 先用后改

*p++ 不是 (*p)++

1
*p++   // 等价于 *(p++)

. 优先级高于 *

1
2
(*p).size(); // 正确
*p.size(); // 错误

sizeof 不会真正求值

1
sizeof(*p); // 不会真的访问 *p

慎用 reinterpret_cast

除非非常清楚底层实现,否则不要用。