动态

详情 返回 返回

正則表達式技巧與注意事項 - 动态 详情

原創:打碼日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。

簡介

現如今,正則表達式幾乎是程序員的必備技能了,它入手確實很容易,但如果你不仔細琢磨學習,會長期停留在正則最基本的用法層面上。
因此,本篇文章,我會介紹一些能用正則解決的場景,但這些場景如果全自己琢磨實現的話,需要花一些時間才能完成,或者就完全想不出來,另外也會介紹一些正則表達式的性能問題。

匹配多個單詞

比如我想匹配zhangsan、lisi、wangwu這三個人名,這是一個很常見的場景,其實在正則裏面也算基本功,但鑑於本人初入門時還是在網上搜索得到的答案,還是值得提一下的!
實現如下:

zhangsan|lisi|wangwu

其中|表示或的含義,就是匹配zhangsan或lisi或wangwu了。

匹配重複數字

匹配如1111、2222、3333這樣的4位長度的重複數字,突一想,這不用\d{4}就解決了嚒,其實不然,因為\d{4}可以匹配1111,但也可以匹配1234啊。
寫法如下:

(\d)\1{3}

\d匹配第一個數字,後面的\1匹配前面\d匹配的內容,重複3次,這樣就可以匹配1111或2222這樣的4位數字串了。

匹配各種空白

在使用正則時,常用\s來匹配空白,但遺憾的是,還是有一些Unicode的空白字符,\s無法匹配,這時可以嘗試POSIX字符類\p{Space},我在Java中驗證通過,可以匹配ascii空白字符與Unicode空白字符,如果是其它語言的話,可能正則語法會稍有區別。

位置匹配

正則表達式中\G與環視是比較難理解的,因為這兩個東西很多書上只是介紹了匹配的規則,沒有説出實質,導致死記的規則過一段時間就忘,也不明白這兩東西有啥用。
我們轉換一下思維,其實在正則表達式中,匹配目標只有兩個,一是匹配字符串中的字符,二是匹配字符串中的位置,如下圖:
image_2022-04-05_20220405200922
上邊的hello,有5個字符可以匹配,另外還有6個位置可以匹配,而^hello^就是代表匹配開頭的位置,所以如果是_hello就無法被^hello匹配,因為_h之間的位置並不是開頭,不能與^匹配!

常見位置匹配規則

規則 匹配的位置
^ \A 匹配開始位置
$ \z \Z 匹配結束位置
\b \B 匹配單詞與非單詞邊界位置
\G 匹配當前匹配的開始位置
(?=a) (?!a) 正向環視,看看當前位置後面是否是a,或不是a
(?<=a) (?<!a) 逆向環視,看看當前位置前面是否是a,或不是a

^與\A
^ 匹配文本開始位置,但在多行匹配模式下,^匹配每一行的開始位置。
\A 僅僅只能匹配開始位置,不管什麼匹配模式下
image_2022-04-05_20220405201002

$與\Z

$ 匹配文本末尾位置,但在多行匹配模式下,$匹配每一行的末尾位置。
\Z 僅僅只能匹配末尾位置,不管什麼匹配模式下
image_2022-04-05_20220405201029

\b與\B
\b匹配單詞邊界,在Java中,單詞邊界即是字母與非字母之間的位置,中文不認為是單詞,另外文本開頭與文本結尾也是單詞邊界
\B匹配非單詞邊界
image_2022-04-05_20220405201048

\G
匹配上次匹配的結束位置或當前匹配的開始位置,第一次匹配時,匹配文本開始位置,如下:
從1234a5678中找單個數字,如果用\d去找,可以找到8個,但使用\G\d去找,卻只能找到4個
查找過程:
第1次查找,\G匹配文本開始位置,1與\d匹配,找到第1個匹配,即1
第2次查找,\G匹配1後面2前面之間的位置,2與\d匹配,找到第2個匹配,即2
第3次查找,\G匹配2後面3前面之間的位置,3與\d匹配,找到第3個匹配,即3
第4次查找,\G匹配3後面4前面之間的位置,4與\d匹配,找到第4個匹配,即4
第5次查詢,\G匹配4後面5前面之間的位置,但a與\d不匹配,匹配結束,總共找到4個匹配。
image_2022-04-05_20220405201108

環視
(?=a) 與 (?!a)
正向肯定(否定)環視,用來檢測當前位置後面字符是否是a,或不是a
(?<=a) 與 (?<!a)
逆向肯定(否定)環視,用來檢查當前位置前面字符是否是a,或不是a
如下,查找被()包裹的單詞,使用環視限定單詞左邊是(,右邊是)
image_2022-04-05_20220405201125

位置可被多次匹配
文本中的一個位置,可以同時匹配多個規則,且與規則在正則表達式中的先後順序無關,例如下面3個正則表達式是等價的:

^abc
^^^^^^abc
^(?=a)\b^^^abc

image_2022-04-05_20220405201144

下面舉兩個實際例子體會一下位置匹配!

例1:密碼強度校驗
前端校驗密碼強度時,經常有這樣的要求,長度8到10位,且必須包含數字、字母、標點符號,可通過一個正則表達式校驗出來,如下:

^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\p{P}).{8,10}$

其中,(?=.*[0-9])表示開頭位置的後面一定要有數字,(?=.*[a-zA-Z])表示開頭位置後面一定要有字母,(?=.*\p{P})表示開頭位置的後面一定要有標點符號,.{8,10}表示匹配8到10位字符,這幾個正則合在一起,就實現了校驗密碼強度的要求。
image_2022-04-05_20220405201203

例2:千分位數字
有時我們需要將123456789變成123,456,789這樣的千分位數字,這個使用正則就可以實現,如下,將此正則匹配到的位置,替換為,

(?!^)(?=(\d{3})+$)

其中,(?=(\d{3})+$)表示匹配位置,這個位置後面必須要有一組或多組3個數字,滿足這樣條件的位置有3個,開頭與1之間的位置,3和4之間的位置,6和7之間的位置,然後(?!^)又限制了同樣的這些位置,不能是開頭,就只能3和4,6和7之間的位置滿足要求了,所以替換之後,就變成了123,456,789
image_2022-04-05_20220405201251

匹配帶引號字符串

匹配諸如"hello,world"這樣的帶引號的字符串,很容易想到,用"[^"]+"即可,但是如果引號字符串裏面允許用\來轉義"呢,如"hello \"bob\"!",如果用"[^"]+"來匹配的話,就只會匹配到"hello \"了,顯然不對,可以先自行想想如何用正則實現。
...
...
...
想不出來?我們可以換一個視角,包含帶\開頭轉義字符的字符串,其實可以拆解為",hello ,\"bob,\"!,",然後再泛化為正則形式,",[^\\"]*,\\.[^\\"]*,\\.[^\\"]*,",組合在一起如下:

"[^\\"]*(?:\\.[^\\"]*)*"

表達式中多了個(?:),這表示非捕獲分組,可以用來提高正則匹配性能,而由於字符串中有可能沒有\開頭的轉義字符,故(?:\\.[^\\"]*)後面是*,直接由[^\\"]*匹配完引號內所有內容。

別搞炸了CPU

正則表達式如果寫得很複雜,就需要謹慎評估了,因為有可能平時運行得好好的,但遇到一些特殊情況,會導致CPU直接100%,比如還是上面那個匹配帶引號字符串的場景,有同學可能會給出這樣的正則:

"([^\\"]+|\\.)*"

乍一看,這個正則很完美,[^\\"]+匹配非轉義字符的部分,\\.匹配\"\n之類的。這個正則在遇到滿足條件的字符串時完全沒有問題(如"hello \"bob\"!"),而遇到不滿足條件的字符串時,正則匹配複雜度會隨着字符串長度呈指數式上升,導致CPU 100%,如"hello \"bob\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!,其中"沒有閉合。

public static void main(String[] args) {
    long begin = System.currentTimeMillis();
    boolean isMatch = "\"hello \\\"bob\\\"!!!!!!!!!!!!!!!!!!".matches("\"([^\\\\\"]+|\\\\.)*\"");
    System.out.println(String.format("%s ms, isMatch: %s", System.currentTimeMillis() - begin, isMatch));
}

這段java代碼,在我機器上跑完要2s的樣子,但如果字符串中再加4個!,運行時間立馬上升到17s,性能下降非常恐怖!

原因
如果知道一些正則匹配原理,應該知道正則在匹配時,如果匹配不上,會將已經匹配的字符吐出來,再看看是否能夠匹配,這叫回溯,比如".*"匹配"hello",先正則中的"匹配上了字符串中的",然後.*依次匹配了h,e,l,l,o,",最後正則中的"匹配字符串結尾位置,匹配不上,這時正則引擎會讓前面的.*吐出它匹配的",然後吐出來的這個",剛好可以和正則中的"匹配,這樣就匹配成功了。

那如果是"hello這樣沒有閉合的字符串,.*會一直吐字符,一直到它沒有字符可吐,發現還是匹配不上,這樣整個匹配才認定為匹配失敗。

是的,正則中包含匹配量詞?,*,+時,你就可以想像為它們一直在吃字符,當後面的規則匹配不上時,會強迫它又吐出來,而如果是懶惰匹配量詞??,*?,+?,你就可以想像它先不吃,當後面的規則匹配不上時,會強迫它去吃。

我們再來分析下"([^\\"]+|\\.)*"匹配"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!為啥會如此低效!
注:為了分析方便,我簡化了待匹配字符串,但效果是一樣的

  1. 首先[^\\"]+吃掉了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  2. 然後發現正則中"與字符串結尾位置不匹配,開始回溯。
  3. 然後[^\\"]+吐出一個!,注意這裏,由於外層還有一個*貪婪量詞,吐出來的!又被[^\\"]+|\\.中的[^\\"]+吃掉了,它吃掉後,到了字符串結尾,發現結尾又與正則中的"不匹配,又要求[^\\"]+|\\.中的[^\\"]+吐出剛吃掉的!,結果吐出後又不匹配。
  4. 然後又逼着最前面的那個[^\\"]+吐出倒數第二個!,注意,再次吐出!後,當前匹配位置後面有兩個!,可惡的是,這兩個!又被後面[^\\"]+|\\.中的[^\\"]+吃掉了,然後悲劇重演,它又要吐出來,如此循環往復,計算量指數級上升。

解決辦法
其實可以看出來,造成這個問題是因為正則表達式中有兩個量詞,內層有一個+,外層有一個*,不信的話,你可以嘗試用^(a+)*$去匹配aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0,同樣的會非常慢。
而要解決這個問題,有兩個辦法。

  1. [^\\"]+吐出來的字符,無法被外層正則中另一個貪婪的自己吃掉,比如前面介紹的"[^\\"]*(?:\\.[^\\"]*)*"[^\\"]*吐出來的字符,是無法被\\.[^\\"]*吃掉的,因為吐出來的一定不是\,而\\.[^\\"]*要先吃一個\
  2. 明知道自己吐出來的字符後,後面的規則也無法匹配,那就讓量詞吃掉字符後不吐,比如將正則修改為"([^\\"]++|\\.)*"這樣,+變成了++,像這種量詞後面再加+號的,比如?+,*+,++,這表示佔有量詞,吃完字符後就不會吐了。

注:佔有量詞不要亂用,有時吐出來字符可以讓整個正則匹配,而你強制讓它不吐出來,反而讓它匹配不了了,如^.+b$可以匹配ab,但如果你用^.++b$就無法匹配ab了,因為.吃掉了ab,吐出一個b剛好可以使後面的b匹配。而^[^b]++b$這種用法就是對的,因為^b吐出來的字符肯定不能和後面的b匹配,就沒必要再吐了。

總結

正則表達式很強大,用好它事半功倍,但也需要了解它的執行過程,避免指數級回溯陷阱。

往期內容

好用的parallel命令
還在胡亂設置連接空閒時間?
常用網絡命令總結
使用socat批量操作多台機器

Add a new 评论

Some HTML is okay.