疯狂极客前传:用最快的速度设计一种新的编程语言
最近打算写一些列有趣、而且有一定难度的文章。这个系列的名字就叫《疯狂极客》,这一系列的文章大多数与计算机有密切的关系。包括制作编译器、制作OS、Android控制电路板、机器人的制作(通过Android、IOS等设备控制)等等。
在正式开始《疯狂极客》系列文章之前,先来热热身。用最短的时间设计一种简单,但好玩的编程语言CShell(不过不用担心,实现CShell解析器基本上用不着编译原理的知识,但以后的文章就会涉及到很多编译原理的内容了)。从CShell的名称可以猜到,是一种C风格的语言,并且可以像Shell一样解释执行(动态语言)。当然,这种语言不可能像C语言或Shell一样强大,因为C语言的编译器实现起来尽管不复杂(因为是结构化编程语言,没有类、接口这些东西,实现起来要比Java编译器简单得多),但仍然不太可能在很短的时间内完成(一至两天)。不过本文实现的CShell尽管简单,但仍然可以实现一些算法。CShell语言支持输出值和变量、条件语句(if),for循环,自加、自减、+、-、*、/操作,函数(支持递归)。由于CShell是动态语言,所以不需要声明变量,不过支持全局和局部变量,当然,还支持数组(整数、字符串类型数组),所以使用CShell可以很容易实现像冒泡排序、阶乘等算法。
在讨论CShell的设计原理和实现过程之前,先看一些用CShell编写的程序。单从这些程序所完成的工作来看都太太太简单了,不过这回完全不同,这回是用我们自己发明的新语言来实现这些算法,例如,递归阶乘计算、冒泡排序,是不是很酷呢!!Let’s go!
// 简单的变量输出 xx= 45; _ok = 64; print (xx); a1 = 65; print (a1); // 数组演示 $arr = [1,2,3,4,5,"aa"]; // 数值与字符串换搭的数组,$表示全局变量 print($arr); // 输出数组的所有元素 print($arr[2]); // 输出数组的第3个元素 // 三重for循环 $x = 0; // 全局变量 // i、j、z都为局部变量,for循环外不可访问 for(i=0;i<10;i=i+1) { for(j = 0; j < 10; j=j+1) { for(z = 0; z < 10; z = z + 1) { $x = $x + 1; } } } print($x); // 输出1000 // 计算10的阶乘,涉及到函数的递归操作和if语句 def jc(n) { if(n == 0) { return 1; } else if(n == 1) { return 1; } else { return jc(n - 1) *n; } } print("10!"); print(jc(10)); // 计算10的阶乘(3628800) // 冒泡排序(降序) $arr = [5,3,1,7,5,4,-56,12]; $len = length($arr); // 双循环冒泡排序 for(i = 0; i < $len; i++) { for(j = 0; j < $len - 1; j++) { if($arr[j] < $arr[j+1]) { x = $arr[j+1]; $arr[j+1] = $arr[j]; $arr[j] = x; } } } print($arr); // 输出[12, 7, 5, 5, 4, 3, 1, -56]
如何设计和实现编程语言
设计一种编程语言的方法很多,当然,通常的做法是要学好编译原理,然后按部就班地从词法分析器做起,然后是词法分析器、语义分析、中间代码生成、中间代码优化,目标代码生成,如果语言需要使用runtime运行,还需要编写可以运行目标代码的虚拟机(解释目标代码的程序,例如jvm就是解析Java字节码文件的虚拟机)。看着就有点晕。而且估计现在很多科班出身的程序员编译原理学的一塌糊涂。就算编译原理学的很好,光凭编译原理的理论,如果要想编写一个比较复杂的编译器或解析器也是很难办到的(尤其是加入面向对象功能)。这是因为一个复杂的编译器有很多代码几乎不太可能完全通过手工编写,例如,语法分析如果使用LL(*)分析方式,计算大量的first和follow集合就非常恐怖,就算把代码编写完了,如果要为语言增加或修改新的语法,修改这些代码将又是一场恶梦。所以大多数复杂的工业级编程语言都是通过半自动化的方式完成的。
所谓半自动化,就是指不可能完全通过自动的方式生成编译器,而只能通过自动的方式生成编译器最核心的部分:词法分析器和语法分析器。基本的做法是通过DSL(domain-specific language )指定词法和语法的结构和必要的信息,然后编译器的编译器(生成编译器的程序)会根据DSL自动生成词法和语法解析器,当然,通过DSL可以增加语义部分的代码,这样生成的程序就直接拥有语义解析功能了。
对于很多世界级的企业,如google、微软、intel、IBM,都会有自己的CC(编译器的编译器),不过对于个人或小企业,完全开发一套CC难度会很大(这东西比开发一套编译器的难度更大)。所以我们可以使用开源免费的CC。例如JavaCC、lex、yacc、antlr等。其中JavaCC只支持Java语言,lex是词法分析器的生成器、yacc是语法分析器的生成器,这两个支持从C语言,而antlr支持多种语言,如Java、C#、ruby、C/C++、JavaScript等等。所以本文使用Antlr来设计和实现CShell语言。
CShell语言是如何练成的
尽管CShell依靠Antlr来实现,需要自己编写的代码仍然非常多,因此本文只介绍核心的代码和实现原理。更详细的代码请参考本文提供的源代码。
学过编译原理的读者首先就会想到,设计语言首先就是进行词法分析,然后根据词法分析的结果进行语法分析。幸运的是,这两样都可以利用Antlr自动完成。
所谓词法分析,就是将语言文本分成最小但愿的词素(称为Token)。例如,下面的是一段CShell语言的代码。
for(i = 0; i < $len; i++) { }
如果要对这段代码进行词法分析,就会分解成如下的一系列Token
“for”、“(”、“i”、“=”、“0”、“;”、“i”、“<”、“$len”、“;”、“i”、“++”、“{”、“}”
当然,要想自己编程实现这个分析,就需要使用到有限自动机(DFA)进行处理,尽管程序不复杂,但还是比较麻烦的。有了Antlr,就容易得多了。通常只要定义这些Token的规则即可。有些Token是与语法规则放到一起的,有些是单独的词法规则。例如,上面代码中有两个变量(i和$len),其中i局部变量、$len为全局变量,这两个变量都属于标识符范畴,所以可以定义一个专门识别标识符号的词法规则。
ID : '$'?(LETTER|'_') (LETTER | '0'..'9')* ;
其中ID是词法规则名称,词法规则名称的第一个字母必须大写。LETTER表示26个小写和26个大写字母。“?”表示可以有,也可能没有,“*”为星闭包,表示重复0次到N次。
LETTER: ('a'..'z' | 'A'..'Z')
从ID的词法规则可以看出,ID就是可能以“$”开头,也可能没有“$”。不管有没有“$”,下一个字符必须是字母或下划线,接下来的字母或者是字母、或者是数字的任意字符串。例如abc、_xyz123、$_23都认为是ID。Antlr会自动根据这个规则生成Java代码。
其他的Token分析也采用类似的方法,例如,识别字符串可以使用下面的规则。
STRING: '\"' .* '\"' ;
其中“.*”表示任意字符序列。也就是在CShell里一个字符串就是在两个双引号中的任意字符序列。
词法处理完,就是相应的语法了,词法的分析结果是Token序列,而这个序列正式语法分析的输入。也就是说语法分析和词法分析的方式很像,只是词法分析的输入是单个字符序列,输出是Token序列。而语法分析的输入是Token序列,输出可能有多种,也可能没有输出,在分析的过程中就执行相应的动作(语义处理),也可能生成AST(抽象语法树),然后进一步对其进行优化。本例使用的是AST方式,也就是说将CShell源代码经过语法分析后转换为一颗AST,目的是去掉一些杂质,例如,for循环中只有i、$len、++等标识符和运算符号是有用的,但左右括号就没有任何用处,这些辅助符号是为了区分for语句和其他语句的。
这里只看一个稍微简单的if语句的语法规则。
statement : 'if' '(' expr ')' slist elseif_statement_all else_statement?
其中slist是另外一个产生式,表示if和else if之间的部分。
slist // 原内容: ':' NL (statement)+ '.' NL
: NL*'{' NL* (statement)* NL* '}'NL* -> ^(BLOCK statement*)
;
其中NL表示空行。而^(BLOCK statement*)部分表示AST,其中BLOCK为AST的根节点,从这一点可以看出,AST已经将slist中的左右大括号都过滤出去了,只剩下有实际意义的statement。
从statement和slist的定义可以看出,if语句必须以“if”开头,Antlr会将if作为一个Token返回给语法分析器。然后紧跟着if的是左括号,接触是表达式(expr,另外一个产生式),然后就是if语句的执行体(slist),接着就是elseif部分,剩下的部分就与if部分的定义类似了,请读者参看源代码中的antlr/CShell.g文件。
那么编写完Antlr需要的DSL,接下来做什么呢?接下来就要自己来做语义部分,这部分内容非常复杂,基本的思想就是通过语法分析将变量、关键字(for、if等)返回,然后由语义部分决定如何做。例如,对于变量,通常做法是定义一个符号表(使用Map对象即可),变量名就是Map的key,先将该变量存储在Map对象中。如果遇到某个变量,会首先到Map对象中查找,如果未找到,就定义该变量(将变量和变量值存入Map对象),如果找到,就直接去除变量值使用。至于for、if语句如何处理,就要利用语法分析生成的AST了。
其中Interpreter类是分析的核心类,给类有一个exec方法,需要将AST的根节点传入该方法,也就是说执行CShell代码的过程就是遍历AST的过程,AST是多叉树,遍历需要使用广度优先方式遍历。exec方法的代码如下:
// CShellAST表示AST节点的类型,一个普通Java类 public Object exec(CShellAST ast) { try { switch (ast.getType()) { case CShellParser.BLOCK: // 处理块操作 block(ast); break; case CShellParser.ASSIGN: // 处理赋值操作 assign(ast); break; case CShellParser.LENGTH: // 处理返回长度操作 return length(ast); case CShellParser.ARRAY: // 处理数组操作 arrayStat(ast); break; case CShellParser.RETURN: ret(ast); break; case CShellParser.PRINT: print(ast); break; case CShellParser.IF: // 处理if语句 ifstat(ast); break; case CShellParser.FOR: forloop(ast); break; case CShellParser.CALL: return call(ast); case CShellParser.ADD: return add(ast); case CShellParser.PREV: case CShellParser.SUFFIX: return incAndDec(ast); case CShellParser.SUB: return op(ast); case CShellParser.MUL: case CShellParser.DIV: return op(ast); case CShellParser.EQ: return eq(ast); case CShellParser.LT: return lt(ast); case CShellParser.GT: return gt(ast); case CShellParser.INT: return Integer.parseInt(ast.getText()); case CShellParser.CHAR: return new Character(ast.getText().charAt(1)); case CShellParser.FLOAT: return Float.parseFloat(ast.getText()); case CShellParser.STRING: String s = ast.getText(); return s.substring(1, s.length() - 1); case CShellParser.ID: case CShellParser.ARRAY_ELEMENT: return load(ast); default: // catch unhandled node types throw new UnsupportedOperationException("无法处理" + ast.getText() + "<" + ast.getType() + ">"); } } catch (Exception e) { listener.error("异常原因: " + ast.toStringTree(), e); } return null; }
下面只看一个如何处理if语句的ifstat方法的实现代码
private void ifstat(CShellAST ast) { // 下面的代码需要从当前AST节点(表示if语句根节点)的子节点获取 // if语句的各个组成部分 // 获取if语句的两个圆括号直接的表达式部分 CShellAST expr = (CShellAST) ast.getChild(0); // 获取if条件如果为true要执行的代码块 CShellAST ifBlock = (CShellAST) ast.getChild(1); // 获取elseif的部分(包括条件表达式和要执行的块) CShellAST elseifAll = (CShellAST) ast.getChild(2); // 获取else部分要执行的代码块 CShellAST elseBlock = (CShellAST) ast.getChild(3); // 利用递归方式再次调用exec方法执行表达式,并返回值 Boolean c = (Boolean) exec(expr); // 如果为true,执行if block if (c.booleanValue()) { exec(ifBlock); // 递归执行if block } else { // 判断有多少个elseif部分,CShell支持有无限多个else if语句 if (elseifAll.getChildCount() > 0) { List<CShellAST> children = elseifAll.getChildren(); // 挨个判断else if后面的表达式是否为true for (CShellAST child : children) { expr = (CShellAST) child.getChild(0); ifBlock = (CShellAST) child.getChild(1); c = (Boolean) exec(expr); // 如果某个else if条件为true,直接执行else if后面的代码块, // 最后返回,剩下的都不执行了 if (c.booleanValue()) { exec(ifBlock); return; } } } // 最后会执行else语句(因为前面的条件都为false) // 判断是否有else语句(最多只能有1个else子句) if (elseBlock.getChildCount() == 1) { exec((CShellAST) elseBlock.getChild(0)); // 执行else block } } }
CShell代码分析器的入口类是CShell,在该类中调用了Interprefer.process方法读者CShell语言源代码。其中bubble.cs就是CShell语言的源代码文件,可以换成其他的源代码文件。调用process方法后,就会根据具体的CShell代码执行相应的操作。例如,print(…)语句会输出相应的字符串。
public class CShell { public static void main(String[] args) throws Exception { InputStream input = null; input = new FileInputStream("source/bubble.cs"); Interpreter interp = new Interpreter(); interp.process(input); } }
如果读者对Antlr还不太理解也没关系,本文只是抛砖引玉,目的并不是讲解Antlr。只是希望读者对Antlr以及设计一种语言的过程有所了解。在后面的一系列文章中将会深度探讨编译原理以及Antlr的使用方法。通过设计自己的专有语言最大的作用是可以显著提高工作效率,例如,可以将常用的工作抽象成某些语句,到时只要一执行脚本就可完成需要数小时,甚至数天才能完成的工作。