3.3 表达式

3.3.1 表达式和数据类型

直到现在,我们没有进行明确地定义就使用了表达式(expression)这个词。

首先介绍基本表达式(primary expression),基本表达式是指:

  • 标识符(变量名、函数名)

  • 常量(包括整数常量和浮点数常量)

  • 字符串常量(使用“”括起来的字符串)

  • 使用()括起来的表达式

此外,对表达式使用运算符,或通过运算符将表达式和表达式相互连接,这些表示方法也称为表达式。

也就是说,“5”、“hoge”都是表达式(如果已经声明了以 hoge 作为名称的变量)。此外,“5 + hoge”也是表达式。

对于下面的表达式,

  1. a + b * 3 / (4 + c)

它可以表现成如图 3-10 这样的树结构,这个树结构的所有部分的树*都是表达式。

* 这里指某个特定节点以下的树。

3.3 表达式 - 图1

图 3-10 表达式的树结构

此外,所有的表达式都持有类型

3.2 节中介绍了可以通过链的结构表现类型。如果所有的表达式都持有类型,那么对于表现表达式的树结构的节点,都可以被挂接上表现类型的链(参照图 3-11)。

3.3 表达式 - 图2

图 3-11 所有的表达式都持有类型

在对表达式使用运算符,或者将表达式作为参数传递给函数的时候,表达式中持有的类型具有特别重要的意义。

比如,对于下面这样的数组:

  1. char str[256];

在输出这个字符数组的内容的时候,使用

  1. printf(str);

初学者看到这段程序,难免会想:“printf()能这么写吗?”

确实,就像这个世上最有名的程序中写的这样:

  1. printf("hello, world\n");

第 1 个参数总是传递字符串常量。

可是,在 stdio.h 的原型声明中,printf()的第 1 参数被定义为“指向 char 指针”。

字符串常量的类型为“char 的数组”,因为是在表达式中,所以它也可以当成“指向 char 的指针”。因此,字符串常量可以传递给 printf()。同样地,str 是“char 的数组”,因为是在表达式中,所以也可以当成“指向 char 的指针”,能够传递给 printf()也是很自然的事。

如果只是单纯地输出字符串,对于字符串中包含%感到麻烦,与其使用

  1. printf("%s", str);

不如使用 puts()会更好。这个就扯远了。

此外,下面的写法也许会让某些人感到惊奇:

  1. "01234567890ABCDEF"[index]

但如果写成这样:

  1. str[index]

谁都不会觉得奇怪了吧。在表达式中,str 和字符串常量都属于“指向 char 的指针”,它们都可以作为[]运算符的操作数*

* 运算符(operator)的作用对象被称为操作数。比如,1 + 2 这个表达式,12 是运算符 + 的操作数。

补充 针对“表达式”使用 sizeof

sizeof 运算符有两种使用方法。

一种是:

  1. sizeof(类型名)

另外一种是:

  1. sizeof 表达式

后者能够返回对象表达式的类型的大小。

在实际开发中,“sizeof 表达式”这种使用方式的唯一用途,就是从编译器获取数组的长度。

对于下面的声明,

  1. int hoge[10];

sizeof(int)为 4 的处理环境中,

  1. sizeof(hoge)

返回 40。因此将这个结果除以 sizeof(int)就可以得到数组元素的个数。

如果像这个例子这样显式地指定了数组的大小,即使不使用 sizeof,使用#define 给大小定义一个合适的名称也是可以满足需求的。不过在下面的情况下,使用 sizeof 也许更加方便。

  1. char color_name[] = {
  2. "black",
  3. "blue",
  4. :
  5. :
  6. };
  7.  
  8. #define COLOR_NUM (sizeof(color_name) / sizeof(char))

在这种情况下,由于使用了数组初始化表达式,这里可以省略定义数组元素的个数,因此就不需要使用#define 定义一个固定的常量了。另外,在某些情况下需要在 color_name 中追加更多的元素,如果使用 sizeof,只需修改程序的一个地方。

参照 3.5.2 节。

无论怎样,sizeof 运算符只是向编译器问询大小的信息,所以,它只能在编译器明确知道对象大小的情况下使用

  1. extern int hoge[];

以上的情况是不能使用 sizeof 的。此外,

  1. void func(int hoge[])
  2. {
  3. printf("%d\n", sizeof(hoge));
  4. }

这样的程序,也只是输出指针的长度(参照 3.5.1 节)。

也许很多人并不知道,对于“sizeof 表达式”,其实不需要括号。当然,加上括号也可以,此时的括号只是单纯地起到括起操作数的作用。

有人尽管知道这一点,但为了阅读的方便,依然使用了括号。

3.3.2 “左值”是什么——变量的两张面孔

假设有下面这样一个声明:

  1. int hoge;

因为此时 hogeint 类型,所以,只要是可以写 int 类型的值的地方,hoge 就可以像常量一样使用。

比如,将 5 赋予 hoge 之后,下面的语句

  1. piyo = hoge * 10;

理所当然地可以写成

  1. piyo = 5 * 10;

但是,在

  1. hoge = 10;

的情况下,即使此时 hoge 的值为 5,

  1. 5 = 10;

这样的置换也是非法的。

也就是说,作为变量,它有作为“自身的值”使用和作为“自身的内存区域”使用两种情况。

此外在 C 中,即使不是变量名,表达式也可以代表“某个变量的内存区域”。比如这种情况:

  1. hoge_p = &hoge;
  2. *hoge_p = 10; ←*hoge_p 是指 hoge 的内存区域

像这样,表达式代表某处的内存区域的时候,我们称当前的表示式为左值(lvalue);相对的是,表达式只是代表值的时候,我们称当前的表达式为右值

表达式中有时候存在左值,有时候不存在左值。比如,根据上下文,表达式可以作为左值或者右值使用,但是 5 这样的常量,或者 1 + hoge 这样的表达式却只能解释成右值。

补充 “左值”这个词汇的由来

在 C 以前的语言中,因为表达式在赋值的左边,所以表达式被解释成左值。“左”在英语中是 left,left value 就被简写成 lvalue。

但在 C 中,++hoge 这样写法也是合法的,此时 hoge 是指某处的内存区域,但是怎么看也看不出“左边”的意思。因此,左值这个词真有点让人摸不着头脑。

在标准委员会的定义中,lvalue 的 l 不是 left 的意思,而表示 locator(指示位置的事物)。Rationale 中有下面一段描述,

The Committee has adopted the definition of lvalue as an object locator.

尽管如此,JIS X3010 还是将 lvalue 解释成了“左值”。1

1 中国国家标准 GB/T 15272*94(189 页)中,也是将 lvalue 解释成左值。——译者注

3.3.3 将数组解读成指针

正如在前面翻来覆去提到的那样,在表达式中,数组可以解读成指针。

  1. int hoge[10];

以上的声明中,hoge 等同于&hoge[0]

hoge 原本的类型为“int 的数组(元素个数 10)”,但并不妨碍将其类型分类“数组”变换为“指针”。

图 3-12 表现了其变换的过程。

3.3 表达式 - 图3

图 3-12 将数组解读成指针

此外,数组被解读成指针的时候,该指针不能作为左值。

这个规则有以下的例外情况。

  • 数组为 sizeof 运算符的操作数

在通过“sizeof 表达式”的方式使用 sizeof 运算符的情况下,如果操作数是“表达式”,此时即使对数组使用 sizeof,数组也会被当成指针,得到的结果也只是指针自身的长度。照理来分析,应该是这样的吧?可是,当数组成为 sizeof 的操作数时,“数组解读为指针”这个规则会被抑制,此时返回的是数组全体的大小。请参照 3.3.1 节的补充内容。

  • 数组为&运算符的操作数

通过对数组使用&,可以返回指向整体数组的指针。在 3.2.4 节中已经介绍了“指向数组的指针”。

这个规则已经被追加到 ANSI C 规则之中。此前的编译器,在对数组使用&的时候,大多会报错。因此,当时的程序在这一点上不会出现问题。那么这个规则的制定有什么好处呢?我想应该是为了保持统一吧。

  • 初始化数组时的字符串常量

我们都知道字符串常量是“char 的数组”,在表达式中它通常被解读成“指向 char 的指针”。其实,初始化 char 的数组时的字符串常量,作为在花括号中将字符用逗号分开的初始化表达式的省略形式,会被编译器特别解释(参照 3.5.3 节)。

在初始化 char 的指针的时候,字符串常量的特别之处,需要引起注意。

3.3.4 数组和指针相关的运算符

以下介绍数组和指针相关的运算符。

解引用

单目运算符*被称为解引用

运算符*将指针作为操作数,返回指针所指向的对象或者函数。只要不是返回函数,运算符*的结果都是左值。

从运算符*的操作数的类型中仅仅去掉一个指针后的类型,就是运算符*返回的表达式的类型(参照图 3-13)。

3.3 表达式 - 图4

图 3-13 使用解引用而发生的类型的变化

地址运算符

单目运算符&被称为地址运算符

&将一个左值作为操作数,返回指向该左值的指针。对左值的类型加上一个指针,就是&运算符的返回类型(参照图 3-14)。

3.3 表达式 - 图5

图 3-14 使用地址运算符而发生的类型的变化

地址运算符不能将非左值的表达式作为操作数。

下标运算符

后置运算符[]被称为下标运算符

[]指针和整数作为操作数。

  1. p[i]

  1. *(p + i)

的语法糖,除此以外没有任何其他意义。

对于声明为 int a[10]的数组,使用 a[i]的方式进行访问的时候,由于 a 在表达式中,因此它可以被解读成指针。所以,你可以通过下标运算符访问数组(将指针和整数作为操作数)。

归根结底,p[i]这个表达式就是*(p + i),所以下标运算符返回的类型是,从 p 的类型去掉一个指针的类型。

->运算符

在标准中,似乎并没有定义->运算符的名称,现实中有时它被称为“箭头运算符”。

通过指针访问结构体的成员的时候,会使用->运算符。

  1. p->hoge;

  1. (*p).hoge;

的语法糖。

利用*p*,从指针 p 获得结构体的实体,然后引用成员 hoge

3.3.5 多维数组

在 3.2.5 节中,我们提到了 C 语言中不存在多维数组

那些看起来像多维数组的其实是“数组的数组”。

这个“多维数组”(山寨货),通常使用 hoge[i][j]的方式进行访问。让我们来看一看这个过程中究竟发生了什么。

  1. int hoge[3][5];

对于上面这个“数组的数组”,使用 hoge[i][j]这样的方式进行访问(参照图 3-15)。

3.3 表达式 - 图6

图 3-15 访问多维数组

  1. hoge 的类型为“int 的数组(元素个数 5)的数组(元素个数 3)”。

  2. 尽管如此,在表达式中数组可以被解读成指针。因此,hoge 的类型为“指向 int 的数组(元素个数 5)的指针”。

  3. hoge[i]*(hoge + i)的语法糖。

    1. 给指针加上 i,就意味着指针前移它指向的类型×i 的距离。hoge 指向的类型为“int 的数组(元素个数 5)”,因此,hoge + i 让指针前移了 sizeof(int[5])×i 的距离。

    2. 通过*(hoge + i)中的*,去掉一个指针,*(hoge + i)的类型就是“指向 int 的数组(元素个数 5)”。

    3. 尽管如此,由于在表达式中,数组可以解读成指针,所以*(hoge + i)的最终类型为“指向 int 的指针”。

  4. (*(hoge + i))[j]*((*(hoge + i)) + j)其实是相等的,因此,(*(hoge + i))[j]就是“对指向 int 的指针加上 j 后得到的地址上的内容”,其类型为 int

某些语言中,使用 array[i, j]这样的写法来支持多维数组*

* Pascal 就支持这样的写法,但是,Pascal 的“多维数组”只不过是“数组的数组”的语法糖。

在 C 中,因为没有多维数组,所以使用“数组的数组”来代替,这样倒也没有什么问题。可是,如果反过来,只有多维数组,而没有“数组的数组”,事情就麻烦了。

比如,将某人一年之中每天的工作时间使用下面这个“数组的数组”来表现*

* 数组中,月和日是从 0 开始的,只有在输出的时候才可修正。2 月等 情况下,数组的元素就会显得冗余,但是不会产生问题。

  1. int working_time[12][31];

在这里,如果开发一个根据一个月的工作时间计算工资的函数,可以像下面这样将某月的工作时间传递给这个函数,

  1. calc_salary(working_time[month]);

calc_salary 的原型像下面这样:

  1. int calc_salary(int *working_time);

这种技巧只有通过“数组的数组”才能实现,多维数组是无能为力的。

补充 运算符的优先级

C 语言中有数量众多的运算符,其优先级分 15 个级别。

和其他语言相比,C 语言的优先级别还是非常多的。很多 C 的参考书,都像表 3-5 这样记载了运算符优先级的内容。

表3-5 运算符优先顺序表(摘录于K&R p.65)

运 算 符 连接规则
() [] -> . 从左往右
! ~ ++ -- + - * & (type) sizeof 从右往左
* / % 从左往右
+ - 从左往右
<< >> 从左往右
< <= > >= 从左往右
== != 从左往右
& 从左往右
^ 从左往右
| 从左往右
&& 从左往右
|| 从左往右
?: 从右往左
= += -= *= /= %= &= ^= |= <<= >>= 从右往左
, 从左往右

注:进行单目运算的+-、和*的优先级高于进行双目运算的+-、和*

其中,对于优先级“最高”的是(),有相当多的人抱有以下观点:

当需要改变原本语法规定的优先级、强制地设定自己需要的优先级的时候,程序员们会使用()。因此,()具有最高的优先级是理所当然的。

这是一种误解

如果()可以这样理解,还有必要特地将它记载在优先级的表中吗?

这个表中的(),(正如 K&R 中记述的那样)代表着调用函数的运算符。此时的优先级是指,对于 func(a, b)这样的表达式,func()之间的关联强度。

此外,我们经常看到类似于下面这样的编码:

  1. p++;

究竟是对 p 进行加法运算?还是对 p 所指向的对象(\p)进行加法运算?关于这一点,很多书是这样解释的:

尽管*++的优先级相同,但由于连接规则是从右往左,所以 p++先进行连接。因此,被进行加法运算的不是*p,而是 p

就连 K&R 自身也是这样说明的。其实这种说法不太恰当。

根据 BNF(Backus-Naur Form)规则,C 语言标准定义了语法规则,其中也包含了运算符的语法规则。

关于 BNF,由于超出了本书的范围,在此就不做说明了。根据 BNF,后置的++比前置的++*等运算符的优先级高,()[]的优先级相同

参照 K&R 中 的标准 6.3.2 和 6.3.3。

也就是说,关于*p++的运算符优先级,

后置的++*的优先级高,因此,被进行加法运算的不是*p,而是 p

从语法上来看,这种说法才是比较合理的。