手寫一個Parser - 程式碼簡單而功能強大的Pratt Parsing

csRyan發表於2022-02-25

在編譯的流程中,一個很重要的步驟是語法分析(又稱解析,Parsing)。解析器(Parser)負責將Token流轉化為抽象語法樹(AST)。這篇文章介紹一種Parser的實現演算法:Pratt Parsing,又稱Top Down Operator Precedence Parsing,並用TypeScript來實現它

Pratt Parsing實現起來非常簡單,你可以看一下TypeScript實現結果,核心程式碼不到40行!

應用背景

實現一個解析器的方式一般有2種:

  • 使用Parser generator
  • 手工實現

Parser generator

使用Parser generator。用一種DSL(比如BNF)來描述你的語法,將描述檔案輸入給Parser generator,後者就會輸出一份用來解析這種語法的程式碼。

這種方式非常方便,足以滿足絕大部分的需求。但是在一些場景下,它不夠靈活(比如無法提供更有用的、包含上下文的錯誤資訊)、效能不夠好、生成程式碼較長。並且,在描述表示式的操作符優先順序(precedence)和結合性(associativity)的時候,語法描述會變得非常複雜、難以閱讀,比如wikipedia的例子

expression ::= equality-expression
equality-expression ::= additive-expression ( ( '==' | '!=' ) additive-expression ) *
additive-expression ::= multiplicative-expression ( ( '+' | '-' ) multiplicative-expression ) *
multiplicative-expression ::= primary ( ( '*' | '/' ) primary ) *
primary ::= '(' expression ')' | NUMBER | VARIABLE | '-' primary

你需要為每一種優先順序建立一個規則,導致表示式的語法描述非常複雜。

因此有時候需要用第二種方式:手工實現。

手工實現

遞迴下降演算法

手工實現Parser的常見方法是遞迴下降演算法 。遞迴下降演算法比較擅長解析的是語句(Statement) ,因為創造者在設計語句的時候,有意地將語句型別的標識放在最開頭,比如if (expression) ...while (expression) ...。得益於此,Parser通過開頭來識別出語句型別以後,就知道需要依次解析哪些結構了,依次呼叫對應的結構解析函式即可,實現非常簡單。

但是,遞迴下降演算法在處理表示式(Expression) 的時候非常吃力,因為Parser在讀到表示式開頭的時候,無法知道正在解析哪種表示式,因為操作符(Operator)往往在表示式的中間位置(甚至結尾),比如加法運算的+、函式呼叫的()。並且,你需要為每一種操作符優先順序(precedence)都單獨編寫一個解析函式,並手動處理結合性(associativity),因此解析函式會比較多、比較複雜。

比如在wikipedia的例子中,expression負責處理加減法、term負責處理乘除法,並且前者呼叫後者。可以想象有更多優先順序時,程式碼會更加複雜,遞迴呼叫層級會更深。比如,即使輸入字串是簡單的1,這個解析器也需要遞迴地呼叫以下解析函式:program -> block -> statement -> expression -> term -> factor 。後面2層呼叫本應該避免,因為輸入根本不包含加減乘除法!

因此,在手工實現Parser的時候,一般會將表示式的解析交給其它演算法,規避遞迴下降的劣勢。Pratt Parsing就是這樣一種擅長解析表示式的演算法。

Pratt Parsing

Pratt Parsing,又稱Top Down Operator Precedence Parsing,是一種很巧妙的演算法,它實現簡單、效能好,而且很容易定製擴充套件,尤其擅長解析表示式,擅長處理表示式操作符優先順序(precedence)結合性(associativity)

演算法介紹

概念介紹

Pratt Parsing將token分成2種:

  • prefix (正規術語是nud)。如果一個token可以放在表示式的最開頭,那麼它就是一個"prefix"。比如 123(,或者表示負數的-。以這種token為中心,構建表示式節點時,不需要知道這個token左邊的表示式。它們構建出來的表示式節點類似於這樣:
// 負數的負號字首
// 不需要知道它左邊的表示式
{
  type: "unary",
  operator: "-",
  body: rightExpression,
}
  • infix (正規術語是led)。如果一個token在構建表示式節點的時候,必須知道它左邊的子表示式,那麼它就是一個"infix"。這意味著infix不能放在任何表示式的開頭。比如加減乘除法操作符。它們構建出來的表示式節點類似於這樣:
// 減法操作符
// 需要提前解析好它左邊的表示式,得到leftExpression,才能構建減法節點
{
  type: "binary",
  operator: "-",
  left: leftExpression,
  right: rightExpression,
}

注意,雖然-既可以是prefix又可以是infix,但實際上,你在從左到右讀取輸入字串的時候,你是可以立即判斷出你遇到的-應該當作prefix還是infix的,不用擔心混淆 (比如-1-2。在理解了下面的演算法以後,你會更明白這一點。

程式碼講解

Pratt Parsing演算法的核心實現就是parseExp函式

/*  1 */ function parseExp(ctxPrecedence: number): Node {
/*  2 */   let prefixToken = scanner.consume();
/*  3 */   if (!prefixToken) throw new Error(`expect token but found none`);
/*  4 */ 
/*  5 */   // because our scanner is so naive,
/*  6 */   // we treat all non-operator tokens as value (.e.g number)
/*  7 */   const prefixParselet =
/*  8 */     prefixParselets[prefixToken] ?? prefixParselets.__value;
/*  9 */   let left: Node = prefixParselet.handle(prefixToken, parser);
/* 10 */
/* 11 */   while (true) {
/* 12 */     const infixToken = scanner.peek();
/* 13 */     if (!infixToken) break;
/* 14 */     const infixParselet = infixParselets[infixToken];
/* 15 */     if (!infixParselet) break;
/* 16 */     if (infixParselet.precedence <= ctxPrecedence) break;
/* 17 */     scanner.consume();
/* 18 */     left = infixParselet.handle(left, infixToken, parser);
/* 19 */   }
/* 20 */   return left;
/* 21 */ }

下面我們逐行講解這個演算法的工作原理。

2~10行:解析prefix

首先,這個方法會從token流吃掉一個token。這個token必定是一個prefix (比如遇到-要將它理解為prefix)。

注意,consume表示吃掉,peek表示瞥一眼。

在第7行,我們找到這個prefix對應的表示式構建器(prefixParselet),並呼叫它。prefixParselet的作用是,構建出以這個prefix為中心的表示式節點。

我們先假設簡單的情況,假設第一個token是123。它會觸發預設的prefixParselet(prefixParselets.__value),直接返回一個value節點:

{
  type: "value",
  value: "123",
}

它就是我們在第9行賦值給left的值(已經構建好的表示式節點)。

在更復雜的情況下,prefixParselet會遞迴呼叫parseExp。比如,負號-的prefixParselets是這樣註冊的:

// 負號字首的優先順序定為150,它的作用在後面講述
prefixParselets["-"] = {
  handle(token, parser) {
    const body = parser.parseExp(150);
    return {
      type: "unary",
      operator: "-",
      body,
    };
  },
};

它會遞迴呼叫parseExp,將它右邊的表示式節點解析出來,作為自己的body。

注意,它完全不關心自己左邊的表示式是什麼,這是prefix的根本特徵。

在這裡,遞迴呼叫parseExp(150)傳遞的引數150,可以理解成它與右邊子表示式的繫結強度。舉個例子,在解析-1+2的時候,prefix - 呼叫parseExp(150)得到的body是1,而不是1+2,這就要歸功於150這個引數。優先順序的具體機理在後面還會講述。

11~19行:解析infix

得到了prefix的表示式節點以後,我們就進入了一個while迴圈,這個迴圈負責解析出後續的infix操作。比如-1 + 2 + 3 + 4,後面3個加號都會在這個迴圈中解析出來。

它先從token流瞥見一個token,作為infix,找到它對應的表示式構建器(infixParselet),呼叫infixParselet.handle,得到新的表示式節點。注意,呼叫infixParselet時傳入了當前的left,因為infix需要它左邊的表示式節點才能構建自己。新的表示式節點又會賦值給leftleft不斷累積,變成更大的節點樹。

比如,-的infixParselet是這樣註冊的:

// 加減法的優先順序定義為120
infixParselets["-"] = {
  precedence: 120,
  handle(left, token, parser) {
    const right = parser.parseExp(120);
    return {
      type: "binary",
      operator: "-",
      left,
      right,
    };
  },
};

類似於prefixParselet,它也會遞迴呼叫parseExp來解析右邊的表示式節點。不同之處在於,它本身還有一個可讀取的precedence屬性,以及它在構建表示式節點時使用了left引數。

繼續往下,理解13~16行的3個判斷,是理解整個演算法的關鍵。

第一個判斷 if (!infixToken) break; 很好理解,說明已經讀到輸入末尾,解析自然就要結束。

第二個判斷 if (!infixParselet) break;也比較好理解,說明遇到了非中綴操作符,可能是因為輸入有錯誤語法,也可能是遇到了)或者;,需要將當前解析出來的表示式節點返回給呼叫者來處理。

第三個判斷if (infixParselet.precedence <= ctxPrecedence) break;是整個演算法的核心,前面提到的parseExp的引數ctxPrecedence,就是為這一行而存在的。它的作用是,限制本次parseExp呼叫只能解析優先順序大於ctxPrecedence的infix操作符。如果遇到的infix優先順序小於等於ctxPrecedence,則停止解析,將當前解析結果返回給呼叫者,讓呼叫者來處理後續token。初始時ctxPrecedence的值為0,表示要解析完所有操作,直到遇到結尾(或遇到不認識的操作符)。

比如,在前面-1+2的例子中,字首-的prefixParselet遞迴呼叫了parseExp(150),在遞迴的parseExp執行中,ctxPrecedence為150,大於 +infix的優先順序 120,因此這個遞迴呼叫遇到+的時候就結束了,使得字首-1繫結,而不是與1+2繫結。這樣,才能得到正確的結果(-(1))+2

在infixParselet遞迴呼叫parseExp的時候,也同樣傳入了這個引數。

你可以將prefixParselet和infixParselet遞迴呼叫parseExp的行為,理解成用一個“磁鐵”來吸引後續的token,遞迴引數ctxPrecedence就表示這個磁鐵的“吸力”。僅僅當後續infix與它左邊的token結合的足夠緊密(infixParselet.precedence足夠大)時,這個infix才會一起被“吸”過來。否則,這個infix會與它左邊的token“分離”,它左邊的token會參與本次parseExp構建表示式節點的過程,而這個infix不會參與。

綜上所述,Pratt Parsing是一種迴圈與遞迴相結合的演算法parseExp的執行結構大概是這樣:

  • 吃一個token作為prefix,呼叫它的prefixParselet,得到left(已經構建好的表示式節點)

    • prefixParselet遞迴呼叫parseExp,解析自己需要的部分,構建表示式節點
  • while迴圈

    • 瞥一眼token作為infix,如果它的優先順序足夠高,才能繼續處理。否則函式return left
    • 吃掉infix token,呼叫它的infixParselet,將left傳給它

      • infixParselet遞迴呼叫parseExp,解析自己需要的部分,構建表示式節點
    • 得到新的left
  • return left

現在,你應該能夠理解前面所說的“你在從左到右讀取輸入字串的時候,你是可以立即判斷出你遇到的-應該當作prefix還是infix的,不用擔心混淆 (比如-1-2)”,因為在讀取下一個token之前,演算法就已經很清楚接下來的token應該作為prefix還是infix!

示例的執行過程

現在,用1 + 2 * 3 - 4作為例子,理解Pratt Parsing演算法的執行過程:

  • 先定義好每個infix的優先順序(即infixParselet.precedence):比如,加減法為120,乘除法為130 (乘除法的“繫結強度”更高)
  • 初始時呼叫parseExp(0),即ctxPrecedence=0

    • 掉一個token 1,呼叫prefixParselet,得到表示式節點 1 ,賦值給left
    • 進入while迴圈,瞥見+,找到它的infixParselet,優先順序為120,大於ctxPrecedence。因此這個infix也一起被“吸走”
    • +,呼叫+的infixParselet.handle,此時left1

      • +的infixParselet.handle 遞迴呼叫parser.parseExp(120),即ctxPrecedence=120
      • 掉一個token 2,呼叫prefixParselet,得到表示式節點2,賦值給left
      • 進入while迴圈,瞥見*,找到它的infixParselet,優先順序為130,大於ctxPrecedence。因此這個infix也一起被“吸走”
      • *,呼叫*的infixParselet.handle,此時left2

        • *的infixParselet.handle遞迴呼叫parser.parseExp(130),即ctxPrecedence=130
        • 掉一個token 3,呼叫prefixParselet,得到表示式節點3,賦值給left
        • 進入while迴圈,瞥見-,找到它的infixParselet,優先順序為120,大於ctxPrecedence,因此這個infix不會被一起吸走,while迴圈結束
        • parser.parseExp(130)返回 3
      • *的infixParselet.handle返回2 * 3 (將parser.parseExp的返回值與left拼起來),賦值給left
      • 繼續while迴圈,瞥見-,找到它的infixParselet,優先順序為120,大於ctxPrecedence。因此這個infix不會被一起吸走,while迴圈結束
      • parser.parseExp(120)返回子表示式 2 * 3
    • +的 infixParselet.handle返回1+(2*3)(將parser.parseExp的返回值與left拼起來),賦值給left
    • 繼續while迴圈,瞥見-,找到它的infixParselet,優先順序為120,大於ctxPrecedence。因此這個infix也一起被“吸走”
    • -,呼叫-的infixParselet.handle,此時left1+(2*3)
    • 與之前同理,-的 infixParselet.handle的返回結果為(1+(2*3))-4(將parser.parseExp的返回值與left拼起來),賦值給left
    • while迴圈繼續,但是發現後面沒有token,因此退出while迴圈,返回left
  • parseExp(0)返回(1+(2*3))-4

如何處理結合性

操作符的結合性(associativity),是指,當表示式出現多個連續的相同優先順序的操作符時,是左邊的操作符優先結合(left-associative),還是右邊的優先結合(right-associative)。

根據上面描述的演算法,1+1+1+1是左結合的,也就是說,它會被解析成((1+1)+1)+1,這符合我們的預期。

但是,有一些操作符是右結合的,比如賦值符號=(比如a = b = 1應該被解析成a = (b = 1))、取冪符號^(比如a^b^c應該被解析成a^(b^c))。

在這裡,我們使用^作為取冪符號,而不是像Javascript一樣使用**,是為了避免一個操作符恰好是另一個操作符的字首,引發當前實現的缺陷:遇到**的第一個字元就急切地識別成乘法。實際上這個缺陷很好修復,你能嘗試提一個PR嗎?

如何實現這種右結合的操作符呢?答案只需要一行:在infixParselet中,遞迴呼叫parseExp時,傳遞一個稍小一點的ctxPrecedence。這是我們用於註冊infix的工具函式:

function helpCreateInfixOperator(
  infix: string,
  precedence: number,
  associateRight2Left: boolean = false
) {
  infixParselets[infix] = {
    precedence,
    handle(left, token, { parseExp }) {
      const right = parseExp(associateRight2Left ? precedence - 1 : precedence);
      return {
        type: "binary",
        operator: infix,
        left,
        right,
      };
    },
  };
}

這樣,遞迴parseExp的“吸力”就弱了一些,在遇到相同優先順序的操作符時,右邊的操作符結合得更加緊密,因此也被一起“吸”了過來(而沒有分離)。

完整實現

完整實現的Github倉庫。它包含了測試(覆蓋率100%),以及更多的操作符實現(比如括號、函式呼叫、分支操作符...?...:... 、右結合的冪操作符^等)。

參考資料

相關文章