(基于Java)编写编译器和解释器-第1章:介绍(连载)

时间:2021-08-01 17:08:59

本章描述了本书的目标和用到的方法并鸟瞰编译器和解释器的全貌。

目标和方法

本书讲授编译器和解释器的基本写法,目标是呈现给你怎样设计和开发它们:

  • 用Java写的编译器,编译Pascal(一个高级的面向过程的编程语言)的一个主要子集。(即包含主要的语言特征,但去掉一些为写编译器方便而去掉的无关大雅的特性)
  • 用Java写的解释器且包含一个交互式的符号调试器(符号调试器即基于符号表,而不是基于机器的指令集、硬件的调试功能),解释同样的Pascal语言子集。
  • 带图形用户界面的集成开发环境(IDE)。这个IDE是你看到的功能全面的开源的Eclipse或者Borland的JBuilder等IDE的一个简化版。不过,它也包含一个源程序编辑器和一个交互界面用来设置断点,单步调试,查看和修改变量值等等其它。

达成这些个极具野心的目标是个大挑战。好的技能将会帮你如何如把程序编译成为机器语言或解释执行程序。现代软件工程法则和优秀的面向对象设计思想将会给你呈现怎么通过代码实现一个编译器或解释器而最终所有组件能良好协作。编译器和解释器程序大且复杂。开发个小程序仅需要某种技能即可,然NB的程序如编译器或解释器还需要软件工程法则和面向对象设计。因此本书强调必备技能,软件工程法则和面向对象思想。

什么是编译器和解释器

编译器和解释器的主要目的是“翻译”由高阶(High-Level)源语言写的源程序。把源程序翻译成什么样是接下几个段落的主题。

本书中源语言为Pascal的一个大子集,换句话说,你能够编译或解释正规的Pascal程序。因为编译器和解释器是用Java写的,实现语言是Java。

Pascal编译器将Pascal源程序翻译成为低阶(Low-Level)的某具体机器的机器语言(更准确的讲是CPU的机器语言)。通常源程序是文本格式。如果编译器工作正常,对应的机器语言和最初的Pascal源程序殊路同归(一样的行为,只不过呈现方式不一样。比如你用钥匙而偷车的直接电线打火发动汽车一样)。机器语言是目标语言,编译器生成用机器语言组成目标代码。代码生成之后,编译器任务就算完成。目标代码一般写到文件里(一般是二进制文件)。

一个程序可包含数个源文件,而编译器为每个文件生成一个目标文件。一个名叫“链接器”(linker)的辅助程序将这些目标文件的内容连同运行时库程序合成到一个计算机能够加载和执行的目标程序(如windows的PE程序)中。库程序一般来自于预先编译好的目标文件。

因为机器语言不好记,编译器可生成汇编语言作为目标语言,汇编语言离机器语言只有一步之遥。通常每个汇编指令都有机器语言的指令与之对应。如果你掌握了短助记名(比如ADD和MOV等)汇编语言好记多了。汇编器(另一个编译器)将汇编语言翻译成为机器语言。

图1-1 概括了将一个或多个源程序编译成为目标程序的过程。

 

(基于Java)编写编译器和解释器-第1章:介绍(连载)

 

图左边展示了将一个包含三个源文件sort1.pas、sort2.pas、sort3.pas的Pascal程序翻译成为三个相应机器语言目标文件sort1.obj、sort2.obj、sort3.obj。链接器将三个目标文件(连带相关运行时库) 合成为一个可执行的目标程序sort.exe。图右边展示了编译器将Pascal源文件翻译为汇编语言目标文件sort1.asm、sort2.asm、sort3.asm,接着汇编器将其转化为机器语言目标文件。最后链接器产生目标程序sort.exe。

 

 

 

 

 

 

 

图1-1

 

 

 

那么编译器和解释器到底有和不同?

解释器不生成任何目标程序,相反它读进源程序就会执行。这好比你被一个Pascal程序把住手,按照它说的某种语句读进顺序去做。你可以在一张草稿纸上记下程序的变量值直到程序结束才输出每条语句的输出结果。本质上你做的正是Pascal解释器干的事情。Pascal解释器读进程序,执行程序。没有任何目标程序需要生成和加载,相反,解释器将程序翻译成为一系列用来执行程序的动作(Action)。

比较编译器和解释器

该如何决策何时用编译器和何时用解释器?

当你把一个源程序交给解释器,解释器接管检查和执行。编译器也检查但生成目标代码。运行完编译器之后还有运行链接器产生目标程序,且还需加载目标程序到内存中去执行它。如果编译器生成汇编语言代码,你还得运行汇编器。所以很显然解释器需要更少步骤。

解释器比编译器更常见。你可用Java写个Pascal解释器运行在基于微软Windows的PC上,苹果的MAC(麦金塔)或某个Linux主机上,解释器能够在前面提到的平台上执行Pascal程序。而编译器必须为某个具体的机器生成代码(无论直接生成或间接通过汇编器生成)。所以即使你要把原来为PC写的Pascal编译器放到MAC上运行,它生成的代码仍旧是PC的,如果想让它为MAC生成代码,你可能得重写编译器的某些部分。

(接下来讨论的编译器将问题的重心放在为Java虚拟机生成代码上,因为虚拟机能够运行在很多平台上。所以为具体机器生成代码先放一边,有兴趣可以将虚拟机替换成为真实PC机上生成x86指令看看)

如果源程序中包含逻辑错误,比如除值为0的变量,直到运行时才发现,那么会发生什么情况?

因为解释器在执行程序过程中控制一切,它能停下来告诉你出问题的行数和变量名称。它甚至能提示你在继续执行程序之前可以做哪些正确操作比如修改变量值为非零。解释器可包含一个交互式的源级(source-level)调试器,俗称符号调试器(symbolic debugger)。符号调试器意味着你可用程序中的符号,比如变量名。

另一方面,由编译器和链接器产生的目标程序通常自我运行(由机器执行,无需第三方)。源程序有关行号和变量名等信息在目标程序中不可见。当运行时抛错,程序简单中断,还可能打印一条包含出问题指令地址的消息。于是找出源程序中相关语句变量除零的问题就交给你了,(基于Java)编写编译器和解释器-第1章:介绍(连载)

所以通常就调试来说,解释器才是正道。有些编译器在目标代码中添加一些额外的信息,这样当错我发生时,目标程序能打印出相应的问题行数和变量名等。于是你改正错误,重新编译,然后重新运行。生成额外的信息会导致程序执行的比正常要慢(这也是Visual C++为什么有Run/Debug编译模式)。这提示你在认为程序到达最终“产品”版本后,应关掉调试特征重新编译。

假设你已经成功调试好程序,那重点将是怎样使运行更快。因为机器能够以最快速度执行原生机器语言程序,编译程序能够比解释器快好几个量级。显然就速来来说编译器是胜者,当优化版编译器知道怎么生成具体场景的优化代码的情况下尤其确定。所以是否使用编译器或解释器取决于程序的开发和执行谁更重要。理想情况是一个带符号源级调试器的解释器用在开发过程中,一个生成机器代码的编译器在程序调试OK之后以求更快的执行速度。这些就是本书的目标,因为它编译器,解释器都教。

情景变得有点模糊

编译器和解释器的差异很容易说明清楚,但是随着虚拟机的快速流行,情景变得有点模糊。

虚拟机是一个用来模拟机器(计算机)的程序。此程序能够运行在不同的真实计算机平台上。举个例子,Java 虚拟机(JVM)能够运行在基于微软Windows的PC上,苹果的MAC(麦金塔),Linux系统和其它很多平台上。(比如Sparc,IBM小型机等)。

虚拟机有自己的虚拟机器语言,而虚拟语言指令被真实宿主机所解释。那么如果你写了一个翻译器将Pascal源程序翻译成为被宿主机解释的虚拟机语言,这个翻译器算编译器还是解释器?

不斤斤计较了,我们本书约定如果一个翻译器将源程序转化成为机器语言,不管是真实的机器语言还是虚拟机器语言,那么这个翻译器就是编译器。翻译器没有优先生成机器语言去执行程序的就算解释器

为什么学习编译器编写技术?

我们都想当然的认为对编译器和解释器学习了个大概,因为你在开发中需要聚焦在编写和调试程序上,你甚至不需要思考编译器的工作机制。你或许仅仅在搞错语法编译器抛出错误信息后才留意到编译器的存在。如果没有语法错误,那么编译将会生成正确的代码无疑。如果你的程序运行失常,你有可能怪罪编译器,但大多时候,你会发现错误在你的程序中。

以上情形通常会出现在你在使用某个流行编程语言(比如Java或C++)它的编译器、解释器和IDE都给你准备好了的时候。这先聊到这。

不过最近我们看到很多新编程语言在被开发。驱动力包括www(比如HTML5)和与基于web的应用相适应的新语言(典型比如PHP,纯web)。对程序员生产力的更高要求催生与具体应用领域紧密结合的新语言(这个可以举很多例子,比如为系统管理员的各种Shell语言,为数据库开发的各种SQL/NO SQL语言,为电路板/DSP开发的类VHDL语言等,为工作流开发的各种BPM语言等)。你可能非常期待自己有天能发明个新脚本语言表达算法或控制与你领域相关的流程。如果你要发明新语言,对应的编译器和解释器必不可少。

编译器和解释器本身很好玩,但你前面注意到了,任何一个都不是个小程序,要开发成功相关的技能,现代软件工程法则和良好的OO设计思想必不可少。除了学习编译器解释器工作机制带来的满足感外,你也要笑着面对编写它们带来的挑战。

概念设计

为接下几章做准备,让我们重温编译器和解释器的概念设计

设计笔记

程序的概念设计是它的软件架构的一个高级视图。概念设计包含程序的主要组件,它们怎么组织,相互之间的交互细节等。它不需要说明组件怎么实现,更确切的说,它可让你先确认和理解组件而无需担心最终怎么去开发它们。

你可将编译器和解释器归为程序语言翻译器。如前面解释的那样,编译器将源程序翻译成机器语言而解释器将之翻译成系列动作(Action)。站在最高角度看翻译器,它包含一个前端(front end)和一个后端(back end)。遵从软件重用法则,你将看到Pascal编译器和Pascal解释器共享前端,但有不同的后端。

翻译器的前端读入源程序然后执行最初的翻译过程。它的主要组件有parser, scanner(更学院派的说法是Lexer即词法分析器),token(最小语言单位,最大词法单元)和source(表示源代码)。

paser控制前端的翻译过程。它不断的从scanner读入token,根据token串(就是token模式)判定当前正翻译的高阶语言元素,比如算术表达式,赋值语句,过程申明等。parser检验源程序的语法是否正确。paser干的事情称之为解析(parsing),parser分析源程序然后将之转换。(转换成啥?后面会有,一般为抽象语法树之类的中间层

scanner一个接一个字符读入源程序的内容,然后构造tokens即源语言的低阶元素。例如Pascal tokens包含关键字如BEGIN、END、IF、THEN和ELSE,标识符即变量、过程、函数名称(identifier,又称ID)以及特殊符号如= := + - *和/ 。scanner干的事情称为扫描(scanning)。scanner扫描源程序,将之分成一个个token。

图1-2 展示了编译器和解释器前端的概念设计

(基于Java)编写编译器和解释器-第1章:介绍(连载)

图1-2

此图中,箭头表示一个组件给另外一个发送命令。parser告诉scanner要下一个token。scanner从source中获取字符然后构造新的token。token 也从source中读入字符。(13章会讲到为何scanner和token组件都需要从source中读取字符)

编译器最终将源程序翻译成机器语言目标代码,所以后端的一个重要组件是代码生成器(目标代码生成器 code generator)。解释器执行程序,所以其后端的首要组件是执行器(executor)。

如果你想让编译器和解释器共享前端,那么它们不同的后端需要有个通用接口用来与前端打交道(也就是只需要将前端传入这个接口即可)。记住前端处理最初的翻译过程。前端生成作为公共接口中间层的中间代码(intermediate code,分析树/语法树,抽象语法树等)和符号表(symbol table)。

中间码(intermediate code)是源程序的预摘要格式(pre-digested,可以理解为在源程序格式和机器语言格式中间的一个摘要格式,一般为分析树parse tree或语法树syntax tree)为方便后端的更有效处理(假设翻译器将塑料翻译成为瓶子,那么源程序为塑料,中间码为瓶盖,瓶身,包装纸,这样后端就能更快的装瓶子)。本书中的中间码是一个驻内存表示源程序语句的树状数据结构(也就是语法树,废话一堆啊)。符号表包含源程序的符号信息(比如标识符)。编译器的后端处理中间码和符号表,生成源程序对应的机器语言。解释器碰到中间码和符号表就直接执行了(通常是树遍历过程)。

为软件重用,你可将中间码和符号表设计成语言无关的结构。换句话说,你可用同样的结构应用于不同的源语言。因此,后端同样可以语言无关,当它处理这些结构(中间码和符号表)是根本不需要知道具体源语言。

图1-3 展示了一个更为复杂的编译器和解释器的概念设计。如果你万事安好,仅需前端知道源语言定义且仅需后端知道区分编译器和解释器。

(基于Java)编写编译器和解释器-第1章:介绍(连载)图1-3 一个更完整的概念设计

第2章开始通过设计一个编译器解释器框架来充实概念设计。第3章讲的是扫描(scanning)。第4章构建第一个符号表,第五章生成最初的中间码。第6章开始编写执行器(executor)且增量式开发直到14章,其中包含符号调试器和IDE。代码生成直到在15章学了了JVM架构之后的16章才涉及。

语法和语义(syntax and semantics)

编程语言的语法是一系列规则用来断定用此语言写的语句或表达式是否正确。语言的语义传达语句和表达式的具体意思(赋值谁赋给谁,循环终止条件是什么)。举个例子,Pascal的语法告诉我们 i := j+k 是一个有效的赋值语句。它的语义是说将变量j 和k的当前值加起来,然后将和赋给 i。

parser基于源语言的语法和语义执行有关动作。扫描源程序抽取tokens是语法动作。查找赋值语句 := 之后的目标变量是语法动作。将标识符(identifiers) i、j、k当作变量存入符号表或日后在符号表中查找是语义动作,因为parser必须明白当前表达式和赋值的意思才知道得用到符号表。生成代表此赋值语句的中间码属于语义动作。

语法动作尽在前端发生,语义动作在前后端都有。在后端执行程序或者生成目标代码需要知道语句的具体意思,所以是语义动作一部分。中间码和符号表存储语义信息。

词法,语法和语义分析

词法分析是扫描(scanning)的正式说法,所以scanner也称词法分析器(lexical analyzer)。语法分析是parsing(解析,parser的主要任务)的正式称谓,语法分析器就是parser。语义分析主要是检查语义规则是否完整。类型检查(type checking)就是一例,它确保操作符(operator)的操作数(operand)类型保持一致。其它的语义分析操作有构造符号表和生成中间码。