语言处理器的第一个组成部分是词法分析器(lexer),也叫scanner。程序的源代码最初是一长串字符串。从内部来看,源代码中的换行也能用专门的(不可见)换行符表示。所以这一长串代码会首先被处理为一个一个的token,也成为token流。
token流
譬如下面这一行代码:
while i<10{
词法分析器会这样处理:
"while" "i" "<" "10" "{"
这样的5个字符串,被称为token。词法分析器将筛选出程序的解释与执行必须的成分。单词之间的空白和注释会被忽略。譬如
while i<10{ //judge if i >10
这行的处理结果与前面是相同的,都是"while" "i" "<" "10" "{"
实际的单词是Token类的子类的对象,Token对象除了记录该单词对应的字符串,还会保留单词的类型、单词所处位置的行号等信息。Stone语言含有标识符、整型字面量和字符串字面量这三种类型的单词,每种单词都定义了对应Token类的子类。每种子类都覆盖了父类Token类的isIndentifier、isNumber、isString方法,并根据具体类型返回相应的值。
此外Stone语言还定义了一个特别的单词Token.EOF(end of file)来表示程序的结束。Token.EOL(end of line)表示程序的换行。不过它是一个String对象,也就是说,只是一个单纯的字符串。
通过正则表达式定义单词
要设计词法分析器,首先要考虑每一种类型的单词的定义,规定怎样的字符串才能构成一个单词,这里最重要的是不能有歧义,某个特定的字符串只能是某种特定类型的单词。举例来讲,要是字符串123h既能被解释为标识符,又能被解释为整型字面量,之后的处理就会相当麻烦。这种单词的定义方式是不可取的。
package stone;
public abstract class Token {
public static final Token EOF=new Token(-1){};//EOF 表示文件终结
public static final String EOL="\\n";//EOL 标识一行终结
private int lineNumber;
protected Token(int line){
this.lineNumber=line;
}
public int getLineNumber(){return lineNumber;}
public boolean isIdentifier(){return false;}//是否是标识符
public boolean isNumber(){return false;}//是否是数字字面量
public boolean isString(){return false;}//是否是字符串字面量
public String getText(){return "";}//具体的字符串
}
package stone;
public class StoneExcetion extends RuntimeException{
private static final long serialVersionUID = *;
public StoneExcetion(String s) {
super(s);
}
public StoneExcetion(String msg,ASTree t){
super(msg+" "+t.location());
}
}
标识符是指变量名、函数名或者类名等名称。此外+、-等运算符号,以及括号等标点符号也属于标识符。标点符号和保留字有时候被归位另一种类型的单词,不过Stone语言在实现的时候,没有对他们加以区分。
整型字面量就是譬如123、1256等字符序列。
字符串字面量就是一串用于表示字符串的字符序列。
不过,我们希望以一种形式化的语言来进行描述,以便计算机自动进行处理。正则表达式就是一个理想的选择。正则表达式不用赘述,它有2种基本要素:
- 表达式
ε
,表示。 - 对于字符集合中的任意字符a,表达式a表示仅有一个字符a的语言,即{a}。
正则表达式有3种基本运算:
- 2个正则表达式的并,记作X|Y。比如a|b所得的语言就是{a, b}。
- 2个正则表达式的连接,记作XY。比如令X = a|b,Y = c|d,那么XY所表示的语言就是{ac, bc, ad, bd}。
- 一个正则表达式的克林闭包,记作X*,表示分别将零个,一个,两个……无穷个X与自己连接。也就是说X* = ε | X | XX | XXX | XXX | ……。
以上三种运算写在一起时克林闭包的优先级高于连接运算,而连接运算的优先级高于并运算。下面我们用正则表达式来描述一下刚才各个词素的规则。
首先是关键字string,刚才我们描述说它是“正好是s-t-r-i-n-g这几个字母按顺序组成”,用正则表达式来表示,那就是s-t-r-i-n-g这几个字母的连接运算,所以写成正则表达是就是string。
先用正则表达式描述“由字母开头”,那就是指,可以是a-z中任意一个字母来开头。这是正则表达式中的并运算:a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z。如果每个正则表达式都这么写,那真是要疯掉了,所以我们引入方括号语法,写在方括号里就表示这些字符的并运算。比如[abc]就表示a|b|c。而a-z一共26个字母我们也简写成a-z,这样,“由字母开头”就可以翻译成正则表达式[a-z]了。接下来我们翻译第二句“后面可以跟零个或多个字母或数字”这句话中的**“零个或多个”**可以翻译成克林闭包运算,最后相信大家都可以写出来,就是[a-z0-9]*。
最后,前后两句之间是一个连接运算,因此最后描述标识符“语言”的正则表达式就是[a-z][a-z0-9]*
。其中的*运算也意味着“标识符”是一种无穷语言,有无数种可能的标识符。本来就是这样,很好理解对吧?
Stone语言的标识符包括各类符号,因此下面才是真正完整的表达式,各个模式之间需要通过|
连接。
首先来定义整型字面量:(表示0-9的任意数字)
[0-9]+
然后是定义标识符:
[A-Z_a-z][A-Z_a-z0-9]*
这个正则表达式至少需要一个字母、数字或下划线_,且首字符不能是数字,这种表示方式涵盖了常用的名称。根据该定义,对整型字面量和标识符的判断不存在二义性。
因此最后,Stone的语言标识符的真正的完整的正则表达式如下:
[A-Z_a-z][A-Z_a-z0-9]*|==|<=|>=|&&|\|\||\p{Punct}
最后的\p{Punct}表示与任意一个符号字符匹配。
最后需要定义的是字符串字面量,虽然我们可能用不上,但是定义一下也没有坏事。(以后万一有什么功能)。
"(\\"|\\\\|\\n[^"])*
借助java.util.regex设计词法分析器
Lexer类是一个词法分析器,它的构造函数接受一个java.io.Reader对象,它能根据需要逐行读取源代码,供执行词法分析。
package stone;
import stone.Exception.ParseException;
import stone.token.IdToken;
import stone.token.NumToken;
import stone.token.StrToken;
import stone.token.Token;
import java.io.IOException;
import java.io.LineNumberReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Lexer {
/*
\s*((//.*)|([0-9]+)|("(\n|\\\\|\\"|[^"])*")|([A-Z_a-z][A-Z_a-z0-9]*)|==|<=|>=|&&|\|\||\p{Punct})
\s* 前导空白符
(//.*) //注释符
([0-9]+) 整形
("(\n|\\\\|\\"|[^"])*") 字符串 里面可以有// \"
([A-Z_a-z][A-Z_a-z0-9]*) 标识符
==
<=
>=
&&
\|\| 短路符
p{Punct} POSIX 字符类 表示标点符号
*/
public static String regexpat =
"\\s*((//.*)|([0-9]+)|(\"(\\\\\"|\\\\\\\\|\n|[^\"])*\")|([A-Z_a-z][A-Z_a-z0-9]*)|==|<=|>=|&&|\\|\\||\\p{Punct})";
private Pattern pattern = Pattern.compile(regexpat);
private ArrayList<Token> queue = new ArrayList<>();
private boolean hasMore;
private LineNumberReader reader;
public Lexer(Reader reader) {
hasMore = true;
this.reader = new LineNumberReader(reader);
}
public Token read()throws ParseException{
if (fillQueue(0)){//如果true 意味着queue中还有
return queue.remove(0);//拿出一个
}else {
return Token.EOF;//否则返回文件末
}
}
public Token peek(int i)throws ParseException{
if (fillQueue(i)){
return queue.get(i);
}
return Token.EOF;
}
/**
* 向queue中填充i个token 如果已经没有可读的了 false
*/
private boolean fillQueue(int i) throws ParseException {
while (i>=queue.size()){
if (hasMore){
readLine();
}else {
return false;
}
}
return true;
}
private void readLine() throws ParseException {
String line;
try {
line = reader.readLine();//读取一行
// System.out.println(line.toString());
} catch (IOException e) {
throw new ParseException(e);
}
if (line == null) {
//如果这是最后一行的话
hasMore = false;
return;
}
int lineNo = reader.getLineNumber();//行号
Matcher matcher = pattern.matcher(line);//检测匹配
matcher.useTransparentBounds(true).useAnchoringBounds(false);
int pos = 0;
int endPos = line.length();
//关键逻辑
while (pos < endPos) {
matcher.region(pos, endPos);//设定匹配范围
if (matcher.lookingAt()){//匹配到
addToken(lineNo, matcher);
pos = matcher.end();//设定新的起始点
} else {//有无法匹配的就抛出异常
throw new ParseException("无法解析此行 " + lineNo);
}
}
queue.add(new IdToken(lineNo, Token.EOL));//解析完一行 增加一个EOL,也就是\n
}
private void addToken(int lineNo, Matcher matcher) {
//matcher.group(0)指的是整个串,1指的是第一个括号,2是第二个括号里的东西,以此类推
//s*((//.*)|([0-9]+)|("(\n|\\\\|\\"|[^"])*")|([A-Z_a-z][A-Z_a-z0-9]*)|==|<=|>=|&&|\|\||\p{Punct})
String m=matcher.group(1);
if (m!=null)//整个匹配有命中
{
if (matcher.group(2)==null)//不是注释
{
Token token;
if (matcher.group(3)!=null){//是数字
token=new NumToken(lineNo,Integer.parseInt(m));
}else if (matcher.group(4)!=null){//是字符串
token=new StrToken(lineNo,toStringliteral(m));
}else {
token=new IdToken(lineNo,m);
}
queue.add(token);
}
}
}
//正则捕获的是 "wwea"的形式 去掉 "" 同时处理一些转义字符
private String toStringliteral(String m) {
StringBuilder sb=new StringBuilder();
int len=m.length()-2;//去掉""后的长度
for (int i=1;i<=len;i++){
char c=m.charAt(i);
if (c=='\\'&&(i+1)<=len){//有转义字符
char c2=m.charAt(i+1);
if (c2=='\\'||c2=='"')//去掉转义的 \\=>\ \"=>"
{
i++;
c=m.charAt(i);
}else if (c2=='n'){//将字符串\n 变成真正的换行符
i++;
c='\n';
}
}
sb.append(c);
}
return sb.toString();
}
}
正则表达式保存于regexPat字段。
read和peek是Lexer的两个主要方法,read是逐一获取单词,peek则用于预读,peek(i)将返回read方法即将返回的单词之后的第i个单词,如果i=0,则与read方法相同。
如果单词读取结束,read方法和peek方法豆浆返回Token.EOF。
为什么要有peek方法呢?因为这是语法分析器阶段的的抽象语法树必不可少的方法,用于回溯。
readLine则是从每一行读取单词,由于正则表达式已经事先编译为Pattern对象,所以能够调用matcher方法来获得一个用于实际检查匹配的Matcher对象。
运行:
package stone;
import stone.token.Token;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
public class LexerRunner {
public static void main(String[] args) throws FileNotFoundException {
File code = new File("data/lex.stone");
Lexer lexer;
lexer = new Lexer(new FileReader(code));
for (Token t; (t = lexer.read()) != Token.EOF; ) {
System.out.println("=> " + t.getText()/*+" "+t.isIdentifier()+" "+t.isNumber()+" "+t.isString()*/);
}
}
}