1. 程式人生 > >Graduation Project——詞法分析器

Graduation Project——詞法分析器

語言處理器的第一個組成部分是詞法分析器(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()*/);
        }
    }
}