前面的文章中我们简单介绍了高级语言的五大语法元素,以及这些语法元素是如何构成表达式,构成语句最终构成可执行的代码的。我们也简单介绍了一些编程范式,可以写出'人能读懂的代码'了。
那么机器是怎么'读'我们的代码的呢?我们称机器'读'我们的代码的过程为代码的编译。机器'读懂'我们用高级语言编写的代码可以大致分为两个阶段:
分析我们的代码,明白代码的意图。
把高级语言代码映射成目标语言。
帮助机器读我们代码的也是一个程序,我们称之为编译器,和上面两个过程对应,编译器在解析我们的代码的时候也可以大致分为两个过程,分别为前端和后端,大致过程如下图所示:
对于某些解释运行的语言,可能并不会生成目标代码,而是在语义分析之后直接在解释器中组织程序的上下文运行程序,不过为了兼顾性能,采用这种解析方式的解释器也不是特别多。
从上面的图中我们可以看出,编译器的前端就是编译器对程序代码的分析和理解过程,它通常只和语言的语法有关,跟目标机器无关;而与之对应的后端则是生成目标代码的过程,这是跟目标机器有关的。
我们这篇文章主要要介绍的是机器是如何'读懂'我们的代码的,所以我们重点介绍的就是编译器的前端。
编译器前端的这三个过程其实和我们人类阅读文章的过程是非常相似的,凑巧的是,我最近正在恶补英文(出来混,总是要还的),而且还花重金购买了有道精品课的《逻辑英语》课程,那么我们就用阅读英文短文的过程来类比一下高级语言代码的编译前端,英文物料如下:
Lily is my first girlfriend,
she got married last year,
but the bridegroom is not me.
请相信我,这是个悲伤的故事。
词法分析(Lexical Analysis)
我们阅读英文文章,第一件事情就是认识单词。编译器识别我们的代码也是一样的,它首先要做的也是认识一个一个的‘单词’,只不过在编程语言的领域中,识别出来的不是单词,而称作词法记号,英文中就叫token。
举个例子,看看下面这段代码,如果编译器要读懂它,首先要怎么做呢?
#include
int main(int argc, char* argv[]){
int age = 45;
if (age >= 17+8+20) {
printf("Hello old man!\\n");
}
else{
printf("Hello young man!\\n");
}
return 0;
}
编译器会识别出 if、else、int 这样的关键字,main、printf、age 这样的标识符,+、-、= 这样的操作符号,还有花括号、圆括号、分号这样的符号,以及数字字面量、字符串字面量等。这些都是 Token。
这个过程其实和我们阅读英文文章的过程是非常类似的,单词是由一个个字母组成的,单词之间用空格或者是标点符号来分隔,我们利用空格或者标点符号把一串连在一起的字符识别成一个单词,就像上面英文物料中的第一行:
Lily is my first girlfriend,
我们会自然而然的根据空格把上面的文字分成Lily
、is
、my
、first
、girlfriend
这五个部分,这个过程就叫做'分词'。如果你要研发一款全文检索引擎,就需要有分词的功能。
正则文法和有限自动机
可以看出的是,在词法分析阶段,编译器处理源代码的粒度是基于字符的,经过词法分析之后,源代码的一个个字符就进化成为了一个个token。我们人类在阅读文章的时候,可以自然而然地根据空格和标点符号来进行'分词',但是编译器却是无法凭感觉做事的,它毕竟只是一个程序,只能按照规则办事。我们基于代码age >= 45
举几个例子:
识别 age 这样的标识符。它以字母开头,后面可以是字母或数字,直到遇到第一个既不是字母又不是数字的字符时结束。识别 >= 这样的操作符。
当扫描到一个 > 字符的时候,就要注意,它可能是一个 GT(Greater Than,大于)操作符。但由于 GE(Greater Equal,大于等于)也是以 > 开头的,所以再往下再看一位,如果是 =,那么这个 Token 就是 GE,否则就是 GT。
识别 45 这样的数字字面量。当扫描到一个数字字符的时候,就开始把它看做数字,直到遇到非数字的字符。
这些规则我们可以用词法分析器的生成工具来生成,比如 Lex(或其 GNU 版本,Flex)。这些生成工具是基于一些规则来工作的,这些规则用“正则文法”表达,符合正则文法的表达式称为“正则表达式”。生成工具可以读入正则表达式,生成一种叫“有限自动机”的算法,来完成具体的词法分析工作。
比如,如下正则表达式就可以表示一个标识符的模式规则:
let regx = /^[$_a-z][^-^%#@!()=:;'/.,<>"`~\|?\s]*$/
上面的代码是用JavaScript编写的,该正则表达式的含义是以字母、下划线或者是$开头,遇到任何的操作符、空白字符或者是#、@等一些特殊的字符结束匹配。我们可以直接在浏览器中进行一些测试:
上面的表达式是不完善的,因为其不允许以汉字等Unicode字符开头,但是却允许包含这些Unicode字符,就像最后两个测试中所示的那样。
上面的正则表达式中涉及字符组的使用,其中有个细节——在字符组中,-如果出现在字符组
[]
的开头或者是排除型字符组[^]
的开头的时候,只是一个普通的字符,而不是代表范围的元字符。比如我们用上面的正在表达式进行如下的测试:
可以看到排除型字符组开头的-发挥了一个普通字符的作用。
正则表达式的引擎会接收正则表达式并且根据正则表达式生成有限自动机,正则表达式的有穷自动机有两种实现方式,分别为:
以文本为主导的确定性有穷自动机:DFA(Deterministic finite automaton)
以正则表达式为主导的非确定性有穷自动机:NFA(Non-deterministic finite automaton)
我后面也会有一个专辑来专门介绍正则表达式。
当然,这些规则可以通过手写程序来实现。事实上,很多编译器的词法分析器都是手写实现的,例如 GNU 的 C 语言编译器。实际上,如果我们手写实现这些规则的话,其思路也是实现一个有限自动机。
有限自动机是有限个状态的自动机器,给这个机器不同的输入或者对这个机器进行不同的操作,会让这个机器在不同的状态之间切换,词法分析器也是一样,它分析整个程序的字符串,当遇到不同的字符时,会驱使它迁移到不同的状态,一旦自动机的状态发生变化,就说明一个token识别完成了,要进入下一个token的识别。
例如,词法分析程序在扫描 age >= 45
的时候,当其遇到字符g
,其处于“标识符”状态,等它遇到一个 > 符号,就切换到“比较操作符”的状态,这时候状态发生了变化,词法分析器就知道上一个状态下的token识别已经完成了,它就会把age
这个标识符token记录下来。词法分析过程,就是这样一个个状态迁移的过程。如下图:
当然,词法分析器在识别出token的时候,还会生成很多元数据信息,比如token的类型,以供编译的下一个阶段使用。
语法分析(Syntactic Analysis/Parsing)
我们阅读文章,在认识了单词之后,下一个步骤就是连词成句了,我们还要分析句子的成分,判断这个句子的语法是不是正确。类似的,编译器在词法分析之后的下一个阶段的工作是语法分析。词法分析是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构。
在语法分析阶段,编译器处理源代码的粒度已经从字符上升为了token。
抽象语法树(AST)
我们在分析一个英文句子的时候,会划分句子的成分,如Lily is my first girlfriend
这句话,Lily是主语,is是系语,first girlfriend是表语,我们会把句子中的每一个单词都进行归类。这样拆下来,我们就能得到一棵,这棵树的每一个子树都有一定的结构,而这个结构要符合语法:
编译器在分析我们的源代码的时候也要分析每一个token的作用,它会根据一定的规则把词法分析所产生的token流构造成一个树形结构,这棵树叫做抽象语法树(Abstract Syntax Tree,AST)。树的每个节点(子树)是一个语法单元,这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。
层层嵌套的树状结构,是我们对计算机程序的直观理解。计算机语言总是一个结构套着另一个结构,大的程序套着子程序,子程序又可以包含子程序。
https://resources.jointjs.com/demos/javascript-ast 这个网址可以生成JavaScript语言的AST,你可以在浏览器中访问这个网址直观的感受一下,下图是表达式2+3*5+6
所生成的JavaScript的AST:
形成 AST 以后有什么好处呢?就是计算机很容易去处理。比如,针对表达式形成的这棵树,从根节点遍历整棵树就可以获得表达式的值。其实,AST的每一个节点中也都会包含很多的元数据信息,供编译器后续阶段使用。
AST的构造
和词法分析一样,针对语法分析,也有很多现成的工具可以使用,比如 Yacc(或 GNU 的版本,Bison)、Antlr、JavaCC 等,而且还有很多开源的语法规则文件,改一改,就能用工具生成我们自己的语法分析器。
当然,我们也可以手写实现解析的过程,一种非常直观的构造思路是自上而下进行分析。首先构造根节点,代表整个程序,之后向下扫描 Token 串,构建它的子节点。
以int age = 45为例
,当看到一个 int 类型的 Token 时,知道这儿遇到了一个变量声明语句,于是建立一个“变量声明”节点;接着遇到 age,建立一个子节点,这是第一个变量;之后遇到 =,意味着这个变量有初始化值,那么建立一个初始化的子节点;最后,遇到“字面量”,其值是 45。
这样,一棵子树就扫描完毕了。程序退回到根节点,开始构建根节点的第二个子节点。这样递归地扫描,直到构建起一棵完整的树。这个算法就是非常常用的递归下降算法(Recursive Descent Parsing),如下图所示:
递归下降算法是一种自顶向下的算法,与之对应的,还有自底向上的算法。这个算法会先将最下面的叶子节点识别出来,然后再组装上一级节点。有点儿像搭积木,我们总是先构造出小的单元,然后再组装成更大的单元。
上下文无关文法
我们人类阅读文章可以通过'语感'来识别出一个句子中各个单词所属的成分,但是编译器只能根据一定的规则来对语法进行分析。无论是使用现成的工具还是手写实现语法解析的过程,我们都需要有规则进行指导。
这个时候我们就需要一种工具来描述一门语言的语法,在词法分析阶段,我们可以通过'正则文法'来描述一门语言的词法规则,但是在语法分析的时候,我们却无法使用正则文法来描述语法规则了(或者说我们很难做到,因为语法规则中所包含的情况比词法规则要复杂的多)。
所幸的是,在经过词法分析阶段之后,我们的源代码已经从一个字符流变成了一个token流,这个时候编译器就可以在token流的维度上对源代码进行处理,而我们描述一门语言的语法规则也就可以基于token,而不用基于最基本的字符了,这样描述语法规则这件事就得到了简化。
上下文无关文法(Context-free Grammar,CFG)就是用来描述语法规则的,它定义的语法范畴(或语法单位)是完全独立于这种范畴可能出现的环境。
它的左侧是一个非终结符,我们可以认为这是一个比较大的语法单元,右侧则是这个非终结符的构成规则(可能包含多个终结符或者非终结符)。我们这里所说的终结符其实就是在词法分析阶段所产生的token,在语法分析这个阶段的最细粒度就是token,它就是语法分析阶段的终结符。上下文无关文法比较强大的地方就是其可以递归引用,即一个产生式的右侧可以包含其左侧的非终结符。
从这里我们也可以看出正则文法和上下文无关文法的区别了,正则文法的粒度是字符,每一个字符都是一个终结符,也就是说正则文法中只能使用终结符,理论上,只使用终结符是可以描述出任意规则的,但是当情况变得复杂的时候,只使用终结符的方式描述能力反而大大下降。
对于上下文无关文法,我们可以自行定义,比如《Java语言规范 基于SE8》一书中的上下文无关文法就是自己定义的,不过其不止是包含元字符,而且包含了样式,可用性并不是很好。现在使用比较多的上下文无关文法的规范是巴科斯范式(BNF)范式或者是扩展的BNF(EBNF)范式。
个人比较喜欢Python中使用的EBNF范式,同时本人在使用的时候也定义了自己的一些规则,本人在自己的文章中使用的上下文无关文法如下:
::=
用来分隔非终结符和其产生规则()
用来分组|
表示被分隔的可选项*
表示前一项的零次或多次重复+
表示前一项的一次或多次重复[]
中的内容表示可以出现零次或一次,也就是可选的""
中的内容表示需要原样出现在代码中的字符串,特别地,\"
表示"。{}
中的多个元素表示它们的书写顺序可以随意排列由
…
分隔的两个字符字面值表示在指定 (闭) 区间范围内的任意单个 ASCII 字符><
括起来的内容是所定义符号的文字描述
如下:
name ::= lc_letter (lc_letter | "_")*
lc_letter ::= "a"..."z"
第一行表示 name
是一个 lc_letter
之后跟零个或多个 lc_letter
和下划线。而一个 lc_letter
则是任意单个 'a'
至 'z'
字符。
这么干巴巴的描述可以不太好理解,下面我就用上面的'EBNF'来简单描述一下英文的基本语法规则:
句子 ::= 主语 谓语 [间接宾语] 直接宾语 | 主 系 表
主语 ::= 代词|名词
谓语 ::= 动词
间接宾语 ::= 代词
直接宾语 ::= [冠词] 名词|代词
代词 ::= "he"|"she"|"me"|"it"
名词 ::=
动词 ::=
冠词 ::= "a"|"an"|"the"
...
虽然上面的实例并不完善,但是通过这个示例我们也能够对上下文无关文法有一个基本的认识。
通过上面的例子,我们可以看出,上下文无关文法的最小粒度是单词(token),token是上下文无关文法中的终结符号,我们一般借助正则文法来描述token的产生规则。
现在,我们来举个程序中的例子,比如下面的一组四则运算表达式的产生式:
add ::= mul | add ("+"|"-)" mul
mul ::= pri | mul ("*"|"/") pri
pri ::= Num | "("add")"
Num =
上面的产生式中把四则运算分为了两种,分别为add
加法表达式和mul
乘法表达式,其中
add
可以是一个mul
或者是一个add
加上或者减去一个mul
mul
可以是一个车pri
或者是一个mul
乘上或者是除以一个pri
pri
的含义是一个不可再分的表达式,它可以是一个数字,也可以是一个用括号括起来的加法表达式。
按照上面的产生式,add 可以替换成 mul,或者 add + mul。这样的替换过程又叫做“推导”。以“2+3*5” 和 “2+3+4”这两个算术表达式为例,这两个算术表达式的推导过程分别如下图所示:
现在,我们知道了语法分析中的指导规则是使用上下文无关文法来表示的,我们也知道,在表达式中,涉及运算符的优先级和结合性,那么我们应该怎么用上下文无关文法来表示出这样的规则来呢?
确保正确的优先级
在上面四则运算表达式的上下文无关文法中,我们由加法规则推导到乘法规则,这种方式保证了 AST 中的乘法节点一定会在加法节点的下层,也就保证了乘法计算优先于加法计算。所以我们在使用上下文表达优先级规则的时候,其实考虑的并不单单是运算符的优先级,而是考虑产生式的优先级,被引用的产生式具有更高的优先级。举个例子:
现在我们加入更多的运算符,它们的优先级从低到高为:赋值运算、逻辑运算(or)、逻辑运算(and)、相等比较(equal)、大小比较(rel)、加法运算(add)、乘法运算(mul)和基础表达式(pri)。
按照上面的思路我们可以写出如下的一组产生式:
exp ::= or | or "=" exp
or ::= and | or "||" and
and ::= equal | and "&&" equal
equal ::= rel | equal ("=="| "!=") rel
rel ::= add | rel (">"|">="|"|"<=") add
add ::= mul | add ("+"|"-") mul
mul ::= pri | mul ("*"|"/") pri
pri ::= Num | "("exp")" //需要注意的是,这个地方的基础表达式为一个数字或者是一个被括号包裹的表达式
Num =
可以看到,我们上面针对每一种运算都书写了一个产生式,而优先级高的运算符所对应的产生式被优先级低的运算符所对应的产生式所引用。这样我们就通过上下文无关文法正确地表达出了运算符的优先级关系
确保正确的结合性和避免左递归
我们已经解决了运算符的优先级问题,那结合性是如何通过上下文无关文法表达的呢?我们知道结合性指的是对于相同优先级的运算符的计算顺序的问题,其实这对应到上下文无关文法中,就是被递归引用的产生式的出现顺序的问题。
比如add ::= mul | add ("+"|"-") mul
,这里被引用的相同优先级的运算符就是其自身,其在左侧,那么其对应的运算符+和-的结合性就是左结合性。
但是这样书写会造成一个问题——因为这是递归引用,在使用递归下降算法实现的时候有可能会产生死循环,如下文法:
在解析 “2+3+4”这样一个最简单的加法表达式的时候,我们直观地将其翻译成算法,结果出现了如下的情况:
首先匹配是不是mul,发现不是,然后进入文法的第二个分支;
然后匹配是不是加法表达式,这里是递归调用;
会重复上面两步,无穷无尽。
如何解决呢?我们可以改写我们的产生式,把它写成如下的形式:
add ::= mul | mul ("+"|"-") add
上面的改写我们把原来的左递归改为了一个右递归,但是用这个产生式推导出来的AST如下所示:
根据这个 AST 做计算会出现计算顺序的错误。不过如果我们将递归项写在左边,就不会出现这种结合性的错误。于是我们得出一个规律:对于左结合的运算符,递归项要放在左边;而右结合的运算符,递归项放在右边。
现在,我们就遇到了一个两难的境地:大多数二元运算都是左结合的,那岂不是都要面临左递归问题?
注意,并不是所有的算法都不能解决左递归问题,比如LR算法。
其实我们可以通过改写产生式来避免左递归问题,很简单,把产生式中的递归引用去掉不就可以了吗?于是,对于加法表达式的产生式,我们可以改写成下面这样没有递归引用的形式:
add ::= mul (("+" | "-") mul)*
采用这样的写法之后,我们原来的递归其实就被展开成为了一个循环。
编译器的报错提醒
对于编译器前端的这三个步骤中,个人认为最复杂的就是语法分析阶段了,而且这个阶段所产生的元数据信息也是最多的,而且编译器的报错提示大部分也都是在这个阶段产生的。
语义分析(Semantic Analysis)
如果一篇英文文章中所有的句子都正确了,这个时候我们如果想要理解整篇文章的意思,还要经过一个步骤——联系上下文,对代词进行替换,比如一个句子中突然出现了一个She,那这个She到底是谁?我们需要联系上下文来确定,就像我们上面的英文物料中的第二句话she got merried last year
,只有联系其上一句话Lily is my first girlfriend
我们才能知道这里的she指的其实是Lily。
而且,在脱离上下文读一句话的时候,可能会产生二义性,以“You can never drink too much water.” 这句话为例。它的确切含义是什么?是“你不能喝太多水”,还是“你喝多少水都不嫌多”?实际上,这两种解释都是可以的,我们只有联系上下文才能知道它的准确含义。
你可能会觉得理解自然语言的含义已经很难了,所以计算机语言的语义分析也一定很难。其实语义分析没那么复杂,因为计算机语言的语义一般可以表达为一些规则,只要检查是否符合这些规则就行了。比如:
某个表达式的计算结果是什么数据类型?如果有数据类型不匹配的情况,是否要做自动转换?
如果在一个代码块的内部和外部有相同名称的变量,我在执行的时候到底用哪个?这就像我们上文中举的'she'到底是谁的例子。
在同一个作用域内,不允许有两个名称相同的变量,这是唯一性检查。你不能刚声明一个变量 a,紧接着又声明同样名称的一个变量 a,这就不允许了。
语义分析基本上就是做这样的事情,也就是根据语义规则进行分析判断。语义分析工作的某些成果,会作为属性标注在抽象语法树上,比如在 age 这个标识符节点和 45 这个字面量节点上,都会标识它的数据类型是 int 型的。在这个树上还可以标记很多属性(元数据信息),有些属性是在之前的两个阶段就被标注上了,比如所处的源代码行号,这一行的第几个字符。这样,在编译程序报错的时候,就可以比较清楚地了解出错的位置。做了这些属性标注以后,编译器在后面就可以依据这些信息生成目标代码了。
总结
这篇文章我们简述了高级语言的编译过程:
词法分析是把程序分割成一个个 Token 的过程,可以通过构造有限自动机来实现。
这个过程就类似于我们在读英文文章的时候需要认识英文单词。
语法分析是把程序的结构识别出来,并形成一棵便于由计算机处理的抽象语法树。可以用递归下降的算法来实现。
这个过程就类似于是我们在阅读英文的时候分析句子结构。
语义分析是消除语义模糊,生成一些属性信息,让计算机能够依据这些信息生成目标代码。
这个过程就类似于我们在阅读英文的时候联系上下文,明确语义。
就这样,经过一系列操作之后,编译器或者解释器就“读懂了”我们的代码,当然上述的类比可能并不准确,但是还是具有一定的相似性的,这可能也是我们通常把计算机叫做“电脑”的重要原因吧。
现在,我们除了可以写出“人能看懂的代码”以外,我们还知道了机器是如何看懂我们的代码的,下一篇文章,我们就简单介绍一下机器是如何运行我们的代码的。
感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163.com)。