理解正規表示式(程式設計師第3期文章)

weixin_34054866發表於2019-01-11

本文為《程式設計師》07年3月號《七種武器》專題所做。有興趣的讀者可以到 這裡  來投一票,表達您對於程式設計師基本功的看法。

在程式設計師日常工作中,資料處理佔據了相當的比重。而在所有的資料之中,文字又佔據了相當的比重。文字能夠被人理解,具有良好的透明性,利於系統的開發、測試和維護。然而,易於被人理解的文字資料,機器處理起來就不一定都那麼容易。文字資料複雜多變,特定性強,甚至是千奇百怪。因此,文字處理程式可謂生存環境惡劣。一般來說,文字處理程式都是特定於應用的,一個專案有一個專案的要求,彼此之間很難抽出共同點,程式碼很難複用,往往是“一次編碼,一次執行,到處補丁”。其程式結構散亂醜陋,談不上有什麼“藝術性”,基本上與“模式”、“架構”什麼的無緣。在這裡,從容雅緻、溫文爾雅派不上用場,要想生存就必須以暴制暴。
事實上,幾十年的實踐證明,除了正規表示式和更高階的parser技術,在這樣一場街頭鬥毆中別無利器。而其中,尤以正規表示式最為常用。所以,對於今天的程式設計師來說,熟練使用正規表示式著實應該是一種必不可少的基本功。然而現實情況卻是,知道的人很多,善於應用的人卻很少,而能夠洞悉其原理,理智而高效地應用它的人則少之又少。大多數開發者被它的外表嚇倒,不敢也不耐煩深入瞭解其原理。事實上,正規表示式背後的原理並不複雜,只要耐心學習,積極實踐,理解正規表示式並不困難。下面列舉的一些條款,來自我本人學習和時間經驗的不完全總結。由於水平和篇幅所限,只能浮光掠影,不足和謬誤之處,希望得到有識之士的指教。


1. 瞭解正規表示式的歷史
正規表示式萌芽於1940年代的神經生理學研究,由著名數學家Stephen Kleene第一個正式描述。具體地說,Kleene歸納了前述的神經生理學研究,在一篇題為《正則集代數》的論文中定義了“正則集”,並在其上定義了一個代數系統,並且引入了一種記號系統來描述正則集,這種記號系統被他稱為“正規表示式”。在理論數學的圈子裡被研究了幾十年之後,1968年,後來發明了UNIX系統的Ken Thompson第一個把正規表示式用於計算機領域,開發了qed和grep兩個實用文字處理工具,取得了巨大成功。在此後十幾年裡,一大批一流電腦科學家和黑客對正規表示式進行了密集的研究和實踐。在1980年代早期,UNIX運動的兩個中心貝爾實驗室和加州大學伯克利分校分別圍繞grep工具對正規表示式引擎進行了研究和實現。與之同時,編譯器“龍書”的作者Alfred Aho開發了Egrep工具,大大擴充套件和增強了正規表示式的功能。此後,他又與《C程式設計語言》的作者Brian Kernighan等三人一起發明了流行的awk文字編輯語言。到了1986年,正規表示式迎來了一次飛躍。先是C語言頂級黑客Henry Spencer以原始碼形式釋出了一個用C語言寫成的正規表示式程式庫(當時還不叫open source),從而把正規表示式的奧妙帶入尋常百姓家,然後是技術怪傑Larry Wall橫空出世,釋出了Perl語言的第一個版本。自那以後,Perl一直是正規表示式的旗手,可以說,今天正規表示式的標準和地位是由Perl塑造的。Perl 5.x釋出以後,正規表示式進入了穩定成熟期,其強大能力已經征服了幾乎所有主流語言平臺,成為每個專業開發者都必須掌握的基本工具。

2. 掌握一門正規表示式語言
使用正規表示式有兩種方法,一種是通過程式庫,另一種是通過內建了正規表示式引擎的語言本身。前者的代表是Java、.NET、C/C++、Python,後者的代表則是Perl、Ruby、JavaScript和一些新興語言,如Groovy等。如果學習正規表示式的目標僅僅是應付日常應用,則通過程式庫使用就可以。但只有掌握一門正規表示式語言,才能夠將正規表示式變成程式設計的直覺本能,達到較高的水準。不但如此,正規表示式語言也能夠在實踐中提供更高的開發和執行效率。因此,有心者應當掌握一門正規表示式語言。

3. 理解DFA和NFA
正規表示式引擎分成兩類,一類稱為DFA(確定性有窮自動機),另一類稱為NFA(非確定性有窮自動機)。兩類引擎要順利工作,都必須有一個正則式和一個文字串,一個捏在手裡,一個吃下去。DFA捏著文字串去比較正則式,看到一個子正則式,就把可能的匹配串全標註出來,然後再看正則式的下一個部分,根據新的匹配結果更新標註。而NFA是捏著正則式去比文字,吃掉一個字元,就把它跟正則式比較,匹配就記下來:“某年某月某日在某處匹配上了!”,然後接著往下幹。一旦不匹配,就把剛吃的這個字元吐出來,一個個的吐,直到回到上一次匹配的地方。
DFA與NFA機制上的不同帶來5個影響:
1. DFA對於文字串裡的每一個字元只需掃描一次,比較快,但特性較少;NFA要翻來覆去吃字元、吐字元,速度慢,但是特性豐富,所以反而應用廣泛,當今主要的正規表示式引擎,如Perl、Ruby、Python的re模組、Java和.NET的regex庫,都是NFA的。
2. 只有NFA才支援lazy和backreference等特性;
3. NFA急於邀功請賞,所以最左子正則式優先匹配成功,因此偶爾會錯過最佳匹配結果;DFA則是“最長的左子正則式優先匹配成功”。
4. NFA預設採用greedy量詞(見item 4);
5. NFA可能會陷入遞迴呼叫的陷阱而表現得效能極差。

我這裡舉一個例子來說明第3個影響。

例如用正則式/perl|perlman/來匹配文字 ‘perlman book’。如果是NFA,則以正則式為導向,手裡捏著正則式,眼睛看著文字,一個字元一個字元的吃,吃完 ‘perl’ 以後,跟第一個子正則式/perl/已經匹配上了,於是記錄在案,往下再看,吃進一個 ‘m’,這下糟了,跟子式/perl/不匹配了,於是把m吐出來,向上彙報說成功匹配 ‘perl’,不再關心其他,也不嘗試後面那個子正則式/perlman/,自然也就看不到那個更好的答案了。

如果是DFA,它是以文字為導向,手裡捏著文字,眼睛看著正則式,一口一口的吃。吃到/p/,就在手裡的 ‘p’ 上打一個鉤,記上一筆,說這個字元已經匹配上了,然後往下吃。當看到 /perl/ 之後,DFA不會停,會嘗試再吃一口。這時候,第一個子正則式已經山窮水盡了,沒得吃了,於是就甩掉它,去吃第二個子正則式的/m/。這一吃好了,因為又匹配上了,於是接著往下吃。直到把正則式吃完,心滿意足往上報告說成功匹配了 ‘perlman’。

由此可知,要讓NFA正確工作,應該使用 /perlman|perl/ 模式。

通過以上例子,可以理解為什麼NFA是最左子式匹配,而DFA是最長左子式匹配。實際上,如果仔細分析,關於NFA和DFA的不同之處,都可以找出道理。而明白這些道理,對於有效應用正規表示式是非常有意義的。
4. 理解greedy和lazy量詞
由於日常遇到的正規表示式引擎全都是NFA,所以預設都採用greedy量詞。Greedy量詞的意思不難理解,就是對於/.*/、//w+/這樣的“重複n”次的模式,以貪婪方式進行,儘可能匹配更多字元,直到不得以罷手為止。

舉一個例子,以 /<.*>/ 模式匹配 ‘<book> <title> Perl Hacks </title> </book>/t’文字,匹配結果不是 ‘<book>’,而是 ‘<book> <title> Perl Hacks </title> </book>’。原因就在於NFA引擎以貪婪方式執行“重複n次”的命令。讓我們來仔細分析一下這個過程。

條款3指出,NFA的模型是以正則式為導向,拿著正則式吃文字。在上面的例子裡,當它拿著/.*/這個正則式去吃文字的時候,預設情況下它就這麼一路吃下去,即使碰到 ‘>’字元也不罷手——既然 /./ 是匹配任意字元, ‘>’ 當然也可以匹配!所以就儘管吃下去,直到吃完遇到結尾(包括/t字元)也不覺得有什麼不對。這個時候它突然發現,在正規表示式最後還有一個 />/,於是慌了神,知道吃多了,於是就開始一個字元一個字元的往回吐,直到吐出倒數第二個字元 ‘>’,完成了與正則式的匹配,才長舒一口氣,向上彙報,匹配字串從第一個字元 ‘<’ 開始,到倒數第二個字元 ‘>’結束,即‘<book> <title> Perl Hacks </title> </book>’。

Greedy量詞的行為有時確實是使用者所需要的,有時則不是。比如在這個例子裡,使用者可能實際上想得到的是 ‘book’ 串。怎麼辦呢?這時候lazy量詞就派上用場了。把模式改為/<.*?>/就可以得到 ‘book’。這個加在 ‘*’號後面的 ‘?’ 把greedy量詞行為變成lazy量詞行為,從而由儘量多吃變為儘量少吃,只要吃到一個 ‘>’立刻停止。

問號在正規表示式裡用途最廣泛,這裡是很重要的一個用途。


5. 理解backtracking
在條款4的基礎上解釋backtracking就很容易了。當NFA發現自己吃多了,一個一個往回吐,邊吐邊找匹配,這個過程叫做backtracking。由於存在這個過程,在NFA匹配過程中,特別是在編寫不合理的正則式匹配過程中,文字被反覆掃描,效率損失是不小的。明白這個道理,對於寫出高效的正規表示式很有幫助。

相關文章