Stanford公開課《編譯原理》學習筆記(2)遞歸降低法

示例代碼託管在:http://www.github.com/dashnowords/blogs前端

博客園地址:《大史住在大前端》原創博文目錄node

華爲雲社區地址:【你要的前端打怪升級指南】git

B站地址:【編譯原理】github

Stanford公開課:【Stanford大學公開課官網】算法

課程裏涉及到的內容講的仍是很清楚的,但個別地方有點脫節,建議課下本身配合經典著做《Compilers-priciples, Techniques and Tools》(也就是大名鼎鼎的龍書)做爲補充閱讀。數組

一. Parse階段

詞法分析階段的任務是將字符串轉爲Token組,而Parse階段的目標是將Token變爲Parse Tree,本篇只是這部份內容最基礎的一部分。ide

CFG

CFGcontext free grammer,定義一種CFG語法規則須要聲明以下特徵:函數

  • 一組終止符號集,也稱爲「詞法單元」
  • 一組非終止符號集,也稱爲「語法變量」
  • 一個開始符號集
  • 若干產生式規則(產生式則就是指在當前CFG的語法下,產生符號->左右兩側能夠互相替代)

CFG的基本轉換流程以下:命令行

從隸屬於開始集S開始,嘗試將字符串中的非終止符X替換爲終止集的形式(X->Y1Y2...Yn),重複這個步驟直到字符串序列中再也不有非終止符。這個過程被稱爲Derivation(派生),它是一系列變換過程的序列,能夠轉換爲樹的形式,樹的根節點即爲起始集合S中的成員,轉換後的每一個終止集以子節點的形式掛載在根節點下,這棵生成的樹就被稱爲Parse Tree,能夠看出最後的結果實際上就是Parse Tree的葉節點遍歷結果。

當須要轉換的非終結字符有多個時,須要按照必定的順序來逐個推導,派生過程能夠按照left-mostright-most進行,但有時會獲得不一樣的合法的轉換樹,一般會經過修改轉換集語法或設定優先級來解決。

Recursive Descent(遞歸降低遍歷)

Recursive Descent是一種遍歷parse tree的策略,是一種典型的遞歸回溯算法,從樹的根節點開始,逐個嘗試當前父節點上記錄的非終止字符可以支持的產生規則,並判斷其子節點是否符合這樣的形式,直到子節點符合某個特定的產生式規則,而後再繼續遞歸進行深度遍歷,若是在某個非終止節點上嘗試完全部的產生式規則都沒法繼續向下進行使得子樹的葉節點都符合終止符號集,則須要經過回溯到上一節點並嘗試父節點的下一個產生式規則,使得循環程序能夠繼續向後進行。課程裏用了不少的數學符號定義和僞代碼來描述遞歸遍歷的過程,若是以爲太抽象很差理解能夠暫時略過。須要注意左遞歸文法會使得遞歸降低遍歷進入死循環,在文法設計時應該避免,龍書中也提供了一種通用的拆分方法來解決這個問題。

二. 遞歸降低遍歷

【聲明】因爲課程中並無看到從tokensparse tree的全貌,只能先逐步消化基礎知識。下文的過程只是筆者本身的理解(尤爲是逐行分析的形式,由於還沒有涉及任何結構性語法,因此通用性還有待考量),僅供參考,也歡迎交流指正。但對於直觀理解遞歸降低法而言是足夠的。

2.1 預備知識

本節中使用JavaScript來實現遞歸降低遍歷,目標代碼還是上一篇博文中的示例代碼:

var b3 = 2;
a = 1 + ( b3 + 4);
return a;

通過上一節的分詞器後能夠獲得下面的詞素序列:

[ 'keywords', 'var' ],
[ 'id', 'b3' ],
[ 'assign', '=' ],
[ 'num', '2' ],
[ 'semicolon', ';' ],
[ 'id', 'a' ],
[ 'assign', '=' ],
[ 'num', '1' ],
[ 'plus', '+' ],
[ 'lparen', '(' ],
[ 'id', 'b3' ],
[ 'plus', '+' ],
[ 'num', '4' ],
[ 'rparen', ')' ],
[ 'semicolon', ';' ],
[ 'keywords', 'return' ],
[ 'id', 'a' ],
[ 'semicolon', ';' ]

語法分析是基於語法規則的,所謂語法規則,一般是指一系列CFG表示的產生式,大多數開發者並不具有設計一套語法規則的能力,此處直接借鑑Mozilla中的Javascript引擎SpiderMonkey中的文法定義來進行基本產生式,因爲Javascript語言中涉及的文法很是多,本節只篩選出與目標解析式相關的一部分簡化的語法規則(圖中標記爲藍色的部分):

完整的語法規則能夠查看【SpiderMonkey_ParserAPI】進行了解。

2.2 多行語句的處理思路

咱們把上面的目標解析代碼當作是一段Javascript代碼,自頂向下分析時,根節點的類型是Program,它能夠由多個Statement節點(語句節點)構成,因此本例中進行簡化後以semicolon(分號)做爲詞素批量處理的分界點,每次將兩個分號之間的部分讀入緩衝區進行分析,因爲上例中均爲單行語句,因此理解起來比較簡單。

在更爲複雜的狀況中,代碼中包含條件語句,循環語句等一些結構化的關鍵詞時可能會存在跨行的語句,此時能夠在遞歸降低以前先對緩衝區的詞素隊列進行基本的結構分析,若是發現匹配的結構化模式,就從tokens序列中將下一行(或多行)也讀入緩衝區,直到緩衝區中的全部tokens放在一塊兒符合了某些特定的結構,再開始進行遞歸降低。

2.3 簡易的文法定義

爲方便理解,本例中均使用關鍵詞縮寫來表示可能的語法規則集,若是你對Javascript語言有必定了解,它們是很是容易理解的

/**
 * 文法定義-生產規則
 * Program -> Statement
 * P -> S
 * 
 * 語句 -> 塊狀語句 | if語句 | return語句 | 聲明 | 表達式 |......
 * Statement -> BlockStatement | IfStatement | ReturnStatement | Declaration | Expression |......
 * S -> B | I | R | D | E
 * 
 * B -> { Statement }
 * 
 * I -> if ( ExpressionStatement ) { Statement }
 * 
 * R -> return Expression | null
 * 
 * 聲明 -> 函數聲明 | 變量聲明
 * Declaration -> FunctionDeclaration | VariableDeclaration
 * D -> F | V
 * 
 * F -> function ID ( SequenceExpression ) { ... }
 * 
 * V -> 'var | let | const' ID [= Expression | Null] ?
 * 
 * 表達式 -> 賦值表達式 | 序列表達式 | 一元運算表達式 | 二元運算表達式 |......
 * Expression -> AssignmentExpression | SequenceExpression | UnaryExpression | BinaryExpression | BracketExpression......
 * E -> A | Seq | U | BI | BRA |...
 * 
 * A -> E = E //賦值表達式
 * 
 * Seq -> ID,ID,ID//相似形式
 * 
 * //一元表達式
 * U -> "-" | "+" | "!" | "~" | "typeof" | "void" | "delete" E
 * 
 * //二元表達式
 * BI -> E "==" | "!=" | "===" | "!=="
         | "<" | "<=" | ">" | ">="
         | "<<" | ">>" | ">>>"
         | "+" | "-" | "*" | "/" | "%"
         | "|" | "^" | "&" | "in"
         | "instanceof" | ".."  E
 * 
 * //括號表達式
 * BRA -> ( E )
 * 
 * N -> null  
 */

須要額外注意的是表達式Expression到賦值表達式AssignmentExpression的產生式,E的判斷規則裏須要判斷A,而A的邏輯裏又再次調用了E,這裏就是一種左遞歸,若是不進行任何處理,在代碼運行時就會陷入死循環而後爆棧,這也就是前文強調的須要在語法產生式設計時消除左遞歸的場景。這裏並非說spiderMonkeyparserAPI是錯的,由於消除左遞歸的語法改造只是一種等價形式的轉換,是爲了防止產生式產生無限遞推(或者說程序實現時進入無限遞歸的死循環)而作的一種形式處理,改造的過程可能只是引入了某個中間集合來消除這種場景的影響,對於最終的語法表意並不會產生影響。

下文示例代碼中並無進行嚴謹的"左遞歸消除",而是簡單地使用了一個E_集合,與本來的E進行一些微小的差別區分,從而避免了死循環。

2.4 文法產生式的代碼轉換

下面將上一小節的語法規則進行代碼翻譯(只包含部分產生式的推導,本例中的完整代碼能夠從demo或代碼倉中獲取):

//判斷是否爲Statement
function S(tokens) {
    //把結尾的分號所有去除
    while(tokens[tokens.length - 1][0] === TT.semicolon){
        tokens.pop();
    }
    return B(tokens) || I(tokens) || R(tokens) || D(tokens) || E(tokens);
}

//判斷是否爲BlockStatement  B -> { Statement } (本例中並不涉及本方法,故暫不考慮末尾分號和文法遞歸的狀況)
function B(tokens) {
     //本例中不涉及,直接返回false
    return false;
}

//判斷是否爲IfStatement I -> if ( ExpressionStatement ) { Statement }
function I(tokens) {
    //本例中不涉及,直接返回false
    return false;
}
//判斷是否爲ReturnStatement  R -> return Expression | null
function R(tokens) {
    return isReturn(tokens[0]) && (E(tokens.slice(1)) || N(tokens.slice(1)[0]));
}

//判斷是否爲聲明語句 Declaration -> FunctionDeclaration | VariableDeclaration
function D(tokens) {
    return F(tokens) || V(tokens);
}

//判斷是否爲函數聲明  F -> function ID ( SequenceExpression ) { ... }
function F(tokens) {
    //本例中不涉及,直接返回false
    return false;
}

//判斷是否爲變量聲明  V -> 'var | let | const' ID [= Expression | Null] ?
function V(tokens) {
    //判斷爲1.單純的聲明 仍是 2.帶有初始值的聲明
    if (tokens.length === 2) {
        return isVariableDeclarationKeywords(tokens[0]) && tokens[1][0] === TT.id;
    }
    return isVariableDeclarationKeywords(tokens[0]) && (A(tokens.slice(1))) || N(tokens.slice(1));
}

//....其餘代碼形式雷同,再也不贅述

2.5 逐行解析

解析時默認每次遇到一個分號時表示一個statement的結束,前文已經說起過對於多行語句的處理思路。實現時只須要將tokens序列一點點讀進buffer數組並從頂層的S方法啓動分析,便可完成自頂向下的推理過程。

/**parser */
function parse(tokens) {
    let buffer = nextStatement(tokens);
    let flag = true;

    while (buffer && flag){

       if (!S(buffer)) {
           console.log('檢測到不符合語法的tokens序列');
           flag = false;
       } 
       buffer = nextStatement(tokens);
    }   
    
    //若是沒有出錯則提示正確
    flag && console.log('檢測結束,被檢測tokens序列是合法的代碼段');
}

//將下一個Statement所有讀入緩衝區
function nextStatement(tokens) {
    let result = [];
    let token;

    while(tokens.length) {
        token = tokens.shift();
        result.push(token);
        //若是不是換行符則
        if (token[0] === CRLF) {
            break;
        }
    }

    return result.length ? result : null;
}

2.6 查看計算過程

單步執行查看計算過程能夠幫助咱們更好地理解遞歸降低法的執行過程:

在demo所在目錄下打開命令行,輸入:node --inspect-brk recursive-descent.js,而後單步執行就很容易看出代碼在執行過程當中如何實現遞歸和回溯:

三.小結

單純地遞歸降低法最終的結果只找出了不知足任何語法規則的語句,或是最終全部語句都符合語法規則時給出提示,但並無獲得一個樹結構的對象,也沒有向下一個環節提供輸出,如何在編譯過程當中與後續環節進行鏈接還有待探索。

相關文章
相關標籤/搜索