在編譯的流程中,一個很重要的步驟是語法分析(又稱解析,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需要它左邊的表示式節點才能構建自己。新的表示式節點又會賦值給left
。left
不斷累積,變成更大的節點樹。
比如,-
的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
- 瞥一眼token作為infix,如果它的優先順序足夠高,才能繼續處理。否則函式
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,此時left
為1
+
的infixParselet.handle 遞迴呼叫parser.parseExp(120)
,即ctxPrecedence=120
- 吃掉一個token
2
,呼叫prefixParselet,得到表示式節點2
,賦值給left
- 進入while迴圈,瞥見
*
,找到它的infixParselet,優先順序為130,大於ctxPrecedence。因此這個infix也一起被“吸走” 吃掉
*
,呼叫*
的infixParselet.handle,此時left
為2
*
的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,此時left
為1+(2*3)
- 與之前同理,
-
的 infixParselet.handle的返回結果為(1+(2*3))-4
(將parser.parseExp
的返回值與left
拼起來),賦值給left
- while迴圈繼續,但是發現後面沒有token,因此退出while迴圈,返回
left
- 吃掉一個token
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%),以及更多的操作符實現(比如括號、函式呼叫、分支操作符...?...:...
、右結合的冪操作符^
等)。
參考資料
- How Desmos uses Pratt Parsers 這篇文章引導讀者從零開始推匯出Pratt演算法,並給出了他們選擇Pratt Parsing時的權衡。
- Pratt Parsers: Expression Parsing Made Easy 也是一篇很不錯的介紹文章,將讀者帶入Pratt演算法的推導過程。
- Arrow functions break JavaScript parsers 帶領我們思考一個很有意思的問題:JavaScript的箭頭函式
(arg1=...)=>{...}
是如何解析的?可能比你想象中的要難!