Qt 學習之路 2(51):布爾表達式樹模型

本章將會是自定義模型的最後一部分。原本打算結束這部分內容,不過實在不忍心放棄這個示例。來自於 C++ GUI Programming with Qt 4, 2nd Edition 這本書的布爾表達式樹模型的示例相當精彩,複雜而又不失實用性,所以我們還是以這個例子結束這部分內容。

這個例子是將布爾表達式分析成一棵樹。這個分析過程在離散數學中經常遇到,特別是複雜的布爾表達式。類似的分析方法可以套用於表達式化簡、求值等一系列的運算。同時,這個技術也可以很方便地分析一個表達式是不是一個正確的布爾表達式。在這個例子中,一共有四個類:

  • Node:組成樹的節點;
  • BooleanModel:布爾表達式的模型,實際上這是一個樹狀模型,用於將布爾表達式形象地呈現爲一棵樹;
  • BooleanParser:分析布爾表達式的分析器;
  • BooleanWindow:圖形用戶界面,用戶在此輸入布爾表達式並進行分析,最後將結果展現成一棵樹。

首先,我們來看看最基礎的Node類。這是分析樹的節點,也是構成整棵樹的基礎。

Node的 cpp 文件也非常簡單:

Node很像一個典型的樹的節點:一個Node指針類型的 parent 屬性,保存父節點;一個QString類型的 str,保存數據。另外,Node還有一個Type屬性,指明這個Node的類型,是一個詞素,還是操作符,或者其他什麼東西;childrenQList<Node *>類型,保存這個 node 的所有子節點。注意,在 Node 類的析構函數中,使用了qDeleteAll()這個全局函數。這個函數是將 [start, end) 範圍內的所有元素進行 delete 運算。因此,它的參數的元素必須是指針類型的。並且,這個函數使用 delete 之後並不會將指針賦值爲 0,所以,如果要在析構函數之外調用這個函數,建議在調用之後顯示的調用clear()函數,將列表所有元素的指針重置爲 0。

雖然我們將這個例子放在自定義模型這部分,但實際上這個例子的核心類是BooleanParser。我們來看一下BooleanParser的代碼:

這裏我們一次把BooleanParser的所有代碼全部列了出來。我們首先從輪廓上面來看一下,BooleanParser作爲核心類,並沒有摻雜有關界面的任何代碼。這是我們提出這個例子的另外一個重要原因:分層。對於初學者而言,如何設計好一個項目至關重要。分層就是其中一個重要的設計手法。或許你已經明白了 MVC 架構的基本概念,在這裏也不再贅述。簡單提一句,所謂分層,就是將程序的不同部分完全分離。比如這裏的BooleanParser 類,僅僅是處理Node的節點,然後返回處理結果,至於處理結果如何顯示,BooleanParser不去關心。通過前面我們瞭解到的 model/view 的相關知識也可以看出,這樣做的好處是,今天我們可以使用QAbstractItemModel 來顯示這個結果,明天我發現圖形界面不大合適,我想換用字符界面顯示——沒問題,只需要替換掉用於顯示的部分就可以了。

大致瞭解了BooleanParser的總體設計思路(也就是從顯示邏輯完全剝離開來)後,我們詳細看看這個類的業務邏輯,也就是算法。雖然算法不是我們這裏的重點,但是針對一個示例而言,這個算法是最核心的部分,並且體現了一類典型的算法,豆子覺得還是有必要了解下。

注意到BooleanParser 類只有一個公共函數,顯然我們必須從這裏着手來理解這個算法。在Node * parse(const QString &)函數中,首先將傳入的布爾表達式的字符串保存下來,避免直接修改參數(這也是庫的接口設計中常見的一個原則:不修改參數);然後我們將其中的空格全部去掉,並將 pos 設爲 0。pos 就是我們在分析布爾表達式字符串時的當前字符位置,起始爲 0。之後我們創建了 Root 節點——布爾表達式的樹狀表達,顯然需要有一個根節點,所以我們在這裏直接創建根節點,這個根節點就是一個完整的布爾表達式。

首先我們先來看看布爾表達式的文法:

這是一個相對比較完整的布爾表達式文法。這裏我們只使用其中一部分:

從我們簡化的文法可以看出,布爾表達式 BE 可以由 BE | BE、BE AND BE、NOT BE、(BE) 和 Identifier 五部分組成,而每一部分都可以再遞歸地由 BE 進行定義。

接下來看算法的真正核心:我們按照上述文法來展開算法。要處理一個布爾表達式,或運算的優先級是最低,應該最後被處理。一旦或運算處理完畢,意味着整個布爾表達式已經處理完畢,所以我們在調用了 addChild(node, parseOrExpression())之後,返回整個 node。下面來看parseOrExpression()函數。要想處理 OR 運算,首先要處理 AND 運算,於是parseOrExpression()函數的第一句,我們調用了parseAndExpression()函數。要想處理 AND 運算,首先要處理 NOT 運算,於是parseAndExpression()的第一句,我們調用了parseNotExpression()函數。在parseNotExpression()函數中,檢查第一個字符是不是 !,如果是,意味着這個表達式是一個 NOT 運算,生成 NOT 節點。NOT 節點可能會有兩種不同的情況:

  1. 子表達式(也就是用括號包圍起來的部分,由於這部分的優先級最高,所以看做是一個完整的子表達式),子表達式是原子性的,需要一個獨立的處理,也要生成一個節點,其分隔符是 ( 和 )。( 和 ) 之間又是一個完整的布爾表達式,回憶一下,一個完整的布爾表達式最後要處理的部分是 OR 運算,因此調用parseOrExpression()函數進行遞歸。
  2. 標識符(如果 ! 符號後面不是 ( 和 ),則只能是一個標識符,這是布爾表達式文法決定的),我們使用parseIdentifier()函數來獲得這個標識符。這個函數很簡單:從 pos 位置開始一個個檢查當前字符是不是字母,如果是,說明這個字符是標識符的一部分,如果不是,說明標識符已經在上一個字符的位置結束(注意,是上一個字符的位置,而不是當前字符,當檢測到當前字符不是字母時,說明標識符已經在上一個字母那裏結束了,當前字母不屬於標識符的一部分),我們截取 startPos 開始,pos – startPos 長度的字符串作爲標識符名稱,而不是 pos – startPos + 1 長度。

NOT 節點處理完畢,函數返回到parseAndExpression()。如果 NOT 節點後面是 &&,說明是 AND 節點。我們生成一個 AND 節點,把剛剛處理過的 NOT 節點添加爲其子節點,如果一直找到了 && 符號,就要一直作爲 AND 節點處理,直到找到的不是 &&,AND 節點處理完畢,返回這個 node。另一方面,如果 NOT 節點後面不是 &&,說明根本不是 AND 節點,則直接把剛剛處理過的 NOT 節點返回。函數重新回到 parseOrExpression() 這裏。此時需要檢查是不是 ||,其過程同 && 類型,這裏不再贅述。

這個過程看起來非常複雜,實際非常清晰:一層一層按照文法遞歸執行,從最頂層一直到最底層。如果把有限自動機圖示畫出來,這個過程非常簡潔明瞭。這就是編譯原理的詞法分析中最重要的算法之一:遞歸下降算法。由於這個算法簡潔明瞭,很多編譯器的詞法分析都是使用的這個算法(當然,其性能有待商榷,所以成熟的編譯器很可能選擇了其它性能更好的算法)。最後,如果你覺得對這部分理解困難,不妨跳過,原本有關編譯原理的內容都比較複雜。

最複雜的算法已經完成,接下來是BooleanModel 類:

BooleanModel類繼承了QAbstractItemModel。之所以不繼承QAbstractListModel或者QAbstractTableModel,是因爲我們要構造一個帶有層次結構的模型。在構造函數中,我們把根節點的指針賦值爲 0,因此我們提供了另外的一個函數setRootNode(),將根節點進行有效地賦值。而在析構中,我們直接使用 delete 操作符將這個根節點釋放掉。在setRootNode()函數中,首先我們釋放原有的根節點,再將根節點賦值。此時我們需要通知所有視圖對界面進行重繪,以表現最新的數據:

直接繼承QAbstractItemModel類,我們必須實現它的五個純虛函數。首先是index()函數。這個函數在QAbstractTableModel或者QAbstractListModel中不需要實現,因此那兩個類已經實現過了。但是,因爲我們現在繼承的QAbstractItemModel,必須提供一個合適的實現:

index()函數用於返回第 row 行,第 column 列,父節點爲 parent 的那個元素的QModelIndex對象。對於樹狀模型,我們關注的是其 parent 參數。在我們實現中,如果 rootNode 或者 row 或者 column 非法,直接返回一個非法的QModelIndex。否則的話,使用nodeFromIndex()函數取得索引爲 parent 的節點,然後使用children屬性(這是我們前面定義的Node裏面的屬性)獲得子節點。如果子節點不存在,返回一個非法值;否則,返回由createIndex()函數創建的一個有效的QModelIndex對象。對於具有層次結構的模型,只有 row 和 column 值是不能確定這個元素的位置的,因此,QModelIndex中除了 row 和 column 之外,還有一個void *或者 int 的空白屬性,可以存儲一個值。在這裏我們就把父節點的指針存入,這樣,就可以由這三個屬性定位這個元素。這裏的createIndex()第三個參數就是這個內部使用的指針。所以我們自己定義一個nodeFromIndex()函數的時候要注意使用QModelIndexinternalPointer()函數獲得這個內部指針,從而定位我們的節點。

rowCount()columnCount()兩個函數相對簡單:

對於rowCount(),顯然返回的是 parentNode 的子節點的數目;對於columnCount(),由於我們界面分爲兩列,所以始終返回 2。

parent()函數返回子節點所屬的父節點的索引。我們需要從子節點開始尋找,直到找到其父節點的父節點,這樣才能定位到這個父節點,從而得到子節點的位置。而data()函數則要返回每個單元格的顯示值。在前面兩章的基礎之上,我們應該可以很容易地理解這兩個函數的內容。headerData()函數返回列頭的名字,同前面一樣,這裏就不再贅述了:

最後是我們定義的一個輔助函數:

相關文章
相關標籤/搜索