要理解一个 C 程序,仅仅理解组成该程序的符号是不够的。程序员还必须理解这些符号是如何组合成声明、表达式、语句和程序的。虽然这些组合方式的定义都很完备,几乎无懈可击,但有时这些定义与人们的直觉相悖,或者容易引起混淆。本章将讨论一些用法和意义与我们想当然的认识不一致的语法结构。
原文:
有一次,一位程序员与我交谈一个问题。他当时正在编写一个独立运行于某种微处理器上的 C 程序。当计算机启动时,硬件将调用首地址为 0位置的子例程,为了模拟开机启动时的情形,我们必须设计出一个 C 语句,以显式调用该子例程。经过一段时间的思考,我们最后得到的语句如下:
(*(void(*)())0)();
像这样的表达式恐怕会令每个 C 程序员都“不寒而栗”。不过,他们大可不必对此望而生畏,因为构造这类表达式其实只有一条简单的规则: 按照使用的方式来声明。
作者在此抛出了一个问题,即 ((void(*)())0)0);
这个语句是如何运行的?
在此,我先向大家讲述几个预备知识,我们再回过头来看这个语句。
它们分别是:表达式,声明,函数名本质。
有人说:“一个语句就是一个表达式。”;有人说:“一个括号内的判断条件就是一个表达式”;等等…
其实大家并不会去纠结到底什么是表达式,但是心里多多少少会对其有一定的认知,接下来我就为大家仔细讲解一些表达式的知识。
表达式的定义为:
由一系列运算符与操作数组成的序列
这个概括未免有点笼统,也就是因为其太笼统了,所以很多意想不到的式子也能被称为表达式,我先抛出我对表达式的比较具体的定义:
1.由常量,变量,函数组成,用C语言语法规则,用运算符链接起来的式子称为表达式
2.一个表示式可以没有运算符,但不能没有操作数。
第一句话好理解,第二句话我用例子来说明:
我们在定义一个变量时,可以不赋值,只声明其类型int a;
,比如这个语句中,a本身就是一个表达式,在a这个表达式中,没有出现任何操作符,仅仅由一个变量组成;再比如while(i)
,在这个语句中,i这个变量是while循环的判断条件,i本身也是一个表达式。
此外,函数调用语句也是一个表达式,printf("Hello world")
就是一个表达式。
对于表达式的目的,相信大家也是众说纷纭,有太多表达式可以做的事情了,光是C语言的库函数就数不过来了,一个函数一个功能,那表达式的目的不就有成千上万种?
其实不是的,在C语言中,表达式只有三种作用,我先将其列举出来,再做详解:
1.计算数值
2.指明作用对象
3.产生副作用
计算数值:
这个很好理解,比如while(a - b )
这个语句,a - b就是一个可以产生数值的表达式,大部分表达式可以返回一个数值,比如调用函数后的返回值,或是一个普通的计算表达式。
但是也有表达式不产生返回值,比如void类型的函数。
指明对象:
指明对象的过程,就是告诉编译器一个数据应该存在哪一个内存,或者从哪一个内存取用。如c = a + b
,我们刚刚说过,一个变量本身也是表达式,所以此处的a,b,c本身都是一个表达式。a与b表达式的作用就是指明a与b对于的内存,让编译器去取用,而c表达式的作用就是指明c的内存,来存储表达式a + b
的返回值。
产生副作用:
什么是副作用呢?
其实除了以上两条以外,都是副作用。
比如:c = a + b
这个语句,它除了计算出一个值,还有什么作用?答案是对变量c赋值。其实我们在使用这样的一个语句的时候,多半就是为了用c来接收a+b的值,但是在定义中,这确实是一个副作用。
再比如:printf("Hello world")
这个语句,它的主要目的是什么?
大部分人会回答输出一个字符串,其实并不是,它的主要作用是得到返回值,printf函数的返回值是字符串中字符的个数,所以此处printf的目的是返回11这个值。而输出hello world只是它的副作用。
我们刚刚简单给大家讲了什么是表达式,我们了解到,表达式的主要目的就是为了求值,那么一个表达式的返回值应该是什么类型呢?这就涉及到了声明:
原文:
任何C变量都由两部分组成:类型以及一组类似表达式的声明符(declarator),声明符从表面上看与表达式有些类似,对它求值应返回一个声明中给定类型的结果。
也就是说,声明的作用是规范一个表达式的所求的值是什么类型的。此处的声明类型极为广泛,包括函数声明,变量声明。
变量的声明:
对单个变量的声明:比如int a
,它由两部分组成,int是类型,a是声明符(理解为表达式即可)。此语句的意思就是:对于a这个表达式,求值后得到的返回值必须为int类型。
对指针的声明:比如char *p
,这个语句有两个理解方式,大部分的现代的理解方式为,变量p,其类型为char*。
而从声明的角度来说,它的理解应该是:一个表达式*p,其求值后返回值为char类型。这也就说明p是一个指向char类型变量的指针。
当然,这两种理解方式没有谁好谁坏,只是角度不同,前者从变量p本身理解,而后者从声明来理解。
函数的声明:
对有了前面对声明的解释,对函数的声明也就不难理解了:
float func()
这样一个声明语句,表达的意思就是:对于函数表达式func()
,其求值后返回值类型为float类型。
以上的两中声明类型也是可以混合着来的,作者给出了两个语句对比分析:
float *g()
与float (*h)()
接下来我带大家辨析一下:
首先,造成它们的区别的最大前提就是:操作符*的优先级是小于()的。
对于*g()
这个表达式,g先和()结合,那么g的本质就是函数,g()
再和*结合成*(g())
。从声明的角度解释:*(g())
的返回值必须是float类型。那么float *g()
的意思就是:g()函数是一个返回值为float*的函数。
对于(*h)()
这个表达式,h由于被圆括号改变了结合顺序,h先和*结合,那么h的本质就是指针,再与()结合,说明这是一个函数指针。float (*h)()
是一个指向返回值为float的函数的指针。
我们尝试监视一个函数名:
可以看到,函数名的值是一个地址,而&add
也是一个地址,而且两者地址相同。所以函数名本质上是一个指针。
我们平常调用函数的时候,函数名()
这样的表达式,函数名是该函数的指针,那我们是不是也可以取一个函数指针出来,然后在指针后面加上小括号来调用函数呢?我们试一试:
此处我们发现,p得到了add函数的地址后,居然就可以直接用p()
来代替add()
了,这就更进一步说明了:add函数名本身是对应函数的指针。
但是我们对比一下p()
与(*p)()
,发现它们居然都能执行这个函数调用,作者在此特殊声明:*ANSIC标准允许将(*p)()
简写为p()
。
所以说,p()
其实是一个简写,而我们平常在调用函数的时候,其实也是直接用函数名来调用,也就是以简写的形式调用。
代码1
有了这些预备知识,我们接下来就可以分析表达式(*(void(*)())0)();
了:
作者说过,这个代码的目的是让一个函数在0地址处被调用,也就是说我们需要一个0地址处的指针。
我们在表达式中只看到了一个数字0,没错,这就是0地址,但0单独出现,只表示一个整型常量,所以在此,我们要将这个int类型的0,强制转化为一个函数类型:这个需要调用的函数是什么样的作者在文中并没有明说,先假设这个函数的函数名为func。
我们可以通过(void(*)())
这个强制转化,推断出来此函数为void func()
。既然我们要将0转化为此函数的指针,那就要得到这个函数的指针。我们将此函数写为完全形式void (*func)()
,想要得到此函数的指针类型,只需要将这个函数的函数名去掉即可。所以此函数的指针类型就是void(*)()
,将函数类型带个小括号,放在0前面,0就被强制转化为了对应的函数指针。得到0处的函数指针后:
如果一个函数指针为p,调用这个函数就是(*p)(参数)
或者p(参数)
。我们的函数是void func()
,其没有参数,此处作者采用的是没有简化的方法,所以调用形式就是(*p)()
,然后将p指针改为我们之前得到的强制转化后的0指针就是(*(void(*)())0)();
。
所以此语句的作用就是:在0地址处调用void func()
函数。
代码2
在解决这个问题之后,作者抛出了第二串代码void (*signal(int, void(*)(int)))(int)
,接下来我们对其分析:
请问这个语句是一个什么过程?
我们可以很明显看出,内部是有函数参数的,但是上述的三个int,后面都没有数据,这说明这不是一个函数调用过程,而是一个函数的声明过程。
signal其实是一个C语言的库函数:
而上述代码就是对signal函数的声明,不过在声明函数时,是可以去掉参数名的。所以作者给出的声明是上图C的声明的去参数版本。
我先简单介绍signal函数,使用signal函数,需要给signal传入两个参数,一个int类型,一个函数指针类型。当signal被调用后,就会返回这个传入函数的指针。
所以signal(int, void(*)(int))
这一部分,就是signal函数名以及它的两个参数。既然是声明过程,那就要声明一个返回值,signal函数的返回值就是传入的那个函数 void (*func)(int)
,那么这个函数的指针就是 void (*)(int)
。这也就是signal的返回类型。
综上,signal的声明就应该是:
void (*)(int) signal(int, void(*)(int))
对吗?
其实不对,在声明的语法规定中,要求如果函数的返回类型为函数指针,返回类型后面的部分要放在*后面。
我们的返回类型为void (*)(int)
,后面的一大段signal(int, void(*)(int))
先用x代替。在经过声明的语法规范后,就应该这样:void (*x)(int)
,随后将x替换为signal(int, void(*)(int))
,就可以得到void (*signal(int, void(*)(int)))(int)
。
作者在第二个小节提出了操作符的优先级来带的问题,并举出了几个案例,来说明对操作符优先级不熟悉会带来的问题,随后又总结了几个操作符的大概优先级,在此我将此小节内容整理为了一个表格,我将先介绍此表格,后分析作者给出的案例。
优先级 | 运算符 | 描述 | 类型 | 操作数 | 结合性 |
---|---|---|---|---|---|
1 | ++ - - | 后置自增与自减 | --- | --- | 从左到右 |
( ) | 函数调用 | 与访问有关的操作符 | |||
[ ] | 数组下标 | ||||
. | 结构体与联合体成员访问 | ||||
-> | 结构体与联合体成员通过指针访问 | ||||
(type){list} | 复合字面量 | --- | |||
2 | ++ - - | 前置自增与自减 | --- | 单目操作符 | 从右到左 |
+ - | 一元加与减 | ||||
! ~ | 逻辑非与按位非 | ||||
(type) | 强制类型转化 | ||||
* | 间接(解引用) | ||||
& | 取址 | ||||
sizeof | 取大小 | ||||
3 | * / % | 乘法、除法及余数 | 算数操作符 | 双目操作符 | 从左到右 |
4 | + - | 加法及减法 | |||
5 | << >> | 按位左移及右移 | 移位操作符 | ||
6 | < <= > >= | 分别为 < , ≤ ,> ,≥ 的关系运算符 | 关系操作符 | ||
7 | == != | 分别为 = 与 ≠ 关系 | |||
8 | & | 按位与 | 位操作符 | ||
9 | ^ | 按位异或(排除或) | 单目 | ||
10 | | | 按位或(包含或) | 双目操作符 | ||
11 | && | 逻辑与 | 逻辑操作符 | ||
12 | || | 逻辑或 | |||
13 | ? : | 三元条件 | --- | 三目操作符 | 从右到左 |
14 | = | 简单赋值 | 赋值操作符 | --- | |
+= -= | 以和及差赋值 | ||||
*= /= %= | 以积、商及余数赋值 | ||||
<<= >>= | 以按位左移及右移赋值 | ||||
&= ^= |= | 以按位与、异或及或赋值 | ||||
15 | , | 逗号 | --- | --- | 从左到右 |
以上表格可以总结为(这也差不多是作者的总结):
1.与访问有关的操作符优先级非常高,甚至高于单目操作符
2.单目操作符 > 双目操作符 > 三目操作符,优先级呈递减趋势(除去按位异或)
3.在双目操作符中:算数 > 移位 > 关系 > 位 > 逻辑
4.所有与赋值有关的操作符,优先级都非常低,甚至低于三目操作符
这些条目都非常好记,也很好理解,基本记忆这几条,就能解决C语言中99%的操作符优先级带来的问题。
如果你认为记忆第三条略有些麻烦,在平常编程时积累也未必不行,但是抛开第三条,我希望大家脑海里可以有一个大致的轴:
访问 -> 单目-> 双目-> 三目-> 赋值
接下来我们带着这四条规则去看看作者提出的案例:
if (flags & FLAG != 0)
此处程序员目的是:判断flags与FLAG按位与后,是否为0。
而根据操作符的优先级,关系操作符的优先级是大于位操作符的,所以会先判断!=,后面才按位与,故此代码有误。
while(c = getc(in) != EOF)
此处程序员的目的是:让c接收getc函数的返回值,并判断输入是否成功。
但是根据第四条,所有类型的赋值操作符优先级都是最低档的,所以这个表达式会先判断!=,后赋值。
*func()
这已经是一个老生常谈的问题了,也就是解引用与函数调用的优先级关系,分析如下:
()是一个访问类型的操作符,优先级极高。而*是一个单目操作符,优先级低于访问类型的操作符,所以func会先和()结合成函数,在解引用函数返回值。
故对上述代码的错误理解为:func作为一个函数名,本质是指针,先解引用fuc,再调用,函数
正确理解:func函数的返回值是一个指针,func()先调用后,将返回值解引用。
原文:
*C语言的switch 语句的控制流程能够依次通过并执行各个 case 部分,这一点是C语言的与众不同之处。考虑下面的例子,两段程序代码分别用 C语言和 Pascal语言编写:
C语言:
switch(color){
case 1:
printf("red");
break;
case 2:
printf("yellow");
break;
case 3:
printf("blue");
break;
Pascal语言:
case color of
1: write('red');
2: write('yellow');
3: write('blue');
end
上述两串代码的最大区别就是break语句,其实两个语言在这方面的大致逻辑差不多,都是根据输入的整型值,来进入某一个分支,并输出其结果。
但是对于C语言switch语句本身,我认为这样理解更贴切:根据输入的整型值,进入某个case,并执行此case开始往下所有case的所有语句。
在switch语句没有break参与时,它会从当前case往下执行,直到整个switch结束,而非当前case结束。而break参与,就会在执行完某个case后跳出switch。
对于上述Pascal的示例中。Pascal并没有在每个分支后面都加上end语句,这是因为pascal语言的每个分支都会偷偷包含一个end语句,来终止上一分支进入下一分支。这也就是为什么Pascal的分支语句最后要加一个end,因为最后一个分支下面没有其它分支了,也就没有一个隐藏的end来跳出整个语句。所以要用一个end来终止。
但是C语言这样的特性也可以带来一些妙用,比如作者给出的一个加减法计算器:
switch(choose)//选择1执行减法,选择2执行加法
case 1:
num1 = -num1;
//此处没有break
case 2;
sum = num1 + num2;
break;
上述代码中,在case1中利用了一个取反操作,当用户输入2,执行的就是num1 + num2的加法操作。而当用户输入1,就会先进行一次取反,由于缺少一个break,会继续执行case2,继续加法。但是由于刚刚case1取反过了,结果其实是num2 - num1,就完成了减法操作。
所以C语言的这个特性,也是有利有弊的。
#include
int main()
{
int a = 0;
int b = 2;
if(a == 1)
if(b == 2)
printf("hehe\n");
else
printf("haha\n");
return 0;
}
作者在最后,提出了悬挂else的问题,这个问题常常是因为代码书写不规范导致的。
我们先分析上述代码,请问输出结果是什么?
答案是啥都不输出。
这就是悬空else 的问题,可以记住这样一条规则,如果有多个if 和else ,else 总是跟最接近的if 匹配。
上面的代码排版,让else 和第一个if 语句对齐,让我们以为else 是和第一个if匹配的,当if语句不成立的时候,自然想到的就是执行 else 子句,打印haha 。
但实际上else 是和第二个if进行匹配的,因为else距离第二个if比较近,这样后边的if…else 语句是嵌套在第一个if 语句中的,第一个if 语句就不成立,第二个if 就没机会执行了,最终啥都不打印。