From 249864d6792abcb2657245e258d0e0f8cc25645b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E4=B9=BE?= Date: Wed, 16 Oct 2024 10:01:33 +0800 Subject: [PATCH] first commit --- ...词为什么你要学习编译原理?.md | 71 +++ ...理解代码:编译器的前端技术.md | 176 ++++++ ...动机:纯手工打造词法分析器.md | 290 ++++++++++ ...一):纯手工打造公式计算器.md | 314 +++++++++++ ...):解决二元表达式中的难点.md | 197 +++++++ ...):实现一门简单的脚本语言.md | 272 +++++++++ ...用Antlr生成词法、语法分析器.md | 276 ++++++++++ ...(二):用Antlr重构脚本语言.md | 393 +++++++++++++ ...生存期:实现块作用域和函数.md | 520 ++++++++++++++++++ ...对象:实现数据和方法的封装.md | 307 +++++++++++ ...理解了原理,它就不反直觉了.md | 478 ++++++++++++++++ ...何建立一个完善的类型系统?.md | 245 +++++++++ ...何做上下文相关情况的处理?.md | 270 +++++++++ ...:面向对象运行期的动态特性.md | 305 ++++++++++ ...透明地支持数据库分库分表?.md | 289 ++++++++++ ...):如何设计一个报表工具?.md | 353 ++++++++++++ ...己实现一个正则表达式工具?.md | 411 ++++++++++++++ ...w集合:用LL算法推演一个实例.md | 289 ++++++++++ ...规约:用LR算法推演一个实例.md | 304 ++++++++++ ...什么我的推导不是左递归的?.md | 231 ++++++++ ...高效运行:编译器的后端技术.md | 156 ++++++ ...象看本质,透过语法看运行时.md | 214 +++++++ ...(一):汇编语言其实不难学.md | 296 ++++++++++ ...):把脚本编译成可执行文件.md | 433 +++++++++++++++ ...代码:兼容不同的语言和硬件.md | 392 +++++++++++++ ...的重用:LLVM不仅仅让你高效.md | 264 +++++++++ ...6生成IR:实现静态编译的语言.md | 467 ++++++++++++++++ ...什么你的代码比他的更高效?.md | 361 ++++++++++++ ...流分析:你写的程序,它更懂.md | 196 +++++++ ...):如何适应各种硬件架构?.md | 228 ++++++++ ...):如何适应各种硬件架构?.md | 251 +++++++++ ...据做计算,到底可以有多快?.md | 327 +++++++++++ ...成:为什么Spring技术很强大?.md | 226 ++++++++ ...收集:能否不停下整个世界?.md | 258 +++++++++ ...优化:即时编译的原理和作用.md | 184 +++++++ ...端部分真的比前端部分难吗?.md | 177 ++++++ ...趋势以及其对编译技术的影响.md | 153 ++++++ ...云计算会如何改变编程模式?.md | 190 +++++++ ...程:一边写程序,一边写语言.md | 198 +++++++ ...加餐汇编代码编程与栈帧管理.md | 466 ++++++++++++++++ ...用户故事因为热爱,所以坚持.md | 71 +++ ...起实战解析真实世界的编译器.md | 29 + ...序语言,推动这个世界的演化.md | 92 ++++ ...何学习这门编译原理实战课?.md | 77 +++ 44 files changed, 11697 insertions(+) create mode 100644 专栏/编译原理之美/00开篇词为什么你要学习编译原理?.md create mode 100644 专栏/编译原理之美/01理解代码:编译器的前端技术.md create mode 100644 专栏/编译原理之美/02正则文法和有限自动机:纯手工打造词法分析器.md create mode 100644 专栏/编译原理之美/03语法分析(一):纯手工打造公式计算器.md create mode 100644 专栏/编译原理之美/04语法分析(二):解决二元表达式中的难点.md create mode 100644 专栏/编译原理之美/05语法分析(三):实现一门简单的脚本语言.md create mode 100644 专栏/编译原理之美/06编译器前端工具(一):用Antlr生成词法、语法分析器.md create mode 100644 专栏/编译原理之美/07编译器前端工具(二):用Antlr重构脚本语言.md create mode 100644 专栏/编译原理之美/08作用域和生存期:实现块作用域和函数.md create mode 100644 专栏/编译原理之美/09面向对象:实现数据和方法的封装.md create mode 100644 专栏/编译原理之美/10闭包:理解了原理,它就不反直觉了.md create mode 100644 专栏/编译原理之美/11语义分析(上):如何建立一个完善的类型系统?.md create mode 100644 专栏/编译原理之美/12语义分析(下):如何做上下文相关情况的处理?.md create mode 100644 专栏/编译原理之美/13继承和多态:面向对象运行期的动态特性.md create mode 100644 专栏/编译原理之美/14前端技术应用(一):如何透明地支持数据库分库分表?.md create mode 100644 专栏/编译原理之美/15前端技术应用(二):如何设计一个报表工具?.md create mode 100644 专栏/编译原理之美/16NFA和DFA:如何自己实现一个正则表达式工具?.md create mode 100644 专栏/编译原理之美/17First和Follow集合:用LL算法推演一个实例.md create mode 100644 专栏/编译原理之美/18移进和规约:用LR算法推演一个实例.md create mode 100644 专栏/编译原理之美/19案例总结与热点问题答疑:对于左递归的语法,为什么我的推导不是左递归的?.md create mode 100644 专栏/编译原理之美/20高效运行:编译器的后端技术.md create mode 100644 专栏/编译原理之美/21运行时机制:突破现象看本质,透过语法看运行时.md create mode 100644 专栏/编译原理之美/22生成汇编代码(一):汇编语言其实不难学.md create mode 100644 专栏/编译原理之美/23生成汇编代码(二):把脚本编译成可执行文件.md create mode 100644 专栏/编译原理之美/24中间代码:兼容不同的语言和硬件.md create mode 100644 专栏/编译原理之美/25后端技术的重用:LLVM不仅仅让你高效.md create mode 100644 专栏/编译原理之美/26生成IR:实现静态编译的语言.md create mode 100644 专栏/编译原理之美/27代码优化:为什么你的代码比他的更高效?.md create mode 100644 专栏/编译原理之美/28数据流分析:你写的程序,它更懂.md create mode 100644 专栏/编译原理之美/29目标代码的生成和优化(一):如何适应各种硬件架构?.md create mode 100644 专栏/编译原理之美/30目标代码的生成和优化(二):如何适应各种硬件架构?.md create mode 100644 专栏/编译原理之美/31内存计算:对海量数据做计算,到底可以有多快?.md create mode 100644 专栏/编译原理之美/32字节码生成:为什么Spring技术很强大?.md create mode 100644 专栏/编译原理之美/33垃圾收集:能否不停下整个世界?.md create mode 100644 专栏/编译原理之美/34运行时优化:即时编译的原理和作用.md create mode 100644 专栏/编译原理之美/35案例总结与热点问题答疑:后端部分真的比前端部分难吗?.md create mode 100644 专栏/编译原理之美/36当前技术的发展趋势以及其对编译技术的影响.md create mode 100644 专栏/编译原理之美/37云编程:云计算会如何改变编程模式?.md create mode 100644 专栏/编译原理之美/38元编程:一边写程序,一边写语言.md create mode 100644 专栏/编译原理之美/加餐汇编代码编程与栈帧管理.md create mode 100644 专栏/编译原理之美/用户故事因为热爱,所以坚持.md create mode 100644 专栏/编译原理之美/第二季回归这次,我们一起实战解析真实世界的编译器.md create mode 100644 专栏/编译原理之美/结束语用程序语言,推动这个世界的演化.md create mode 100644 专栏/编译原理实战课/00学习指南如何学习这门编译原理实战课?.md diff --git a/专栏/编译原理之美/00开篇词为什么你要学习编译原理?.md b/专栏/编译原理之美/00开篇词为什么你要学习编译原理?.md new file mode 100644 index 0000000..f8429d1 --- /dev/null +++ b/专栏/编译原理之美/00开篇词为什么你要学习编译原理?.md @@ -0,0 +1,71 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 00 开篇词 为什么你要学习编译原理? + 你好,我是宫文学,一名技术创业者。我曾经参与过几个公司的创业过程,在开源技术社区也做过一些工作,现在是北京物演科技CEO。 + +我喜欢做平台性的软件,而编译技术就是产品取得优势的关键。我是国内最早一拨做BPM的,也就是流程管理平台,也是最早一拨做BI平台的,现在流行叫大数据。当时我们只有3个人,用编译技术做了一些硬核的产品原型,跟联想集团签订了战略级合作协议。之后我又做过电子表单和快速开发平台,而它们的核心就是编译技术。 + +我参与的第一个公司卖给了上市公司,第二个在新三板上市,这些成果在一定程度上受益于编译技术。而我呢,对编译技术一直很重视,也一直保持着兴趣。所以很高兴能在“极客时间”上分享与编译技术有关的原理和经验,希望我的分享能帮助你在编译技术这个领域获得实实在在的进步。 + +众所周知,编译技术是计算机科学皇冠上的明珠之一。历史上各门计算机语言的发明人,总是被当作英雄膜拜。比尔·盖茨早期最主要的成就,就是写了一个Basic的解释器。当年Brendan Eich设计的JavaScript,虽然语言略微有点儿糙,但却顽强地生存到了现在。 + +很多国外厂商的软件,普遍都具备二次编程能力,比如Office、CAD、GIS、Mathematica等等。德国SAP公司的企业应用软件也是用自己的业务级语言编写的。目前来看,谷歌也好,苹果也好,微软也好,这些技术巨头们的核心能力,都是拥有自己的语言和生态。可见编译技术有多么重要! + +编译技术,与你的工作息息相关 + +但也有一些程序员认为:“我不可能自己去写一门新的语言,还有必要学习编译原理吗?” + +这种想法是把编译原理的用途简单化了。编译原理不是只能用于炫耀的屠龙技。 别的不说,作为程序员,在实际工作中你经常会碰到需要编译技术的场景。 + +Java程序员想必很熟悉Hibernate和Spring,前者用到了编译技术做HQL的解析,后者对注解的支持和字节码动态生成也属于编译技术。所以,如果你要深入理解和用好这类工具,甚至想写这种类型的工具,会需要编译技术。 + +而PHP程序员在写程序的时候,一般会用到模板引擎实现界面设计与代码的分离。模板引擎对模板进行编译,形成可执行的PHP代码。模板引擎可以很强大,支持条件分支、循环等语法。如果你了解编译技术,会更容易掌握这些模板引擎,甚至写出更符合领域需求的模板引擎。 + +我们2001年开发了一款工作流软件,里面有依据自定义公式判断流转方向的功能。像这类需要用户自定义功能的软件,比如报表软件、工资管理软件等,都需要编译技术。 + +如果你要参与编写一个基础设施类的软件,比如数据库软件、ETL软件、大数据平台等,很多需要采用编译技术提供软件自带的语言功能,比如SQL。这种功能无法由外部通用语言实现。 + +除此之外,解析用户输入,防止代码注入,为前端工程师提供像React那样的DSL,像TypeScript那样把一门语言翻译成另一门语言,像CMake和Maven那样通过配置文件来灵活工作,以及运维工程师分析日志文件等等高级别的需求,都要用到编译技术。 + +除了丰富的应用场景,学习编译技术对于提升程序员的竞争力也很重要。现在一些大公司在招聘程序员时,有难度的面试题都是涉及底层机制的。因为理解了底层机制,才能有更深入思考问题,以及深层次解决问题的能力,而不是只能盲目地搜索答案,从表面解决问题。而学习编译原理能让你从前端的语法维度、代码优化的维度、与硬件结合的维度几个方面,加深对计算机技术的理解,提升自己的竞争力。 + +所以,无论你是前端工程师、后端工程师,还是运维工程师,不论你是初级工程师还是职场老手,编译技术都能给你帮助,甚至让你提升一个级别。 + +编译技术并不难学 + +但问题来了,你可能会说:“我知道编译技术很重要,我也很想把它啃下,可是我每次鼓起勇气拿起《编译原理》,啃不了多少页就放下了。编译原理已经成了我的心魔……” + +在我看来,你之所以遇到困难,很大一个原因在于市面上讲述编译原理的内容往往过于抽象和理论化。学习,说到底是一个学和练,以及学以致用的过程。所以在和朋友们沟通了解之后,我想用下面的思路组织课程内容,帮你克服畏难情绪,更好地理解和学习编译原理。 + +我会通过具体的案例带你理解抽象的原理。比如语义分析阶段有个I属性和S属性,传统课本里只专注I属性和S属性的特点和计算过程,很抽象。那么我会分析常用语言做语义分析时,哪些属性是I属性,哪些是S属性,以及如何进一步运用这些属性,来让你更直观地了解它们。 + +我也会重视过程,带你一步步趟过雷区。我写了示例程序,带你逐渐迭代出一门脚本语言和一门编译型语言。当然了,我们会遇到一些挑战和问题,而在解决问题的过程中,你会切切实实体会到某个技术在哪个环节会发挥什么作用。最重要的是,你会因此逐渐战胜畏难情绪,不再担心看不懂、学不会。 + +我还会让你在工作中真正运用到编译技术。课程里的代码,可以给你的工作提供参考。我介绍的Antlr和LLVM工具,前者能帮你做编译器前端的工作,后者能帮你完成编译器后端的工作。在课程中,你能真正运用编译技术解决报表设计等实际问题。 + +为了帮你迅速了解课程的知识结构体系,我画了一张思维导图。课程从三方面展开,包括实现一门脚本语言、实现一门编译型语言和面向未来的编程语言。 + + + + +课程的第一部分主要聚焦编译器前端技术,也就是通常说的词法分析、语法分析和语义分析。我会带你了解它们的原理,实现一门脚本语言。我也会教你用工具提升编译工作的效率,还会在几个应用场景中检验我们的学习成果。 + +第二部分主要聚焦编译器后端技术,也就是如何生成目标代码和对代码进行优化的过程。我会带你纯手工生成汇编代码,然后引入中间代码和后端工具LLVM,最后生成可执行的文件能支持即时编译,并经过了多层优化。 + +第三部分是对编译技术发展趋势的一些分析。这些分析会帮助你更好地把握未来技术发展的脉搏。比如人工智能与编译技术结合是否会出现人工智能编程?云计算与编译技术结合是否会催生云编程的新模式?等等。 + + +写在后面 + +课程虽然只有30多节,但每节课绝对是干货满满。我希望这个课程能让所有有志于提升自己技术的工程师,顺利攻下编译技术这重要的一关,能够在工作中应用它见到实效,并且对编程理解更上一层。 + +最后,我希望你在留言区立下Flag,写下自己的计划,在“极客时间”与志同道合的朋友互相监督,一起学习,一起进步! + + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/01理解代码:编译器的前端技术.md b/专栏/编译原理之美/01理解代码:编译器的前端技术.md new file mode 100644 index 0000000..16b5afa --- /dev/null +++ b/专栏/编译原理之美/01理解代码:编译器的前端技术.md @@ -0,0 +1,176 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 01 理解代码:编译器的前端技术 + 在开篇词里,我分享了一些使用编译技术的场景。其中有的场景,你只要掌握编译器的前端技术就能解决。比如文本分析场景,软件需要用户自定义功能的场景以及前端编程语言的翻译场景等。而且咱们大学讲的编译原理,也是侧重讲解前端技术,可见编译器的前端技术有多么重要。 + +当然了,这里的“前端(Front End)”指的是编译器对程序代码的分析和理解过程。它通常只跟语言的语法有关,跟目标机器无关。而与之对应的“后端(Back End)”则是生成目标代码的过程,跟目标机器有关。为了方便你理解,我用一张图直观地展现了编译器的整个编译过程。 + + + +你可以看到,编译器的“前端”技术分为词法分析、语法分析和语义分析三个部分。而它主要涉及自动机和形式语言方面的基础的计算理论。 + +这些抽象的理论也许会让你“撞墙”,不过不用担心,我今天会把难懂的理论放到一边,用你听得懂的大白话,联系实际使用的场景,带你直观地理解它们,让你学完本节课之后,实现以下目标: + + +对编译过程以及其中的技术点有个宏观、概要的了解。 +能够在大脑里绘制一张清晰的知识地图,以应对工作需要。比如分析一个日志文件时,你能知道所对应的技术点,从而针对性地解决问题。 + + +好了,接下来让我们正式进入今天的课程吧! + +词法分析(Lexical Analysis) + +通常,编译器的第一项工作叫做词法分析。就像阅读文章一样,文章是由一个个的中文单词组成的。程序处理也一样,只不过这里不叫单词,而是叫做“词法记号”,英文叫Token。我嫌“词法记号”这个词太长,后面直接将它称作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。 + +那么,如何写一个程序来识别Token呢?可以看到,英文内容中通常用空格和标点把单词分开,方便读者阅读和理解。但在计算机程序中,仅仅用空格和标点分割是不行的。比如“age >= 45”应该分成“age”“>=”和“45”这三个Token,但在代码里它们可以是连在一起的,中间不用非得有空格。 + +这和汉语有点儿像,汉语里每个词之间也是没有空格的。但我们会下意识地把句子里的词语正确地拆解出来。比如把“我学习编程”这个句子拆解成“我”“学习”“编程”,这个过程叫做“分词”。如果你要研发一款支持中文的全文检索引擎,需要有分词的功能。 + +其实,我们可以通过制定一些规则来区分每个不同的Token,我举了几个例子,你可以看一下。 + + +识别age这样的标识符。它以字母开头,后面可以是字母或数字,直到遇到第一个既不是字母又不是数字的字符时结束。 + +识别>=这样的操作符。 当扫描到一个>字符的时候,就要注意,它可能是一个GT(Greater Than,大于)操作符。但由于GE(Greater Equal,大于等于)也是以>开头的,所以再往下再看一位,如果是=,那么这个Token就是GE,否则就是GT。 + +识别45这样的数字字面量。当扫描到一个数字字符的时候,就开始把它看做数字,直到遇到非数字的字符。 + + +这些规则可以通过手写程序来实现。事实上,很多编译器的词法分析器都是手写实现的,例如GNU的C语言编译器。 + +如果嫌手写麻烦,或者你想花更多时间陪恋人或家人,也可以偷点儿懒,用词法分析器的生成工具来生成,比如Lex(或其GNU版本,Flex)。这些生成工具是基于一些规则来工作的,这些规则用“正则文法”表达,符合正则文法的表达式称为“正则表达式”。生成工具可以读入正则表达式,生成一种叫“有限自动机”的算法,来完成具体的词法分析工作。 + +不要被“正则文法(Regular Grammar)”和“有限自动机(Finite-state Automaton,FSA,or Finite Automaton)”吓到。正则文法是一种最普通、最常见的规则,写正则表达式的时候用的就是正则文法。我们前面描述的几个规则,都可以看成口语化的正则文法。 + +有限自动机是有限个状态的自动机器。我们可以拿抽水马桶举例,它分为两个状态:“注水”和“水满”。摁下冲马桶的按钮,它转到“注水”的状态,而浮球上升到一定高度,就会把注水阀门关闭,它转到“水满”状态。 + + + +词法分析器也是一样,它分析整个程序的字符串,当遇到不同的字符时,会驱使它迁移到不同的状态。例如,词法分析程序在扫描age的时候,处于“标识符”状态,等它遇到一个>符号,就切换到“比较操作符”的状态。词法分析过程,就是这样一个个状态迁移的过程。 + + + +你也许熟悉正则表达式,因为我们在编程过程中经常用正则表达式来做用户输入的校验,例如是否输入了一个正确的电子邮件地址,这其实就是在做词法分析,你应该用过。 + +语法分析 (Syntactic Analysis, or Parsing) + +编译器下一个阶段的工作是语法分析。词法分析是识别一个个的单词,而语法分析就是在词法分析的基础上识别出程序的语法结构。这个结构是一个树状结构,是计算机容易理解和执行的。 + +以自然语言为例。自然语言有定义良好的语法结构,比如,“我喜欢又聪明又勇敢的你”这个句子包含了“主、谓、宾”三个部分。主语是“我”,谓语是“喜欢”,宾语部分是“又聪明又勇敢的你”。其中宾语部分又可以拆成两部分,“又聪明又勇敢”是定语部分,用来修饰“你”。定语部分又可以分成“聪明”和“勇敢”两个最小的单位。 + +这样拆下来,会构造一棵树,里面的每个子树都有一定的结构,而这个结构要符合语法。比如,汉语是用“主谓宾”的结构,日语是用“主宾谓”的结构。这时,我们说汉语和日语的语法规则是不同的。 + + + +程序也有定义良好的语法结构,它的语法分析过程,就是构造这么一棵树。一个程序就是一棵树,这棵树叫做抽象语法树(Abstract Syntax Tree,AST)。树的每个节点(子树)是一个语法单元,这个单元的构成规则就叫“语法”。每个节点还可以有下级节点。 + +层层嵌套的树状结构,是我们对计算机程序的直观理解。计算机语言总是一个结构套着另一个结构,大的程序套着子程序,子程序又可以包含子程序。 + +接下来,我们直观地看一下这棵树长什么样子。 我在Mac电脑上打下这个命令: + +clang -cc1 -ast-dump hello.c + + +这个命令是运行苹果公司的C语言编译器来编译hello.c,-ast-dump参数使它输出AST,而不是做常规的编译。我截取了一部分输出结果给你看,从中你可以看到这棵树的结构。 试着修改程序,添加不同的语句,你会看到不同的语法树。 + + + +如果你觉得这棵树还不够直观,可以参考我提供的网址,它能够生成JavaScript语言的AST,并以更加直观的方式呈现。 + +在这个网址里输入一个可以计算的表达式,例如“2+3*5”,你会得到一棵类似下图的AST。 + + + +形成AST以后有什么好处呢?就是计算机很容易去处理。比如,针对表达式形成的这棵树,从根节点遍历整棵树就可以获得表达式的值。基于这个原理,我在后面的课程中会带你实现一个计算器,并实现自定义公式功能。 + +如果再把循环语句、判断语句、赋值语句等节点加到AST上,并解释执行它,那么你实际上就实现了一个脚本语言。而执行脚本语言的过程,就是遍历AST的过程。当然,在后面的课程中,我也会带你实际实现一个脚本语言。 + +好了,你已经知道了AST的作用,那么怎样写程序构造它呢? + +一种非常直观的构造思路是自上而下进行分析。首先构造根节点,代表整个程序,之后向下扫描Token串,构建它的子节点。当它看到一个int类型的Token时,知道这儿遇到了一个变量声明语句,于是建立一个“变量声明”节点;接着遇到age,建立一个子节点,这是第一个变量;之后遇到=,意味着这个变量有初始化值,那么建立一个初始化的子节点;最后,遇到“字面量”,其值是45。 + +这样,一棵子树就扫描完毕了。程序退回到根节点,开始构建根节点的第二个子节点。这样递归地扫描,直到构建起一棵完整的树。 + + + +这个算法就是非常常用的递归下降算法(Recursive Descent Parsing)。是不是很简单?你完全可以动手写出来。 + +递归下降算法是一种自顶向下的算法,与之对应的,还有自底向上的算法。这个算法会先将最下面的叶子节点识别出来,然后再组装上一级节点。有点儿像搭积木,我们总是先构造出小的单元,然后再组装成更大的单元。原理就是这么简单。 + +也许你会想,除了手写,有没有偷懒的、更省事的方法呢?多一些时间去陪家人总不是坏事。 + +你现在已经有了一定的经验,大可以去找找看有没有现成的工具,比如Yacc(或GNU的版本,Bison)、Antlr、JavaCC等。实际上,你可以在维基百科里找到一个挺大的清单,我把它放到了CSDN的博客上,其中对各种工具的特性做了比较。 + +顺理成章地,你还能找到很多开源的语法规则文件,改一改,就能用工具生成你的语法分析器。 + +很多同学其实已经做过语法解析的工作,比如编写一个自定义公式的功能,对公式的解析就是语法分析过程。另一个例子是分析日志文件等文本文件,对每行日志的解析,本质上也是语法分析过程。解析用XML、JSON写的各种配置文件、模型定义文件的过程,其实本质也是语法分析过程,甚至还包含了语义分析工作。 + +语义分析(Semantic Analysis) + +好了,讲完了词法分析、语法分析,编译器接下来做的工作是语义分析。说白了,语义分析就是要让计算机理解我们的真实意图,把一些模棱两可的地方消除掉。 + +以“You can never drink too much water.” 这句话为例。它的确切含义是什么?是“你不能喝太多水”,还是“你喝多少水都不嫌多”?实际上,这两种解释都是可以的,我们只有联系上下文才能知道它的准确含义。 + +你可能会觉得理解自然语言的含义已经很难了,所以计算机语言的语义分析也一定很难。其实语义分析没那么复杂,因为计算机语言的语义一般可以表达为一些规则,你只要检查是否符合这些规则就行了。比如: + + +某个表达式的计算结果是什么数据类型?如果有数据类型不匹配的情况,是否要做自动转换? + +如果在一个代码块的内部和外部有相同名称的变量,我在执行的时候到底用哪个? 就像“我喜欢又聪明又勇敢的你”中的“你”,到底指的是谁,需要明确。 + +在同一个作用域内,不允许有两个名称相同的变量,这是唯一性检查。你不能刚声明一个变量a,紧接着又声明同样名称的一个变量a,这就不允许了。 + + +语义分析基本上就是做这样的事情,也就是根据语义规则进行分析判断。 + +语义分析工作的某些成果,会作为属性标注在抽象语法树上,比如在age这个标识符节点和45这个字面量节点上,都会标识它的数据类型是int型的。 + +在这个树上还可以标记很多属性,有些属性是在之前的两个阶段就被标注上了,比如所处的源代码行号,这一行的第几个字符。这样,在编译程序报错的时候,就可以比较清楚地了解出错的位置。 + +做了这些属性标注以后,编译器在后面就可以依据这些信息生成目标代码了,我们在编译技术的后端部分会去讲。 + +课程小结 + +讲完语义分析,本节课也就告一段落了,我来总结一下本节课的重点内容: + + +词法分析是把程序分割成一个个Token的过程,可以通过构造有限自动机来实现。 + +语法分析是把程序的结构识别出来,并形成一棵便于由计算机处理的抽象语法树。可以用递归下降的算法来实现。 + +语义分析是消除语义模糊,生成一些属性信息,让计算机能够依据这些信息生成目标代码。 + + +我想让你知道,上述编译过程其实跟你的实际工作息息相关。比如,词法分析就是你工作中使用正则表达式的过程。而语法分析在你解析文本文件、配置文件、模型定义文件,或者做自定义公式功能的时候都会用到。 + +我还想让你知道,编译技术并没有那么难,它的核心原理是很容易理解的。学习之后,你能很快上手,如果善用一些辅助生成工具会更省事。所以,我希望你通过学习这篇文章,已经破除了一些心理障碍,并跃跃欲试,想要动手做点儿什么了! + +一课一思 + +你有没有觉得,刚开始学编译原理中的某些知识点时特别难,一旦学通了以后,就会发出类似的感慨:“啊!原来就是这么回事!”欢迎在留言区与我分享你的感慨时刻。另外,你是否尝试实现过一个编译器,还颇有一些心得?可以在留言区与大家一起交流。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/02正则文法和有限自动机:纯手工打造词法分析器.md b/专栏/编译原理之美/02正则文法和有限自动机:纯手工打造词法分析器.md new file mode 100644 index 0000000..2c2bae2 --- /dev/null +++ b/专栏/编译原理之美/02正则文法和有限自动机:纯手工打造词法分析器.md @@ -0,0 +1,290 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 02 正则文法和有限自动机:纯手工打造词法分析器 + 上一讲,我提到词法分析的工作是将一个长长的字符串识别出一个个的单词,这一个个单词就是Token。而且词法分析的工作是一边读取一边识别字符串的,不是把字符串都读到内存再识别。你在听一位朋友讲话的时候,其实也是同样的过程,一边听,一边提取信息。 + +那么问题来了,字符串是一连串的字符形成的,怎么把它断开成一个个的Token呢?分割的依据是什么呢?本节课,我会通过讲解正则表达式(Regular Expression)和有限自动机的知识带你解决这个问题。 + +其实,我们手工打造词法分析器的过程,就是写出正则表达式,画出有限自动机的图形,然后根据图形直观地写出解析代码的过程。而我今天带你写的词法分析器,能够分析以下3个程序语句: + + +age >= 45 +int age = 40 +2+3*5 + + +它们分别是关系表达式、变量声明和初始化语句,以及算术表达式。 + +接下来,我们先来解析一下“age >= 45”这个关系表达式,这样你就能理解有限自动机的概念,知道它是做词法解析的核心机制了。 + +解析 age >= 45 + +在“01 | 理解代码:编译器的前端技术”里,我举了一个词法分析的例子,并且提出词法分析要用到有限自动机。当时,我画了这样一个示意图: + + + +我们来描述一下标识符、比较操作符和数字字面量这三种Token的词法规则。 + + +标识符:第一个字符必须是字母,后面的字符可以是字母或数字。 +比较操作符:>和>=(其他比较操作符暂时忽略)。 +数字字面量:全部由数字构成(像带小数点的浮点数,暂时不管它)。 + + +我们就是依据这样的规则,来构造有限自动机的。这样,词法分析程序在遇到age、>=和45时,会分别识别成标识符、比较操作符和数字字面量。不过上面的图只是一个简化的示意图,一个严格意义上的有限自动机是下面这种画法: + + + +我来解释一下上图的5种状态。 + +1.初始状态:刚开始启动词法分析的时候,程序所处的状态。 + +2.标识符状态:在初始状态时,当第一个字符是字母的时候,迁移到状态2。当后续字符是字母和数字时,保留在状态2。如果不是,就离开状态2,写下该Token,回到初始状态。 + +3.大于操作符(GT):在初始状态时,当第一个字符是>时,进入这个状态。它是比较操作符的一种情况。 + +4.大于等于操作符(GE):如果状态3的下一个字符是=,就进入状态4,变成>=。它也是比较操作符的一种情况。 + +5.数字字面量:在初始状态时,下一个字符是数字,进入这个状态。如果后续仍是数字,就保持在状态5。 + +这里我想补充一下,你能看到上图中的圆圈有单线的也有双线的。双线的意思是这个状态已经是一个合法的Token了,单线的意思是这个状态还是临时状态。 + +按照这5种状态迁移过程,你很容易编成程序(我用Java写了代码示例,你可以用自己熟悉的语言编写)。我们先从状态1开始,在遇到不同的字符时,分别进入2、3、5三个状态: + +DfaState newState = DfaState.Initial; +if (isAlpha(ch)) { //第一个字符是字母 + newState = DfaState.Id; //进入Id状态 + token.type = TokenType.Identifier; + tokenText.append(ch); +} else if (isDigit(ch)) { //第一个字符是数字 + newState = DfaState.IntLiteral; + token.type = TokenType.IntLiteral; + tokenText.append(ch); +} else if (ch == '>') { //第一个字符是> + newState = DfaState.GT; + token.type = TokenType.GT; + tokenText.append(ch); +} + + +上面的代码中,我用Java中的枚举(enum)类型定义了一些枚举值来代表不同的状态,让代码更容易读。 + +其中Token是自定义的一个数据结构,它有两个主要的属性:一个是“type”,就是Token的类型,它用的也是一个枚举类型的值;一个是“text”,也就是这个Token的文本值。 + +我们接着处理进入2、3、5三个状态之后的状态迁移过程: + +case Initial: + state = initToken(ch); //重新确定后续状态 + break; +case Id: + if (isAlpha(ch) || isDigit(ch)) { + tokenText.append(ch); //保持标识符状态 + } else { + state = initToken(ch); //退出标识符状态,并保存Token + } + break; +case GT: + if (ch == '=') { + token.type = TokenType.GE; //转换成GE + state = DfaState.GE; + tokenText.append(ch); + } else { + state = initToken(ch); //退出GT状态,并保存Token + } + break; +case GE: + state = initToken(ch); //退出当前状态,并保存Token + break; +case IntLiteral: + if (isDigit(ch)) { + tokenText.append(ch); //继续保持在数字字面量状态 + } else { + state = initToken(ch); //退出当前状态,并保存Token + } + break; + + +运行这个示例程序,你就会成功地解析类似“age >= 45”这样的程序语句。不过,你可以先根据我的讲解自己实现一下,然后再去参考这个示例程序。 + +示例程序的输出如下,其中第一列是Token的类型,第二列是Token的文本值: + +Identifier age +GE >= +IntLiteral 45 + + +上面的例子虽然简单,但其实已经讲清楚了词法原理,就是依据构造好的有限自动机,在不同的状态中迁移,从而解析出Token来。你只要再扩展这个有限自动机,增加里面的状态和迁移路线,就可以逐步实现一个完整的词法分析器了。 + +初识正则表达式 + +但是,这里存在一个问题。我们在描述词法规则时用了自然语言。比如,在描述标识符的规则时,我们是这样表达的: + + +第一个字符必须是字母,后面的字符可以是字母或数字。 + + +这样描述规则并不精确,我们需要换一种严谨的表达方式,这种方式就是正则表达式。 + +上面的例子涉及了4种Token,这4种Token用正则表达式表达,是下面的样子: + +Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])* +IntLiteral: [0-9]+ +GT : '>' +GE : '>=' + + +我先来解释一下这几个规则中用到的一些符号: + + + +需要注意的是,不同语言的标识符、整型字面量的规则可能是不同的。比如,有的语言可以允许用Unicode作为标识符,也就是说变量名称可以是中文的。还有的语言规定,十进制数字字面量的第一位不能是0。这时候正则表达式会有不同的写法,对应的有限自动机自然也不同。而且,不同工具的正则表达式写法会略有不同,但大致是差不多的。 + +我在本节课讲正则表达式,主要是为了让词法规则更为严谨,当然了,也是为后面的内容做铺垫。在后面的课程中,我会带你用工具生成词法分析器,而工具读取的就是用正则表达式描述的词法规则。到时候,我们会把所有常用的词法都用正则表达式描述出来。 + +不过在这之前,如果你想主动了解更完整的正则表达式规则,完全可以参考自己所采用的正则表达式工具的文档。比如,Java的正则式表达式工具在java.util.regex包中,在其Javadoc中有详细的规则说明。 + +解析int age = 40,处理标识符和关键字规则的冲突 + +说完正则表达式,我们接着去处理其他词法,比如解析“int age = 40”这个语句,以这个语句为例研究一下词法分析中会遇到的问题:多个规则之间的冲突。 + +如果我们把这个语句涉及的词法规则用正则表达式写出来,是下面这个样子: + +Int: 'int' +Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])* +Assignment : '=' + + +这时候,你可能会发现这样一个问题:int这个关键字,与标识符很相似,都是以字母开头,后面跟着其他字母。 + +换句话说,int这个字符串,既符合标识符的规则,又符合int这个关键字的规则,这两个规则发生了重叠。这样就起冲突了,我们扫描字符串的时候,到底该用哪个规则呢? + +当然,我们心里知道,int这个关键字的规则,比标识符的规则优先级高。普通的标识符是不允许跟这些关键字重名的。 + +在这里,我们来回顾一下:什么是关键字? + +关键字是语言设计中作为语法要素的词汇,例如表示数据类型的int、char,表示程序结构的while、if,表述特殊数据取值的null、NAN等。 + +除了关键字,还有一些词汇叫保留字。保留字在当前的语言设计中还没用到,但是保留下来,因为将来会用到。我们命名自己的变量、类名称,不可以用到跟关键字和保留字相同的字符串。那么我们在词法分析器中,如何把关键字和保留字跟标识符区分开呢? + +以“int age = 40”为例,我们把有限自动机修改成下面的样子,借此解决关键字和标识符的冲突。 + + + +这个思路其实很简单。在识别普通的标识符之前,你先看看它是关键字还是保留字就可以了。具体做法是: + + +当第一个字符是i的时候,我们让它进入一个特殊的状态。接下来,如果它遇到n和t,就进入状态4。但这还没有结束,如果后续的字符还有其他的字母和数字,它又变成了普通的标识符。比如,我们可以声明一个intA(int和A是连着的)这样的变量,而不会跟int关键字冲突。 + + +相应的代码也修改一下,文稿里的第一段代码要改成: + +if (isAlpha(ch)) { + if (ch == 'i') { + newState = DfaState.Id_int1; //对字符i特殊处理 + } else { + newState = DfaState.Id; + } + ... //后续代码 +} + + +第二段代码要增加下面的语句: + +case Id_int1: + if (ch == 'n') { + state = DfaState.Id_int2; + tokenText.append(ch); + } + else if (isDigit(ch) || isAlpha(ch)){ + state = DfaState.Id; //切换回Id状态 + tokenText.append(ch); + } + else { + state = initToken(ch); + } + break; +case Id_int2: + if (ch == 't') { + state = DfaState.Id_int3; + tokenText.append(ch); + } + else if (isDigit(ch) || isAlpha(ch)){ + state = DfaState.Id; //切换回Id状态 + tokenText.append(ch); + } + else { + state = initToken(ch); + } + break; +case Id_int3: + if (isBlank(ch)) { + token.type = TokenType.Int; + state = initToken(ch); + } + else{ + state = DfaState.Id; //切换回Id状态 + tokenText.append(ch); + } + break; + + +接着,我们运行示例代码,就会输出下面的信息: + +Int int +Identifier age +Assignment = +IntLiteral 45 + + +而当你试着解析“intA = 10”程序的时候,会把intA解析成一个标识符。输出如下: + +Identifier intA +Assignment = +IntLiteral 10 + + +解析算术表达式 + +解析完“int age = 40”之后,我们再按照上面的方法增加一些规则,这样就能处理算术表达式,例如“2+3*5”。 增加的词法规则如下: + +Plus : '+' +Minus : '-' +Star : '*' +Slash : '/' + + +然后再修改一下有限自动机和代码,就能解析“2+3*5”了,会得到下面的输出: + +IntLiteral 2 +Plus + +IntLiteral 3 +Star * +IntLiteral 5 + + +好了,现在我们已经能解析不少词法了,之后的课程里,我会带你实现一个公式计算器,所以在这里要先准备好所需要的词法分析功能。 + +课程小结 + +本节课,我们实现了一个简单的词法分析器。你可以看到,要实现一个词法分析器,首先需要写出每个词法的正则表达式,并画出有限自动机,之后,只要用代码表示这种状态迁移过程就可以了。 + +我们总是说理解原理以后,实现并不困难。今天的分享,你一定有所共鸣。 + +反之,如果你在编程工作中遇到困难,往往是因为不清楚原理,没有将原理吃透。而这门课就是要帮助你真正吃透编译技术中的几个核心原理,让你将知识应用到实际工作中,解决工作中遇到的困难。 + +小试了词法分析器之后,在下一讲,我会带你手工打造一下语法分析器,并实现一个公式计算器的功能。 + +一课一思 + +很多同学已经用到过正则表达式,这是学计算机必懂的知识点,十分有用。正则表达式工具其实就可以看做一个通用的词法分析器。那么你都用正则表达式功能做过哪些事情?有没有发现一些软件工具因为支持正则表达式而变得特别强大的情况呢?可以在留言区与大家一起交流。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +另外,为了便于你更好地学习,我将本节课的示例程序放到了GitHub上,你可以看一下。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/03语法分析(一):纯手工打造公式计算器.md b/专栏/编译原理之美/03语法分析(一):纯手工打造公式计算器.md new file mode 100644 index 0000000..0e91768 --- /dev/null +++ b/专栏/编译原理之美/03语法分析(一):纯手工打造公式计算器.md @@ -0,0 +1,314 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 03 语法分析(一):纯手工打造公式计算器 + 我想你应该知道,公式是Excel电子表格软件的灵魂和核心。除此之外,在HR软件中,可以用公式自定义工资。而且,如果你要开发一款通用报表软件,也会大量用到自定义公式来计算报表上显示的数据。总而言之,很多高级一点儿的软件,都会用到自定义公式功能。 + +既然公式功能如此常见和重要,我们不妨实现一个公式计算器,给自己的软件添加自定义公式功能吧! + +本节课将继续“手工打造”之旅,让你纯手工实现一个公式计算器,借此掌握语法分析的原理和递归下降算法(Recursive Descent Parsing),并初步了解上下文无关文法(Context-free Grammar,CFG)。 + +我所举例的公式计算器支持加减乘除算术运算,比如支持“2 + 3 * 5”的运算。 + +在学习语法分析时,我们习惯把上面的公式称为表达式。这个表达式看上去很简单,但你能借此学到很多语法分析的原理,例如左递归、优先级和结合性等问题。 + +当然了,要实现上面的表达式,你必须能分析它的语法。不过在此之前,我想先带你解析一下变量声明语句的语法,以便让你循序渐进地掌握语法分析。 + +解析变量声明语句:理解“下降”的含义 + +在“01 | 理解代码:编译器的前端技术”里,我提到语法分析的结果是生成AST。算法分为自顶向下和自底向上算法,其中,递归下降算法是一种常见的自顶向下算法。 + +与此同时,我给出了一个简单的代码示例,也针对“int age = 45”这个语句,画了一个语法分析算法的示意图: + + + +我们首先把变量声明语句的规则,用形式化的方法表达一下。它的左边是一个非终结符(Non-terminal)。右边是它的产生式(Production Rule)。在语法解析的过程中,左边会被右边替代。如果替代之后还有非终结符,那么继续这个替代过程,直到最后全部都是终结符(Terminal),也就是Token。只有终结符才可以成为AST的叶子节点。这个过程,也叫做推导(Derivation)过程: + +intDeclaration : Int Identifier ('=' additiveExpression)?; + + +你可以看到,int类型变量的声明,需要有一个Int型的Token,加一个变量标识符,后面跟一个可选的赋值表达式。我们把上面的文法翻译成程序语句,伪代码如下: + +//伪代码 +MatchIntDeclare(){ + MatchToken(Int); //匹配Int关键字 + MatchIdentifier(); //匹配标识符 + MatchToken(equal); //匹配等号 + MatchExpression(); //匹配表达式 +} + + +实际代码在SimpleCalculator.java类的IntDeclare()方法中: + +SimpleASTNode node = null; +Token token = tokens.peek(); //预读 +if (token != null && token.getType() == TokenType.Int) { //匹配Int + token = tokens.read(); //消耗掉int + if (tokens.peek().getType() == TokenType.Identifier) { //匹配标识符 + token = tokens.read(); //消耗掉标识符 + //创建当前节点,并把变量名记到AST节点的文本值中, + //这里新建一个变量子节点也是可以的 + node = new SimpleASTNode(ASTNodeType.IntDeclaration, token.getText()); + token = tokens.peek(); //预读 + if (token != null && token.getType() == TokenType.Assignment) { + tokens.read(); //消耗掉等号 + SimpleASTNode child = additive(tokens); //匹配一个表达式 + if (child == null) { + throw new Exception("invalide variable initialization, expecting an expression"); + } + else{ + node.addChild(child); + } + } + } else { + throw new Exception("variable name expected"); + } +} + + +直白地描述一下上面的算法: + + +解析变量声明语句时,我先看第一个Token是不是int。如果是,那我创建一个AST节点,记下int后面的变量名称,然后再看后面是不是跟了初始化部分,也就是等号加一个表达式。我们检查一下有没有等号,有的话,接着再匹配一个表达式。 + + +我们通常会对产生式的每个部分建立一个子节点,比如变量声明语句会建立四个子节点,分别是int关键字、标识符、等号和表达式。后面的工具就是这样严格生成AST的。但是我这里做了简化,只生成了一个子节点,就是表达式子节点。变量名称记到ASTNode的文本值里去了,其他两个子节点没有提供额外的信息,就直接丢弃了。 + +另外,从上面的代码中我们看到,程序是从一个Token的流中顺序读取。代码中的peek()方法是预读,只是读取下一个Token,但并不把它从Token流中移除。在代码中,我们用peek()方法可以预先看一下下一个Token是否是等号,从而知道后面跟着的是不是一个表达式。而read()方法会从Token流中移除,下一个Token变成了当前的Token。 + +这里需要注意的是,通过peek()方法来预读,实际上是对代码的优化,这有点儿预测的意味。我们后面会讲带有预测的自顶向下算法,它能减少回溯的次数。 + +我们把解析变量声明语句和表达式的算法分别写成函数。在语法分析的时候,调用这些函数跟后面的Token串做模式匹配。匹配上了,就返回一个AST节点,否则就返回null。如果中间发现跟语法规则不符,就报编译错误。 + +在这个过程中,上级文法嵌套下级文法,上级的算法调用下级的算法。表现在生成AST中,上级算法生成上级节点,下级算法生成下级节点。这就是“下降”的含义。 + +分析上面的伪代码和程序语句,你可以看到这样的特点:程序结构基本上是跟文法规则同构的。这就是递归下降算法的优点,非常直观。 + +接着说回来,我们继续运行这个示例程序,输出AST: + +Programm Calculator + IntDeclaration age + AssignmentExp = + IntLiteral 45 + + +前面的文法和算法都很简单,这样级别的文法没有超出正则文法。也就是说,并没有超出我们做词法分析时用到的文法。 + +好了,解析完变量声明语句,带你理解了“下降”的含义之后,我们来看看如何用上下文无关文法描述算术表达式。 + +用上下文无关文法描述算术表达式 + +我们解析算术表达式的时候,会遇到更复杂的情况,这时,正则文法不够用,我们必须用上下文无关文法来表达。你可能会问:“正则文法为什么不能表示算术表达式?”别着急,我们来分析一下算术表达式的语法规则。 + +算术表达式要包含加法和乘法两种运算(简单起见,我们把减法与加法等同看待,把除法也跟乘法等同看待),加法和乘法运算有不同的优先级。我们的规则要能匹配各种可能的算术表达式: + + +2+3*5 +2*3+5 +2*3 +…… + + +思考一番之后,我们把规则分成两级:第一级是加法规则,第二级是乘法规则。把乘法规则作为加法规则的子规则,这样在解析形成AST时,乘法节点就一定是加法节点的子节点,从而被优先计算。 + +additiveExpression + : multiplicativeExpression + | additiveExpression Plus multiplicativeExpression + ; + +multiplicativeExpression + : IntLiteral + | multiplicativeExpression Star IntLiteral + ; + + +你看,我们可以通过文法的嵌套,实现对运算优先级的支持。这样我们在解析“2 + 3 * 5”这个算术表达式时会形成类似下面的AST: + + + +如果要计算表达式的值,只需要对根节点求值就可以了。为了完成对根节点的求值,需要对下级节点递归求值,所以我们先完成“3 * 5 = 15”,然后再计算“2 + 15 = 17”。 + +有了这个认知,我们在解析算术表达式的时候,便能拿加法规则去匹配。在加法规则中,会嵌套地匹配乘法规则。我们通过文法的嵌套,实现了计算的优先级。 + +应该注意的是,加法规则中还递归地又引用了加法规则。通过这种递归的定义,我们能展开、形成所有各种可能的算术表达式。比如“2+3*5” 的推导过程: + +-->additiveExpression + multiplicativeExpression +-->multiplicativeExpression + multiplicativeExpression +-->IntLiteral + multiplicativeExpression +-->IntLiteral + multiplicativeExpression * IntLiteral +-->IntLiteral + IntLiteral * IntLiteral + + +这种文法已经没有办法改写成正则文法了,它比正则文法的表达能力更强,叫做“上下文无关文法”。正则文法是上下文无关文法的一个子集。它们的区别呢,就是上下文无关文法允许递归调用,而正则文法不允许。 + +上下文无关的意思是,无论在任何情况下,文法的推导规则都是一样的。比如,在变量声明语句中可能要用到一个算术表达式来做变量初始化,而在其他地方可能也会用到算术表达式。不管在什么地方,算术表达式的语法都一样,都允许用加法和乘法,计算优先级也不变。好在你见到的大多数计算机语言,都能用上下文无关文法来表达它的语法。 + +那有没有上下文相关的情况需要处理呢?也是有的,但那不是语法分析阶段负责的,而是放在语义分析阶段来处理的。 + +解析算术表达式:理解“递归”的含义 + +在讲解上下文无关文法时,我提到了文法的递归调用,你也许会问,是否在算法上也需要递归的调用呢?要不怎么叫做“递归下降算法”呢? + +的确,我们之前的算法只算是用到了“下降”,没有涉及“递归”,现在,我们就来看看如何用递归的算法翻译递归的文法。 + +我们先按照前面说的,把文法直观地翻译成算法。但是,我们遇到麻烦了。这个麻烦就是出现了无穷多次调用的情况。我们来看个例子。 + +为了简单化,我们采用下面这个简化的文法,去掉了乘法的层次: + +additiveExpression + : IntLiteral + | additiveExpression Plus IntLiteral + ; + + +在解析 “2 + 3”这样一个最简单的加法表达式的时候,我们直观地将其翻译成算法,结果出现了如下的情况: + + +首先匹配是不是整型字面量,发现不是; +然后匹配是不是加法表达式,这里是递归调用; +会重复上面两步,无穷无尽。 + + +“additiveExpression Plus multiplicativeExpression”这个文法规则的第一部分就递归地引用了自身,这种情况叫做左递归。通过上面的分析,我们知道左递归是递归下降算法无法处理的,这是递归下降算法最大的问题。 + +怎么解决呢?把“additiveExpression”调换到加号后面怎么样?我们来试一试。 + +additiveExpression + : multiplicativeExpression + | multiplicativeExpression Plus additiveExpression + ; + + +我们接着改写成算法,这个算法确实不会出现无限调用的问题: + +private SimpleASTNode additive(TokenReader tokens) throws Exception { + SimpleASTNode child1 = multiplicative(); //计算第一个子节点 + SimpleASTNode node = child1; //如果没有第二个子节点,就返回这个 + Token token = tokens.peek(); + if (child1 != null && token != null) { + if (token.getType() == TokenType.Plus) { + token = tokens.read(); + SimpleASTNode child2 = additive(); //递归地解析第二个节点 + if (child2 != null) { + node = new SimpleASTNode(ASTNodeType.AdditiveExp, token.getText()); + node.addChild(child1); + node.addChild(child2); + } else { + throw new Exception("invalid additive expression, expecting the right part."); + } + } + } + return node; +} + + +为了便于你理解,我解读一下上面的算法: + + +我们先尝试能否匹配乘法表达式,如果不能,那么这个节点肯定不是加法节点,因为加法表达式的两个产生式都必须首先匹配乘法表达式。遇到这种情况,返回null就可以了,调用者就这次匹配没有成功。如果乘法表达式匹配成功,那就再尝试匹配加号右边的部分,也就是去递归地匹配加法表达式。如果匹配成功,就构造一个加法的ASTNode返回。 + + +同样的,乘法的文法规则也可以做类似的改写: + +multiplicativeExpression + : IntLiteral + | IntLiteral Star multiplicativeExpression + ; + + +现在我们貌似解决了左递归问题,运行这个算法解析 “2+3*5”,得到下面的AST: + +Programm Calculator + AdditiveExp + + IntLiteral 2 + MulticativeExp * + IntLiteral 3 + IntLiteral 5 + + +是不是看上去一切正常?可如果让这个程序解析“2+3+4”呢? + +Programm Calculator + AdditiveExp + + IntLiteral 2 + AdditiveExp + + IntLiteral 3 + IntLiteral 4 + + +问题是什么呢?计算顺序发生错误了。连续相加的表达式要从左向右计算,这是加法运算的结合性规则。但按照我们生成的AST,变成从右向左了,先计算了“3+4”,然后才跟“2”相加。这可不行! + +为什么产生上面的问题呢?是因为我们修改了文法,把文法中加号左右两边的部分调换了一下。造成的影响是什么呢?你可以推导一下“2+3+4”的解析过程: + + +首先调用乘法表达式匹配函数multiplicative(),成功,返回了一个字面量节点2。 +接着看看右边是否能递归地匹配加法表达式。 +匹配的结果,真的返回了一个加法表达式“3+4”,这个变成了第二个子节点。错误就出在这里了。这样的匹配顺序,“3+4”一定会成为子节点,在求值时被优先计算。 + + +所以,我们前面的方法其实并没有完美地解决左递归,因为它改变了加法运算的结合性规则。那么,我们能否既解决左递归问题,又不产生计算顺序的错误呢?答案是肯定的。不过我们下一讲再来解决它。目前先忍耐一下,凑合着用这个“半吊子”的算法吧。 + +实现表达式求值 + +上面帮助你理解了“递归”的含义,接下来,我要带你实现表达式的求值。其实,要实现一个表达式计算,只需要基于AST做求值运算。这个计算过程比较简单,只需要对这棵树做深度优先的遍历就好了。 + +深度优先的遍历也是一个递归算法。以上文中“2 + 3 * 5”的AST为例看一下。 + + +对表达式的求值,等价于对AST根节点求值。 +首先求左边子节点,算出是2。 +接着对右边子节点求值,这时候需要递归计算下一层。计算完了以后,返回是15(3*5)。 +把左右节点相加,计算出根节点的值17。 + + +代码参见SimpleCalculator.Java中的evaluate()方法。 + +还是以“2+3*5”为例。它的求值过程输出如下,你可以看到求值过程中遍历了整棵树: + + Calculating: AdditiveExp //计算根节点 + Calculating: IntLiteral //计算第一个子节点 + Result: 2 //结果是2 + Calculating: MulticativeExp //递归计算第二个子节点 + Calculating: IntLiteral + Result: 3 + Calculating: IntLiteral + Result: 5 + Result: 15 //忽略递归的细节,得到结果是15 + Result: 17 //根节点的值是17 + + +你可以运行一下示例程序看看输出结果,而且我十分建议你修改表达式,自己做做实验,并试着让表达式不符合语法,看看语法分析程序能不能找出错误来。 + +课程小结 + +今天我们实现了一个简单的公式计算器,尽管简单,相信你已经有了收获。那么我来总结一下今天的重点: + + +初步了解上下文无关文法,知道它能表达主流的计算机语言,以及与正则文法的区别。 +理解递归下降算法中的“下降”和“递归”两个特点。它跟文法规则基本上是同构的,通过文法一定能写出算法。 +通过遍历AST对表达式求值,加深对计算机程序执行机制的理解。 + + +在后面的课程中,我们会在此基础上逐步深化,比如在变量声明中可以使用表达式,在表达式中可以使用变量,例如能够执行像这样的语句: + +int A = 17; +int B = A + 10*2; + + +实现了上述功能以后,这个程序就越来越接近一个简单的脚本解释器了!当然,在此之前,我们还必须解决左递归的问题。所以下一讲,我会带你填掉左递归这个坑。我们学习和工作的过程,就是在不停地挖坑、填坑,你要有信心,只要坚强走过填坑这段路,你的职业生涯将会愈发平坦! + +一课一思 + +递归算法是很好的自顶向下解决问题的方法,是计算机领域的一个核心的思维方式。拥有这种思维方式,可以说是程序员相对于非程序员的一种优势。 + +那么,你是否用递归算法或递归思维解决过工作中或者生活中存在的某些问题?你能否再找一些证据证明一下,哪些语法规则只能用上下文无关文法表达,用正则文法是怎样都写不出来的? 欢迎在留言区和我一起讨论。 + +最后,十分感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +另外,为了便于你更好地学习,我将本节课的示例程序放到了码云和GitHub上,你可以看一下。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/04语法分析(二):解决二元表达式中的难点.md b/专栏/编译原理之美/04语法分析(二):解决二元表达式中的难点.md new file mode 100644 index 0000000..00e5d5f --- /dev/null +++ b/专栏/编译原理之美/04语法分析(二):解决二元表达式中的难点.md @@ -0,0 +1,197 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 04 语法分析(二):解决二元表达式中的难点 + 在“03 | 语法分析(一):纯手工打造公式计算器”中,我们已经初步实现了一个公式计算器。而且你还在这个过程中,直观地获得了写语法分析程序的体验,在一定程度上破除了对语法分析算法的神秘感。 + +当然了,你也遇到了一些问题,比如怎么消除左递归,怎么确保正确的优先级和结合性。所以本节课的主要目的就是解决这几个问题,让你掌握像算术运算这样的二元表达式(Binary Expression)。 + +不过在课程开始之前,我想先带你简单地温习一下什么是左递归(Left Recursive)、优先级(Priority)和结合性(Associativity)。 + +在二元表达式的语法规则中,如果产生式的第一个元素是它自身,那么程序就会无限地递归下去,这种情况就叫做左递归。比如加法表达式的产生式“加法表达式 + 乘法表达式”,就是左递归的。而优先级和结合性则是计算机语言中与表达式有关的核心概念。它们都涉及了语法规则的设计问题。 + +我们要想深入探讨语法规则设计,需要像在词法分析环节一样,先了解如何用形式化的方法表达语法规则。“工欲善其事必先利其器”。熟练地阅读和书写语法规则,是我们在语法分析环节需要掌握的一项基本功。 + +所以本节课我会先带你了解如何写语法规则,然后在此基础上,带你解决上面提到的三个问题。 + +书写语法规则,并进行推导 + +我们已经知道,语法规则是由上下文无关文法表示的,而上下文无关文法是由一组替换规则(又叫产生式)组成的,比如算术表达式的文法规则可以表达成下面这种形式: + +add -> mul | add + mul +mul -> pri | mul * pri +pri -> Id | Num | (add) + + +按照上面的产生式,add可以替换成mul,或者add + mul。这样的替换过程又叫做“推导”。以“2+3*5” 和 “2+3+4”这两个算术表达式为例,这两个算术表达式的推导过程分别如下图所示: + + + +通过上图的推导过程,你可以清楚地看到这两个表达式是怎样生成的。而分析过程中形成的这棵树,其实就是AST。只不过我们手写的算法在生成AST的时候,通常会做一些简化,省略掉中间一些不必要的节点。比如,“add-add-mul-pri-Num”这一条分支,实际手写时会被简化成“add-Num”。其实,简化AST也是优化编译过程的一种手段,如果不做简化,呈现的效果就是上图的样子。 + +那么,上图中两颗树的叶子节点有哪些呢?Num、+和*都是终结符,终结符都是词法分析中产生的Token。而那些非叶子节点,就是非终结符。文法的推导过程,就是把非终结符不断替换的过程,让最后的结果没有非终结符,只有终结符。 + +而在实际应用中,语法规则经常写成下面这种形式: + +add ::= mul | add + mul +mul ::= pri | mul * pri +pri ::= Id | Num | (add) + + +这种写法叫做“巴科斯范式”,简称BNF。Antlr和Yacc这两个工具都用这种写法。为了简化书写,我有时会在课程中把“::=”简化成一个冒号。你看到的时候,知道是什么意思就可以了。 + +你有时还会听到一个术语,叫做扩展巴科斯范式(EBNF)。它跟普通的BNF表达式最大的区别,就是里面会用到类似正则表达式的一些写法。比如下面这个规则中运用了*号,来表示这个部分可以重复0到多次: + +add -> mul (+ mul)* + + +其实这种写法跟标准的BNF写法是等价的,但是更简洁。为什么是等价的呢?因为一个项多次重复,就等价于通过递归来推导。从这里我们还可以得到一个推论:就是上下文无关文法包含了正则文法,比正则文法能做更多的事情。 + +确保正确的优先级 + +掌握了语法规则的写法之后,我们来看看如何用语法规则来保证表达式的优先级。刚刚,我们由加法规则推导到乘法规则,这种方式保证了AST中的乘法节点一定会在加法节点的下层,也就保证了乘法计算优先于加法计算。 + +听到这儿,你一定会想到,我们应该把关系运算(>、=、<)放在加法的上层,逻辑运算(and、or)放在关系运算的上层。的确如此,我们试着将它写出来: + +exp -> or | or = exp +or -> and | or || and +and -> equal | and && equal +equal -> rel | equal == rel | equal != rel +rel -> add | rel > add | rel < add | rel >= add | rel <= add +add -> mul | add + mul | add - mul +mul -> pri | mul * pri | mul / pri + + +这里表达的优先级从低到高是:赋值运算、逻辑运算(or)、逻辑运算(and)、相等比较(equal)、大小比较(rel)、加法运算(add)、乘法运算(mul)和基础表达式(pri)。 + +实际语言中还有更多不同的优先级,比如位运算等。而且优先级是能够改变的,比如我们通常会在语法里通过括号来改变计算的优先级。不过这怎么表达成语法规则呢? + +其实,我们在最低层,也就是优先级最高的基础表达式(pri)这里,用括号把表达式包裹起来,递归地引用表达式就可以了。这样的话,只要在解析表达式的时候遇到括号,那么就知道这个是最优先的。这样的话就实现了优先级的改变: + +pri -> Id | Literal | (exp) + + +了解了这些内容之后,到目前为止,你已经会写整套的表达式规则了,也能让公式计算器支持这些规则了。另外,在使用一门语言的时候,如果你不清楚各种运算确切的优先级,除了查阅常规的资料,你还多了一项新技能,就是阅读这门语言的语法规则文件,这些规则可能就是用BNF或EBNF的写法书写的。 + +弄明白优先级的问题以后,我们再来讨论一下结合性这个问题。 + +确保正确的结合性 + +在上一讲中,我针对算术表达式写的第二个文法是错的,因为它的计算顺序是错的。“2+3+4”这个算术表达式,先计算了“3+4”然后才和“2”相加,计算顺序从右到左,正确的应该是从左往右才对。 + +这就是运算符的结合性问题。什么是结合性呢?同样优先级的运算符是从左到右计算还是从右到左计算叫做结合性。我们常见的加减乘除等算术运算是左结合的,“.”符号也是左结合的。 + +比如“rectangle.center.x” 是先获得长方形(rectangle)的中心点(center),再获得这个点的x坐标。计算顺序是从左向右的。那有没有右结合的例子呢?肯定是有的。赋值运算就是典型的右结合的例子,比如“x = y = 10”。 + +我们再来回顾一下“2+3+4”计算顺序出错的原因。用之前错误的右递归的文法解析这个表达式形成的简化版本的AST如下: + + + +根据这个AST做计算会出现计算顺序的错误。不过如果我们将递归项写在左边,就不会出现这种结合性的错误。于是我们得出一个规律:对于左结合的运算符,递归项要放在左边;而右结合的运算符,递归项放在右边。 + +所以你能看到,我们在写加法表达式的规则的时候,是这样写的: + +add -> mul | add + mul + + +这是我们犯错之后所学到的知识。那么问题来了,大多数二元运算都是左结合的,那岂不是都要面临左递归问题?不用担心,我们可以通过改写左递归的文法,解决这个问题。 + +消除左递归 + +我提到过左递归的情况,也指出递归下降算法不能处理左递归。这里我要补充一点,并不是所有的算法都不能处理左递归,对于另外一些算法,左递归是没有问题的,比如LR算法。 + +消除左递归,用一个标准的方法,就能够把左递归文法改写成非左递归的文法。以加法表达式规则为例,原来的文法是“add -> add + mul”,现在我们改写成: + +add -> mul add' +add' -> + mul add' | ε + + +文法中,ε(读作epsilon)是空集的意思。接下来,我们用刚刚改写的规则再次推导一下 “2+3+4”这个表达式,得到了下图中左边的结果: + + + +左边的分析树是推导后的结果。问题是,由于add’的规则是右递归的,如果用标准的递归下降算法,我们会跟上一讲一样,又会出现运算符结合性的错误。我们期待的AST是右边的那棵,它的结合性才是正确的。那么有没有解决办法呢? + +答案是有的。我们仔细分析一下上面语法规则的推导过程。只有第一步是按照add规则推导,之后都是按照add’规则推导,一直到结束。 + +如果用EBNF方式表达,也就是允许用*号和+号表示重复,上面两条规则可以合并成一条: + +add -> mul (+ mul)* + + +写成这样有什么好处呢?能够优化我们写算法的思路。对于(+ mul)*这部分,我们其实可以写成一个循环,而不是一次次的递归调用。伪代码如下: + +mul(); +while(next token is +){ + mul() + createAddNode +} + + +我们扩展一下话题。在研究递归函数的时候,有一个概念叫做尾递归,尾递归函数的最后一句是递归地调用自身。 + +编译程序通常都会把尾递归转化为一个循环语句,使用的原理跟上面的伪代码是一样的。相对于递归调用来说,循环语句对系统资源的开销更低,因此,把尾递归转化为循环语句也是一种编译优化技术。 + +好了,我们继续左递归的话题。现在我们知道怎么写这种左递归的算法了,大概是下面的样子: + +private SimpleASTNode additive(TokenReader tokens) throws Exception { + SimpleASTNode child1 = multiplicative(tokens); //应用add规则 + SimpleASTNode node = child1; + if (child1 != null) { + while (true) { //循环应用add' + Token token = tokens.peek(); + if (token != null && (token.getType() == TokenType.Plus || token.getType() == TokenType.Minus)) { + token = tokens.read(); //读出加号 + SimpleASTNode child2 = multiplicative(tokens); //计算下级节点 + node = new SimpleASTNode(ASTNodeType.Additive, token.getText()); + node.addChild(child1); //注意,新节点在顶层,保证正确的结合性 + node.addChild(child2); + child1 = node; + } else { + break; + } + } + } + return node; +} + + +修改完后,再次运行语法分析器分析“2+3+4+5”,会得到正确的AST: + +Programm Calculator + AdditiveExp + + AdditiveExp + + AdditiveExp + + IntLiteral 2 + IntLiteral 3 + IntLiteral 4 + IntLiteral 5 + + +这样,我们就把左递归问题解决了。左递归问题是我们用递归下降算法写语法分析器遇到的最大的一只“拦路虎”。解决这只“拦路虎”以后,你的道路将会越来越平坦。 + +课程小结 + +今天我们针对优先级、结合性和左递归这三个问题做了更系统的研究。我来带你梳理一下本节课的重点知识: + + +优先级是通过在语法推导中的层次来决定的,优先级越低的,越先尝试推导。 +结合性是跟左递归还是右递归有关的,左递归导致左结合,右递归导致右结合。 +左递归可以通过改写语法规则来避免,而改写后的语法又可以表达成简洁的EBNF格式,从而启发我们用循环代替右递归。 + + +为了研究和解决这三个问题,我们还特别介绍了语法规则的产生式写法以及BNF、EBNF写法。在后面的课程中我们会不断用到这个技能,还会用工具来生成语法分析器,我们提供给工具的就是书写良好的语法规则。 + +到目前为止,你已经闯过了语法分析中比较难的一关。再增加一些其他的语法,你就可以实现出一个简单的脚本语言了! + +一课一思 + +本节课提到了语法的优先级、结合性。那么,你能否梳理一下你熟悉的语言的运算优先级?你能说出更多的左结合、右结合的例子吗?可以在留言区与大家一起交流。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/05语法分析(三):实现一门简单的脚本语言.md b/专栏/编译原理之美/05语法分析(三):实现一门简单的脚本语言.md new file mode 100644 index 0000000..93bedc6 --- /dev/null +++ b/专栏/编译原理之美/05语法分析(三):实现一门简单的脚本语言.md @@ -0,0 +1,272 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 05 语法分析(三):实现一门简单的脚本语言 + 前两节课结束后,我们已经掌握了表达式的解析,并通过一个简单的解释器实现了公式的计算。但这个解释器还是比较简单的,看上去还不大像一门语言。那么如何让它支持更多的功能,更像一门脚本语言呢?本节课,我会带你寻找答案。 + +我将继续带你实现一些功能,比如: + + +支持变量声明和初始化语句,就像“int age” “int age = 45”和“int age = 17+8+20”; +支持赋值语句“age = 45”; +在表达式中可以使用变量,例如“age + 10 *2”; +实现一个命令行终端,能够读取输入的语句并输出结果。 + + +实现这些功能之后,我们的成果会更像一个脚本解释器。而且在这个过程中,我还会带你巩固语法分析中的递归下降算法,和你一起讨论“回溯”这个特征,让你对递归下降算法的特征理解得更加全面。 + +不过,为了实现这些新的语法,我们首先要把它们用语法规则描述出来。 + +增加所需要的语法规则 + +首先,一门脚本语言是要支持语句的,比如变量声明语句、赋值语句等等。单独一个表达式,也可以视为语句,叫做“表达式语句”。你在终端里输入2+3;,就能回显出5来,这就是表达式作为一个语句在执行。按照我们的语法,无非是在表达式后面多了个分号而已。C语言和Java都会采用分号作为语句结尾的标识,我们也可以这样写。 + +我们用扩展巴科斯范式(EBNF)写出下面的语法规则: + +programm: statement+; + +statement +: intDeclaration +| expressionStatement +| assignmentStatement +; + + +变量声明语句以int开头,后面跟标识符,然后有可选的初始化部分,也就是一个等号和一个表达式,最后再加分号: + +intDeclaration : 'int' Id ( '=' additiveExpression)? ';'; + + +表达式语句目前只支持加法表达式,未来可以加其他的表达式,比如条件表达式,它后面同样加分号: + +expressionStatement : additiveExpression ';'; + + +赋值语句是标识符后面跟着等号和一个表达式,再加分号: + +assignmentStatement : Identifier '=' additiveExpression ';'; + + +为了在表达式中可以使用变量,我们还需要把primaryExpression改写,除了包含整型字面量以外,还要包含标识符和用括号括起来的表达式: + +primaryExpression : Identifier| IntLiteral | '(' additiveExpression ')'; + + +这样,我们就把想实现的语法特性,都用语法规则表达出来了。接下来,我们就一步一步实现这些特性。 + +让脚本语言支持变量 + +之前实现的公式计算器只支持了数字字面量的运算,如果能在表达式中用上变量,会更有用,比如能够执行下面两句: + +int age = 45; +age + 10 * 2; + + +这两个语句里面的语法特性包含了变量声明、给变量赋值,以及在表达式里引用变量。为了给变量赋值,我们必须在脚本语言的解释器中开辟一个存储区,记录不同的变量和它们的值: + +private HashMap variables = new HashMap(); + + +我们简单地用了一个HashMap作为变量存储区。在变量声明语句和赋值语句里,都可以修改这个变量存储区中的数据,而获取变量值可以采用下面的代码: + +if (variables.containsKey(varName)) { + Integer value = variables.get(varName); //获取变量值 + if (value != null) { + result = value; //设置返回值 + } else { //有这个变量,没有值 + throw new Exception("variable " + varName + " has not been set any value"); + } +} +else{ //没有这个变量。 + throw new Exception("unknown variable: " + varName); +} + + +通过这样的一个简单的存储机制,我们就能支持变量了。当然,这个存储机制可能过于简单了,我们后面讲到作用域的时候,这么简单的存储机制根本不够。不过目前我们先这么用着,以后再考虑改进它。 + +解析赋值语句 + +接下来,我们来解析赋值语句,例如“age = age + 10 * 2;”: + +private SimpleASTNode assignmentStatement(TokenReader tokens) throws Exception { + SimpleASTNode node = null; + Token token = tokens.peek(); //预读,看看下面是不是标识符 + if (token != null && token.getType() == TokenType.Identifier) { + token = tokens.read(); //读入标识符 + node = new SimpleASTNode(ASTNodeType.AssignmentStmt, token.getText()); + token = tokens.peek(); //预读,看看下面是不是等号 + if (token != null && token.getType() == TokenType.Assignment) { + tokens.read(); //取出等号 + SimpleASTNode child = additive(tokens); + if (child == null) { //出错,等号右面没有一个合法的表达式 + throw new Exception("invalide assignment statement, expecting an expression"); + } + else{ + node.addChild(child); //添加子节点 + token = tokens.peek(); //预读,看看后面是不是分号 + if (token != null && token.getType() == TokenType.SemiColon) { + tokens.read(); //消耗掉这个分号 + + } else { //报错,缺少分号 + throw new Exception("invalid statement, expecting semicolon"); + } + } + } + else { + tokens.unread(); //回溯,吐出之前消化掉的标识符 + node = null; + } + } + return node; +} + + +为了方便你理解,我来解读一下上面这段代码的逻辑: + + +我们既然想要匹配一个赋值语句,那么首先应该看看第一个Token是不是标识符。如果不是,那么就返回null,匹配失败。如果第一个Token确实是标识符,我们就把它消耗掉,接着看后面跟着的是不是等号。如果不是等号,那证明我们这个不是一个赋值语句,可能是一个表达式什么的。那么我们就要回退刚才消耗掉的Token,就像什么都没有发生过一样,并且返回null。回退的时候调用的方法就是unread()。- +如果后面跟着的确实是等号,那么在继续看后面是不是一个表达式,表达式后面跟着的是不是分号。如果不是,就报错就好了。这样就完成了对赋值语句的解析。 + + +利用上面的代码,我们还可以改造一下变量声明语句中对变量初始化的部分,让它在初始化的时候支持表达式,因为这个地方跟赋值语句很像,例如“int newAge = age + 10 * 2;”。 + +理解递归下降算法中的回溯 + +不知道你有没有发现,我在设计语法规则的过程中,其实故意设计了一个陷阱,这个陷阱能帮我们更好地理解递归下降算法的一个特点:回溯。理解这个特点能帮助你更清晰地理解递归下降算法的执行过程,从而再去想办法优化它。 + +考虑一下age = 45;这个语句。肉眼看过去,你马上知道它是个赋值语句,但是当我们用算法去做模式匹配时,就会发生一些特殊的情况。看一下我们对statement语句的定义: + +statement +: intDeclaration +| expressionStatement +| assignmentStatement +; + + +我们首先尝试intDeclaration,但是age = 45;语句不是以int开头的,所以这个尝试会返回null。然后我们接着尝试expressionStatement,看一眼下面的算法: + +private SimpleASTNode expressionStatement() throws Exception { + int pos = tokens.getPosition(); //记下初始位置 + SimpleASTNode node = additive(); //匹配加法规则 + if (node != null) { + Token token = tokens.peek(); + if (token != null && token.getType() == TokenType.SemiColon) { //要求一定以分号结尾 + tokens.read(); + } else { + node = null; + tokens.setPosition(pos); // 回溯 + } + } + return node; + } + + +出现了什么情况呢?age = 45;语句最左边是一个标识符。根据我们的语法规则,标识符是一个合法的addtiveExpresion,因此additive()函数返回一个非空值。接下来,后面应该扫描到一个分号才对,但是显然不是,标识符后面跟的是等号,这证明模式匹配失败。 + +失败了该怎么办呢?我们的算法一定要把Token流的指针拨回到原来的位置,就像一切都没发生过一样。因为我们不知道addtive()这个函数往下尝试了多少步,因为它可能是一个很复杂的表达式,消耗掉了很多个Token,所以我们必须记下算法开始时候的位置,并在失败时回到这个位置。尝试一个规则不成功之后,恢复到原样,再去尝试另外的规则,这个现象就叫做“回溯”。 + +因为有可能需要回溯,所以递归下降算法有时会做一些无用功。在assignmentStatement的算法中,我们就通过unread(),回溯了一个Token。而在expressionStatement中,我们不确定要回溯几步,只好提前记下初始位置。匹配expressionStatement失败后,算法去尝试匹配assignmentStatement。这次获得了成功。 + +试探和回溯的过程,是递归下降算法的一个典型特征。通过上面的例子,你应该对这个典型特征有了更清晰的理解。递归下降算法虽然简单,但它通过试探和回溯,却总是可以把正确的语法匹配出来,这就是它的强大之处。当然,缺点是回溯会拉低一点儿效率。但我们可以在这个基础上进行改进和优化,实现带有预测分析的递归下降,以及非递归的预测分析。有了对递归下降算法的清晰理解,我们去学习其他的语法分析算法的时候,也会理解得更快。 + +我们接着再讲回溯牵扯出的另一个问题:什么时候该回溯,什么时候该提示语法错误? + +大家在阅读示例代码的过程中,应该发现里面有一些错误处理的代码,并抛出了异常。比如在赋值语句中,如果等号后面没有成功匹配一个加法表达式,我们认为这个语法是错的。因为在我们的语法中,等号后面只能跟表达式,没有别的可能性。 + +token = tokens.read(); //读出等号 +node = additive(); //匹配一个加法表达式 +if (node == null) { + //等号右边一定需要有另一个表达式 + throw new Exception("invalide assignment expression, expecting an additive expression"); +} + + +你可能会意识到一个问题,当我们在算法中匹配不成功的时候,我们前面说的是应该回溯呀,应该再去尝试其他可能性呀,为什么在这里报错了呢?换句话说,什么时候该回溯,什么时候该提示这里发生了语法错误呢? + +其实这两种方法最后的结果是一样的。我们提示语法错误的时候,是说我们知道已经没有其他可能的匹配选项了,不需要浪费时间去回溯。就比如,在我们的语法中,等号后面必然跟表达式,否则就一定是语法错误。你在这里不报语法错误,等试探完其他所有选项后,还是需要报语法错误。所以说,提前报语法错误,实际上是我们写算法时的一种优化。 + +在写编译程序的时候,我们不仅仅要能够解析正确的语法,还要尽可能针对语法错误提供友好的提示,帮助用户迅速定位错误。错误定位越是准确、提示越是友好,我们就越喜欢它。 + +好了,到目前为止,已经能够能够处理几种不同的语句,如变量声明语句,赋值语句、表达式语句,那么我们把所有这些成果放到一起,来体会一下使用自己的脚本语言的乐趣吧! + +我们需要一个交互式的界面来输入程序,并执行程序,这个交互式的界面就叫做REPL。 + +实现一个简单的REPL + +脚本语言一般都会提供一个命令行窗口,让你输入一条一条的语句,马上解释执行它,并得到输出结果,比如Node.js、Python等都提供了这样的界面。这个输入、执行、打印的循环过程就叫做REPL(Read-Eval-Print Loop)。你可以在REPL中迅速试验各种语句,REPL即时反馈的特征会让你乐趣无穷。所以,即使是非常资深的程序员,也会经常用REPL来验证自己的一些思路,它相当于一个语言的PlayGround(游戏场),是个必不可少的工具。 + +在SimpleScript.java中,我们也实现了一个简单的REPL。基本上就是从终端一行行的读入代码,当遇到分号的时候,就解释执行,代码如下: + +SimpleParser parser = new SimpleParser(); +SimpleScript script = new SimpleScript(); +BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); //从终端获取输入 + +String scriptText = ""; +System.out.print("\n>"); //提示符 + +while (true) { //无限循环 + try { + String line = reader.readLine().trim(); //读入一行 + if (line.equals("exit();")) { //硬编码退出条件 + System.out.println("good bye!"); + break; + } + scriptText += line + "\n"; + if (line.endsWith(";")) { //如果没有遇到分号的话,会再读一行 + ASTNode tree = parser.parse(scriptText); //语法解析 + if (verbose) { + parser.dumpAST(tree, ""); + } + + script.evaluate(tree, ""); //对AST求值,并打印 + + System.out.print("\n>"); //显示一个提示符 + + scriptText = ""; + } + + } catch (Exception e) { //如果发现语法错误,报错,然后可以继续执行 + System.out.println(e.getLocalizedMessage()); + System.out.print("\n>"); //提示符 + scriptText = ""; + } +} + + +运行java craft.SimpleScript,你就可以在终端里尝试各种语句了。如果是正确的语句,系统马上会反馈回结果。如果是错误的语句,REPL还能反馈回错误信息,并且能够继续处理下面的语句。我们前面添加的处理语法错误的代码,现在起到了作用!下面是在我电脑上的运行情况: + + + +如果你用java craft.SimpleScript -v启动REPL,则进入Verbose模式,它还会每次打印出AST,你可以尝试一下。 + +退出REPL需要在终端输入ctl+c,或者调用exit()函数。我们目前的解释器并没有支持函数,所以我们是在REPL里硬编码来实现exit()函数的。后面的课程里,我会带你真正地实现函数特性。 + +我希望你能编译一下这个程序,好好的玩一玩它,然后再修改一下源代码,增加一些你感兴趣的特性。我们学习跟打游戏一样,好玩、有趣才能驱动我们不停地学下去,一步步升级打怪。我个人觉得,我们作为软件工程师,拿出一些时间来写点儿有趣的东西作为消遣,乐趣和成就感也是很高的,况且还能提高水平。 + +课程小结 + +本节课我们通过对三种语句的支持,实现了一个简单的脚本语言。REPL运行代码的时候,你会有一种真真实实的感觉,这确实是一门脚本语言了,虽然它没做性能的优化,但你运行的时候也还觉得挺流畅。 + +学完这讲以后,你也能找到了一点感觉:Shell脚本也好,PHP也好,JavaScript也好,Python也好,其实都可以这样写出来。 + +回顾过去几讲,你已经可以分析词法、语法、进行计算,还解决了左递归、优先级、结合性的问题。甚至,你还能处理语法错误,让脚本解释器不会因为输入错误而崩溃。 + +想必这个时候你已经开始相信我的承诺了:每个人都可以写一个编译器。这其实也是我最想达到的效果。相信自己,只要你不给自己设限,不设置玻璃天花板,其实你能够做出很多让自己惊讶、让自己骄傲的成就。 + +收获对自己的信心,掌握编译技术,将是你学习这门课程后最大的收获! + +一课一思 + +本节课,我们设计了一个可能导致递归下降算法中回溯的情景。在你的计算机语言中,有哪些语法在运用递归下降算法的时候,也是会导致回溯的? + +如果你还想进一步挑战自己,可以琢磨一下,递归下降算法的回溯,会导致多少计算时间的浪费?跟代码长度是线性关系还是指数关系?我们在后面梳理算法的时候,会涉及到这个问题。 + +欢迎在留言区里分享你的发现,与大家一起讨论。最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +另外,第2讲到第5讲的代码,都在代码库中的lab子目录的craft子目录下,代码库在码云和GitHub上都有,希望你能下载玩一玩。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/06编译器前端工具(一):用Antlr生成词法、语法分析器.md b/专栏/编译原理之美/06编译器前端工具(一):用Antlr生成词法、语法分析器.md new file mode 100644 index 0000000..1a1c5f3 --- /dev/null +++ b/专栏/编译原理之美/06编译器前端工具(一):用Antlr生成词法、语法分析器.md @@ -0,0 +1,276 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 06 编译器前端工具(一):用Antlr生成词法、语法分析器 + 前面的课程中,我重点讲解了词法分析和语法分析,在例子中提到的词法和语法规则也是高度简化的。虽然这些内容便于理解原理,也能实现一个简单的原型,在实际应用中却远远不够。实际应用中,一个完善的编译程序还要在词法方面以及语法方面实现很多工作,我这里特意画了一张图,你可以直观地看一下。 + + + +如果让编译程序实现上面这么多工作,完全手写效率会有点儿低,那么我们有什么方法可以提升效率呢?答案是借助工具。 + +编译器前端工具有很多,比如Lex(以及GNU的版本Flex)、Yacc(以及GNU的版本Bison)、JavaCC等等。你可能会问了:“那为什么我们这节课只讲Antlr,不选别的工具呢?”主要有两个原因。 + +第一个原因是Antlr能支持更广泛的目标语言,包括Java、C#、JavaScript、Python、Go、C++、Swift。无论你用上面哪种语言,都可以用它生成词法和语法分析的功能。而我们就使用它生成了Java语言和C++语言两个版本的代码。 + +第二个原因是Antlr的语法更加简单。它能把类似左递归的一些常见难点在工具中解决,对提升工作效率有很大的帮助。这一点,你会在后面的课程中直观地感受到。 + +而我们今天的目标就是了解Antlr,然后能够使用Antlr生成词法分析器与语法分析器。在这个过程中,我还会带你借鉴成熟的词法和语法规则,让你快速成长。 + +接下来,我们先来了解一下Antlr这个工具。 + +初识Antlr + +Antlr是一个开源的工具,支持根据规则文件生成词法分析器和语法分析器,它自身是用Java实现的。 + +你可以下载Antlr工具,并根据说明做好配置。同时,你还需要配置好机器上的Java环境(可以在Oracle官网找到最新版本的JDK)。 + +因为我用的是Mac,所以我用macOS平台下的软件包管理工具Homebrew安装了Antlr,它可以自动设置好antlr和grun两个命令(antlr和grun分别是java org.antlr.v4.Tool和java org.antlr.v4.gui.TestRig这两个命令的别名)。这里需要注意的是,你要把Antlr的JAR文件设置到CLASSPATH环境变量中,以便顺利编译所生成的Java源代码。 + +GitHub上还有很多供参考的语法规则,你可以下载到本地硬盘随时查阅。 + +现在你已经对Antlr有了初步的了解,也知道如何安装它了。接下来,我带你实际用一用Antlr,让你用更轻松的方式生成词法分析器和语法分析器。 + +用Antlr生成词法分析器 + +你可能对Antlr还不怎么熟悉,所以我会先带你使用前面课程中,你已经比较熟悉的那些词法规则,让Antlr生成一个新的词法分析器,然后再借鉴一些成熟的规则文件,把词法分析器提升到更加专业、实用的级别。 + +Antlr通过解析规则文件来生成编译器。规则文件以.g4结尾,词法规则和语法规则可以放在同一个文件里。不过为了清晰起见,我们还是把它们分成两个文件,先用一个文件编写词法规则。 + +为了让你快速进入状态,我们先做一个简单的练习预热一下。我们创建一个Hello.g4文件,用于保存词法规则,然后把之前用过的一些词法规则写进去。 + +lexer grammar Hello; //lexer关键字意味着这是一个词法规则文件,名称是Hello,要与文件名相同 + +//关键字 +If : 'if'; +Int : 'int'; + +//字面量 +IntLiteral: [0-9]+; +StringLiteral: '"' .*? '"' ; //字符串字面量 + +//操作符 +AssignmentOP: '=' ; +RelationalOP: '>'|'>='|'<' |'<=' ; +Star: '*'; +Plus: '+'; +Sharp: '#'; +SemiColon: ';'; +Dot: '.'; +Comm: ','; +LeftBracket : '['; +RightBracket: ']'; +LeftBrace: '{'; +RightBrace: '}'; +LeftParen: '('; +RightParen: ')'; + +//标识符 +Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*; + +//空白字符,抛弃 +Whitespace: [ \t]+ -> skip; +Newline: ( '\r' '\n'?|'\n')-> skip; + + +你能很直观地看到,每个词法规则都是大写字母开头,这是Antlr对词法规则的约定。而语法规则是以小写字母开头的。其中,每个规则都是用我们已经了解的正则表达式编写的。 + +接下来,我们来编译词法规则,在终端中输入命令: + +antlr Hello.g4 + + +这个命令是让Antlr编译规则文件,并生成Hello.java文件和其他两个辅助文件。你可以打开看一看文件里面的内容。接着,我用下面的命令编译Hello.java: + +javac *.java + + +结果会生成Hello.class文件,这就是我们生成的词法分析器。接下来,我们来写个脚本文件,让生成的词法分析器解析一下: + +int age = 45; +if (age >= 17+8+20){ + printf("Hello old man!"); +} + + +我们将上面的脚本存成hello.play文件,然后在终端输入下面的命令: + +grun Hello tokens -tokens hello.play + + +grun命令实际上是调用了我们刚才生成的词法分析器,即Hello类,打印出对hello.play词法分析的结果: + + + +从结果中看到,我们的词法分析器把每个Token都识别了,还记录了它们在代码中的位置、文本值、类别。上面这些都是Token的属性。 + +以第二行[@1, 4:6=‘age’,< Id >,1:4]为例,其中@1是Token的流水编号,表明这是1号Token;4:6是Token在字符流中的开始和结束位置;age是文本值,Id是其Token类别;最后的1:4表示这个Token在源代码中位于第1行、第4列。 + +非常好,现在我们已经让Antlr顺利跑起来了!接下来,让词法规则更完善、更严密一些吧!怎么做呢?当然是参考成熟的规则文件。 + +从Antlr的一些示范性的规则文件中,我选了Java的作为参考。先看看我们之前写的字符串字面量的规则: + +StringLiteral: '"' .*? '"' ; //字符串字面量 + + +我们的版本相当简化,就是在双引号可以包含任何字符。可这在实际中不大好用,因为连转义功能都没有提供。我们对于一些不可见的字符,比如回车,要提供转义功能,如“\n”。同时,如果字符串里本身有双引号的话,也要将它转义,如“\”。Unicode也要转义。最后,转义字符本身也需要转义,如“\\”。 + +下面这一段内容是Java语言中的字符串字面量的完整规则。你可以看一下文稿,这个规则就很细致了,把各种转义的情况都考虑进去了: + +STRING_LITERAL: '"' (~["\\\r\n] | EscapeSequence)* '"'; + +fragment EscapeSequence + : '\\' [btnfr"'\\] + | '\\' ([0-3]? [0-7])? [0-7] + | '\\' 'u'+ HexDigit HexDigit HexDigit HexDigit + ; + +fragment HexDigit + : [0-9a-fA-F] + ; + + +在这个规则文件中,fragment指的是一个语法片段,是为了让规则定义更清晰。它本身并不生成Token,只有StringLiteral规则才会生成Token。 + +当然了,除了字符串字面量,数字字面量、标识符的规则也可以定义得更严密。不过,因为这些规则文件都很严密,写出来都很长,在这里我就不一一展开了。如果感兴趣,我推荐你在下载的规则文件中找到这些部分看一看。你还可以参考不同作者写的词法规则,体会一下他们的设计思路。和高手过招,会更快地提高你的水平。 + +我也拷贝了一些成熟的词法规则,编写了一个CommonLexer.g4的规则文件,这个词法规则是我们后面工作的基础,它基本上已经达到了专业、实用的程度。 + +在带你借鉴了成熟的规则文件之后,我想穿插性地讲解一下在词法规则中对Token归类的问题。在设计词法规则时,你经常会遇到这个问题,解决这个问题,词法规则会更加完善。 + +在前面练习的规则文件中,我们把>=、>、<都归类为关系运算符,算作同一类Token,而+、*等都单独作为另一类Token。那么,哪些可以归并成一类,哪些又是需要单独列出的呢? + +其实,这主要取决于语法的需要。也就是在语法规则文件里,是否可以出现在同一条规则里。它们在语法层面上没有区别,只是在语义层面上有区别。比如,加法和减法虽然是不同的运算,但它们可以同时出现在同一条语法规则中,它们在运算时的特性完全一致,包括优先级和结合性,乘法和除法可以同时出现在乘法规则中。你把加号和减号合并成一类,把乘号和除号合并成一类是可以的。把这4个运算符每个都单独作为一类,也是可以的。但是,不能把加号和乘号作为同一类,因为它们在算术运算中的优先级不同,肯定出现在不同的语法规则中。 + +我们再来回顾一下在“02 | 正则文法和有限自动机:纯手工打造词法分析器”里做词法分析时遇到的一个问题。当时,我们分析了词法冲突的问题,即标识符和关键字的规则是有重叠的。Antlr是怎么解决这个问题的呢?很简单,它引入了优先级的概念。在Antlr的规则文件中,越是前面声明的规则,优先级越高。所以,我们把关键字的规则放在ID的规则前面。算法在执行的时候,会首先检查是否为关键字,然后才会检查是否为ID,也就是标识符。 + +这跟我们当时构造有限自动机做词法分析是一样的。那时,我们先判断是不是关键字,如果不是关键字,才识别为标识符。而在Antlr里,仅仅通过声明的顺序就解决了这个问题,省了很多事儿啊! + +再说个有趣的题外话。之前国内有人提“中文编程语言”的概念,也就是语法中的关键字采用中文,比如“如果”“那么”等。他们似乎觉得这样更容易理解和掌握。我不太提倡这种想法,别的不说,用中文写关键字和变量名,需要输入更多的字符,有点儿麻烦。中国的英语教育很普及,用英语来写代码,其实就够了。 + +不过,你大可以试一下,让自己的词法规则支持中文关键字。比如,把“If”的规则改成同时支持英文的“if”,以及中文的“如果”: + +If: 'if' | '如果'; + + +再把测试用的脚本hello.play中的“if”也改成“如果”,写成: + +如果 (age >= 17+8+20){ + + +重新生成词法分析器并运行,你会发现输出中有这么一行: + +[@5,14:15='如果',,2:0] + + +这个Token的文本值是“如果”,但类别仍然是“If”。所以,要想实现所谓的“中文编程语言”,把C、Java等语言的词法规则改一改,再把编译器重新编译一下就行了! + +用Antlr生成语法分析器 + +说回我们的话题。现在,你已经知道如何用Antlr做一个词法分析器,还知道可以借鉴成熟的规则文件,让自己的词法规则文件变得更完善、更专业。接下来,试着用Antlr生成一个语法分析器,替代之前手写的语法分析器吧! + +这一次的文件名叫做PlayScript.g4。playscript是为我们的脚本语言起的名称,文件开头是这样的: + +grammar PlayScript; +import CommonLexer; //导入词法定义 + +/*下面的内容加到所生成的Java源文件的头部,如包名称,import语句等。*/ +@header { +package antlrtest; +} + + +然后把之前做过的语法定义放进去。Antlr内部有自动处理左递归的机制,你可以放心大胆地把语法规则写成下面的样子: + +expression + : assignmentExpression + | expression ',' assignmentExpression + ; + +assignmentExpression + : additiveExpression + | Identifier assignmentOperator additiveExpression + ; + +assignmentOperator + : '=' + | '*=' + | '/=' + | '%=' + | '+=' + | '-=' + ; + +additiveExpression + : multiplicativeExpression + | additiveExpression '+' multiplicativeExpression + | additiveExpression '-' multiplicativeExpression + ; + +multiplicativeExpression + : primaryExpression + | multiplicativeExpression '*' primaryExpression + | multiplicativeExpression '/' primaryExpression + | multiplicativeExpression '%' primaryExpression + ; + + +你可能会问:“既然用Antlr可以不管左递归问题,那之前为什么要费力气解决它呢?”那是因为当你遇到某些问题却没有现成工具时,还是要用纯手工的方法去解决问题。而且,有的工具可能没有这么智能,你需要写出符合这个工具的规则文件,比如说不能有左递归的语法规则。还是那句话:懂得基础原理,会让你站得更高。 + +我们继续运行下面的命令,生成语法分析器: + +antlr PlayScript.g4 +javac antlrtest/*.java + + +然后测试一下生成的语法分析器: + +grun antlrtest.PlayScript expression -gui + + +这个命令的意思是:测试PlayScript这个类的expression方法,也就是解析表达式的方法,结果用图形化界面显示。 + +我们在控制台界面中输入下面的内容: + +age + 10 * 2 + 10 +^D + + +其中^D是按下Ctl键的同时按下D,相当于在终端输入一个EOF字符,即文件结束符号(Windows操作系统要使用^Z)。当然,你也可以提前把这些语句放到文件中,把文件名作为命令参数。之后,语法分析器会分析这些语法,并弹出一个窗口来显示AST: + + + +看得出来,AST完全正确,优先级和结合性也都没错。所以,Antlr生成的语法分析器还是很靠谱的。以后,你专注写语法规则就行了,可以把精力放在语言的设计和应用上。 + +课程小结 + +今天,我带你了解了Antlr,并用Antlr生成了词法分析器和语法分析器。有了工具的支持,你可以把主要的精力放在编写词法和语法规则上,提升了工作效率。 + +除此之外,我带你借鉴了成熟的词法规则和语法规则。你可以将这些规则用到自己的语言设计中。采用工具和借鉴成熟规则十分重要,站在别人的肩膀上能让自己更快成长。 + +在后面的课程中,我会带你快速实现报表工具、SQL解析器这种需要编译功能的应用。那时,你就更能体会到,用编译技术实现一个功能的过程,是非常高效的!与此同时,我也会带你扩展更多的语法规则,并生成一个更强大的脚本语言解释器。这样,你就会实现流程控制语句,接着探索函数、闭包、面向对象功能的实现机制。几节课之后,你的手里就真的有一门不错的脚本语言了! + +一课一思 + +今天我们介绍了Antlr这个工具,你有没有使用类似工具的经验?在使用过程中又有什么心得或问题呢?欢迎在留言区分享你的心得或问题。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本讲的示例代码位于lab/antlrtest,代码链接我放在了文末,供你参考。 + + +Hello.g4(用Antlr重写了前几讲的词法规则):码云 GitHub + +CommonLexer.g4(比较成熟的词法文件):码云 GitHub + +PlayScript.g4(用Antlr重写了前几讲的语法规则):码云 GitHub + +ASTEvaluator.java(对AST遍历,实现整数的算术运算):码云 GitHub + +PlayScript.java(一个测试程序,实现词法分析、语法分析、公式计算):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/07编译器前端工具(二):用Antlr重构脚本语言.md b/专栏/编译原理之美/07编译器前端工具(二):用Antlr重构脚本语言.md new file mode 100644 index 0000000..33d64a9 --- /dev/null +++ b/专栏/编译原理之美/07编译器前端工具(二):用Antlr重构脚本语言.md @@ -0,0 +1,393 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 07 编译器前端工具(二):用Antlr重构脚本语言 + 上一讲,我带你用Antlr生成了词法分析器和语法分析器,也带你分析了,跟一门成熟的语言相比,在词法规则和语法规则方面要做的一些工作。 + +在词法方面,我们参考Java的词法规则文件,形成了一个CommonLexer.g4词法文件。在这个过程中,我们研究了更完善的字符串字面量的词法规则,还讲到要通过规则声明的前后顺序来解决优先级问题,比如关键字的规则一定要在标识符的前面。 + +目前来讲,我们已经完善了词法规则,所以今天我们来补充和完善一下语法规则,看一看怎样用最高效的速度,完善语法功能。比如一天之内,我们是否能为某个需要编译技术的项目实现一个可行性原型? + +而且,我还会带你熟悉一下常见语法设计的最佳实践。这样当后面的项目需要编译技术做支撑时,你就会很快上手,做出成绩了! + +接下来,我们先把表达式的语法规则梳理一遍,让它达到成熟语言的级别,然后再把语句梳理一遍,包括前面几乎没有讲过的流程控制语句。最后再升级解释器,用Visitor模式实现对AST的访问,这样我们的代码会更清晰,更容易维护了。 + +好了,让我们正式进入课程,先将表达式的语法完善一下吧! + +完善表达式(Expression)的语法 + +在“06 | 编译器前端工具(一):用Antlr生成词法、语法分析器”中,我提到Antlr能自动处理左递归的问题,所以在写表达式时,我们可以大胆地写成左递归的形式,节省时间。 + +但这样,我们还是要为每个运算写一个规则,逻辑运算写完了要写加法运算,加法运算写完了写乘法运算,这样才能实现对优先级的支持,还是有些麻烦。 + +其实,Antlr能进一步地帮助我们。我们可以把所有的运算都用一个语法规则来涵盖,然后用最简洁的方式支持表达式的优先级和结合性。在我建立的PlayScript.g4语法规则文件中,只用了一小段代码就将所有的表达式规则描述完了: + +expression + : primary + | expression bop='.' + ( IDENTIFIER + | functionCall + | THIS + ) + | expression '[' expression ']' + | functionCall + | expression postfix=('++' | '--') + | prefix=('+'|'-'|'++'|'--') expression + | prefix=('~'|'!') expression + | expression bop=('*'|'/'|'%') expression + | expression bop=('+'|'-') expression + | expression ('<' '<' | '>' '>' '>' | '>' '>') expression + | expression bop=('<=' | '>=' | '>' | '<') expression + | expression bop=INSTANCEOF typeType + | expression bop=('==' | '!=') expression + | expression bop='&' expression + | expression bop='^' expression + | expression bop='|' expression + | expression bop='&&' expression + | expression bop='||' expression + | expression bop='?' expression ':' expression + | expression + bop=('=' | '+=' | '-=' | '*=' | '/=' | '&=' | '|=' | '^=' | '>>=' | '>>>=' | '<<=' | '%=') + expression + ; + + +这个文件几乎包括了我们需要的所有的表达式规则,包括几乎没提到的点符号表达式、递增和递减表达式、数组表达式、位运算表达式规则等,已经很完善了。 + +那么它是怎样支持优先级的呢?原来,优先级是通过右侧不同产生式的顺序决定的。在标准的上下文无关文法中,产生式的顺序是无关的,但在具体的算法中,会按照确定的顺序来尝试各个产生式。 + +你不可能一会儿按这个顺序,一会儿按那个顺序。然而,同样的文法,按照不同的顺序来推导的时候,得到的AST可能是不同的。我们需要注意,这一点从文法理论的角度,是无法接受的,但从实践的角度,是可以接受的。比如LL文法和LR文法的概念,是指这个文法在LL算法或LR算法下是工作正常的。又比如我们之前做加法运算的那个文法,就是递归项放在右边的那个,在递归下降算法中会引起结合性的错误,但是如果用LR算法,就完全没有这个问题,生成的AST完全正确。 + +additiveExpression + : IntLiteral + | IntLiteral Plus additiveExpression + ; + + +Antlr的这个语法实际上是把产生式的顺序赋予了额外的含义,用来表示优先级,提供给算法。所以,我们可以说这些文法是Antlr文法,因为是与Antlr的算法相匹配的。当然,这只是我起的一个名字,方便你理解,免得你产生困扰。 + +我们再来看看Antlr是如何依据这个语法规则实现结合性的。在语法文件中,Antlr对于赋值表达式做了的属性标注,说明赋值表达式是右结合的。如果不标注,就是左结合的,交给Antlr实现了! + +我们不妨继续猜测一下Antlr内部的实现机制。我们已经分析了保证正确的结合性的算法,比如把递归转化成循环,然后在构造AST时,确定正确的父子节点关系。那么Antlr是不是也采用了这样的思路呢?或者说还有其他方法?你可以去看看Antlr生成的代码验证一下。 + +在思考这个问题的同时你会发现,学习原理是很有用的。因为当你面对Antlr这样工具时,能够猜出它的实现机制。 + +通过这个简化的算法,AST被成功简化,不再有加法节点、乘法节点等各种不同的节点,而是统一为表达式节点。你可能会问了:“如果都是同样的表达式节点,怎么在解析器里把它们区分开呢?怎么知道哪个节点是做加法运算或乘法运算呢?” + +很简单,我们可以查找一下当前节点有没有某个运算符的Token。比如,如果出现了或者运算的Token(“||”),就是做逻辑或运算,而且语法里面的bop=、postfix=、prefix=这些属性,作为某些运算符Token的别名,也会成为表达式节点的属性。通过查询这些属性的值,你可以很快确定当前运算的类型。 + +到目前为止,我们彻底完成了表达式的语法工作,可以放心大胆地在脚本语言里使用各种表达式,把精力放在完善各类语句的语法工作上了。 + +完善各类语句(Statement)的语法 + +我先带你分析一下PlayScript.g4文件中语句的规则: + +statement + : blockLabel=block + | IF parExpression statement (ELSE statement)? + | FOR '(' forControl ')' statement + | WHILE parExpression statement + | DO statement WHILE parExpression ';' + | SWITCH parExpression '{' switchBlockStatementGroup* switchLabel* '}' + | RETURN expression? ';' + | BREAK IDENTIFIER? ';' + | SEMI + | statementExpression=expression ';' + ; + + +同表达式一样,一个statement规则就可以涵盖各类常用语句,包括if语句、for循环语句、while循环语句、switch语句、return语句等等。表达式后面加一个分号,也是一种语句,叫做表达式语句。 + +从语法分析的难度来看,上面这些语句的语法比表达式的语法简单的多,左递归、优先级和结合性的问题这里都没有出现。这也算先难后易,苦尽甘来了吧。实际上,我们后面要设计的很多语法,都没有想象中那么复杂。 + +既然我们尝到了一些甜头,不如趁热打铁,深入研究一下if语句和for语句?看看怎么写这些语句的规则?多做这样的训练,再看到这些语句,你的脑海里就能马上反映出它的语法规则。 + +1.研究一下if语句 + +在C和Java等语言中,if语句通常写成下面的样子: + +if (condition) + 做一件事情; +else + 做另一件事情; + + +但更多情况下,if和else后面是花括号起止的一个语句块,比如: + +if (condition){ + 做一些事情; +} +else{ + 做另一些事情; +} + + +它的语法规则是这样的: + +statement : + ... + | IF parExpression statement (ELSE statement)? + ... + ; +parExpression : '(' expression ')'; + + +我们用了IF和ELSE这两个关键字,也复用了已经定义好的语句规则和表达式规则。你看,语句规则和表达式规则一旦设计完毕,就可以被其他语法规则复用,多么省心! + +但是if语句也有让人不省心的地方,比如会涉及到二义性文法问题。所以,接下来我们就借if语句,分析一下二义性文法这个现象。 + +2.解决二义性文法 + +学计算机语言的时候,提到if语句,会特别提一下嵌套if语句和悬挂else的情况,比如下面这段代码: + +if (a > b) +if (c > d) +做一些事情; +else +做另外一些事情; + + +在上面的代码中,我故意取消了代码的缩进。那么,你能不能看出else是跟哪个if配对的呢? + +一旦你语法规则写得不够好,就很可能形成二义性,也就是用同一个语法规则可以推导出两个不同的句子,或者说生成两个不同的AST。这种文法叫做二义性文法,比如下面这种写法: + +stmt -> if expr stmt + | if expr stmt else stmt + | other + + +按照这个语法规则,先采用第一条产生式推导或先采用第二条产生式推导,会得到不同的AST。左边的这棵AST中,else跟第二个if配对;右边的这棵AST中,else跟第一个if配对。 + + + +大多数高级语言在解析这个示例代码时都会产生第一个AST,即else跟最邻近的if配对,也就是下面这段带缩进的代码表达的意思: + +if (a > b) + if (c > d) + 做一些事情; + else + 做另外一些事情; + + +那么,有没有办法把语法写成没有二义性的呢?当然有了。 + +stmt -> fullyMatchedStmt | partlyMatchedStmt +fullyMatchedStmt -> if expr fullyMatchedStmt else fullyMatchedStmt + | other +partlyMatchedStmt -> if expr stmt + | if expr fullyMatchedStmt else partlyMatchedStmt + + +按照上面的语法规则,只有唯一的推导方式,也只能生成唯一的AST: + + + +其中,解析第一个if语句时只能应用partlyMatchedStmt规则,解析第二个if语句时,只能适用fullyMatchedStmt规则。 + +这时,我们就知道可以通过改写语法规则来解决二义性文法。至于怎么改写规则,确实不像左递归那样有清晰的套路,但是可以多借鉴成熟的经验。 + +再说回我们给Antlr定义的语法,这个语法似乎并不复杂,怎么就能确保不出现二义性问题呢?因为Antlr解析语法时用到的是LL算法。 + +LL算法是一个深度优先的算法,所以在解析到第一个statement时,就会建立下一级的if节点,在下一级节点里会把else子句解析掉。如果Antlr不用LL算法,就会产生二义性。这再次验证了我们前面说的那个知识点:文法要经常和解析算法配合。 + +分析完if语句,并借它说明了二义性文法之后,我们再针对for语句做一个案例研究。 + +3.研究一下for语句 + +for语句一般写成下面的样子: + +for (int i = 0; i < 10; i++){ + println(i); +} + + +相关的语法规则如下: + +statement : + ... + | FOR '(' forControl ')' statement + ... + ; + +forControl + : forInit? ';' expression? ';' forUpdate=expressionList? + ; + +forInit + : variableDeclarators + | expressionList + ; + +expressionList + : expression (',' expression)* + ; + + +从上面的语法规则中看到,for语句归根到底是由语句、表达式和变量声明构成的。代码中的for语句,解析后形成的AST如下: + + + +熟悉了for语句的语法之后,我想提一下语句块(block)。在if语句和for语句中,会用到它,所以我捎带着把语句块的语法构成写了一下,供你参考: + +block + : '{' blockStatements '}' + ; + +blockStatements + : blockStatement* + ; + +blockStatement + : variableDeclarators ';' //变量声明 + | statement + | functionDeclaration //函数声明 + | classDeclaration //类声明 + ; + + +现在,我们已经拥有了一个相当不错的语法体系,除了要放到后面去讲的函数、类有关的语法之外,我们几乎完成了playscript的所有的语法设计工作。接下来,我们再升级一下脚本解释器,让它能够支持更多的语法,同时通过使用Visitor模式,让代码结构更加完善。 + +用Vistor模式升级脚本解释器 + +我们在纯手工编写的脚本语言解释器里,用了一个evaluate()方法自上而下地遍历了整棵树。随着要处理的语法越来越多,这个方法的代码量会越来越大,不便于维护。而Visitor设计模式针对每一种AST节点,都会有一个单独的方法来负责处理,能够让代码更清晰,也更便于维护。 + +Antlr能帮我们生成一个Visitor处理模式的框架,我们在命令行输入: + +antlr -visitor PlayScript.g4 + + +-visitor参数告诉Antlr生成下面两个接口和类: + +public interface PlayScriptVisitor extends ParseTreeVisitor {...} + +public class PlayScriptBaseVisitor extends AbstractParseTreeVisitor implements PlayScriptVisitor {...} + + +在PlayScriptBaseVisitor中,可以看到很多visitXXX()这样的方法,每一种AST节点都对应一个方法,例如: + +@Override public T visitPrimitiveType(PlayScriptParser.PrimitiveTypeContext ctx) {...} + + +其中泛型< T >指的是访问每个节点时返回的数据的类型。在我们手工编写的版本里,当时只处理整数,所以返回值一律用Integer,现在我们实现的版本要高级一点,AST节点可能返回各种类型的数据,比如: + + +浮点型运算的时候,会返回浮点数; +字符类型运算的时候,会返回字符型数据; +还可能是程序员自己设计的类型,如某个类的实例。 + + +所以,我们就让Visitor统一返回Object类型好了,能够适用于各种情况。这样,我们的Visitor就是下面的样子(泛型采用了Object): + +public class MyVisitor extends PlayScriptBaseVisitor{ + ... +} + + +这样,在visitExpression()方法中,我们可以编写各种表达式求值的代码,比如,加法和减法运算的代码如下: + +public Object visitExpression(ExpressionContext ctx) { + Object rtn = null; + //二元表达式 + if (ctx.bop != null && ctx.expression().size() >= 2) { + Object left = visitExpression(ctx.expression(0)); + Object right = visitExpression(ctx.expression(1)); + ... + Type type = cr.node2Type.get(ctx);//数据类型是语义分析的成果 + + switch (ctx.bop.getType()) { + case PlayScriptParser.ADD: //加法运算 + rtn = add(leftObject, rightObject, type); + break; + case PlayScriptParser.SUB: //减法运算 + rtn = minus(leftObject, rightObject, type); + break; + ... + } + } + ... +} + + +其中ExpressionContext就是AST中表达式的节点,叫做Context,意思是你能从中取出这个节点所有的上下文信息,包括父节点、子节点等。其中,每个子节点的名称跟语法中的名称是一致的,比如加减法语法规则是下面这样: + +expression bop=('+'|'-') expression + + +那么我们可以用ExpressionContext的这些方法访问子节点: + +ctx.expression(); //返回一个列表,里面有两个成员,分别是左右两边的子节点 +ctx.expression(0); //运算符左边的表达式,是另一个ExpressionContext对象 +ctx.expression(1); //云算法右边的表达式 +ctx.bop(); //一个Token对象,其类型是PlayScriptParser.ADD或SUB +ctx.ADD(); //访问ADD终结符,当做加法运算的时候,该方法返回非空值 +ctx.MINUS(); //访问MINUS终结符 + + +在做加法运算的时候我们还可以递归的对下级节点求值,就像代码里的visitExpression(ctx.expression(0))。同样,要想运行整个脚本,我们只需要visit根节点就行了。 + +所以,我们可以用这样的方式,为每个AST节点实现一个visit方法。从而把整个解释器升级一遍。除了实现表达式求值,我们还可以为今天设计的if语句、for语句来编写求值逻辑。以for语句为例,代码如下: + +// 初始化部分执行一次 +if (forControl.forInit() != null) { + rtn = visitForInit(forControl.forInit()); +} + +while (true) { + Boolean condition = true; // 如果没有条件判断部分,意味着一直循环 + if (forControl.expression() != null) { + condition = (Boolean) visitExpression(forControl.expression()); + } + + if (condition) { + // 执行for的语句体 + rtn = visitStatement(ctx.statement(0)); + + // 执行forUpdate,通常是“i++”这样的语句。这个执行顺序不能出错。 + if (forControl.forUpdate != null) { + visitExpressionList(forControl.forUpdate); + } + } else { + break; + } +} + + +你需要注意for语句中各个部分的执行规则,比如: + + +forInit部分只能执行一次; +每次循环都要执行一次forControl,看看是否继续循环; +接着执行for语句中的语句体; +最后执行forUpdate部分,通常是一些“i++”这样的语句。 + + +支持了这些流程控制语句以后,我们的脚本语言就更丰富了! + +课程小结 + +今天,我带你用Antlr高效地完成了很多语法分析工作,比如完善表达式体系,完善语句体系。除此之外,我们还升级了脚本解释器,使它能够执行更多的表达式和语句。 + +在实际工作中,针对面临的具体问题,我们完全可以像今天这样迅速地建立可以运行的代码,专注于解决领域问题,快速发挥编译技术的威力。 + +而且在使用工具时,针对工具的某个特性,比如对优先级和结合性的支持,我们大致能够猜到工具内部的实现机制,因为我们已经了解了相关原理。 + +一课一思 + +我们通过Antlr并借鉴成熟的规则文件,很快就重构了脚本解释器,这样工作效率很高。那么,针对要解决的领域问题,你是不是借鉴过一些成熟实践或者最佳实践来提升效率和质量?在这个过程中又有什么心得呢?欢迎在留言区分享你的心得。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +我把一门功能比较全的脚本语言的示例放在了playscript-java项目下,以后几讲的内容都会参考这里面的示例代码。 + + +playscript-java(项目目录): 码云 GitHub +PlayScript.java(入口程序): 码云 GitHub +PlayScript.g4(语法规则): 码云 GitHub +ASTEvaluator.java(解释器): 码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/08作用域和生存期:实现块作用域和函数.md b/专栏/编译原理之美/08作用域和生存期:实现块作用域和函数.md new file mode 100644 index 0000000..c711f03 --- /dev/null +++ b/专栏/编译原理之美/08作用域和生存期:实现块作用域和函数.md @@ -0,0 +1,520 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 08 作用域和生存期:实现块作用域和函数 + 目前,我们已经用Antlr重构了脚本解释器,有了工具的帮助,我们可以实现更高级的功能,比如函数功能、面向对象功能。当然了,在这个过程中,我们还要克服一些挑战,比如: + + +如果要实现函数功能,要升级变量管理机制; +引入作用域机制,来保证变量的引用指向正确的变量定义; +提升变量存储机制,不能只把变量和它的值简单地扔到一个HashMap里,要管理它的生存期,减少对内存的占用。 + + +本节课,我将借实现块作用域和函数功能,带你探讨作用域和生存期及其实现机制,并升级变量管理机制。那么什么是作用域和生存期,它们的重要性又体现在哪儿呢? + +“作用域”和“生存期”是计算机语言中更加基础的概念,它们可以帮你深入地理解函数、块、闭包、面向对象、静态成员、本地变量和全局变量等概念。 + +而且一旦你深入理解,了解作用域与生存期在编译期和运行期的机制之后,就能解决在学习过程中可能遇到的一些问题,比如: + + +闭包的机理到底是什么? +为什么需要栈和堆两种机制来管理内存?它们的区别又是什么? +一个静态的内部类和普通的内部类有什么区别? + + +了解上面这些内容之后,接下来,我们来具体看看什么是作用域。 + +作用域(Scope) + +作用域是指计算机语言中变量、函数、类等起作用的范围,我们来看一个具体的例子。 + +下面这段代码是用C语言写的,我们在全局以及函数fun中分别声明了a和b两个变量,然后在代码里对这些变量做了赋值操作: + +/* +scope.c +测试作用域。 + */ +#include + +int a = 1; + +void fun() +{ + a = 2; + //b = 3; //出错,不知道b是谁 + int a = 3; //允许声明一个同名的变量吗? + int b = a; //这里的a是哪个? + printf("in fun: a=%d b=%d \n", a, b); +} + +int b = 4; //b的作用域从这里开始 + +int main(int argc, char **argv){ + printf("main--1: a=%d b=%d \n", a, b); + + fun(); + printf("main--2: a=%d b=%d \n", a, b); + + //用本地变量覆盖全局变量 + int a = 5; + int b = 5; + printf("main--3: a=%d b=%d \n", a, b); + + //测试块作用域 + if (a > 0){ + int b = 3; //允许在块里覆盖外面的变量 + printf("main--4: a=%d b=%d \n", a, b); + } + else{ + int b = 4; //跟if块里的b是两个不同的变量 + printf("main--5: a=%d b=%d \n", a, b); + } + + printf("main--6: a=%d b=%d \n", a, b); +} + + +这段代码编译后运行,结果是: + +main--1: a=1 b=4 +in fun: a=3 b=3 +main--2: a=2 b=4 +main--3: a=5 b=5 +main--4: a=5 b=3 +main--6: a=5 b=5 + + +我们可以得出这样的规律: + + +变量的作用域有大有小,外部变量在函数内可以访问,而函数中的本地变量,只有本地才可以访问。 +变量的作用域,从声明以后开始。 +在函数里,我们可以声明跟外部变量相同名称的变量,这个时候就覆盖了外部变量。 + + +下面这张图直观地显示了示例代码中各个变量的作用域: + + + +另外,C语言里还有块作用域的概念,就是用花括号包围的语句,if和else后面就跟着这样的语句块。块作用域的特征跟函数作用域的特征相似,都可以访问外部变量,也可以用本地变量覆盖掉外部变量。 + +你可能会问:“其他语言也有块作用域吗?特征是一样的吗?”其实,各个语言在这方面的设计机制是不同的。比如,下面这段用Java写的代码里,我们用了一个if语句块,并且在if部分、else部分和外部分别声明了一个变量c: + +/** + * Scope.java + * 测试Java的作用域 + */ +public class ScopeTest{ + + public static void main(String args[]){ + int a = 1; + int b = 2; + + if (a > 0){ + //int b = 3; //不允许声明与外部变量同名的变量 + int c = 3; + } + else{ + int c = 4; //允许声明另一个c,各有各的作用域 + } + + int c = 5; //这里也可以声明一个新的c + } +} + + +你能看到,Java的块作用域跟C语言的块作用域是不同的,它不允许块作用域里的变量覆盖外部变量。那么和C、Java写起来很像的JavaScript呢?来看一看下面这段测试JavaScript作用域的代码: + +/** + * Scope.js + * 测试JavaScript的作用域 + */ +var a = 5; +var b = 5; +console.log("1: a=%d b=%d", a, b); + +if (a > 0) { + a = 4; + console.log("2: a=%d b=%d", a, b); + var b = 3; //看似声明了一个新变量,其实还是引用的外部变量 + console.log("3: a=%d b=%d", a, b); +} +else { + var b = 4; + console.log("4: a=%d b=%d", a, b); +} + +console.log("5: a=%d b=%d", a, b); + +for (var b = 0; b< 2; b++){ //这里是否能声明一个新变量,用于for循环? + console.log("6-%d: a=%d b=%d",b, a, b); +} + +console.log("7: a=%d b=%d", a, b); + + +这段代码编译后运行,结果是: + +1: a=5 b=5 +2: a=4 b=5 +3: a=4 b=3 +5: a=4 b=3 +6-0: a=4 b=0 +6-1: a=4 b=1 +7: a=4 b=2 + + +你可以看到,JavaScript是没有块作用域的。我们在块里和for语句试图重新定义变量b,语法上是允许的,但我们每次用到的其实是同一个变量。 + +对比了三种语言的作用域特征之后,你是否发现原来看上去差不多的语法,内部机理却不同?这种不同其实是语义差别的一个例子。你要注意的是,现在我们讲的很多内容都已经属于语义的范畴了,对作用域的分析就是语义分析的任务之一。 + +生存期(Extent) + +了解了什么是作用域之后,我们再理解一下跟它紧密相关的生存期。它是变量可以访问的时间段,也就是从分配内存给它,到收回它的内存之间的时间。 + +在前面几个示例程序中,变量的生存期跟作用域是一致的。出了作用域,生存期也就结束了,变量所占用的内存也就被释放了。这是本地变量的标准特征,这些本地变量是用栈来管理的。 + +但也有一些情况,变量的生存期跟语法上的作用域不一致,比如在堆中申请的内存,退出作用域以后仍然会存在。 + +下面这段C语言的示例代码中,fun函数返回了一个整数的指针。出了函数以后,本地变量b就消失了,这个指针所占用的内存(&b)就收回了,其中&b是取b的地址,这个地址是指向栈里的一小块空间,因为b是栈里申请的。在这个栈里的小空间里保存了一个地址,指向在堆里申请的内存。这块内存,也就是用来实际保存数值2的空间,并没有被收回,我们必须手动使用free()函数来收回。 + +/* +extent.c +测试生存期。 + */ +#include +#include + +int * fun(){ + int * b = (int*)malloc(1*sizeof(int)); //在堆中申请内存 + *b = 2; //给该地址赋值2 + + return b; +} + +int main(int argc, char **argv){ + int * p = fun(); + *p = 3; + + printf("after called fun: b=%lu *b=%d \n", (unsigned long)p, *p); + + free(p); +} + + +类似的情况在Java里也有。Java的对象实例缺省情况下是在堆中生成的。下面的示例代码中,从一个方法中返回了对象的引用,我们可以基于这个引用继续修改对象的内容,这证明这个对象的内存并没有被释放: + +/** + * Extent2.java + * 测试Java的生存期特性 + */ +public class Extent2{ + + StringBuffer myMethod(){ + StringBuffer b = new StringBuffer(); //在堆中生成对象实例 + b.append("Hello "); + System.out.println(System.identityHashCode(b)); //打印内存地址 + return b; //返回对象引用,本质是一个内存地址 + } + + public static void main(String args[]){ + Extent2 extent2 = new Extent2(); + StringBuffer c = extent2.myMethod(); //获得对象引用 + System.out.println(c); + c.append("World!"); //修改内存中的内容 + System.out.println(c); + + //跟在myMethod()中打印的值相同 + System.out.println(System.identityHashCode(c)); + } +} + + +因为Java对象所采用的内存超出了申请内存时所在的作用域,所以也就没有办法自动收回。所以Java采用的是自动内存管理机制,也就是垃圾回收技术。 + +那么为什么说作用域和生存期是计算机语言更加基础的概念呢?其实是因为它们对应到了运行时的内存管理的基本机制。虽然各门语言设计上的特性是不同的,但在运行期的机制都很相似,比如都会用到栈和堆来做内存管理。 + +好了,理解了作用域和生存期的原理之后,我们就来实现一下,先来设计一下作用域机制,然后再模拟实现一个栈。 + +实现作用域和栈 + +在之前的PlayScript脚本的实现中,处理变量赋值的时候,我们简单地把变量存在一个哈希表里,用变量名去引用,就像下面这样: + +public class SimpleScript { + private HashMap variables = new HashMap(); + ... +} + + +但如果变量存在多个作用域,这样做就不行了。这时,我们就要设计一个数据结构,区分不同变量的作用域。分析前面的代码,你可以看到作用域是一个树状的结构,比如Scope.c的作用域: + + + +面向对象的语言不太相同,它不是一棵树,是一片树林,每个类对应一棵树,所以它也没有全局变量。在我们的playscript语言中,我们设计了下面的对象结构来表示Scope: + +//编译过程中产生的变量、函数、类、块,都被称作符号 +public abstract class Symbol { + //符号的名称 + protected String name = null; + + //所属作用域 + protected Scope enclosingScope = null; + + //可见性,比如public还是private + protected int visibility = 0; + + //Symbol关联的AST节点 + protected ParserRuleContext ctx = null; +} + +//作用域 +public abstract class Scope extends Symbol{ + // 该Scope中的成员,包括变量、方法、类等。 + protected List symbols = new LinkedList(); +} + +//块作用域 +public class BlockScope extends Scope{ + ... +} + +//函数作用域 +public class Function extends Scope implements FunctionType{ + ... +} + +//类作用域 +public class Class extends Scope implements Type{ + ... +} + + +目前我们划分了三种作用域,分别是块作用域(Block)、函数作用域(Function)和类作用域(Class)。 + +我们在解释执行playscript的AST的时候,需要建立起作用域的树结构,对作用域的分析过程是语义分析的一部分。也就是说,并不是有了AST,我们马上就可以运行它,在运行之前,我们还要做语义分析,比如对作用域做分析,让每个变量都能做正确的引用,这样才能正确地执行这个程序。 + +解决了作用域的问题以后,再来看看如何解决生存期的问题。还是看Scope.c的代码,随着代码的执行,各个变量的生存期表现如下: + + +进入程序,全局变量逐一生效; +进入main函数,main函数里的变量顺序生效; +进入fun函数,fun函数里的变量顺序生效; +退出fun函数,fun函数里的变量失效; +进入if语句块,if语句块里的变量顺序生效; +退出if语句块,if语句块里的变量失效; +退出main函数,main函数里的变量失效; +退出程序,全局变量失效。 + + +通过下面这张图,你能直观地看到运行过程中栈的变化: + + + +代码执行时进入和退出一个个作用域的过程,可以用栈来实现。每进入一个作用域,就往栈里压入一个数据结构,这个数据结构叫做栈桢(Stack Frame)。栈桢能够保存当前作用域的所有本地变量的值,当退出这个作用域的时候,这个栈桢就被弹出,里面的变量也就失效了。 + +你可以看到,栈的机制能够有效地使用内存,变量超出作用域的时候,就没有用了,就可以从内存中丢弃。我在ASTEvaluator.java中,用下面的数据结构来表示栈和栈桢,其中的PlayObject通过一个HashMap来保存各个变量的值: + +private Stack stack = new Stack(); + +public class StackFrame { + //该frame所对应的scope + Scope scope = null; + + //enclosingScope所对应的frame + StackFrame parentFrame = null; + + //实际存放变量的地方 + PlayObject object = null; +} + +public class PlayObject { + //成员变量 + protected Map fields = new HashMap(); +} + + +目前,我们只是在概念上模仿栈桢,当我们用Java语言实现的时候,PlayObject对象是存放在堆里的,Java的所有对象都是存放在堆里的,只有基础数据类型,比如int和对象引用是放在栈里的。虽然只是模仿,这不妨碍我们建立栈桢的概念,在后端技术部分,我们会实现真正意义上的栈桢。 + +要注意的是,栈的结构和Scope的树状结构是不一致的。也就是说,栈里的上一级栈桢,不一定是Scope的父节点。要访问上一级Scope中的变量数据,要顺着栈桢的parentFrame去找。我在上图中展现了这种情况,在调用fun函数的时候,栈里一共有三个栈桢:全局栈桢、main()函数栈桢和fun()函数栈桢,其中main()函数栈桢的parentFrame和fun()函数栈桢的parentFrame都是全局栈桢。 + +实现块作用域 + +目前,我们已经做好了作用域和栈,在这之后,就能实现很多功能了,比如让if语句和for循环语句使用块作用域和本地变量。以for语句为例,visit方法里首先为它生成一个栈桢,并加入到栈中,运行完毕之后,再从栈里弹出: + +BlockScope scope = (BlockScope) cr.node2Scope.get(ctx); //获得Scope +StackFrame frame = new StackFrame(scope); //创建一个栈桢 +pushStack(frame); //加入栈中 + +... + +//运行完毕,弹出栈 +stack.pop(); + + +当我们在代码中需要获取某个变量的值的时候,首先在当前桢中寻找。找不到的话,就到上一级作用域对应的桢中去找: + +StackFrame f = stack.peek(); //获取栈顶的桢 +PlayObject valueContainer = null; +while (f != null) { + //看变量是否属于当前栈桢里 + if (f.scope.containsSymbol(variable)){ + valueContainer = f.object; + break; + } + //从上一级scope对应的栈桢里去找 + f = f.parentFrame; +} + + +运行下面的测试代码,你会看到在执行完for循环以后,我们仍然可以声明另一个变量i,跟for循环中的i互不影响,这证明它们确实属于不同的作用域: + +String script = "int age = 44; for(int i = 0;i<10;i++) { age = age + 2;} int i = 8;"; + + +进一步的,我们可以实现对函数的支持。 + +实现函数功能 + +先来看一下与函数有关的语法: + +//函数声明 +functionDeclaration + : typeTypeOrVoid? IDENTIFIER formalParameters ('[' ']')* + functionBody + ; +//函数体 +functionBody + : block + | ';' + ; +//类型或void +typeTypeOrVoid + : typeType + | VOID + ; +//函数所有参数 +formalParameters + : '(' formalParameterList? ')' + ; +//参数列表 +formalParameterList + : formalParameter (',' formalParameter)* (',' lastFormalParameter)? + | lastFormalParameter + ; +//单个参数 +formalParameter + : variableModifier* typeType variableDeclaratorId + ; +//可变参数数量情况下,最后一个参数 +lastFormalParameter + : variableModifier* typeType '...' variableDeclaratorId + ; +//函数调用 +functionCall + : IDENTIFIER '(' expressionList? ')' + | THIS '(' expressionList? ')' + | SUPER '(' expressionList? ')' + ; + + +在函数里,我们还要考虑一个额外的因素:参数。在函数内部,参数变量跟普通的本地变量在使用时没什么不同,在运行期,它们也像本地变量一样,保存在栈桢里。 + +我们设计一个对象来代表函数的定义,它包括参数列表和返回值的类型: + +public class Function extends Scope implements FunctionType{ + // 参数 + protected List parameters = new LinkedList(); + + //返回值 + protected Type returnType = null; + + ... +} + + +在调用函数时,我们实际上做了三步工作: + + +建立一个栈桢; +计算所有参数的值,并放入栈桢; +执行函数声明中的函数体。 + + +我把相关代码放在了下面,你可以看一下: + +//函数声明的AST节点 +FunctionDeclarationContext functionCode = (FunctionDeclarationContext) function.ctx; + +//创建栈桢 +functionObject = new FunctionObject(function); +StackFrame functionFrame = new StackFrame(functionObject); + +// 计算实参的值 +List paramValues = new LinkedList(); +if (ctx.expressionList() != null) { + for (ExpressionContext exp : ctx.expressionList().expression()) { + Object value = visitExpression(exp); + if (value instanceof LValue) { + value = ((LValue) value).getValue(); + } + paramValues.add(value); + } +} + +//根据形参的名称,在栈桢中添加变量 +if (functionCode.formalParameters().formalParameterList() != null) { + for (int i = 0; i < functionCode.formalParameters().formalParameterList().formalParameter().size(); i++) { + FormalParameterContext param = functionCode.formalParameters().formalParameterList().formalParameter(i); + LValue lValue = (LValue) visitVariableDeclaratorId(param.variableDeclaratorId()); + lValue.setValue(paramValues.get(i)); + } +} + +// 调用方法体 +rtn = visitFunctionDeclaration(functionCode); + +// 运行完毕,弹出栈 +stack.pop(); + + +你可以用playscript测试一下函数执行的效果,看看参数传递和作用域的效果: + +String script = "int b= 10; int myfunc(int a) {return a+b+3;} myfunc(2);"; + + +课程小结 + +本节课,我带你实现了块作用域和函数,还跟你一起探究了计算机语言的两个底层概念:作用域和生存期。你要知道: + + +对作用域的分析是语义分析的一项工作。Antlr能够完成很多词法分析和语法分析的工作,但语义分析工作需要我们自己做。 +变量的生存期涉及运行期的内存管理,也引出了栈桢和堆的概念,我会在编译器后端技术时进一步阐述。 + + +我建议你在学习新语言的时候,先了解它在作用域和生存期上的特点,然后像示例程序那样做几个例子,借此你会更快理解语言的设计思想。比如,为什么需要命名空间这个特性?全局变量可能带来什么问题?类的静态成员与普通成员有什么区别?等等。 + +下一讲,我们会尝试实现面向对象特性,看看面向对象语言在语义上是怎么设计的,以及在运行期有什么特点。 + +一课一思 + +既然我强调了作用域和生存期的重要性,那么在你熟悉的语言中,有哪些特性是能用作用域和生存期的概念做更基础的解读呢?比如,面向对象的语言中,对象成员的作用域和生存期是怎样的?欢迎在留言区与大家一起交流。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +今天讲的功能照样能在playscript-java项目中找到示例代码,其中还有用playscript写的脚本,你可以多玩一玩。 + + +playscript-java(项目目录): 码云 GitHub +PlayScript.java(入口程序):码云 GitHub +PlayScript.g4(语法规则):码云 GitHub +ASTEvaluator.java(解释器):码云 GitHub +BlockScope.play(演示块作用域):码云 GitHub +function.play(演示基础函数功能):码云 GitHub +lab/scope目录(各种语言的作用域测试):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/09面向对象:实现数据和方法的封装.md b/专栏/编译原理之美/09面向对象:实现数据和方法的封装.md new file mode 100644 index 0000000..de4d877 --- /dev/null +++ b/专栏/编译原理之美/09面向对象:实现数据和方法的封装.md @@ -0,0 +1,307 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 09 面向对象:实现数据和方法的封装 + 在现代计算机语言中,面向对象是非常重要的特性,似乎常用的语言都支持面向对象特性,比如Swift、C++、Java……不支持的反倒是异类了。 + +而它重要的特点就是封装。也就是说,对象可以把数据和对数据的操作封装在一起,构成一个不可分割的整体,尽可能地隐藏内部的细节,只保留一些接口与外部发生联系。 在对象的外部只能通过这些接口与对象进行交互,无需知道对象内部的细节。这样能降低系统的耦合,实现内部机制的隐藏,不用担心对外界的影响。那么它们是怎样实现的呢? + +本节课,我将从语义设计和运行时机制的角度剖析面向对象的特性,带你深入理解面向对象的实现机制,让你能在日常编程工作中更好地运用面向对象的特性。比如,在学完这讲之后,你会对对象的作用域和生存期、对象初始化过程等有更清晰的了解。而且你不会因为学习了Java或C++的面向对象机制,在学习JavaScript和Ruby的面向对象机制时觉得别扭,因为它们的本质是一样的。 + +接下来,我们先简单地聊一下什么是面向对象。 + +面向对象的语义特征 + +我的一个朋友,在10多年前做过培训师,为了吸引学员的注意力,他在讲“什么是面向对象”时说:“面向对象是世界观,是方法论。” + +虽然有点儿语不惊人死不休的意思,但我必须承认,所有的计算机语言都是对世界进行建模的方式,只不过建模的视角不同罢了。面向对象的设计思想,在上世纪90年代被推崇,几乎被视为最好的编程模式。实际上,各种不同的编程思想,都会表现为这门语言的语义特征,所以,我就从语义角度,利用类型、作用域、生存期这样的概念带你深入剖析一下面向对象的封装特性,其他特性在后面的课程中再去讨论。 + + +从类型角度 + + +类型处理是语义分析时的重要工作。现代计算机语言可以用自定义的类来声明变量,这是一个巨大的进步。因为早期的计算机语言只支持一些基础的数据类型,比如各种长短不一的整型和浮点型,像字符串这种我们编程时离不开的类型,往往是在基础数据类型上封装和抽象出来的。所以,我们要扩展语言的类型机制,让程序员可以创建自己的类型。 + + +从作用域角度 + + +首先是类的可见性。作为一种类型,它通常在整个程序的范围内都是可见的,可以用它声明变量。当然,一些像Java的语言,也能限制某些类型的使用范围,比如只能在某个命名空间内,或者在某个类内部。 + +对象的成员的作用域是怎样的呢?我们知道,对象的属性(“属性”这里指的是类的成员变量)可以在整个对象内部访问,无论在哪个位置声明。也就是说,对象属性的作用域是整个对象的内部,方法也是一样。这跟函数和块中的本地变量不一样,它们对声明顺序有要求,像C和Java这样的语言,在使用变量之前必须声明它。 + + +从生存期的角度 + + +对象的成员变量的生存期,一般跟对象的生存期是一样的。在创建对象的时候,就对所有成员变量做初始化,在销毁对象的时候,所有成员变量也随着一起销毁。当然,如果某个成员引用了从堆中申请的内存,这些内存需要手动释放,或者由垃圾收集机制释放。 + +但还有一些成员,不是与对象绑定的,而是与类型绑定的,比如Java中的静态成员。静态成员跟普通成员的区别,就是作用域和生存期不同,它的作用域是类型的所有对象实例,被所有实例共享。生存期是在任何一个对象实例创建之前就存在,在最后一个对象销毁之前不会消失。 + +你看,我们用这三个语义概念,就把面向对象的封装特性解释清楚了,无论语言在顶层怎么设计,在底层都是这么实现的。 + +了解了面向对象在语义上的原理之后,我们来实际动手解析一下代码中的类,这样能更深刻地体会这些原理。 + +设计类的语法,并解析它 + +我们要在语言中支持类的定义,在PlayScript.g4中,可以这样定义类的语法规则: + +classDeclaration + : CLASS IDENTIFIER + (EXTENDS typeType)? + (IMPLEMENTS typeList)? + classBody + ; + +classBody + : '{' classBodyDeclaration* '}' + ; + +classBodyDeclaration + : ';' + | memberDeclaration + ; + +memberDeclaration + : functionDeclaration + | fieldDeclaration + ; + +functionDeclaration + : typeTypeOrVoid IDENTIFIER formalParameters ('[' ']')* + (THROWS qualifiedNameList)? + functionBody + ; + + +我来简单地讲一下这个语法规则: + + +类声明以class关键字开头,有一个标识符是类型名称,后面跟着类的主体。 +类的主体里要声明类的成员。在简化的情况下,可以只关注类的属性和方法两种成员。我们故意把类的方法也叫做function,而不是method,是想把对象方法和函数做一些统一的设计。 +函数声明现在的角色是类的方法。 +类的成员变量的声明和普通变量声明在语法上没什么区别。 + + +你能看到,我们构造像class这样高级别的结构时,越来越得心应手了,之前形成的一些基础的语法模块都可以复用,比如变量声明、代码块(block)等。 + +用上面的语法写出来的playscript脚本的效果如下,在示例代码里也有,你可以运行它: + +/* +ClassTest.play 简单的面向对象特性。 +*/ +class Mammal{ + //类属性 + string name = ""; + + //构造方法 + Mammal(string str){ + name = str; + } + + //方法 + void speak(){ + println("mammal " + name +" speaking..."); + } +} + +Mammal mammal = Mammal("dog"); //playscript特别的构造方法,不需要new关键字 +mammal.speak(); //访问对象方法 +println("mammal.name = " + mammal.name); //访问对象的属性 + +//没有构造方法,创建的时候用缺省构造方法 +class Bird{ + int speed = 50; //在缺省构造方法里初始化 + + void fly(){ + println("bird flying..."); + } +} + +Bird bird = Bird(); //采用缺省构造方法 +println("bird.speed : " + bird.speed + "km/h"); +bird.fly(); + + +接下来,我们让playscript解释器处理这些看上去非常现代化的代码,怎么处理呢? + +做完词法分析和语法分析之后,playscript会在语义分析阶段扫描AST,识别出所有自定义的类型,以便在其他地方引用这些类型来声明变量。因为类型的声明可以在代码中的任何位置,所以最好用单独的一次遍历来识别和记录类型(类型扫描的代码在TypeAndScopeScanner.java里)。 + +接着,我们在声明变量时,就可以引用这个类型了。语义分析的另一个工作,就是做变量类型的消解。当我们声明“Bird bird = Bird(); ”时,需要知道Bird对象的定义在哪里,以便正确地访问它的成员(变量类型的消解在TypeResolver.java里)。 + +在做语义分析时,要把类型的定义保存在一个数据结构中,我们来实现一下: + +public class Class extends Scope implements Type{ + ... +} + +public abstract class Scope extends Symbol{ + // 该Scope中的成员,包括变量、方法、类等。 + protected List symbols = new LinkedList( +} + +public interface Type { + public String getName(); //类型名称 + + public Scope getEnclosingScope(); +} + + +在这个设计中,我们看到Class就是一个Scope,Scope里面原来就能保存各种成员,现在可以直接复用,用来保存类的属性和方法,画成类图如下: + + + +图里有几个类,比如Symbol、Variable、Scope、Function和BlockScope,它们是我们的符号体系的主要成员。在做词法分析时,我们会解析出很多标识符,这些标识符出现在不同的语法规则里,包括变量声明、表达式,以及作为类名、方法名等出现。 + +在语义分析阶段,我们要把这些标识符一一识别出来,这个是一个变量,指的是一个本地变量;那个是一个方法名等。 + +变量、类和函数的名称,我们都叫做符号,比如示例程序中的Mammal、Bird、mammal、bird、name、speed等。编译过程中的一项重要工作就是建立符号表,它帮助我们进一步地编译或执行程序,而符号表就用上面几个类来保存信息。 + +在符号表里,我们保存它的名称、类型、作用域等信息。对于类和函数,我们也有相应的地方来保存类变量、方法、参数、返回值等信息。你可以看一看示例代码里面是如何解析和记录这些符号的。 + +解析完这些语义信息以后,我们来看运行期如何执行具有面向对象特征的程序,比如如何实例化一个对象?如何在内存里管理对象的数据?以及如何访问对象的属性和方法? + +对象是怎么实例化的 + +首先通过构造方法来创建对象。 + +在语法中,我们没有用new这个关键字来表示对象的创建,而是省略掉了new,直接调用一个跟类名称相同的函数,这是我们独特的设计,示例代码如下: + +Mammal mammal = Mammal("dog"); //playscript特别的构造方法,不需要new关键字 +Bird bird = Bird(); //采用缺省构造方法 + + +但在语义检查的时候,在当前作用域中是肯定找不到这样一个函数的,因为类的初始化方法是在类的内部定义的,我们只要检查一下,Mammal和Bird是不是一个类名就可以了。 + +再进一步,Mammal类中确实有个构造方法Mammal(),而Bird类中其实没有一个显式定义的构造方法,但这并不意味着变量成员不会被初始化。我们借鉴了Java的初始化机制,就是提供缺省初始化方法,在缺省初始化方法里,会执行对象成员声明时所做的初始化工作。所以,上面的代码里,我们调用Bird(),实际上就是调用了这个缺省的初始化方法。无论有没有显式声明的构造方法,声明对象的成员变量时的初始化部分,一定会执行。对于Bird类,实际上就会执行“int speed = 50;”这个语句。 + +在RefResolver.java中做语义分析的时候,下面的代码能够检测出某个函数调用其实是类的构造方法,或者是缺省构造方法: + +// 看看是不是类的构建函数,用相同的名称查找一个class +Class theClass = at.lookupClass(scope, idName); +if (theClass != null) { + function = theClass.findConstructor(paramTypes); + if (function != null) { + at.symbolOfNode.put(ctx, function); + } + //如果是与类名相同的方法,并且没有参数,那么就是缺省构造方法 + else if (ctx.expressionList() == null){ + at.symbolOfNode.put(ctx, theClass); // TODO 直接赋予class + } + else{ + at.log("unknown class constructor: " + ctx.getText(), ctx); + } + + at.typeOfNode.put(ctx, theClass); // 这次函数调用是返回一个对象 +} + + +当然,类的构造方法跟普通函数还是有所不同的,例如我们不允许构造方法定义返回值,因为它的返回值一定是这个类的一个实例对象。 + +对象做了缺省初始化以后,再去调用显式定义的构造方法,这样才能完善整个对象实例化的过程。不过问题来了,我们可以把普通的本地变量的数据保存在栈里,那么如何保存对象的数据呢? + +如何在内存里管理对象的数据 + +其实,我们也可以把对象的数据像其他数据一样,保存在栈里。 + + + +C语言的结构体struct和C++语言的对象,都可以保存在栈里。保存在栈里的对象是直接声明并实例化的,而不是用new关键字来创建的。如果用new关键字来创建,实际上是在堆里申请了一块内存,并赋值给一个指针变量,如下图所示: + + + +当对象保存在堆里的时候,可以有多个变量都引用同一个对象,比如图中的变量a和变量b就可以引用同一个对象object1。类的成员变量也可以引用别的对象,比如object1中的类成员引用了object2对象。对象的生存期可以超越创建它的栈桢的生存期。 + +我们可以对比一下这两种方式的优缺点。如果对象保存在栈里,那么它的生存期与作用域是一样的,可以自动的创建和销毁,因此不需要额外的内存管理。缺点是对象没办法长期存在并共享。而在堆里创建的对象虽然可以被共享使用,却增加了内存管理的负担。 + +所以在C语言和C++语言中,要小心管理从堆中申请的内存,在合适的时候释放掉这些内存。在Java语言和其他一些语言中,采用的是垃圾收集机制,也就是说当一个对象不再被引用时,就把内存收集回来。 + +分析到这儿的时候,我们其实可以帮Java语言优化一下内存管理。比如我们在分析代码时,如果发现某个对象的创建和使用都局限在某个块作用域中,并没有跟其他作用域共享,那么这个对象的生存期与当前栈桢是一致的,可以在栈里申请内存,而不是在堆里。这样可以免除后期的垃圾收集工作。 + +分析完对象的内存管理方式之后,回到playscript的实现。在playscript的Java版本里,我们用一个ClassObject对象来保存对象数据,而ClassObject是PlayObject的子类。上一讲,我们已经讲过PlayObject,它被栈桢用来保存本地变量,可以通过传入Variable来访问对象的属性值: + +//类的实例 +public class ClassObject extends PlayObject{ + //类型 + protected Class type = null; + ... +} + +//保存对象数据 +public class PlayObject { + //成员变量 + protected Map fields = new HashMap(); + + public Object getValue(Variable variable){ + Object rtn = fields.get(variable); + return rtn; + } + + public void setValue(Variable variable, Object value){ + fields.put(variable, value); + } +} + + +在运行期,当需要访问一个对象时,我们也会用ClassObject来做一个栈桢,这样就可以像访问本地变量一样访问对象的属性了。而不需要访问这个对象的时候,就把它从栈中移除,如果没有其他对象引用这个对象,那么它会被Java的垃圾收集机制回收。 + +访问对象的属性和方法 + +在示例代码中,我们用点操作符来访问对象的属性和方法,比如: + +mammal.speak(); //访问对象方法 +println("mammal.name = " + mammal.name); //访问对象的属性 + + +属性和方法的引用也是一种表达式,语法定义如下: + +expression + : ... + | expression bop='.' + ( IDENTIFIER //对象属性 + | functionCall //对象方法 + ) + ... + ; + + +注意,点符号的操作可以是级联的,比如: + +obj1.obj2.field1; +obj1.getObject2().field1; + + +所以,对表达式的求值,要能够获得正确的对象引用,你可以运行一下ClassTest.play脚本,或者去看看我的参考实现。 + +另外,对象成员还可以设置可见性。也就是说,有些成员只有对象内部才能用,有些可以由外部访问。这个怎么实现呢?这只是个语义问题,是在编译阶段做语义检查的时候,不允许私有的成员被外部访问,报编译错误就可以了,在其他方面,并没有什么不同。 + +课程小结 + +我们针对面向对象的封装特性,从类型、作用域和生存期的角度进行了重新解读,这样能够更好地把握面向对象的本质特征。我们还设计了与面向对象的相关的语法并做了解析,然后讨论了面向对象程序的运行期机制,例如如何实例化一个对象,如何在内存里管理对象的数据,以及如何访问对象的属性和方法。 + +通过对类的语法和语义的剖析和运行机制的落地,我相信你会对面向对象的机制有更加本质的认识,也能更好地使用语言的面向对象特性了。 + +一课一思 + +我们用比较熟悉的语法实现了面向对象的基础特性,像Ruby、Go这样的语言,还有另外的机制来实现面向对象。思考一下,你所熟悉的语言的面向对象机制,在底层是如何实现的?它们在类型、作用域和生存期三个方面的特点是什么?欢迎在留言区分享你的发现。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +我将本节课相关代码的链接放在了文末,供你参考。 + + +playscript-java(项目目录): 码云 GitHub +PlayScript.java(入口程序): 码云 GitHub +PlayScript.g4(语法规则): 码云 GitHub +ASTEvaluator.java(解释器): 码云 GitHub +TypeAndScopeScanner.java(识别对象声明): 码云 GitHub +TypeResolver.java(消解变量声明中引用的类型): 码云 GitHub +RefResolver.java(消解变量引用和函数调用): 码云 GitHub +ClassTest.play(演示面向对象的基本特征): 码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/10闭包:理解了原理,它就不反直觉了.md b/专栏/编译原理之美/10闭包:理解了原理,它就不反直觉了.md new file mode 100644 index 0000000..d1e427e --- /dev/null +++ b/专栏/编译原理之美/10闭包:理解了原理,它就不反直觉了.md @@ -0,0 +1,478 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 10 闭包: 理解了原理,它就不反直觉了 + 在讲作用域和生存期时,我提到函数里的本地变量只能在函数内部访问,函数退出之后,作用域就没用了,它对应的栈桢被弹出,作用域中的所有变量所占用的内存也会被收回。 + +但偏偏跑出来闭包(Closure)这个怪物。 + +在JavaScript中,用外层函数返回一个内层函数之后,这个内层函数能一直访问外层函数中的本地变量。按理说,这个时候外层函数已经退出了,它里面的变量也该作废了。可闭包却非常执着,即使外层函数已经退出,但内层函数仿佛不知道这个事实一样,还继续访问外层函数中声明的变量,并且还真的能够正常访问。 + +不过,闭包是很有用的,对库的编写者来讲,它能隐藏内部实现细节;对面试者来讲,它几乎是前端面试必问的一个问题,比如如何用闭包特性实现面向对象编程?等等。 + +本节课,我会带你研究闭包的实现机制,让你深入理解作用域和生存期,更好地使用闭包特性。为此,要解决两个问题: + + +函数要变成playscript的一等公民。也就是要能把函数像普通数值一样赋值给变量,可以作为参数传递给其他函数,可以作为函数的返回值。 +要让内层函数一直访问它环境中的变量,不管外层函数退出与否。 + + +我们先通过一个例子,研究一下闭包的特性,看看它另类在哪里。 + +闭包的内在矛盾 + +来测试一下JavaScript的闭包特性: + +/** + * clojure.js + * 测试闭包特性 + * 作者:宫文学 + */ +var a = 0; + +var fun1 = function(){ + var b = 0; // 函数内的局部变量 + + var inner = function(){ // 内部的一个函数 + a = a+1; + b = b+1; + return b; // 返回内部的成员 + } + + return inner; // 返回一个函数 +} + +console.log("outside: a=%d", a); + +var fun2 = fun1(); // 生成闭包 +for (var i = 0; i< 2; i++){ + console.log("fun2: b=%d a=%d",fun2(), a); //通过fun2()来访问b +} + +var fun3 = fun1(); // 生成第二个闭包 +for (var i = 0; i< 2; i++){ + console.log("fun3: b=%d a=%d",fun3(), a); // b等于1,重新开始 +} + + +在Node.js环境下运行上面这段代码的结果如下: + +outside: a=0 +fun2: b=1 a=1 +fun2: b=2 a=2 +fun3: b=1 a=3 +fun3: b=2 a=4 + + +观察这个结果,可以得出两点: + + +内层的函数能访问它“看得见”的变量,包括自己的本地变量、外层函数的变量b和全局变量a。 +内层函数作为返回值赋值给其他变量以后,外层函数就结束了,但内层函数仍能访问原来外层函数的变量b,也能访问全局变量a。 + + +这样似乎让人感到困惑:站在外层函数的角度看,明明这个函数已经退出了,变量b应该失效了,为什么还可以继续访问?但是如果换个立场,站在inner这个函数的角度来看,声明inner函数的时候,告诉它可以访问b,不能因为把inner函数赋值给了其他变量,inner函数里原本正确的语句就不能用了啊。 + + + +其实,只要函数能作为值传来传去,就一定会产生作用域不匹配的情况,这样的内在矛盾是语言设计时就决定了的。我认为,闭包是为了让函数能够在这种情况下继续运行所提供的一个方案。这个方案有一些不错的特点,比如隐藏函数所使用的数据,歪打正着反倒成了一个优点了! + +在这里,我想补充一下静态作用域(Static Scope)这个知识点,如果一门语言的作用域是静态作用域,那么符号之间的引用关系能够根据程序代码在编译时就确定清楚,在运行时不会变。某个函数是在哪声明的,就具有它所在位置的作用域。它能够访问哪些变量,那么就跟这些变量绑定了,在运行时就一直能访问这些变量。 + +看一看下面的代码,对于静态作用域而言,无论在哪里调用foo()函数,访问的变量i都是全局变量: + +int i = 1; +void foo(){ + println(i); // 访问全局变量 +} + +foo(); // 访问全局变量 + +void bar(){ + int i = 2; + foo(); // 在这里调用foo(),访问的仍然是全局变量 +} + + +我们目前使用的大多数语言都是采用静态作用域的。playscript语言也是在编译时就形成一个Scope的树,变量的引用也是在编译时就做了消解,不再改变,所以也是采用了静态作用域。 + +反过来讲,如果在bar()里调用foo()时,foo()访问的是bar()函数中的本地变量i,那就说明这门语言使用的是动态作用域(Dynamic Scope)。也就是说,变量引用跟变量声明不是在编译时就绑定死了的。在运行时,它是在运行环境中动态地找一个相同名称的变量。在macOS或Linux中用的bash脚本语言,就是动态作用域的。 + +静态作用域可以由程序代码决定,在编译时就能完全确定,所以又叫做词法作用域(Lexcical Scope)。不过这个词法跟我们做词法分析时说的词法不大一样。这里,跟Lexical相对应的词汇可以认为是Runtime,一个是编写时,一个是运行时。 + +用静态作用域的概念描述一下闭包,我们可以这样说:因为我们的语言是静态作用域的,它能够访问的变量,需要一直都能访问,为此,需要把某些变量的生存期延长。 + +当然了,闭包的产生还有另一个条件,就是让函数成为一等公民。这是什么意思?我们又怎样实现呢? + +函数作为一等公民 + +在JavaScript和Python等语言里,函数可以像数值一样使用,比如给变量赋值、作为参数传递给其他函数,作为函数返回值等等。这时,我们就说函数是一等公民。 + +作为一等公民的函数很有用,比如它能处理数组等集合。我们给数组的map方法传入一个回调函数,结果会生成一个新的数组。整个过程很简洁,没有出现啰嗦的循环语句,这也是很多人提倡函数式编程的原因之一: + +var newArray = ["1","2","3"].map( + fucntion(value,index,array){ + return parseInt(value,10) + }) + + +那么在playscript中,怎么把函数作为一等公民呢? + +我们需要支持函数作为基础类型,这样就可以用这种类型声明变量。但问题来了,如何声明一个函数类型的变量呢? + +在JavaScript这种动态类型的语言里,我们可以把函数赋值给任何一个变量,就像前面示例代码里的那样:inner函数作为返回值,被赋给了fun2和fun3两个变量。 + +然而在Go语言这样要求严格类型匹配的语言里,就比较复杂了: + +type funcType func(int) int // Go语言,声明了一个函数类型funcType +var myFun funType // 用这个函数类型声明了一个变量 + + +它对函数的原型有比较严格的要求:函数必须有一个int型的参数,返回值也必须是int型的。 + +而C语言中函数指针的声明也是比较严格的,在下面的代码中,myFun指针能够指向一个函数,这个函数也是有一个int类型的参数,返回值也是int: + +int (*myFun) (int); //C语言,声明一个函数指针 + + +playscript也采用这种比较严格的声明方式,因为我们想实现一个静态类型的语言: + +function int (int) myFun; //playscript中声明一个函数型的变量 + + +写成上面这样是因为我个人喜欢把变量名称左边的部分看做类型的描述,不像Go语言把类型放在变量名称后面。最难读的就是C语言那种声明方式了,竟然把变量名放在了中间。当然,这只是个人喜好。 + +把上面描述函数类型的语法写成Antlr的规则如下: + +functionType + : FUNCTION typeTypeOrVoid '(' typeList? ')' + ; + +typeList + : typeType (',' typeType)* + ; + + +在playscript中,我们用FuntionType接口代表一个函数类型,通过这个接口可以获得返回值类型、参数类型这两个信息: + +package play; +import java.util.List; +/** + * 函数类型 + */ +public interface FunctionType extends Type { + public Type getReturnType(); //返回值类型 + public List getParamTypes(); //参数类型 +} + + +试一下实际使用效果如何,用Antlr解析下面这句的语法: + +function int(long, float) fun2 = fun1(); + + +它的意思是:调用fun1()函数会返回另一个函数,这个函数有两个参数,返回值是int型的。 + +我们用grun显示一下AST,你可以看到,它已经把functionType正确地解析出来了: + + + +目前,我们只是设计完了语法,还要实现运行期的功能,让函数真的能像数值一样传来传去,就像下面的测试代码,它把foo()作为值赋给了bar(): + +/* +FirstClassFunction.play 函数作为一等公民。 +也就是函数可以数值,赋给别的变量。 +支持函数类型,即FunctionType。 +*/ +int foo(int a){ + println("in foo, a = " + a); + return a; +} + +int bar (function int(int) fun){ + int b = fun(6); + println("in bar, b = " + b); + return b; +} + +function int(int) a = foo; //函数作为变量初始化值 +a(4); + +function int(int) b; +b = foo; //函数用于赋值语句 +b(5); + +bar(foo); //函数做为参数 + + +运行结果如下: + +in foo, a = 4 +in foo, a = 5 +in foo, a = 6 +in bar, b = 6 + + +运行这段代码,你会发现它实现了用函数来赋值,而实现这个功能的重点,是做好语义分析。比如编译程序要能识别赋值语句中的foo是一个函数,而不是一个传统的值。在调用a()和b()的时候,它也要正确地调用foo()的代码,而不是报“找不到a()函数的定义”这样的错误。 + +实现了一等公民函数的功能以后,我们进入本讲最重要的一环:实现闭包功能。 + +实现我们自己的闭包机制 + +在这之前,我想先设计好测试用例,所以先把一开始提到的那个JavaScript的例子用playscript的语法重写一遍,来测试闭包功能: + +/** + * clojure.play + * 测试闭包特性 + */ +int a = 0; + +function int() fun1(){ //函数的返回值是一个函数 + int b = 0; //函数内的局部变量 + + int inner(){ //内部的一个函数 + a = a+1; + b = b+1; + return b; //返回内部的成员 + } + + return inner; //返回一个函数 +} + +function int() fun2 = fun1(); +for (int i = 0; i< 3; i++){ + println("b = " + fun2() + ", a = "+a); +} + +function int() fun3 = fun1(); +for (int i = 0; i< 3; i++){ + println("b = " + fun3() + ", a = "+a); +} + + +代码的运行效果跟JavaScript版本的程序是一样的: + +b = 1, a = 1 +b = 2, a = 2 +b = 3, a = 3 +b = 1, a = 4 +b = 2, a = 5 +b = 3, a = 6 + + +这段代码的AST我也让grun显示出来了,并截了一部分图,你可以直观地看一下外层函数和内层函数的关系: + + + +现在,测试用例准备好了,我们着手实现一下闭包的机制。 + +前面提到,闭包的内在矛盾是运行时的环境和定义时的作用域之间的矛盾。那么我们把内部环境中需要的变量,打包交给闭包函数,它就可以随时访问这些变量了。 + +在AST上做一下图形化的分析,看看给fun2这个变量赋值的时候,发生了什么事情: + + + +简单地描述一下给fun2赋值时的执行过程: + + +先执行fun1()函数,内部的inner()函数作为返回值返回给调用者。这时,程序能访问两层作用域,最近一层是fun1(),里面有变量b;外层还有一层,里面有全局变量a。这时是把环境变量打包的最后的机会,否则退出fun1()函数以后,变量b就消失了。 + +然后把内部函数连同打包好的环境变量的值,创建一个FunctionObject对象,作为fun1()的返回值,给到调用者。 + +给fun2这个变量赋值。 + +调用fun2()函数。函数执行时,有一个私有的闭包环境可以访问b的值,这个环境就是第二步所创建的FunctionObject对象。 + + +最终,我们实现了闭包的功能。 + +在这个过程中,我们要提前记录下inner()函数都引用了哪些外部变量,以便对这些变量打包。这是在对程序做语义分析时完成的,你可以参考一下ClosureAnalyzer.java中的代码: + + /** + * 为某个函数计算闭包变量,也就是它所引用的外部环境变量。 + * 算法:计算所有的变量引用,去掉内部声明的变量,剩下的就是外部的。 + * @param function + * @return + */ +private Set calcClosureVariables(Function function){ + Set refered = variablesReferedByScope(function); + Set declared = variablesDeclaredUnderScope(function); + refered.removeAll(declared); + return refered; +} + + +下面是ASTEvaluator.java中把环境变量打包进闭包中的代码片段,它是在当前的栈里获取数据的: + +/** + * 为闭包获取环境变量的值 + * @param function 闭包所关联的函数。这个函数会访问一些环境变量。 + * @param valueContainer 存放环境变量的值的容器 + */ +private void getClosureValues(Function function, PlayObject valueContainer){ + if (function.closureVariables != null) { + for (Variable var : function.closureVariables) { + // 现在还可以从栈里取,退出函数以后就不行了 + LValue lValue = getLValue(var); + Object value = lValue.getValue(); + valueContainer.fields.put(var, value); + } + } +} + + +你可以把测试用例跑一跑,修改一下,试试其他闭包特性。 + +体验一下函数式编程 + +现在,我们已经实现了闭包的机制,函数也变成了一等公民。不经意间,我们似乎在一定程度上支持了函数式编程(functional programming)。 + +它是一种语言风格,有很多优点,比如简洁、安全等。备受很多程序员推崇的LISP语言就具备函数式编程特征,Java等语言也增加了函数式编程的特点。 + +函数式编程的一个典型特点就是高阶函数(High-order function)功能,高阶函数是这样一种函数,它能够接受其他函数作为自己的参数,javascript中数组的map方法,就是一个高阶函数。我们通过下面的例子测试一下高阶函数功能: + +/** +LinkedList.play +实现了一个简单的链表,并演示了高阶函数的功能,比如在javascript中常用的map功能, +它能根据遍历列表中的每个元素,执行一个函数,并返回一个新的列表。给它传不同的函数,会返回不同的列表。 +*/ +//链表的节点 +class ListNode{ + int value; + ListNode next; //下一个节点 + + ListNode (int v){ + value = v; + } +} + +//链表 +class LinkedList{ + ListNode start; + ListNode end; + + //添加新节点 + void add(int value){ + ListNode node = ListNode(value); + if (start == null){ + start = node; + end = node; + } + else{ + end.next = node; + end = node; + } + } + + //打印所有节点内容 + void dump(){ + ListNode node = start; + while (node != null){ + println(node.value); + node = node.next; + } + } + + //高阶函数功能,参数是一个函数,对每个成员做一个计算,形成一个新的LinkedList + LinkedList map(function int(int) fun){ + ListNode node = start; + LinkedList newList = LinkedList(); + while (node != null){ + int newValue = fun(node.value); + newList.add(newValue); + node = node.next; + } + return newList; + } +} + +//函数:平方值 +int square(int value){ + return value * value; +} + +//函数:加1 +int addOne(int value){ + return value + 1; +} + +LinkedList list = LinkedList(); +list.add(2); +list.add(3); +list.add(5); + +println("original list:"); +list.dump(); + +println(); +println("add 1 to each element:"); +LinkedList list2 = list.map(addOne); +list2.dump(); + +println(); +println("square of each element:"); +LinkedList list3 = list.map(square); +list3.dump(); + + +运行后得到的结果如下: + +original list: +2 +3 +5 + +add 1 to each element: +3 +4 +6 + +square of each element: +4 +9 +25 + + +高阶函数功能很好玩,你可以修改程序,好好玩一下。 + +课程小结 + +闭包这个概念,对于初学者来讲是一个挑战。其实,闭包就是把函数在静态作用域中所访问的变量的生存期拉长,形成一份可以由这个函数单独访问的数据。正因为这些数据只能被闭包函数访问,所以也就具备了对信息进行封装、隐藏内部细节的特性。 + +听上去是不是有点儿耳熟?封装,把数据和对数据的操作封在一起,这不就是面向对象编程嘛!一个闭包可以看做是一个对象。反过来看,一个对象是不是也可以看做一个闭包呢?对象的属性,也可以看做被方法所独占的环境变量,其生存期也必须保证能够被方法一直正常的访问。 + +你看,两个不相干的概念,在用作用域和生存期这样的话语体系去解读之后,就会很相似,在内部实现上也可以当成一回事。现在,你应该更清楚了吧? + +一课一思 + +思考一下我在开头提到的那个面试题:如何用闭包做类似面向对象的编程? + +其实,我在课程中提供了一个closure-mammal.play的示例代码,它完全用闭包的概念实现了面向对象编程的多态特征。而这个闭包的实现,是一种更高级的闭包,比普通的函数闭包还多了一点有用的特性,更像对象了。我希望你能发现它到底不同在哪里,也能在代码中找到实现这些特性的位置。 + +你能发现,我一直在讲作用域和生存期,不要嫌我啰嗦,把它们吃透,会对你使用语言有很大帮助。比如,有同学非常困扰JavaScript的this,我负责任地讲,只要对作用域有清晰的了解,你就能很容易地掌握this。 + +那么,关于作用域跟this之间的关联,如果你有什么想法,也欢迎在留言区分享。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友,特别是分享给那些还没搞清楚闭包的朋友。 + +本节课的示例代码放在了文末,供你参考。 + + +playscript-java(项目目录): 码云 GitHub +PlayScript.java(入口程序): 码云 GitHub +PlayScript.g4(语法规则): 码云 GitHub +ASTEvaluator.java(解释器,找找闭包运行期时怎么实现的): 码云 GitHub +ClosureAnalyzer.java(分析闭包所引用的环境变量):码云 GitHub +RefResolver.java(在这里看看函数型变量是怎么消解的): 码云 GitHub +closure.play(演示基本的闭包特征): 码云 GitHub +closure-fibonacci.play(用闭包实现了斐波那契数列计算):码云 GitHub +closure-mammal.play(用闭包实现了面向对象特性,请找找它比普通闭包强在哪里):码云 GitHub +FirstClassFunction.play(演示一等公民函数的特征):码云 GitHub +LinkedList.play(演示了高阶函数map):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/11语义分析(上):如何建立一个完善的类型系统?.md b/专栏/编译原理之美/11语义分析(上):如何建立一个完善的类型系统?.md new file mode 100644 index 0000000..f819214 --- /dev/null +++ b/专栏/编译原理之美/11语义分析(上):如何建立一个完善的类型系统?.md @@ -0,0 +1,245 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 11 语义分析(上):如何建立一个完善的类型系统? + 在做语法分析时我们可以得到一棵语法树,而基于这棵树能做什么,是语义的事情。比如,+号的含义是让两个数值相加,并且通常还能进行缺省的类型转换。所以,如果要区分不同语言的差异,不能光看语言的语法。比如Java语言和JavaScript在代码块的语法上是一样的,都是用花括号,但在语义上是不同的,一个有块作用域,一个没有。 + +这样看来,相比词法和语法的设计与处理,语义设计和分析似乎要复杂很多。虽然我们借作用域、生存期、函数等特性的实现涉猎了很多语义分析的场景,但离系统地掌握语义分析,还差一点儿火候。所以,为了帮你攻破语义分析这个阶段,我会用两节课的时间,再梳理一下语义分析中的重要知识,让你更好地建立起相关的知识脉络。 + +今天这节课,我们把注意力集中在类型系统这个话题上。 + +围绕类型系统产生过一些争论,有的程序员会拥护动态类型语言,有的会觉得静态类型语言好。要想探究这个问题,我们需要对类型系统有个清晰的了解,最直接的方式,就是建立一个完善的类型系统。 + +那么什么是类型系统?我们又该怎样建立一个完善的类型系统呢? + +其实,类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间怎么相互作用的(比如一个类型能否转换成另一个类型)。如果要建立一个完善的类型系统,形成对类型系统比较完整的认知,需要从两个方面出发: + + +根据领域的需求,设计自己的类型系统的特征。 +在编译器中支持类型检查、类型推导和类型转换。 + + +先从第一个方面出发看一下。 + +设计类型系统的特征 + +在进入这个话题之前,我想先问你一个有意义的问题:类型到底是什么?我们说一个类型的时候,究竟在说什么? + +要知道,在机器代码这个层面,其实是分不出什么数据类型的。在机器指令眼里,那就是0101,它并不对类型做任何要求,不需要知道哪儿是一个整数,哪儿代表着一个字符,哪儿又是内存地址。你让它做什么操作都可以,即使这个操作没有意义,比如把一个指针值跟一个字符相加。 + +那么高级语言为什么要增加类型这种机制呢? + +对类型做定义很难,但大家公认的有一个说法:类型是针对一组数值,以及在这组数值之上的一组操作。比如,对于数字类型,你可以对它进行加减乘除算术运算,对于字符串就不行。 + +所以,类型是高级语言赋予的一种语义,有了类型这种机制,就相当于定了规矩,可以检查施加在数据上的操作是否合法。因此类型系统最大的好处,就是可以通过类型检查降低计算出错的概率。所以,现代计算机语言都会精心设计一个类型系统,而不是像汇编语言那样完全不区分类型。 + +不过,类型系统的设计有很多需要取舍和权衡的方面,比如: + + +面向对象的拥护者希望所有的类型都是对象,而重视数据计算性能的人认为应该支持非对象化的基础数据类型; +你想把字符串作为原生数据类型,还是像Java那样只是一个普通的类? +是静态类型语言好还是动态类型语言好? +…… + + +虽然类型系统的设计有很多需要取舍和权衡的方面,但它最需要考虑的是,是否符合这门语言想解决的问题,我们用静态类型语言和动态类型语言分析一下。 + +根据类型检查是在编译期还是在运行期进行的,我们可以把计算机语言分为两类: + + +静态类型语言(全部或者几乎全部的类型检查是在编译期进行的)。 +动态类型语言(类型的检查是在运行期进行的)。 + + +静态类型语言的拥护者说: + + +因为编译期做了类型检查,所以程序错误较少,运行期不用再检查类型,性能更高。像C、Java和Go语言,在编译时就对类型做很多处理,包括检查类型是否匹配,以及进行缺省的类型转换,大大降低了程序出错的可能性,还能让程序运行效率更高,因为不需要在运行时再去做类型检查和转换。 + + +而动态类型语言的拥护者说: + + +静态语言太严格,还要一遍遍编译,编程效率低,用动态类型语言方便进行快速开发。JavaScript、Python、PHP等都是动态类型的。 + + +客观地讲,这些说法都有道理。目前的趋势是,某些动态类型语言在想办法增加一些机制,在编译期就能做类型检查,比如用TypeScript代替JavaScript编写程序,做完检查后再输出成JavaScript。而某些静态语言呢,却又发明出一些办法,部分地绕过类型检查,从而提供动态类型语言的灵活性。 + +再延伸一下,跟静态类型和动态类型概念相关联的,还有强类型和弱类型。强类型语言中,变量的类型一旦声明就不能改变,弱类型语言中,变量类型在运行期时可以改变。二者的本质区别是,强类型语言不允许违法操作,因为能够被检查出来,弱类型语言则从机制上就无法禁止违法操作,所以是不安全的。比如你写了一个表达式a*b。如果a和b这两个变量是数值,这个操作就没有问题,但如果a或b不是数值,那就没有意义了,弱类型语言可能就检查不出这类问题。 + +也就是,静态类型和动态类型说的是什么时候检查的问题,强类型和弱类型说的是就算检查,也检查不出来,或者没法检查的问题,这两组概念经常会被搞混,所以我在这里带你了解一下。 + +接着说回来。关于类型特征的取舍,是根据领域问题而定的。举例来说,很多人可能都觉得强类型更好,但对于儿童编程启蒙来说,他们最好尽可能地做各种尝试,如果必须遵守与类型有关的规则,程序总是跑不起来,可能会打击到他们。 + +对于playscript而言,因为目前是用来做教学演示的,所以我们尽可能地多涉及与类型处理有关的情况,供大家体会算法,或者在自己的工作中借鉴。 + +首先,playscript是静态类型和强类型的,所以几乎要做各种类型检查,你可以参考看看这些都是怎么做的。 + +第二,我们既支持对象,也支持原生的基础数据类型。这两种类型的处理特点不一样,你也可以借鉴一下。后面面向对象的一讲,我会再讲与之相关的子类型(Subtyping)和运行时类型信息(Run Time Type Information, RTTI)的概念,这里就不展开了。 + +第三,我们还支持函数作为一等公民,也就是支持函数的类型。函数的类型是它的原型,包括返回值和参数,原型一样的函数,就看做是同样类型的,可以进行赋值。这样,你也就可以了解实现函数式编程特性时,要处理哪些额外的类型问题。 + +接下来,我们来说一说如何做类型检查、类型推导和类型转换。 + +如何做类型检查、类型推导和类型转换 + +先来看一看,如果编写一个编译器,我们在做类型分析时会遇到哪些问题。以下面这个最简单的表达式为例,这个表达式在不同的情况下会有不同的运行结果: + +a = b + 10 + + + +如果b是一个浮点型,b+10的结果也是浮点型。如果b是字符串型的,有些语言也是允许执行+号运算的,实际的结果是字符串的连接。这个分析过程,就是类型推导(Type Inference)。 +当右边的值计算完,赋值给a的时候,要检查左右两边的类型是否匹配。这个过程,就是类型检查(Type Checking)。 +如果a的类型是浮点型,而右边传过来的是整型,那么一般就要进行缺省的类型转换(Type Conversion)。 + + +类型的检查、推导和转换是三个工作,可是采用的技术手段差不多,所以我们放在一起讲,先来看看类型的推导。 + +在早期的playscript的实现中,是假设运算符两边的类型都是整型的,并做了强制转换。 + +这在实际应用中,当然不够用,因为我们还需要用到其他的数据类型。那怎么办呢?在运行时再去判断和转换吗?当然可以,但我们还有更好的选择,就是在编译期先判断出表达式的类型来。比如下面这段代码,是在RefResolve.java中,推导表达式的类型: + +case PlayScriptParser.ADD: + if (type1 == PrimitiveType.String || + type2 == PrimitiveType.String){ + type = PrimitiveType.String; + } + else if (type1 instanceof PrimitiveType && + type2 instanceof PrimitiveType){ + //类型“向上”对齐,比如一个int和一个float,取float + type = PrimitiveType.getUpperType(type1,type2); + }else{ + at.log("operand should be PrimitiveType for additive operation", ctx); + } + break; + + +这段代码提到,如果操作符号两边有一边数据类型是String类型的,那整个表达式就是String类型的。如果是其他基础类型的,就要按照一定的规则进行类型的转换,并确定运算结果的类型。比如,+号一边是double类型的,另一边是int类型的,那就要把int型的转换成double型的,最后计算结果也是double类型的。 + +做了类型的推导以后,我们就可以简化运行期的计算,不需要在运行期做类型判断了: + +private Object add(Object obj1, Object obj2, Type targetType) { + Object rtn = null; + if (targetType == PrimitiveType.String) { + rtn = String.valueOf(obj1) + + String.valueOf(obj2); + } else if (targetType == PrimitiveType.Integer) { + rtn = ((Number)obj1).intValue() + + ((Number)obj2).intValue(); + } else if (targetType == PrimitiveType.Float) { + rtn = ((Number)obj1).floatValue()+ + ((Number)obj2).floatValue(); + } + ... + return rtn; +} + + +通过这个类型推导的例子,我们又可以引出S属性(Synthesized Attribute)的知识点。如果一种属性能够从下级节点推导出来,那么这种属性就叫做S属性,字面意思是综合属性,就是在AST中从下级的属性归纳、综合出本级的属性。更准确地说,是通过下级节点和自身来确定的。 + + + +与S属性相对应的是I属性(Inherited Attribute),也就是继承属性,即AST中某个节点的属性是由上级节点、兄弟节点和它自身来决定的,比如: + +int a; + + +变量a的类型是int,这个很直观,因为变量声明语句中已经指出了a的类型,但这个类型可不是从下级节点推导出来的,而是从兄弟节点推导出来的。 + +在PlayScript.g4中,变量声明的相关语法如下: + +variableDeclarators + : typeType variableDeclarator (',' variableDeclarator)* + ; + +variableDeclarator + : variableDeclaratorId ('=' variableInitializer)? + ; + +variableDeclaratorId + : IDENTIFIER ('[' ']')* + ; + +typeType + : (classOrInterfaceType| functionType | primitiveType) ('[' ']')* + ; + + +把int a;这样一个简单的变量声明语句解析成AST,就形成了一棵有两个分枝的树: + + + +这棵树的左枝,可以从下向上推导类型,所以类型属性也就是S属性。而右枝则必须从根节点(也就是variableDeclarators)往下继承类型属性,所以对于a这个节点来说,它的类型属性是I属性。 + +这里插一句,RefResolver.java实现了PlayScriptListener接口。这样,我们可以用标准的方法遍历AST。代码中的enterXXX()方法表示刚进入这个节点,exitXXX()方法表示退出这个节点,这时所有的子节点都已经遍历过了。在计算S属性时,我一定是在exitXXX()方法中,因为可以利用下级节点的类型推导出自身节点的类型。 + +很多现代语言会支持自动类型推导,例如Go语言就有两种声明变量的方式: + +var a int = 10 //第一种 +a := 10 //第二种 + + +第一种方式,a的类型是显式声明的;第二种方式,a的类型是由右边的表达式推导出来- +的。从生成的AST中,你能看到它们都是经历了从下到上的综合,再从上到下的继承的过程: + + + +说完了类型推导,我们再看看类型检查。 + +类型检查主要出现在几个场景中: + + +赋值语句(检查赋值操作左边和右边的类型是否匹配)。 +变量声明语句(因为变量声明语句中也会有初始化部分,所以也需要类型匹配)。 +函数传参(调用函数的时候,传入的参数要符合形参的要求)。 +函数返回值(从函数中返回一个值的时候,要符合函数返回值的规定)。 + + +类型检查还有一个特点:以赋值语句为例,左边的类型,是I属性,是从声明中得到的;右边的类型是S属性,是自下而上综合出来的。当左右两边的类型相遇之后,就要检查二者是否匹配,被赋值的变量要满足左边的类型要求。 + +如果匹配,自然没有问题,如果不完全匹配,也不一定马上报错,而是要看看是否能进行类型转换。比如,一般的语言在处理整型和浮点型的混合运算时,都能进行自动的转换。像JavaScript和SQL,甚至能够在算术运算时,自动将字符串转换成数字。在MySQL里,运行下面的语句,会得到3,它自动将’2’转换成了数字: + +select 1 + '2'; + + +这个过程其实是有风险的,这就像在强类型的语言中开了一个后门,绕过或部分绕过了编译器的类型检查功能。把父类转成子类的场景中,编译器顶多能检查这两个类之间是否有继承关系,如果连继承关系都没有,这当然能检查出错误,制止这种转换。但一个基类的子类可能是很多的,具体这个转换对不对,只有到运行期才能检查出错误来。C语言因为可以强制做各种转换,这个后门开的就更大了。不过这也是C语言要达到它的设计目的,必须具备的特性。 + +关于类型的处理,大家可以参考playscript的示例代码,里面有三个类可以看一看: + + +TypeResolver.java(做了自上而下的类型推导,也就是I属性的计算,包括变量- 声明、类的继承声明、函数声明)。 +RefResolver.java(有自下而上的类型推导的逻辑)。 +TypeChecker.java(类型检查)。 + + +课程小结 + +本节课我们重点探讨了语义分析和语言设计中的一个重要话题:类型系统。 + +理解类型系统,了解它的本质对我们学习语言会有很大的帮助。我希望在这个过程中,你不会再被静态类型和动态类型,强类型和弱类型这样的概念难倒,甚至可以质疑已有的一些观念。比如,如果你仔细研究,会发现静态类型和动态类型不是绝对的,静态类型的语言如Java,也会在运行期去处理一些类型检查。强类型和弱类型可能也不是绝对的,就像C语言,你如果不允许做任何强制类型转换,不允许指针越界,那它也就完全变成强类型的了。 + +掌握对计算机语言更深一点儿的理解能力,将会是你学习编译原理的额外回报! + +一课一思 + +针对今天讲的类型系统的知识,你所熟悉的语言是静态类型的,还是动态类型的?是强类型的,还是弱类型的?它的类型系统中有哪些你觉得有意思或者引起你困扰的设计?欢迎在留言区分享你的发现。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本节课相关的示例代码放在文末,供你参考。 + + +playscript-java(项目目录): 码云 GitHub +PlayScript.g4(语法规则): 码云 GitHub +TypeAndScopeScanner.java(类型和作用域扫描): 码云 GitHub +TypeResolver.java(自上而下的类型推导): 码云 GitHub +RefResolver.java(自下而上的类型推导): 码云 GitHub +TypeChecker.java(类型检查): 码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/12语义分析(下):如何做上下文相关情况的处理?.md b/专栏/编译原理之美/12语义分析(下):如何做上下文相关情况的处理?.md new file mode 100644 index 0000000..cba6bca --- /dev/null +++ b/专栏/编译原理之美/12语义分析(下):如何做上下文相关情况的处理?.md @@ -0,0 +1,270 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 12 语义分析(下):如何做上下文相关情况的处理? + 我们知道,词法分析和语法分析阶段,进行的处理都是上下文无关的。可仅凭上下文无关的处理,是不能完成一门强大的语言的。比如先声明变量,再用变量,这是典型的上下文相关的情况,我们肯定不能用上下文无关文法表达这种情况,所以语法分析阶段处理不了这个问题,只能在语义分析阶段处理。语义分析的本质,就是针对上下文相关的情况做处理。 + +我们之前讲到的作用域,是一种上下文相关的情况,因为如果作用域不同,能使用的变量也是不同的。类型系统也是一种上下文相关的情况,类型推导和类型检查都要基于上下文中相关的AST节点。 + +本节课,我们再讲两个这样的场景:引用的消解、左值和右值,然后再介绍上下文相关情况分析的一种方法:属性计算。这样,你会把语义分析就是上下文处理的本质掌握得更清楚,并掌握属性计算这个强大的方法。 + +我们先来说说引用的消解这个场景。 + +语义分析场景:引用的消解 + +在程序里使用变量、函数、类等符号时,我们需要知道它们指的是谁,要能对应到定义它们的地方。下面的例子中,当使用变量a时,我们需要知道它是全局变量a,还是fun()函数中的本地变量a。因为不同作用域里可能有相同名称的变量,所以必须找到正确的那个。这个过程,可以叫引用消解。 + +/* +scope.c +测试作用域 + */ +#include + +int a = 1; + +void fun() +{ + a = 2; //这是指全局变量a + int a = 3; //声明一个本地变量 + int b = a; //这个a指的是本地变量 + printf("in func: a=%d b=%d \n", a, b); +} + + +在集成开发环境中,当我们点击一个变量、函数或类,可以跳到定义它的地方。另一方面,当我们重构一个变量名称、方法名称或类名称的时候,所有引用它的地方都会同步修改。这是因为IDE分析了符号之间的交叉引用关系。 + +函数的引用消解比变量的引用消解还要更复杂一些。 + +它不仅要比对函数名称,还要比较参数和返回值(可以叫函数原型,又或者叫函数的类型)。我们在把函数提升为一等公民的时候,提到函数类型(FunctionType)的概念。两个函数的类型相同,需要返回值、参数个数、每个参数的类型都能匹配得上才行。 + +在面向对象编程语言中,函数引用的消解也很复杂。 + +当一个参数需要一个对象的时候,程序中提供其子类的一个实例也是可以的,也就是子类可以用在所有需要父类的地方,例如下面的代码: + +class MyClass1{} //父类 +class MyClass2 extends MyClass1{} //子类 + +MyClass1 obj1; +MyClass2 obj2; + +function fun(MyClass1 obj){} //参数需要父类的实例 + +fun(obj2); //提供子类的实例 + + +在C++语言中,引用的消解还要更加复杂。 + +它还要考虑某个实参是否能够被自动转换成形参所要求的类型,比如在一个需要double类型的地方,你给它传一个int也是可以的。 + +命名空间也是做引用消解的时候需要考虑的因素。 + +像Java、C++都支持命名空间。如果在代码前头引入了某个命名空间,我们就可以直接引用里面的符号,否则需要冠以命名空间。例如: + +play.PlayScriptCompiler.Compile() //Java语言 +play::PlayScriptCompiler.Compile() //C++语言 + + +而做引用消解可能会产生几个结果: + + +解析出了准确的引用关系。 +重复定义(在声明新的符号的时候,发现这个符号已经被定义过了)。 +引用失败(找不到某个符号的定义)。 +如果两个不同的命名空间中都有相同名称的符号,编程者需要明确指定。 + + +在playscript中,引用消解的结果被存到了AnnotatedTree.java类中的symbolOfNode属性中去了,从它可以查到某个AST节点引用的到底是哪个变量或函数,从而在运行期正确的执行,你可以看一下代码,了解引用消解和使用的过程。 + +了解完引用的消解之后,接下来,我们再讲一个很有意思的场景:左值和右值。 + +语义分析场景:左值和右值 + +在开发编译器或解释器的过程中,你一定会遇到左值和右值的问题。比如,在playscript的ASTEvaluate.java中,我们在visitPrimary节点可以对变量求值。如果是下面语句中的a,没有问题,把a变量的值取出来就好了: + +a + 3; + + +可是,如果针对的是赋值语句,a在等号的左边,怎么对a求值呢? + +a = 3; + + +假设a变量原来的值是4,如果还是把它的值取出来,那么成了3=4,这就变得没有意义了。所以,不能把a的值取出来,而应该取出a的地址,或者说a的引用,然后用赋值操作把3这个值写到a的内存地址。这时,我们说取出来的是a的左值(L-value)。 + +左值最早是在C语言中提出的,通常出现在表达式的左边,如赋值语句的左边。左值取的是变量的地址(或者说变量的引用),获得地址以后,我们就可以把新值写进去了。 + +与左值相对应的就是右值(R-value),右值就是我们通常所说的值,不是地址。 + +在上面这两种情况下,变量a在AST中都是对应同一个节点,也就是primary节点。那这个节点求值时是该返回左值还是右值呢?这要借助上下文来分析和处理。如果这个primary节点存在于下面这几种情况中,那就需要取左值: + + +赋值表达式的左边; +带有初始化的变量声明语句中的变量; +当给函数形参赋值的时候; +一元操作符: ++和–。 +其他需要改变变量内容的操作。 + + +在讨论primary节点在哪种情况下取左值时,我们可以引出另一个问题:不是所有的表达式,都能生成一个合格的左值。也就是说,出现在赋值语句左边的,必须是能够获得左值的表达式。比如一个变量是可以的,一个类的属性也是可以的。但如果是一个常量,或者2+3这样的表达式在赋值符号的左边,那就不行。所以,判断表达式能否生成一个合格的左值也是语义检查的一项工作。 + +借上节课讲过的S属性和I属性的概念,我们把刚才说的两个情况总结成primay节点的两个属性,你可以判断一下,这两个属性是S属性还是I属性? + + +属性1:某primary节点求值时,是否应该求左值? +属性2:某primary节点求值时,能否求出左值? + + +你可能发现了,这跟我们类型检查有点儿相似,一个是I属性,一个是S属性,两个一比对,就能检查求左值的表达式是否合法。从这儿我们也能看出,处理上下文相关的情况,经常用属性计算的方法。接下来,我们就谈谈如何做属性计算。 + +如何做属性计算 + +属性计算是做上下文分析,或者说语义分析的一种算法。按照属性计算的视角,我们之前所处理的各种语义分析问题,都可以看做是对AST节点的某个属性进行计算。比如,针对求左值场景中的primary节点,它需要计算的属性包括: + + +它的变量定义是哪个(这就引用到定义该变量的Symbol)。 +它的类型是什么? +它的作用域是什么? +这个节点求值时,是否该返回左值?能否正确地返回一个左值? +它的值是什么? + + +从属性计算的角度看,对表达式求值,或运行脚本,只是去计算AST节点的Value属性,Value这个属性能够计算,其他属性当然也能计算。 + +属性计算需要用到属性文法。在词法、语法分析阶段,我们分别学习了正则文法和上下文无关文法,在语义分析阶段我们要了解的是属性文法(Attribute Grammar)。 + +属性文法的主要思路是计算机科学的重要开拓者,高德纳(Donald Knuth)在《The Genesis of Attribute Grammers》中提出的。它是在上下文无关文法的基础上做了一些增强,使之能够计算属性值。下面是上下文无关文法表达加法和乘法运算的例子: + +add → add + mul +add → mul +mul → mul * primary +mul → primary +primary → "(" add ")" +primary → integer + + +然后看一看对value属性进行计算的属性文法: + +add1 → add1 + mul [ add1.value = add2.value + mul.value ] +add → mul [ add.value = mul.value ] +mul1 → mul2 * primary [ mul1.value = mul2.value * primary.value ] +mul → primary [ mul.value = primary.value ] +primary → "(" add ")" [ primary.value = add.value ] +primary → integer [ primary.value = strToInt(integer.str) ] + + +利用属性文法,我们可以定义规则,然后用工具自动实现对属性的计算。有同学曾经问:“我们解析表达式2+3的时候,得到一个AST,但我怎么知道它运算的时候是做加法呢?” + +因为我们可以在语法规则的基础上制定属性文法,在解析语法的过程中或者形成AST之后,我们就可以根据属性文法的规则做属性计算。比如在Antlr中,你可以在语法规则文件中插入一些代码,在语法分析的过程中执行你的代码,完成一些必要的计算。 + +总结一下属性计算的特点:它会基于语法规则,增加一些与语义处理有关的规则。 + +所以,我们也把这种语义规则的定义叫做语法制导的定义(Syntax directed definition,SDD),如果变成计算动作,就叫做语法制导的翻译(Syntax directed translation,SDT)。 + +属性计算,可以伴随着语法分析的过程一起进行,也可以在做完语法分析以后再进行。这两个阶段不一定完全切分开。甚至,我们有时候会在语法分析的时候做一些属性计算,然后把计算结果反馈回语法分析的逻辑,帮助语法分析更好地执行(这是在工程实践中会运用到的一个技巧,我这里稍微做了一个延展,帮大家开阔一下思路,免得把知识学得太固化了)。 + +那么,在解析语法的时候,如何同时做属性计算呢?我们知道,解析语法的过程,是逐步建立AST的过程。在这个过程中,计算某个节点的属性所依赖的其他节点可能被创建出来了。比如在递归下降算法中,当某个节点建立完毕以后,它的所有子节点一定也建立完毕了,所以S属性就可以计算出来了。同时,因为语法解析是从左向右进行的,它左边的兄弟节点也都建立起来了。 + +如果某个属性的计算,除了可能依赖子节点以外,只依赖左边的兄弟节点,不依赖右边的,这种属性就叫做L属性。它比S属性的范围更大一些,包含了部分的I属性。由于我们常用的语法分析的算法都是从左向右进行的,所以就很适合一边解析语法,一边计算L属性。 + +比如,C语言和Java语言进行类型分析,都可以用L属性的计算来实现。因为这两门语言的类型要么是从下往上综合出来的,属于S属性。要么是在做变量声明的时候,由声明中的变量类型确定的,类型节点在变量的左边。 + +2+3; //表达式类型是整型 +float a; //a的类型是浮点型 + + +那问题来了,Go语言的类型声明是放在变量后面的,这意味着类型节点一定是在右边的,那就不符合L属性文法了: + +var a int = 10 + + +没关系,我们没必要在语法分析阶段把属性全都计算出来,等到语法分析完毕后,再对AST遍历一下就好了。这时所有节点都有了,计算属性也就不是难事了。 + +在我们的playscript语言里,就采取了这种策略,实际上,为了让算法更清晰,我把语义分析过程拆成了好几个任务,对AST做了多次遍历。 + +第1遍:类型和作用域解析(TypeAndScopeScanner.java)。 + +把自定义类、函数和和作用域的树都分析出来。这么做的好处是,你可以使用在前,声明在后。比如你声明一个Mammal对象,而Mammal类的定义是在后面才出现的;在定义一个类的时候,对于类的成员也会出现使用在声明之前的情况,把类型解析先扫描一遍,就能方便地支持这个特性。 + +在写属性计算的算法时,计算的顺序可能是个最重要的问题。因为某属性的计算可能要依赖别的节点的属性先计算完。我们讨论的S属性、I属性和L属性,都是在考虑计算顺序。像使用在前,声明在后这种情况,就更要特殊处理了。 + +第2遍:类型的消解(TypeResolver.java)。 + +把所有出现引用到类型的地方,都消解掉,比如变量声明、函数参数声明、类的继承等等。做完消解以后,我们针对Mammal m;这样语句,就明确的知道了m的类型。这实际上是对I属性的类型的计算。 + +第3遍:引用的消解和S属性的类型的推导(RefResolver.java)。 + +这个时候,我们对所有的变量、函数调用,都会跟它的定义关联起来,并且完成了所有的类型计算。 + +第4遍:做类型检查(TypeChecker.java)。 + +比如当赋值语句左右两边的类型不兼容的时候,就可以报错。 + +第5遍:做一些语义合法性的检查(SematicValidator.java)。 + +比如break只能出现在循环语句中,如果某个函数声明了返回值,就一定要有return语句,等等。 + +语义分析的结果保存在AnnotatedTree.java类里,意思是被标注了属性的语法树。注意,这些属性在数据结构上,并不一定是AST节点的属性,我们可以借助Map等数据结构存储,只是在概念上,这些属性还是标注在树节点上的。 + +AnnotatedTree类的结构如下: + +public class AnnotatedTree { + // AST + protected ParseTree ast = null; + + // 解析出来的所有类型,包括类和函数 + protected List types = new LinkedList(); + + // AST节点对应的Symbol + protected Map symbolOfNode = new HashMap(); + + // AST节点对应的Scope,如for、函数调用会启动新的Scope + protected Map node2Scope = new HashMap(); + + // 每个节点推断出来的类型 + protected Map typeOfNode = new HashMap(); + + // 命名空间,作用域的根节点 + NameSpace nameSpace = null; + + ... +} + + +我建议你看看这些语义分析的代码,了解一下如何保证语义分析的全面性。 + +课程小结 + +本节课我带你继续了解了语义分析的相关知识: + + +语义分析的本质是对上下文相关情况的处理,能做词法分析和语法分析所做不到的事情。 +了解引用消解,左值和右值的场景,可以增加对语义分析的直观理解。 +掌握属性计算和属性文法,可以使我们用更加形式化、更清晰的算法来完成语义分析的任务。 + + +在我看来,语义分析这个阶段十分重要。因为词法和语法都有很固定的套路,甚至都可以工具化的实现。但语言设计的核心在于语义,特别是要让语义适合所解决的问题。比如SQL语言针对的是数据库的操作,那就去充分满足这个目标就好了。我们在前端技术的应用篇中,也会复盘讨论这个问题,不断实现认知的迭代升级。 + +如果想做一个自己领域的DSL,学习了这几讲语义分析的内容之后,你会更好地做语义特性的设计与取舍,也会对如何完成语义分析有清晰的思路。 + +一课一思 + +基于你熟悉的语言,来说说你觉得在语义分析阶段还有哪些上下文处理工作要做?需要计算出哪些属性?它们是I属性还是S属性?起到什么作用?这个思考练习很有意思,欢迎在留言区分享你的发现。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本节课相关的示例代码放在文末,供你参考。 + + +playscript-java(项目目录): 码云 GitHub +PlayScript.g4(语法规则): 码云 GitHub +TypeAndScopeScanner.java(类型和作用域扫描): 码云 GitHub +TypeResolver.java(消解变量声明中引用的类型): 码云 GitHub +RefResolver.java(变量和函数应用的消解,及S属性的类型推断): 码云 GitHub +TypeChecker.java(类型检查): 码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/13继承和多态:面向对象运行期的动态特性.md b/专栏/编译原理之美/13继承和多态:面向对象运行期的动态特性.md new file mode 100644 index 0000000..696155e --- /dev/null +++ b/专栏/编译原理之美/13继承和多态:面向对象运行期的动态特性.md @@ -0,0 +1,305 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 继承和多态:面向对象运行期的动态特性 + 面向对象是一个比较大的话题。在“09 | 面向对象:实现数据和方法的封装”中,我们了解了面向对象的封装特性,也探讨了对象成员的作用域和生存期特征等内容。本节课,我们再来了解一下面向对象的另外两个重要特征:继承和多态。 + +你也许会问,为什么没有在封装特性之后,马上讲继承和多态呢?那是因为继承和多态涉及的语义分析阶段的知识点比较多,特别是它对类型系统提出了新的概念和挑战,所以我们先掌握语义分析,再了解这部分内容,才是最好的选择。 + +继承和多态对类型系统提出的新概念,就是子类型。我们之前接触的类型往往是并列关系,你是整型,我是字符串型,都是平等的。而现在,一个类型可以是另一个类型的子类型,比如我是一只羊,又属于哺乳动物。这会导致我们在编译期无法准确计算出所有的类型,从而无法对方法和属性的调用做完全正确的消解(或者说绑定)。这部分工作要留到运行期去做,也因此,面向对象编程会具备非常好的优势,因为它会导致多态性。这个特性会让面向对象语言在处理某些类型的问题时,更加优雅。 + +而我们要想深刻理解面向对象的特征,就必须了解子类型的原理和运行期的机制。所以,接下来,我们从类型体系的角度理解继承和多态,然后看看在编译期需要做哪些语义分析,再考察继承和多态的运行期特征。 + +从类型体系的角度理解继承和多态 + +继承的意思是一个类的子类,自动具备了父类的属性和方法,除非被父类声明为私有的。比如一个类是哺乳动物,它有体重(weight)的属性,还会做叫(speak)的操作。如果基于哺乳动物这个父类创建牛和羊两个子类,那么牛和羊就自动继承了哺乳动物的属性,有体重,还会叫。 + +所以继承的强大之处,就在于重用。也就是有些逻辑,如果在父类中实现,在子类中就不必重复实现。 + +多态的意思是同一个类的不同子类,在调用同一个方法时会执行不同的动作。这是因为每个子类都可以重载掉父类的某个方法,提供一个不同的实现。哺乳动物会“叫”,而牛和羊重载了这个方法,发出“哞~”和“咩~”的声音。这似乎很普通,但如果创建一个哺乳动物的数组,并在里面存了各种动物对象,遍历这个数组并调用每个对象“叫”的方法时,就会发出“哞~”“咩~”“喵~”等各种声音,这就有点儿意思了。 + +下面这段示例代码,演示了继承和多态的特性,a的speak()方法和b的speak()方法会分别打印出牛叫和羊叫,调用的是子类的方法,而不是父类的方法: + +/** +mammal.play 演示面向对象编程:继承和多态。 +*/ +class Mammal{ + int weight = 20; + boolean canSpeak(){ + return true; + } + + void speak(){ + println("mammal speaking..."); + } +} + +class Cow extends Mammal{ + void speak(){ + println("moo~~ moo~~"); + } +} + +class Sheep extends Mammal{ + void speak(){ + println("mee~~ mee~~"); + println("My weight is: " + weight); //weight的作用域覆盖子类 + } +} + +//将子类的实例赋给父类的变量 +Mammal a = Cow(); +Mammal b = Sheep(); + +//canSpeak()方法是继承的 +println("a.canSpeak() : " + a.canSpeak()); +println("b.canSpeak() : " + b.canSpeak()); + +//下面两个的叫声会不同,在运行期动态绑定方法 +a.speak(); //打印牛叫 +b.speak(); //打印羊叫 + + +所以,多态的强大之处,在于虽然每个子类不同,但我们仍然可以按照统一的方式使用它们,做到求同存异。以前端工程师天天打交道的前端框架为例,这是最能体现面向对象编程优势的领域之一。 + +前端界面往往会用到各种各样的小组件,比如静态文本、可编辑文本、按钮等等。如果我们想刷新组件的显示,没必要针对每种组件调用一个方法,把所有组件的类型枚举一遍,可以直接调用父类中统一定义的方法redraw(),非常简洁。即便将来添加新的前端组件,代码也不需要修改,程序也会更容易维护。 + +总结一下:面向对象编程时,我们可以给某个类创建不同的子类,实现一些个性化的功能;写程序时,我们可以站在抽象度更高的层次上,不去管具体的差异。 + +如果把上面的结论抽象成一般意义上的类型理论,就是子类型(subtype)。 + +子类型(或者动名词:子类型化),是对我们前面讲的类型体系的一个补充。 + +子类型的核心是提供了is-a的操作。也就是对某个类型所做的所有操作都可以用子类型替代。因为子类型 is a 父类型,也就是能够兼容父类型,比如一只牛是哺乳动物。 + +这意味着只要对哺乳动物可以做的操作,都可以对牛来做,这就是子类型的好处。它可以放宽对类型的检查,从而导致多态。你可以粗略地把面向对象的继承看做是子类型化的一个体现,它的结果就是能用子类代替父类,从而导致多态。 + +子类型有两种实现方式:一种就是像Java和C++语言,需要显式声明继承了什么类,或者实现了什么接口。这种叫做名义子类型(Nominal Subtyping)。 + +另一种是结构化子类型(Structural Subtyping),又叫鸭子类型(Duck Type)。也就是一个类不需要显式地说自己是什么类型,只要它实现了某个类型的所有方法,那就属于这个类型。鸭子类型是个直观的比喻,如果我们定义鸭子的特征是能够呱呱叫,那么只要能呱呱叫的,就都是鸭子。 + +了解了继承和多态之后,我们看看在编译期如何对继承和多态的特性做语义分析。 + +如何对继承和多态的特性做语义分析 + +针对哺乳动物的例子,我们用前面语义分析的知识,看看如何在编译期针对继承和多态做语义分析,也算对语义分析的知识点进行应用和复盘。 + +首先,从类型处理的角度出发,我们要识别出新的类型:Mammal、Cow和Sheep。之后,就可以用它们声明变量了。 + +第二,我们要设置正确的作用域。 + +从作用域的角度来看,一个类的属性(或者说成员变量),是可以规定能否被子类访问的。以Java为例,除了声明为private的属性以外,其他属性在子类中都是可见的。所以父类的属性的作用域,可以说是以树状的形式覆盖到了各级子类: + + + +第三,要对变量和函数做类型的引用消解。 + +也就是要分析出a和b这两个变量的类型。那么a和b的类型是什么呢?是父类Mammal?还是Cow或Sheep? + +注意,代码里是用Mammal来声明这两个变量的。按照类型推导的算法,a和b都是Mammal,这是个I属性计算的过程。也就是说,在编译期,我们无法知道变量被赋值的对象确切是哪个子类型,只知道声明变量时,它们是哺乳动物类型,至于是牛还是羊,就不清楚了。 + +你可能会说:“不对呀,我在编译的时候能知道a和b的准确类型啊,因为我看到了a是一个Cow对象,而b是一个Sheep,代码里有这两个对象的创建过程,我可以推导出a和b的实际类型呀。” + +没错,语言的确有自动类型推导的特性,但你忽略了限制条件。比如,强类型机制要求变量的类型一旦确定,在运行过程就不能再改,所以要让a和b能够重新指向其他的对象,并保持类型不变。从这个角度出发,a和b的类型只能是父类Mammal。 + +所以说,编译期无法知道变量的真实类型,可能只知道它的父类型,也就是知道它是一个哺乳动物,但不知道它具体是牛还是羊。这会导致我们没法正确地给speak()方法做引用消解。正确的消解,是要指向Cow和Sheep的speak方法,而我们只能到运行期再解决这个问题。 + +所以接下来,我们就讨论一下如何在运行期实现方法的动态绑定。 + +如何在运行期实现方法的动态绑定 + +在运行期,我们能知道a和b这两个变量具体指向的是哪个对象,对象里是保存了真实类型信息的。具体来说,在playscript中,ClassObject的type属性会指向一个正确的Class,这个类型信息是在创建对象的时候被正确赋值的: + + + +在调用类的属性和方法时,我们可以根据运行时获得的,确定的类型信息进行动态绑定。下面这段代码是从本级开始,逐级查找某个方法的实现,如果本级和父类都有这个方法,那么本级的就会覆盖掉父类的,这样就实现了多态: + +protected Function getFunction(String name, List paramTypes){ + //在本级查找这个这个方法 + Function rtn = super.getFunction(name, paramTypes); //TODO 是否要检查visibility + + //如果在本级找不到,那么递归的从父类中查找 + if (rtn == null && parentClass != null){ + rtn = parentClass.getFunction(name,paramTypes); + } + + return rtn; +} + + +如果当前类里面没有实现这个方法,它可以直接复用某一级的父类中的实现,这实际上就是继承机制在运行期的原理。 + +你看,只有了解运行期都发生了什么,才能知道继承和多态是怎么发生的吧。 + +这里延伸一下。我们刚刚谈到,在运行时可以获取类型信息,这种机制就叫做运行时类型信息(Run Time Type Information, RTTI)。C++、Java等都有这种机制,比如Java的instanceof操作,就能检测某个对象是不是某个类或者其子类的实例。 + +汇编语言是无类型的,所以一般高级语言在编译成目标语言之后,这些高层的语义就会丢失。如果要在运行期获取类型信息,需要专门实现RTTI的功能,这就要花费额外的存储开销和计算开销。就像在playscript中,我们要在ClassObject中专门拿出一个字段来存type信息。 + +现在,我们已经了解如何在运行期获得类型信息,实现方法的动态绑定。接下来,我带你了解一下运行期的对象的逐级初始化机制。 + +继承情况下对象的实例化 + +在存在继承关系的情况下,创建对象时,不仅要初始化自己这一级的属性变量,还要把各级父类的属性变量也都初始化。比如,在实例化Cow的时候,还要对Mammal的成员变量weight做初始化。 + +所以我们要修改playscript中对象实例化的代码,从最顶层的祖先起,对所有的祖先层层初始化: + +//从父类到子类层层执行缺省的初始化方法,即不带参数的初始化方法 +protected ClassObject createAndInitClassObject(Class theClass) { + ClassObject obj = new ClassObject(); + obj.type = theClass; + + Stack ancestorChain = new Stack(); + + // 从上到下执行缺省的初始化方法 + ancestorChain.push(theClass); + while (theClass.getParentClass() != null) { + ancestorChain.push(theClass.getParentClass()); + theClass = theClass.getParentClass(); + } + + // 执行缺省的初始化方法 + StackFrame frame = new StackFrame(obj); + pushStack(frame); + while (ancestorChain.size() > 0) { + Class c = ancestorChain.pop(); + defaultObjectInit(c, obj); + } + popStack(); + + return obj; +} + + +在逐级初始化的过程中,我们要先执行缺省的成员变量初始化,也就是变量声明时所带的初始化部分,然后调用这一级的构造方法。如果不显式指定哪个构造方法,就会执行不带参数的构造方法。不过有的时候,子类会选择性地调用父类某一个构造方法,就像Java可以在构造方法里通过super()来显式地调用父类某个具体构造方法。 + +如何实现this和super + +现在,我们已经了解了继承和多态在编译期和运行期的特性。接下来,我们通过一个示例程序,把本节课的所有知识复盘检验一下,加深对它们的理解,也加深对this和super机制的理解。 + +这个示例程序是用Java写的,在Java语言中,为面向对象编程专门提供了两个关键字:this和super,这两个关键字特别容易引起混乱。 + +比如在下面的ThisSuperTest.Java代码中,Mammal和它的子类Cow都有speak()方法。如果我们要创建一个Cow对象,会调用Mammal的构造方法Mammal(int weight),而在这个构造方法里调用的this.speak()方法,是Mammal的,还是Cow的呢? + +package play; + +public class ThisSuperTest { + + public static void main(String args[]){ + //创建Cow对象的时候,会在Mammal的构造方法里调用this.reportWeight(),这里会显示什么 + Cow cow = new Cow(); + + System.out.println(); + + //这里调用,会显示什么 + cow.speak(); + } +} + +class Mammal{ + int weight; + + Mammal(){ + System.out.println("Mammal() called"); + this.weight = 100; + } + + Mammal(int weight){ + this(); //调用自己的另一个构造函数 + System.out.println("Mammal(int weight) called"); + this.weight = weight; + + //这里访问属性,是自己的weight + System.out.println("this.weight in Mammal : " + this.weight); + + //这里的speak()调用的是谁,会显示什么数值 + this.speak(); + } + + void speak(){ + System.out.println("Mammal's weight is : " + this.weight); + } +} + + +class Cow extends Mammal{ + int weight = 300; + + Cow(){ + super(200); //调用父类的构造函数 + } + + void speak(){ + System.out.println("Cow's weight is : " + this.weight); + System.out.println("super.weight is : " + super.weight); + } +} + + +运行结果如下: + +Mammal() called +Mammal(int weight) called +this.weight in Mammal : 200 +Cow's weight is : 0 +super.weight is : 200 + +Cow's weight is : 300 +super.weight is : 200 + + +答案是Cow的speak()方法,而不是Mammal的。怎么回事?代码里不是调用的this.speak()吗?怎么这个this不是Mammal,却变成了它的子类Cow呢? + +其实,在这段代码中,this用在了三个地方: + + +this.weight 是访问自己的成员变量,因为成员变量的作用域是这个类本身,以及子类。 +this()是调用自己的另一个构造方法,因为这是构造方法,肯定是做自身的初始化。换句话说,构造方法不存在多态问题。 +this.speak()是调用一个普通的方法。这时,多态仍会起作用。运行时会根据对象的实际类型,来绑定到Cow的speak()方法上。 + + +只不过,在Mammal的构造方法中调用this.speak()时,虽然访问的是Cow的speak()方法,打印的是Cow中定义的weight成员变量,但它的值却是0,而不是成员变量声明时“int weight = 300;”的300。为什么呢? + +要想知道这个答案,我们需要理解多层继承情况下对象的初始化过程。在Mammal的构造方法中调用speak()的时候,Cow的初始化过程还没有开始呢,所以“int weight = 300;”还没有执行,Cow的weight属性还是缺省值0。 + +怎么样?一个小小的例子,却需要用到三个方面的知识:面向对象的成员变量的作用域、多态、对象初始化。Java程序员可以拿这个例子跟同事讨论一下,看看是不是很好玩。 + +讨论完this,super就比较简单了,它的语义要比this简单,不会出现歧义。super的调用,也是分成三种情况: + + +super.weight。这是调用父类或更高的祖先的weight属性,而不是Cow这一级的weight属性。不一定非是直接父类,也可以是祖父类中的。根据变量作用域的覆盖关系,只要是比Cow这一级高的就行。 +super(200)。这是调用父类的构造方法,必须是直接父类的。 +super.speak()。跟访问属性的逻辑一样,是调用父类或更高的祖先的speak()方法。 + + +课程小结 + +这节课我带你实现了面向对象中的另两个重要特性:继承和多态。在这节课中,我建议你掌握的重点内容是: + + +从类型的角度,面向对象的继承和多态是一种叫做子类型的现象,子类型能够放宽对类型的检查,从而支持多态。 +在编译期,无法准确地完成对象方法和属性的消解,因为无法确切知道对象的子类型。 +在运行期,我们能够获得对象的确切的子类型信息,从而绑定正确的方法和属性,实现继承和多态。另一个需要注意的运行期的特征,是对象的逐级初始化过程。 + + +面向对象涉及了这么多精彩的知识点,拿它作为前端技术原理篇的最后一讲,是正确的选择。到目前为止,我们已经讲完了前端技术的原理篇,也如约拥有了一门具备丰富特性的脚本语言,甚至还支持面向对象编程、闭包、函数式编程这些很高级的特性。一般的应用项目所需要的语言特性,很难超过这个范围了。接下来的两节,我们就通过两个具体的应用案例,来检验一下学到的编译原理前端技术,看看它的威力! + +一课一思 + +本节课我们深入讨论了面向对象的继承和多态特征。那么你所熟悉的框架,有没有充分利用继承和多态的特点实现一些很有威力的功能?或者,你有没有利用多态的特点,写过一些比较有用的类库或框架呢?欢迎在留言区分享你的经验。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本节课的示例代码我放在了文末,供你参考。 + + +playscript-java(项目目录): 码云 GitHub +ASTEvaluator.java(解释器,请找一下运行期方法和属性动态绑定,以及对象实例逐级初始化的代码): 码云 GitHub +ThisSuperTest.java(测试Java的this和super特性):码云 GitHub +this-and-super.play (playscript的this和super特性):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/14前端技术应用(一):如何透明地支持数据库分库分表?.md b/专栏/编译原理之美/14前端技术应用(一):如何透明地支持数据库分库分表?.md new file mode 100644 index 0000000..8e2be49 --- /dev/null +++ b/专栏/编译原理之美/14前端技术应用(一):如何透明地支持数据库分库分表?.md @@ -0,0 +1,289 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 14 前端技术应用(一):如何透明地支持数据库分库分表? + 从今天开始,我们正式进入了应用篇,我会用两节课的时间,带你应用编译器的前端技术。这样,你会把学到的编译技术和应用领域更好地结合起来,学以致用,让技术发挥应有的价值。还能通过实践加深对原理的理解,形成一个良好的循环。 + +这节课,我们主要讨论,一个分布式数据库领域的需求。我会带你设计一个中间层,让应用逻辑不必关心数据库的物理分布。这样,无论把数据库拆成多少个分库,编程时都会像面对一个物理库似的没什么区别。 + +接下来,我们先了解一下分布式数据库的需求和带来的挑战。 + +分布式数据库解决了什么问题,又带来了哪些挑战 + +随着技术的进步,我们编写的应用所采集、处理的数据越来越多,处理的访问请求也越来越多。而单一数据库服务器的处理能力是有限的,当数据量和访问量超过一定级别以后,就要开始做分库分表的操作。比如,把一个数据库的大表拆成几张表,把一个库拆成几个库,把读和写的操作分离开等等。我们把这类技术统称为分布式数据库技术。 + +分库分表(Sharding)有时也翻译成“数据库分片”。分片可以依据各种不同的策略,比如我开发过一个与社区有关的应用系统,这个系统的很多业务逻辑都是围绕小区展开的。对于这样的系统,按照地理分布的维度来分片就很合适,因为每次对数据库的操作基本上只会涉及其中一个分库。 + +假设我们有一个订单表,那么就可以依据一定的规则对订单或客户进行编号,编号中就包含地理编码。比如SDYT代表山东烟台,BJHD代表北京海淀,不同区域的数据放在不同的分库中: + + + +通过数据库分片,我们可以提高数据库服务的性能和可伸缩性。当数据量和访问量增加时,增加数据库节点的数量就行了。不过,虽然数据库的分片带来了性能和伸缩性的好处,但它也带来了一些挑战。 + +最明显的一个挑战,是数据库分片逻辑侵入到业务逻辑中。过去,应用逻辑只访问一个数据库,现在需要根据分片的规则,判断要去访问哪个数据库,再去跟这个数据库服务器连接。如果增加数据库分片,或者对分片策略进行调整,访问数据库的所有应用模块都要修改。这会让软件的维护变得更复杂,显然也不太符合软件工程中模块低耦合、把变化隔离的理念。 + +所以如果有一种技术,能让我们访问很多数据库分片时,像访问一个数据库那样就好了。数据库的物理分布,对应用是透明的。 + +可是,“理想很吸引人,现实很骨感”。要实现这个技术,需要解决很多问题: + +首先是跨库查询的难题。如果SQL操作都针对一个库还好,但如果某个业务需求恰好要跨多个库,比如上面的例子中,如果要查询多个小区的住户信息,那么就要在多个库中都执行查询,然后把查询结果合并,一般还要排序。 + +如果我们前端显示的时候需要分页,每页显示一百行,那就更麻烦了。我们不可能从10个分库中各查出10行,合并成100行,这100行不一定排在前面,最差的情况,可能这100行恰好都在其中一个分库里。所以,你可能要从每个分库查出100行来,合并、排序后,再取出前100行。如果涉及数据库表跨库做连接,你想象一下,那就更麻烦了。 + +其次就是跨库做写入的难题。如果对数据库写入时遇到了跨库的情况,那么就必须实现分布式事务。所以,虽然分布式数据库的愿景很吸引人,但我们必须解决一系列技术问题。 + +这一讲,我们先解决最简单的问题,也就是当每次数据操作仅针对一个分库的时候,能否自动确定是哪个分库的问题。解决这个问题我们不需要依据别的信息,只需要提供SQL就行了。这就涉及对SQL语句的解析了,自然要用到编译技术。 + +解析SQL语句,判断访问哪个数据库 + +我画了一张简化版的示意图:假设有两张表,分别是订单表和客户表,它们的主键是order_id和cust_id: + + + +我们采用的分片策略,是依据这两个主键的前4位的编码来确定数据库分片的逻辑,比如:前四位是SDYT,那就使用山东烟台的分片,如果是BJHD,就使用北京海淀的分片,等等。 + +在我们的应用中,会对订单表进行一些增删改查的操作,比如会执行下面的SQL语句: + +//查询 +select * from orders where order_id = 'SDYT20190805XXXX' +select * from orders where cust_id = 'SDYT987645' + + +//插入 +insert into orders (order_id,...其他字段) values( "BJHD20190805XXXX",...) + +//修改 +update orders set price=298.00 where order_id='FJXM20190805XXXX' + +//删除 +delete from orders where order_id='SZLG20190805XXXX' + + +我们要能够解析这样的SQL语句,根据主键字段的值,决定去访问哪个分库或者分表。这就需要用到编译器前端技术,包括词法分析、语法分析和语义分析。 + +听到这儿,你可能会质疑:“解析SQL语句?是在开玩笑吗?”你可能觉得这个任务太棘手,犹豫着是否要忍受业务逻辑和技术逻辑混杂的缺陷,把判断分片的逻辑写到应用代码里,或者想解决这个问题,又或者想自己写一个开源项目,帮到更多的人。 + +无论你的内心活动如何,应用编译技术,能让你有更强的信心解决这个问题。那么如何去做呢?要想完成解析SQL的任务,在词法分析和语法分析这两个阶段,我建议你采用工具快速落地,比如Antlr。你要找一个现成的SQL语句的语法规则文件。 + +GitHub中,那个收集了很多示例Antlr规则文件的项目里,有两个可以参考的规则:一个是PLSQL的(它是Oracle数据库的SQL语法);一个是SQLite的(这是一个嵌入式数据库)。 + +实际上,我还找到MySQL workbench所使用的一个产品级的规则文件。MySQL workbench是一个图形化工具,用于管理和访问MySQL。这个规则文件还是很靠谱的,不过它里面嵌了很多属性计算规则,而且是C++语言写的,我嫌处理起来麻烦,就先弃之不用,暂且采用SQLite的规则文件来做示范。 + +先来看一下这个文件里的一些规则,例如select语句相关的语法: + +factored_select_stmt + : ( K_WITH K_RECURSIVE? common_table_expression ( ',' common_table_expression )* )? + select_core ( compound_operator select_core )* + ( K_ORDER K_BY ordering_term ( ',' ordering_term )* )? + ( K_LIMIT expr ( ( K_OFFSET | ',' ) expr )? )? + ; + +common_table_expression + : table_name ( '(' column_name ( ',' column_name )* ')' )? K_AS '(' select_stmt ')' + ; + +select_core + : K_SELECT ( K_DISTINCT | K_ALL )? result_column ( ',' result_column )* + ( K_FROM ( table_or_subquery ( ',' table_or_subquery )* | join_clause ) )? + ( K_WHERE expr )? + ( K_GROUP K_BY expr ( ',' expr )* ( K_HAVING expr )? )? + | K_VALUES '(' expr ( ',' expr )* ')' ( ',' '(' expr ( ',' expr )* ')' )* + ; + +result_column + : '*' + | table_name '.' '*' + | expr ( K_AS? column_alias )? + ; + + +我们可以一边看这个语法规则,一边想几个select语句做一做验证。你可以思考一下,这个规则是怎么把select语句拆成不同的部分的。 + +SQL里面也有表达式,我们研究一下它的表达式的规则: + +expr + : literal_value + | BIND_PARAMETER + | ( ( database_name '.' )? table_name '.' )? column_name + | unary_operator expr + | expr '||' expr + | expr ( '*' | '/' | '%' ) expr + | expr ( '+' | '-' ) expr + | expr ( '<<' | '>>' | '&' | '|' ) expr + | expr ( '<' | '<=' | '>' | '>=' ) expr + | expr ( '=' | '==' | '!=' | '<>' | K_IS | K_IS K_NOT | K_IN | K_LIKE | K_GLOB | K_MATCH | K_REGEXP ) expr + | expr K_AND expr + | expr K_OR expr + | function_name '(' ( K_DISTINCT? expr ( ',' expr )* | '*' )? ')' + | '(' expr ')' + | K_CAST '(' expr K_AS type_name ')' + | expr K_COLLATE collation_name + | expr K_NOT? ( K_LIKE | K_GLOB | K_REGEXP | K_MATCH ) expr ( K_ESCAPE expr )? + | expr ( K_ISNULL | K_NOTNULL | K_NOT K_NULL ) + | expr K_IS K_NOT? expr + | expr K_NOT? K_BETWEEN expr K_AND expr + | expr K_NOT? K_IN ( '(' ( select_stmt + | expr ( ',' expr )* + )? + ')' + | ( database_name '.' )? table_name ) + | ( ( K_NOT )? K_EXISTS )? '(' select_stmt ')' + | K_CASE expr? ( K_WHEN expr K_THEN expr )+ ( K_ELSE expr )? K_END + | raise_function + ; + + +你可能会觉得SQL的表达式的规则跟其他语言的表达式规则很像。比如都支持加减乘除、关系比较、逻辑运算等等。而且从这个规则文件里,你一下子就能看出各种运算的优先级,比如你会注意到,字符串连接操作“||”比乘法和除法的优先级更高。所以,研究一门语言时积累的经验,在研究下一门语言时仍然有用。 + +有了规则文件之后,接下来,我们用Antlr生成词法分析器和语法分析器: + +antlr -visitor -package dsql.parser SQLite.g4 + + +在这个命令里,我用-package参数指定了生成的Java代码的包是dsql.parser。dsql是分布式SQL的意思。接着,我们可以写一点儿程序测试一下所生成的词法分析器和语法分析器: + +String sql = "select order_id from orders where cust_id = 'SDYT987645'"; + +//词法分析 +SQLiteLexer lexer = new SQLiteLexer(CharStreams.fromString(sql)); +CommonTokenStream tokens = new CommonTokenStream(lexer); + +//语法分析 +SQLiteParser parser = new SQLiteParser(tokens); +ParseTree tree = parser.sql_stmt(); + +//输出lisp格式的AST +System.out.println(tree.toStringTree(parser)); + + +这段程序的输出是LISP格式的AST,我调整了一下缩进,让它显得更像一棵树: + +(sql_stmt + (factored_select_stmt + (select_core select + (result_column + (expr + (column_name + (any_name order_id)))) + from (table_or_subquery + (table_name + (any_name orders))) + where (expr + (expr + (column_name + (any_name cust_id))) + = + (expr + (literal_value + ('SDYT987645')))))) + + +从AST中,我们可以清晰地看出这个select语句是如何被解析成结构化数据的,再继续写点儿代码,就能获得想要的信息了。 + +接下来的任务是:对于访问订单表的select语句,要在where子句里找出cust_id=“客户编号”或order_id=“订单编号”这样的条件,从而能够根据客户编号或订单编号确定采用哪个分库。 + +怎么实现呢?很简单,我们用visitor模式遍历一下AST就可以了: + +public String getDBName(String sql) { + //词法解析 + SQLiteLexer lexer = new SQLiteLexer(CharStreams.fromString(sql)); + CommonTokenStream tokens = new CommonTokenStream(lexer); + + //语法解析 + SQLiteParser parser = new SQLiteParser(tokens); + ParseTree tree = parser.sql_stmt(); + + //以lisp格式打印AST + System.out.println(tree.toStringTree(parser)); + + //获得select语句的要素,包括表名和where条件 + SQLVisitor visitor = new SQLVisitor(); + SelectStmt select = (SelectStmt) visitor.visit(tree); + + String dbName = null; + if (select.tableName.equals("orders")) { + if (select.whereExprs != null) { + for (WhereExpr expr : select.whereExprs) { + //根据cust_id或order_id来确定库的名称 + if (expr.columnName.equals("cust_id") || expr.columnName.equals("order_id")) { + //取编号的前4位,即区域编码 + String region = expr.value.substring(1, 5); + //根据区域编码,获取库名称 + dbName = region2DB.get(region); + break; + } + } + } + } + return dbName; +} + + +获取表名和where子句条件的代码在SQLVisitor.java中。因为已经有了AST,抽取这些信息是不难的。你可以点开我在文稿中提供的链接,查看示例代码。 + +我们的示例离实用还有多大差距? + +目前,我们已经初步解决了数据库访问透明化的问题。当然,这只是一个示例,如果要做得严密、实用,我们还要补充一些工作。 + +我们需要做一些语义分析工作,确保SQL语句的合法性。语法分析并不能保证程序代码完全合法,我们必须进行很多语义的检查才行。 + +我给订单表起的名字,是orders。如果你把表名称改为order,那么必须用引号引起来,写成’order’,不带引号的order会被认为是一个关键字。因为在SQL中我们可以使用order by这样的子句,这时候,order这个表名就会被混淆,进而被解析错误。这个语法解析程序会在表名的地方出现一个order节点,这在语义上是不合法的,需要被检查出来并报错。 + +如果要检查语义的正确性,我们还必须了解数据库的元数据。否则,就没有办法判断在SQL语句中是否使用了正确的字段,以及正确的数据类型。除此之外,我们还需要扩展到能够识别跨库操作,比如下面这样一个where条件: + +order_id = 'FJXM20190805XXXX' or order_id = 'SZLG20190805XXXX' + + +分析这个查询条件,可以知道数据是存在两个不同的数据库中的。但是我们要让解析程序分析出这个结果,甚至让它针对更加复杂的条件,也能分析出来。这就需要更加深入的语义分析功能了。 + +最后,解析器的速度也是一个需要考虑的因素。因为执行每个SQL都需要做一次解析,而这个时间就加在了每一次数据库访问上。所以,SQL解析的时间越少越好。因此,有的项目就会尽量提升解析效率。阿里有一个开源项目Druid,是一个数据库连接池。这个项目强调性能,因此他们纯手写了一个SQL解析器,尽可能地提升性能。 + +总之,要实现一个完善的工具,让工具达到产品级的质量,有不少工作要做。如果要支持更强的分布式数据库功能,还要做更多的工作。不过,你应该不会觉得这事儿有多么难办了吧?至少在编译技术这部分你是有信心的。 + +在这里,我还想讲一讲SQL防注入这个问题。SQL注入攻击是一种常见的攻击手段。你向服务器请求一个url的时候,可以把恶意的SQL嵌入到参数里面,这样形成的SQL就是不安全的。 + +以前面的SQL语句为例,这个select语句本来只是查询一个订单,订单编号“SDYT20190805XXXX”作为参数传递给服务端的一个接口,服务端收到参数以后,用单引号把这个参数引起来,并加上其他部分,就组装成下面的SQL并执行: + +//原来的SQL +select * from orders where order_id = 'SDYT20190805XXXX' + + +如果我们遇到了一个恶意攻击者,他可能把参数写成“SDYT20190805XXXX’;drop table customers; –”。服务器接到这个参数以后,仍然把它拿单引号引起来,并组装成SQL,组装完毕以后就是下面的语句: + +//被注入恶意SQL后 +select * from orders where order_id = 'SDYT20190805XXXX'; drop table customers; --' + + +如果你看不清楚,我分行写一下,这样你就知道它是怎么把你宝贵的客户信息全都删掉的: + +//被注入恶意SQL后 +select * from orders where order_id = 'SDYT20190805XXXX'; +drop table customers; // 把顾客表给删了 +--' //把你加的单引号变成了注释,这样SQL不会出错 + + +所以SQL注入有很大的危害。而我们一般用检查客户端传过来的参数的方法,看看有没有SQL语句中的关键字,来防止SQL注入。不过这是比较浅的防御,有时还会漏过一些非法参数,所以要在SQL执行之前,做最后一遍检查。而这个时候,就要运用编译器前端技术来做SQL的解析了。借此,我们能检查出来异常:明明这个功能是做查询的,为什么形成的SQL会有删除表的操作? + +通过这个例子,我们又分析了一种场景:开发一个安全可靠的系统,用编译技术做SQL分析是必须做的一件事情。 + +课程小结 + +今天,我带你利用学到的编译器前端技术,解析了SQL语句,并针对分布式数据库透明查询的功能做了一次概念证明。 + +SQL是程序员经常打交道的语言。有时,我们会遇到需要解析SQL语言的需求,除了分布式数据库场景的需求以外,Hibernate对HQL的解析,也跟解析SQL差不多。而且,最近有一种技术,能够通过RESTful这样的接口做通用的查询,其实也是一种类SQL的子语言。 + +当然了,今天我们只是基于工具做解析。一方面,有时候我们就是需要做个原型系统或者最小的能用的系统,有时间有资源了,再追求完美也不为过,比如追求编译速度的提升。另一方面,你能看到MySQL workbench也是用Antlr来作帮手的,在很多情况下,Antlr这样的工具生成的解析器足够用,甚至比你手写的还要好,所以,我们大可以节省时间,用工具做解析。 + +可能你会觉得,实际应用的难度似乎要低于学习原理的难度。如果你有这个感觉,那就对了,这说明你已经掌握了原理篇的内容,所以日常的一些应用根本不是问题,你可以找出更多的应用场景来练练手。 + +一课一思 + +你在工作中,是否遇到过其他需要解析SQL的场景?另外,当你阅读了SQL的规则文件之后,是否发现了它跟Java这样的语言规则的不同之处?是更加简单还是更复杂?欢迎在留言区写下你的发现。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/15前端技术应用(二):如何设计一个报表工具?.md b/专栏/编译原理之美/15前端技术应用(二):如何设计一个报表工具?.md new file mode 100644 index 0000000..337e02b --- /dev/null +++ b/专栏/编译原理之美/15前端技术应用(二):如何设计一个报表工具?.md @@ -0,0 +1,353 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 15 前端技术应用(二):如何设计一个报表工具? + 众所周知,很多软件都需要面向开发者甚至最终用户提供自定义功能,在开篇词里,我提到自己曾经做过工作流软件和电子表单软件,它们都需要提供自定义功能,报表软件也是其中的典型代表。 + +在每个应用系统中,我们对数据的处理大致会分成两类:一类是在线交易,叫做OLTP,比如在网上下订单;一类是在线分析,叫做OLAP,它是对应用中积累的数据进行进一步分析利用。而报表工具就是最简单,但也是最常用的数据分析和利用的工具。 + +本节课,我们就来分析一下,如果我们要做一个通用的报表工具,需要用到哪些编译技术,又该怎样去实现。 + +报表工具所需要的编译技术 + +如果要做一个报表软件,我们要想清楚软件面对的用户是谁。有一类报表工具面向的用户是程序员,那么这种软件可以暴露更多技术细节。比如,如果报表要从数据库获取数据,你可以写一个SQL语句作为数据源。 + +还有一类软件是给业务级的用户使用的,很多BI软件包都是这种类型。带有IT背景的顾问给用户做一些基础配置,然后用户就可以用这个软件包了。Excel可以看做是这种报表工具,IT人员建立Excel与数据库之间的连接,剩下的就是业务人员自己去操作了。 + +这些业务人员可以采用一个图形化的界面设计报表,对数据进行加工处理。我们来看看几个场景。 + +第一个场景是计算字段。计算字段的意思是,原始数据里没有这个数据,我们需要基于原始数据,通过一个自定义的公式来把它计算出来。比如在某个CRM系统中保存着销售数据,我们有每个部门的总销售额,也有每个部门的人数,要想在报表中展示每个部门的人均销售额,这个时候就可以用到计算公式功能,计算公式如下: + +人均销售额=部门销售额/部门人数 + + +得到的结果如下图所示: + + + +进一步,我们可以在计算字段中支持函数。比如我们可以把各个部门按照人均销售额排名次。这可以用一个函数来计算: + +=rank(人均销售额) + + +rank就是排名次的意思,其他统计函数还包括: + + +min(),求最小值。 +max(),求最大值。 +avg(),求平均值。 +sum(),求和。 + + +还有一些更有意思的函数,比如: + + +runningsum(),累计汇总值。 +runningavg(),累计平均值。 + + +这些有意思的函数是什么意思呢?因为很多明细性的报表,都是逐行显示的,累计汇总值和累计平均值,就是累计到当前行的计算结果。当然了,我们还可以支持更多的函数,比如当前日期、当前页数等等。更有意思的是,上述字段也好、函数也好,都可以用来组合成计算字段的公式,比如: + +=部门销售额/sum(部门销售额) //本部门的销售额在全部销售额的占比 +=max(部门销售额)-部门销售额 //本部门的销售额与最高部门的差距 +=max(部门销售额/部门人数)-部门销售额/部门人数 //本部门人均销售额与最高的那个部门的差 +=sum(部门销售额)/sum(人数)-部门销售额/部门人数 //本部门的人均销售额与全公司人均销售额的差 + + +怎么样,是不是越来越有意思了呢?现在你已经知道了在报表中会用到普通字段和各种各样的计算公式,那么,我们如何用这样的字段和公式来定义一张报表呢? + +如何设计报表 + +假设我们的报表是一行一行地展现数据,也就是最简单的那种。那我们将报表的定义做成一个XML文件,可能是下面这样的,它定义了表格中每一列的标题和所采用字段或公式: + + +
+ + 部门 + dept + + + 人数 + num_person + + + 销售额 + sales_amount + + + 人均销售额 + sales_amount/num_person + +
+ + 数据库连接信息... + select dept, num_person, sales_amount from sales + +
+ + +这个报表定义文件还是蛮简单的,它主要表达的是数据逻辑,忽略了表现层的信息。如果我们想要优先表达表现层的信息,例如字体大小、界面布局等,可以采用HTML模板的方式来定义报表,其实就是在一个HTML中嵌入了公式,比如: + + + +
+
+
部门
+
人数
+
销售额
+
人均销售额
+
+
+
{=dept}
+
{=num_person}
+
{=sales_amount}
+
{=sales_amount/num_person}
+
+
+ + + + +这样的HTML模板看上去是不是很熟悉?其实在很多语言里,比如PHP,都提供模板引擎功能,实现界面设计和应用代码的分离。这样一个模板,可以直接解释执行,或者先翻译成PHP或Java代码,然后再执行。只要运用我们学到的编译技术,这些都可以实现。 + +我想你应该会发现,这样的一个模板文件,其实就是一个特定领域语言,也就是我们常说的DSL。DSL可以屏蔽掉实现细节,让我们专注于领域问题,像上面这样的DSL,哪怕没有技术背景的工作人员,也可以迅速地编写出来。 + +而这个简单的报表,在报表设计界面上可能是下图这样的形式: + + + +分析完如何设计报表之后,接下来,我们看看如何定义报表所需要的公式规则。 + +编写所需要的语法规则 + +我们设计了PlayReport.g4规则文件,这里面的很多规则,是把PlayScript.g4里的规则拿过来改一改用的: + +bracedExpression + : '{' '=' expression '}' + ; + +expression + : primary + | functionCall + | expression bop=('*'|'/'|'%') expression + | expression bop=('+'|'-') expression + | expression bop=('<=' | '>=' | '>' | '<') expression + | expression bop=('==' | '!=') expression + | expression bop='&&' expression + | expression bop='||' expression + ; + +primary + : '(' expression ')' + | literal + | IDENTIFIER + ; + +expressionList + : expression (',' expression)* + ; + +functionCall + : IDENTIFIER '(' expressionList? ')' + ; + +literal + : integerLiteral + | floatLiteral + | CHAR_LITERAL + | STRING_LITERAL + | BOOL_LITERAL + | NULL_LITERAL + ; + +integerLiteral + : DECIMAL_LITERAL + | HEX_LITERAL + | OCT_LITERAL + | BINARY_LITERAL + ; + +floatLiteral + : FLOAT_LITERAL + | HEX_FLOAT_LITERAL + ; + + +这里面,其实就是用了表达式的语法,包括支持加减乘除等各种运算,用来书写公式。我们还特意支持functionCall功能,也就是能够调用函数。因为我们内部实现了很多内置函数,比如求最大值、平均值等,可以在公式里调用这些函数。 + +现在呢,我们已经做好了一个最简单的报表定义,接下来,就一起实现一个简单的报表引擎,这样就能实际生成报表了! + +实现一个简单的报表引擎 + +报表引擎的工作,是要根据报表的定义和数据源中的数据,生成最后报表的呈现格式。具体来说,可以分为以下几步: + + +解析报表的定义。我们首先要把报表定义形成Java对象。这里只是简单地生成了一个测试用的报表模板。 +从数据源获取数据。我们设计了一个TabularData类,用来保存类似数据库表那样的数据。 +实现一个FieldEvaluator类,能够在运行时对字段和公式进行计算。这个类是playscript中ASTEvaluator类的简化版。我们甚至连语义分析都简化了。数据类型信息作为S属性,在求值的同时自底向上地进行类型推导。当然,如果做的完善一点儿,我们还需要多做一点儿语义分析,比如公式里的字段是不是数据源中能够提供的?而这时需要用到报表数据的元数据。 +渲染报表。我们要把上面几个功能组合在一起,对每一行、每一列求值,获得最后的报表输出。 + + +主控程序我放在了下面,用一个示例报表模板和报表数据来生成报表: + +public static void main(String args[]) { + System.out.println("Play Report!"); + + PlayReport report = new PlayReport(); + + //打印报表1 + String reportString = report.renderReport(ReportTemplate.sampleReport1(), TabularData.sampleData()); + System.out.println(reportString); +} + + +renderReport方法用来渲染报表,它会调用解析器和报表数据的计算器: + +public String renderReport(ReportTemplate template, TabularData data){ + StringBuffer sb = new StringBuffer(); + + //输出表格头 + for (String columnHeader: template.columnHeaders){ + sb.append(columnHeader).append('\t'); + } + sb.append("\n"); + + //编译报表的每个字段 + List fieldASTs = new LinkedList(); + for (String fieldExpr : template.fields){ + //这里会调用解析器 + BracedExpressionContext tree = parse(fieldExpr); + fieldASTs.add(tree); + } + + //计算报表字段 + FieldEvaluator evaluator = new FieldEvaluator(data); + List fieldNames = new LinkedList(); + for (BracedExpressionContext fieldAST: fieldASTs){ + String fieldName = fieldAST.expression().getText(); + fieldNames.add(fieldName); + if (!data.hasField(fieldName)){ + Object field = evaluator.visit(fieldAST); + data.setField(fieldName, field); + } + } + + //显示每一行数据 + for (int row = 0; row< data.getNumRows(); row++){ + for (String fieldName: fieldNames){ + Object value = data.getFieldValue(fieldName, row); + sb.append(value).append("\t"); + } + sb.append("\n"); + } + + return sb.toString(); +} + + +程序的运行结果如下,它首先打印输出了每个公式的解析结果,然后输出报表: + +Play Report! +(bracedExpression { = (expression (primary dept)) }) +(bracedExpression { = (expression (primary num_person)) }) +(bracedExpression { = (expression (primary sales_amount)) }) +(bracedExpression { = (expression (expression (primary sales_amount)) / (expression (primary num_person))) }) +部门 人数 销售额 人均销售额 +电话销售部 10 2345.0 234.5 +现场销售部 20 5860.0 293.0 +电子商务部 15 3045.0 203.0 +渠道销售部 20 5500.0 275.0 +微商销售部 12 3624.0 302.0 + + +你可以看到,报表工具准确地得出了计算字段的数据。接下来,我再讲一讲报表数据计算的细节。 + +如果你看一看FieldEvaluator.java这个类,就会发现我实际上实现了一个简单的向量数据的计算器。在计算机科学里,向量是数据的有序列表,可以看做一个数组。相对应的,标量只是一个单独的数据。运用向量计算,我们在计算人均销售额的时候,会把“销售额”和“人数”作为两个向量,每个向量都有5个数据。把这两个向量相除,会得到第三个向量,就是“人均销售额”。这样就不需要为每行数据运行一次计算器,会提高性能,也会简化程序。 + +其实,这个向量计算器还能够把向量和标量做混合运算。因为我们的报表里有时候确实会用到标量,比如对销售额求最大值{=max(sales_amount)},就是一个标量。而如果计算销售额与最大销售额的差距{=max(sales_amount)-sales_amount},就是标量和向量的混合运算,返回结果是一个向量。 + +TabularData.java这个类是用来做报表数据的存储的。我简单地用了一个Map,把字段的名称对应到一个向量或标量上,其中字段的名称可以是公式: + + + +在报表数据计算过程中,我们还做了一个优化。公式计算的中间结果会被存起来,如果下一个公式刚好用到这个数据,可以复用。比如,在计算rank(sales_amount/num_person)这个公式的时候,它会查一下括号中的sales_amount/num_person这个子公式的值是不是以前已经计算过,如果计算过,就复用,否则,就计算一下,并且把这个中间结果也存起来。 + +我们把这个报表再复杂化一点,形成下面一个报表模板。这个报表模板用到了好几个函数,包括排序、汇总值、累计汇总值和最大值,并通过公式定义出一些相对复杂的计算字段,包括最高销售额、销售额的差距、销售额排序、人均销售额排序、销售额累计汇总、部门销售额在总销售额中的占比,等等。 + +public static ReportTemplate sampleReport2(){ + ReportTemplate template = new ReportTemplate(); + + template.columnHeaders.add("部门"); + template.columnHeaders.add("人数"); + template.columnHeaders.add("销售额"); + template.columnHeaders.add("最高额"); + template.columnHeaders.add("差距"); + template.columnHeaders.add("排序"); + template.columnHeaders.add("人均"); + template.columnHeaders.add("人均排序"); + template.columnHeaders.add("累计汇总"); + template.columnHeaders.add("占比%"); + + template.fields.add("{=dept}"); + template.fields.add("{=num_person}"); + template.fields.add("{=sales_amount}"); + template.fields.add("{=max(sales_amount)}"); + template.fields.add("{=max(sales_amount)-sales_amount}"); + template.fields.add("{=rank(sales_amount)}"); + template.fields.add("{=sales_amount/num_person}"); + template.fields.add("{=rank(sales_amount/num_person)}"); + template.fields.add("{=runningsum(sales_amount)}"); + template.fields.add("{=sales_amount/sum(sales_amount)*100}"); + + return template; +} + + +最后输出的报表截屏如下,怎么样,现在看起来功能还是挺强的吧! + + + +当然了,这个程序只是拿很短的时间写的一个Demo,如果要变成一个成熟的产品,还要在很多地方做工作。比如: + + +可以把字段名称用中文显示,这样更便于非技术人员使用; +除了支持行列报表,还要支持交叉表,用于统计分析; +支持多维数据计算。 +…… + + +在报表工具中,编译技术除了用来做字段的计算,还可以用于其他功能,比如条件格式。我们可以在人均销售额低于某个数值时,给这行显示成红色,其中的判断条件,也是一个公式。 + +甚至你还可以为报表工具添加自定义公式功能。我们给用户提供脚本功能,用户可以自己做一个函数,实现某个领域的一个专业功能。我十分建议你在这个示例程序的基础上进一步加工,看看能做否做出一些让自己惊喜的功能。 + +课程小结 + +本节课我们做了一个示例性的报表工具。你能在这个过程中看到,像报表工具这样的软件,如果有编译技术的支持,真的可以做得很灵活、很强大。你完全可以借鉴本节课的思路,去尝试做一下其他需要自定义功能的软件工具或产品。 + +与此同时,我们能看到编译技术可以跟某个应用领域结合在一起,内置在产品中,同时形成领域的DSL,比如报表的模板文件。这样,我们就相当于赋予了普通用户在某个领域内的编程能力,比如用户只需要编写一个报表模板,就可以生成报表了。了解这些内容之后,我来带你回顾一下,这个应用是怎么运用编译器前端技术的。 + +词法分析和语法分析都很简单,我们就是简单地用了表达式和函数调用的功能。而语义分析除了需要检查类型以外,还要检查所用到的字段和函数是否合法,这是另一种意义上的引用消解。而且这个例子中的运算的含义是向量运算,同样是加减乘除,每个操作都会处理一组数据,这也是一种语义上的区别。 + +我希望在学习了这两节课之后,你能对如何在某个应用领域应用编译技术有更直观的了解,甚至有了很多的启发。 + +一课一思 + +你在自己的工作领域中,是否发现有哪些需要用户自定义功能的需求?你又是怎么实现这些需求的?编译技术会不会在这些地方帮助到你?欢迎在留言区分享你的发现。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,欢迎你将它分享给更多的朋友。 + +本节课的示例代码我放在文末,供你参考。 + + +lab/report(报表项目示例代码) 码云 GitHub +PlayReport.java(主程序入口) 码云 GitHub +FieldEvaluator.java(做报表计算的代码) 码云 GitHub +ReportTemplate.java(报表模板) 码云 GitHub +TabularData.java(报表数据) 码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/16NFA和DFA:如何自己实现一个正则表达式工具?.md b/专栏/编译原理之美/16NFA和DFA:如何自己实现一个正则表达式工具?.md new file mode 100644 index 0000000..6e16fbc --- /dev/null +++ b/专栏/编译原理之美/16NFA和DFA:如何自己实现一个正则表达式工具?.md @@ -0,0 +1,411 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 16 NFA和DFA:如何自己实现一个正则表达式工具? + 回顾之前讲的内容,原理篇重在建立直观理解,帮你建立信心,这是第一轮的认知迭代。应用篇帮你涉足应用领域,在解决领域问题时发挥编译技术的威力,积累运用编译技术的一手经验,也启发你用编译技术去解决更多的领域问题,这是第二轮的认知迭代。而为时三节课的算法篇将你是第三轮的认知迭代。 + +在第三轮的认知迭代中,我会带你掌握前端技术中的核心算法。而本节课,我就借“怎样实现正则表达式工具?”这个问题,探讨第一组算法:与正则表达式处理有关的算法。 + +在词法分析阶段,我们可以手工构造有限自动机(FSA,或FSM)实现词法解析,过程比较简单。现在我们不再手工构造词法分析器,而是直接用正则表达式解析词法。 + +你会发现,我们只要写一些规则,就能基于这些规则分析和处理文本。这种能够理解正则表达式的功能,除了能生成词法分析器,还有很多用途。比如Linux的三个超级命令,又称三剑客(grep、awk和sed),都是因为能够直接支持正则表达式,功能才变得强大的。 + +接下来,我就带你完成编写正则表达式工具的任务,与此同时,你就能用正则文法生成词法分析器了: + +首先,把正则表达式翻译成非确定的有限自动机(Nondeterministic Finite Automaton,NFA)。- +其次,基于NFA处理字符串,看看它有什么特点。- +然后,把非确定的有限自动机转换成确定的有限自动机(Deterministic Finite Automaton,DFA)- +最后,运行DFA,看看它有什么特点。 + +强调一下,不要被非确定的有限自动机、确定的有限自动机这些概念吓倒,我肯定让你学明白。 + +认识DFA和NFA + +在讲词法分析时,我提到有限自动机(FSA)有有限个状态。识别Token的过程,就是FSA状态迁移的过程。其中,FSA分为确定的有限自动机(DFA)和非确定的有限自动机(NFA)。 + +DFA的特点是,在任何一个状态,我们基于输入的字符串,都能做一个确定的转换,比如: + + + +NFA的特点是,它存在某些状态,针对某些输入,不能做一个确定的转换,这又细分成两种情况: + + +对于一个输入,它有两个状态可以转换。 +存在ε转换。也就是没有任何输入的情况下,也可以从一个状态迁移到另一个状态。 + + +比如,“a[a-zA-Z0-9]*bc”这个正则表达式对字符串的要求是以a开头,以bc结尾,a和bc之间可以有任意多个字母或数字。在图中状态1的节点输入b时,这个状态是有两条路径可以选择的,所以这个有限自动机是一个NFA。 + + + +这个NFA还有引入ε转换的画法,它们是等价的。实际上,第二个NFA可以用我们今天讲的算法,通过正则表达式自动生成出来。 + + + +需要注意的是,无论是NFA还是DFA,都等价于正则表达式。也就是,所有的正则表达式都能转换成NFA或DFA,所有的NFA或DFA,也都能转换成正则表达式。 + +理解了NFA和DFA之后,来看看我们如何从正则表达式生成NFA。 + +从正则表达式生成NFA + +我们需要把它分为两个子任务: + +第一个子任务,是把正则表达式解析成一个内部的数据结构,便于后续的程序使用。因为正则表达式也是个字符串,所以要先做一个小的编译器,去理解代表正则表达式的字符串。我们可以偷个懒,直接针对示例的正则表达式生成相应的数据结构,不需要做出这个编译器。 + +用来测试的正则表达式可以是int关键字、标识符,或者数字字面量: + +int | [a-zA-Z][a-zA-Z0-9]* | [0-9]+ + + +我用下面这段代码创建了一个树状的数据结构,来代表用来测试的正则表达式: + +private static GrammarNode sampleGrammar1() { + GrammarNode node = new GrammarNode("regex1",GrammarNodeType.Or); + + //int关键字 + GrammarNode intNode = node.createChild(GrammarNodeType.And); + intNode.createChild(new CharSet('i')); + intNode.createChild(new CharSet('n')); + intNode.createChild(new CharSet('t')); + + //标识符 + GrammarNode idNode = node.createChild(GrammarNodeType.And); + GrammarNode firstLetter = idNode.createChild(CharSet.letter); + + GrammarNode letterOrDigit = idNode.createChild(CharSet.letterOrDigit); + letterOrDigit.setRepeatTimes(0, -1); + + + //数字字面量 + GrammarNode literalNode = node.createChild(CharSet.digit); + literalNode.setRepeatTimes(1, -1); + + return node; +} + + +打印输出的结果如下: + +RegExpression + Or + Union + i + n + t + Union + [a-z]|[A-Z] + [0-9]|[a-z]|[A-Z]* + [0-9]+ + + +画成图会更直观一些: + + + +测试数据生成之后,第二个子任务就是把表示正则表达式的数据结构,转换成一个NFA。这个过程比较简单,因为针对正则表达式中的每一个结构,我们都可以按照一个固定的规则做转换。 + + +识别ε的NFA: + + + +不接受任何输入,也能从一个状态迁移到另一个状态,状态图的边上标注ε。 + + + + + +识别i的NFA: + + + +当接受字符i的时候,引发一个转换,状态图的边上标注i。 + + + + + +转换“s|t”这样的正则表达式: + + + +它的意思是或者s,或者t,二者选一。s和t本身是两个子表达式,我们可以增加两个新的状态:开始状态和接受状态(最终状态)也就是图中带双线的状态,它意味着被检验的字符串此时是符合正则表达式的。然后用ε转换分别连接代表s和t的子图。它的含义也比较直观,要么走上面这条路径,那就是s,要么走下面这条路径,那就是t。 + + + + + +转换“st”这样的正则表达式: + + + +s之后接着出现t,转换规则是把s的开始状态变成st整体的开始状态,把t的结束状态变成st整体的结束状态,并且把s的结束状态和t的开始状态合二为一。这样就把两个子图接了起来,走完s接着走t。 + + + + + +对于“?”“*”和“+”这样的操作: + + + +意思是可以重复0次、0到多次、1到多次,转换时要增加额外的状态和边。 + + +以“s*”为例,做下面的转换: + + + +你能看出,它可以从i直接到f,也就是对s匹配零次,也可以在s的起止节点上循环多次。 + + +“s+”: + + + +没有办法跳过s,s至少经过一次。 + + + + +按照这些规则,我们可以编写程序进行转换。你可以参考示例代码Regex.java中的regexToNFA方法。转换完毕以后,将生成的NFA打印输出,列出了所有的状态,以及每个状态到其他状态的转换,比如“0 ε -> 2”的意思是从状态0通过ε转换,到达状态2 : + +NFA states: +0 ε -> 2 + ε -> 8 + ε -> 14 +2 i -> 3 +3 n -> 5 +5 t -> 7 +7 ε -> 1 +1 (end) + acceptable +8 [a-z]|[A-Z] -> 9 +9 ε -> 10 + ε -> 13 +10 [0-9]|[a-z]|[A-Z] -> 11 +11 ε -> 10 + ε -> 13 +13 ε -> 1 +14 [0-9] -> 15 +15 ε -> 14 + ε -> 1 + + +我用图片直观地展示了输出结果,图中分为上中下三条路径,你能清晰地看出解析int关键字、标识符和数字字面量的过程: + + + +生成NFA之后,如何利用它识别某个字符串是否符合这个NFA代表的正则表达式呢? + +以上图为例,当我们解析intA这个字符串时,首先选择最上面的路径去匹配,匹配完int这三个字符以后,来到状态7,若后面没有其他字符,就可以到达接受状态1,返回匹配成功的信息。可实际上,int后面是有A的,所以第一条路径匹配失败。 + +失败之后不能直接返回“匹配失败”的结果,因为还有其他路径,所以我们要回溯到状态0,去尝试第二条路径,在第二条路径中,尝试成功了。 + +运行Regex.java中的matchWithNFA()方法,你可以用NFA来做正则表达式的匹配: + +/** + * 用NFA来匹配字符串 + * @param state 当前所在的状态 + * @param chars 要匹配的字符串,用数组表示 + * @param index1 当前匹配字符开始的位置。 + * @return 匹配后,新index的位置。指向匹配成功的字符的下一个字符。 + */ +private static int matchWithNFA(State state, char[] chars, int index1){ + System.out.println("trying state : " + state.name + ", index =" + index1); + + int index2 = index1; + for (Transition transition : state.transitions()){ + State nextState = state.getState(transition); + //epsilon转换 + if (transition.isEpsilon()){ + index2 = matchWithNFA(nextState, chars, index1); + if (index2 == chars.length){ + break; + } + } + //消化掉一个字符,指针前移 + else if (transition.match(chars[index1])){ + index2 ++; //消耗掉一个字符 + + if (index2 < chars.length) { + index2 = matchWithNFA(nextState, chars, index1 + 1); + } + //如果已经扫描完所有字符 + //检查当前状态是否是接受状态,或者可以通过epsilon到达接受状态 + //如果状态机还没有到达接受状态,本次匹配失败 + else { + if (acceptable(nextState)) { + break; + } + else{ + index2 = -1; + } + } + } + } + + return index2; +} + + +其中,在匹配“intA”时,你会看到它的回溯过程: + +NFA matching: 'intA' +trying state : 0, index =0 +trying state : 2, index =0 //先走第一条路径,即int关键字这个路径 +trying state : 3, index =1 +trying state : 5, index =2 +trying state : 7, index =3 +trying state : 1, index =3 //到了末尾了,发现还有字符'A'没有匹配上 +trying state : 8, index =0 //回溯,尝试第二条路径,即标识符 +trying state : 9, index =1 +trying state : 10, index =1 //在10和11这里循环多次 +trying state : 11, index =2 +trying state : 10, index =2 +trying state : 11, index =3 +trying state : 10, index =3 +true + + +从中可以看到用NFA算法的特点:因为存在多条可能的路径,所以需要试探和回溯,在比较极端的情况下,回溯次数会非常多,性能会变得非常慢。特别是当处理类似s*这样的语句时,因为s可以重复0到无穷次,所以在匹配字符串时,可能需要尝试很多次。 + +注意,在我们生成的NFA中,如果一个状态有两条路径到其他状态,算法会依据一定的顺序来尝试不同的路径。 + +9和11两个状态都有两条向外走的线,其中红色的线是更优先的路径,也就是尝试让*号匹配尽量多的字符。这种算法策略叫做“贪婪(greedy)”策略。 + +在有的情况下,我们会希望让算法采用非贪婪策略,或者叫“忽略优先”策略,以便让效率更高。有的正则表达式工具会支持多加一个?,比如??、*?、+?,来表示非贪婪策略。 + +NFA的运行可能导致大量的回溯,所以能否将NFA转换成DFA,让字符串的匹配过程更简单呢?如果能的话,那整个过程都可以自动化,从正则表达式到NFA,再从NFA到DFA。 + +把NFA转换成DFA + +的确有这样的算法,那就是子集构造法,它的思路如下。 + +首先NFA有一个初始状态(从状态0通过ε转换可以到达的所有状态,也就是说,在不接受任何输入的情况下,从状态0也可以到达的状态)。这个状态的集合叫做“状态0的ε闭包”,简单一点儿,我们称之为s0,s0包含0、2、8、14这几个状态。 + + + +将字母i给到s0中的每一个状态,看它们能转换成什么状态,再把这些状态通过ε转换就能到达的状态也加入进来,形成一个包含“3、9、10、13、1”5个状态的集合s1。其中3和9是接受了字母i所迁移到的状态,10、13、1是在状态9的ε闭包中。 + + + +在s0和s1中间画条迁移线,标注上i,意思是s0接收到i的情况下,转换到s1: + + + +在这里,我们把s0和s1分别看成一个状态。也就是说,要生成的DFA,它的每个状态,是原来的NFA的某些状态的集合。 + +在上面的推导过程中,我们有两个主要的计算: + +1.ε-closure(s),即集合s的ε闭包。也就是从集合s中的每个节点,加上从这个节点出发通过ε转换所能到达的所有状态。- +2.move(s, ‘i’),即从集合s接收一个字符i,所能到达的新状态的集合。- +所以,s1 = ε-closure(move(s0,‘i’)) + +按照上面的思路继续推导,识别int关键字的识别路径也就推导出来了: + + + +我们把上面这种推导的思路写成算法,参见Regex.java中的NFA2DFA()方法。我写了一段伪代码,方便你阅读: + +计算s0,即状态0的ε闭包 +把s0压入待处理栈 +把s0加入所有状态集的集合S +循环:待处理栈内还有未处理的状态集 + 循环:针对字母表中的每个字符c + 循环:针对栈里的每个状态集合s(i)(未处理的状态集) + 计算s(m) = move(s(i), c)(就是从s(i)出发,接收字符c能够 + 迁移到的新状态的集合) + 计算s(m)的ε闭包,叫做s(j) + 看看s(j)是不是个新的状态集,如果已经有这个状态集了,把它找出来 + 否则,把s(j)加入全集S和待处理栈 + 建立s(i)到s(j)的连线,转换条件是c + + +运行NFA2DFA()方法,然后打印输出生成的DFA。画成图,你就能很直观地看出迁移的路径了: + + + +从初始状态开始,如果输入是i,那就走int识别这条线,也就是按照19、21、22这条线依次迁移,如果中间发现不符合int模式,就跳转到20,也就是标识符状态。 + +注意,在上面的DFA中,只要包含接受状态1的,都是DFA的接受状态。进一步区分的话,22是int关键字的接受状态,因为它包含了int关键字原来的接受状态7。同理,17是数字字面量的接受状态,18、19、20、21都是标识符的接受状态。 + +而且,你会发现,算法生成的DFA跟手工构造DFA是很接近的!我们在第二讲手工构造了DFA识别int关键字和标识符,比本节课少识别一个数字字面量: + + + +不过,光看对int关键字和标识符的识别,我们算法生成的DFA和手工构造的DFA,非常相似!手工构造的相当于把18和20两个状态合并了,所以,这个算法是非常有效的!你可以运行一下示例程序Regex.java中的matchWithDFA()的方法,看看效果: + +private static boolean matchWithDFA(DFAState state, char[] chars, int index){ + System.out.println("trying DFAState : " + state.name + ", index =" + index); + //根据字符,找到下一个状态 + DFAState nextState = null; + for (Transition transition : state.transitions()){ + if (transition.match(chars[index])){ + nextState = (DFAState)state.getState(transition); + break; + } + } + + if (nextState != null){ + //继续匹配字符串 + if (index < chars.length-1){ + return matchWithDFA(nextState,chars, index + 1); + } + else{ + //字符串已经匹配完毕 + //看看是否到达了接受状态 + if(state.isAcceptable()){ + return true; + } + else{ + return false; + } + } + } + else{ + return false; + } +} + + +运行时会打印输出匹配过程,而执行过程中不产生任何回溯。 + +现在,我们可以自动生成DFA了,可以根据DFA做更高效的计算。不过,有利就有弊,DFA也存在一些缺点。比如,DFA可能有很多个状态。 + +假设原来NFA的状态有n个,那么把它们组合成不同的集合,可能的集合总数是2的n次方个。针对我们示例的NFA,它有13个状态,所以最坏的情况下,形成的DFA可能有2的13次方,也就是8192个状态,会占据更多的内存空间。而且生成这个DFA本身也需要消耗一定的计算时间。 + +当然了,这种最坏的状态很少发生,我们示例的NFA生成DFA后,只有7个状态。 + +课程小结 + +本节课,我带你实现了一个正则表达式工具,或者说根据正则表达式自动做了词法分析,它们的主要原理是相同的。 + +首先,我们需要解析正则表达式,形成计算机内部的数据结构,然后要把这个正则表达式生成NFA。我们可以基于NFA进行字符串的匹配,或者把NFA转换成DFA,再进行字符串匹配。 + +NFA和DFA有各自的优缺点:NFA通常状态数量比较少,可以直接用来进行计算,但可能会涉及回溯,从而性能低下;DFA的状态数量可能很大,占用更多的空间,并且生成DFA本身也需要消耗计算资源。所以,我们根据实际需求选择采用NFA还是DFA就可以了。 + +不过,一般来说,正则表达式工具可以直接基于NFA。而词法分析器(如Lex),则是基于DFA。原因很简单,因为在生成词法分析工具时,只需要计算一次DFA,就可以基于这个DFA做很多次词法分析。 + +一课一思 + +本节课我们实现了一个简单的正则表达式工具。在你的日常编程任务中,有哪些需要进行正则处理的需求?用传统的正则表达式工具有没有性能问题?你有没有办法用本节课讲到的原理来优化这些工作?欢迎在留言区分享你的发现。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本节课的示例代码我放在了文末,供你参考。 + + +lab/16-18(算法篇的示例代码):码云 GitHub +Regex.java(正则表达式有关的算法):码云 GitHub +Lexer.java(基于正则文法自动做词法解析):码云 GitHub +GrammarNode.java(用于表达正则文法):码云 GitHub +State.java(自动机的状态):码云 GitHub +DFAState.java(DFA的状态):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/17First和Follow集合:用LL算法推演一个实例.md b/专栏/编译原理之美/17First和Follow集合:用LL算法推演一个实例.md new file mode 100644 index 0000000..afd222b --- /dev/null +++ b/专栏/编译原理之美/17First和Follow集合:用LL算法推演一个实例.md @@ -0,0 +1,289 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 First和Follow集合:用LL算法推演一个实例 + 在前面的课程中,我讲了递归下降算法。这个算法很常用,但会有回溯的现象,在性能上会有损失。所以我们要把算法升级一下,实现带有预测能力的自顶向下分析算法,避免回溯。而要做到这一点,就需要对自顶向下算法有更全面的了解。 + +另外,在留言区,有几个同学问到了一些问题,涉及到对一些基本知识点的理解,比如: + + +基于某个语法规则做解析的时候,什么情况下算是成功,什么情况下算是失败? +使用深度优先的递归下降算法时,会跟广度优先的思路搞混。 + + +要搞清这些问题,也需要全面了解自顶向下算法。比如,了解Follow集合和$符号的用法,能帮你解决第一个问题;了解广度优先算法能帮你解决第二个问题。 + +所以,本节课,我先把自顶向下分析的算法体系梳理一下,让你先建立更加清晰的全景图,然后我再深入剖析LL算法的原理,讲清楚First集合与Follow集合这对核心概念,最终让你把自顶向下的算法体系吃透。 + +自顶向下分析算法概述 + +自顶向下分析的算法是一大类算法。总体来说,它是从一个非终结符出发,逐步推导出跟被解析的程序相同的Token串。 + +这个过程可以看做是一张图的搜索过程,这张图非常大,因为针对每一次推导,都可能产生一个新节点。下面这张图只是它的一个小角落。 + + + +算法的任务,就是在大图中,找到一条路径,能产生某个句子(Token串)。比如,我们找到了三条橘色的路径,都能产生“2+3*5”这个表达式。 + +根据搜索的策略,有深度优先(Depth First)和广度优先(Breadth First)两种,这两种策略的推导过程是不同的。 + +深度优先是沿着一条分支把所有可能性探索完。以“add->mul+add”产生式为例,它会先把mul这个非终结符展开,比如替换成pri,然后再把它的第一个非终结符pri展开。只有把这条分支都向下展开之后,才会回到上一级节点,去展开它的兄弟节点。 + +递归下降算法就是深度优先的,这也是它不能处理左递归的原因,因为左边的分支永远也不能展开完毕。 + +而针对“add->add+mul”这个产生式,广度优先会把add和mul这两个都先展开,这样就形成了四条搜索路径,分别是mul+mul、add+mul+mul、add+pri和add+mul*pri。接着,把它们的每个非终结符再一次展开,会形成18条新的搜索路径。 + +所以,广度优先遍历,需要探索的路径数量会迅速爆炸,成指数级上升。哪怕用下面这个最简单的语法,去匹配“2+3”表达式,都需要尝试20多次,更别提针对更复杂的表达式或者采用更加复杂的语法规则了。 + +//一个很简单的语法 +add -> pri //1 +add -> add + pri //2 +pri -> Int //3 +pri -> (add) //4 + + + + +这样看来,指数级上升的内存消耗和计算量,使得广度优先根本没有实用价值。虽然上面的算法有优化空间,但无法从根本上降低算法复杂度。当然了,它也有可以使用左递归文法的优点,不过我们不会为了这个优点去忍受算法的性能。 + +而深度优先算法在内存占用上是线性增长的。考虑到回溯的情况,在最坏的情况下,它的计算量也会指数式增长,但我们可以通过优化,让复杂度降为线性增长。 + +了解广度优先算法,你的思路会得到拓展,对自顶向下算法的本质有更全面的理解。另外,在写算法时,你也不会一会儿用深度优先,一会儿用广度优先了。 + +针对深度优先算法的优化方向是减少甚至避免回溯,思路就是给算法加上预测能力。比如,我在解析statement的时候,看到一个if,就知道肯定这是一个条件语句,不用再去尝试其他产生式了。 + +LL算法就属于这类预测性的算法。第一个L,是Left-to-right,代表从左向右处理程序代码。第二个L,是Leftmost,意思是最左推导。 + +按照语法规则,一个非终结符展开后,会形成多个子节点,其中包含终结符和非终结符。最左推导是指,从左到右依次推导展开这些非终结符。采用Leftmost的方法,在推导过程中,句子的左边逐步都会被替换成终结符,只有右边的才可能包含非终结符。 + +以“2+3*5”为例,它的推导顺序从左到右,非终结符逐步替换成了终结符: + + + +下图是上述推导过程建立起来的AST,“1、2、3……”等编号是AST节点创建的顺序: + + + +好了,我们把自顶向下分析算法做了总体概述,并讲清楚了最左推导的含义,现在来看看LL算法到底是怎么回事。 + +计算和使用First集合 + +LL算法是带有预测能力的自顶向下算法。在推导的时候,我们希望当存在多个候选的产生式时,瞄一眼下一个(或多个)Token,就知道采用哪个产生式。如果只需要预看一个Token,就是LL(1)算法。 + +拿statement的语法举例子,它有好几个产生式,分别产生if语句、while语句、switch语句…… + +statement + : block + | IF parExpression statement (ELSE statement)? + | FOR '(' forControl ')' statement + | WHILE parExpression statement + | DO statement WHILE parExpression ';' + | SWITCH parExpression '{' switchBlockStatementGroup* switchLabel* + | RETURN expression? ';' + | BREAK IDENTIFIER? ';' + | CONTINUE IDENTIFIER? ';' + | SEMI + | statementExpression=expression ';' + | identifierLabel=IDENTIFIER ':' statement + ; + + +如果我看到下一个Token是if,那么后面跟着的肯定是if语句,这样就实现了预测,不需要一个一个产生式去试。 + +问题来了,if语句的产生式的第一个元素就是一个终结符,这自然很好判断,可如果是一个非终结符,比如表达式语句,那该怎么判断呢? + +我们可以为statement的每条分支计算一个集合,集合包含了这条分支所有可能的起始Token。如果每条分支的起始Token是不一样的,也就是这些集合的交集是空集,那么就很容易根据这个集合来判断该选择哪个产生式。我们把这样的集合,就叫做这个产生式的First集合。 + +First集合的计算很直观,假设我们要计算的产生式是x: + + +如果x以Token开头,那么First(x)包含的元素就是这个Token,比如if语句的First集合就是{IF}。 +如果x的开头是非终结符a,那么First(x)要包含First(a)的所有成员。比如expressionStatment是以expression开头,因此它的First集合要包含First(expression)的全体成员。 +如果x的第一个元素a能够产生ε,那么还要再往下看一个元素b,把First(b)的成员也加入到First(x),以此类推。如果所有元素都可能返回ε,那么First(x)也应该包含ε,意思是x也可能产生ε。比如下面的blockStatements产生式,它的第一个元素是blockStatement*,也就意味着blockStatement的数量可能为0,因此可能产生ε。那么First(blockStatements)除了要包含First(blockStatement)的全部成员,还要包含后面的“;”。 + + +blockStatements + : blockStatement* + ; + + + +最后,如果x是一个非终结符,它有多个产生式可供选择,那么First(x)应包含所有产生式的First()集合的成员。比如statement的First集合要包含if、while等所有产生式的First集合的成员。并且,如果这些产生式只要有一个可能产生ε,那么x就可能产生ε,因此First(x)就应该包含ε。 + + +在本讲的示例程序里,我们可以用SampleGrammar.expressionGrammar()方法获得一个表达式的语法,把它dump()一下,这其实是消除了左递归的表达式语法: + +expression : assign ; +assign : equal | assign1 ; +assign1 : '=' equal assign1 | ε; +equal : rel equal1 ; +equal1 : ('==' | '!=') rel equal1 | ε ; +rel : add rel1 ; +rel1 : ('>=' | '>' | '<=' | '<') add rel1 | ε ; +add : mul add1 ; +add1 : ('+' | '-') mul add1 | ε ; +mul : pri mul1 ; +mul1 : ('*' | '/') pri mul1 | ε ; +pri : ID | INT_LITERAL | LPAREN expression RPAREN ; + + +我们用GrammarNode类代表语法的节点,形成一张语法图(蓝色节点的下属节点之间是“或”的关系,也就是语法中的竖线)。 + + + +基于这个数据结构能计算每个非终结符的First集合,可以参考LLParser类的caclFirstSets()方法。运行示例程序可以打印出表达式语法中各个非终结符的First集合。 + +在计算时你要注意,因为上下文无关文法是允许递归嵌套的,所以这些GrammarNode节点构成的是一个图,而不是树,不能通过简单的遍历树的方法来计算First集合。比如,pri节点是expression的后代节点,但pri又引用了expression(pri->(expression))。这样,计算First(expression)需要用到First(pri),而计算First(pri)又需要依赖First(expression)。 + +破解这个僵局的方法是用“不动点法”来计算。多次遍历图中的节点,看看每次有没有计算出新的集合成员。比如,第一遍计算的时候,当求First(pri)的时候,它所依赖的First(expression)中的成员可能不全,等下一轮继续计算时,发现有新的集合成员,再加进来就好了,直到所有集合的成员都没有变动为止。 + +现在我们可以用First集合进行分支判断了,不过还要处理产生式可能为ε的情况,比如“+mul add1 | ε”或“blockStatement*”都会产生ε。 + +计算和使用Follow集合 + +对ε的处理分成两种情况。 + +第一种情况,是产生式中的部分元素会产生ε。比如,在Java语法里,声明一个类成员的时候,可能会用public、private这些来修饰,但也可以省略不写。在语法规则中,这个部分是“accessModifier?”,它就可能产生ε。 + +memberDeclaration : accessModifier? type identifier ';' ; +accessModifier : 'public' | 'private' ; +type : 'int' | 'long' | 'double' ; + + +所以,当我们遇到下面这两个语句的时候,都可以判断为类成员的声明: + +public int a; +int b; + + +这时,type能够产生的终结符 ‘int’、‘long’和‘double’也在memberDeclaration的First集合中。这样,我们实际上把accessModifier给穿透了,直接到了下一个非终结符type。所以这类问题依靠First集合仍然能解决。在解析的过程中,如果下一个Token是 ‘int’,我们可以认为accessModifier返回了ε,忽略它,继续解析下一个元素type,因为它的First集合中才会包含 ‘int’。 + +第二种情况是产生式本身(而不是其组成部分)产生ε。这类问题仅仅依靠First集合是无法解决的,要引入另一个集合:Follow集合。它是所有可能跟在某个非终结符之后的终结符的集合。 + +以block语句为例,在PlayScript.g4中,大致是这样定义的: + +block + : '{' blockStatements '}' + ; + +blockStatements + : blockStatement* + ; + +blockStatement + : variableDeclarators ';' + | statement + | functionDeclaration + | classDeclaration + ; + + +也就是说,block是由blockStatements构成的,而blockStatements可以由0到n个blockStatement构成,因此可能产生ε。 + +接下来,我们来看看解析block时会发生什么。 + +假设花括号中一个语句也没有,也就是blockStatments实际上产生了ε。那么在解析block时,首先读取了一个Token,即“{”,然后处理blockStatements,我们再预读一个Token,发现是“}”,那这个右花括号是blockStatement的哪个产生式的呢?实际上它不在任何一个产生式的First集合中,下面是进行判断的伪代码: + +nextToken = tokens.peek(); //得到'}' +nextToken in First(variableDeclarators) ? //no +nextToken in First(statement) ? //no +nextToken in First(functionDeclaration) ? //no +nextToken in First(classDeclaration) ? //no + + +我们找不到任何一个可用的产生式。这可怎么办呢?除了可能是blockStatments本身产生了ε之外,还有一个可能性就是出现语法错误了。而要继续往下判断,就需要用到Follow集合。 + +像blockStatements的Follow集合只有一个元素,就是右花括号“}”。所以,我们只要再检查一下nextToken是不是花括号就行了: + +//伪代码 +nextToken = tokens.peek(); //得到'}' +nextToken in First(variableDeclarators) ? //no +nextToken in First(statement) ? //no +nextToken in First(functionDeclaration) ? //no +nextToken in First(classDeclaration) ? //no + +if (nextToken in Follow(blockStatements)) //检查Follow集合 + return Epsilon; //推导出ε +else + error; //语法错误 + + +那么怎么计算非终结符x的Follow集合呢? + + +扫描语法规则,看看x后面都可能跟哪些符号。 +对于后面跟着的终结符,都加到Follow(x)集合中去。 +如果后面是非终结符,就把它的First集合加到自己的Follow集合中去。 +最后,如果后面的非终结符可能产出ε,就再往后找,直到找到程序终结符号。 + + +这个符号通常记做$,意味一个程序的结束。比如在表达式的语法里,expression 后面可能跟这个符号,expression 的所有右侧分支的后代节点也都可能跟这个符号,也就是它们都可能出现在程序的末尾。但另一些非终结符,后面不会跟这个符号,如blockstatements,因为它后面肯定会有“}”。 + +你可以参考LLParser类的caclFollowSets()方法,这里也要用到不动点法做计算。运行程序可以打印出示例语法的的Follow集合。我把程序打印输出的First和follow集合整理如下(其实打印输出还包含一些中间节点,这里就不展示了): + + + +在表达式的解析中,我们会综合运用First和Follow集合。比如,对于“add1 -> + mul add1 | ε”,如果预读的下一个Token是+,那就按照第一个产生式处理,因为+在First(“+ mul add1”)集合中。如果预读的Token是>号,那它肯定不在First(add1)中,而我们要看它是否属于Follow(add1),如果是,那么add1就产生一个ε,否则就报错。 + +LL算法和文法 + +现在我们已经建立了对First集合、Follow集合和LL算法计算过程的直觉认知。这样再写出算法的实现,就比较容易了。用LL算法解析语法的时候,我们可以选择两种实现方式。 + +第一种,还是采用递归下降算法,只不过现在的递归下降算法是没有任何回溯的。无论走到哪一步,我们都能准确地预测出应该采用哪个产生式。 + +第二种,是采用表驱动的方式。这个时候需要基于我们计算出来的First和Follow集合构造一张预测分析表。根据这个表,查找在遇到什么Token的情况下,应该走哪条路径。 + +这两种方式是等价的,你可以根据自己的喜好来选择,我用的是第一种。关于算法,我们就说这么多,接下来,我们谈谈如何设计符合LL(k)特别是LL(1)算法的文法。 + +我们已经知道左递归的文法是要避免的,也知道要如何避免。除此之外,我们要尽量抽取左公因子,这样可以避免First集合产生交集。举例来说,变量声明和函数声明的规则在前半截都差不多,都是类型后面跟着标识符: + +statement : variableDeclare | functionDeclare | other; +variableDeclare : type Identifier ('=' expression)? ; +funcationDeclare : type Identifier '(' parameterList ')' block ; + + +具体例子如下: + +int age; +int cacl(int a, int b){ + return a + b; +} + + +这样的语法规则,如果按照LL(1)算法,First(variableDeclare)和First(funcationDeclare)是相同的,没法决定走哪条路径。你就算用LL(2),也是一样的,要用到LL(3)才行。但对于LL(k) k > 1来说,程序开销有点儿大,因为要计算更多的集合,构造更复杂的预测分析表。 + +不过这个问题很容易解决,只要把它们的左公因子提出来就可以了: + +statement: declarator | other; +declarator : declarePrefix (variableDeclarePostfix + |functionDeclarePostfix) ; +variableDeclarePostfix : ('=' expression)? ; +functionDeclarePostfix : '(' parameterList ')' block ; + + +这样,解析程序先解析它们的公共部分,即declarePrefix,然后再看后面的差异。这时,它俩的First集合,一个{ = ; },一个是{ ( },两者没有交集,能够很容易区分。 + +课程小结 + +本节课我们比较全面地梳理了自顶向下算法。语法解析过程可以看做是对图的遍历过程,遍历时可以采取深度优先或广度优先的策略,这里要注意,你可能在做深度优先遍历的时候,误用广度优先的思路。 + +针对LL算法,我们通过实例分析了First集合和Follow集合的使用场景和计算方式。掌握了这两个核心概念,特别是熟悉它们的使用场景,你会彻底掌握LL算法。 + +一课一思 + +处理ε是LL算法中的关键点。在你熟悉的语言中,哪些语法会产生ε,你在做语法解析的时候会怎样处理它们?欢迎在留言区分享你的思考。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本节课的示例代码我放在了文末,供你参考。 + + +lab/16~18(算法篇的示例代码):码云 GitHub +LLParser.java(LL算法的语法解析器):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/18移进和规约:用LR算法推演一个实例.md b/专栏/编译原理之美/18移进和规约:用LR算法推演一个实例.md new file mode 100644 index 0000000..339db1e --- /dev/null +++ b/专栏/编译原理之美/18移进和规约:用LR算法推演一个实例.md @@ -0,0 +1,304 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 移进和规约:用LR算法推演一个实例 + 到目前为止,我们所讨论的语法分析算法,都是自顶向下的。与之相对应的,是自底向上的算法,比如本节课要探讨的LR算法家族。 + +LR算法是一种自底向上的算法,它能够支持更多的语法,而且没有左递归的问题。第一个字母L,与LL算法的第一个L一样,代表从左向右读入程序。第二个字母R,指的是RightMost(最右推导),也就是在使用产生式的时候,是从右往左依次展开非终结符。例如,对于“add->add+mul”这样一个产生式,是优先把mul展开,然后再是add。在接下来的讲解过程中,你会看到这个过程。 + +自顶向下的算法,是递归地做模式匹配,从而逐步地构造出AST。那么自底向上的算法是如何构造出AST的呢?答案是用移进-规约的算法。 + +本节课,我就带你通过移进-规约方法,自底向上地构造AST,完成语法的解析。接下来,我们先通过一个例子看看自底向上语法分析的过程。 + +通过实例了解自底向上语法分析的过程 + +我们选择熟悉的语法规则: + +add -> mul +add -> add + mul +mul -> pri +mul -> mul * pri +pri -> Int | (add) + + +然后来解析“2+3*5”这个表达式,AST如下: + + + +我们分步骤看一下解析的具体过程。 + +第1步,看到第一个Token,是Int,2。我们把它作为AST的第一个节点,同时把它放到一个栈里(就是图中红线左边的部分)。这个栈代表着正在处理的一些AST节点,把Token移到栈里的动作叫做移进(Shift)。 + + + +第2步,根据语法规则,Int是从pri推导出来的(pri->Int),那么它的上级AST肯定是pri,所以,我们给它加了一个父节点pri,同时,也把栈里的Int替换成了pri。这个过程是语法推导的逆过程,叫做规约(Reduce)。 + +Reduce这个词你在学Map-Reduce时可能接触过,它相当于我们口语化的“倒推”。具体来讲,它是从工作区里倒着取出1到n个元素,根据某个产生式,组合出上一级的非终结符,也就是AST的上级节点,然后再放进工作区(也就是竖线的左边)。 + +这个时候,栈里可能有非终结符,也可能有终结符,它仿佛是我们组装AST的一个工作区。竖线的右边全都是Token(也就是终结符),它们在等待处理。 + + + +第3步,与第2步一样,因为pri只能是mul推导出来的,产生式是“mul->pri”,所以我们又做了一次规约。 + + + +第4步,我们根据“add->mul”产生式,将mul规约成add。至此,我们对第一个Token做了3次规约,已经到头了。这里为什么做规约,而不是停在mul上,移进+号,是有原因的。因为没有一个产生式,是mul后面跟+号,而add后面却可以跟+号。 + + + +第5步,移进+号。现在栈里有两个元素了,分别是add和+。 + + + +第6步,移进Int,也就是数字3。栈里现在有3个元素。 + + + +第7到第8步,Int规约到pri,再规约到mul。 + +到目前为止,我们做规约的方式都比较简单,就是对着栈顶的元素,把它反向推导回去。 + + + +第9步,我们面临3个选择,比较难。 + +第一个选择是继续把mul规约成add,第二个选择是把“add+mul”规约成add。这两个选择都是错误的,因为它们最终无法形成正确的AST。 + + + +第三个选择,也就是按照“mul->mul*pri”,继续移进 *号 ,而不是做规约。只有这样,才能形成正确的AST,就像图中的虚线。 + + + +第10步,移进Int,也就是数字5。 + + + +第11步,Int规约成pri。 + + + +第12步,mul*pri规约成mul。 + +注意,这里也有两个选择,比如把pri继续规约成mul。但它显然也是错误的选择。 + + + +第13步,add+mul规约成add。 + + + +至此,我们就构建完成了一棵正确的AST,并且,栈里也只剩下了一个元素,就是根节点。 + +整个语法解析过程,实质是反向最右推导(Reverse RightMost Derivation)。什么意思呢?如果把AST节点根据创建顺序编号,就是下面这张图呈现的样子,根节点编号最大是13: + + + +但这是规约的过程,如果是从根节点开始的推导过程,顺序恰好是反过来的,先是13号,再是右子节点12号,再是12号的右子节点11号,以此类推。我们把这个最右推导过程写在下面: + + + +在语法解析的时候,我们是从底下反推回去,所以叫做反向的最右推导过程。从这个意义上讲,LR算法中的R,带有反向(Reverse)和最右(Reightmost)这两层含义。 + +在最右推导过程中,我加了下划线的部分,叫做一个句柄(Handle)。句柄是一个产生式的右边部分,以及它在一个右句型(最右推导可以得到的句型)中的位置。以最底下一行为例,这个句柄“Int”是产生式“pri->Int”的右边部分,它的位置是句型“Int + Int * Int”的第一个位置。 + +简单来说,句柄,就是产生式是在这个位置上做推导的,如果需要做反向推导的话,也是从这个位置去做规约。 + +针对这个简单的例子,我们可以用肉眼进行判断,找到正确的句柄,做出正确的选择。不过,要把这种判断过程变成严密的算法,做到在每一步都采取正确的行动,知道该做移进还是规约,做规约的话,按照哪个产生式,这就是LR算法要解决的核心问题了。 + +那么,如何找到正确的句柄呢? + +找到正确的句柄 + +我们知道,最右推导是从最开始的产生式出发,经过多步推导(多步推导记做->*),一步步形成当前的局面 (也就是左边栈里有一些非终结符和终结符,右边还可以预看1到k个Token)。 + +add ->* 栈 | Token + + +我们要像侦探一样,根据手头掌握的信息,反向推导出这个多步推导的路径,从而获得正确的句柄。我们依据的是左边栈里的信息,以及右边的Token串。对于LR(0)算法来说,我们只依据左边的栈,就能找到正确的句柄,对于LR(1)算法来说,我们可以从右边预看一个Token。 + +我们的思路是根据语法规则,复现这条推导路径。以第8步为例,下图是它的推导过程,橙色的路径是唯一能够到达第8步的路径。知道了正向推导的路径,自然知道接下来该做什么,在第8步,我们正确的选择是做移进。 + + + +为了展示这个推导过程,我引入了一个新概念:项目(Item)。 + +Item代表带有“.”符号的产生式。比如“pri->(add)”可以产生4个Item,“.”分别在不同的位置。“.”可以看做是前面示意图中的竖线,左边的看做已经在栈里的部分,“.”右边的看做是期待获得的部分: + +pri->.(add) +pri->(.add) +pri->(add.) +pri->(add). + + +上图其实是一个NFA,利用这个NFA,我们表达了所有可能的推导步骤。每个Item(或者状态),在接收到一个符号的时候,就迁移到下一个状态,比如“add->.add+mul”在接收到一个add的时候,就迁移到“add->add.+mul”,再接收到一个“+”,就迁移到“add->add+.mul”。 + +在这个状态图的左上角,我们用一个辅助性的产生式“start->add”,作为整个NFA的唯一入口。从这个入口出发,可以用这个NFA来匹配栈里内容,比如在第8步的时候,栈以及右边下一个Token的状态如下,其中竖线左边是栈的内容: + +add + mul | * + + +在NFA中,我们从start开始遍历,基于栈里的内容,能找到图中橙色的多步推导路径。在这个状态迁移过程中,导致转换的符号分别是“ε、add、+、ε、mul”,忽略其中的ε,就是栈里的内容。 + +在NFA中,我们查找到的Item是“mul->mul.*pri”。这个时候“.”在Item的中间。因此下一个操作只能是一个Shift操作,也就是把下一个Token,*号,移进到栈里。 + +如果“.”在Item的最后,则对应一个规约操作,比如在第12步,栈里的内容是: + +add + mul | $ //$代表Token串的结尾 + + + + +这个时候的Item是“add->add+mul.”。对于所有点符号在最后面的Item,我们已经没有办法继续向下迁移了,这个时候需要做一个规约操作,也就是基于“add + mul”规约到add,也就是到“add->.add+mul”这个状态。对于任何的ε转换,其逆向操作也是规约,比如图中从“add->.add+mul”规约到“start->.add”。 + +但做规约操作之前,我们仍然需要检查后面跟着的Token,是不是在Follow(add)中。对于add来说,它的Follow集合包括{$ + )}。如果是这些Token,那就做规约。否则,就报编译错误。 + +所以,现在清楚了,我们能通过这个有限自动机,跟踪计算出正确的推导过程。 + +当然了,在16讲里,我提到每个NFA都可以转换成一个DFA。所以,你可以直接在上面的NFA里去匹配,也可以把NFA转成DFA,避免NFA的回溯现象,让算法效率更高。转换完毕的DFA如下: + + + +在这个DFA中,我同样标注了在第8步时的推导路径。 + +为了更清晰地理解LR算法的本质,我们基于这个DFA再把语法解析的过程推导一遍。 + +第1步,移进一个Int,从状态1迁移到9。Item是“pri->Int.”。 + + + +第2步,依据“pri->Int”做规约,从状态9回到状态1。因为现在栈里有个pri元素,所以又迁移进了状态8。 + + + +第3步,依据“mul->pri”做规约,从状态8回到状态1,再根据栈里的mul元素进入状态7。注意,在状态7的时候,下一步的走向有两个可能的方向,分别是“add->mul.”和“mul->mul.*pri”这两个Item代表的方向。 + +基于“add->mul.”会做规约,而基于“mul->mul.*pri”会做移进,这就需要看看后面的Token了。如果后面的Token是 *号,那其实要选第二个方向。但现在后面是+号,所以意味着这里只能做规约。 + + + +第4步,依据“add->mul”做规约,从状态7回到状态1,再依据add元素进入状态2。 + + + +第5步,移进+号。这对应状态图上的两次迁移,首先根据栈里的第一个元素add,从1迁移到2。然后再根据“+”,从2到3。Item的变化是: + + +状态1:start->.add- +状态1:add->.add+mul- +状态2:add->add.+mul- +状态3:add->add+.mul + + +你看,通过移进这个加号,我们实际上知道了这个表达式顶部必然有一个“add+mul”的结构。 + + + +第6到第8步,移进Int,并一直规约到mul。状态变化是先从状态3到状态9,然后回到状态3,再进到状态4。 + + + + + +第9步,移进一个*。根据栈里的元素,迁移路径是1->2->3->4->5。 + + + +第10步,移进Int,进入状态9。 + + + +第11步,根据“pri->Int”规约到pri,先退回到状态5,接着根据pri进入状态6。 + + + +第12步,根据“mul->mul*pri”规约到mul,从而退回到状态4。 + + + +第13步,根据“add->add+mul”规约到add,从而退回到状态2。 + + + +从状态2再根据“start->add”再规约一步,就变成了start,回到状态1,解析完成。 + +现在我们已经对整个算法的整个执行过程建立了直觉认知。如果想深入掌握LR算法,我建议你把这种推导过程多做几遍,自然会了然于胸。建立了直觉认知以后,接下来,我们再把LR算法的类型和实现细节讨论一下。 + +LR解析器的类型和实现 + +LR算法根据能力的强弱和实现的复杂程度,可以分成多个级别,分别是LR(0)、SLR(k)(即简单LR)、LALR(k)(Look ahead LR)和LR(k),其中k表示要在Token队列里预读k个Token。 + + + +我来讲解一下这四种类型算法的特点,便于你选择和使用。 + +LR(0)不需要预看右边的Token,仅仅根据左边的栈就能准确进行反向推导。比如,前面DFA中的状态8只有一个Item:“mul->pri.”。如果处在这个状态,那接下来操作是规约。假设存在另一个状态,它也只有一个Item,点符号不在末尾,比如“mul->mul.*pri”,那接下来的操作就是移进,把下一个输入放到栈里。 + +但实际使用的语法规则很少有这么简单的。所以LR(0)的表达能力太弱,能处理的语法规则有限,不太有实用价值。就像在前面的例子中,如果我们不往下预读一个Token,仅仅利用左边工作区的信息,是找不到正确的句柄的。 + +比如,在状态7中,我们可以做两个操作: + + +对于第一个Item,“add->mul.”,需要做一个规约操作。 +对于第二个Item,“mul->mul.*pri”,实际上需要做一个移进操作。 + + +这里发生的冲突,就叫做“移进/规约”冲突(Shift/Reduce Conflict)。意思是,又可以做移进,又可以做规约,到底做哪个?对于状态7来说,到底做哪个操作,实际上取决于右边的Token。 + +SLR(Simple LR)是在LR(0)的基础上做了增强。对于状态7的这种情况,我们要加一个判断条件:右边下一个输入的Token,是不是在add的Follow集合中。因为只有这样,做规约才有意义。 + +在例子中,add的Follow集合是{+ ) $}。如果不在这个范围内,那么做规约肯定是不合法的。因为Follow集合的意思,就是哪些Token可以出现在某个非终结符后面。所以,如果在状态7中,下一个Token是*,它不在add的Follow集合中,那么我们就只剩了一个可行的选择,就是移进。这样就不存在两个选择,也不存在冲突。 + +实际上,就我们本讲所用的示例语法而言,SLR就足够了,但是对于另一些更复杂的语法,采用SLR仍然会产生冲突,比如: + +start -> exp +exp -> lvalue = rvalue +exp -> rvalue +lvalue -> Id +lvalue -> *rvalue +rvalue -> lvalue + + +这个语法说的是关于左值和右值的情况,我们曾在语义分析的时候说过。在这个语法里,右值只能出现在赋值符号右边。 + +在状态2,如果下一个输入是“=”,那么做移进和规约都是可以的。因为“=”在rvalue的Follow集合中。 + + + +怎么来处理这种冲突呢?仅仅根据Follow集合来判断是否Reduce,不太严谨。因为在上图状态2的情况下,即使后面跟着的是“=”,我们仍然不能做规约。因为你一规约,就成了一个右值,但它在等号的左边,显然是跟我们的语法定义冲突的。 + +办法是Follow集合拆了,把它的每个成员都变成Item的一部分。这样我们就能做更细致的判断。如下图所示,这样细化以后,我们发现在状态2中,只有下一个输入是“$”的时候,才能做规约。这就是LR(1)算法的原理,它更加强大。 + + + +但LR(1)算法也有一个缺点,就是DFA可能会很大。在语法分析阶段,DFA的大小会随着语法规则的数量呈指数级上升,一个典型的语言的DFA状态可能达到上千个,这会使语法分析的性能很差,从而也丧失了实用性。 + +LALR(k)是基于这个缺点做的改进。它用了一些技巧,能让状态数量变得比较少,但处理能力没有太大的损失。YACC和Bison这两个工具,就是基于LALR(1)算法的。 + +课程小结 + +今天,我们讲了自底向上的LR算法的原理,包括移进-规约,如何寻找正确的句柄,如果基于NFA和DFA决定如何做移进和规约。 + +LR算法是公认的比较难学的一个算法。好在我们已经在前两讲给它做了技术上的铺垫了,包括NFA和DFA,First和Follow集合。这节课我们重点在于建立直观理解,特别是如何依据栈里的信息做正确的反推。这个直觉认知很重要,建立这个直觉的最好办法,就是像本节课一样,根据实例来画图、推导。这样,在你真正动手写算法的时候,就胸有成竹了! + +到今天为止,我们已经把前端技术中的关键算法都讲完了。不过我还是想强调一下,如果想真正掌握这些算法,必须动手实现一下才行,勤动手才是王道。 + +一课一思 + +在讲自顶向下的算法时,我提到,递归思维是重要的计算机科学思维方式。而自底向上的方法,也是另一种重要的思维方式。那么,请结合你的经验,思考一下在你的领域内,是否有一些问题,用自底向上的方法能更好地解决。LR算法的移进-规约思想,能否在解决其他自底向上的问题中发挥作用?欢迎在留言区分享你的经验和思考。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +本节课的示例代码我放在了文末,供你参考。 + + +lab/16-18(算法篇的示例代码):码云 GitHub +LLParser.java(LL算法的语法解析器):码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/19案例总结与热点问题答疑:对于左递归的语法,为什么我的推导不是左递归的?.md b/专栏/编译原理之美/19案例总结与热点问题答疑:对于左递归的语法,为什么我的推导不是左递归的?.md new file mode 100644 index 0000000..2c41102 --- /dev/null +++ b/专栏/编译原理之美/19案例总结与热点问题答疑:对于左递归的语法,为什么我的推导不是左递归的?.md @@ -0,0 +1,231 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 19 案例总结与热点问题答疑:对于左递归的语法,为什么我的推导不是左递归的? + 目前为止,“编译原理”的前端部分已经讲完了,你学到现在,感受如何呢? + +不得不说,订阅这门课程的同学,都是很有追求的。因为编译原理这门课,肯定给你的学习生涯多多少少地带来过“伤害”,你现在有勇气重拾“编译原理”,下决心将它攻克,本身就是一种有追求的表现。 + +在课程开始之初,很多同学当场立下(入)了Flag(坑),比如: + + +@andylo25:立下Flag,想写一个解释性语言。- +@陈越 :许诺会跟着学完。- +@许。:强调自己因为面试华为来学习编译原理。- +…… + + +还有同学认为自己半路出家,为了长远的发展,一定要补好基本功。要我说,乔布斯还是辍学加半路出家的呢,终生学习是互联网时代的常态: + + +@一只豪猪 :半路出家的野路子码农来补课了。- +…… + + +在准备课程的过程中,我努力把晦涩的知识点变得通俗易懂,希望得到你的认可。当我在留言区看到一些留言时,我的内心是欣慰的,也是欣喜的: + + +@许童童:之前看到词法分析什么的就是一脸蒙,看了老师的文章,醍醐灌顶。- +@VVK:老师讲的太好了,十几年没搞懂的概念终于整理明白了。- +…… + + +与此同时,我也在不断优化课程,力求将内容做到深入浅出,比如,在策划算法篇的内容时,我吸取一些同学的建议,尽可能画成可视化的图形,并且让整个算法的推导过程很直观地呈现。 + +但是我不能回避一个事实,就是即便这些内容你认为很好,但你要想学好编译原理,还是要花费不少精力将这些内容反复地看上几遍。你需要认真跟上课程的思路和进程,用心思考和实践,才会有所得,单看内容不动手尝试是没办法学为所用的。所以,在这里,我想表扬一些有耐心,愿意尝试的同学,比如@曾经瘦过@Unrestrained@周小明@Sam 当然,还有很多同学在一直坚持,我为你们点赞! + +而且,我发现,很多同学还有探知和质疑精神,比如,@沉淀的梦想 发现我在示例代码里用的都是左值,也跟我讨论在实现闭包的时候,如何仍然正常访问全局变量。@mcuking 指出JavaScript的ES6版本已经支持块作用域 @李梁|东大 也与我讨论了关于C++ auto变量的类型推导等等。 + +我知道大部分同学的时间很紧,但我感谢你们的坚持,感谢你们在努力抽时间动手实践,比如@Smallfly 自己动手写规则;@曾经瘦过 再次动手跟着敲代码。 + +还有很多同学花了很多时间,用自己熟悉的语言,参照课程的示例代码重写了词法分析器、语法分析器,并分享了代码: + + +@(——_ ——):写了一晚上,终于用C语言模仿实现了第二节课的内容。- +@windpiaoxue:也做了一个C语言实现。- +…… + + +其他有Go语言的(@catplanet)、Swift语言的(@Smallfly@Rockbean@贾献华)、C++语言的(@阿尔伯特@中年男子@蛋黄儿)、TypeScript的(@缺个豆饼吗@好吃的呆梨)、PHP的(@吴军旗)等等,我通常都会编译并运行一下。 + +@catplanet 甚至提供了一个界面,可以通过浏览器调用自己写的编译程序,运行并显示结果。 + +@京京beaver 还分享了在Windows环境下如何做Antlr的配置,让其他同学可以更顺畅地运行Antlr。 + +@knull 建议我在写BNF的时候,用到+号Token要带上引号,避免跟原来BNF表示重复1到多次的+号冲突。 + +@kaixiao7 提醒我在Windows下,EOF是用Ctl+z输入。 + +我对你们取得的成果以及建议感到由衷的高兴和感谢,我相信,你们的分享也激励了其他同学克服困难,继续前进! + +当然了,你在学习的过程中,还会遇到一些问题,我很感谢提问题的同学。其中一些问题,我认为是比较典型,有通用意义的,所以选了4个典型的问题,再带你详细地探究一下。 + +问题一:对于左递归的语法,为什么我的推导不是左递归的? + +这个问题本身反映了,进行递归下降分析的时候,如何保持清晰的思路,值得讲一讲。 + +在03讲,我们刚开始接触到语法分析,也刚开始接触递归下降算法。这时,我介绍了左递归的概念,但你可能在实际推导的过程中,觉得不是左递归,比如用下面这个语法,来推导“2+3”这个简单的表达式: + +//简化的左递归文法 +add->Int +add->add + Int + + +你可能会拿第一个产生式做推导: + + +add->2- +成功返回 + + +因为没有采用第二条产生式,所以不会触发递归调用。但这里的问题是,“2+3”是一个加法表达式,2也是一个合法的加法表达式,但仅仅解析出2是不行的,我们必须完整地解析出“2+3”来。 + +在17讲,我提到,任何自顶向下的算法,都是在一个大的图里找到一条搜索路径的过程。最后的结果,是经过多次推导,生成跟输入的Token串相同的结果,解析完毕以后,所有Token也耗光。 + + + +如果只匹配上2,那就证明这条搜索路径是错误的,我们必须尝试另一种可能性,也就是第二个产生式。 + +要找到正确的搜索路径,在递归下降算法或者LL算法时,我们都是采用“贪婪”策略,这个策略在16讲关于正则表达式时讲过。也就是要匹配尽量多的Token才可以。就算是换成右递归的文法,也不能采用第一个产生式。因为解析完Int以后,接下来的Token是+号,还可以尝试用第二个产生式,那我们就要启动贪婪策略,用第二个,而不是第一个。 + +//简化的右递归文法 +add->Int +add->Int + add + + +以上是第一种情况。 + +不过有的同学说:“我运用第二个产生式也能匹配成功,根据‘add->add + int’这个产生式,先拿第一个add去匹配2,再去匹配+号和3不就行了吗?” + +这是另一种引起困扰的情况,也是我在17讲必须说一下广度优先算法的一个原因。因为这位同学的推导过程,是典型的广度优先。add非终结符,先广度优先地拆成两条路径:第一条路径匹配不成功;第二条路径进一步进行广度优先的探索,于是成功解析: + + + +但我们在17讲也说过了,广度优先算法的性能很低,在这个简单的例子中还看不出来,但如果是稍微复杂一点儿的语法和表达式,计算量就指数级上升。 + +问题二:二元表达式的结合性的实现。 + + +@nil:最终通过循环来消除递归带来的二元预算符的结合性问题?能否直接在递归中消除结合性问题? + + +04讲的这个问题在递归下降算法中是个难点,反映了理论上的算法用于工程实践时,会遇到的具体问题和解决方案,所以也值得探讨。 + +因为递归下降算法是自顶向下、最左推导的。对于AST来说,父节点总是先于子节点来生成。因此,使用下面这个消除了左递归的加法文法来尝试解析“2+3+4+5”这个表达式: + +add -> Int add' +add' -> + Int add' | ε + + +得到的AST应该是这样的: + + + +这个AST会觉得有点儿怪,毕竟它把加法操作分成了add和add’这两种操作。针对add’这样一个节点,我们可以定义为把Int子节点和add’子节点做加法,但这样就一共要做四次计算,1个add计算,3个add’计算。并且,因为是右递归,所以计算顺序是右结合的。 + +如果我们想改成左结合,可以尝试改变之前的约定,就是父节点先于子节点生成,把AST强行拧成这个样子: + + + +可以看出,这样强拧的过程,已经违背了add和add’产生式的规则。 + +同时,用add和add’这两个节点才能表达加法运算,还是跟我们日常的习惯相违背。与之相对的,Antlr的写法,就很符合我们日常习惯。它是根据这样的额外信息,决定解析时如何生成AST的结构: + +add : Int + | add + add + ; + + +我们文稿中的示例算法,跟这个思路类似,也是不改变加法运算的含义,但会根据结合性改变AST节点的父子结构。这种改变,等价于我们在解析加法表达式时,不是用的最左推导,而是最右推导。 + +所以,我们可以看出: + +单纯的运用递归下降算法或LL算法,我们是无法支持左结合的,一定要做一些特殊的处理。而LR算法就不需要这些特殊处理,仅仅通过文法的设计,就能支持好结合性,这可能是很多人推崇LR算法的原因吧。 + +另一方面,工程上运用良好的语法解析方法,不需要是纯粹的某一种单一的算法,增加一些辅助手段会让它更有效。比如Antlr的内部实现可以自动选择预读1个或更多个Token。必要的话还会启动回溯机制。这样做的好处,是对语法编写的要求降低,更加照顾程序员的工作效率。 + +问题三 :二义性文法为什么也能正常解析? + + +@windpiaoxue: + + +stmt -> if expr stmt + | if expr stmt else stmt + | other + + + +我测试了一下,Antlr使用上面这个规则可以正确地处理悬挂else的问题, Antlr在处理这种二义性问题的时候,是依据什么来处理的? + + +针对07讲中关于二义性文法的问题也有普遍意义,其实原因我在07讲里已经说了。我们实现一个算法的时候,是有确定的顺序来匹配的,所以,即使是二义性文法,在某种算法下也可以正常解析,也就是生成正确的AST。 + +如果我们采取深度优先的自顶向下的算法,在使用这两个产生式时: + +stmt -> if expr stmt +stmt -> if expr stmt else stmt + + +我们就像问题一中讲加法运算时提到的那样,采用“贪婪”的算法,总是试图匹配更多的Token。所以,只要有else,它就会去匹配else,所以else总是会跟最近的if组成一对。但采用这个文法的时候,如果不是用贪婪策略来解析,就可能会导致if和else错配。 + +而严格的非二义性文法要求得比较高,它要求是算法无关的,也就是无论采用哪种推导顺序,形成的AST是一样的。 这里的关键点,在于把“文法”和“算法”这两件事区分开,文法是二义的,用某个具体算法却不一定是二义的。 + +问题四:“语法”和“文法”有什么区别和联系? + + +@鸠摩智:请问语法和文法有什么区别和联系? + + +这是一个术语的问题,确实要理清楚,你也可能会有这种疑问。 + +文法(Grammar),是形式语言(Formal Language)的一个术语。所以也有Formal Grammar这样的说法。这里的文法是定义清晰的规则,比如,我们的词法规则、语法规则和属性规则,都是用形式文法来定义的。 + +我们的课程里讲解了正则文法(Regular Grammar)、上下文无关文法(Context-free Grammar)等不同的文法规则,用来描述词法和语法。 + +语法分析中的语法(Syntax),主要是描述词是怎么组成句子的,一个语言的语法规则,通常指的是这个Syntax。 + +问题是,Grammar这个词,在中文很多应用场景中也叫做语法。这是会引起混淆的地方。我们在使用的时候要小心一点儿就行了。 + +比如,我做了一个规则文件,里面都是一些词法规则(Lexer Grammar),我会说,这是一个词法规则文件,或者词法文法文件。这个时候,把它说成是一个语法规则文件,就有点儿含义模糊。因为这里面并没有语法规则(Syntax Grammar)。 + +案例总结 + +在前端部分,我们伴随着文稿提供了丰富的示例程序,我相信代码是程序员之间沟通的最好手段。 + +第一批示例程序,是lab/craft目录下的。 + +通过手工实现简单的词法分析和语法分析,获得第一手的感受,破除对于编译技术的神秘感。你会感觉到,如果要实现公式计算器甚至一个简单脚本,似乎也没那么难。 + +第二批示例程序,是基于Antlr工具的。 + +使用这个工具,实现了两个目的: + + +第一,让你借鉴成熟的规则文件,高效实现词法分析和语法分析功能。 +第二,在不必关注词法分析和语法分析的情况下,我们把更多的精力放在了语言特性设计、语义分析和运行期机制上。针对作用域、函数、闭包、面向对象等特性都提供了示例程序,最终实现出一门看上去挺威风的脚本语言。 + + +第三批示例程序,则是完成了应用篇的两个题目。 + +一个示范了如何通过解析SQL语句,实现分布式数据库的一个简单特性。另一个演示了如何来实现一个报表系统。通过两个实际案例将技术跟应用领域做了很好的连接,启发你按照类似的思路,去解决自己领域的问题。 + +第四批示例程序,是在算法篇,针对编译器前端的三组核心算法提供了示例。 + +这些示例程序能够根据文法规则直接做词法分析和语法分析,不需要为每一组规则单独构造词法分析器和语法分析器,实际上相当于简化版本的Lex(词法分析)、Antlr(LL语法分析)和YACC(LR语法分析)。 + +我给你的学习设计了多次迭代、循环提升认知的路径,从简单原理、现有工具和最佳实践、领域应用、算法逻辑等多个维度,给你全面的感受。 + +小结 + +编译原理的前端技术部分,正式告一个段落。在这个过程中,我强调地是建立直觉,掌握关键知识点,以及跟实践结合,这三个方面是关键。在短短的十多节课中,我们已经覆盖了所有关键的知识点,吃透这部分课程,会对你的实际工作有所裨益。 + +当然,我也知道,不到两个月的时间,你肯定不可能完全把它完全吃透,不过,你已经在自己的知识体系中种下了一颗高质量种子,它会随着时间的流逝,伴随着你在实际应用中的体会,不断成长,并结出丰硕的果实! + +如果你还有其他的问题,欢迎在留言区提问,我会逐一解答。最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/20高效运行:编译器的后端技术.md b/专栏/编译原理之美/20高效运行:编译器的后端技术.md new file mode 100644 index 0000000..db243df --- /dev/null +++ b/专栏/编译原理之美/20高效运行:编译器的后端技术.md @@ -0,0 +1,156 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 20 高效运行:编译器的后端技术 + 前18节课,我们主要探讨了编译器的前端技术,它的重点,是让编译器能够读懂程序。无结构的代码文本,经过前端的处理以后,就变成了Token、AST和语义属性、符号表等结构化的信息。基于这些信息,我们可以实现简单的脚本解释器,这也从另一个角度证明了我们的前端处理工作确实理解了程序代码,否则程序不可能正确执行嘛。 + +实际上,学完前端技术以后,我们已经能做很多事情了,比如让软件有自定义功能,就像我们在15讲中提到的报表系统,这时,不需要涉及编译器后端技术。 + +但很多情况下,我们需要继续把程序编译成机器能读懂的代码,并高效运行。这时,我们就面临了三个问题: + +1.我们必须了解计算机运行一个程序的原理(也就是运行期机制),只有这样,才知道如何生成这样的程序。- +2.要能利用前端生成的AST和属性信息,将其正确翻译成目标代码。- +3.需要对程序做尽可能多的优化,比如让程序执行效率更高,占空间更少等等。 + +弄清这三个问题,是顺利完成编译器后端工作的关键,本节课,我会让你对程序运行机制、生成代码和优化代码有个直观的了解,然后再在接下来的课程中,将这些问题逐一击破。 + +弄清程序的运行机制 + +总的来说,编译器后端要解决的问题是:现在给你一台计算机,你怎么生成一个可以运行的程序,然后还能让这个程序在计算机上正确和高效地运行? + +我画了一个模型: + + + +基本上,我们需要面对的是两个硬件: + + +一个是CPU,它能接受机器指令和数据,并进行计算。它里面有寄存器、高速缓存和运算单元,充分利用寄存器和高速缓存会让系统的性能大大提升。 + +另一个是内存。我们要在内存里保存编译好的代码和数据,还要设计一套机制,让程序最高效地利用这些内存。 + + +通常情况下,我们的程序要受某个操作系统的管理,所以也要符合操作系统的一些约定。但有时候我们的程序也可能直接跑在硬件上,单片机和很多物联网设备采用这样的结构,甚至一些服务端系统,也可以不跑在操作系统上。 + +你可以看出,编译器后端技术跟计算机体系结构的关系很密切。我们必须清楚地理解计算机程序是怎么运行的,有了这个基础,才能探讨如何编译生成这样的程序。 + +所以,我会在下一节课,也就是21讲,将运行期的机制讲清楚,比如内存空间如何划分和组织;程序是如何启动、跳转和退出的;执行过程中指令和数据如何传递到CPU;整个过程中需要如何跟操作系统配合,等等。 + +也有的时候,我们的面对的机器是虚拟机,Java的运行环境就是一个虚拟机(JVM),那我们需要就了解这个虚拟机的特点,以便生成可以在这个虚拟机上运行的代码,比如Java的字节码。同时,字节码有时仍然需要编译成机器码。 + +在对运行期机制有了一定的了解之后,我们就有底气来进行下一步了,生成符合运行期机制的代码。 + +生成代码 + +编译器后端的最终结果,就是生成目标代码。如果目标是在计算机上直接运行,就像C语言程序那样,那这个目标代码指的是汇编代码。而如果运行目标是Java虚拟机,那这个目标代码就是指JVM的字节码。 + +基于我们在编译器前端所生成的成果,我们其实可以直接生成汇编代码,在后面的课程中,我会带你做一个这样的尝试。 + +你可能惧怕汇编代码,觉得它肯定很难,能写汇编的人一定很牛。在我看来,这是一个偏见,因为汇编代码并不难写,为什么呢? + +其实汇编没有类型,也没有那么多的语法结构,它要做的通常就是把数据拷贝到寄存器,处理一下,再保存回内存。所以,从汇编语言的特性看,就决定了它不可能复杂到哪儿去。 + +你如果问问硬件工程师就知道了,因为他们经常拿汇编语言操作寄存器、调用中断,也没多难。但另一方面,正是因为汇编的基础机制太简单,而且不太安全,用它编写程序的效率太低,所以现在直接用汇编写的程序,都是处理很小、很单一的问题,我们不会再像阿波罗登月计划那样,用汇编写整个系统,这个项目的代码最近已经开源了,如果现在用高级语言去做这项工作,会容易得多,还可以像现在的汽车自动驾驶系统一样实现更多的功能。 + +所以,在22和23讲,我会带你从AST直接翻译成汇编代码,并编译成可执行文件,这样你就会看到这个过程没有你想象的那么困难,你对汇编代码的恐惧感,也会就此消失了。 + +当然,写汇编跟使用高级语言有很多不同,其中一点就是要关心CPU和内存这样具体的硬件。比如,你需要了解不同的CPU指令集的差别,你还需要知道CPU是64位的还是32位的,有几个寄存器,每个寄存器可以用于什么指令,等等。但这样导致的问题是,每种语言,针对每种不同的硬件,都要生成不同的汇编代码。你想想看,一般我们设计一门语言要支持尽可能多的硬件平台,这样的工作量是不是很庞大? + +所以,为了降低后端工作量,提高软件复用度,就需要引入中间代码(Intermediate Representation,IR)的机制,它是独立于具体硬件的一种代码格式。各个语言的前端可以先翻译成IR,然后再从IR翻译成不同硬件架构的汇编代码。如果有n个前端语言,m个后端架构,本来需要做m*n个翻译程序,现在只需要m+n个了。这就大大降低了总体的工作量。 + + + +甚至,很多语言主要做好前端就行了,后端可以尽量重用已有的库和工具,这也是现在推出新语言越来越快的原因之一。像Rust就充分利用了LLVM,GCC的各种语言,如C、C++、Object C等,也是充分共享了后端技术。 + +IR可以有多种格式,在第24讲,我们会介绍三地址代码、静态单赋值码等不同的IR。比如,“x + y * z”翻译成三地址代码是下面的样子,每行代码最多涉及三个地址,其中t1和t2是临时变量: + +t1 := y * z +t2 := x + t1 + + +Java语言生成的字节码也是一种IR,我们还会介绍LLVM的IR,并且基于LLVM这个工具来加速我们后端的开发。 + +其实,IR这个词直译成中文,是“中间表示方式”的意思,不一定非是像汇编代码那样的一条条的指令。所以,AST其实也可以看做一种IR。我们在前端部分实现的脚本语言,就是基于AST这个IR来运行的。 + +每种IR的目的和用途是不一样的: + + +AST主要用于前端的工作。 +Java的字节码,是设计用来在虚拟机上运行的。 +LLVM的中间代码,主要是用于做代码翻译和编译优化的。 +…… + + +总的来说,我们可以把各种语言翻译成中间代码,再针对每一种目标架构,通过一个程序将中间代码翻译成相应的汇编代码就可以了。然而事情真的这么简单吗?答案是否定的,因为我们还必须对代码进行优化。 + +代码分析和优化 + +生成正确的、能够执行的代码比较简单,可这样的代码执行效率很低,因为直接翻译生成的代码往往不够简洁,比如会生成大量的临时变量,指令数量也较多。因为翻译程序首先照顾的是正确性,很难同时兼顾是否足够优化,这是一方面。另一方面,由于高级语言本身的限制和程序员的编程习惯,也会导致代码不够优化,不能充分发挥计算机的性能。所以我们一定要对代码做优化。程序员在比较各种语言的时候,一定会比较它们的性能差异。一个语言的性能太差,就会影响它的使用和普及。 + +实际上,就算是现在常见的脚本语言,如Python和JavaScript,也做了很多后端优化的工作,包括编译成字节码、支持即时编译等,这些都是为了进一步提高性能。从谷歌支持的开源项目V8开始,JavaScript的性能获得了巨大的提高,这才导致了JavaScript再一次的繁荣,包括支持体验更好的前端应用和基于Node.js的后端应用。 + +优化工作又分为“独立于机器的优化”和“依赖于机器的优化”两种。 + +独立于机器的优化,是基于IR进行的。它可以通过对代码的分析,用更加高效的代码代替原来的代码。比如下面这段代码中的foo()函数,里面有多个地方可以优化。甚至,我们连整个对foo()函数的调用,也可以省略,因为foo()的值一定是101。这些优化工作在编译期都可以去做。 + +int foo(){ + int a = 10*10; //这里在编译时可以直接计算出100这个值 + int b = 20; //这个变量没有用到,可以在代码中删除 + + if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉 + return a+1; //这里可以在编译器就计算出是101 + } + else{ + return a-1; + } +} +int a = foo(); //这里可以直接地换成 a=101; + + +上面的代码,通过优化,可以消除很多冗余的逻辑。这就好比你正在旅行,先从北京飞到了上海,然后又飞到厦门,最后飞回北京。然后你朋友问你现在在哪时,你告诉他在北京。那么他虽然知道你在北京,但并没有意识到你已经在几个城市折腾了一圈,因为他只关心你现在在哪儿,并不关心你的中间过程。 我们在给a赋值的时候,只需要知道这个值是101就行了。完全不需要在运行时去兜一大圈来计算。 + +计算机代码里有很多这种需要优化的情形。我们在27和28讲会介绍多种优化技术,比如局部优化和全局优化,常数折叠、拷贝传播、删除公共子表达式等,其中数据流分析方法比较重要,会重点介绍。 + +依赖于机器的优化,则是依赖于硬件的特征。现代的计算机硬件设计了很多特性,以便提供更高的处理能力,比如并行计算能力,多层次内存结构(使用多个级别的高速缓存)等等。编译器要能够充分利用硬件提供的性能,比如 : + + +寄存器优化。对于频繁访问的变量,最好放在寄存器中,并且尽量最大限度地利用寄存器,不让其中一些空着,有不少算法是解决这个问题的,教材上一般提到的是染色算法; + +充分利用高速缓存。高速缓存的访问速度可以比内存快几十倍上百倍,所以我们要尽量利用高速缓存。比如,某段代码操作的数据,在内存里尽量放在一起,这样CPU读入数据时,会一起都放到高速缓存中,不用一遍一遍地重新到内存取。 + +并行性。现代计算机都有多个内核,可以并行计算。我们的编译器要尽可能把充分利用多个内核的计算能力。 这在编译技术中是一个专门的领域。 + +流水线。CPU在处理不同的指令的时候,需要等待的时间周期是不一样的,在等待某些指令做完的过程中其实还可以执行其他指令。就比如在星巴克买咖啡,交了钱就可以去等了,收银员可以先去处理下一个顾客,而不是要等到前一个顾客拿到咖啡才开始处理下一个顾客。 + +指令选择。有的时候,CPU完成一个功能,有多个指令可供选择。而针对某个特定的需求,采用A指令可能比B指令效率高百倍。比如X86架构的CPU提供SIMD功能,也就是一条指令可以处理多条数据,而不是像传统指令那样一条指令只能处理一条数据。在内存计算领域,SIMD也可以大大提升性能,我们在第30讲的应用篇,会针对SIMD做一个实验。 + +其他优化。比如可以针对专用的AI芯片和GPU做优化,提供AI计算能力,等等。 + + +可以看出来,做好依赖于机器的优化要对目标机器的体系结构有清晰的理解,如果能做好这些工作,那么开发一些系统级的软件也会更加得心应手。实际上,数据库系统、大数据系统等等,都是要融合编译技术的。 + +总结起来,在编译器中需要对代码进行的优化非常多。因此,这部分工作也是编译过程中耗时最长、最体现某个编译器的功力的一类工作,所以更值得引起你的重视。 + +课程小结 + +本节课,我们对编译器的后端技术做了概述。你了解到要做好后端工作,必须熟悉计算机体系结构和程序的运行时机制;还要从前端生成中间代码,然后基于中间代码生成针对不同平台的目标代码;最后,需要对代码做各种优化工作,包括独立于机器的优化和依赖于机器的优化。 + +刚接触编译技术的时候,你可能会把视线停留在前端技术上,以为能做Lexer、Parser就是懂编译了。实际上,词法分析和语法分析比较成熟,有成熟的工具来支撑。相对来说,后端的工作量更大,挑战更多,研究的热点也更多。比如,人工智能领域又出现了一些专用的AI芯片和指令集,就需要去适配。 + +编译器的后端,要把高级语言翻译成计算机能够理解的目标语言。它跟前端相比,关注点是不同的。前端关注的是正确反映了代码含义的静态结构,而后端关注的是让代码良好运行的动态结构。它们之间的差别,从我讲解“作用域”和“生存期”两个概念时就能看出来。作用域是前端的概念,而生存期是后端的概念。 + +其实在前面的课程中,我们已经涉及了少量的后端技术的概念,比如生存期、栈桢,因为我们要让脚本语言运行起来。但这个运行环境比较简单,脚本的执行也是简单的基于AST,所以性能是比较低的。但在后端部分,我们会实现一门静态编译型的语言,因此会对对运行期机制做更加深入的解读和实现。 + +如果能把后端技术学好,你对计算机底层运行机制的理解会更上一层楼,也会成为一名底子更加扎实的软件工程师。 + +一课一思 + +我们说编译器后端的任务是让程序适配硬件、高效运行。对于你所熟悉的程序语言,它的后端技术有什么特点呢?比如它采用了哪些技术使得性能更高,或者代码尺寸更小,或者能更好地兼容硬件?欢迎在留言区分享你的经验和观点。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/21运行时机制:突破现象看本质,透过语法看运行时.md b/专栏/编译原理之美/21运行时机制:突破现象看本质,透过语法看运行时.md new file mode 100644 index 0000000..3bde86e --- /dev/null +++ b/专栏/编译原理之美/21运行时机制:突破现象看本质,透过语法看运行时.md @@ -0,0 +1,214 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 21 运行时机制:突破现象看本质,透过语法看运行时 + 编译器的任务,是要生成能够在计算机上运行的代码,但要生成代码,我们必须对程序的运行环境和运行机制有比较透彻的了解。 + +你要知道,大型的、复杂一点儿的系统,比如像淘宝一样的电商系统、搜索引擎系统等等,都存在一些技术任务,是需要你深入了解底层机制才能解决的。比如淘宝的基础技术团队就曾经贡献过,Java虚拟机即时编译功能中的一个补丁。 + +这反映出掌握底层技术能力的重要性,所以,如果你想进阶成为这个层次的工程师,不能只学学上层的语法,而是要把计算机语言从上层的语法到底层的运行机制都了解透彻。 + +本节课,我会对计算机程序如何运行,做一个解密,话题分成两个部分: + +1.了解程序运行的环境,包括CPU、内存和操作系统,探知它们跟程序到底有什么关系。- +2.了解程序运行的过程。比如,一个程序是怎么跑起来的,代码是怎样执行和跳转的,又是如何管理内存的。 + +首先,我们先来了解一下程序运行的环境。 + +程序运行的环境 + +程序运行的过程中,主要是跟两个硬件(CPU和内存)以及一个软件(操作系统)打交道。 + + + +本质上,我们的程序只关心CPU和内存这两个硬件。你可能说:“不对啊,计算机还有其他硬件,比如显示器和硬盘啊。”但对我们的程序来说,操作这些硬件,也只是执行某些特定的驱动代码,跟执行其他代码并没有什么差异。 + +1.关注CPU和内存 + +CPU的内部有很多组成部分,对于本课程来说,我们重点关注的是寄存器以及高速缓存,它们跟程序的执行机制和优化密切相关。 + +寄存器是CPU指令在进行计算的时候,临时数据存储的地方。CPU指令一般都会用到寄存器,比如,典型的一个加法计算(c=a+b)的过程是这样的: + + +指令1(mov):从内存取a的值放到寄存器中;- +指令2(add):再把内存中b的值取出来与这个寄存器中的值相加,仍然保存在寄存器中;- +指令3(mov):最后再把寄存器中的数据写回内存中c的地址。 + + +寄存器的速度也很快,所以能用寄存器就别用内存。尽量充分利用寄存器,是编译器做优化的内容之一。 + +而高速缓存可以弥补CPU的处理速度和内存访问速度之间的差距。所以,我们的指令在内存读一个数据的时候,它不是老老实实地只读进当前指令所需要的数据,而是把跟这个数据相邻的一组数据都读进高速缓存了。这就相当于外卖小哥送餐的时候,不会为每一单来回跑一趟,而是一次取一批,如果这一批外卖恰好都是同一个写字楼里的,那小哥的送餐效率就会很高。 + +内存和高速缓存的速度差异差不多是两个数量级,也就是一百倍。比如,高速缓存的读取时间可能是0.5ns,而内存的访问时间可能是50ns。不同硬件的参数可能有差异,但总体来说是几十倍到上百倍的差异。 + +你写程序时,尽量把某个操作所需的数据都放在内存中的连续区域中,不要零零散散地到处放,这样有利于充分利用高速缓存。这种优化思路,叫做数据的局部性。 + +这里提一句,在写系统级的程序时,你要对各种IO的时间有基本的概念,比如高速缓存、内存、磁盘、网络的IO大致都是什么数量级的。因为这都影响到系统的整体性能,也影响到你如何做程序优化。如果你需要对程序做更多的优化,还需要了解更多的CPU运行机制,包括流水线机制、并行机制等等,这里就不展开了。 + +讲完CPU之后,还有内存这个硬件。 + +程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它在运行期可以使用。我们目前使用的都是64位的机器,你可以用一个64位的长整型来表示内存地址,它能够表示的所有地址,我们叫做寻址空间。 + +64位机器的寻址空间就有2的64次方那么大,也就是有很多很多个TB(Terabyte),大到你的程序根本用不完。不过,操作系统一般会给予一定的限制,不会给你这么大的寻址空间,比如给到100来个G,这对一般的程序,也足够用了。 + +在存在操作系统的情况下,程序逻辑上可使用的内存一般大于实际的物理内存。程序在使用内存的时候,操作系统会把程序使用的逻辑地址映射到真实的物理内存地址。有的物理内存区域会映射进多个进程的地址空间。 + + + +对于不太常用的内存数据,操作系统会写到磁盘上,以便腾出更多可用的物理内存。 + +当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。 + +对于这个内存,该怎么用呢? + +本质上来说,你想怎么用就怎么用,并没有什么特别的限制。一个编译器的作者,可以决定在哪儿放代码,在哪儿放数据,当然了,别的作者也可能采用其他的策略。实际上,C语言和Java虚拟机对内存的管理和使用策略就是不同的。 + +尽管如此,大多数语言还是会采用一些通用的内存管理模式。以C语言为例,会把内存划分为代码区、静态数据区、栈和堆。 + + + +一般来讲,代码区是在最低的地址区域,然后是静态数据区,然后是堆。而栈传统上是从高地址向低地址延伸,栈的最顶部有一块区域,用来保存环境变量。 + +代码区(也叫文本段)存放编译完成以后的机器码。这个内存区域是只读的,不会再修改,但也不绝对。现代语言的运行时已经越来越动态化,除了保存机器码,还可以存放中间代码,并且还可以在运行时把中间代码编译成机器码,写入代码区。 + +静态数据区保存程序中全局的变量和常量。它的地址在编译期就是确定的,在生成的代码里直接使用这个地址就可以访问它们,它们的生存期是从程序启动一直到程序结束。它又可以细分为Data和BSS两个段。Data段中的变量是在编译期就初始化好的,直接从程序装在进内存。BSS段中是那些没有声明初始化值的变量,都会被初始化成0。 + +堆适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。比如,我们在某个方法里创建了一个对象并返回,并希望代表这个对象的数据在退出函数后仍然可以访问。 + +而栈适合保存生存期比较短的数据,比如函数和方法里的本地变量。它们在进入某个作用域的时候申请内存,退出这个作用域的时候就可以释放掉。 + +讲完了CPU和内存之后,我们再来看看跟程序打交道的操作系统。 + +2.程序和操作系统的关系 + +程序跟操作系统的关系比较微妙: + + +一方面我们的程序可以编译成不需要操作系统也能运行,就像一些物联网应用那样,完全跑在裸设备上。 + +另一方面,有了操作系统的帮助,可以为程序提供便利,比如可以使用超过物理内存的存储空间,操作系统负责进行虚拟内存的管理。 + + +在存在操作系统的情况下,因为很多进程共享计算机资源,所以就要遵循一些约定。这就仿佛办公室是所有同事共享的,那么大家就都要遵守一些约定,如果一个人大声喧哗,就会影响到其他人。 + +程序需要遵守的约定包括:程序文件的二进制格式约定,这样操作系统才能程序正确地加载进来,并为同一个程序的多个进程共享代码区。在使用寄存器和栈的时候也要遵守一些约定,便于操作系统在不同的进程之间切换的时候、在做系统调用的时候,做好上下文的保护。 + +所以,我们编译程序的时候,要知道需要遵守哪些约定。因为就算是使用同样的CPU,针对不同的操作系统,编译的结果也是非常不同的。 + +好了,我们了解了程序运行时的硬件和操作系统环境。接下来,我们看看程序运行时,是怎么跟它们互动的。 + +程序运行的过程 + +你天天运行程序,可对于程序运行的细节,真的清楚吗? + +1.程序运行的细节 + +首先,可运行的程序一般是由操作系统加载到内存的,并且定位到代码区里程序的入口开始执行。比如,C语言的main函数的第一行代码。 + +每次加载一条代码,程序都会顺序执行,碰到跳转语句,才会跳到另一个地址执行。CPU里有一个指令寄存器,里面保存了下一条指令的地址。 + + + +假设我们运行这样一段代码编译后形成的程序: + +int main(){ + int a = 1; + foo(3); + bar(); +} + +int foo(int c){ + int b = 2; + return b+c; +} + +int bar(){ + return foo(4) + 1; +} + + +我们首先激活(Activate)main()函数,main()函数又激活foo()函数,然后又激活bar()函数,bar()函数还会激活foo()函数,其中foo()函数被两次以不同的路径激活。 + + + +我们把每次调用一个函数的过程,叫做一次活动(Activation)。每个活动都对应一个活动记录(Activation Record),这个活动记录里有这个函数运行所需要的信息,比如参数、返回值、本地变量等。 + +目前我们用栈来管理内存,所以可以把活动记录等价于栈桢。栈桢是活动记录的实现方式,我们可以自由设计活动记录或栈桢的结构,下图是一个常见的设计: + + + + +返回值:一般放在最顶上,这样它的地址是固定的。foo()函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中,我们会优先通过寄存器来传递返回值,比通过内存传递性能更高。 + +参数:在调用foo函数时,把参数写到这个地址里。同样,我们也可以通过寄存器来传递,而不是内存。 + +控制链接:就是上一级栈桢的地址。如果用到了上一级作用域中的变量,就可以顺着这个链接找到上一级栈桢,并找到变量的值。 + +返回地址:foo函数执行完毕以后,继续执行哪条指令。同样,我们可以用寄存器来保存这个信息。 + +本地变量:foo函数的本地变量b的存储空间。 + +寄存器信息:我们还经常在栈桢里保存寄存器的数据。如果在foo函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。这种约定叫做被调用者责任,也就是使用寄存器的人要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,要自己保存在栈桢中。这种约定叫做调用者责任。 + + + + +你可以看到,每个栈桢的长度是不一样的。 + +用到的参数和本地变量多,栈桢就要长一点。但是,栈桢的长度和结构是在编译期就能完全确定的。这样就便于我们计算地址的偏移量,获取栈桢里某个数据。 + +总的来说,栈桢的设计很自由。但是,你要考虑不同语言编译形成的模块要能够链接在一起,所以还是要遵守一些公共的约定的,否则,你写的函数,别人就没办法调用了。 + +在08讲,我提到过栈桢,这次我们用了更加贴近具体实现的描述:栈桢就是一块确定的内存,变量就是这块内存里的地址。在下一讲,我会带你动手实现我们的栈桢。 + +2.从全局角度看整个运行过程 + +了解了栈桢的实现之后,我们再来看一个更大的场景,从全局的角度看看整个运行过程中都发生了什么。 + + + +代码区里存储了一些代码,main函数、bar函数和foo函数各自有一段连续的区域来存储代码,我用了一些汇编指令来表示这些代码(实际运行时这里其实是机器码)。 + +假设我们执行到foo函数中的一段指令,来计算“b+c”的值,并返回。这里用到了mov、add、jmp这三个指令。mov是把某个值从一个地方拷贝到另一个地方,add是往某个地方加一个值,jmp是改变代码执行的顺序,跳转到另一个地方去执行(汇编命令的细节,我们下节再讲,你现在简单了解一下就行了)。 + +mov b的地址 寄存器1 +add c的地址 寄存器1 +mov 寄存器1 foo的返回值地址 +jmp 返回地址 //或ret指令 + + +执行完这几个指令以后,foo的返回值位置就写入了6,并跳转到bar函数中执行foo之后的代码。 + +这时,foo的栈桢就没用了,新的栈顶是bar的栈桢的顶部。理论上讲,操作系统这时可以把foo的栈桢所占的内存收回了。比如,可以映射到另一个程序的寻址空间,让另一个程序使用。但是在这个例子中你会看到,即使返回了bar函数,我们仍要访问栈顶之外的一个内存地址,也就是返回值的地址。 + +所以,目前的调用约定都规定,程序的栈顶之外,仍然会有一小块内存(比如128K)是可以由程序访问的,比如我们可以拿来存储返回值。这一小段内存操作系统并不会回收。 + +我们目前只讲了栈,堆的使用也类似,只不过是要手工进行申请和释放,比栈要多一些维护工作。 + +课程小结 + +本节课,我带你了解了程序运行的环境和过程,我们的程序主要跟CPU、内存,以及操作系统打交道。你需要了解的重点如下: + + +CPU上运行程序的指令,运行过程中要用到寄存器、高速缓存来提高指令和数据的存取效率。 + +内存可以划分成不同的区域保存代码、静态数据,并用栈和堆来存放运行时产生的动态数据。 + +操作系统会把物理的内存映射成进程的寻址空间,同一份代码会被映射进多个进程的内存空间,操作系统的公共库也会被映射进进程的内存空间,操作系统还会自动维护栈。 + + +程序在运行时顺序执行代码,可以根据跳转指令来跳转;栈被划分成栈桢,栈桢的设计有一定的自由度,但通常也要遵守一些约定;栈桢的大小和结构在编译时就能决定;在运行时,栈桢作为活动记录,不停地被动态创建和释放。 + +以上这些内容就是一个程序运行时的秘密。你再面对代码时,脑海里就会想象出它是怎样跟CPU、内存和操作系统打交道的了。而且有了这些背景知识,你也可以让编译器生成代码,按照本节课所说的模式运行了! + +一课一思 + +本节课,我概要地介绍了程序运行的环境和运行过程。常见的静态编译型的语言,比如C语言、Go语言,差不多都是这个模式。那么你是否了解你所采用的计算机语言的运行环境和运行过程?跟本文描述的哪些地方相同,哪些地方不同?欢迎在留言区分享你的经验。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/22生成汇编代码(一):汇编语言其实不难学.md b/专栏/编译原理之美/22生成汇编代码(一):汇编语言其实不难学.md new file mode 100644 index 0000000..fa9200d --- /dev/null +++ b/专栏/编译原理之美/22生成汇编代码(一):汇编语言其实不难学.md @@ -0,0 +1,296 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 22 生成汇编代码(一):汇编语言其实不难学 + +敲黑板:课程用的是GNU汇编器,macOS和Linux已内置,本文的汇编语言的写法是GNU汇编器规定的写法。Windows系统可安装MinGW或Linux虚拟机。 + + +对于静态编译型语言,比如C语言和Go语言,编译器后端的任务就是生成汇编代码,然后再由汇编器生成机器码,生成的文件叫目标文件,最后再使用链接器就能生成可执行文件或库文件了。 + + + +就算像JavaScript这样的解释执行的语言,也要在运行时利用类似的机制生成机器码,以便调高执行的速度。Java的字节码,在运行时通常也会通过JIT机制编译成机器码。而汇编语言是完成这些工作的基础。 + +对你来说,掌握汇编语言是十分有益的,因为哪怕掌握一小点儿汇编技能,就能应用到某项工作中,比如,在C语言里嵌入汇编,实现某个特殊功能;或者读懂某些底层类库或驱动程序的代码,因为它可能是用汇编写的。 + +本节课,我先带你了解一下汇编语言,来个破冰之旅。然后在接下来的课程中再带你基于AST手工生成汇编代码,破除你对汇编代码的恐惧,了解编译期后端生成汇编代码的原理。 + +以后,当你看到高级语言的代码,以及IR时,就可以想象出来它对应的汇编代码是什么样子,实现从上层到底层认知的贯通。 + +了解汇编语言 + +机器语言都是0101的二进制的数据,不适合我们阅读。而汇编语言,简单来说,是可读性更好的机器语言,基本上每条指令都可以直接翻译成一条机器码。 + +跟你日常使用的高级语言相比,汇编语言的语法特别简单,但它要跟硬件(CPU和内存)打交道,我们来体会一下。 + +计算机的处理器有很多不同的架构,比如x86-64、ARM、Power等,每种处理器的指令集都不相同,那也就意味着汇编语言不同。我们目前用的电脑,CPU一般是x86-64架构,是64位机。(如不做特别说明,本课程都是以x86-64架构作为例子的)。 + +说了半天,汇编代码长什么样子呢?我用C语言写的例子来生成一下汇编代码。 + +#include +int main(int argc, char* argv[]){ + printf("Hello %s!\n", "Richard"); + return 0; +} + + +在macOS中输入下面的命令,其中的-S参数就是告诉编译器把源代码编译成汇编代码,而-O2参数告诉编译器进行2级优化,这样生成的汇编代码会短一些: + +clang -S -O2 hello.c -o hello.s +或者: +gcc -S -O2 hello.c -o hello.s + + +生成的汇编代码是下面的样子: + + .section __TEXT,__text,regular,pure_instructions + .build_version macos, 10, 14 sdk_version 10, 14 + .globl _main ## -- Begin function main + .p2align 4, 0x90 +_main: ## @main + .cfi_startproc +## %bb.0: + pushq %rbp + .cfi_def_cfa_offset 16 + .cfi_offset %rbp, -16 + movq %rsp, %rbp + .cfi_def_cfa_register %rbp + leaq L_.str(%rip), %rdi + leaq L_.str.1(%rip), %rsi + xorl %eax, %eax + callq _printf + xorl %eax, %eax + popq %rbp + retq + .cfi_endproc + ## -- End function + .section __TEXT,__cstring,cstring_literals +L_.str: ## @.str + .asciz "Hello %s!\n" + +L_.str.1: ## @.str.1 + .asciz "Richard" + +.subsections_via_symbols + + +你如果再打下面的命令,就会把这段汇编代码编译成可执行文件(在macOS或Linux执行as命令,就是调用了GNU的汇编器): + +as hello.s -o hello.o //用汇编器编译成目标文件 +gcc hello.o -o hello //链接成可执行文件 +./hello //运行程序 + + +以上面的代码为例,来看一下汇编语言的组成元素。这是汇编语言入门的基础,也是重点内容,在阅读时,你不需要死记硬背,而是要灵活掌握,比如CPU的指令特别多,我们记住常用的就行了,不太常用的可以去查手册。 + +1.汇编语言的组成元素 + +这段代码里有指令、伪指令、标签和注释四种元素,每个元素单独占一行。 + +指令(instruction)是直接由CPU进行处理的命令,例如: + +pushq %rbp +movq %rsp, %rbp + + +其中,开头的一个单词是助记符(mnemonic),后面跟着的是操作数(operand),有多个操作数时以逗号分隔。第二行代码的意思是把数据从这里(源)拷贝到那里(目的),这跟“请倒杯咖啡给我”这样的自然语句是一样的,先是动词(倒),然后是动词的作用对象(咖啡),再就是目的地(给我)。 + +伪指令以“.”开头,末尾没有冒号“:”。 + +.section __TEXT,__text,regular,pure_instructions +.globl _main +.asciz "Hello %s!\n" + + +伪指令是是辅助性的,汇编器在生成目标文件时会用到这些信息,但伪指令不是真正的CPU指令,就是写给汇编器的。每种汇编器的伪指令也不同,要查阅相应的手册。 + +标签以冒号“:”结尾,用于对伪指令生成的数据或指令做标记。例如L_.str: 标签是对一个字符串做了标记。其他代码可以访问标签,例如跳转到这个标签所标记的指令。 + +L_.str: ## @.str + .asciz "Hello %s!\n" + + +标签很有用,它可以代表一段代码或者常量的地址(也就是在代码区或静态数据区中的位置)。可一开始,我们没法知道这个地址的具体值,必须生成目标文件后,才能算出来。所以,标签会简化汇编代码的编写。 + +第四种元素,注释,以“#”号开头,这跟C语言中以//表示注释语句是一样的。 + +因为指令是汇编代码的主要部分,所以我们再把与指令有关的知识点展开讲解一下。 + +2.详细了解指令这个元素 + +在代码中,助记符“movq”“xorl”中的“mov”和“xor”是指令,而“q”和“l”叫做后缀,表示操作数的位数。后缀一共有b, w, l, q四种,分别代表8位、16位、32位和64位。 + + + +比如,movq中的q代表操作数是8个字节,也就是64位的。movq就是把8字节从一个地方拷贝到另一个地方,而movl则是拷贝4个字节。 + +而在指令中使用操作数,可以使用四种格式,它们分别是:立即数、寄存器、直接内存访问和间接内存访问。 + +立即数以$开头, 比如$40。(下面这行代码是把40这个数字拷贝到%eax寄存器)。 + +movl $40, %eax + + +除此之外,我们在指令中最常见到的就是对寄存器的访问,GNU的汇编器规定寄存器一定要以%开头。 + +直接内存访问:当我们在代码中看到操作数是一个数字时,它其实指的是内存地址。不要误以为它是一个数字,因为数字立即数必须以$开头。另外,汇编代码里的标签,也会被翻译成直接内存访问的地址。比如“callq _printf”中的“_printf”是一个函数入口的地址。汇编器帮我们计算出程序装载在内存时,每个字面量和过程的地址。 + +间接内存访问:带有括号,比如(%rbp),它是指%rbp寄存器的值所指向的地址。 + +间接内存访问的完整形式是: + + +偏移量(基址,索引值,字节数)这样的格式。 + + +其地址是: + + +基址 + 索引值*字节数 + 偏移量 + + +举例来说: + + +8(%rbp),是比%rbp寄存器的值加8。- +-8(%rbp),是比%rbp寄存器的值减8。- +(%rbp, %eax, 4)的值,等于%rbp + %eax*4。这个地址格式相当于访问C语言中的数组中的元素,数组元素是32位的整数,其索引值是%eax,而数组的起始位置是%rbp。其中字节数只能取1,2,4,8四个值。 + + +你现在应该对指令的格式有所了解了,接下来,我们再学几个常用的指令: + +mov指令 + +mov 寄存器|内存|立即数, 寄存器|内存 + + +这个指令最常用到,用于在寄存器或内存之间传递数据,或者把立即数加载到内存或寄存器。mov指令的第一个参数是源,可以是寄存器、内存或立即数。第二个参数是目的地,可以是寄存器或内存。 + +lea指令,lea是“load effective address”的意思,装载有效地址。 + +lea 源,目的 + + +比如前面例子代码中的leaq指令,是把字符串的地址加载到%rdi寄存器。 + +leaq L_.str(%rip), %rdi + + +add指令是做加法运算,它可以采取下面的格式: + +add 立即数, 寄存器 +add 寄存器, 寄存器 +add 内存, 寄存器 +add 立即数, 内存 +add 寄存器, 内存 + + +比如,典型的c=a+b这样一个算术运算可能是这样的: + +movl -4(%rbp), %eax #把%rbp-4的值拷贝到%eax +addl -8(%rbp), %eax #把%rbp-8地址的值加到%eax上 +movl %eax, -12(%rbp) #把%eax的值写到内存地址%rbp-12 + + +这三行代码,分别是操作a、b、c三个变量的地址。它们的地址分别比%rbp的值减4、减8、减12,因此a、b、c三个变量每个都是4个字节长,也就是32位,它们是紧挨着存放的,并且是从高地址向低地址延伸的,这是栈的特征。 + +除了add以外,其他算术运算的指令: + + + +与栈有关的操作: + + + +跳转类: + + + +过程调用: + + + +比较操作: + + + +以上我列举的指令,是你在编写汇编代码时,经常会用到的,比较重要,会满足你编写简单汇编程序的需求,所以你需要重点关注。 + +x86-64是复杂指令集的处理器,有非常多的指令,差不多有上千条,全部记住是比较难的。更好的办法,是记住主要的指令,其他指令在使用时去查Intel公司的手册,在这里我就不举例了。 + +x86-64架构的寄存器 + +在汇编代码中,我们经常要使用各种以%开头的寄存器的符号。初学者阅读这些代码时,通常会有一些疑问:有几个寄存器可以用?我可以随便用它们吗?使用不当会不会造成错误?等等。所以,有必要让你熟悉一下这些寄存器,了解它们的使用方法。 + +x86-64架构的CPU里有很多寄存器,我们在代码里最常用的是16个64位的通用寄存器,分别是: + + +%rax,%rbx,%rcx,%rdx,%rsi,%rdi,%rbp,%rsp, %r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。 + + +这些寄存器在历史上有各自的用途,比如,rax中的“a”,是Accumulator(累加器)的意思,这个寄存器是累加寄存器。 + +但随着技术的发展,这些寄存器基本上都成为了通用的寄存器,不限于某种特定的用途。但是,为了方便软件的编写,我们还是做了一些约定,给这些寄存器划分了用途。针对x86-64架构有多个调用约定(Calling Convention),包括微软的x64调用约定(Windows系统)、System V AMD64 ABI(Unix和Linux系统)等,下面的内容属于后者: + + +%rax 除了其他用途外,通常在函数返回的时候,把返回值放在这里。 + +%rsp 作为栈指针寄存器,指向栈顶。 + +%rdi,%rsi,%rdx,%rcx,%r8,%r9 给函数传整型参数,依次对应第1参数到第6参数。超过6个参数怎么办?放在栈桢里,我们21讲已经讲过了。 + +如果程序要使用%rbx,%rbp,%r12,%r13,%r14,%r15 这几个寄存器,是由被调用者(Callee)负责保护的,也就是写到栈里,在返回的时候要恢复这些寄存器中原来的内容。其他寄存器的内容,则是由调用者(Caller)负责保护,如果不想这些寄存器中的内容被破坏,那么要自己保护起来。 + + +上面这些寄存器的名字都是64位的名字,对于每个寄存器,我们还可以只使用它的一部分,并且另起一个名字。比如对于%rax,如果使用它的前32位,就叫做%eax,前16位叫%ax,前8位(0到7位)叫%al,8到15位叫%ah。 + + + +其他的寄存器也有这样的使用方式,当你在汇编代码中看到这些名称的时候,你就知道其实它们有可能在物理上是同一个寄存器。 + + + +除了通用寄存器以外,有可能的话,还要了解下面的寄存器和它们的用途,我们写汇编代码时也经常跟它们发生关联: + + +8个80位的x87寄存器,用于做浮点计算; + +8个64位的MMX寄存器,用于MMX指令(即多媒体指令),这8个跟x87寄存器在物理上是相同的寄存器。在传递浮点数参数的时候,要用mmx寄存器。 + +16个128位的SSE寄存器,用于SSE指令。我们将在应用篇里使用SSE指令,讲解SIMD的概念。 + +指令寄存器,rip,保存指令地址。CPU总是根据这个寄存器来读取指令。 + +flags(64位:rflags, 32位:eflags)寄存器:每个位用来标识一个状态。比如,它们会用于比较和跳转的指令,比如if语句翻译成的汇编代码,就会用它们来保存if条件的计算结果。 + + +总的来说,我们的汇编代码处处要跟寄存器打交道,正确和高效使用寄存器,是编译期后端的重要任务之一。 + +课程小结 + +本节课,我讲解了汇编语言的一些基础知识,由于汇编语言的特点,涉及的知识点和细节比较多,在这个过程中,你无需死记硬背,只需要掌握几个重点内容: + +1.汇编语言是由指令、标签、伪指令和注释构成的。其中主要内容都是指令。指令包含一个该指令的助记符和操作数。操作数可以使用直接数、寄存器,以及用两种方式访问内存地址。 + +2.汇编指令中会用到一些通用寄存器。这些寄存器除了用于计算以外,还可以根据调用约定帮助传递参数和返回值。使用寄存器时,要区分由调用者还是被调用者负责保护寄存器中原来的内容。 + +另外,我们还要注意按照一定的规则维护和使用栈桢,这个知识点会在后面的加餐中展开来讲一个例子。 + +鉴于你可能是第一次使用汇编语言,所以我提供两个建议,让你快速上手汇编语言: + +1.你可以用C语言写一些示例代码,然后用编译器生成汇编代码,看看能否看懂。 + +2.模仿文稿中的例子,自己改写并运行你自己的汇编程序,这个过程中,你会发现真的没那么难。 + +一课一思 + +你之前学习过或者在项目中使用过汇编语言吗?感受是什么呢?有什么经验和体会呢?欢迎在留言区分享你的经验与感受。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/23生成汇编代码(二):把脚本编译成可执行文件.md b/专栏/编译原理之美/23生成汇编代码(二):把脚本编译成可执行文件.md new file mode 100644 index 0000000..56a7995 --- /dev/null +++ b/专栏/编译原理之美/23生成汇编代码(二):把脚本编译成可执行文件.md @@ -0,0 +1,433 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 23 生成汇编代码(二):把脚本编译成可执行文件 + 学完两节课之后,对于后端编译过程,你可能还会产生一些疑问,比如: + +1.大致知道汇编程序怎么写,却不知道如何从AST生成汇编代码,中间有什么挑战。 + +2.编译成汇编代码之后需要做什么,才能生成可执行文件。 + +本节课,我会带你真正动手,基于AST把playscript翻译成正确的汇编代码,并将汇编代码编译成可执行程序。 + +通过这样一个过程,可以实现从编译器前端到后端的完整贯通,帮你对编译器后端工作建立比较清晰的认识。这样一来,你在日常工作中进行大型项目的编译管理的时候,或者需要重用别人的类库的时候,思路会更加清晰。 + +从playscript生成汇编代码 + +先来看看如何从playscript生成汇编代码。 + +我会带你把playscript的几个有代表性的功能,而不是全部的功能翻译成汇编代码,一来工作量少一些,二来方便做代码优化。这几个有代表性的功能如下: + +1.支持函数调用和传参(这个功能可以回顾加餐)。 + +2.支持整数的加法运算(在这个过程中要充分利用寄存器提高性能)。 + +3.支持变量声明和初始化。 + +具体来说,要能够把下面的示例程序正确生成汇编代码: + +//asm.play +int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){ + int c = 10; + return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c; +} + +println("fun1:" + fun1(1,2,3,4,5,6,7,8)); + + +在加餐中,我提供了一段手写的汇编代码,功能等价于这段playscript代码,并讲述了如何在多于6个参数的情况下传参,观察栈帧的变化过程,你可以看看下面的图片和代码,回忆一下: + + + +# function-call2-craft.s 函数调用和参数传递 + # 文本段,纯代码 + .section __TEXT,__text,regular,pure_instructions + +_fun1: + # 函数调用的序曲,设置栈指针 + pushq %rbp # 把调用者的栈帧底部地址保存起来 + movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部 + + movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp) + + # 做加法 + movl %edi, %eax # 第一个参数放进%eax + addl %esi, %eax # 加参数2 + addl %edx, %eax # 加参数3 + addl %ecx, %eax # 加参数4 + addl %r8d, %eax # 加参数5 + addl %r9d, %eax # 加参数6 + addl 16(%rbp), %eax # 加参数7 + addl 24(%rbp), %eax # 加参数8 + + addl -4(%rbp), %eax # 加上c的值 + + # 函数调用的尾声,恢复栈指针为原来的值 + popq %rbp # 恢复调用者栈帧的底部数值 + retq # 返回 + + .globl _main # .global伪指令让_main函数外部可见 +_main: ## @main + + # 函数调用的序曲,设置栈指针 + pushq %rbp # 把调用者的栈帧底部地址保存起来 + movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部 + + subq $16, %rsp # 这里是为了让栈帧16字节对齐,实际使用可以更少 + + # 设置参数 + movl $1, %edi # 参数1 + movl $2, %esi # 参数2 + movl $3, %edx # 参数3 + movl $4, %ecx # 参数4 + movl $5, %r8d # 参数5 + movl $6, %r9d # 参数6 + movl $7, (%rsp) # 参数7 + movl $8, 8(%rsp) # 参数8 + + callq _fun1 # 调用函数 + + # 为pritf设置参数 + leaq L_.str(%rip), %rdi # 第一个参数是字符串的地址 + movl %eax, %esi # 第二个参数是前一个参数的返回值 + + callq _printf # 调用函数 + + # 设置返回值。这句也常用 xorl %esi, %esi 这样的指令,都是置为零 + movl $0, %eax + + addq $16, %rsp # 缩小栈 + + # 函数调用的尾声,恢复栈指针为原来的值 + popq %rbp # 恢复调用者栈帧的底部数值 + retq # 返回 + + # 文本段,保存字符串字面量 + .section __TEXT,__cstring,cstring_literals +L_.str: ## @.str + .asciz "fun1 :%d \n" + + +接下来,我们动手写程序,从AST翻译成汇编代码(相关代码在playscript-java项目的AsmGen.java类里)。 + +我们实现加法运算的翻译过程如下: + +case PlayScriptParser.ADD: + //为加法运算申请一个临时的存储位置,可以是寄存器和栈 + address = allocForExpression(ctx); + bodyAsm.append("\tmovl\t").append(left).append(", ").append(address).append("\n"); //把左边节点拷贝到存储空间 + bodyAsm.append("\taddl\t").append(right).append(", ").append(address).append("\n"); //把右边节点加上去 + break; + + +这段代码的含义是:我们通过allocForExpression()方法,为每次加法运算申请一个临时空间(可以是寄存器,也可以是栈里的一个地址),用来存放加法操作的结果。接着,用mov指令把加号左边的值拷贝到这个临时空间,再用add指令加上右边的值。 + +生成汇编代码的过程,基本上就是基于AST拼接字符串,其中bodyAsm变量是一个StringBuffer对象,我们可以用StringBuffer的toString()方法获得最后的汇编代码。 + +按照上面的逻辑,针对“x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c”这个表达式,形成的汇编代码如下: + + # 过程体 + movl $10, -4(%rbp) + movl %edi, %eax //x1 + addl %esi, %eax //+x2 + movl %eax, %ebx + addl %edx, %ebx //+x3 + movl %ebx, %r10d + addl %ecx, %r10d //+x4 + movl %r10d, %r11d + addl %r8d, %r11d //+x5 + movl %r11d, %r12d + addl %r9d, %r12d //+x6 + movl %r12d, %r13d + addl 16(%rbp), %r13d //+x7 + movl %r13d, %r14d + addl 24(%rbp), %r14d //+x8 + movl %r14d, %r15d + addl -4(%rbp), %r15d //+c,本地变量 + + +看出这个代码有什么问题了吗?我们每次执行加法运算的时候,都要占用一个新的寄存器。比如,x1+x2使用了%eax,再加x3时使用了%ebx,按照这样的速度,寄存器很快就用完了,使用效率显然不高。所以必须要做代码优化。 + +如果只是简单机械地翻译代码,相当于产生了大量的临时变量,每个临时变量都占用了空间: + +t1 := x1 + x2; +t2 := t1 + x3; +t3 := t2 + x4; +... + + +进行代码优化可以让不再使用的存储位置(t1,t2,t3…)能够复用,从而减少临时变量,也减少代码行数,优化后的申请临时存储空间的方法如下: + +//复用前序表达式的存储位置 +if (ctx.bop != null && ctx.expression().size() >= 2) { + ExpressionContext left = ctx.expression(0); + String leftAddress = tempVars.get(left); + if (leftAddress!= null){ + tempVars.put(ctx, leftAddress); //当前节点也跟这个地址关联起来 + return leftAddress; + } +} + + +这段代码的意思是:对于每次加法运算,都要申请一个寄存器,如果加号左边的节点已经在某个寄存器中,那就直接复用这个寄存器,就不要用新的了。 + +调整以后,生成的汇编代码就跟手写的一样了。而且,我们至始至终只用了%eax一个寄存器,代码数量也减少了一半,优化效果明显: + +# 过程体 + movl $10, -4(%rbp) + movl %edi, %eax + addl %esi, %eax + addl %edx, %eax + addl %ecx, %eax + addl %r8d, %eax + addl %r9d, %eax + addl 16(%rbp), %eax + addl 24(%rbp), %eax + addl -4(%rbp), %eax + + # 返回值 + # 返回值在之前的计算中,已经存入%eax + + +对代码如何使用寄存器进行充分优化,是编译器后端一项必须要做的工作。这里只用了很粗糙的方法,不具备实用价值,后面可以学习更好的优化算法。 + +弄清楚了加法运算的代码翻译逻辑,我们再看看AsmGen.java中的generate()方法和generateProcedure()方法,看看汇编代码完整的生成逻辑是怎样的。这样可以帮助你弄清楚整体脉络和所有的细节,比如函数的标签是怎么生成的,序曲和尾声是怎么加上去的,本地变量的地址是如何计算的,等等。 + +public String generate() { + StringBuffer sb = new StringBuffer(); + + // 1.代码段的头 + sb.append("\t.section __TEXT,__text,regular,pure_instructions\n"); + + // 2.生成函数的代码 + for (Type type : at.types) { + if (type instanceof Function) { + Function function = (Function) type; + FunctionDeclarationContext fdc = (FunctionDeclarationContext) function.ctx; + visitFunctionDeclaration(fdc); // 遍历,代码生成到bodyAsm中了 + generateProcedure(function.name, sb); + } + } + + // 3.对主程序生成_main函数 + visitProg((ProgContext) at.ast); + generateProcedure("main", sb); + + // 4.文本字面量 + sb.append("\n# 字符串字面量\n"); + sb.append("\t.section __TEXT,__cstring,cstring_literals\n"); + for(int i = 0; i< stringLiterals.size(); i++){ + sb.append("L.str." + i + ":\n"); + sb.append("\t.asciz\t\"").append(stringLiterals.get(i)).append("\"\n"); + } + + // 5.重置全局的一些临时变量 + stringLiterals.clear(); + + return sb.toString(); +} + + +generate()方法是整个翻译程序的入口,它做了几项工作: + +1.生成一个.section伪指令,表明这是一个放文本的代码段。 + +2.遍历AST中的所有函数,调用generateProcedure()方法为每个函数生成一段汇编代码,再接着生成一个主程序的入口。 + +3.在一个新的section中,声明一些全局的常量(字面量)。整个程序的结构跟最后生成的汇编代码的结构是一致的,所以很容易看懂。 + +generateProcedure()方法把函数转换成汇编代码,里面的注释也很清晰,开头的工作包括: + +1.生成函数标签、序曲部分的代码、设置栈顶指针、保护寄存器原有的值等。 + +2.接着是函数体,比如本地变量初始化、做加法运算等。 + +3.最后是一系列收尾工作,包括恢复被保护的寄存器的值、恢复栈顶指针,以及尾声部分的代码。 + +我们之前已经理解了一个函数体中的汇编代码的结构,所以看这段翻译代码肯定不费事儿。 + +private void generateProcedure(String name, StringBuffer sb) { + // 1.函数标签 + sb.append("\n## 过程:").append(name).append("\n"); + sb.append("\t.globl _").append(name).append("\n"); + sb.append("_").append(name).append(":\n"); + + // 2.序曲 + sb.append("\n\t# 序曲\n"); + sb.append("\tpushq\t%rbp\n"); + sb.append("\tmovq\t%rsp, %rbp\n"); + + // 3.设置栈顶 + // 16字节对齐 + if ((rspOffset % 16) != 0) { + rspOffset = (rspOffset / 16 + 1) * 16; + } + sb.append("\n\t# 设置栈顶\n"); + sb.append("\tsubq\t$").append(rspOffset).append(", %rsp\n"); + + // 4.保存用到的寄存器的值 + saveRegisters(); + + // 5.函数体 + sb.append("\n\t# 过程体\n"); + sb.append(bodyAsm); + + // 6.恢复受保护的寄存器的值 + restoreRegisters(); + + // 7.恢复栈顶 + sb.append("\n\t# 恢复栈顶\n"); + sb.append("\taddq\t$").append(rspOffset).append(", %rsp\n"); + + // 8.如果是main函数,设置返回值为0 + if (name.equals("main")) { + sb.append("\n\t# 返回值\n"); + sb.append("\txorl\t%eax, %eax\n"); + } + + // 9.尾声 + sb.append("\n\t# 尾声\n"); + sb.append("\tpopq\t%rbp\n"); + sb.append("\tretq\n"); + + // 10.重置临时变量 + rspOffset = 0; + localVars.clear(); + tempVars.clear(); + bodyAsm = new StringBuffer(); +} + + +最后,你可以通过-S参数运行playscript-java,将asm.play文件生成汇编代码文件asm.s,再生成和运行可执行文件: + +java play.PlayScript -S asm.play -o asm.s //生成汇编代码 +gcc asm.s -o asm //生成可执行文件 +./asm //运行可执行文件 + + +另外,我们的翻译程序只实现了少量的特性(加法运算、本地变量、函数……)。我建议基于这个代码框架做修改,增加其他特性,比如减法、乘法和除法,支持浮点数,支持if语句和循环语句等。学过加餐之后,你应该清楚如何生成这样的汇编代码了。 + +到目前为止,我们已经成功地编译playscript程序,并生成了可执行文件!为了加深你对生成可执行文件的理解,我们再做个挑战,用playscript生成目标文件,让C语言来调用。这样可以证明playscript生成汇编代码的逻辑是靠谱的,以至于可以用playscript代替C语言来写一个共用模块。 + +通过C语言调用playscript模块 + +我们在编程的时候,经常调用一些公共的库实现一些功能,这些库可能是别的语言写的,但我们仍然可以调用。我们也可以实现playscript与其他语言的功能共享,在示例程序中实现很简单,微调一下生成的汇编代码,使用“.global _fun1”伪指令让_fun1过程变成全局的,这样其他语言写的程序就可以调用这个_fun1过程,实现功能的重用。 + +# convention-fun1.s 测试调用约定,_fun1将在外部被调用 + # 文本段,纯代码 + .section __TEXT,__text,regular,pure_instructions + + .globl _fun1 # .global伪指令让_fun1函数外部可见 +_fun1: + # 函数调用的序曲,设置栈指针 + pushq %rbp # 把调用者的栈帧底部地址保存起来 + movq %rsp, %rbp # 把调用者的栈帧顶部地址,设置为本栈帧的底部 + + movl $10, -4(%rbp) # 变量c赋值为10,也可以写成 movl $10, (%rsp) + + # 做加法 + movl %edi, %eax # 第一个参数放进%eax + addl %esi, %eax # 加参数2 + addl %edx, %eax # 加参数3 + addl %ecx, %eax # 加参数4 + addl %r8d, %eax # 加参数5 + addl %r9d, %eax # 加参数6 + addl 16(%rbp), %eax # 加参数7 + addl 24(%rbp), %eax # 加参数8 + + addl -4(%rbp), %eax # 加上c的值 + + # 函数调用的尾声,恢复栈指针为原来的值 + popq %rbp # 恢复调用者栈帧的底部数值 + retq # 返回 + + +接下来再写一个C语言的函数来调用fun1(),其中的extern关键字,说明有一个fun1()函数是在另一个模块里实现的: + +/** + * convention-main.c 测试调用约定。调用一个外部函数fun1 + */ +#include + +//声明一个外部函数,在链接时会在其他模块中找到 +extern int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8); + +int main(int argc, char *argv[]) +{ + printf("fun1: %d \n", fun1(1,2,3,4,5,6,7,8)); + return 0; +} + + +然后在命令行敲下面两个命令: + +# 编译汇编程序 +as convention-fun1.s -o convention-fun1.o + +# 编译C程序 +gcc convention-main.c convention-fun1.o -o convention + + + +第一个命令,把playscript生成的汇编代码编译成一个二进制目标文件。 + +第二个命令在编译C程序的时候,同时也带上这个二进制文件,那么编译器就会找到fun1()函数的定义,并链接到一起。- +最后生成的可执行文件能够顺利运行。 + + +这里面,我需要解释一下链接过程,它有助于你在二进制文件层面上加深对编译过程的理解。 + +其实,高级语言和汇编语言都容易阅读。而二进制文件,则是对计算机友好的,便于运行。汇编器可以把每一个汇编文件都编译生成一个二进制的目标文件,或者叫做一个模块。而链接器则把这些模块组装成一个整体。 + +但在C语言生成的那个模块中,调用fun1()函数时,它没有办法知道fun1()函数的准确地址,因为这个地址必须是整个文件都组装完毕以后才能计算出来。所以,汇编器把这个任务推迟,交给链接器去解决。 + + + +这就好比你去饭店排队吃饭,首先要拿个号(函数的标签),但不知道具体坐哪桌。等叫到你的号的时候(链接过程),服务员才会给你安排一个确定的桌子(函数的地址)。 + +既然我们已经从文本世界进入了二进制的世界,那么我们可以再加深一下对可执行文件结构的理解。 + +理解可执行文件 + +我们编译一个程序,最后的结果是生成可运行的二进制文件。其实,生成汇编代码以后,我们就可以认为编译器的任务完成了。后面的工作,其实是由汇编器和链接器完成的。但我们也可以把整个过程都看做编译过程,了解二进制文件的结构,也为我们完整地了解整个编译过程划上了句号。 + +当然了,对二进制文件格式的理解,也是做大型项目编译管理、二进制代码分析等工作的基础,很有意义。 + +对于每个操作系统,我们对于可执行程序的格式要求是不一样的。比如,在Linux下,目标文件、共享对象文件、二进制文件,都是采用ELF格式。 + +实际上,这些二进制文件的格式跟加载到内存中的程序的格式是很相似的。这样有什么好处呢?它可以迅速被操作系统读取,并加载到内存中去,加载速度越快,也就相当于程序的启动速度越快。 + +同内存中的布局一样,在ELF格式中,代码和数据也是分开的。这样做的好处是,程序的代码部分,可以在多个进程中共享,不需要在内存里放多份。放一份,然后映射到每个进程的代码区就行了。而数据部分,则是每个进程都不一样的,所以要为每个进程加载一份。 + +这样讲的话,你就理解了可执行文件、目标文件等二进制文件的原理了,具体的细节,可以查阅相关的文档和手册。 + +课程小结 + +这节课,我们实现了从AST到汇编代码,汇编代码到可执行文件的完整过程。现在,你应该对后端工作的实质建立起了直接的认识。我建议你抓住几个关键点: + +首先,从AST生成汇编代码,可以通过比较机械的翻译来完成,我们举了加法运算的例子。阅读示例程序,你也可以看看函数调用、参数传递等等的实现过程。总体来说,这个过程并不难。 + +第二,这种机械地翻译生成的代码,一定是不够优化的。我们已经看到了加法运算不够优化的情况,所以一定要增加一个优化的过程。 + +第三,在生成汇编的过程中,最需要注意的就是要遵守调用约定。这就需要了解调用约定的很多细节。只要遵守调用约定,不同语言生成的二进制目标文件也可以链接在一起,形成最后的可执行文件。 + +现在我已经带你完成了编译器后端的第一轮认知迭代,并且直接操刀汇编代码,破除你对汇编的恐惧心。在之后的课程中,我们会进入第二轮迭代:中间代码和代码优化。 + +一课一思 + +我们针对加法计算、函数调用等语法生成了汇编代码。你能否思考一下,如果要支持其他运算和语法,比如乘法运算、条件判断、循环语句等,大概会怎样实现?如果要支持面向对象编程,又该怎样实现呢?欢迎你打开思路,在留言区分享自己的想法。 + +最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。 + +示例代码我放在文末,供你参考。 + + +AsmGen.java(将AST翻译成汇编代码) 码云 GitHub +asm.play(用于生成汇编码的playscript脚本) 码云 GitHub + + + + + \ No newline at end of file diff --git a/专栏/编译原理之美/24中间代码:兼容不同的语言和硬件.md b/专栏/编译原理之美/24中间代码:兼容不同的语言和硬件.md new file mode 100644 index 0000000..c65b625 --- /dev/null +++ b/专栏/编译原理之美/24中间代码:兼容不同的语言和硬件.md @@ -0,0 +1,392 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 中间代码:兼容不同的语言和硬件 + 前几节课,我带你尝试不通过IR,直接生成汇编代码,这是为了帮你快速破冰,建立直觉。在这个过程中,你也遇到了一些挑战,比如: + + +你要对生成的代码进行优化,才有可能更好地使用寄存器和内存,同时也能减少代码量; + +另外,针对不同的CPU和操作系统,你需要调整生成汇编代码的逻辑。 + + +这些实际体验,都进一步验证了20讲中,IR的作用:我们能基于IR对接不同语言的前端,也能对接不同的硬件架构,还能做很多的优化。 + +既然IR有这些作用,那你可能会问,IR都是什么样子的呢?有什么特点?如何生成IR呢? + +本节课,我就带你了解IR的特点,认识常见的三地址代码,学会如何把高级语言的代码翻译成IR。然后,我还会特别介绍LLVM的IR,以便后面使用LLVM这个工具。 + +首先,来看看IR的特征。 + +介于中间的语言 + +IR的意思是中间表达方式,它在高级语言和汇编语言的中间,这意味着,它的特征也是处于二者之间的。 + +与高级语言相比,IR丢弃了大部分高级语言的语法特征和语义特征,比如循环语句、if语句、作用域、面向对象等等,它更像高层次的汇编语言;而相比真正的汇编语言,它又不会有那么多琐碎的、与具体硬件相关的细节。 + +相信你在学习汇编语言的时候,会发现汇编语言的细节特别多。比如,你要知道很多指令的名字和用法,还要记住很多不同的寄存器。在22讲,我提到,如果你想完整地掌握x86-64架构,还需要接触很多指令集,以及调用约定的细节、内存使用的细节等等(参见Intel的手册)。 + +仅仅拿指令的数量来说,据有人统计,Intel指令的助记符有981个之多!都记住怎么可能啊。所以说,汇编语言并不难,而是麻烦。 + +IR不会像x86-64汇编语言那么繁琐,但它却包含了足够的细节信息,能方便我们实现优化算法,以及生成针对目标机器的汇编代码。 + +另外,我在20讲提到,IR有很多种类(AST也是一种IR),每种IR都有不同的特点和用途,有的编译器,甚至要用到几种不同的IR。 + +我们在后端部分所讲的IR,目的是方便执行各种优化算法,并有利于生成汇编。这种IR,可以看做是一种高层次的汇编语言,主要体现在: + + +它可以使用寄存器,但寄存器的数量没有限制; +控制结构也跟汇编语言比较像,比如有跳转语句,分成多个程序块,用标签来标识程序块等; +使用相当于汇编指令的操作码。这些操作码可以一对一地翻译成汇编代码,但有时一个操作码会对应多个汇编指令。 + + +下面来看看一个典型IR:三地址代码,简称TAC。 + +认识典型的IR:三地址代码(TAC) + +下面是一种常见的IR的格式,它叫做三地址代码(Three Address Code, TAC),它的优点是很简洁,所以适合用来讨论算法: + +x := y op z //二元操作 +x := op y //一元操作 + + +每条三地址代码最多有三个地址,其中两个是源地址(比如第一行代码的y和z),一个是目的地址(也就是x),每条代码最多有一个操作(op)。 + +我来举几个例子,带你熟悉一下三地址代码,这样,你能掌握三地址代码的特点,从高级语言的代码转换生成三地址代码。 + +1.基本的算术运算: + +int a, b, c, d; +a = b + c * d; + + +TAC: + +t1 := c * d +a := b + t1 + + +t1是新产生的临时变量。当源代码的表达式中包含一个以上的操作符时,就需要引入临时变量,并把原来的一条代码拆成多条代码。 + +2.布尔值的计算: + +int a, b; +bool x, y; +x = a * 2 < b; +y = a + 3 == b; + + +TAC: + +t1 := a * 2; +x := t1 < b; +t2 := a + 3; +y := t2 == b; + + +布尔值实际上是用整数表示的,0代表false,非0值代表true。 + +3.条件语句: + +int a, b c; +if (a < b ) + c = b; +else + c = a; +c = c * 2; + + +TAC: + + t1 := a < b; + IfZ t1 Goto L1; + c := a; + Goto L2; +L1: + c := b; +L2: + c := c * 2; + + +IfZ是检查后面的操作数是否是0,“Z”就是“Zero”的意思。这里使用了标签和Goto语句来进行指令的跳转(Goto相当于x86-64的汇编指令jmp)。 + +4.循环语句: + +int a, b; +while (a < b){ + a = a + 1; +} +a = a + b; + + +TAC: + +L1: + t1 := a < b; + IfZ t1 Goto L2; + a := a + 1; + Goto L1; +L2: + a := a + b; + + +三地址代码的规则相当简单,我们可以通过比较简单的转换规则,就能从AST生成TAC。 + +在课程中,三地址代码主要用来描述优化算法,因为它比较简洁易读,操作(指令)的类型很少,书写方式也符合我们的日常习惯。不过,我并不用它来生成汇编代码,因为它含有的细节信息还是比较少,比如,整数是16位的、32位的还是64位的?目标机器的架构和操作系统是什么?生成二进制文件的布局是怎样的等等? + +我会用LLVM的IR来承担生成汇编的任务,因为它有能力描述与目标机器(CPU、操作系统)相关的更加具体的信息,准确地生成目标代码,从而真正能够用于生产环境。 + +在讲这个问题之前,我想先延伸一下,讲讲另外几种IR的格式,主要想帮你开拓思维,如果你的项目需求,恰好能用这种IR实现,到时不妨拿来用一下: + + +首先是四元式。它是与三地址代码等价的另一种表达方式,格式是:(OP,arg1,arg2,result)所以,“a := b + c” 就等价于(+,b,c,a)。 + +另一种常用的格式是逆波兰表达式。它把操作符放到后面,所以也叫做后缀表达式。“b + c”对应的逆波兰表达式是“b c +”;而“a = b + c”对应的逆波兰表达式是“a b c + =”。 + + +逆波兰表达式特别适合用栈来做计算。比如计算“b c +”,先从栈里弹出加号,知道要做加法操作,然后从栈里弹出两个操作数,执行加法运算即可。这个计算过程,跟深度优先的遍历AST是等价的。所以,采用逆波兰表达式,有可能让你用一个很简单的方式就实现公式计算功能,如果你编写带有公式功能的软件时可以考虑使用它。而且,从AST生成逆波兰表达式也非常容易。 + +三地址代码主要是学习算法的工具,或者用于实现比较简单的后端,要实现工业级的后端,充分发挥硬件的性能,你还要学习LLVM的IR。 + +认识LLVM汇编码 + +LLVM汇编码(LLVM Assembly),是LLVM的IR。有的时候,我们就简单地称呼它为LLVM语言,因此我们可以把用LLVM汇编码书写的一个程序文件叫做LLVM程序。 + +我会在下一讲,详细讲解LLVM这个开源项目。本节课作为铺垫,告诉我们在使用LLVM之前,要先了解它的核心——IR。 + +首先,LLVM汇编码是采用静态单赋值代码形式的。 + +在三地址代码上再加一些限制,就能得到另一种重要的代码,即静态单赋值代码(Static Single Assignment, SSA),在静态单赋值代码中,一个变量只能被赋值一次,来看个例子。 + +“y = x1 + x2 + x3 + x4”的普通三地址代码如下: + +y := x1 + x2; +y := y + x3; +y := y + x4; + + +其中,y被赋值了三次,如果写成SSA的形式,就只能写成下面的样子: + +t1 := x1 + x2; +t2 := t1 + x3; +y := t2 + x4; + + +为什么要费力写成这种形式呢,还要为此多添加t1和t2两个临时变量?原因是SSA的形式,体现了精确的“使用-定义”关系。 + +每个变量很确定地只会被定义一次,然后可以多次使用。这种特点使得基于SSA更容易做数据流分析,而数据流分析又是很多代码优化技术的基础,所以,几乎所有语言的编译器、解释器或虚拟机中都使用了SSA,因为有利于做代码优化。而LLVM的IR,也是采用SSA的形式,也是因为SSA方便做代码优化。 + +其次,LLVM IR比起三地址代码,有更多的细节信息。比如整型变量的字长、内存对齐方式等等,所以使用LLVM IR能够更准确地翻译成汇编码。 + +看看下面这段C语言代码: + +int fun1(int a, int b){ + int c = 10; + return a + b + c; +} + + +对应的LLLM汇编码如下(这是我在macOS上生成的): + +; ModuleID = 'fun1.c' +source_filename = "fun1.c" +target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128" +target triple = "x86_64-apple-macosx10.14.0" +; Function Attrs: noinline nounwind optnone ssp uwtable +define i32 @fun1(i32, i32) #0 { + %3 = alloca i32, align 4 //为3个变量申请空间 + %4 = alloca i32, align 4 + %5 = alloca i32, align 4 + store i32 %0, i32* %3, align 4 //参数1赋值给变量1 + store i32 %1, i32* %4, align 4 //参数2赋值给变量2 + store i32 10, i32* %5, align 4 //常量10赋值给变量3 + %6 = load i32, i32* %3, align 4 // + %7 = load i32, i32* %4, align 4 + %8 = add nsw i32 %6, %7 + %9 = load i32, i32* %5, align 4 + %10 = add nsw i32 %8, %9 + ret i32 %10 +} +attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" } + +!llvm.module.flags = !{!0, !1, !2} +!llvm.ident = !{!3} + +!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 10, i32 14]} +!1 = !{i32 1, !"wchar_size", i32 4} +!2 = !{i32 7, !"PIC Level", i32 2} +!3 = !{!"Apple LLVM version 10.0.1 (clang-1001.0.46.4)"} + + +这些代码看上去确实比三地址代码复杂,但还是比汇编精简多了,比如LLVM IR的指令数量连x86-64汇编的十分之一都不到。 + +我们来熟悉一下里面的元素: + + +模块 + + +LLVM程序是由模块构成的,这个文件就是一个模块。模块里可以包括函数、全局变量和符号表中的条目。链接的时候,会把各个模块拼接到一起,形成可执行文件或库文件。 + +在模块中,你可以定义目标数据布局(target datalayout)。例如,开头的小写“e”是低字节序(Little Endian)的意思,对于超过一个字节的数据来说,低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。 + +target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128" + + +“target triple”用来定义模块的目标主机,它包括架构、厂商、操作系统三个部分。 + +target triple = "x86_64-apple-macosx10.14.0" + + + +函数 + + +在示例代码中有一个以define开头的函数的声明,还带着花括号。这有点儿像C语言的写法,比汇编用采取标签来表示一个函数的可读性更好。 + +函数声明时可以带很多修饰成分,比如链接类型、调用约定等。如果不写,缺省的链接类型是external的,也就是可以像23讲中做链接练习的那样,暴露出来被其他模块链接。调用约定也有很多种选择,缺省是“ccc”,也就是C语言的调用约定(C Calling Convention),而“swiftcc”则是swift语言的调用约定。这些信息都是生成汇编时所需要的。 + +示例中函数fun1还带有“#0”的属性值,定义了许多属性。这些也是生成汇编时所需要的。 + + +标识符 + + +分为全局的(Glocal)和本地的(Local):全局标识符以@开头,包括函数和全局变量,前面代码中的@fun1就是;本地标识符以%开头。 + +有的标识符是有名字的,比如@fun1或%a,有的是没有名字的,用数字表示就可以了,如%1。 + + +操作码 + + +alloca、store、load、add、ret这些,都是操作码。它们的含义是: + + + +它们跟我们之前学到的汇编很相似。但是似乎函数体中的代码有点儿长。怎么一个简单的“a+b+c”就翻译成了10多行代码,还用到了那么多临时变量?不要担心,这只是完全没经过优化的格式,带上优化参数稍加优化以后,它就会被精简成下面的样子: + +define i32 @fun1(i32, i32) local_unnamed_addr #0 { + %3 = add i32 %0, 10 + %4 = add i32 %3, %1 + ret i32 %4 +} + + + +类型系统 + + +汇编是无类型的。如果你用add指令,它就认为你操作的是整数。而用fadd(或addss)指令,就认为你操作的是浮点数。这样会有类型不安全的风险,把整型当浮点数用了,造成的后果是计算结果完全错误。 + +LLVM汇编则带有一个类型系统。它能避免不安全的数据操作,并且有助于优化算法。这个类型系统包括基础数据类型、函数类型和void类型。 + + + +函数类型是包括对返回值和参数的定义,比如:i32 (i32); + +void类型不代表任何值,也没有长度。 + + +全局变量和常量 + + +在LLVM汇编中可以声明全局变量。全局变量所定义的内存,是在编译时就分配好了的,而不是在运行时,例如下面这句定义了一个全局变量C: + +@c = global i32 100, align 4 + + +你也可以声明常量,它的值在运行时不会被修改: + +@c = constant i32 100, align 4 + + + +元数据 + + +在代码中你还看到以“!”开头的一些句子,这些是元数据。这些元数据定义了一些额外的信息,提供给优化器和代码生成器使用。 + + +基本块 + + +函数中的代码会分成一个个的基本块,可以用标签(Label)来标记一个基本块。下面这段代码有4个基本块,其中第一个块有一个缺省的名字“entry”,也就是作为入口的基本块,这个基本块你不给它标签也可以。 + +define i32 @bb(i32) #0 { + %2 = alloca i32, align 4 + %3 = alloca i32, align 4 + store i32 %0, i32* %3, align 4 + %4 = load i32, i32* %3, align 4 + %5 = icmp sgt i32 %4, 0 + br i1 %5, label %6, label %9 + +;