博客大纲
词法陷阱
引入
原文:
在阅读一个英文句子时,我们并不去考虑组成这个句子的单词中单个字母的含义,而是把单词作为一个整体来理解。确实,字母本身并没有什么意义,我们总是将字母组成单词,然后给单词赋予一定的意义。
对于用C语言或其他语言编写的程序,道理也是一样的。程序中的单个字符孤立来看并没有什么意义,只有结合上下文才有意义。因此,在 p->s =“->”;这个语句中,两处出现的-字符的意义大相径庭。更精确地说,上式中出现的两个字符分别是不同符号的组成部分:第一个-字符是符号->的组成部分,而第二个字符是一个字符串的组成部分。此外,符号->的含义与组成该符号的字符-或字符>的含义也完全不同。
作者将编程语言中的符号与英语中的字母相比较,试图说明:字符本身是没有意义的,只有根据其它的符号才可以判断某个字符的作用,因此同一个字符在不同位置出现,含义可能也不同。
此处作者拿出了一个案例:p->s ="->"
,对于老练的C程序员,应该不难分析处出,p应该是一个结构体,而s应该是一个char*类型的指针成员,此过程将一个字符串"->"的指针交给了s。所以前面的-与>构成了->(结构体成员访问操作符);而后方的-仅仅是作为一个字符存在于字符串中。
词法分析
作者在原文引入时提出了词法分析器的概念,那么在此我们给大家讲解一下词法分析:
我们写的代码在变成可执行程序前,会经过编译与链接两个过程。而编译过程又分为预编译,编译,汇编三步。在编译这个过程中,又会执行词法分析,语法分析,语义分析三个过程。
其中词法分析过程,会将每一个字符提取出来,跳过空格,制表符等等。
比如这样一串代码: if (x > big) big = x;
,这个语句在语法分析时,编译器就会将其拆解为下表格:
记号 | 类型 |
if | 关键字 |
( | 左圆括号 |
x | 标识符 |
> | 大于号 |
big | 标识符 |
) | 右圆括号 |
big | 标识符 |
= | 赋值 |
x | 标识符 |
随后会在语法分析过程中依据这个表格生成语法树,这些语法树是以表达式为节点的树。
最后在语义分析中,对语法树进行语义标识。在这个过程中,编译器就会检测你的错误语法信息,比如你的操作符两侧的数据类型不对,你的数据还没定义就使用等等。在此不展开详解了,有兴趣可以去了解编译原理这门课程。
原文提到:此处由于其跳过空格,制表符的特性,我们的语句也可以写成:
if ( x > big ) big = x ;
当然,我们是不建议这样写代码的,作者此处只是想借此引入本章主题:“探讨符号与组成符号的字符之间的关系。”
= 不同于 ==
相信大部分C学习者在初学时都经常遇到这个问题,此处我们先讲一下这两个符号区别的由来:
对于部分语言,比如Pascal与Ada,它们会使用=作为比较操作符,而使用:=作为赋值运算符。而C语言则使用=与==来区分。因为在程序中赋值出现的比比较更为频繁,于是数学中使用比较多的=就对应了程序中使用多的赋值,而使用了另外的符号==来区分出比较操作符。
原文:
某些C编译器在发现形如 e1 = e2 的表达式出现在循环语句的条件判断部分时,会给出警告消息以提醒程序员。当确实需要对变量进行赋值并检查该变量的新值是否为0时,为了避免来自该类编译器的警告,我们不应该简单关闭警告选项,而应该显式地进行比较。也就是说,下例
if (x = y)
foo();
应该写作:if ((x = y) != 0)
foo();
这种写法也使得代码的意图一目了然。至于为什么要用括号把x=y 括起来2.2节将讨论这个问题。
接下来我带大家解析一下上述两个代码的区别于目的:
if (x = y)
这个语句的本意是:将y赋值给x,并判度赋值后的x是不是0,如果x不为0,就执行foo();
语句。当然这样的写法是可以达成目的的,但是编译器一般会警报,在团队合作时,这样的代码也会给队友带来困扰。
于是作者提出:在这里要将这样的语句显性表达,也就是说要让你这条语句的目的明确。
于是它被改为了if ((x = y) != 0)
。
通过这个语句就可以非常明确的看出来,程序员的目的是将y赋给x,并判断赋值后是否为0。
至于这里的x=y为什么要括起来,这是操作符的优先级导致的问题,在操作符中,赋值操作符的优先级是最低的,会低于!=。导致还没有将y赋值给x,y就先和0判断是否不等了。所以要用小括号来改变这个语句的执行顺序,先进行赋值再比较。
&和| 不同于 &&和||
原文:
很多其他语言都使用=作为比较运算符,因此很容易误将赋值运算符=写成比较运算符==。同样,将按位运算符&与逻辑运算符&&调换,或者将按位运算符与逻辑运算符调换,也是很容易犯的错误。特别是 C语言中按位与运算符&和按位或运算符|,与某些其他语言中的按位与运算符和按位或运算符在表现形式上完全不同(如 Pascal 语言中分别是and 和or),这更容易让程序员因为受到其他语言的影响而犯错。关于这些运算符精确含义的讨论见3.8 节
其实这几个操作符并不难区分,在高中数学中我们就学过与或非,此处的&&和||其实就是与和或。&&当两边表达式有一个是假就输出假;||只要两边表达式有一个是真就输出真。
对于&与|,它们属于位操作符,直接针对数据的每一个bit位,而一个bit内部只能存0或。&将左右两个数据的bit位一一比较,有0则为0,都1才为1。|将左右两个数据的bit位一一比较,有1则为1,都0才为0。
对于这个知识,可以看看我的另一篇博客:C语言:位操作符详解
词法分析中的“贪心法”
原文:
C 语言的某些符号,例如/ 和=,只有一个字符,称为单字符符号。而C 语言中的其他符号,例如== ,以及标识符,包括了多个字符,称为多字符符号。
当 C 编译器读入一个字符‘/’后又跟了一个字符,那么编译器就必须做出判断:是将其作为两个分别的符号对待,还是合起来作为一个符号对待。
C语当对这个问题的解决方案可以归纳为一个很简单的规则:每一个符号应该包含尽可能多的字符。也就是说,编译器将程序分解成符号的方法是,从左到右一个字符一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判新已经读入的两个字符组成的字符串是否可能是一个符号的组成部分:如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。这个处理策略有时被称为“贪心法”,或者更口语化一点称为“大嘴法”。
Kernighan 与 Ritchie 对这个方法的表述如下,如果(编译器的)输入流截至某个字符之前都已经被分解为一个个符号,那么下一个符号将包括从该字符之后可能组成一个符号的最长字符串”
需要注意的是,除了字符串与字符常量,符号的中间不能嵌有空白(空格符制表符和换行符)。
我简单的为大家整理一下贪心法的规则:
1.从左到右一个一个字符地读入
2.若当前字符与前面保留的字符可以组成新符号,保留此字符,再读入下一个字符
3.若当前字符无法与前面的所有保留的字符组成新字符,则前面保留的所有字符组成一个符号,从当前字符开始重新保留。
4.若遇到空白(空格符制表符和换行符),停止读入,将前面保留的所有字符组成一个符号。
5.若遇到标识符,停止读入,将前面保留的所有字符组成一个符号。
我们再利用书中的一个例子说明:a---b
那么这样一个代码是如何解析的?
进度 | 解析 |
读到a | 这是一个标识符。 |
读到第一个- | 前方是一个标识符,从此字符开始保留。 |
读到第二个- | 前方保留了一个-,与当前的-可以构成–自减操作符,保留这两个字符。 |
读到第三个- | 前方保留了两个-,与当前的-无法构成新的操作符,将前面的两个字符解析为自减操作符,保留当前字符。 |
读到b | b是一个标识符,前方保留了一个-字符,解析为减号。 |
所以最后得到的的表达式就是a -- - b
。
我们可以发现,这个过程中,编译器总是不停的想让更多的字符构成一个操作符,所以作者称之为“贪心法”。
这样的贪心法会在我们平常应用中产生一些问题,作者在此给出了一个案例:
y = x/*p /* p指向除数*/
我先为大家解析这串代码的本意:这里用y来接收了x/*p 这个除法的结果,p是一个指针,解引用后就得到了除数。于是程序员在这串代码后备注了一段 /* p指向除数*/
。
但是这样的代码最后会被解析成什么样呢?
我们不妨分析一下
进度 | 解析 |
读到y | 这是一个标识符。 |
读到= | 前方是一个标识符,从此字符开始保留。 |
读到x | 这是一个标识符,前方保留的=解析为赋值操作符 |
读到/ | 前方是一个标识符,从此字符开始保留。 |
读到* | 前方保留了一个字符/,可以与当前字符构成/*,表示一段注释的开始,保留这两个操作符 |
到这里就发生了错误,/*居然被解析成了一个注释的开始。最后表达式指向的结果就是y = x
,而后面一大段都被解析为了注释。为了避免这个问题,一般在这种操作符后方,我们要加一个小空格,来终止读取。所以我们平常在写代码的时候,如果操作符与后方的操作数之间没有空格,有的编译器会报一个的警告,比如pycharm;而有的编译器会帮助程序员矫正,在敲入分号的时候,自动为你添入空白,比如vs2022。如下:
一个空格居然能带来这么大的变化。所以在此我不得不提醒各位:记得在操作符后方加一个小空格,来终止贪心法的读取。
整型常量
原文:
如果一个整型常量的第一个字符是数字 0,那么该常量将被视作八进制数。
因此,10与010的含义截然不同。此外,许多 C编译器会把8和9也作为8进制数处理。这种多少有点奇怪的处理方式来自八进制数的定义。例如,0195的含义是0 * 83+ 1 * 82+ 9 * 81 + 5 * 80。也就是 141(十进制)者 0215 (八进制)。我们当然不建议这种用法,ANSIC 标准也禁止这种用法。
作者在此提出了一个错误,即在数字前加上0,会导致数字转为8进制。
这是C语言定义中的,当一个数字以0开头,说明它是一个8进制;当数字以0x开头,说明它是一个16进制。
但是它有一个非常不合理的的地方就是,八进制中本不应该出现数字8与数字9,而一个以0开头的数字0195,仍然可以解析为八进制,也就是以八进制的定义转化,每位上的数字乘以它这一位的位权,原文中的0 * 83+ 1 * 82+ 9 * 81 + 5 * 80这个过程,就是在将一个不应该出现在8进制的数字转为8进制。
随后作者给出了一个例子:
struct { int part_number; char* description; }parttab[] = { 046, "left-handed widget" , 047, "right-handed widget" , 125, "frammis" };
这是一个匿名结构体,在结构体末尾,定义了一个此结构体类型的数组,并为三个结构体的成员赋值。作者指出,这个程序员为了美观,将前两个结构体的第一个成员前加上了0,以对齐第三个结构体的成员的125。这就导致了前两个结构体的首个成员没有得到46,47,而是它们转化为8进制后形式。
字符与字符串
此处,作者重点在解析单引号与双引号的本质,表面上单引号引起的是一个字符,双引号引起的是一个字符串。接下来我们从本上分别解析两者:
双引号本质
原文:
用双引号引起的字符串,代表的却是一个指向无名数组起始学符的指数组被双引号之间的字符以及一个额外的二进制值为零的字符’O’初始化。
下面的这个语句:
printf (“Hello world\n”);
与
char str[] = (‘H’,‘e’,‘l’,‘l’,‘o’,‘w’,‘o’,‘r’,‘l’,‘d’,‘\n’, ‘0’);
printf (str);
是等效的。
C语言中的字符串本质上是一个字符数组,以’\0’结尾。在C语言中,字符串常量是一个以’\0’结尾的字符数组,可以通过字符指针来访问。
在我们写出char *str = “hello world”;
时,内存的静态区会存一个字符串的数组,并且数组的最后一个元素是’\0‘。然后“hello world/0”这个双引号引起的语句的本质是这个数组的首元素地址。
在上方作者的代码示例中,printf ("Hello world\n");
实际上传入printf的是这个字符串的首元素指针。
而printf (str);
传入的也是一个指针(数组名的本质是一个指针,可见博客:C语言:数组详解)。
故作者说它们是等效的。
为什么给prinf传入的参数是一个指针?接下来我再给大家拓展一下printf这个函数:
可以看到,prinft函数的参数是一个const char*的指针类型,printf在接收到指针时,会从这个指针开始向后读取,直到遇到\0为止,所有被读取的符号都会被输出。当然这只是printf输出的一种情况,在遇到占位符,或者直接输入一个变量等其它情况,我们在此不讨论。
最后再次重复本区块的重点:双引号引起来的字符串,本质上是一个指针。
单引号本质
原文;
用单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集中的序列值。因此,对于采用 ASCII 字符集的编译器而言,'a”的含义与0141 (八进制)或者 97(十进制) 严格一致。
此处作者已经提出,单引号引起的字符本质上是一个数字。凡是我们键盘上能敲出来的都算字符,而字符是有编码的,即ASCII码值。
我们尝试运行一下代码:printf('a')
可以发现,编译器报错了。为什么printf不能输出一个单引号引起来的a呢?
我们刚刚说过printf本质上接收了一个指针。我们这里用单引号引起的字符a,本质上是一个数字,a的ASCII码值就是61,于是printf接受到了61这个数字,并把它当成了地址去访问,最后访问了0000000000000061这个地址,毫无疑问这是一个野指针,访问后就报错了。
上面这个例子不仅证明了printf本质上接收一个指针,还说明了单引号引起的字符本质上是一个数字。
那么当我们用单引号引起一个字符串会发生什么?
单引号引起字符串,字符串本质是一个指针,但是单引号引起的内容最后会被转化为一个数字。这该如何是好?
作者在文中提及了不同编译器的处理方式:
大多数C编译器理解为,”一个整数值,由每个字符的整数值按照特定的编译器实现中定义的方式组合得到“
在BorlandC++ 5.5和Cv3.6 中采取的做法是,忽略多余的字符,最后的整数值即第一个字符的整数
在visual C++ 6.0和 GCC 2.95 中取的做法是,依次用后一个字符覆盖前一个字符,最后得到的整数值即最后一个字符的整数值
也就是说,我们最后得到的整数,在有的编译器中,是取第一个字符的ASCII码值,有的是取最后一个字符的ASCCII码值。而在大多编译器中是多个字符的数字以特定方式组合。
在此我拿vs2022给大家演示:
在vs2022中,则是以每个字符的整数值做两位数,直接按顺序组合得到。当字符数量超过4时,则会报错。
练习
此处我并不将所有习题都讲解,我只挑出部分题目来分析。
1-3:
为什么
n-->0
的含义是n-- > 0
而不是n- -> 0
?
在此我用表格来分析:
进度 | 解析 |
读到n | 这是一个标识符。 |
读到- | 前方是一个标识符,从此字符开始保留。 |
读到- | 前方保留了一个字符,可以与当前字符构成–自减操作符,保留这两个操作符 |
读到> | 前方保留了两个字符,无法与当前字符构成新符号,将前两个操作符解析为自减操作符,保留当前操作符 |
读到0 | 前方保留了一个字符,这是一个常量,将前一个字符解析为大于号 |
1-4:
a+++++b
的含义是什么?
对于许多老练的C程序员,第一反应就是a++ + ++b
。毫无疑问,这是这个表达式唯一有意义的理解方式,但是编译器并不这么认为,我们依然用表格分析:
进度 | 解析 |
读到a | 这是一个标识符。 |
读到+ | 前方是一个标识符,从此字符开始保留。 |
读到+ | 前方保留了一个字符,可以与当前字符构成++自增操作符,保留这两个操作符 |
读到+ | 前方保留了两个字符,无法与当前字符构成新符号,将前两个操作符解析为自增操作符,保留当前操作符 |
读到+ | 前方保留了一个字符,可以与当前字符构成++自增操作符,保留这两个操作符 |
读到+ | 前方保留了两个字符,无法与当前字符构成新符号,将前两个操作符解析为自增操作符,保留当前操作符 |
读到b | 前方保留了一个字符,这是一个标识符,将前一个字符解析为加号 |
可以发现,最后的解析结果居然是 a++ ++ +b
写规范点就是: ((a++) ++) +b
相当于a后置++了两次。