從詞法分析到正規表示式(2)

漆楚衡發表於2015-03-27

從詞法分析到正規表示式(2)

本文所有程式碼在vs2013下通過編譯

從詞法分析到正規表示式(1)

完整原始碼

花了兩天完成模式匹配器,雖然過程坎坷,不過當時自信心膨脹,覺得自己可以搞定一切,而自己所寫的程式在自己看來也是挺厲害的……

自然而然地,就想到能不能把程式的介面再升級一下,畢竟模式就是通過正規表示式的手段構建的,再寫一個函式將正規表示式翻譯成模式應該不難,所以就著手去做了。

首先設計所用的正規表示式,除了基本的三個操作star(*), connect(隱含), either(|),還需要萬用字元_,和轉義字元\(轉義字和母語C++的轉義字相同簡直是失敗的不可救藥……)。

三個組合手段之間有優先順序

* > connect > |

為了改變組合的優先順序,括號()也是不可或缺的。

正則直譯器

一開始的正規表示式的解釋函式reg_exp其實相當簡單,有一個儲存模式的棧,順序逐個讀取字元

  • _就呼叫is_any_charis_any_char很好實現not_one_of("")
  • *就從模式棧中取出一個模式,用star修飾後放回去
  • |
    1. 遞迴呼叫reg_exp,得到right
    2. 將整個棧用connect連線起來,得到left(最初居然沒注意到要把整個棧串起來)
    3. either(left, right)入棧
  • (就遞迴呼叫reg_exp,結果入棧
  • )就把棧用connect串起來,返回
  • \就再讀一個字元,入棧
  • 其它,入棧

其實可以寫成迭代形式的,稍微想了一下,這個其實跟《資料結構》棧那一章所實現的計算器很像,應該可以用同樣的方法去除遞迴。不過真動手的時候,瞬間迷茫,於是作罷。

缺陷

函式寫好以後就迫不及待地去寫了這種語言的第一個表示式,最簡單的那種,字元字面量

…… ……

寫不出來,因為不支援否定。

於是如果要求除了\之外的字元,就要用|把整個字母表除\的字串起來……

在上面的直譯器里加入否定:

  • !,再讀一個字元,用is_not_char修飾,入棧

經過不斷的失敗,特別是認識到和母語用同一個轉義字的愚蠢後,我終於寫出了能執行的字元字面量表示式

'(!\\|\\_)'

實際在c++裡面是

"'(!\\\\|\\\\_)'"

接下來把它擴充一下就可以用到字串字面量上了……不對,又寫不出來了……

這次還是否定,因為在字串字面量裡要同時否定兩個字元\",!不夠用啊

在直譯器的否定里加入:

  • 如果!後是(,則讀取到),對讀取的串用not_one_of修飾,入棧

實際上沒有上面這步,因為整個過程中充滿了BUG,又穿插著上課,所以思維不太連貫,當時因為某個現在想不起來的BUG徹底絕望了,放棄了繼續打補丁。最終決定重寫整個正規表示式解析模組。

正則計算器

首先要搞清楚一個概念性的問題,整個表示式的特殊符號分成三類

  1. 轉義字
  2. 擴充符號(“語法糖”)
  3. 正則運算子

它們在不同的時候被處理

  1. 轉義字 在讀取原始表示式串時就被處理了,不進入後續計算
  2. 擴充符號 表示了原始表示式中的一個特殊子串,內部格式自定,這個子串在解析時和普通字元一樣得出一個模式
  3. 正則運算子 運算子操作的物件是正規表示式(得出的模式)

另一個棘手的問題是connect,因為它是隱含的,不在原始表示式裡,但是如果希望用和計算器一樣的演算法去計算它,就要把它加上。

綜上,我對原始表示式增加了一個預處理步驟,將正規表示式自身的單元分割工作和後來的語義識別分開。

預處理的輸出是list<RegToken>,RegToken即帶型別的單元串。

順帶地,預處理還根據相鄰Token的型別在list中插入connect的Token。

list<RegToken> preprocess(const string &primary) {    //break to tokens and add connect
    list<RegToken> acc;
    string::const_iterator iter = primary.begin();
    string::const_iterator end = primary.end();
    while (iter != end) {
        acc.push_back(read_and_tag(iter));
    }
    list<RegToken>::iterator fst = acc.begin(), snd = acc.begin(); ++snd;
    for (; snd != acc.end(); fst = snd, ++snd) {
        if (should_insert_connect(fst->second, snd->second)) {
            acc.insert(snd, RegToken("", Op_conn));
        }
    }
    return acc;
}

接下來是從list<RegToken>到模式的組合過程,跟中綴表示式的計算如出一輒

void zip(stack<Pattern> &operands, stack<RegTag> &ops, RegTag op_now) {
    while (ops.size() > 0 && (op_cmp(ops.top(), op_now) > 0)) {
        bool test = (op_cmp(ops.top(), op_now) > 0);
        switch (ops.top()) {
        case Op_star :
            zip_star(operands);
            break;
        case Op_or :
            zip_or(operands);
            break;
        case Op_conn :
            zip_conn(operands);
            break;
        default :
            throw "zip : op error";
            break;
        }
        ops.pop();
    }

    if (op_now == Op_right) {
        assert(ops.top() == Op_left);
        ops.pop();
    }
    else {
        ops.push(op_now);
    }
}

Pattern calculator(list<RegToken>::const_iterator begin, const list<RegToken>::const_iterator end) {
    stack<Pattern> operands;
    stack<RegTag> ops;

    while (begin != end) {
        if (begin->second == Clause) {
            operands.push(cal_clause(begin->first));
        }
        else {
            zip(operands, ops, begin->second);
        }
        ++begin;
    }

    assert(operands.size() == 1 && ops.size() == 0);

    return operands.top();
}

於是,只剩下將普通字元和擴充格式對映到對應得模式構造器上了。

來自《SICP》的啟示

在某節課的走神時,突然《SICP》 4.3節那個神奇的語言模型進入腦海,當時瞬間大腦充血,滿腦子都是實現這個模型的好處(非確定性計算,之前star的問題直接不存在)。

不過下課坐在電腦前才發現自己對於如何實現完全沒有一點概念,探索了一天,一籌莫展。

第二天的上課例行走神,思路又神奇的回到了這個問題,居然有了思路(只是其中的一點點,畢竟C++不是Scheme)

現在想想,當時完全是大腦充血,加上對自己在幹什麼不是很清楚。

事後說起來其實相當簡單,就是想把遞迴轉成迭代。

經歷了很長時間的思考與失敗,終於形成了以下的模型

新的Pattern形式

class AbstractPatternClass {
public :
    virtual Context exec(Cursor &code, std::shared_ptr<AbstractPatternClass> succ, Context fail) = 0;
};

typedef std::shared_ptr<AbstractPatternClass> Pattern;

Context,上下文類有兩類子類,

  • 一類是特殊狀態標識
  • 一類用於執行時(類比為一組暫存器),儲存 一個Cursor 一個Current模式(類比於PC暫存器) 一個succ模式 一個fail上下文

succ和fail代表了計算的兩個走向

  • 如果當前計算成功,則繼續執行succ
  • 如果當前計算失敗,則執行fail

注意到fail是一個上下文,不需要當前上下文給出執行的資訊,而succ是一個模式,它的執行需要指定一些額外的資訊以形成自己的上下文。

整理一下思路

初始(待匹配)模式現在被視為一種不變的結構,即代表了一個正規表示式的機器。

我們通過給初始模式三個引數(資源遊標cursor,成功指示符succ,失敗指示符fail)以啟動機器

機器的一個執行時狀態由一個上下文表示,而執行本身就是上下文的轉換

  • 當一次計算順利進行後,執行的下一步就是由succ指定。

  • 而當計算失敗,我們在fail裡儲存了之前某些需要做出選擇的地方的其他選擇,所以我們跳過去(替換上下文),繼續尋找希望。

succ和fail各自有一個終點,也就是在最初啟動時給出的那兩個。

只要我們給出字串是有限長的而機器又不會不正常地改變遊標,機器就總會執行到兩個終點中的一個。(終於找到了希望 VS 徹底沒希望了)

基本的模式

template<typename Predicate>
struct CharAtom : public AbstractPatternClass {
private :
    Predicate pred;
public :
    CharAtom(Predicate pred) : pred(pred) { }
    Context exec(cursor_succ_fail) {
        if (!cursor.is_end() && pred(cursor.get_char())) {
            cursor.step_fore();
            return make_run_ctx(cursor, succ, get_epsilon(), fail);
        }
        else {
            return fail;
        }
    }
};

struct CharIsPredicate {
private :
    const char target;
public :
    CharIsPredicate(const char &target) : target(target) { }
    bool operator () (const char &c) { return c == target; }
};

struct EpsilonAtom : public AbstractPatternClass {
public :
    Context exec(cursor_succ_fail) { return make_run_ctx(cursor, succ, undefined_pattern, fail); }
};

因為抵達基本元素時,如果成功,當前模式被消耗掉,則只剩下succ了,則用epsilon做為其後繼(佔位)。

而epsilon將undefined_pattern(它會直接返回一個特殊上下文)作為填充,希望永遠不會執行至此。(只要初始succ不會返回一個執行狀態的上下文)

組合模式

struct StarPatternClass : public AbstractPatternClass {
private :
    Pattern pattern;
public :
    StarPatternClass(Pattern pattern) : pattern(pattern) { }

    Context exec(cursor_succ_fail) {
        // try from the most longest star-pattern
        Context directly_go_to_succ = make_run_ctx(cursor, get_epsilon(), succ, fail);
        return make_run_ctx(cursor, pattern, connect(star(pattern), succ), directly_go_to_succ);
    }
};

組合模式是真正表現Context跳轉的地方,star模式會首先將匹配擴充到最大,並在這一過程中擴充失敗鏈(棧),如果後繼匹配發生失敗,則會(通過返回fail)退回到短一次的匹配成功處繼續嘗試後繼。

通過star其實就可以看出來整個匹配模型其實只是某種形式的遞迴轉迭代+棧(失敗鏈)。也就是說跟之前的版本沒有本質差別。

領悟到這一點的時候還是有些失望。

不過下面的函式還是讓我有些激動

bool run_loop(Context ctx) {
    while (ctx->get_state() == ctx->Running) {
        ctx = run_ctx_step_next(ctx);
    }

    assert(ctx->get_state() != ctx->Undefined);

    return ctx->get_state() == ctx->HaltHapply;
}

這就是這一整套機制的表層

bool RegExp::Machine::match_all(const string &source) const {
    struct CursorEndPredicate : public CursorPredicate {
    public:
        bool operator () (Cursor cursor) { return cursor.is_end(); }
    };
    Context beginning = make_run_ctx(Cursor(source), pattern, get_terminate(new CursorEndPredicate), get_halt_sadly());
    return run_loop(beginning);
}

記一些教訓

  • 雖然都大三了,還是不會很有效率的Debug,記得最開始時的問題是檔案讀取失敗導致的,我花了很久在其它部分
  • 在第一個版本里對於程式碼資源我用的Code(本身儲存程式碼,要求所有改動它的函式引用傳遞),隨後一個Bug是一個函式用了值傳遞……
  • 想當然,在第一個版本要組合一些符號串時我用了for_each,當時想自己寫一個通用的累積器,要用到二元函式,然後把binary_function拿來當父類,用ptr_fun轉換函式引數,編譯時發現沒有過載operator (),其實問題很明顯了,不過我一直沒有意識到,最後耐下心來讀msdn才發現binary_function本身確實沒有過載operator (),辜負了我的滿心信任……

學習編譯原理後的反思

*4/10/2015 8:21:28 AM *

現在編譯原理課程也在上,也在看《自制程式語言》(兩者的側重點相當不一樣)。

逐漸對自己的這個小作品有了更清晰的認識。

我所用的模式樹看來就是語法分析樹

而最後的正規表示式解析算是自底向上分析了。

而匹配是直接在分析樹上完成的,應該算是一個解釋型的語言了。

值得反思的地方

不論是分析樹還是自底向上分析都是上下文無關語言的範疇。也就是說我用大炮解決了可以用蒼蠅拍解決的問題。

這個作品完全是我在對相關內容沒有深刻了解的情況下開始的,雖然自己在這個過程中收穫了不少,不過還是要說時間花得有點不值。而且在我理解了自己所做的到底在什麼層次的時候,感覺這種閉門造車容易對自己的能力產生偏差,自我感覺過好……

相關文章