乘着春节假期闲来无事,准备挑战一下有点难度的事情,试着写一门脚本语言。没有任何依据,要想凭空造一门脚本语言,那是几乎不可能的事情,所以首先要解决的是参考资料的问题——龙🐲(Compilers: Principles,Techniques,and Tools)、虎🐯(Modern Compiler Implementation in C)、鲸🐳(Advanced Compiler Design and Implementation)三书是编译原理的三大经典,可是看看这几本书的个头以及有限的假期,看来只能等以后再来啃他们了。到亚马逊和京东上搜索“自制语言”,出来的结果全是图灵出的:《两周自制脚本语言》、《自制编程语言》、《自制编译器》,而且与设计脚本语言有关的中文书似乎只此一本——《两周自制脚本语言》
英文书里倒是也有一本2016年新出的关于自制脚本语言的书《Writing An Interpreter In Go》,粗看了一下觉得不错,也列为参考资料。
选好参考资料,以两周做为时间参考坐标,我的自制脚本之旅就开始启动了:
第一天:来,我们一起做些什么吧
翻开之后,发现第一天的内容好像少的让我有些吃惊,只有9页,真的要花一天才能读完吗😓❓ 当然内容虽少却是提纲挈领的一章,里面介绍了要设计一门脚本语言必备的基础概念:什么是机器语言、什么是汇编语言、解释器和编译器有什么区别等等……最重要的是介绍了我们要设计一门编程语言必不可少的结构—— 如下图所示:
首先是源代码,程序员每天都要打交道的东西。既然是“自制”,那么现在这个语言要靠自己设计了,这是语言设计师的天地,语法(风格)任由我来设定,比如:
令 年龄 = 1;
令 名字 = "我的姓名";
令 运算结果 = 10 乘以 (20除以2);
/*上面这三行看上去虽然和程序员们日常打交道的各种语言不那么一样,但是应该还是能看懂的吧😊*/
第二步,是词法分析(Lexing),我们需要实现一个词法分析器(被称为lexical analyzer 或 lexer 也有叫scanner的),把上面这些语句进一步拆分成特定的“符号”,比如把“令 年龄 = 1;” 拆分为“令”、“年龄”、“=”、“1”、“;”,这个拆分的结果被称为Token。
第三步,是语法分析(Parsing),我们需要实现一个语法分析器(即Parser),语法分析器的作用就是把前面一步词法分析(Lexing)获取的Token进一步的解析并生成抽象语法树(abstract syntax tree或者缩写为AST),这是每个编程语言(不论是解释型的还是编译型的)都少不了的部分。
第四步,则是脚本语言与编译型语言的分野处,通过Parser拿到抽象语法树(AST)后,如果你要通过编译器(Compiler)生成机器码后再运行,那么你搞的就是编译型语言,如果你是要通过解释器(Interpreter)直接执行,那么你搞的就是脚本语言。如果我既搞了编译器,又搞了解释器,那算什么呢 —— 其实这真的没有问题,只要你手上已经有了抽象语法树(AST),那你可以同时把两者都给实现了😄。
第一章虽然只有短短9页,但是把整本书的内容做了高度的概括,并且在第7-9页列出了按天(章)划分的学习计划,剧透一下,其实本书要完整看完并不是14天,最后还有5章(天)是用于自学的,按照作者建议的阅读顺序是:先读完第 2~8 章,之后是第 15、16 章,若还有时间,再读第 11 章和第 13 章。
第一步:设计语言风格
既然是自制脚本语言,那么我并不打算完整的跟着本书作者后面亦步亦趋,而是从一开始就确立了本次实战的语言风格:中文型——除了通用操作符(+、-、*、/、=、;等)之外,关键字都用中文😈:
变量赋值在上面已经举例了,下面就举一些例子:
流程控制语句:
若(年龄>18){
打印("年龄已经符合要求");
}否则{
打印("岁数还小");
}
循环语句1:
令 总数 = 0;
当(总数<1000){
打印(总数);
总数++;
}
循环语句2:
令 数组 = [1,2,3,4,5];
在 数组 遍历 i,v {
打印("第" + i + "个数组元素是:" + v);
}
第二步:设计词法分析器(Lexing)
在确定了语言风格之后,其实词法分析器就不是很困难了,说起来就是把每一行里哪些是关键字(比如“令”、“遍历”、“在”、“当”、“若”、“否则”)、哪些是变量(如“i”、“v”、“年龄”等)、哪些是操作符(如“+”、“-”、“*"、“/”、“=”)、哪些是分隔符(如“{”、“}”、“;”等)并且分门别类,这些被分析出来的元素统称为Token。
至于如何找出Token,方法其实并不限定,这取决于第一步你的语言风格是如何设计的,常见的分析方法:(1)用正则表达式分析,本书第三章用的是这个方法。
(2)正则表达式能够表述非常复杂的字符串模式匹配逻辑,但它并非万能,所以本书又在第十五章提供了一个“手工词法分析器”的方法,将正则表达式分析方法改写为自动机方法,具体流程见下图
第三步:设计语法分析器(Parsing)
我们在第二步获得Token之后,就要通过语法分析器(Parser)将Token解析为抽象语法树(abstract syntax tree,即AST),如果学过编译原理,那么你不会对AST感到陌生,如果不熟悉的话,应该好好阅读一下第4章:用于表示程序的对象。
在计算机科学中,抽象语法树,是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于if-condition-then这样的条件跳转语句,可以使用带有两个分支的节点来表示。
例如我们要用一个抽象语法树表示一个表达式: 13+x*2
会得到下面的🌲树状图:
了解完抽象语法树的概念后,我们将正式开始语法分析器的设计。在第二步,我们已经通过词法分析器(Lexer)分解为了单词序列。而语法分析器(Parser)的任务是将这些单词序列与语法规则定义的模式进行匹配,并构造出如上图那样的抽象语法树。
在语法设计的时候,你设计的表达式越复杂,则在语法分析的时候,面临的困难就越大,所以从语言使用者的角度来看,我还是喜欢设计简洁的语言。
其实对于搞出语法分析器还有另一种办法,那就是借助语法分析器的生成器(Parser Generator),例如yacc, bison 或者 ANTLR,这些都是比较完善的生成器,可以大大降低工作量以及减少bug。
当然本着我们要自己搞一套的理念😄,本书中仍然提供了手写语法分析器的过程以及代码。自己手写语法分析器的过程,当然是对语法分析的一次极有价值的实践,所以嘛,从玩票的角度,我们仍然应该坚持手写Parser💪🏽。
第四步:设计解释器并执行程序(Evaluation)
如第一天教程所介绍的,我们拿到抽象语法树(AST)之后,要编译还是解释就取决于我们的需求了,当然,有兴趣的话两者都可以实现,编译器的设计请参看图灵的《自制编译器》一书。
言归正传,从抽象语法树(AST)到通过求值(Evaluate)得到结果,其实有很多种策略,根据策略的不同,执行效率也会有较大的差别,最简单的策略就是本书中提到的遍历抽象语法树解释法(A Tree-Walking Interpreter),即从抽象语法树的根节点开始遍历该树直至叶节点,并计算各节点的内容。
我们的语法树的每个节点都需要具备相应的求值(eval)方法。eval方法将计算与以该节点为根的子树对应的语句、表达式及子表达式,并返回执行结果。因此,只要调用抽象语法树的根节点对象的eval方法,就能完整执行该语法树对应的程序。eval方法将递归调用该节点的子节点的eval方法,并根据它们的返回值计算自身的返回值,最后将结果返回给调用者。
说到关键之处,本书作者引入了一个其自己开发的“GlounJ”的系统来实现解释器。当然,我们也完全可以抛开“GlounJ”系统来完成自己的解释器。
总结:
本书到第6章,其实已经把一个初步的脚本语言设计完成,并且涉及到了开发一门脚本语言所需要的基础知识和概念,后面的7-10章则陆续介绍了如何引入函数、面向对象、数组的方法,进一步的丰富了脚本语言,使其具有图灵完备性。第11-14章则进一步讨论如何优化解释器的性能:优化变量读写性能、优化对象操作性能、设计中间代码解释器、添加静态类型等等。第15-19章则从另一种角度出发,以不同的方式实现了脚本语言的第2、3、4步,反映了解决问题的不同思路。
总的来说,如果你要是想设计并实现一门脚本语言,而又需要中文教材的话,本书是你目前的唯一选择🤖。至于缺点嘛,我觉得就是大段的代码边上没有注释,啃起来有点费劲,要是能有一个带注释的版本会更好!