讀古今文學網 > 編寫高質量代碼:改善JavaScript程序的188個建議 > 建議45:警惕嵌套量詞和回溯失控 >

建議45:警惕嵌套量詞和回溯失控

嵌套量詞總是需要額外的關注和小心,以確保沒有掩蓋回溯失控問題。嵌套量詞出現在一個自身被重複量詞修飾的組中。

嵌套量詞本身並不會造成性能危害,只是在嘗試匹配字符串過程中,很容易不小心在內部量詞和外部量詞之間,產生一大堆分解文本的方法。例如,要匹配HTML標籤,使用了下面的正則表達式:


/<(?:[^>"']|"[^"]*"|'[^']*')*>/


這也許過於簡單,因為它不能正確地處理所有情況的有效和無效標記,但在處理有效HTML片段時應該沒什麼問題。與更加簡單的/<[^>]*>/相比,它的優勢在於涵蓋了出現在屬性值中的>符號。在非捕獲組中它不使用第二和第三分支,僅匹配單引號和雙引號包圍的屬性值,除特定的引號外允許所有字符出現。

雖然遇到了嵌套量詞*,但目前還沒有回溯失控的危險。在分組的每次重複過程中,由於第二和第三分支選項嚴格匹配一個帶引號的字符串,所以潛在的回溯點數目隨目標字符串長度而呈線性增長。

但是,查看非捕獲組的第一分支:[^>"'],每次只匹配一個字符,效率似乎有些低。在字符類後面加一個量詞會更好些,這樣每次組重複過程就可以匹配多於一個的字符了。正則表達式可以在目標字符串的位置上發現一個匹配。通過每次匹配多個字符,正則表達式會在成功匹配的過程中跳過許多不必要的步驟。

如果正則表達式匹配一個「<」字符,但後面沒有「>」,則可以令匹配成功完成,回溯失控就會進入「快車道」,因為內部量詞和外部量詞的排列組合產生了數量巨大的分支路徑(跟在非捕獲組之後)用以匹配「<」之後的文本。正則表達式在最終放棄匹配之前必須嘗試所有的排列組合。

關於嵌套量詞導致回溯失控,一個更加極端的例子是,在一大串A上應用正則表達式/(A+A+)+B/。雖然這個正則表達式寫成/AA+B/更好,但為了討論方便,設想一下兩個A能夠匹配同一個字符串的多少種模板。

當應用在一個由10個A組成的字符串上(「AAAAAAAAAA」)時,正則表達式首先使用第一個A+匹配所有10個字符,然後正則表達式回溯一個字符,讓第二個A+匹配最後一個字符。這個分組試圖重複,但沒有更多的A,而且分組中的+量詞已經符合匹配所需的最少一次,因此正則表達式開始查找B。雖然正則表達式沒有找到B,但是還不能放棄,因為還有許多路徑沒有被測試過。如果第一個A+匹配8個字符,第二個A+匹配2個字符會怎麼樣呢?或者第一個匹配3個,第二個匹配2個,分組重複兩次,又會怎麼樣呢?如果在分組的第一遍重複中,第一個A+匹配2個字符,第二個A+匹配3個字符,然後在第二遍重複中,第一個匹配1個,第二個匹配4個,又怎麼樣呢?

正則表達式在最壞情況下的複雜性是驚人的O(2n),也就是2的n次方。n表示字符串的長度。在由10個A構成的字符串中,正則表達式需要1024次回溯才能確定匹配失敗,如果是20個A,回溯的數字劇增到一百萬以上。25個A足以掛起Chrome、IE、Firefox和Opera瀏覽器至少10分鐘(如果還沒死機)用以處理超過34 000 000次回溯以排除正則表達式的各種排列組合。唯一的例外是最新的Safari瀏覽器,它能夠檢測到正則表達式陷入了循環,並快速終止匹配(Safari瀏覽器還限定了回溯的次數,超出則終止匹配嘗試)。

預防此類問題的關鍵是確保正則表達式的兩個部分不能對字符串的同一部分進行匹配。這個正則表達式可重寫為/AA+B/,但複雜的正則表達式可能難以避免此類問題。雖然還有其他解決辦法,但是增加一個模擬原子組往往作為最後一招使用,如果可能,盡可能保持正則表達式簡單易懂。如果這麼做,此正則表達式將改成/((?=(A+A+))\2)+B/,就可以徹底消除回溯問題。