3.3 表达式
3.3.1 表达式和数据类型
直到现在,我们没有进行明确地定义就使用了表达式(expression)这个词。
首先介绍基本表达式(primary expression),基本表达式是指:
标识符(变量名、函数名)
常量(包括整数常量和浮点数常量)
字符串常量(使用
“”
括起来的字符串)使用
()
括起来的表达式
此外,对表达式使用运算符,或通过运算符将表达式和表达式相互连接,这些表示方法也称为表达式。
也就是说,“5
”、“hoge
”都是表达式(如果已经声明了以 hoge
作为名称的变量)。此外,“5 + hoge
”也是表达式。
对于下面的表达式,
a + b * 3 / (4 + c)
它可以表现成如图 3-10 这样的树结构,这个树结构的所有部分的树*都是表达式。
* 这里指某个特定节点以下的树。
图 3-10 表达式的树结构
此外,所有的表达式都持有类型。
3.2 节中介绍了可以通过链的结构表现类型。如果所有的表达式都持有类型,那么对于表现表达式的树结构的节点,都可以被挂接上表现类型的链(参照图 3-11)。
图 3-11 所有的表达式都持有类型
在对表达式使用运算符,或者将表达式作为参数传递给函数的时候,表达式中持有的类型具有特别重要的意义。
比如,对于下面这样的数组:
char str[256];
在输出这个字符数组的内容的时候,使用
printf(str);
初学者看到这段程序,难免会想:“printf()
能这么写吗?”
确实,就像这个世上最有名的程序中写的这样:
printf("hello, world\n");
第 1 个参数总是传递字符串常量。
可是,在 stdio.h 的原型声明中,printf()
的第 1 参数被定义为“指向 char
指针”。
字符串常量的类型为“char
的数组”,因为是在表达式中,所以它也可以当成“指向 char
的指针”。因此,字符串常量可以传递给 printf()
。同样地,str
是“char
的数组”,因为是在表达式中,所以也可以当成“指向 char
的指针”,能够传递给 printf()
也是很自然的事。
如果只是单纯地输出字符串,对于字符串中包含%
感到麻烦,与其使用
printf("%s", str);
不如使用 puts()
会更好。这个就扯远了。
此外,下面的写法也许会让某些人感到惊奇:
"01234567890ABCDEF"[index]
但如果写成这样:
str[index]
谁都不会觉得奇怪了吧。在表达式中,str
和字符串常量都属于“指向 char
的指针”,它们都可以作为[]
运算符的操作数*。
* 运算符(operator)的作用对象被称为操作数。比如,1 + 2
这个表达式,1
和 2
是运算符 +
的操作数。
补充 针对“表达式”使用 sizeof
sizeof
运算符有两种使用方法。一种是:
- sizeof(类型名)
另外一种是:
- sizeof 表达式
后者能够返回对象表达式的类型的大小。
在实际开发中,“
sizeof
表达式”这种使用方式的唯一用途,就是从编译器获取数组的长度。对于下面的声明,
- int hoge[10];
在
sizeof(int)
为 4 的处理环境中,
- sizeof(hoge)
返回 40。因此将这个结果除以
sizeof(int)
就可以得到数组元素的个数。如果像这个例子这样显式地指定了数组的大小,即使不使用
sizeof
,使用#define
给大小定义一个合适的名称也是可以满足需求的。不过在下面的情况下,使用sizeof
也许更加方便。
- char color_name[] = {
- "black",
- "blue",
- :
- :
- };
- #define COLOR_NUM (sizeof(color_name) / sizeof(char))
在这种情况下,由于使用了数组初始化表达式,这里可以省略定义数组元素的个数,因此就不需要使用
#define
定义一个固定的常量了。另外,在某些情况下需要在color_name
中追加更多的元素,如果使用sizeof
,只需修改程序的一个地方。参照 3.5.2 节。
无论怎样,
sizeof
运算符只是向编译器问询大小的信息,所以,它只能在编译器明确知道对象大小的情况下使用。
- extern int hoge[];
以上的情况是不能使用
sizeof
的。此外,
- void func(int hoge[])
- {
- printf("%d\n", sizeof(hoge));
- }
这样的程序,也只是输出指针的长度(参照 3.5.1 节)。
也许很多人并不知道,对于“
sizeof
表达式”,其实不需要括号。当然,加上括号也可以,此时的括号只是单纯地起到括起操作数的作用。有人尽管知道这一点,但为了阅读的方便,依然使用了括号。
3.3.2 “左值”是什么——变量的两张面孔
假设有下面这样一个声明:
int hoge;
因为此时 hoge
是 int
类型,所以,只要是可以写 int
类型的值的地方,hoge
就可以像常量一样使用。
比如,将 5 赋予 hoge
之后,下面的语句
piyo = hoge * 10;
理所当然地可以写成
piyo = 5 * 10;
但是,在
hoge = 10;
的情况下,即使此时 hoge
的值为 5,
5 = 10;
这样的置换也是非法的。
也就是说,作为变量,它有作为“自身的值”使用和作为“自身的内存区域”使用两种情况。
此外在 C 中,即使不是变量名,表达式也可以代表“某个变量的内存区域”。比如这种情况:
hoge_p = &hoge;
*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 将数组解读成指针
正如在前面翻来覆去提到的那样,在表达式中,数组可以解读成指针。
int hoge[10];
以上的声明中,hoge
等同于&hoge[0]
。
hoge
原本的类型为“int
的数组(元素个数 10
)”,但并不妨碍将其类型分类“数组”变换为“指针”。
图 3-12 表现了其变换的过程。
图 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-13 使用解引用而发生的类型的变化
▲地址运算符
单目运算符&
被称为地址运算符。
&
将一个左值作为操作数,返回指向该左值的指针。对左值的类型加上一个指针,就是&
运算符的返回类型(参照图 3-14)。
图 3-14 使用地址运算符而发生的类型的变化
地址运算符不能将非左值的表达式作为操作数。
▲下标运算符
后置运算符[]
被称为下标运算符。
[]
将指针和整数作为操作数。
p[i]
是
*(p + i)
的语法糖,除此以外没有任何其他意义。
对于声明为 int a[10]
的数组,使用 a[i]
的方式进行访问的时候,由于 a
在表达式中,因此它可以被解读成指针。所以,你可以通过下标运算符访问数组(将指针和整数作为操作数)。
归根结底,p[i]
这个表达式就是*(p + i)
,所以下标运算符返回的类型是,从 p
的类型去掉一个指针的类型。
▲->
运算符
在标准中,似乎并没有定义->
运算符的名称,现实中有时它被称为“箭头运算符”。
通过指针访问结构体的成员的时候,会使用->
运算符。
p->hoge;
是
(*p).hoge;
的语法糖。
利用*p
的*
,从指针 p
获得结构体的实体,然后引用成员 hoge
。
3.3.5 多维数组
在 3.2.5 节中,我们提到了 C 语言中不存在多维数组。
那些看起来像多维数组的其实是“数组的数组”。
这个“多维数组”(山寨货),通常使用 hoge[i][j]
的方式进行访问。让我们来看一看这个过程中究竟发生了什么。
int hoge[3][5];
对于上面这个“数组的数组”,使用 hoge[i][j]
这样的方式进行访问(参照图 3-15)。
图 3-15 访问多维数组
hoge
的类型为“int
的数组(元素个数 5)的数组(元素个数 3)”。尽管如此,在表达式中数组可以被解读成指针。因此,
hoge
的类型为“指向int
的数组(元素个数 5)的指针”。
hoge[i]
是*(hoge + i)
的语法糖。
给指针加上
i
,就意味着指针前移它指向的类型×i
的距离。hoge
指向的类型为“int
的数组(元素个数 5)”,因此,hoge + i
让指针前移了sizeof(int[5])
×i
的距离。通过
*(hoge + i)
中的*
,去掉一个指针,*(hoge + i)
的类型就是“指向int
的数组(元素个数 5)”。尽管如此,由于在表达式中,数组可以解读成指针,所以
*(hoge + i)
的最终类型为“指向int
的指针”。
(*(hoge + i))[j]
和*((*(hoge + i)) + j)
其实是相等的,因此,(*(hoge + i))[j]
就是“对指向int
的指针加上j
后得到的地址上的内容”,其类型为int
。
某些语言中,使用 array[i, j]
这样的写法来支持多维数组*。
* Pascal 就支持这样的写法,但是,Pascal 的“多维数组”只不过是“数组的数组”的语法糖。
在 C 中,因为没有多维数组,所以使用“数组的数组”来代替,这样倒也没有什么问题。可是,如果反过来,只有多维数组,而没有“数组的数组”,事情就麻烦了。
比如,将某人一年之中每天的工作时间使用下面这个“数组的数组”来表现*,
* 数组中,月和日是从 0 开始的,只有在输出的时候才可修正。2 月等 情况下,数组的元素就会显得冗余,但是不会产生问题。
int working_time[12][31];
在这里,如果开发一个根据一个月的工作时间计算工资的函数,可以像下面这样将某月的工作时间传递给这个函数,
calc_salary(working_time[month]);
calc_salary
的原型像下面这样:
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
和()
之间的关联强度。此外,我们经常看到类似于下面这样的编码:
- 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
。从语法上来看,这种说法才是比较合理的。