在之前的一篇部落格中,曾經用clang提供的庫LibTooling
編寫了一個簡單的匯出iOS程式碼中函式呼叫關係圖的工具,然而這種實現方式存在一些很明顯的缺點:
- 在分析一個工程中的單個程式碼檔案時,無法得知定義在其他檔案中的類或方法,導致生成的語法樹節點缺失,對最終的結果造成不小的影響。
- 在解析時clang會進行預處理,導致最終生成的結果可能包括一些外部系統庫的函式,這對於我們來說是無用的資訊(當然這個應該是我的使用姿勢問題)。
- 無法支援swift。swift編譯器的前端並不是clang,而這個工具是基於clang的庫來開發的,所以也就沒有支援swift的可能。
由於這幾個缺點(主要是第三點,因為在日常工作中還是以swift為主),後來也沒有再繼續使用和完善。直到最近因為工作上的安排,需要維護一份較為陳舊的程式碼,面對動輒數千行的程式碼檔案,覺得還是需要一個比較趁手的工具來輔助閱讀。前段時間正好恰逢國慶長假,抽空用swift重新寫了一個工具:drafter,如名字所示,它的目的在於生成描述程式碼的草圖。
Drafter是什麼
- Drafter是一個命令列工具,用於分析iOS工程的程式碼,支援Objective-C和Swift。
- 自動解析程式碼並生成方法呼叫關係圖。
- 自動解析程式碼並生成類繼承關係圖。
安裝和使用
完整的程式碼在這裡:github.com/L-Zephyr/Dr…
這裡提供了一個快速安裝的指令碼,在shell中執行指令:
curl "https://raw.githubusercontent.com/L-Zephyr/Drafter/master/install.sh" | /bin/sh
複製程式碼
drafter
程式會自動安裝到 /usr/local/bin 目錄中,之後直接在終端使用即可。
具體使用方法請檢視使用介紹
實現原理
注:解析器部分後來已用parser combinator重構,文章所講述的程式碼對應於0.1.0的tag
在之前的做法中對原始碼的解析全交給clang,只對生成的AST做處理,這其實是一種比較偷懶的做法,對最後生成的結果不可控,而且也斷了支援swift的可能。為了獲得更優化的輸出並同時支援Swift和OC,原始碼解析這一步還是得自己來做。幸運的是我們只需要解析類、方法定義、方法呼叫這幾塊,實際工作並不是很複雜。
詞法解析
詞法解析是程式編譯的第一步,所謂詞法解析就是將程式碼分割成一系列的詞法單元。詞法單元是一個有特殊意義的標記,也是語法分析程式在處理原始碼時的最小單元。比如說一個簡單的賦值表示式int i = 3
,在經過詞法分析之後被處理成了一系列的詞法單元:int
、i
、=
、3
。
struct Token {
var type: TokenType
var text: String
}
enum TokenType {
case endOfFile // 檔案結束
case name // 變數名
case colon // 冒號
case comma // 逗號
...
}
複製程式碼
先定義一個名為Token
的結構體,用來表示詞法單元,其中列舉值type
用來表示詞法單元的型別,text
儲存該詞法單元的原始資料,如:對於一個變數n,它在解析成Token之後type為.name
,text為n
。由於我們的目的只是解析類和方法,所以這裡只定義了在類和方法的定義中會用到的詞法單元型別,對於那些我們不關心的詞法則一概忽略。
詞法解析器會將任何輸入的原始碼解析成詞法單元流,對於上層使用者來說就像是迭代器一樣遍歷詞法單元直到檔案結束,所以這裡可以定義一個基本的詞法解析器型別,只有一個計算屬性nextToken
,用來獲取下一個詞法單元:
protocol Lexer {
var nextToken: Token { get }
}
複製程式碼
語法解析
在經過第一步的詞法分析將原始碼分割成帶有型別的詞法單元之後,就可以進入語法解析的階段了。要分析一段程式,如表示式1 + 2
,我們是無法直接從字面上來處理的,必須將其轉換成某種可以處理的中間形式,這就是語法解析要做的事情。語法解析器根據語言的文法規則掃描詞法單元流,同時生成中間表示形式(IR),通常來說會生成一棵抽象語法樹(AST),之後的語義分析階段會基於這一步生成的AST進行分析。Drafter只處理到語法解析這一步,僅對程式碼中的類、方法定義和方法呼叫進行解析,解析後生成的資料結構也比較簡單。
語言的文法描述
程式是由多個有效的表示式組成的,我們要做的就是將這些符合特定規則的式子識別出來,語言特定的語法規則稱為這門語言的文法,這種規則可以用一種DSL來描述(BNF正規化)。
舉個例子(來源於《程式語言實現模式》一書),對於一個可以包含任意字母的列表宣告如[a, b, c]
,它的文法規則描述如下:
list = '[' elements ']'; // 單引號之間的內容直接匹配
elements = elemenet (',' element)*; // *表示0個或多個
element = NAME | list; // |表示或,元素可能是另一個列表
NAME = ('a'..'z' | 'A'..'Z')+; // +表示一個或多個
複製程式碼
上面每一條式子都描述了一條文法規則,這裡將詞法規則和文法規則做了區分,文法規則的名稱小寫,詞法規則的名稱大寫。像list
這樣的規則稱為產生式,它可以繼續向下推導,如list
會產生elements
。另外有一些被單引號包圍的符號,這樣的符號是實際要匹配的內容,稱為終結符,因為它無法再繼續往下推導了。
這個文法描述了一個列表宣告的語法,每個規則都包含一個或多個解析選項,多個解析選項通過|
符號分隔。上面宣告瞭三個文法規則和一個詞法規則:詞法規則NAME
匹配包含至少一個字母的詞法單元;list
規則表示列表必須由中括號包圍,並至少包含一個元素,多個元素之間用逗號分隔,元素可以是一個變數也可以是另一個列表宣告。
有了明確的文法規則定義我們才能夠去編寫語法解析器,對Objective-C的文法我參考了這裡。
遞迴下降分析法
定義了語法的結構和相關的詞法單元之後,在解析時只需要識別出相應的式子即可,簡單來說解析器的工作就是:遇到某種結構,就執行某種操作。具體到實現上,我們為每一種文法規則提供一個專用匹配函式,對於詞法規則則統一用match
函式來匹配:
@discardableResult
func match(_ t: TokenType) throws -> Token // 匹配指定型別詞法單元,匹配成功返回該詞法單元
複製程式碼
對於上面那個列表的例子,可以編寫如下用於識別的函式:
func list() throws
func elements() throws
func element() throws
複製程式碼
每個函式都識別一個特定的子結構,並且可能會呼叫其他的識別函式或遞迴呼叫自身。在識別時從起始的詞法單元開始,自上而下進行推導。所以這種分析的方法也被稱為遞迴下降分析法,以這種方法編寫的解析器稱為LL解析器。第一個L表示解析內容的輸入順序是從左到右,第二個L表示解析時也是從左向右進行推導(最左推導)。
對於上面的element
規則,它可能匹配一個變數名或是另一個列表,在進入element
函式時需要先進行判斷,所幸list
規則始終以[
符號開始,變數的規則始終以字母開始,只需要檢查當前的詞法單元型別就可以做出判斷:
func element() throws {
if currentToken.type == .leftSquare {
try list()
} else {
try match(.name)
}
}
複製程式碼
在這個列表的文法規則中,從當前的位置開始只需要檢查一個詞法單元的型別就可以做出決斷,像這樣的文法稱為LL(1)
文法,相應的解析器稱為LL(1)解析器,1表示該解析器只能從解析位置向前檢視一個詞法單元,通常這個詞法單元被稱為前瞻符號(lookahead)。
LL(k)解析器
LL(1)解析器十分簡單,但是解析能力不足。比如在上面列表語法的例子中,為列表的元素新增一個賦值的操作:[a, b = c, d]
,這樣一來,element
規則就變成了:
element = NAME
| NAME '=' NAME
| list
複製程式碼
element
文法中有兩個解析選項都是以詞法單元NAME
開頭的,僅檢視一個詞法單元無法確定,在解析時需要向前檢查更多的詞法單元,也就是說這個語法不再是LL(1)的了。
在實際解析時情況比這裡要複雜很多,可能需要向前檢檢視多個詞法單元才能確定解析策略,所以需要構建一個能夠根據需要檢視任意多符號的解析器,也就是LL(k)解析器。目前在應用上有一些能夠根據特定DSL自動生成解析器的工具,如Antlr等,但是考慮通過DSL生成的程式碼並不是特別便於除錯,而且Drafter只是做了一些非常簡單的解析工作,所以還是自己編寫了一個簡單的LL(k)解析器。在Drafter中提供一個這樣一個基礎的解析器:
class BacktrackParser: Parser {
init(lexer: Lexer) {
self.input = lexer
}
func token(at index: Int = 0) -> Token {
...
}
...
}
複製程式碼
以一個詞法解析器(Lexer)作為初始化引數,token()
方法提供從當前位置開始向前檢視任意位置詞法單元的能力,而具體的文法規則解析則通過各個子類化的解析器來完成。Objective-C和Swift的程式碼通過不同的解析器來進行,解析完成後輸出相同的資料結構,如表示型別的節點:
class ClassNode: Node {
var superCls: ClassNode? = nil // 父類
var className: String = "" // 類名
var protocols: [String] = [] // 實現的協議
}
複製程式碼
在將所有關心的語法節點資訊解析出來之後,剩下的就是對這些資訊進行處理和展示了。Drafter中提供了一些對語法節點進行過濾和搜尋的選項,通過提供的引數過濾出感興趣的資訊,最後將這些資料傳遞給DotGenerator
類,這個類的作用是根據節點資訊生成Dot
語言(一種描述圖形的語言)的程式碼,傳遞給Graphviz生成圖片。
方法呼叫解析
單獨討論一下對於方法呼叫的解析,首先為方法呼叫定義一個語法節點型別:
enum MethodInvoker {
case name(String) // 普通變數
case method(MethodInvokeNode) // 另一個方法呼叫
}
class MethodInvokeNode: Node {
var isSwift: Bool = false
var invoker: MethodInvoker = .name("") // 呼叫者
var params: [String] = [] // 引數名
var methodName: String = ""
}
複製程式碼
一個方法的呼叫者可能是一個變數,也可能是另一個方法呼叫的返回值(鏈式呼叫),所以invoker
被定義為一個列舉值。
OC方法呼叫的Parser由類ObjcMessageSendParser
實現,swift方法呼叫的Parse由類SwiftInvokeParser
實現。以OC為例,對於這樣的簡單呼叫:
[self.view insertSubview:subview atIndex:0];
複製程式碼
匹配的結果為:[self.view insertSubview: atIndex:]
,忽略引數的具體內容。對於鏈式的方法呼叫:
[[self objectAtIndex: 1] doSomethingWith: param];
複製程式碼
解析的結果只保留一個鏈式呼叫的表示:[[self objectAtIndex:] doSomethingWith:]
,而不是objectAtIndex:
和doSomethingWith:
。
而對於一些更加複雜的形式,如引數為一個Block的定義,Block中還呼叫了其他方法,如:
[Post globalTimelinePostsWithBlock:^(NSArray *posts, NSError *error) {
if (!error) {
self.posts = posts;
[self.tableView reloadData];
}
}];
複製程式碼
先看看對於OC方法呼叫文法的一個簡單定義:
message_send = '[' receiver param_list ']'
receiver = message_send | NAME
param_list = NAME | (NAME ':' param)+
param = ...
複製程式碼
方法呼叫中具體的引數是通過規則param
來解析的,param
要知道自己當前是否位於另一個閉包或是其他子結構中,這樣才能在正確的時機結束匹配,這一步可以通過計算左右括號的數量來判斷,param
在碰到另一個方法呼叫語句時進入message_send
規則並將結果新增到最後的匹配結果中,虛擬碼如下:
func param() throws {
while 檔案未結束 {
if 不在子結構中 && 引數匹配結束 {
return
}
if isMessageSend() {
try messageSend() // 匹配方法呼叫
儲存到最終的匹配結果中
continue
}
consume()
}
}
複製程式碼
後記
以上就是Drafter實現的基本思路,開頭提到的三個問題基本上得到了解決。在這段時間的工作中Drafter給了我不少幫助,至少當我在面對一個這樣的程式碼檔案
以及動輒數百行的方法時不再那麼頭疼,匯出指定方法的呼叫流可以更迅速的理清程式碼邏輯上的關係:
之後如果有需要的話會為Drafter新增更多的功能、增強解析能力等,希望這個小工具能稍微減輕你在閱讀程式碼時的負擔?。