如何手寫一個簡單的 parser

前一陣子,收到燁兄的私聊,他忽然要解決這樣一個任務:html

作以下格式的表達式轉換:前端

  • Multi(a, Multi(b, c)) --> a * (b * c)
  • Divide(a, Sub(b, c)) --> a / (b - c)

支持的運算符有:git

  • Add: +
  • Sub: -
  • Multi: *
  • Divide: /

並且好死不死的須要用他沒怎麼用過的 C++ 來寫。我發現這是一個 parser 的問題,第一反應是推薦他用 flex/bison,但想到爲了這麼大點任務大費周章不太合適,又開始想手寫這樣一個表達式的 parser 難不難。最後得出的結論是,不難。程序員

瞭解編譯原理的人都知道什麼是 parser。Parser 中文名(語法)分析器,是每一個編譯器的前端都會有的一個東西。不過,從編譯原理的視角來看,「語言」的範疇要比咱們理解的編程語言要廣義得多,任何有必定規則的字符串構成方式,均可以當作是語言,例如上面的那個任務裏用 AddSub 這樣的函數描述的表達式。github

那麼,要解決上面這個任務,只須要對錶達式的字符串進行語法分析,獲得一箇中間表示(通常是分析樹或抽象語法樹),再將中間表示輸出爲所需的格式便可。也就是說咱們須要爲表達式提供一個 parser,這個任務的任何解決方式,本質上均可以當作是寫了一個 parser。編程

在平時,咱們徹底沒有任何須要去手寫一個 parser,由於這東西已經有工具能夠爲咱們生成。感謝幾十年前偉大的程序員就已經發明瞭這樣的工具。我用過的有 C/C++ 的 flex/bison,以及 Java 的 ANTLR。你只須要提供一個文法描述,這些工具就能夠爲你自動生成對應的語法分析器。若是要手寫分析器,會很複雜,也很容易出錯,不是一個明智的選擇。數組

不過,面對上面舉例的這種小任務,使用自動生成 parser 的工具備時候顯得過重了,這時候也許手寫一個 parser 是更好的選擇。並且在這樣的任務場景下,咱們的 parser 有兩個地方起碼是能夠獲得大大簡化的:app

第一,咱們要處理的語言應該不會像通用編程語言那樣,有很複雜的狀態轉移。一般狀況下,應該能看到當前的字符串就知道下面要分析什麼類型的內容。通常標記語言都會是這種風格的,好比:編程語言

  • XML/HTML:看到 <tag> 就知道是一個標籤的開始,直到 </tag> 爲止
  • CSS:選擇器後的聲明,老是用花括號括起來,每一條聲明以 ; 分隔
  • Markdown:一行以 # 開頭就是標題,以 1. 開頭就是有序列表項

第二,咱們不須要進行復雜的語法錯誤處理,只須要報「語法錯誤」就行了,而不須要費力說明到底發生了什麼錯誤。ide

有了這兩個前提,咱們開始思考如何手寫一個語法分析器。固然,我已經思考好了,下面是我給出的一個簡單的分析器的實現。我是用 Java 實現的,用到了一點 lambda 表達式的語法,不過不難理解。由於 parser 的主要工做是作字符串比較,因此用任何語言都差很少。後面我會考慮再用其餘語言實現。

在實現上咱們再作一點簡化:咱們把要分析的字符串做爲字符數組保存下來,而不是從所謂「字符流」中讀入。這樣咱們沒必要考慮讀 (get) 了字符卻不用掉 (consume) 的狀況下,這些是輸入模塊要考慮的部分,咱們專一於 parser 自己。

首先,咱們的 SimpleParser 是這樣定義的:

public class SimpleParser {

    private char[] input;
    private int pos;

    public SimpleParser(String source) {
        this.input = source.toCharArray();
        this.pos = 0;
    }
}
複製代碼

咱們將輸入保存爲字符數組,pos 是一個指向待讀取的下一個字符的指針。將 pos 加一,就至關於從讀入了一個字符。

下面,咱們添加一些腳手架函數:

private void consumeWhitespace() {
    consumeWhile(Character::isWhitespace);
}

private String consumeWhile(Predicate<Character> test) {
    StringBuilder sb = new StringBuilder();
    while (!eof() && test.test(nextChar())) {
        sb.append(consumeChar());
    }
    return sb.toString();
}

private char consumeChar() {
    return input[pos++];
}

private boolean startsWith(String s) {
    return new String(input, pos, input.length - pos).startsWith(s);
}

private char nextChar() {
    return input[pos];
}

private boolean eof() {
    return pos >= input.length;
}
複製代碼

這些函數的來源於我以前看過的一個系列文章:Let's build a browser engine!(原文是用 Rust 語言的)。咱們來看一下這幾個函數:

其中,nextChar, startsWith 這兩個函數是用來「向後看」,判斷後面輸入的狀態。這實際上已經和編譯原理中說的語法分析不太同樣了(回憶一下,編譯原理中說的語法分析方法只會向後看一個字符),可是由於咱們只是判斷是否是等於一個固定的字符串,因此也不是太大的問題。

consume... 開頭的幾個函數就是真正的讀取輸入的函數了。其中,consumeWhile 是一個通用的函數,consumeWhitespace 也是基於其實現的。相似地,咱們還能夠基於其實現解析變量名的函數:

private String parseVariableName() {
    return consumeWhile(Character::isAlphabetic);
}
複製代碼

注意到這實際上就是在解析咱們任務中的變量名了,以此爲思路,後面的實現其實很簡單。咱們一上來會以爲手寫 parser 會很複雜,其實是由於沒找到入手點。因此這幾個腳手架函數特別重要,先有了他們,後面就能夠一步一步寫出整個 parser 的功能了。

那麼咱們接下來能夠這麼寫:

// 解析由單個變量組成的表達式
private VariableExpression parseVariableExpression() {
    String name = parseVariableName();
    // VariableExpression 的定義略
    return new VariableExpression(name);
}
複製代碼
// 解析加減乘除表達式
private CompoundExpression parseCompoundExpression(String name) {
    for (char c : name.toCharArray()) {
        checkState(c == consumeChar());
    }
    checkState('(' == consumeChar());
    // 遞歸解析
    Expression left = parseExpression();
    checkState(',' == consumeChar());
    consumeWhitespace();
    Expression right = parseExpression();
    checkState(')' == consumeChar());
    // CompoundExpression 的定義略
    return new CompoundExpression(name, left, right);
}

// VariableExpression 和 CompoundExpression 都是 Expression
private Expression parseExpression() {
    if (startsWith("Add")) {
        return parseCompoundExpression("Add");
    } else if (startsWith("Sub")) {
        return parseCompoundExpression("Sub");
    } else if (startsWith("Multi")) {
        return parseCompoundExpression("Multi");
    } else if (startsWith("Divide")) {
        return parseCompoundExpression("Divide");
    } else {
        return parseVariableExpression();
    }
}
複製代碼

寫到這裏,咱們 parser 的主要工做已經作完了,接下來的任務就很是簡單了。彷佛咱們的任務有點太簡單了?在這種場景下,手寫 parser 確實不難,接下來能夠手寫一個 Markdown 的 parser 練習一下了😜。

P.S. 燁兄後來並無作這個任務,我也是到如今纔想起來把這個 parser 實現出來,只是我本身以爲好玩想了這件事。

文章中的 parser 的完整代碼,能夠到個人 GitHub 上查看:simpleparser

相關文章
相關標籤/搜索