AST模組其實要寫的話,100篇都寫不完,我將一些簡單知識點翻譯成JavaScript程式碼來進行講解(v8內部的複雜性永遠都能超出我的意料,現在看到萬行的原始碼都已經沒感覺了),如果誰想看C++原始碼,就去翻我前面的流水賬。
先寫幾個結論。
- 抽象語法樹內部有嚴格的分類,比如繼承於AstNode的語句Statement、表示式Expression、宣告Declaration等等,當判定對應詞法的型別,會有一個工廠類專門生成對應型別的描述類。
- v8內部有一個名為string_table_的hashmap快取了所有字串,轉換抽象語法樹時,每遇到一個字串,會根據其特徵換算為一個hash值,插入到hashmap中。在之後如果遇到了hash值一致的字串,會優先從裡面取出來進行比對,一致的話就不會生成新字串類。
- 抽象語法樹解析的判定優先順序依次為Declaration(let a = 1)、Statement(if(true) {})、Expression("a" + "b"),其中還有一個非常特殊的語法型別是goto,即label語法,我只能說盡量不要用這個東西,v8為其專門寫了特殊的解析,非常複雜。
- 每一個大型別(例如Statement)也會有非常詳細的子型別,比如if、while、return等等,當前解析詞法不匹配對應型別,會進行降級解析。
- 快取字串時,會分為三種情況處理,長度為1的單字元、長度為2-10的且值小於2^32 - 2的純數字字串、其他字串,僅僅影響生成hash值方式,純數字字串會轉換成數值再計算hash。
案例中,單個詞法'Hello'屬於原始字串,由AstRawString類進行管理。而整個待編譯字串"'Hello' + ' World'"中,加號左右的空格會被忽略,解析後分為三段,即字串、加號、字串。由於這段程式碼以字串開頭,被判定為一個字面量(literal),在依次解析後發現了加號與另外一個字串後結束,所以被判定是一個'普通二元運算表示式',在expression中的標記分別是normal、binary operation、literal。
這裡用JavaScript模擬一遍"'Hello + World'"的解析過程,完整的解析後面有人看再說。命名和邏輯儘量還原C++原始碼,有些類存在多層繼承就不搞了,列舉用陣列代替,部分地方的語法與呼叫可能會看起來有些奇怪,指標以及模版元那些就沒辦法了。
首先我們需要兩個對映表,如下。
const kMaxAscii = 127; const UnicodeToAsciiMapping = []; for(let i = 0;i < kMaxAscii;i ++) { UnicodeToAsciiMapping.push(String.fromCharCode(i)); } /** * 原始碼確實是一個超長的三元表示式 * Token是一個列舉 這裡直接用字串代替了 * 因為太多了 只保留幾個看看 */ const TokenToAsciiMapping = (c) => { return c === '(' ? 'Token::LPAREN' : c == ')' ? 'Token::RPAREN' : // ...很多很多 c == '"' ? 'Token::STRING' : c == '\'' ? 'Token::STRING' : // ...很多很多 'Token::ILLEGAL' }; const UnicodeToToken = UnicodeToAsciiMapping.map(v => TokenToAsciiMapping(v));
一個map負責對Unicode與Ascii做對映,一個map負責對Unicode與Token型別的對映,這裡v8利用陣列下標來快速定位字元型別。
v8內部是對字串做逐字解析,我們需要一個Stream類來管理和處理,實現一下。
class Stream { constructor(source_string) { /** * buffer_不會在建構函式中初始化 * 但為了模擬v8這裡暫時儲存源字串 */ this.source_string = source_string; /** * 作為容器儲存字元 */ this.buffer_ = []; /** * 三個指標分別代表當前解析進度 */ this.buffer_start_ = 0 this.buffer_cursor_ = 0 this.buffer_end_ = 0 } ReadBlockChecked() { return this.ReadBlock(); } ReadBlock() { this.buffer_ = this.source_string.split('').map(v => UnicodeToAsciiMapping.indexOf(v)); this.buffer_end_ = this.buffer_.length; /** * 這裡的返回與原始碼不同 涉及gc 不做展開 */ return this.buffer_.length; } /** * 返回當前字元 並前進一格 */ Advance() { let tmp = this.peek(); this.buffer_cursor_++; return tmp; } /** * 返回當前字元 * 同時會做初始化 */ peek() { if(this.buffer_cursor_ < this.buffer_end_) { return this.buffer_[this.buffer_cursor_]; } else if(this.ReadBlockChecked()) { return this.buffer_[this.buffer_cursor_]; } else { return null; } } }
有了這個類,就能對字串逐字解析,但是還是需要一個機器來啟動這個步驟,機器叫scanner。在實現掃描機器之前,我們還需要實現詞法類,也就是如何描述單個詞法。這個類在v8中叫TokenDesc,屬於Ast中最基礎的單元。
class TokenDesc { constructor() { /** * 原始碼中是一個結構體 * 除了標記起始、結束位置還有若干方法 */ this.location = { beg_pos: 0, end_pos: 0, }; /** * 負責管理字串 * 還有一個名為raw_literal_chars的同型別屬性負責儲存源字串 */ this.literal_chars = new LiteralBuffer(); /** * Token型別 */ this.token = null; /** * 處理小整數 */ this.smi_value = 0; this.after_line_terminator = false; } }
裡面的屬性基本上還原了v8原始碼,Location做了簡化,另外literal_chars負責專門處理字串,後面會給出實現。
token則標記了該詞法的型別,型別判斷可見上面的第二個對映表,根據不同的型別有不同的case處理。
smi_value則管理小整數型別的詞法,可以去看jjc對於這個的介紹,我這裡就不展開了。
有了詞法類,再來實現掃描器scanner。
class Scanner { constructor(source_string) { this.source_ = new stream(source_string); /** * 當前字元的Unicode編碼 * 如果為null代表解析完成 */ this.c0_ = null; /** * 其實v8有三個詞法描述類 * token_storage_是一個陣列 裡面裝著那個三個類 這裡就不用了 * 為了方便就弄一個 */ this.TokenDesc = new TokenDesc(); this.token_storage_ = []; } /** * 原始碼有current_、next_、next_next_三個標記 這裡搞一個 */ next() { return this.TokenDesc; } Initialize() { this.Init(); this.next().after_line_terminator = true; this.Scan(); } Init() { this.Advance(); // 後面會有一些詞法描述類對token_storage_的對映 這裡跳過 } Advance() { this.c0_ = this.source_.Advance(); } /** * 這裡有函式過載 JS就直接用預設引數模擬了 */ Scan(next = this.TokenDesc) { next.token = this.ScanSingleToken(); next.location.end_pos = this.source_.buffer_cursor_ - 1; } /** * 單個詞法的解析 */ ScanSingleToken() { let token = null; do { this.next().location.beg_pos = this.source_.buffer_cursor_; if(this.c0_ < kMaxAscii) { token = UnicodeToToken[this.c0_]; switch(token) { case 'Token::LPAREN': /** * 有很多其他的case * 因為只講字串 * 這裡就不實現這個方法了 */ return this.Select(token); case 'Token::STRING': return this.ScanString(); // ... } } /** * 原始碼中這裡處理一些特殊情況 不展開了 */ } while(token === 'Token::WHITESPACE') return token; } }
這個類比較大,簡化了不少地方,核心當然是解析。在原始碼中,對scanner類呼叫初始化的Initialize時就會對第一個詞法進行解析,如同我重寫的那個邏輯,最後對字串的處理方法就是那個ScanString。
在這裡暫時沒有將ScanString的實現給出來,主要是在這個方法關聯著另外一個類,即之前TokenDesc類中的literal_chars。
所以先把管理字串的類實現,再來看對字串的最終解析。
const Latin1_kMaxChar = 255; // constexpr int kOneByteSize = kCharSize = sizeof(char); const kOneByteSize = 1; class LiteralBuffer { constructor() { /** * 原始碼中是一個Vector容器 * 有對應擴容演算法 */ this.backing_store_ = []; this.position_ = 0; /** * 當字串中有字元的Unicode值大於255 * 判定為雙位元組型別 這裡先不處理這種 */ this.is_one_byte_ = null; } /** * 啟動這個時預設字串為單位元組 */ start() { this.position_ = 0; this.is_one_byte_ = true; } /** * 只關心單位元組字元 所以那兩個方法不給出實現了 */ AddChar(code_unit) { if(this.is_one_byte_) { if(code_unit <= Latin1_kMaxChar) { return this.AddOneByteChar(code_unit); } this.ConvertToTwoByte(); } this.AddTwoByteChar(code_unit); } AddOneByteChar(one_byte_char) { /** * 擴容演算法簡述就是以64為基準 每次擴容*4 * 當所需容器大於(1024 * 1024) / 3時 寫死為2 * 1024 * 1024 */ if (this.position_ >= this.backing_store_.length) this.ExpandBuffer(); this.backing_store_[this.position_] = one_byte_char; this.position_ += kOneByteSize; } }
其實這個類本身比較簡單,只是用了一個容器來裝字元,必要時進行擴容,單雙位元組不關心的話也就沒什麼了。
有了這個類,就能對字串進行完整的解析,來實現scanner類的ScanString方法吧。
class Scanner { // ... ScanString() { // 儲存當前字串的標記符號 ' 或 " let quote = this.c0_; this.next().literal_chars.Start(); while(true) { this.AdvanceUntil(); /** * 特殊符號直接前進一格 */ while(this.c0_ === '\\') { this.Advance(); } /** * 遇到結束的標記代表解析結束 */ if (this.c0_ === quote) { this.Advance(); return 'Token::STRING'; } this.AddLiteralChar(this.c0_); } } AddLiteralChar(c) { this.next().literal_chars.AddChar(c); } }
可以看到,除去那個AdvanceUntil方法,其實還是正常的逐字遍歷字元,當遇到同一個標記時,就代表字串解析結束。
但是這個AdvanceUtil方法確實比較有意思,簡述就是快速檢測字串的結尾位置並完成掃描,順利的話跑完這個方法就結束了整個ScanString。其引數是一個函式,負責檢查當前字元是否可能是字串結束標誌。C++原始碼中用的是匿名函式,看起來比較難受,這裡用JS重寫一遍,如下。
class Scanner { // ... /** * 這裡相對原始碼有改動 * 1、實際呼叫的是source_上的方法 並把返回值給了c0_ * 2、判斷函式在這裡寫實現 */ AdvanceUntil() { /** * 這裡需要實現std標準庫中一個方法 * 實際上是三個引數 且前兩個引數為迭代器 為了方便暫時就不完美實現了 */ const find_if = (arr, start, end, callback) => { let tarArr = arr.slice(start, end); let tarIdx = tarArr.findIndex(v => callback(v)); return tarIdx === -1 ? end : tarIdx; } const callback = (c0) => { /** * 代表當前字元可能是一個結束符 這裡簡化了判斷 原始碼如下 * uint8_t char_flags = character_scan_flags[c0]; * if (MayTerminateString(char_flags)) return true; */ if(["\'", "\""].includes(UnicodeToAsciiMapping[c0])) return true; this.AddLiteralChar(c0); return false; } /** * 在字串中尋找第一個字元結尾標記的位置 * 例如'、"等等 */ let next_cursor_pos = find_if(this.source_.buffer_, this.source_.buffer_cursor_, this.source_.buffer_end_, callback); if(next_cursor_pos === this.source_.buffer_end_) { this.source_.buffer_cursor_ = this.source_.buffer_end_; this.c0_ = null; } else { this.source_.buffer_cursor_ = next_cursor_pos + 1; this.c0_ = this.source_.buffer_[next_cursor_pos + 1]; } } }
這裡其實也對字串進行了遍歷,但只是粗糙的掃描,在一般情況下,這個方法走完字串就遍歷完畢,但是偶爾也會有特殊情況,比如說"ab'c'd"、"abc\"d"。當遇到特殊情況,這裡只能將前面的字元add後,交給外部繼續處理。
裡面其實還有一個對映表,叫character_scan_flag,也是對單個字元的型別判定,屬於一種可能性分類。比如遍歷到一個字元z,這裡就會給一個標記kCannotBeKeyword,代表這個詞法不可能是一個關鍵詞,在某些情況可以快速跳過一些流程。同理,在遇到'、"字元時,會被判斷可能是一個字串的結尾標記,這裡就用上了。這個對映表比較複雜,前面我就沒搞出來。
至此,一個字串的詞法就算是解析完了,最後會返回一個型別的Token::STRING的標記,作為詞法描述型別。當然,這個單獨的詞法實際上沒有任何意義,單獨拿出來會被忽略。但是如果與運算子ADD和另外一個字串連起來,會進化成一個二元運算表示式,這些東西都是後面的事了。
給一個測試結果,執行的時候要註釋掉一些方法,因為沒有給實現。
let scanner = new Scanner(source_code); scanner.Initialize(); console.log(scanner)
結果如圖.
其中TokenDesc會被包裝成更高層的類最後進入抽象語法樹,這些是後話了。字串的儲存方式、hash表等等後面有空再說吧。