考虑这样一个问题,给定一个字符串,“1+1+(3+4)-2*3+8/2”,如何将它转化为如下形式,
“1+1=2”“3+4=7”“2+7=9”“2*3=6”“9-6=3”“8/2=4”“3+4=7”
换句话说,就是如何将字符串按照四则运算计算出来,如何写一个计算器。
拿java来举例,并且为了简单,我们只考虑个位数。这个过程大概分为这几个步骤,首先需要扫描字符串去除空白字符,其次将各个字符转换成对应的操作符或操作数,然后按照四则运算规则逐次计算并输出。
好,我们首先构造一个scanner,它主要功能是顺序扫描字符串,返回字符并跳过其中的空白字符,如下
public class Scanner { public Scanner(String source){ this.source = source.toCharArray(); } private char[] source; private int index = 0; private static char END = '\n'; public char getNext(){ char result; do{ if (index >= source.length){ return END; } result = source[index]; index += 1; }while (Character.isWhitespace(result)); return result; }}
在进行下一步之前,让我们思考一下这个算式的规律,算式中存在两种对象,一种是数字,一种是操作符,由于存在运算的优先级,我们分成三种对象,并用下面的形式来说明,
expr —> term + expr | term - expr | termterm —> factor * term | factor/term | factorfactor—> digit |(expr)
‘—>’的意思是’由...组成’,’|’ 代表’或关系’,expr代表加减法运算式,term代表乘除法运算式,factor代表操作的最小元素,最后一句的意思就是factor由数字或者带括号的expr组成。这三个定义式是递归的,它可以代表任意深度的算式。让我们用树的形式来观察一下,
有了这三种抽象对象我们可以写出对应方法了,我们在parser类里定义三个函数,来代表三种对象的产生过程,并且定义char类型变量head代表正在被扫描的字符。
public class Parser { private Scanner scanner; public Parser(Scanner scanner){ this.scanner = scanner; } private char head; public void parse(){ if (Scanner.END != (head = scanner.getNext())){ expr(); } if (head != Scanner.END){ throw new RuntimeException("syntax error at "+head); } } public int expr(){ int result = term(); int tempResult; char operate; while ((operate = head) == '+' || operate == '-') { head = scanner.getNext(); tempResult = term(); switch (operate) { case '+': System.out.println(result + "+" + tempResult + "=" + (result + tempResult)); result += tempResult; break; case '-': System.out.println(result + "-" + tempResult + "=" + (result - tempResult)); result -= tempResult; } } return result; } public int term(){ int result = factor(); int tempResult; char operate ; while ((operate=head) == '*' ||operate == '/') { head = scanner.getNext(); tempResult = factor(); switch (operate) { case '*': System.out.println(result + "*" + tempResult + "=" + (result * tempResult)); result *= tempResult; break; case '/': System.out.println(result + "/" + tempResult + "=" + (result / tempResult)); result /= tempResult; } } return result; } public int factor(){ int factor; if (Character.isDigit(head)){ factor = head - 48; //字符变量-48可以转换成对应的字面数字 head = scanner.getNext(); } else { match('('); factor = expr(); match(')'); } return factor; }//match方法用来断言head是什么字符,如果为真,将读取下一个字符赋值给head public boolean match(char symbol){ if (symbol == head){ head = scanner.getNext(); return true; } throw new RuntimeException("syntax error at "+head); } public static void main(String... args){ Scanner scanner = new Scanner("1+1+(3+4)-2*3+8/2"); Parser parser = new Parser(scanner); parser.parse(); }}
如果回过头来重新考虑这件事情,你会发现我们这个小程序的本质是将一段文本转化成可以执行的程序,正如我们的编译器一样。而实际上编译器要复杂的多,它的基本工作过程可以分为几个步骤:
词法分析(scanning),读入源程序字符流,将字符转换成有意义的词素(lexeme)的序列,并生成对应的词法单元(token)
语法分析(parsing),主要目的是生成词法单元的语法结构,一般会使用树形结构来表示,称为语法树。
语义分析(semantic analysis),使用语法树检查源程序是否和语言定义的语义一致。其中一个重要部分是类型检查。
生成中间代码,语义分析完成后,编译器会将语法树生成为一种接近机器语言的中间代码。我们程序最后产生的一系列小的表达式与之类似。
代码优化,编译器会尝试改进中间代码,用以生成更高效的机器代码。
代码生成,将优化过对中间代码生成机器代码。
在这些过程中,递归的方法起到了非常重要的作用,有一句话说明了编译器的本质,编译器就是让你的源程序变成可执行程序的另一个程序。你会发现这个定义本身就是递归的。透过这些编译原理,可以让我们更加深入的理解编程语言,甚至发明一种编程语言。