用JavaScript帶你體驗V8引擎解析識別符號
上一篇講了字串的解析過程,這一篇來講講識別符號(IDENTIFIER)的解析。
先上知識點,識別符號的掃描分為快解析和慢解析,一旦出現Ascii編碼大於127的字元或者轉義字元,會進入慢解析,略微影響效能,所以最好不要用中文、特殊字元來做變數名(不過現在程式碼壓縮後基本不會有這種情況了)。
每一位JavaScript的初學者在學習宣告一個變數時,都會遇到識別符號這個概念,定義如下。
第一個字元,可以是任意Unicode字母(包括英文字母和其他語言的字母),以及美元符號($)和下劃線(_)。 第二個字元及後面的字元,除了Unicode字母、美元符號和下劃線,還可以用數字0-9。
籠統來講,v8也是通過這個規則來處理識別符號,下面就來看看詳細的解析過程。
老規矩,程式碼我丟github上面,接著前面一篇的內容,進行了一些整理,將檔案分類,保證下載即可執行。
連結:https://github.com/pflhm2005/V8record/tree/master/JS
待解析字元如下。
var
目的就是解析這個var關鍵詞。
首先需要完善Token對映表,新增關於識別符號的內容,如下。
const TokenToAsciiMapping = (c) => { return c === '(' ? 'Token::LPAREN' : c == ')' ? 'Token::RPAREN' : // ...很多很多 c == '"' ? 'Token::STRING' : c == '\'' ? 'Token::STRING' : // 識別符號部分單獨抽離出一個方法判斷 IsAsciiIdentifier(c) ? 'Token::IDENTIFIER' : // ...很多很多 'Token::ILLEGAL' };
在那個超長的三元表示式中新增一個識別符號的判斷,由於識別符號的合法字元較多,所以單獨抽離一個方法做判斷。
方法的邏輯只要符合定義就夠了,實現如下。
/** * 判斷給定字元是否在兩個字元的範圍內 * C++比較靈活 JS只能手動強轉字元 * @param {char} c 目標字元 * @param {char} lower_limit 低位字元 * @param {chat} higher_limit 高位字元 */ const IsInRange = (c, lower_limit, higher_limit) => { return (String(c).charCodeAt() - String(lower_limit).charCodeAt()) >= (String(higher_limit).charCodeAt() - String(lower_limit).charCodeAt()); } /** * 將大寫字母轉換為小寫字母 JS沒有char、int這種嚴格型別 需要手動搞一下 */ const AsciiAlphaToLower = (c) => { return String.fromCharCode(c | 0x20); } /** * 數字字元判斷 */ const IsDecimalDigit = (c) => { return IsInRange(c, '0', '9'); } /** * 大小寫字母、數字 */ const IsAlphaNumeric = (c) => { return IsInRange(AsciiAlphaToLower(c), 'a', 'z') || IsDecimalDigit(c); } /** * 判斷是否是合法識別符號字元 */ const IsAsciiIdentifier = (c) => { return IsAlphaNumeric(c) || c == '$' || c == '_'; }
v8內部定義了很多字元相關的方法,這些只是一部分。比較有意思的是那個大寫字母轉換為小寫,一般在JS中都是toLowerCase()一把梭,但是C++用的是位運算。
方法都比較簡單,可以看到,大小寫字母、數字、$、_都會認為是一個合法識別符號。
得到一個Token::IDENTIFIER的初步標記後,會進入單個Token的解析,即Scanner::ScanSingleToken(翻上一篇),在這裡,也需要新增一個處理識別符號的方法,如下。
class Scanner { /** * 單個詞法的解析 */ ScanSingleToken() { let token = null; do { this.next().location.beg_pos = this.source_.buffer_cursor_ - 1; if(this.c0_ < kMaxAscii) { token = UnicodeToToken[this.c0_]; switch(token) { /** * 在這裡新增識別符號的case */ case 'Token::IDENTIFIER': return ScanIdentifierOrKeyword(); // ... } } /** * 原始碼中這裡處理一些特殊情況 * 特殊情況就包括Ascii編碼過大的識別符號 特殊情況暫不展開 */ } while(token === 'Token::WHITESPACE') return token; } }
上一篇這裡只有Token::String,多加一個case就行。一般情況下,所有字元都是普通的字元,即Ascii編碼小於128。如果出現類似於中文這種特殊字元,會進入下面的特殊情況處理,現在一般不會出現,這裡就不做展開了。
接下來就是實現識別符號解析的方法,從名字可以看出,識別符號分為變數、關鍵詞兩種型別,那麼還是需要再弄一個對映表來做型別快速判斷,先來完善上一篇留下的尾巴,字元型別對映表。
裡面其實還有一個對映表,叫character_scan_flag,也是對單個字元的型別判定,屬於一種可能性分類。
之前還以為這個表很麻煩,其實挺簡單的(假的,噁心了我一中午)。表的作用如上,通過一個字元,來判斷這個識別符號可能是什麼東西,型別總共有6種情況,如下。
/** * 字元型別 */ const kTerminatesLiteral = 1 << 0; const kCannotBeKeyword = 1 << 1; const kCannotBeKeywordStart = 1 << 2; const kStringTerminator = 1 << 3; const kIdentifierNeedsSlowPath = 1 << 4; const kMultilineCommentCharacterNeedsSlowPath = 1 << 5;
這6個列舉值分別表示:
- 識別符號的結束標記,比如')'、'}'等符號都代表這個識別符號沒了
- 非關鍵詞標記,比如一個識別符號包含'z'字元,就不可能是一個關鍵字
- 非關鍵詞首字元標記,比如varrr的首字元是'v',這個識別符號可能是關鍵詞(實際上並不是)
- 字串結束標記,上一篇有提到,單雙引號、換行等都可能代表字串結束
- 識別符號慢解析標記,一旦識別符號出現轉義、Ascii編碼大於127的值,標記會被啟用
- 多行註釋標記,參考上面那個程式碼的註釋
始終需要記住,這只是一種可能性型別推斷,並不是斷言,只能用於快速跳過某些流程。
有了標記和對應定義,下面來實現這個字元型別推斷對映表,如下。
const GetScanFlags = (c) => { (!IsAsciiIdentifier(c) ? kTerminatesLiteral : 0) | (IsAsciiIdentifier(c) && !CanBeKeywordCharacter(c)) ? kCannotBeKeyword : 0 | (IsKeywordStart(c) ? kCannotBeKeywordStart : 0) | ((c === '\'' || c === '"' || c === '\n' || c === '\r' || c === '\\') ? kStringTerminator : 0) | (c === '\\' ? kIdentifierNeedsSlowPath : 0) | (c === '\n' || c === '\r' || c === '*' ? kMultilineCommentCharacterNeedsSlowPath : 0) } // UnicodeToAsciiMapping下標代表字元對應的Ascii編碼 上一篇有講 const character_scan_flags = UnicodeToAsciiMapping.map(c => GetScanFlags(c));
對照定義,上面的方法基本上不用解釋了,用到了我前面講過的一個技巧bitmap(文盲不懂專業術語,難怪阿里一面就掛了)。由於是按照C++原始碼寫的,上述部分工具方法還是需要挨個實現。原始碼用的巨集,寫起來一把梭,用JS還是挺繁瑣的,具體程式碼我放github了。
有了這個對映表,後面很多地方就很方便了,現在來實現識別符號的解析方法。
實現之前,來列舉一下可能出現的識別符號:var、vars、avr、1ab、{ '\a': 1 }、吉米(\u5409\u7c73),這些識別符號有些合法有些不合法,但是都會進入解析階段。所以總的來說,方法首先保證可以處理上述所有情況。
對於數字開頭的識別符號,其實在case階段就被攔截了,雖然說數字1也會出現在一個IDENTIFIER中,但是1會首先被優先解析成'Token::Number',有對應的方法處理這個型別,如下。
case 'Token::STRING': return this.ScanString(); // 數字開頭 case 'Token::NUMBER': return ScanNumber(false); case 'Token::IDENTIFIER': return ScanIdentifierOrKeyword();
所以1ab這種情況不用考慮,實現方法如下。
Scanner::ScanIdentifierOrKeyword() { this.next().literal_chars.Start(); return this.ScanIdentifierOrKeywordInner(); } Scanner::ScanIdentifierOrKeywordInner() { /** * 兩個布林型別的flag * 一個標記轉義字元 一個標記關鍵詞 */ let escaped = false; let can_be_keyword = true; if(this.c0_ < kMaxAscii) { // 轉義字元以'\'字元開頭 if(this.c0_ !== '\\') { let scan_flags = character_scan_flags[this.c0_]; // 這個地方比較迷 沒看懂 scan_flags >>= 1; this.AddLiteralChar(this.c0_); this.AdvanceUntil((c0) => { // 當某個字元的Ascii編碼大於127 進入慢解析 if(c0 > kMaxAscii) { scan_flags |= kIdentifierNeedsSlowPath; return true; } // 疊加每個字元的bitmap let char_flags = character_scan_flags[c0]; scan_flags |= char_flags; // 用bitmap識別結束標記 if(TerminatesLiteral(char_flags)) { return true; } else { this.AddLiteralChar(c0); return false; } }); // 基本上都是進這裡 快分支 if(!IdentifierNeedsSlowPath(scan_flags)) { if(!CanBeKeyword(scan_flags)) return 'Token::IDENTIFIER'; // 原始碼返回一個新的vector容器 這裡簡單處理成一個字串 let chars = this.next().literal_chars.one_byte_literal(); return this.KeywordOrIdentifierToken(chars, chars.length); } can_be_keyword = CanBeKeyword(scan_flags); } else { escaped = true; let c = this.ScanIdentifierUnicodeEscape(); // 合法變數以大小寫字母_開頭 if(c === '\\' || !IsIdentifierStart(c)) return 'Token::ILLEGAL'; this.AddLiteralChar(c); can_be_keyword = CharCanBeKeyword(c); } } // 邏輯同上 進這裡代表首字元Ascii編碼就過大 return ScanIdentifierOrKeywordInnerSlow(escaped, can_be_keyword); }
感覺C++的類方法實現的寫法看起來很舒服,部落格裡也這麼寫了,希望JavaScript什麼時候也借鑑一下,貌似::在JS裡目前還不是一個運算子,總之真香。
首先可以發現,識別符號的解析也用到了Literal類,之前說這是用了字串解析並不準確,因此我修改了AdvanceUntil方法,將callback作為引數傳入。啟動類後,掃描邏輯如下。
- 一旦字元出現Ascii編碼大於127或者轉義符號,扔到慢解析方法中
- 對所有字元進行逐個遍歷,方式類似於上篇的字串解析,結束標記略有不同
- 一般情況下不用慢解析,根據bitmap中的kCannotBeKeyword快速判斷返回變數還是進入關鍵詞解析分支
v8中字元相關的工具方法就單獨搞了一個cpp檔案,裡面方法非常多,後續如果是把v8全部翻譯過來估計也要分好多檔案了,先這樣吧。
先不管慢解析了,大部分情況下也不會用中文做變數,類似於zzz、jjj的變數會快速跳出,標記為"Token::IDENTIFIER"。而可能是關鍵詞的識別符號,比如上面列舉的var、vars、avr,由於或多或少的具有一些關鍵詞特徵,會深入再次解析。
需要說的是,從一個JavaScript使用者的角度看,關鍵詞的識別只需要對字串做嚴格對等比較就行了,比如長度3,字元順序依次是v、a、r,那麼必定是關鍵詞var。
但是v8的實現比較迷,用上了Hash,既然是v8體驗文章,那麼就按照原始碼的邏輯實現上面的KeywordOrIdentifierToken方法。
// 跳到另外一個檔案裡實現 Scanner::KeywordOrIdentifierToken(str, len) { return PerfectKeywordHash.GetToken(str, len); } /** * 特殊const就放這裡算了 */ const MIN_WORD_LENGTH = 2; const MAX_WORD_LENGTH = 10; class PerfectKeywordHash { static GetToken(str, len) { if(IsInRange(len, MIN_WORD_LENGTH, MAX_WORD_LENGTH)) { let key = PerfectKeywordHash.Hash(str, len) & 0x3f; if(len === kPerfectKeywordLengthTable[key]) { const s = kPerfectKeywordHashTable[key].name; let l = s.length; let i = -1; /** * C++可以直接操作指標 JS只能搞變量了 * 做字元嚴格校對 形如avr會被識別為變數 */ while(i++ !== l) { if(s[i] !== str[i]) return 'Token::IDENTIFIER'; } return kPerfectKeywordHashTable[key].value; } } return 'Token::IDENTIFIER'; } }
總體邏輯如上所示,關鍵詞的長度目前是2-10,所以根據長度先篩一波,再v8根據傳入的字串算出了一個hash值,然後根據這個值從對映表找出對應的特徵,對兩者進行嚴格對對比,來判定這個識別符號是不是一個關鍵詞。
涉及1個hash演算法和2個對映表,這裡把hash演算法給出來,對映表實在是繁瑣,有興趣去github看吧。
/** * asso_values儲存了Ascii編碼0-127的對映 * 所有關鍵詞字元都有對應的hash值 而非關鍵詞字元都是56 比如j、k、z等等 * 通過運算返回一個整形 */ static Hash(str, len) { const asso_values = [ 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 8, 0, 6, 0, 0, 9, 9, 9, 0, 56, 56, 34, 41, 0, 3, 6, 56, 19, 10, 13, 16, 39, 26, 37, 36, 56, 56, 56, 56, 56, 56, ]; return len + asso_values[str[1].charCodeAt()] + asso_values[str[0].charCodeAt()]; }
可以看到,hash方法的內部也有一個對映表,每一個關鍵字元都有對應的hash值,通過前兩個字元進行運算(最短的關鍵詞就是2個字元,並且),得到一個hash值,將這個值套到另外的table得到其理論上的長度,長度一致再進行嚴格比對。
這個方法個人感覺有一些微妙,len主要是做一個修正,因為前兩個字元一樣的關鍵詞還是蠻多的,比如說case、catch,delete、default等等,但是長度不一樣,加上len可以區分。如果有一天出現前兩個字元一樣,且長度也一樣的關鍵詞,這個hash演算法肯定要修改了,反正也不關我事咯。
經過這一系列的處理,識別符號的解析算是完成了,程式碼可以github上面下載,然後修改test檔案裡面傳入的引數就能看到輸出。