pyparsing語法解析心得

    一直想總結一年來開發維護導表工具的心得,卻由於懶或者項目緊而長期擱置着。最近一個milestone結束以後,有了短暫的空閒調整期,正好趁着這段時間系統得整理一下,也算是一種備份,方便之後查找。

    開發起始,花了必定的時間調研尋找一個好的語法解析器,由於在表格安全性檢查過程當中須要解析各類形式靈活的檢查規則,因此須要一個相似lex/yacc這樣具備強大語言解析功能,但語法規則又能夠輕量級配置的解析器,最後選擇了一種近似上下文無關CFG(context-free-grammar)的語言PEG(parsing-expression-grammar),做爲咱們編寫檢查規則的基礎文法。 python

  • PEG文法
    要了解PEG文法的演變,首先得從CFG上下文無關文法講起。對CFG文法的理解一直都停留在理論層面上,而語言{a nb n, n >= 1}(例子1)則是絕大多數人首先可以想起的一個上下文無關文法的典型例子。對於CFG的詳細定義能夠從通常計算理論書上找到,這裏就再也不累贅。就我我的理解,上下文無關語言就是對有限肯定集合中的元素,按照有限的遞歸級聯合並規則組合而成的語言,譬如例子1中的語言所對應的有限肯定集合就是
V = {a, b},
有限遞歸級聯合並規則就是
S <-- ab

S <-- aSb 正則表達式

    這兩條規則產生的語言S 就等於{a nb n, n >= 1}。而語言{a nb n c n, n >= 1}雖然也具有有限的肯定元素集合,但不能由遞歸級聯合並規則所產生,因此其不屬於上下文無關語言。 算法

    然而上下文無關文法(CFG)卻由於其規則的靈活性和語法解析的多樣性,在工程運用上存在着障礙。CFG文法屬於一種能夠自頂向下解析的語言,若是一個字符串屬於一種CFG語言,那麼能夠將這個字符串自頂向下逆向推導出構建的過程,產生一個語法解析樹,而對於大多數CFG語言來講,其合法字符串能夠被解析成多個不一樣結構的語法樹,譬如以下的CFG語言(例子2)
S <-- a

S <-- S+S express

解析字符串a+a+a就會獲得兩種不一樣的語法解析樹,以下所示: 緩存

        S
       / | \
      /  |  \
     /   |   \
     S  +   S
    |       / | \
    |      /  |  \
    a     S  +  S
           |       |
           a      a
       S
     / | \
     /  |  \
    /   |   \
    S  +   S
   / | \      |
   /  |  \    |
  S  +  S   a
  |       |
  a      a
正是爲了消除 CFG 語言在語義上的歧義性,工程中引入了 PEG 這種也能夠自頂向下解析的語言。 PEG 在儘可能保持 CFG 語言解析能力的同時,還能夠保證語法解析樹的惟一性,計算理論對 PEG CFG 之間的差別進行了詳細的闡述。就我的理解而言, PEG 的本質就是對 CFG 語言的級聯 規則作了相應的限制,使 PEG 在解析語言的時候有了必定的順序性 (ordering) ,從而保證了語法解析樹的惟一性。
相比靈活的 CFG 語法級聯規則, PEG 只支持如下的幾種級聯操做:
•       Sequence:   e 1  e 2
•       Ordered choice:   e 1 | e 2
•       Zero-or-more:   e*
•       One-or-more:   e+
•       Optional:   e?
對於上面提到的 CFG 語法(例子 2 ),只需將對應的規則進行稍稍的修改就能夠轉化成 PEG 文法(例子 3 ):
S1 <-- a
S2 <--  S3
S3 <-- (S1 | S2 ) + ZeroOrMore( + + (S1 | S2))
對於 a+a+a,就只能解析成爲以下語法解析樹:

         S3
         /  \
        /    \
     S1   ZeroOrMore(+ (S2 | S1)
       |       /  \
       |      /    \ 安全

      a     +    S2
                    |
                    |  數據結構

                    S3
                   /  \
                  /    \ 
               S1   ZeroOrMore(+ (S2 | S1) 
                |       /  \ 
                |      /    \ 
                a     +    S1 
                              |
                              |
                              a 架構

    因爲PEG語言自己對級聯規則的限制性,消除了語法解析過程當中的歧義性,並且理論上能夠保證絕大多數CFG語言,只要稍加修改相應的語法規則均可以轉換成相應的PEG語言(對於存在左遞歸的CFG語言沒法用PEG語法來解析),而且能夠保證解析過程的惟一性,這樣就爲工程的應用提供了便利性。 less

  • pyparsing 分析

    如今已經有不少用不一樣程序語言開發的開源庫來支持解析peg語言,因爲項目自己的緣由,咱們選擇使用了python實現的開源庫pyparsing(1.5.6)pyparsing的架構主要有兩大部分組成,分別爲用來表示解析結果的ParseResults和表示解析規則和解析算法的ParserElement,前者表示語法解析過程當中所操做的數據結構,後者表示具體的語法解析流程。 ide

      1. 數據結構

    ParseResults是算法所操做的基本數據結構,用來表示解析字符串後所獲得語法解析樹。單個Parseresults的數據結構用來表示一棵樹的某個節點,內部還能夠包含相應子節點ParseResults結構。一個節點ParseResults的子節點序列用一個list來表示,能夠經過訪問序號來獲取;同時爲了快速的定位子樹中一些有用的子節點,還能夠將ParseResults按照dict來操做,經過字符串key快速定位。對於以某個ParseResults節點(PS)爲根的解析樹,全部該層子節點能夠經過鏈表的形式獲得,如PS[0],PS[1],…;對於在語法表達式中用setResultsName(‘xxx’)定義的token,均可以用字典以key爲索引的方式獲得,如PS[‘xxx’]

ParseResults結構以下所示:

class ParseResults:
member: __toklist
member: __tokdict
member: __accumNames
member: __parent

其中__toklist主要維護了子節點的鏈表結構,__tokdict維護了字典結構,__accumNames保存了__tokdict中非modal形式(多個相同keyvalue只取當前最後一個)的key值集合,__parent指向了父節點。其中__toklist中的元素能夠是string或者做爲子節點ParseResults結構;__tokdictkeystringvalueParseResultsWithOffset的數據結構,ParseResultsWithOffset是一個包含有兩個元素的pair tupletuple[0]__toklist中的元素,tuple[1]爲該元素在__toklist中的index,這樣該元素便可以經過PS[index]來提取,也能夠經過PS[key]來提取。下圖表示在一個ParseResults中在index爲2的位置插入keyYYY的節點E


   ParseResults主要經過重載python中的__getitem____setitem___delitem____iadd__這三個方法來統一實現了listdict相應操做。

# 重載[]操做
def __getitem__( self, i ):
    if isinstance( i, (int,slice) ):
        # 若是i是整數或者slice,則看成鏈表操做
        return self._toklist[i]
    else:
	# 若是傳入的i是str類型的key,則看成字典操做
        if i not in self._accumNames:
            # 當parseresult在建立時,參數modal爲True時,則返回values中的最後一個
            return self._tokdict[i][-1][0]
        else:
	    #非modal形式,則將全部values按照一個list返回
            return ParseResults([ v[0] for v in self._tokdict[i] ])
# 重載[]操做
def __setitem__( self, k, v, isinstance=isinstance ): 
    if isinstance(v,_ParseResultsWithOffset):
        #字典操做非modal模式下,放在全部values的末尾
        self._tokdict[k] = self._tokdict.get(k,list()) + [v]
        sub = v[0]
    elif isinstance(k,int):
	# 若是key爲int就代表鏈表操做,直接取代原來的value
        self._toklist[k] = v
        sub = v
    else:
	# 組裝ParseResultsWithOffset做爲value
        self._tokdict[k] = self._tokdict.get(k,list()) + [_ParseResultsWithOffset(v,0)]
        sub = v
    if isinstance(sub,ParseResults):
	# 申明父節點
        sub._parent = wkref(self)
# 重載+=操做,其中 + 操做也是由+=來實現
def __iadd__( self, other ):
    if other._tokdict:
        # 合併dict的操做
        offset = len(self._toklist)
        addoffset = ( lambda a: (a<0 and offset) or (a+offset) )
        otheritems = other._tokdict.items()
        otherdictitems = [(k, _ParseResultsWithOffset(v[0],addoffset(v[1])) )
                                for (k,vlist) in otheritems for v in vlist]
        for k,v in otherdictitems:
            self[k] = v
            if isinstance(v[0],ParseResults):
                v[0]._parent = wkref(self)
    # 合併list的操做
    self._toklist += other._toklist
    # 更新key的集合
    self._accumNames.update( other._accumNames )
    return self

def __delitem__( self, i )在當前版本中的實現是錯誤的,當以dict的方式進行刪除時,即傳入的i不爲int的狀況,只簡單地刪除dict中的值,而沒有更新列表中剩餘的elements的序號,即index值,好在peg的解析過程並不涉及dict刪除狀況。

爲了更好的闡述ParseResults數據結構,對上面的語法(例子3)稍做改變,添加了對括號對稱檢查的語法解析(例子4)

S1 <-- Literal('a')

S2 <-- Literal('(') + S3 + Literal(')')

S3 <-- (S2 | S1 ) + ZeroOrMore(Group(Literal('+') + (S2 | S1)).setResultsName(‘right token'))

則字符串(a + a) + a解析的結果爲

__tokdict : {'right token': [((['+', 'a'], {}), 2), ((['+', 'a'], {}), 4)]}

__toklist : ['(', 'a', ['+', 'a'], ')', ['+', 'a']]

圖示以下:

2. 算法流程:

ParserElement是解析peg語法的基本組成元素,由有限肯定集合(token)和相應的限制級聯規則如 Zero-or-moreOne-or-moreForward等所組成,能夠將這類級聯規則當作例子4裏面的語法規則。ParserElement的大體架構圖以下:

    圖中Token表示有限肯定集合和簡單的級聯規則如Sequence, ParseExpression表示單元或者雙元級聯規則如AndOrParseElementEnhance表示具備遞歸性的級聯規則如Forward,正是因爲ParseElementEnhance這類語法的引入,才加強了PEG的解析能力,將其與正則表達式的解析區分開來。

ParserElement的每一個繼承類型都實現了相應的匹配規則,匹配規則的實現都是由函數parseImpl來定義的:

def parseImpl( self, instring, loc, doActions=True )

    其中instring表示須要匹配的字符串,loc表示開始匹配的字符串位置,doActions表示在匹配成功以後執行操做函數,通常有用戶提供,而且這些操做的定義將直接影響到後續語法解析過程可否進行優化,這將在後面說明。函數將返回匹配終止時指針所在的index和表示相應匹配結果的ParseResults

    Token中的各個匹配規則相對比較簡單,包括簡單的字符串匹配(Literal,Keyword),正則表達式匹配(Reg)等,以Keyword爲例,算法只要求兩個字符串相同:

def parseImpl( self, instring, loc, doActions=True ):
    if self.caseless:
        # 若是忽略大小寫
        if ( (instring[ loc:loc+self.matchLen ].upper() == self.caselessmatch) and
                 (loc >= len(instring)-self.matchLen or instring[loc+self.matchLen].upper() not                  in self.identChars) and
                 (loc == 0 or instring[loc-1].upper() not in self.identChars) ):
             return loc+self.matchLen, self.match
    else:
        if (instring[loc] == self.firstMatchChar and
            # 真正的匹配過程,第一個字符相等(優化),字符串匹配startswith
                (self.matchLen==1 or instring.startswith(self.match,loc)) and
                (loc >= len(instring)-self.matchLen or instring[loc+self.matchLen] not in self.identChars) and
                (loc == 0 or instring[loc-1] not in self.identChars) ):
             return loc+self.matchLen, self.match
        #~ raise ParseException( instring, loc, self.errmsg )
        # 匹配失敗的狀況
        exc = self.myException
        exc.loc = loc
        exc.pstr = instring
        raise exc

    加強型級聯規則的引入特別是Forward等遞歸類型的引入,加強了peg語言的解析能力,咱們都知道CFG的語言表達性要強於正則表達式,是由於CFG中引入了一種遞歸級聯的能力,而Forward正是爲PEG注入了這種遞歸能力,咱們就以Forward爲例來看一下遞歸匹配規則:

def parseImpl( self, instring, loc, doActions=True ):
    if self.expr is not None:
        # 遞歸調用包含規則   
        return self.expr._parse( instring, loc, doActions, callPreParse=False )
    else:
        raise ParseException("",loc,self.errmsg,self) 

Forward的parseImpl繼承於ParseElementEnhance,只是遞歸地調用了子表達式expr的匹配規則,但其真正的亮點是它重載了python中的<<操做符

def __lshift__( self, other ):
    if isinstance( other, basestring ):
        other = Literal(other)
        # 經過這個賦值,實現了遞歸語法定義,能夠在other中包含本身的表達式,但不能出現左遞歸,不然程序將陷入死循環,這也是peg語言惟一的限制條件
    self.expr = other
    …
    return None

    分析了ParserElement的組成元素和相應的匹配規則,下面來說講PEG的解析算法。PEG的解析算法是一個簡單遞歸深度優先解析過程,遞歸窮舉全部匹配過程,若是匹配就繼續深刻解析,若是窮舉完全部可能匹配規則後失敗,則回溯深度嘗試其餘匹配可能。

匹配算法入口是:
def parseString( self, instring, parseAll=False ):
    # 重置匹配cache,爲parse rat全部
    ParserElement.resetCache()
    if not self.streamlined:
        self.streamline()
    #~ self.saveAsList = True
    for e in self.ignoreExprs:
        e.streamline()
    if not self.keepTabs:
        instring = instring.expandtabs()
    try:
        # 遞歸調用各個子ParserElement相應的匹配規則
        loc, tokens = self._parse( instring, 0 )
        if parseAll:
            # 要整個字符串徹底匹配的狀況
            se = Empty() + StringEnd()
            se._parse( instring, loc )
    except ParseBaseException:
        if ParserElement.verbose_stacktrace:
            raise
        else:
            # catch and re-raise exception from here, clears out pyparsing internal stack trace
            exc = sys.exc_info()[1]
            raise exc
    else:
        return tokens

其中_parse函數就是咱們剛纔分析的各個組成元素的parseImpl函數。若是這個element是ParseElementEnhance,則會在相應的parseImpl函數中遞歸調用各個子規則的parseImpl函數。

因爲深度優先解析算法經過向前(look ahead)和回溯(look backward)嘗試全部匹配可能,因此在最差的狀況下會呈指數級時間複雜度。工程上爲了優化解析的效率,引入了packrat parser算法,其本質是緩存全部的中間解析結果,節省了重複解析的時間,在最好的狀況下能夠達到線性的時間複雜度。在pyparsing中能夠經過設置標誌位

_packratEnabled = True

來激活packrat parser算法,這樣各個ParserElementparseImpl匹配規則被封裝,以下所示:

# this method gets repeatedly called during backtracking with the same arguments -
    # we can cache these arguments and save ourselves the trouble of re-parsing the contained expression
    def _parseCache( self, instring, loc, doActions=True, callPreParse=True ):
        lookup = (self,instring,loc,callPreParse,doActions)
        if lookup in ParserElement._exprArgCache:
            # 嘗試着從cache中找匹配結果
            value = ParserElement._exprArgCache[ lookup ]
            if isinstance(value, Exception):
                raise value
            return (value[0],value[1].copy())
        else:
            try:
                #在沒有找到的狀況下,才真正作解析流程
                value = self._parseNoCache( instring, loc, doActions, callPreParse )
                ParserElement._exprArgCache[ lookup ] = (value[0],value[1].copy())
                return value
            except ParseBaseException:
                pe = sys.exc_info()[1]
                ParserElement._exprArgCache[ lookup ] = pe
                Raise
packrat parser算法雖然提升瞭解析算法的效率,但也對解析算法作了必定的限制,其要求在解析過程當中全部匹配成功後用戶所定義的動做函數(經過 setParseAction來定義)不能修改原始的字符串,不然 cache將會出錯。同時 packrat parser算法是一個空間換時間的算法,過程當中 cache將消耗不少內存,爲此 pyparsing也作了必定的優化,對於每個 ParseResults都重載了 __new__函數 ,限制產生ParseResults實體的個數,代碼以下:
def __new__(cls, toklist, name=None, asList=True, modal=True ):
    if isinstance(toklist, cls):
        # 空間優化,若是是解析的中間結果,將不產生新的parserresults,只是重用和修改原有的instance
        return toklist
    retobj = object.__new__(cls)
    retobj.__doinit = True
    return retobj
pyparsing大概就分析到這裏,其實以後還有不少的工做花在如何用好PEG這個語言,發揮其強大的解析功能用於導表的安全性檢查中,固然這就是另一個話題了。
相關文章
相關標籤/搜索