從編譯原理看一個解釋器的實現
來自專欄 編程啟示錄
『設計模式』中有一個模式可以解釋特定的語法規則,它就是解釋器模式(Interpreter Pattern)。不同於常見的策略模式或者是工廠模式,解釋器模式在.NET或者JDK中並不常見,而且在業務上也很少會去解釋特定的語法,所以它並不被廣泛使用。一個解釋器可大可小,大可以是複雜的編譯器,小也可以是一個簡單的字元串解析,但本質上它們都是對特定的語法做出合理的解釋。
解釋器在遊戲領域的應用
雖然解釋器模式很少使用,但在在遊戲開發中,還是很常見的。比如你在戰鬥時,普通攻擊和魔法攻擊一定會產生不同的傷害,遊戲設計者會為技能設計不同的『公式』,簡單如我方的攻擊力-敵方的防禦力,同時『公式』還可以加入參數,如$critRate代表一個爆發率。故遊戲的技能傷害如下圖所示:
遊戲里的『公式』本質上是字元串,很像數學表達式,但又比它更高級,可以加入自定義的參數,所以『公式』更像是數學表達式的超集。既然談到了數學表達式,那麼有必要知道怎樣去解析一個數學表達式。
千萬不要小看這個任務,實際上要做一個計算器是非常複雜的。假設輸入一個字元串:-(1+(2+3)x4-5),注意這是一個字元串。解決方案有兩種:
while遍歷字元串,將括弧、運算符、數字等取出來,根據運算符左結合以及優先順序計算
將表達式轉化成二叉樹形式,二叉樹的父節點是運算符,左右子節點代表數字,通過遞歸遍歷樹,將左右節點的數字運算之後放入父節點,直至到達根節點
很顯然第一種方式簡單直白,但很繁重,代碼的易讀性也不佳,第二種是目前最好的解決方式,將表達式轉化為二叉樹。所以難點在於怎樣將表達式轉化為一棵二叉樹?
這需要了解數據結構相關知識,表達式-(1+(2+3)x4-5)又被稱為中序排序,中序排序不能生成一棵二叉樹,你需要將中序排序轉化為前序排序或者後序排序,然後根據中序排序和前序排序生成二叉樹,相關演算法自行搜索,不做累贅。
我在閱讀了《編譯原理》第1,2章之後,還有另外一種方式將表達式生成二叉樹形式,這也是編譯的基本原理。
一個編譯器的前端模型
我們以最簡單的算術表達式舉例,編譯器在分析階段把一個字元序列分為各個組成部分,最終生成一棵抽象語法樹(abstract syntax tree),如下所示:
表達式語法定義
語法,顧名思義,是一種特定的描述方法。我們學習的英語語法,又或者是程序語言的語法,都有嚴格的格式要求。對於一個算術表達式而言,比如9-5+2,3-2,語法指的是 「兩個數字之間必須出現+,-,如果出現9+-5,那麼這就是錯誤的語法」。
那我們怎麼來制定語法呢?在編譯原理領域,使用一個通用的表示方法來描述語法,這個方法就是上下文無關文法或BNF範式。
比如上述的算術(+和-)表達式:9-5+2,我們可以推導出如下BNF範式:
list->list+digit|list-digit|digit
digit->0|1|2|3|4|5|6|7|8|9
list代表一個表達式序列,digit代表數字,箭頭->可以讀作「可以具有如下形式」,而豎線|代表或的意思。
詞法分析器
詞法分析器讀入源程序中的字元序列,將他們組織為具有詞法含義的詞素,生成並輸出代表這些詞素的詞法單元(Token)。
語法分析器
語法分析器根據詞法單元,以語法分析樹的形式構建表達式,最終形成一顆抽象的語法樹(abstract syntax tree),這是一種表示了層次化的結構。
語法分析樹
如果非終端節點A有一個產生式A->XYZ,那麼在語法分析樹中就可能有一個標號為A的內部節點,該節點有三個子節點,從左向右標號為X,Y,Z。內部節點對應於產生式的頭,它的子節點對應於產生式的體:
BNF範式構建
數學表達式的特點
運用編譯原理的知識,編寫一個自定義的解釋器,我們需要如下三個步驟:
BNF範式來描述遊戲『公式』
詞法分析器獲得詞法單元Token,對應的類是LexicalAnalyzer
語法分析器根據Token構建抽象樹,對應的類是Parser
我在一開始就提到過,遊戲里的『公式』很像數學表達式,那麼數學表達式有什麼廣泛和通用的特點?
首先數學表達式由數字和運算符構成,並且運算符有左結合性和優先性:
結合性:依照慣例,9+5+2等價於(9+5)+2,9-5-2等價於(9-5)-2。當一個運算分量,比如上述的5左右兩側都有運算符時,我們需要一些規則來決定哪個運算符被應用於該運算分量。我們說運算符「+」是左結合的,因為當一個運算分量左右兩側都有「+」號時,它屬於其左邊運算符。加,減,乘,除四種算術運算符都是左結合。
優先性:在算術中,乘法和除法比加法和減法具有更高的優先順序。因此在表達式9+5x2和9x5+2中,都是運算分量5首先參與x運算。
算術表達式的BNF構建
通過對數學表達式的了解,我們知道一個數學表達式有數字、運算符等組成,並且運算符是左結合和有優先性,那怎樣去構建它的BNF範式呢?
我們創建兩個非終結符號expr(表達式) 和 term(項) ,分別對應這兩個優先順序層次,並使用另一個非終結符號factor(因子)來生成表達式的基本單元。
那什麼是factor呢?
我們可以將因子(factor)理解成不能被任何運算符分開的表達式。『不能分開』的意思是說當我們在任意因子的任意一邊放置一個運算符,都不會導致這個因子的任何部分分離出來,成為這個運算符的運算分量。當然,因子本身作為一個整體可以成為該運算符的一個運算分量。如果這個因子是由一個括弧括起來的表達式,那麼這個括弧將起到保護其不被分開的作用。
factor->digit|(expr)
digit->0|1|2|3|4|5|6|7|8|9
那什麼是term呢?
一個(不是因子)項(term)是一個可能被高優先順序的運算符x和/分開,但不能被低優先順序運算符分開的表達式。
term->term x factor|term / factor|factor
那什麼是expr呢?
一個(不是因子也不是項)的表達式可能被任何一個運算符分開。
expr->expr+term|expr-term|term
因此最終得到的BNF範式是:
expr->expr+term|expr-term|term
term->term x factor|term/factor|factor
factor->digit|(expr)
使用這個BNF範式時,一個表達式就是一個由+或-分割開來的項(term)列表,而項是由x或者/分隔的因子(factor)列表。請注意,任何由括弧括起來的表達式都是一個因子。
這個BNF範式的語法分析樹為如下所示:
求值時,從root節點遍歷二叉樹,如果節點有子節點,遞歸的方式遍歷下去,直到是葉子節點為止,接著將左子樹和右子樹取得的值放入它們的根節點,最後root節點的值就是表達式最終的值。
開始實現解釋器
有了準備之後,接下來就是實現解釋器,它可以解釋遊戲中的『公式』。
1.) 創建一個數學表達式類MathExpression,根據面向對象思想,它封裝了數據和行為,由於篇幅有限,只展示其骨架:
public class MathExpression{ private readonly string _expression; public int CurrentIndex{} public bool IsIndexOutOfRange{} public bool IsEndOfString{} public char CurrentChar{} public char GetSpecificCharByIndex(int index){}}
2.) 創建一個詞法分析器LexicalAnalyzer,獲取對應的詞法單元Token:
switch (_mathExpression.CurrentChar){ case +: token = Token.Add; _mathExpression.CurrentIndex++; break; case -: token = Token.Sub; _mathExpression.CurrentIndex++; break; case *: token=Token.Mul; _mathExpression.CurrentIndex++; break; case /: token = Token.Div; _mathExpression.CurrentIndex++; break; case (: token = Token.OParen; _mathExpression.CurrentIndex++; break; case ): token = Token.CParen; _mathExpression.CurrentIndex++; break; case $: if (_mathExpression.GetSpecificCharByIndex(_mathExpression.CurrentIndex + 1) ==c) { _mathExpression.CurrentIndex += 2; token = Token.Param; } else { _mathExpression.CurrentIndex++; token=Token.Illegal; } break; default: if (char.IsDigit(_mathExpression.CurrentChar)) { token = GetDigitsFromString(); }else if (char.IsLetter(_mathExpression.CurrentChar)) { token = GetSineCosineFromString(); } else { throw new Exception("Illegal Token"); } break;}
3.) 值得一提的事情,怎樣從字元串中獲取數字,數字有兩種形式:整數和小數點形式,通過有窮自動機在不同的狀態間跳轉並記錄下數字的索引下標,直到遇到非數字退出,有窮自動機如下所示:
一個有窮自動機的狀態判斷代碼如下:
do{ isEndOfString = _mathExpression.IsEndOfString; currentChar = _mathExpression.CurrentChar; switch (_currentState) { case State.Init: if (char.IsDigit(currentChar)) { _currentState = State.Integer; if (!isEndOfString) { _mathExpression.CurrentIndex++; } } else { //Init狀態非數字則退出 _currentState= State.Quit; } break; case State.Integer: if (currentChar == .) { _currentState = State.Float;//輸入小數點,狀態轉移到Float if (!isEndOfString) { _mathExpression.CurrentIndex++; } } else { if (!char.IsDigit(currentChar))//既不是數字也不是小數 { _currentState = State.Quit; } else { if (!isEndOfString) { _mathExpression.CurrentIndex++;//讀取下一個字元 } } } break; case State.Float: if (!char.IsDigit(currentChar))//非數字,退出 { _currentState = State.Quit; } else { if (!isEndOfString) { _mathExpression.CurrentIndex++; } } break; case State.Quit: break; }} while (_currentState != State.Quit && !isEndOfString);
4.)通過語法解析器Parser構建表達式樹,每個節點都是一個抽象Expression
public abstract class Expression{ public abstract double Evaluate(Context context);}
Expression根據類型不同有常量表達式,二元表達式,一元表達式等,一個常見的二元表達式如下:
public class BinaryExpression:Expression{ private Expression _leftExpression; private Expression _rightExpression; private Operator _operator; public BinaryExpression(Expression leftExpression,Expression righExpression,Operator op) { _leftExpression = leftExpression; _rightExpression = righExpression; _operator = op; } public override double Evaluate(Context context) { switch (_operator) { case Operator.Plus: return _leftExpression.Evaluate(context) + _rightExpression.Evaluate(context); case Operator.Minus: return _leftExpression.Evaluate(context) - _rightExpression.Evaluate(context); case Operator.Mul: return _leftExpression.Evaluate(context) * _rightExpression.Evaluate(context); case Operator.Div: return _leftExpression.Evaluate(context) / _rightExpression.Evaluate(context); } return Double.NaN; }}
可以看到左子樹和右子樹同樣是Expression。
5.)到目前為止,可以說是萬事俱備,只欠東風了,這個『東風』就是怎麼樣去構建表達式樹。已知的是,一個 expr 就是一個由+或-分割開來的項( term )列表,而項是由x或者/分隔的因子( factor )列表。
expr->expr+term|expr-term|term
private Expression Expr(){ Token old; Expression expression = Term(); while (_currentToken==Token.Add|| _currentToken==Token.Sub) { old = _currentToken; _currentToken = _lexicalAnalyzer.GetToken(); Expression e1 = Expr(); expression=new BinaryExpression(expression,e1,old==Token.Add?Operator.Plus:Operator.Minus); } return expression;}
term->term x factor|term/factor|factor
private Expression Term(){ Token old; Expression expression = Factor(); while (_currentToken==Token.Mul || _currentToken==Token.Div) { old = _currentToken; _currentToken = _lexicalAnalyzer.GetToken(); Expression e1 = Term(); expression=new BinaryExpression(expression,e1,old==Token.Mul?Operator.Mul:Operator.Div); } return expression;}
factor->digit|(expr)
private Expression Factor(){ Token token; Expression expression; if (_currentToken==Token.Double) { expression=new NumericConstant(_lexicalAnalyzer.GetDigits()); _currentToken = _lexicalAnalyzer.GetToken(); } else if (_currentToken == Token.Param) { expression=new Var(); _currentToken = _lexicalAnalyzer.GetToken(); } else if (_currentToken==Token.OParen) { _currentToken = _lexicalAnalyzer.GetToken(); expression = Expr(); if (_currentToken!=Token.CParen) { throw new Exception("Missing Closing Parenthesis
"); } _currentToken = _lexicalAnalyzer.GetToken(); } else if(_currentToken==Token.Add || _currentToken==Token.Sub) { var old = _currentToken; _currentToken = _lexicalAnalyzer.GetToken(); expression = Factor(); expression=new UnaryExpression(expression,old==Token.Add?Operator.Plus:Operator.Minus); } else { throw new Exception("error"); } return expression;}
最後生成的樹結構如下所示:
小結
本文為大家介紹了怎樣從編譯原理的角度來實現一個解釋器。在遊戲領域,需要解釋器來解釋自定義的『公式』。這個『公式』的語法往往是和上下文無關的,又被稱為BNF範式。解釋器的核心就是怎樣構建一棵抽象的表達式樹,這需要詞法分析和語法分析的相關知識。
點擊查看示例代碼
推薦閱讀:
※一起寫一個解釋器(2)---看,兔子!
※用python實現一個簡易的lisp解釋器--默認操作符的實現(6)
※一晚上糊出一個語言「後端」
※用python實現一個簡易的lisp解釋器--解釋器基本結構的實現(5)