一輩子受用的 Regular Expressions -- 兼談另類的電腦學習態度


這份講義已過時; 建議改參考 新的講義


Regexp 是什麼?

Regular Expression (簡稱 regexp 或 RE) 是什麼? 有人直譯為「常規表示式」; 筆者偏好意譯, 姑且叫它「字串樣版」。 它的功能是協助我們搜尋字串, 甚至對檔案內的特定字串做全面性的代換。 一般簡單的文字檔案編輯器 (例如 MS Windows 下的記事本, DOS 下的 edit 或是 Linux 下的 nano) 不支援 regular expression (以下簡稱 regexp), 所以我們在搜尋或代換字串時, 這些編輯器往往無法抓住我們的意思, 不是太寬鬆 (例如想找 port, 卻連 important 也找出來) 就是太嚴格 (例如想找 color, 卻遺漏了 colour)。 雖然大部分編輯器提供忽略大小寫等選項, 但所能解決的問題還是相當有限, 像上述兩個簡單的例子都沒有辦法解決。 如果改用 regexp, 則甚至可以處理諸如 "一份 html 檔裡面所有的 hyperlinks" 這類的複雜搜尋條件。

具體地說, 我們在想要搜尋的字串當中夾雜一些特殊符號, 指示電腦以較嚴格或較寬鬆的條件去搜尋, 這樣的字串就叫做 regexp。 例如要找 modem 或 Modem, 我們可以用 [Mm]odem 這個 regexp 來表示。 這裡的方括弧就是 regexp 語法中的特殊符號之一, 代表裡面任何一個字元放在這一個位置都可以 (但總歸要是一個字元)。

Regexp 可以應用在什麼領域?

任何用得上電腦的領域。 只要

  1. 你想處理的資料是某種文字格式 (.txt, .html, .csv, ...), 或者很容易與某種文字格式互轉而不失真 (例如 .sxw); 抱歉, 封閉的 .doc 格式 並不適用;
  2. 你想做的事情是有規律, 機械化, 重複繁瑣的動作

聽起來跟 「需要寫程式解決的問題」 很類似嗎? 沒錯, 有很多時候, 沒有學過 regexp 的人, 真的會著手寫一支程式來解決他的問題; 但是會 regexp 的人, 則會下一個稍長, 含有 regexp 的指令來解決同一個問題, 連文字檔案編輯器都不用打開, 就已經完成相同的任務。 且讓我舉幾個例子:

  1. 數個班級的同學混合進行專題分組, 老師傳來的成績依組別區分; 但系辦公室要將成績改成依班級區分, 以便傳給教務處。
  2. 經常上某個固定的網頁查看某類商品當天的促銷狀況, 想自動化。
  3. 將一份文件當中的美式日期 (月/日/年) 改成澳洲日期 (日/月年)
  4. 將數千個照片檔按照檔名分別放到不同的目錄底下。
  5. 寫一封信給過去幾個月內曾經與我通過三封以上 e-mail 的朋友。
  6. ...(請出題目給我, 讓我們把您的問題與我的解答擺在這裡與大家分享)

Regexp 可以拿來做這麼多神奇的事, 學起來卻遠比寫程式簡單許多! 它不是一種程式語言, 也不是一套應用軟體, 而是一套約莫兩打特殊符號的規則 (其實只要學一打就很夠用了)。 從廿年前的 vi 編輯器, 到現在的 java 語言, 在 許多不同應用領域的軟體上 都可以使用上這套規則。 只要有國中英文程度, 與幾小時的學習投資, 就可以學會 regexp, 這並不是資訊科系的專利。 另一方面, 它的知識價值卻比任何視窗軟體 (微軟視窗或 Linux X 視窗) 更長久, 因為它不會退流行, 而且你可以拿它來與其他知識發揮 組合相乘 的效果。 筆者特別呼籲 擅長分析語句文法的英文老師, 與喜愛分析組合勝過死記的數理老師, 花一點點工夫認識 regexp。 Regexp 絕對不比英文的複合句, 數學的因式分解, 物理的運動學更困難。 請您給我幾個小時, 讓我證明給您看, 這個, 而不是如何拉 MS Word 的選單, 才具有長遠的學習投資價值, 才應該是資訊教育的主體。

練習前的準備

在 *BSD 或各種版本的 GNU/Linux 底下學習 regexp 比較方便。 在這些系統裡面, 許多支援 regexp 的工具都是內建的, 不必另外安裝; 而且它們使用的資料檔多為開放透明的文字檔, 比較方便舉例。 當然 regexp 在 MS Windows 底下也可以使用 (如果花那麼多精神學習一套工具, 卻只能在一兩個作業平臺上使用, 未免太不划算!) 例如你的 Windows 系統內若裝有 perl, vi, 或 cygwin, 就可以用 regexp。 不過 windows 下到處都是 封閉的 .doc 檔, 無法發揮 regexp 的長處。

所以筆者假設你有一個 Linux 帳號可以使用, 並且不害怕學一點 命令列操作。 使用本講義的老師們, 如果不方便準備安裝有 Linux 的主機並開設帳號, 可以考慮發免安裝, 直接光碟開機即可使用的 knoppix 光碟給學生。 以下用 Mandrake 9.1 為例; 如果是其他版本, 命令下法完全一樣, 只有檔案與目錄的位置可能要調整一下。 例如前幾個實驗裡面用最多的 howto 文件, 在 Mandrake 底下, 放在 /usr/share/doc/HOWTO/HTML/en 裡面。 sunsiteibiblio 找到最近的 Linux-HOWTOs-2005*.tar.gz 檔案, 解壓縮至任意目錄。

我們需要用到 less 來閱覽文字檔案, 也可能需要用到 lynx 文字瀏覽器將我網頁上的資料抓回你的帳號實驗。 有時候我們下很長的指令, 如果你會用上箭頭把過去的命令叫出來改, 還會用 [TAB] 鍵讓電腦替你完成檔案名稱快打, 會方便很多。 例如要進入上述 howto 文件的目錄, 可以這樣打: cd /usTABshTABdo TABHOTABHTTABen 這些都是 readline 使用者界面 所提供的功能。

支援 regexp 的工具很多, 但其中以 perl 的支援最為豐富, 所以本文拿 perl 作例子。 別緊張! 你不必學 perl。 我準備了三種句型:

  1. perl -ne 'print if /.../' < 資料檔名
  2. perl -ne 'print "$1\n" if /..(...)../' < 資料檔名
  3. perl -pe 's/.../.../g' < 資料檔名

[輸入輸出重新導向]

實驗時, 只要把其中一句話剪貼到你的命令列視窗上面再加以修改就可以了。 讀者只需要理解/專注在斜線之間的符號變化, 不必理會其他部分究竟是什麼意思。 當然 「資料檔名」 不要照抄; 檔名及路徑的 大小寫要完全照打; 單引號要成對。 Linux 的 shell (接受/執行命令列的程式) 一板一眼, 堅持要求命令列上每個參數都要分清楚, 所以該留空格的地方, 就請務必留空格, 例如 -ne 與 -pe 的前後, 及檔案名稱之前的空格都不可以省略。

我們下的 perl 命令, 它要處理的資料, 可以有兩種不同的來源: (1) 靜態的文字檔, 像這樣: perl -ne '...' < 資料檔名 這裡的小於符號表示 "原本要從鍵盤上敲進去的資料, 改由資料檔讀入" (對我們所下的三種 perl 句型而言, 其實這個小於符號省略不寫也可以; 但解釋又不太相同。 為了解釋方便, 我們一律寫出。) (2) 由前一個命令動態即時印出來的資料, 像這樣: who | perl -ne '...' 這裡的直線符號, 念作 pipe, 意思是將前一個指令本來要印到螢幕上的資料, 直接餵給下一個命令當做輸入資料來吃, 彷彿用水管將兩個命令串起來一樣。 這兩種語法請不要搞混。 如果拿英文來作比喻, 資料檔名是名詞, 而命令是動詞, 第一種寫法是簡單句, 只有一個動詞; 第二種寫法是複合句, pipe 符號前後各有一個動詞。

英文文件跳著讀, 專業知識得來速

第一個例子要告訴讀者: 學 regexp 絕對不是資訊人的專利, 其他領域的朋友也能夠從這裡獲得許多好處。 今日的臺灣, 必須與國際社會接軌, 不論是財經/法律/管理/... 任何領域/專長的讀者, 都逃不掉要閱讀英文文件, 這其中有許多文件來自網路上, 本來就以純文字檔或 html 檔的形式存在。 我們希望可以在厚厚的一疊英文文件當中, 比同儕更快找到需要的資訊, 而不必老老實實地逐行閱讀完整的英文文件。 當然也希望不要因為馬虎搜尋而忽略掉太多相關的資訊。 這就是使用 regexp 的時機了。

話說回來, 筆者的知識局限在電腦, 所以就用電腦文件為例吧 :-) 請非資訊專長的讀者注意我們要搜尋的英文字串就好, 不要太在意順便提到的電腦知識。 從前剛開始學 Linux 的時候, 撥接上網出了問題。 我知道這個問題與 I/O port (輸入/輸出阜) 有關, 又知道可以到 howto 文件裡面去找答案, 所以就在 HOWTO-INDEX (所有 howto 文件的總索引) 裡面搜尋 "port", 看看有那一篇文件提到輸入/輸出阜: 先更換「目前工作目錄」到 /usr/.../en/HOWTO-INDEX 底下, 然後下: perl -ne 'print if /port/' *.html 這會將所有 html 文件當中, 所有提到 port 的那幾列給印出來。 如果一下子印太多, 跳太快, 可以將印出來的結果丟給 less, 分頁印出來看比較清楚: perl -ne 'print if /port/' *.html | lessless 裡面, 還可以按 /portENTER, 就可以看到所有的 port 字串反白。 請按 G (大寫!) 跳到最後面, 看看總共抓到多少列 「含有 "port" 字串」。 最後按 q (小寫!) 離開 less。

顯然沒有抓到大寫的 Port。 可以改搜尋 /[Pp][Oo][Rr][Tt]/ 或者乾脆寫 /port/i 這裡的 i 是 ignore, 忽略大小寫差別的意思。 (好了, 我要開始偷懶, 不寫完整的指令, 只寫 /.../ 之間, 字串樣版在變化的部分; 當然你必須把完整的指令打出來。) 請再次在 less 當中搜尋 port, 跳到最後面看看抓到多少列, 再按 q 離開。 注意: perl 之前的搜尋動作 (目的在篩選過濾資料) 與之後在 less 裡面的搜尋 (目的在反白及移動遊標) 不相關。 如果注意看, 會發覺在 less 裡面, 大寫的 port 並未反白。 你可以在當初命令列上多放一個 -i, 像這樣: perl -ne 'print if /port/i' *.html | less -i 也可以在進入 less 之後才按 -i 鍵, 叫 less 也忽略大小寫。

但是, 怎麼連一點都不重要的 "important", 甚至是葡萄牙 (Portuguese) 都跑出來了呢? 其實我們要的 port, 是單獨出現, 完整的一個字。 我們希望 p 之前與 t 之後不要有其他英文字母 -- 希望它們出現在字的邊界。 請改用 /\bport\b/i 搜尋。 (這裡的 \b 相當於 less 裡面的 \< 與 \> ) 這次條件變得比較嚴格, 搜尋到的列數是否少了許多? 不過英文有個討厭的地方: 名詞有分單複數。 像這樣找, 複數的 ports 都被我們排除在外了。 沒關係, 請改用 /\bports?\b/i。 這裡的問號相當於 {0,1} 也就是 「前面的東西可以有, 也可以沒有」 的意思。 請注意找找看是否多出一些複數, 搜尋到的列數是否又增加了呢? 至此, 我們找到了 「不嵌在其他字當中, 不管大小寫, 單數或複數的 "port"」。

後來在使用 PPP 撥接上網的時候, 又遇到撥接失敗, 但卻沒有錯誤訊息的問題。 有許多程式, 並不把錯誤訊息印在螢幕上, 而是印到檔案裡面, 這個動作叫做 log。 於是我到 PPP-HOWTO 裡面去搜尋 "log"。 還是一樣, 又找到一大堆 analogue, Catalogue 等等不相關的字。 再改用 /\blogs?\b/i 搜尋, 就好很多。 但是英文的動詞變化不只加 s, 還有現在分詞與過去分詞。 所以改搜尋: /\blog(|s|ged|ging)\b/i 這樣是否正確多了呢? 也請在 less 裡面搜尋 \<log(|s|ged|ging)\> 反白看起來更清楚。 這裡的小括弧與 | 符號代表從多個字串中選一個: 我們要找的是 log, logs, logged, 或 logging. 請注意這比 [ ... ] 功能更強, 因為方括弧是: 從多個字元中選一個. 換句話說, 所有 [ ... ] 能做的事, 其實都可以用 ( ... | ... | ... ) 來做; 另一方面, 用小括弧與 | 來取代方括弧, 當然會比較囉嗦一些. 例如 [aeiou] 的效果和 (a|e|i|o|u) 一樣。

不要前後文, 只印出真正有興趣的部分

如果讀者真的操作上面的例子, 會發覺上節中 perl -ne 'print if /REGEXP/;' 這樣的句型會把含有 REGEXP 的一整列全部給列印出來. 如果我們只要 REGEXP 本身呢? 那就要用 (...) 和 $1, $2, ... 了。 使用小括弧和「錢號數字」的組合, 可以圈選出我們有興趣的子字串, 並告訴 perl 我們只想印那幾對小括弧內的子字串。

上面的 perl 句型, 會將找到的字串 連同同一列上的前後文 一起印出。 但這樣並不方便, 因為有時候我們希望只印出真正有興趣的部分就好。 此時可以在 regexp 外面包一對小括弧, 然後稱之為 $1 將它印出來。 例如我們想分析 last -a 指令的輸出。 它印出過去一段時間內, 每個登入主機的人次。 如果想抓取 「星期幾」 欄位, 可以這樣下: last -a | perl -ne 'print "$1\n" if /\b([A-Z][a-z][a-z])\b/' 就像吃西式自助餐一樣, 只取我們真正有興趣的東西, 不要取而不用, 感覺更清爽。

請注意: 如果檔案內的一列上有兩個以上符合 regexp 描述的字串, 那麼這第二種句型只會印出每列上第一個符合條件的字串, 後面其他符合條件的字串都不會印出來。 例如我們要抓的如果是月份, 長相與星期幾一模一樣, 該怎麼辦呢? 搜尋時只好改成搜尋 「大小小空大小小」 (分別指 "大寫", "小寫", "空格"), 並且在第二串 「大小小」 外面加上一對小括弧就可以了。 請根據提示自己試試看。

又, 如果想同時印出月份及每次連線有多久, 例如:

        ckhung   :0           Sun Oct 26 20:51 - 21:54 (01:03)

應該如何寫呢? 與上一例 (抓月份) 相同, 雖然我們對「星期幾」沒有興趣, 但是它的長相與我們有興趣的字串相同, 我們不得不從它開始比對起: perl -ne 'print "Month: $1 Duration: $2\n" if /\b[A-Z][a-z][a-z] ([A-Z][a-z][a-z]).*\(([0-9][0-9]:[0-9][0-9])\)/' 這裡的 * 相當於 {0,無窮大}, 也就是說前面那個東西 ("任意一個字元") 可以重複出現 0 次, 1 次, 2 次, ... 任意次。 又, 因為小括弧在 regexp 裡面有特殊意義, 所以當我們真正對「小括弧字元」有興趣的時候, 前面必須加上倒斜線, 取消它的特殊意義。 最後注意到 print 後面的字串其實隨我們高興愛怎麼印就怎麼寫, 不見得只能寫 $1 (當然如果用到 $2 $3 等等, 後面就必須真的有那麼多對小括弧才有意義)。

再例如我們想將 constants.txt 檔案裡面, 每列上出現的 (第一個) 數字抓出來。 先下一個簡單的 regexp 試試看: perl -ne 'print "$1\n" if /([0-9]+)/' constants.txt 這裡的 + 號相當於 {1,無窮大}, 也就是說前面那個東西 ("一個數字") 可以重複出現 1 次, 2 次, 3 次, ... 任意次。 諸如 {3,5} ? * + 等等符號, 功能都在重複前一串 regexp, 也稱為 quantifiers。 回到我們的例子, 這裡在抓 「連續出現的數字」。 但是這樣只能抓到整數部分, 而且沒有正負號。 以下我們省略完整的命令, 只寫出 regexp, 逐步提高它的複雜度, 請讀者自行用上箭頭叫出前一個命令來修改, 並注意每次抓到的數字是否更加完整。

  1. 因為 [0-9] 太常用到了, 在 perl 裡面有一個簡寫: /(\d+)/ 這裡的 \d 就是 digit "一個數字" 的意思。
  2. 再考慮正負號 (但也可能沒有): /([+-]?\d+)/
  3. 再考慮小數部分 (但也可能沒有): /([+-]?\d+(\.\d+)?)/ 注意第二個問號指的是前面小括弧內一整串 ("小數部分") 可有可無。
  4. 最後考慮指數部分 (但也可能沒有): /([+-]?\d+(\.\d+)?([Ee][+-]?\d+)?)/

從這個例子, 我們也學到一個態度: 初學時, 別想要一步登天, 先從簡單的狀況處理起, 逐步修改到正確; 每修改一點, 就立即實驗一下。 另外, 錯誤訊息 是學習過程當中的至寶; 如果習慣性地忽略錯誤訊息, 學習的效果會非常差。

提到 perl 為常用 regexp 提供的簡寫, 還有 \w 表示 「一個文數字」 (word character), 等同於 [0-9a-zA-Z] 以及 \s 表示 「一個空白類字元」, 等同於 [ \t\n]。 這些簡寫只在 perl 的 regexp 裡面能用; 其他支援 regexp 的語言並沒有這樣的符號。 當然這並沒有增加 perl 的 regexp 功能, 只是讓使用者可以寫出比較簡潔的 regexp 而已。

再換一個例子請讀者自行練習: 從文件裡面抓出 ip。 也就是想抓 「看起來像是 host name」 的字串, 例如 163.17.9.5, penguin.im.cyut.edu.tw, mail.so-net.net, ... 等等。 不妨就將本頁的原始碼存檔, 拿 regexp.php 當做資料檔來做實驗。 請參考以下提示, 逐步將你的 regexp 複雜化:

  1. 先抓出連續出現的文數字或減號。
  2. 後面再加上 「一個句點, 及一串連續出現的文數字或減號」。 因為 . 在 regexp 裡面有特殊意義 (「任何一個字元」), 所以這裡的 「一個句點」 前面必須加上倒斜線, 將它的特殊意義取消: \.
  3. 其實第二步所加的東西可以重複出現 2 次, 3 次, ... (大概不會超過 9 次吧)。

參考答案在網頁原始碼某處 (沒有所謂的「正確答案」啦)。

最後看一個稍微複雜而完整, 很有成就感的例子: 網頁伺服器會將過去來訪本網站的每一人次摘要記錄在一個 access_log 檔裡面。 假設我想統計每個小時 (0 點到 1 點, 1 點到 2 點, ... 23 點到 0 點) 的來訪人次, 產生 像這樣的圖。 需要撰寫複雜的 C/C++/Java 程式嗎? 先用 regexp 與一些簡單的命令: (每一步下完, 請用 less 看一下新產生的資料檔, 了解每一句話的意義, 並且看看是否與這裡的結果相同?))

    perl -ne 'print "$1\n" if /:(\d\d):/' < access_log > ws_1.txt
    sort < ws_1.txt > ws_2.txt
    uniq -c < ws_2.txt > ws_3.txt
    perl -ne 'print "$2 $1\n" if /\s*(\d+)\s+(\d+)/' < ws_3.txt >
    ws_4.txt

上面的 sort 指令, 顧名思義就是在排序; uniq -c 則將相鄰的重複列刪除, 並且計算重複的次數。 最後進入 gnuplot 下兩個指令

    set style data lines
    plot "ws_4TAB"

就完成了! 注意到在 gnuplot 裡面的 TAB 鍵一樣也有檔案名稱快打的功能。 如果你不怕長句的話, 上面四句其實可以改用 pipe 串在一起, 完全不需要產生中間的臨時檔。 滑鼠那麼好用, 為什麼許多真正的高手卻更喜歡用鍵盤? 比手畫腳那麼方便, 為什麼我們非英文科系的一般民眾也要要學英文? 非資訊科系不能學命令列嗎? 什麼樣的資訊教育才可以 組合活用, 才有長遠的投資價值? 筆者對此有一些看法, 完全不同於目前強調拉選單, 比炫麗的主流資訊教育思想。 (要求所有科目的老師用微軟公司的 power point 做教材, 就是資訊資訊融入教學? 呼! 哈!) 讀者在看完這個例子之後, 也許能夠產生一些共鳴。

貪婪: 需要的不多, 想要的太多

regexp 的搜尋引擎有一個特性, 叫做 greediness -- 能吃多少, 就盡量吃多少。 還記得 less 裡面搜尋 "長度 5-7 的英文字" 那個例子嗎? 如果不指定邊界 (\b), 為什麼 "greatapeproject" 當中的前十四個字母會反白? 請再拿本頁的原始碼做實驗, 用 less 閱讀, 並搜尋 「一對雙引號之間的字串」: ".*" 用 perl 的第二種句型寫, 就是這樣: perl -ne 'print "$1\n" if /"(.*)"/' regexp.php | less 不妨在 less 裡面再搜尋一次, 令它反白。 注意到了嗎? 大部分 「一對雙引號之間的字串」 都被正確抓出; 但是有些地方卻因為一列上出現數個字串, 而令 regexp 抓過頭了。 例如 "text/css" 出現的那一列上面有三個字串, 結果 less 與 perl 從第一個引號開始, 到最後一個引號為止, 把其間所有東西都抓出來了。 這可不是因為 less 會抓出所有符合條件字串的關係 -- 果真如此, ' rel=' 和 ' href=' 就不應該反白才對。 這是因為 regexp 非常貪婪, 會在滿足搜尋條件的前提下, 盡情地吃到無法再吃為止。 但其實我們需要的並不多, 看到第二個雙引號, 老早就應該停下來了。 有時候, 貪婪還真是會誤事。 難怪 Linus Torvalds 要說: 「無論如何, 貪念永遠不是件好事」。

要解決這個問題, 可以用 [^...] "除了 ... 之外的任何一個字元"。 只要將 "任意字元重複出現任意次" 改成 "除了雙引號之外的任意字元重複出現任意次" 就可以了: perl -ne 'print "$1\n" if /"([^"]*)"/' regexp.php | less 請用錯誤 (貪婪) 的方式再試一次, 用力比較一下什麼地方不一樣。

在 perl 裡面, 還有一個更優雅的阻止貪婪搜尋的方法。 所謂貪婪搜尋的問題, 其實都出在 quantifier。 因為 quantifier 的出現, 才讓 regexp 搜尋引擎有機會選擇多吃一點或少吃一點。 任何一個 quantifier 後面只要加上問號, 就可以將這個 quantifier 轉化成不貪婪的 quantifier, 一旦吃足符合 regexp 的資料, 就盡早停下來, 不再多吃, 像這樣: "(.*?)"。 也許可以把這裡的問號翻譯成: 「我真的需要這麼多嗎?」

再舉一個例子。 /etc/passwd 這個檔案, 裡面存放使用者的個人資訊。 請用 less 閱覽, 並搜尋你的使用者代號。 注意連續的兩個冒號, 這中間其實有一個存放 "真實姓名, 辦公室, 辦公室電話, 家中電話" 的欄位, 不過一開始都是空的。 請下 chfn 指令修改你的個人資訊。 (提示: 沒有人規定不可以填一點笑話進去; 當然你要先通過密碼檢查一關, 否則任何人都可以趁你離開電腦五分鐘時亂改你的資訊, 就不好玩了。) 下面這句話會產生 "使用者代號 = 真實姓名" 的對照表: perl -ne 'print "$1 = $2\n" if /^(.*?):.*:(.*?),/' /etc/passwd 請猜猜看以下搜尋條件分別會得到什麼結果? /:(.*),//:(.*?),//:[^:,]*,/ (當然, 現在只有一對括弧, 下命令的時候, print 後面就不應有 $2)

一個 quantifier 後面加了問號 (最常見的是 +?*?) 並沒有改變它的意義, 改變的只是它的 「貪婪程度」。

全面代換字串

前面談的都是搜尋; 以下介紹如何運用 regexp 來對檔案內的特定字串做全面性的代換。 例如當朝陽技術學院升格成朝陽科技大學的時候, 需要將所有文件裡面的 cyit 改成 cyut; 其他地方原封不動。 如果只有 regexp.php 一個檔案要改, 可以這麼下: perl -pe 's/\bcyit\b/cyut/g' regexp.php > new.php (螢幕上看不到東西? 當然! 被我們用 output redirection 轉入檔案去嘍。 如果將上句中的 > new.php 換成 | less 就可以看到啦。) 然後用 diff 比較: diff regexp.php new.php | less 它會將兩個非常相似的檔案抓來比較, 只印出有差異的地方, 以 < 開頭的是左檔的內容; 以 > 開頭的則是右檔的內容。 如果沒有問題, 最後再下 mv new.php regexp.php 覆蓋舊檔。

當然我不只一個檔案要改, 所以真正下的指令是: perl -i -pe 's/\bcyit\b/cyut/g' `find . -iname '*htm*'` 將本目錄底下所有檔名當中出現 htm 的檔案一口氣全部抓來修改。 不過這是危險動作, 初學者請勿模倣。 有興趣的讀者請自行研讀 shell 講義 當中的命令結果代換, 並查手冊 man 1 perlrun 裡面有關 -i 的說明。 即使是筆者也習慣用上一段的語法; 如果要一次改很多檔案, 也會先用安全的語法實驗, 找出最恰當的 regexp 寫法之後, 再改用這段的危險語法, 以免平白磨損硬碟。 此外, 先行備份以防萬一也非常重要。 水能載舟, 亦能覆舟; 學習運用原力的絕地武士們可要戒慎 (但是不要恐懼) 啊! 以下我們就用上段的安全語法來寫例子。

美國日期的寫法是 「月/日/年」 例如 "12/25/1999"; 而澳洲日期的寫法是 「日/月/年」 例如 "25/12/1999"。 想將 report.txt 檔案中的美式日期全部改成澳洲式日期, 可以這樣下: perl -pe 's#\b(\d\d)/(\d\d)/(\d\d\d\d)\b#$2/$1/$3#' < report.txt

想要製作乾淨/有效/無障礙的 html 檔案, 最簡單的方式就是遵循 w3c 建議的 "Separation of Content from Presentation" 觀念 以 "強調" 為例, 在文件當中應避免使用 <i> 而要改用 <em> 更不應該在文件裡面放一大堆 <font> (請不要說我對微軟有偏見 -- 它的 FrontPage 確實毒害了太多網頁設計者, 進而傷害了視障者及其他弱勢族群的瀏覽權益。 請幫忙呼籲親朋好友, 叫大家發揮道德良知, 別再用 FrontPage 了!) 兩者其實都是斜體字, 但用 i 比較注重排版外觀; 用 em 表示重視的是文件結構) 以下這句把 test.html 檔案裡的 <i> 全部改成 <em> 同時把 </i> 全部改成 </em> perl -pe 's#<(/?)i>#<$1em>#g' test.html 遇到 <i> 時, 這裡的 $1 就是空字串; 遇到 </i> 時, $1 就是 "/"

用命令產生一長串命令

把目前目錄下的所有 .php 檔更名為 .html 檔: find . -name '*.php' | perl -pe 's/^(.*)\.php$/mv $1.php $1.html/' 上面只是產生許多 mv 指令而已, 並沒有真正執行更名的動作。 如果確定沒錯, 可以把上面的結果 pipe 給 bash 去執行。

更改檔名這個例子示範了一個比代換字串更強大的技巧: 我們不僅可以把資料轉換成另一形式的資料, 還可以把資料轉換成程式! 以下列出幾個可以應用的場合, 請讀者自己試著寫出細節:

  1. PostgreSQL 目前似乎無法批次匯入 (import) 大量資料, 如何將既有的純文字表格輸入 PostgreSQL? 可以把純文字表轉換成 psql 可以處理的 SQL 指令檔。
  2. eznet 電話撥接程式 (真的很容易設定!) 可以管理很多 ppp 帳號。 如何把現有的帳號資料一次輸入 eznet?
  3. gnuplot 可以畫複雜的函數圖形; 但是要畫大量的箭頭或文字就必須一句一句寫. 如何讓 gnuplot 把一個座標檔內的資料都以箭頭的形式畫出來呢?
  4. 目錄 A (以及它的子目錄, 孫目錄 ...) 底下有許多文字檔; 目錄 B 有相同的目錄結構與檔案, 而檔案的內容也幾乎與目錄 A 下相應的檔案一模一樣. 如何用 diff 命令找出究竟有那幾個檔案不同?
  5. 延伸上例, 假設讀者身為老師, 發覺學生 A 與學生 B 的期末作業 (許多 *.h, *.c 檔, 分別放在 A 與 B 兩目錄下) 用上題的方式檢查之後, 出奇地類似, 看起來只是部分變數名稱改變。 除了變數名稱的改變之外還有多少相異呢? 這裡要用 perl 產生的程式, 是內含呼叫 perl 命令的 shell script。

Perl 的 regexp 總整理

常用的 regexp 符號可以大致分為三類:

  1. 比對 「一個字元」 的符號:
    • [...] ... 當中任何一個字元
    • [^...] 除了 ... 之外的任何一個字元
    • . 任何一個字元
  2. 具有 「定位」 功能, 但本身不吃掉任何字元的 anchor:
    • ^... 以 ... 開頭的字串
    • ...$ 以 ... 結尾的字串
    • \b 文數字/非文數字 的邊界。
  3. 計數用, 表達 「前面那個東西重複出現多少次」 的 quantifier:
    • {5} 重複 5 次
    • {3,7} 重複 3 到 7 次
    • ? 可有可無 (0 次或 1 次)
    • * 重複出現任意次, 包含 0 次
    • + 重複出現任意次, 至少 1 次
    所謂 「前面那個東西」 可能是一個字元, 也可能是由小括弧括起來的一長串

另外還有表達 「這一串」 的 (...), 及表達 「或」 的 |

如先前所說, 支援 regexp 的程式很多。 以上所列的觀念, 在大多數支援 POSIX 標準的語言/軟體裡面都可以找得到。 但是不同的語言/軟體, 採用的符號可能有一點點細微的差別。 例如表達邊界, 在 perl 裡是 \b 但在 less 或 vi 裡卻是 \<...\> 又例如在 grep 當中, ? + | ( 等等符號要加上倒斜線才有特殊意義; 但在 perl 裡面, 這些字元反而是加了倒斜線就失去特殊意義, 變成比對到它本身。 讀者不必擔心記不得這麼多不同的規則 -- 筆者也記不太起來 :-) 瞭解觀念最重要; 至於細節, 需要用的時候再查手冊就好了。

我們之所以選擇 perl 來教 regexp, 有好幾個原因。 第一, perl 有一個很容易記的規則: 凡是標點符號, 加上倒斜線, 一定沒有特殊意義

第二, perl 替最常用的 [...] 定義了簡寫:

  1. \d 其實就是 [0-9], "任何一個數字"
  2. \D 其實就是 [^0-9], "任何一個非數字"
  3. \w 其實就是 [a-zA-Z0-9_], "任何一個文數字"
  4. \W 其實就是 [^a-zA-Z0-9_], "任何一個非文數字"
  5. \s 其實就是 [ \t\n], "任何一個空白類字元"
  6. \S 其實就是 [^ \t\n], "任何一個非空白類字元"

這裡有關 \w 與 \W 的說明並不嚴謹. 若要處理英文以外的西方語言, 請參考 perlre(1) 與 perllocale(1)。

第三, perl 的彈性很大, 小小的變化就可以造出三種不同的句型, 應付常用的搜尋/代換工作: (實際上簡單的變化還多得是; 不過筆者必須忍痛就此打住) [圖解 'regexp 的三種常用句型']

  1. perl -ne 'print if /.../' 含有特定字串的那幾列, 全都印出來。
  2. perl -ne 'print "$1\n" if /..(..)../' 不要前後文, 只印出特定字串就好。
  3. perl -pe 's/.../.../g' 把文中所有特定字串全部代換掉。

其他支援 regexp 的工具

事實上如果用的是比較簡單的 REGEXP 來搜尋, 那麼 grep 'REGEXP' 的效果和 perl -ne 'print if /REGEXP/' (第一種句型) 的效果一樣。 所以簡單的搜尋 (只用到 [] . ^ $ * \b) 通常都用 grep 指令。 但是在 grep 中, 想使用 ? + () {} | 等功能, 必須要在前面加上 \ 例如搜尋 port 的範例, 如果用 grep 來做, 要寫成: grep -i '\bports\?\b' howto-index 所以比較複雜的搜尋, 用 egrep 或 perl 寫起來反而比較清楚。

同樣地, 簡單的字串代換可以用 sed 來做。 sed 's/REGEXP/SUBST/g' 的效果和 perl -pe 's/REGEXP/SUBST/g' 一樣。

高手們最喜歡常用的文字編輯器 vi 與 emacs 當然也都支援 regexp。

在 perl 紅起來之前, awk 曾經是最受系統管理人員歡迎的萬用瑞士刀。 事實上 perl 有很多語法都是從 awk (還有 C, 還有 shell script, ...) 學來的。

用 archie 尋找 ftp archives 時, 也可以使用 regular expression。 花的時間比較多, 但是或許可以避免遺珠之憾。

幾乎所有 scripting languages 都支援 regexp, 例如 perl, python, php, tcl, ruby, guile, ... 等等。

編譯語言 (例如 C, C++, ...) 的使用者可以取得 pcre 程式庫。 [7]

但 regexp 不是只給程式設計師用的。 許多 GUI 軟體 也支援 regexp。

永遠的朋友 -- 文字檔

太空梭很棒; 但是大多數時候 腳踏車 比較實用。 我所有的資料 (程式, 文件, ...) 都盡量用文字檔儲存。 搭配 regexp, 在任何艱困的環境底下都能夠工作。

作業與更多範例

覺得內容太多, 一時無法吸收嗎? 沒有關係, 這些知識具有長遠的價值, 不會退流行, 所以也不急著立即完全吸收。 筆者花了很多年的時間, 每幾年學一點, 才將 regexp 學好 (例如 *?+? 就是筆者開始教 regexp 很多年之後才學會的)。 另一方面, 在還沒有學完整之前, 就已經可以開始應用於日常作業當中了。 重點之一是: 要經常使用, 才會熟悉。 重點之二是: 不論是否用得出來, 至少要經常 認出可以使用 regexp 的場合。 如果您遇到一些實用的問題, 即使只是查覺到可能可以 (但不知如何) 用 regexp 解, 本文的目的也就達成一半了。 筆者懇請您將問題清楚描述, 如果確實可以用 regexp 解決, 我樂意為您服務; 如果您已自己解決問題, 更歡迎與我們分享。 因為這不僅解決您的問題, 同時也可以讓我收錄入範例/作業/考試當中, 讓其他讀者一併獲益!

  1. 用 man 1 perlfunc 看手冊時, 想找 printf 函數的說明, 可以按 /printf 搜尋 printf, 並連續多按幾個 n (搜尋下一個)。 但是這樣做太慢 -- 因為用 printf 做例子的地方太多, 要按很多次 n 才會找到 printf 的定義。 怎樣找會比較快一點呢?
  2. 有些文字檔後面會多出一些沒有用的空白字元。 請用 regexp 將它們刪除掉。
  3. look 命令可以查字典 (沒有解釋, 只能知道如何拼字) 例如 look pr 會印出所有以 pr 開頭的英文單字。 想知道有一個英文單字 "特權" pr...ge 怎麼拼, 應該如何找比較快?
  4. 在 linux 下要修改系統設定, 如果不熟悉眼前的視窗環境, 還是可以用下指令的方式來進行。 問題是要下什麼指令才能修改滑鼠或網路的設定呢? 可以先下: ls /bin /sbin /usr/bin /usr/sbin/ | perl -ne 'print if /(cfg|cnf|conf|config)/' 得知系統內有那些看來像是改變設定用的指令, 再從裡面撿出與滑鼠或網路相關的指令來試。
  5. (林坤鋅君提供) 顯示第二到第三層各目錄使用空間多寡: du | perl -ne 'print if /\.(\/[^\/]+){2,3}$/;'
  6. (給朝陽的同學) 某個檔案裡面有系上同學的某些資料, 每列一筆, 其中包含學號。 (不妨用 last 指令的輸出做實驗) 請下指令抓出屬於你們班同學的部分 (單雙號有別, 不要抓到隔壁班的哦!)
  7. Unix 底下的系統信箱放在 /var/spool/mail/ 目錄內。 請用 less 檢視檔案內容, 看看如何下指令抓出最近寄信給我的人。 如果你像我一樣用 mutt 或 elm 或 pine 等文字模式的軟體收發信件 (真的很方便, 速度快很多) 那麼讀過的信件一樣也以純文字格式儲存在家目錄底下 (例如 ~/mail/mbox) 。 如果你會 perl 語言, 還可以進一步寫一個短短的程式抓出 "過去六個月內至少曾經寄三封信給我的人"。 然後一個指令就可以寄信給這些人。 (也許你的 e-mail 改變了, 要通知眾親朋好友。)
  8. /usr/X11R6/lib/X11/app-defaults/ 目錄下有許多設定檔, 裡面包含各種視窗前景背景顏色的設定等等, 長得像這樣: Rosegarden*background: #ffe4b5 這裡井字號開頭的三位數或六位數 16 進位數字代表顏色的 rgb 成分。 請抓出所有顏色 (不要前後文)
  9. 在 DOS 下, 文字檔內的換列由 ^M ^J 兩個字元構成; Linux 下文字檔的換列只有 ^J 一個字元。 請下命令, 將來自 DOS 的文字檔每列最後面的 ^M (在 perl 裡面可以用八進位表示: \015 或用十六進位表示: \xd) 刪掉。 當然也可以試著將來自 linux 的文字檔每列最後面加上 ^M, 變成方便 DOS 處理的文字檔。
  10. 如果你把手冊的輸出存檔, 例如 man perl > perl.txt 再用 editor (例如 vi 或 nano) 去看, 會發覺檔案內有很多 ^H 也就是倒退字元。 這是因為過去的印表機都是靠 "倒退再印" 的方式來印底線或粗體字。 請下命令刪除這些字元。 (^H 用八進位表示: \010; 用 十六進位表示: \x8) 其實在 Linux 下其實可以直接用 man perl | col -b 解決這個問題, 不需要用 regexp。
  11. 許多版本的 linux 以 rpm 管理套件。 下這個命令: rpm -qa --qf '%{NAME} <%{GROUP}> <%{SUMMARY}>\n' 可以查詢系統內有那些套件, 各別屬於那一類, 並印出摘要說明。 不要理會複雜的命令, 我們有興趣的是它的結果:
            mpg321 <Applications/Multimedia> <An MPEG audio player.>
            vnc <User Interface/Desktops> <A remote display system.>
            libxml <System Environment/Libraries> <An XML library.>
            icewm <User Interface/Desktops> <X11 Window Manager>
            ..。
    
    請用 regexp 抓出第一對角括弧號裡面的東西 (例如 "Applications/Multimedia") 請分別試試看貪婪版, 「除...之外」版, 及不貪婪版的效果。
  12. 請分析 last 的輸出, 統計週日至週六每天的登入人次 (不分那一週, 只要同是週二, 就全部算在一起) 並按照登入人次多寡對這七天排序。 提示: 把 perl 處理完的資料 pipe 給 uniq -csort -n 接續處理。
  13. gnuplot 講義裡面, 有一個處理文字檔案, 產生 gnuplot 命令稿, 畫出亞洲各國人口密度比較圖的例子, 也用了大量的 regexp。

參考資料

  1. http://irw.ncit.edu.tw/peterju/webslide/re/ 勤益科大朱孝國老師的講稿, 有豐富的歷史與背景資料及實例與解答
  2. http://people.ofset.org/~ckhung/b/sa/cygwin.php
  3. http://people.ofset.org/~ckhung/a/c041.php
  4. http://people.ofset.org/~ckhung/a/c013.php
  5. http://www.pcre.org/
  6. http://linuxtoday.com/news_story.php3?ltsn=1998-12-24-003-05-OP "Unix as an element of literacy" 及 http://www.osviews.com/modules.php?op=modload&name=News&file=article&sid=1084 "Death to the Wizards!" 兩篇有類似的看法.
  7. 請參考其他文章:
    中研院報告(1): http://phi.sinica.edu.tw/aspac/reports/94/94019/
    中研院報告(2): http://phi.sinica.edu.tw/aspac/reports/96/96004/ (感謝 Y. Cheng 提供) < br /> http://info.sayya.org/~edt1023/vim/ (以 vim 為中心)
    http://lib.stat.cmu.edu/scgn/v52/section1_7_0_1.html (英文)
    http://www.ciser.cornell.edu/info/regex.html (英文)

雜七雜八還沒整理的舊資料

例如要找出所有「看起來像是 C 語言當中的 keyword 或 identifier 的字串」, 可以用 [A-Za-z_]\w* 又例如 perl 語言當中 split " " 的效果也幾乎可以用 split /\s+/ 來達成. 這些都只是簡寫, 並沒有增加新的功能, 但是可以讓我們寫起 regexp 來比較簡潔. 以下我們會經常使用; 但是請注意如果你用的是其他支援 regexp 的程式, 凡是用到這些簡寫的地方可能都要改寫。

  1. []^-] 字元類別: 可以是 ] 或 ^ 或 - 當中任何一個字元。 要找的字元如果正好是有特殊意義的標點符號, 就把它放在它不可能有特殊意義的地方, 例如把 ] 放在最前面, ^ 不要放在最前面, - 放在最開始或最後面。 不過這樣寫不易讀, 建議只在 "用丟即棄" 的命令列當中使用。 如果出現在程式當中, 建議這樣寫: [\]\^\-] 日後維護才方便。
  2. {2,4} 前一個樣版要出現 2 到 4 次. 延續上例, 例如要在 COPYING 檔案內找連續 2 個, 連續 3 個, 或連續 4 個數字, 可以在 less 內下: /\b[0-9]{2,4}\b 嚴格說來, 為了不遺漏掉 "19yy" 之類的字串, 應該這樣寫才完整: /([^0-9]|^)[0-9]{2,4}([^0-9]|$) 簡單地說, 我們本想要求「前後不可有數字」, 所以在 [0-9]{2,4} 之前與之後加上 [^0-9]; 但是 [^0] 的要求又太多了, 它要求 "要有一個不是數字的字元"; 那麼如果我們找到的一串數字正好出現在該列的最前面或最後面, 豈不是比對失敗了嗎? 所以我們再加上 "前面沒有東西了" 及 "後面沒有東西了" 這些可能狀況。
  3. 想統計一下系統內所安裝的各類套件各有多少個: perl -ne 'print "$1\n" if /^Group\s*:\s*(.*\S)\s*Source RPM/;' rpm-listing 將結果 pipe 給 sort | uniq -c | sort -n | less 就可以看到由少而多的各類套件個數. 因為 regexp 引擎具有「貪婪」 (greed) 的特性 -- * 與 + 之類符號會盡其所能地吞噬它有能力吞噬的字串 -- 所以這裡需要有一個 \S 來阻止 .* 把 "Source RPM" 之前的空白吃進去. 如果沒有 \S 會怎麼樣? 在 less 當中做類似的搜尋就知道了 (但記得 \s 與 \S 只能在 perl 用)。
  4. 列印出 myprog.c 所包含 (#include) 進來的所有標頭檔 (*.h) 檔名: perl -ne 'print "$1\n" if /^#\s*include\s<(.*)>/' myprog.c
  5. 上節找六位或七位數字的例子, 我們可以用新語法重寫, 讓 perl 只印出 (每列上第一個) 找到的數字, 而不要整列印出來: perl -ne 'print "$2\n" if /(\D|^)(\d{6,7})(\D|$)/' Consultants-HOWTO
  6. 印出所有看起來像是 URL 的字串: perl -ne 'print "$1\n" if m#(\w+://([-\w]+\.)*[-\w]+(:\d+)?[^<>\s"]*)#' ..。 這裡因為我們要找的字串當中有 "/", 和 perl 用來分隔搜尋字串 /.../ 的分隔字元衝突, 如果按照標準的寫法, 每個 / 都要變成 \/ 還好在 perl 內, 大部分的標點符號我們都可以拿來當做分隔字元. 本例用 # 當分隔字元, 那麼 / 就可以當做一般字元來比對而不需要加 \ 了。
  7. 把一個程式檔當中的 (整數) x,y 座標顛倒過來: perl -pe 's/\[(-?\d)+,(-?\d)+\]/[$2,$1]/g' prog.pl 這個假設座標都是整數, 並以 "," 分開. (這裡假設我們要處理的是 perl, 且程式中的座標存在 anonymous array 當中)
  8. 把 sample.c 內的 (大部分) /* ... */ 形式的註解改成 # ..。 形式的註解: perl -pe 's@/\*(.*)\*/@#$1@;' sample.c (也許我們想用 perl 重寫 sample.c 這個程式, 所以註解的形式當然要改嘍.) 不過跨列的註解還有層層相疊的註解就不方便用 regexp 改了。
  9. 把 test 檔案內的文數字以外所有字元都給變成 %... 這種 url-escape 的形式: perl -pale 's#([\W])#"%" 。 sprintf("%02X",ord($1))#ge' test
  10. sirobot 可以用來映射整個網站 (或其中一個子目錄), 但抓回來的檔案裡面, 許多 link 都會亂掉, 例如本來應該是 "abc.html" 的 link, 會變成 "..%2Fabc.html%2Fabc.html" 這裡的 "%2F" 其實就是 "/" 沒什麼問題; 但是 ".." 與重複出現的檔名就麻煩了。 以下命令可以一口氣修正本目錄下所有 htm(l) 檔: perl -i.bak -pe 's#%2F#/#g; s#\.\./(.+?)/\1#\1#g' `find . -name '*.*htm*'` 註: 有些比較複雜的狀況需要用這個更精確的寫法: perl -i.bak -pe 's#\.\.%2F(.*?\.\w+?)%2F\1#\1#g; s#"\.\.%2F(.*?)"#$1#g; s#%2F#/#g' `find . -name '*.*htm*'` 心得: 還是 wget 比較好用 ...

不論未來套裝程式如何進步, 提供功能如何多, 都不可能取代善用 regexp 的能力, 因為 regexp 不是一個功能或一個工具而已, 它的是一個表達力遠比選單視窗更豐富的人機介面。 學會這個豐富表達方式, 再理性地選擇真正有用的工具來配合使用, 讓你的新舊知識發揮相乘的效果, [6] 我想會比盲目地追隨流行更能夠有長遠的收穫. 另一方面, 讀者也應該體會到使用 .html 或 .txt 等文字檔儲存資料的好處。 如果使用的是像 .doc 檔這樣封閉的二進位檔案格式, 就沒有許多好用的工具可以使用 (regexp 只是其中之一而已)。 [5]

筆者認為我們的「全民資訊教育」有必要全面檢討: 如果非得在傳統課程之外, 再給我們的中學生增加資訊科技的課程, 那麼真正應該教的不是 Office 2000, 而是 regexp。 [8] Regexp 之於電腦化文字處理的重要性, 不亞於四則運算之於數學的重要性. 基本的 regexp 並不比四則運算困難, 而它的應用範圍也不比四則運算小. 既然一般 (還沒有決定要念數學系的) 小學生可以學四則運算, 一般 (還沒有決定要念資訊相關科系的) 國中生當然也可以學 regexp. (不是因為 regexp 本身那麼難, 需要國中生才能理解, 而是因為大部分應用的場合需要一些基本的電腦與英文知識.)

如果讀者從本文得到一點有用的知識, 也希望能夠做點什麼事情回饋社會, 那麼筆者建議把這篇文章印給你的上司/下屬, 老師/學生, 朋友看。 尤其可以拿給尚在求學的年輕朋友。 一方面是因為他們沒有立即的就業壓力與沉重的歷史包袱, 比較可以接受目前非主流的知識; 另一方面是因為要期待正統的資訊教育擺脫廿世紀末的微軟包袱, 走上重視思考甚於重視操作的正途, 恐怕不是, 呃, 200\d 年的事。