大類的技術手記

淺談 regex 及其應用

  • 分類:
  • 字數:雞 x 199雞數:計算文長的常見計量單位,一般而言數字大小與文章長度呈正相關

先別管 regex 了,你有用 Ctrl + F 搜尋過文章嗎?

一篇網頁文章中,搜尋「小雞」這個關鍵字,如果文章有出現這樣的字眼,就會移到出現「小雞」的位置;如果沒有,它就會說找不到。

一個簡單好用的功能。

但人生很複雜,文章內容也有千千萬萬。

當你搜尋「小雞」這個詞時,多半心裡想的是毛茸茸很可愛的「小雞」,所以當你發現找出來的結果是「小雞雞」時,心情就不會太好。但你也知道不能怪可憐的搜尋器,因為你心裡明白它是無辜的。

又比如,「複雜的人生」有時會迫使你搜尋連自己也不十分確定內容的字串。

反正你就是覺得小雞很可愛,具體是「小母雞」、「小公雞」、還是「小白雞」無關緊要,只要開頭為「小」,結尾是「雞」就行了,該怎麼搜呢?有點麻煩對吧?

(咦!這樣「小雞雞」也符合定義耶?)

又或者,你想搜尋小雞的相關資料,像是小雞的體重。搜「小雞」自然找不著,因為結果太多了,但搜「體重」也不對,萬一出來的是蟑螂的體重怎麼辦?多噁心?

雖然你知道想搜尋的句子大致是「小雞 XX 公克重」這類的句子。但問題在於這個 XX 偏偏就不知道,若是知道何必搜尋?

到了最後,發現自己不過只是想看一下可愛的小雞而己,竟然還能搞得這麼麻煩,人生實在有許多無奈。

不管啦!老子就是想要知道小雞的體重啦!

幸好,英勇的 regex 這時就現身了,regex 好棒棒!

regex 是正規表示式(Regular Expression)的簡稱,我們只要使用正確的格式撰寫 regex,就可以直接透過對應的工具幫我們找到答案,現在的程式語言幾乎都有支援,有些甚至就直接就內建在語言裡頭了,好比說 Python 的 re 模組就是一個例子。

與普通的搜尋相比,regex 的好處太多了,能夠輕鬆的表示多種、甚至無限種可能的字串,連不確定、不知道的字串也可以搜尋,好比說剛剛提到的「小雞」、「小白雞」、「小小雞」,甚至是不知道答案的「小雞 XX 公克重」都有辦法找到。

有了 regex ,我們想要知道小雞的體重便容易許多了。

regex 語法簡介

regex 用法規則不少,小弟我把它大致區分為四種類別,分別是「選擇」、「次數」、「錨點」和「截取」類。

選擇

首先是「選擇」類,regex 可以在不知道準確字串的情況下對內文進行搜尋,不知道實際是小「白」雞、還是小「小」雞也沒關係,都可以搜尋得到,在此例中,我們可以這麼做:

小白雞|小小雞

「|」代表「或」的意思,表示由「|」區隔出來的字串都可以接受,在這個例子中,無論是「小白雞」還是「小小雞」都可抓得到。

我們也可以加上小括號可以限制「或」的範圍,達成同樣的效果:

小(白|小)雞

這並不限制只能有兩種狀況,假設除了「小白雞」和「小小雞」,「小母雞」也是可以接受的,那只要再加上一個「|」即可:

小白雞|小小雞|小母雞

或是

小(白|小|母)雞

其中 (白|小|母) 可以用中括號的語法簡化:

小[白小母]雞

省去寫一大堆「|」的麻煩。

不過,由於複雜的世界,可接受的情況越來越多,什麼小懶雞、小肥雞都出來了,這個中括號裡的內容可能也會越來越長。

幸好中括號的用法還提供了另一個功能,那就是反向選擇的功能,假設今天我們已經豁出去,覺得什麼都可以,只要不是「小雞雞」就行!我們可以這樣寫:

小[^雞]雞

在中括號內的開頭加上「^」代表反向選擇,只要不是括號內的字都可以接受。

選擇的用法大概就這樣,非常單純。不過在實際的情況下,還是有些便利的技巧,比如說:

[a-zA-Z0-9]

a-z 代表 a 到 z,A-Z 代表 A 到 Z 而 0-9 代表 0 到 9,這個 regex 代表「所有英文字母和數字」都可以接受。

而有些太長用的字,甚至可以直接利用特殊的符號代表。下面列一個簡單的列表:

regex 語法 意義
. 任意字元
\d 數字(digit),如 0 到 9
\D 非數字
\w 文字(word)
\W 非文字
\s 廣義的空白符號(whitespace),如空白、tab 等
\S 非空白

Note

通常 \w 代表的是 [A-Za-z0-9_],但在 Python3 中,\w 也可以是中文字。

次數

再來則是關於「次數」類型的規則。

有時不只出現搜尋的文字不確定,就連出現的次數也不能肯定。regex 也提供了一些特殊符號來處理這種次數未定的情況。

好比說假設我們一開始就知道是小「白」雞,但卻發現「白」有機會不只出現一次,有可能是「小白雞」、「小白白雞」、「小白白白雞」、「小白白白白雞」……。

這時就可以用「次數」類型的規則處理這個問題,在這個例子中,我們可以用:

小白*雞

「*」修飾前面的「白」字,表示「白」可以出現任意次數(包含 0 次),所以這個 regex 可以同時代表「小雞」、「小白雞」、「小白白雞」、「小白白白雞」……。

如果不想包含 0 次,也可以用「+」代替:

小白+雞

「+」一樣代表任意次數,但不包含 0 次。所以只能是「小白雞」、「小白白雞」、「小白白白雞」、「小白白白白雞」……。

我們也可以直接指定可以出現的次數範圍:

小白{1,3}雞

{} 大括號代表可以出現的次數範圍,這個例子即代表「白」可以出現 1 次到 3 次,所以只會有「小白雞」、「小白白雞」、「小白白白雞」三種情況。

類似的規則可以見下表:

regex 語法 意義
* 任意次數(包含 0 次)
+ 任意次數(不包含 0 次)
? 0 次或 1 次
{n} n 次
{n, m} n 次到 m 次
{n,} n 次以上

錨點

再來是定位相關的「錨點」類型。

感謝我們身在一個複雜的世界,有時我們不但對搜尋的字串很龜毛,甚至對搜尋的字串出現的位置也有意見。這種類型的語法比較少,比較有機會用到的就那幾個。

其中「^」代表開頭,「$」代表結尾。

^小白雞

代表「小白雞」一定要搜尋內文的開頭。

(……話說這個例子還要用搜尋,也是一種本事?)

反過來說:

小白雞$

代表「小白雞」一定要在內文的結尾。

另外還有比較常用的是「\b」和「\B」,前者代表「邊界」,後者代表「非邊界」。這個定義比較模糊,有時碰到像是中文的時候會出事,比較沒異議的應用場景比較像是應用在拼音文字單字的邊界,如:

chick\b

在這個例子中,「chicken」就不符合要求,因為「k」並非單字的邊界。但反過來如果是:

chick\B

那麼「chicken」就可以接受,但「chick」就不能過。但如果換成中文呢?比如說:

小雞\b

據我測試的結果,有些可有些不行,像是 Python 2 就抓不到,但 Python 3 就可以。因此結論就是最好不要太依靠這個規則。

截取

最後則是「截取」類的規則。

小括號除了可以改變規則影響的範圍,像是下例 regex 對應的字串並非是「小雞雞」,而是「小雞小雞」:

(小雞){2}

括號裡的內容本身也能直接當成結果輸出,說來有些抽象,舉個例子先:

小雞 (\d+) 公克重

我們可以用這個 regex 搜尋文章中是否有對應的字串外,也可以直接截取括號裡的內容。如果符合的字串為「小雞 30 公克重」,我們可以直接取得「30」這個數字。

事實上,小括號也不限於只能用一次:

小雞 (\d+) 公克重, (\d+) 公分長。

這樣我們就能同時取得體重和身高了。

接下來,再度因應複雜的社會,越來越機車的情況一個接著一個出現,有時我們會碰到需要考慮「前後文」的字串。

<p>小雞</p>

假設有一種情況,需要知道「小雞」屬於什麼標籤的內文,該怎麼辦呢?不能單純用角括號「<」和「>」來判斷,因為裡頭其實也可以包含其他的標籤,好比:

<p>這是一隻<strong>3000</strong>公斤重的小雞</p>

幸好,用小括號截取出來的字串,也可以當成規則的一部分。我們可以用小括號配合 \1 解決。

<(\w+)>.*小雞.*</\1>

\1 代表第一個小括號截取出來的內容,在這個例子中就代表「p」。以此類推,如果有第二個括號,那麼就是 \2,第三、四個則是 \3 和 \4。

原則上,regex 比較常用的規則大約都不會超出這些,看起來好像很簡單,但事實上有不少人,可能一聽到 regex,心裡就會抽蓄,不能自已。

不過這也不能怪他們,舉個例子,下面是我 Google 到驗證 Email 格式的 regex:

((([\t ]*\r\n)?[\t ]+)?[-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*(([\t ]*\r\n)?[\t ]+)?|(([\t ]*\r\n)?[\t ]+)?"(((([\t ]*\r\n)?[\t ]+)?([]!#-[^-~]|(\\[\t -~])))+(([\t ]*\r\n)?[\t ]+)?|(([\t ]*\r\n)?[\t ]+)?)"(([\t ]*\r\n)?[\t ]+)?)@((([\t ]*\r\n)?[\t ]+)?[-!#-'*+/-9=?A-Z^-~]+(\.[-!#-'*+/-9=?A-Z^-~]+)*(([\t ]*\r\n)?[\t ]+)?|(([\t ]*\r\n)?[\t ]+)?\[((([\t ]*\r\n)?[\t ]+)?[!-Z^-~])*(([\t ]*\r\n)?[\t ]+)?](([\t ]*\r\n)?[\t ]+)?)

(傳送門: http://stackoverflow.com/questions/13992403/regex-validation-of-email-addresses-according-to-rfc5321-rfc5322)

看到這個噁心的語法,誰都會想叫媽媽。

但其實雖然 regex 看起來不容易,但實際去寫的時候,卻常會發現並沒有想像中困難。因為 regex 在大部分的情況下都是前後文無關的,後面不會影響前面,所以寫起來感覺像是一層一層疊起來的感覺,實際上考慮的東西並不會如何複雜。

寫的時候雖然很輕鬆,但把最後結果拿給別人看的時候,就會是「一整坨」regex,別人自然會覺得它很噁心,好像很難的樣子。

對此,我只能說--反正寫出來能用就好,痛苦是之後看的人!

呵呵。

(開玩笑的,別當真。)

我們也可以透過一些工具,像是 Rubular 這些線上工具,用視覺化的形式即時驗證自己寫的東西是否正確,讓我們更輕鬆地撰寫 regex。

咦! Rubular 是 Ruby 的?這怎麼可以!趕緊推薦另一個工具 RegExr ,這個工具還附帶一些用法的教學、隨附語法簡表、常用 regex 範例、甚至還開放原始碼,這真是太棒了!比前者好用一萬倍啊!

(而且不是 Ruby)

Python 的 re 模組

在 Python 中,要使用 regex 並不難,已經內建在標準庫裡頭了,只要引入「re」模組即可。其中最常用的函式,大概就是 re.search 函式了。

import re

# 要搜尋的內文
text = "…… 小雞 30 公克重 ……"

# 第一個參數代表 pattern,後者代表內文
match_object = re.search(r"小雞 (\d+) 公克重", text)

# 如果要抓到,就會回傳一個 Match Object,若無則回傳 None
if match_object:

    # group 函式會回傳截取的內容,0 代表自己, 1 代表第一個截
    # 取的內容,依此類推
    print(match_object.group(0))  # '小雞 30 公克重'
    print(match_object.group(1))  # 30

要同時找多個符合的結果,則可以使用 re.findall 函式:

import re

text = "小雞 白雞 黃雞 綠雞"


re.findall(r".雞", text)  # ['小雞', '白雞', '黃雞', '綠雞']

若需要多次同一 regex 搜尋,可以使用 re.compile 函式預先處理 regex 以增加效能。

import re

text = "小雞 白雞 黃雞 綠雞"

regex = re.compile(r".雞")
regex.findall(text)  # ['小雞', '白雞', '黃雞', '綠雞']

text = "黑雞 青雞 灰雞 橙雞"
regex.findall(text)  # ['黑雞', '青雞', '灰雞', '橙雞']

由於 regex 相當有用,所以無論是透過第三方函式庫,如 C++ 的 Qt、boost 等或是語言內建,像是 Perl、Javascript、C++11 等,現代的程式語言幾乎都能找到辦法使用 regex,舉幾個例子:

Qt:

#include <QCoreApplication>
#include <QRegularExpression>
#include <QDebug>

int main(int argc, char *argv[])
{
    QString text = QString("…… 小雞 30 公克重 ……");

    QRegularExpression re("小雞 (\d+) 公克重");
    QRegularExpressionMatch match = re.match(text);
    if(match.hasMatch()){
        qDebug() << match.captured(0); // 小雞 30 公克重
        qDebug() << match.captured(1); // 30
    }
}

Boost C++:

#include <iostream>
#include <string>

#include <boost/regex.hpp>

int main(int argc, char *argv[])
{
    std::string text("…… 小雞 30 公克重 ……");
    boost::regex pattern("小雞 (\d+) 公克重");
    boost::smatch matches;
    if (boost::regex_search(text, matches, pattern))
    {
        std::cout << matches[0] << std::endl; // 小雞 30 公克重
        std::cout << matches[1] << std::endl; // 30

    }
    getchar();
}

Javascript:

var text = "…… 小雞 30 公克重 ……";

var re = /小雞 (\d+) 公克重/;

matches = re.exec(text);

console.log(matches[0]); // 小雞 30 公克重
console.log(matches[1]); // 30

常見應用

說了這麼多 regex 的用法,我們到底可以拿 regex 做什麼用呢?

抓取特定格式的字串

抓取特定格式的字串,大概就是最常見的應用情景,通常會順帶截取字串裡的關鍵資訊。

import re

text = "…… 小雞 300 公斤重 ……"
match_object = re.search(r"小雞 (\d+) 公斤重", text)

if match_object and int(match_object.group(1)) > 100:
    print("好肥的小雞……開始減肥!")

在這個例子中,小雞的體重就成功的被我抓出來,之後的減肥計劃就可以順利展開了。

Note

有些網站會為了讓比較弱的爬蟲自爆,又或是為了節省頻寬。會在不影響網站顯示的情況下,故意讓部分的標籤的 end tag 去掉。如果單純以普通的 HTML parser 來處理這類型的網站時,爬蟲就會死給你看。

為了不要讓這些網站的奸計得逞,為了我們看漫畫的權益,很多情況下,除了用專門處理的工具像是 BeautifulSoup 外,也可以直接使用 regex 來解決。

<img src="([^"]+)">

(而且效能似乎較好)

驗證格式

我們也可以用 regex 來驗證某段文字的格式,這也是相當常用的做法。除了剛才見到的 Email 格式,也能用來驗證時間的格式、IP 的格式、URL 的格式等。許多時候,我們其實可以不用自己實際去寫這些對應的 regex,善用 Google 搜尋,可以節省自己許多力氣(其實 RegExr 就有提供很多例子可以使用了)。

"""
驗證是不是兩大類寫的文章
"""
text = "..."
if re.search("雞|chicken|喵"):
    print("可能是?")
else:
    print("也許不是?")

Note

雖然 regex 可以做的事很多,但很多時候,善用第三方才是最佳選擇。比方說驗證時間請愛用 python-dateutil

取代特定格式的內容

有時,我們會想替換字串裡的某些內容,像是字串中的「小雞」是換成「小肥雞」,最直覺的做法就是使用 replace 函式:

"... 小雞 ...".replace("小雞", "小肥雞")

但是我們也可能會碰到比較複雜的情況,比如說我們想修改一個腳本:

甲說:"烤雞好吃"

乙說:"才怪,炸雞才好吃"

我們想把腳本中的 "" 改我們比較習慣的 「」,這時單純用字串的 replace 函式,就比較不好處理了,我們可以改用 re.sub 函式解決這個問題。

text = """
甲說:"烤雞好吃"

乙說:"才怪,炸雞才好吃"
"""

re.sub(r':"([^"]+)"', r":「\1」", text) # '\n甲說:「烤雞好吃」\n\n乙說:「才怪,炸雞才好吃」\n'

re.sub 函式用法相對比較複雜,第一個參數代表要修改的 regex,第二個參數是要改成的字串,第三個參數則是來源字串。其中還有一些比較進階用法,這裡就不多提了。有興趣的可以直接參考官方的文件 (傳送門)

拆解特定格式的 token

Python 字串有一個好用的 split 函式,可以指定的參數將字串拆成多個子字串,比如說:

"小雞 白雞 母雞".split(" ")  # ['小雞', '白雞', '母雞']

"綠雞:藍雞:黑雞".split(":")  # ['綠雞', '藍雞', '黑雞']

另外,如果不輸入任何參數,可以直接將一系列的空白字元(包含tab、換行等) 去掉。

"灰雞      紅雞 \t \n    紫雞".split()  # ['灰雞', '紅雞', '紫雞']

不過這個函式還是有一些侷限性,如果同時碰到多種不同類型的分隔符號時,就無能為力了。

"黃雞 橙雞:青雞;金雞".split() # ?

這時就可以使用 re.split 函式來幫助我們。

re.split(" |:|;", "黃雞 橙雞:青雞;金雞")  # ['黃雞', '橙雞', '青雞', '金雞']

不過個人覺得這算是比較少用的功能。

編輯器上的應用

說個題外話,regex 也不只能在程式中用到,事實上,我們在「寫程式」本身,也可以用到 regex 實用的功能。現在很多編輯器,包含 Vim、Sublime Text 都可以使用,雖然語法可能會有些許的變化,但概念都是一樣的。

好比說之前碰過一個例子,專案裡有關圖表的變數命名方式與同事沒有協調好,結果同時出現 piechart 和 pie_chart 兩種命名,結果經過一些「親密交流」後,終於決定用 pie_chart 來命名……碰到這種問題時,也可以用 regex 解決。

https://marco79423.net/backend/static/c18d3908-4c88-11ee-9f5e-0242ac110003/

輸下 Ctrl + h,然後確認 Regular Expression 模式有開啟(最左邊的圖示)。

https://marco79423.net/backend/static/c18c0ff6-4c88-11ee-9f5e-0242ac110003/

按下 Replace All。

https://marco79423.net/backend/static/c18cb0fa-4c88-11ee-9f5e-0242ac110003/

搞定!

常見問題與注意事項

前面說了這麼 regex 好用的地方,但 regex 其實也有很多陷阱需要注意。

注意「/」和「\」

regex 不同的實作品,語法規則並不統一,不同實作,不同語言,甚或是同一語言不同版本都有所差別,雖然大多數常用的規則基本相同,但還是有一些地方要注意。

好比說 Perl 的 regex ,「/」是用來判斷 pattern 的開始與結束,擁有特定的意涵,因此 pattern 中的 / 前面要加上 \ 跳脫,受到 Perl 的影響,幾乎所有 regex 函式庫都對「/」有特殊的判定。

/<Name>([^<]+)<\/Name>/

但不是每個語言都將「/」當成分隔符號,好比說 Python 即是如此。這種情況如果還要跳脫「/」其實沒什麼道理,所以 re 其實也能同時支援不跳脫「/」的情況。

re.match(r"<Name>([^<]+)<\/Name>", "<Name>飛天雞</Name>") # 抓得到~

re.match(r"<Name>([^<]+)</Name>", "<Name>飛天雞</Name>")  # 也抓得到~

「\」也有異曲同工之妙,在 regex 中 \d 有特殊意涵,但有些語言本身本來就會對 \ 特殊處理(如 C/C++),可能要改成 \\d 才能運作。

尤其是 Python 在這裡也比較麻煩,因為它的「\」有時會特殊處理,有時不會,搞得像女人一樣難懂。

簡單來說,Python 的邏輯是--若『\』後面接特定字元,如「n」、「t」、「\」或數字時,就會當成特殊字元,其餘則不理會,因此在 Python 中,下例代表一樣的意義:

"\d" == "\\d"  # True

但這個例子便有差別:

"\n" == "\\n"  # False

尤其是後者,因為除了語言層面,「\n」對 regex 本身也有意義,因此:

re.search("\n", "\n")  # 找得到(語言自己判斷為特殊字元)

re.search("\\n", "\n")  # 也找得到(regex 函式庫判斷特殊字元)

所以這個地方很容易讓人產生困惑,因此我會建議一致使用 raw string 來寫 regex。Python 的 raw string 不會處理任何一個 \,所以不會造成混淆。

re.search(r"\n", "\n")  # 代表全交由 regex 函式庫判斷

Note

raw string 其實也並非全然都不處理,可以試試「r"雞\"」,看會發生什麼?

注意 * 是貪婪的

<strong>小雞</strong>

如果我想要抓取環繞「小雞」前的標籤,該怎麼做呢?

text = "<strong>小雞</strong>"

re.search("<.+>", text)  # 是不是這樣寫呢?

事實上,這種寫法實際抓出來的會是「<strong>雞</strong>」,而非「<strong>」,理由是 regex 搜尋的時候,會盡可能的抓越多越好,這個例子中「.+」同時符合「strong>小雞</strong」和「strong」,但由於 regex 是「貪婪」的,所以它會選擇多的那一個。

當然,我們也可以讓它不「貪婪」,那就是在「+」後加上「?」:

text = "<strong>小雞</strong>"

re.search("<.+?>", text)  # <strong>

這個方法雖然可行,但演算法實作的原因,效能會比較差一些,其實在很多時候,我們可以改用另一種寫法來達成同樣的目的,不需要學習新的語法。

text = "<strong>小雞</strong>"

re.search("<[^>]+>", text)  # <strong>

搞定!

注意換行

要注意 regex 是一行一行找的,所以對於換行的處理,有幾個比較奇怪的地方,舉個例子:

text = """
雞腿
雞心
雞肝
"""
re.search("雞.*", text)  # '雞腿'

雖然前面有說過「.」代表任意字元,但其實不包含換行,當碰到第一個換行時,就會停止抓取,所以最後只抓到「雞腿」就停止了。如果要讓「.」也能代表換行字元,就要加上「DOTALL」這個 flag 才行。

text = """
雞腿
雞心
雞肝
"""
re.search("雞.*", text, re.DOTALL)  # '雞腿\n雞心\n雞肝\n'

「^」和「$」的情況比較像反過來,前面說過兩者分別代表文章的「開頭」和「結尾」,但有時我們可能會希望這個「開頭」或「結尾」代表的是「行」的開頭和結尾,這時可以加上「MULTILINE」這個 flag。

text = """
雞腿
雞心
雞肝
"""

re.findall("^雞.*", text)  # []
re.findall("^雞.*", text, re.MULTILINE)  # ['雞腿', '雞心', '雞肝']

結語

本來還想寫一下 regex 實作相關的介紹,不過我覺得再加內容大概就沒人想看了吧?

總之,這篇介紹了 regex 的簡單用法和我對 regex 應用的心得,沒有太過深入討論 regex 太多詳細的規則語法,畢竟雖然規則很多,但多數都不太常用,我覺得需要時再上網查即可。在這個網路的時代,很多時候都不需要死背硬記,畢竟人的腦袋就這麼小小一顆,何必老是欺負它呢?

以上。

,,
,