正規表示法易學難精, 是一種學 N 遍會忘記 N+1 遍的東西 (因為寫正規式比較像是藝術, 離科學似乎較遠), 我從 Javascript 到 PHP 都學過, Python 的正規式也看過好幾遍, 但每次要用時都還是要重新複習, 真的很傷腦筋. 每次複習完都想把筆記整理起來以便縮短複習時間, 但都因忙它事而中輟. 這回因為撰寫網路爬蟲程式與學習自然語言處理的需要, 終於把這篇寫完了.
本篇測試所參考的書籍如下 :
- 精通正規表達式 (歐萊里, 2012)
- 處理大數據的必備美工刀 :全支援中文的正規表示法精解 (上奇, 2016)
- 增壓的 Python : 讓程式碼進化到全新境界 (碁峰, 2020) 第六, 七章
- Python 自動化的樂趣 (碁峰, 2016) 第七章
- Python最強入門邁向數據科學之路:王者歸來 (第二版, 深智, 2019) 第十六章
- Regex Quick Syntax Reference : Understanding and Using Regular Expressions (Apress, 2018)
- The Python 3 Standard Library by Example (Addison-Wesley, 2017) 第 1.3 節
- Regular Expression Pocket Primer (Mercury Learning and Information, 2019) 第三章
- A Python Data Analysts Toolkit (Apress, 2019) 第三章
其中第一本 "精通正規表達式" 詳細介紹了正規式的內部運作原理, 可說是徹底了解正規表示法機制的好書, 但此書出版較早並未納入 Python, 而是聚焦於 Perl, Java, PHP 與 .Net 等語言的正規式語法. 第二本 "處理大數據的美工刀" 我認為是中文書裡寫得最好的, 但可惜全書範例大部分使用 Python 2.7 版. 第三本 "增壓的 Python" 則是翻譯書, 其中正規式部分也做了很好的鋪陳.
一. 關於正規表達式 :
正規表示法 (又譯正則表示法, 正規表達式) 是字串處理的瑞士刀, 它使用詮釋字元 (metacharacter) 組成的範本字串來描述符合特定規則的文字, 被廣泛地應用於網路爬蟲, 系統維護, 自動化測試, 以及自然語言處理等大量文本操作場域, 主要用來檢索 (search), 擷取 (extract), 驗證 (validate) 或替換 (replace) 符合特定樣式 (pattern) 的字串.
使用正規表示法可簡潔地完成大量文本資料的過濾與比對工作, 具有大幅降低程式碼量以及提高可重用性與執行效能等好處. 正規式雖然不像物件導向技術那樣可登上軟體工程的殿堂, 但所有經常處理文本資料的程式員都知道它的價值在哪, 因為大部分的軟體都與文本處理或分析 (text processing or analytics) 有關.
為何需要用到正規表示法? 這是因為使用程式語言的語法基本上只能做精確比對, 具體而言就是使用關係運算子 == 去比對, 所以 if str == 'abc' 要成真只有 str 之值為 'abc' 才行 (匹配). 如果要做複雜的比對必須使用巢狀的 if else 去判斷. 正規表示法則是內嵌於程式語言中專門用來描述字元排列關係的小型語法, 它不僅能做精確比對, 也可以極有彈性地做模糊比對.
1960 年代 Unix 設計者之一的
Kenneth Thompson 根據有限自動機模型設計了正規表達式, 並將其導入 Unix 的文字編輯器 qed, ed, 以及 grep 之中, 由於 Unix 的應用程式大都遵循 POSIX 介面規範, 所以後來 IEEE 在制定正規式的語法標準時, 這個 UNIX 版本的正規表示法就被稱為 POSIX 流派 (flavor).
Perl 語言的設計者
Larry Wall 在 1986 年發布的 Perl 語言函式庫中實作了正規式引擎, 其正規式句法被稱為
PCRE (Perl Compatible Regular Expression) 流派, 後來許多程式語言 (例如 Java, R, Python, PHP, Julia, Ruby 等) 也採用了 PCRE 字元集, 使得 PCRE 漸漸成為主流, 而正規式也成為現代主流程式語言必備的基礎模組.
Python 最早期的 re 模組基本上屬於 PCRE 流派, 後來加入的 regex 模組則同時兼具 PCRE 與 POSIX 流派之功能. POSIX 風格的正規表達式版本基本上與 PCRE 相容, 但增加了一些額外的功能而且完全支援 Unicode. 有些語言例如 PHP, R, 與 Python 等同時支援 PCRE 與 POSIX 風格的正規表達式引擎 , 參考 :
#
Comparison of regular expression engines但各程式語言之正規表達式語法存在一些差異, 參考 :
#
Comparing regular expressions in Perl, Python, and Emacs
正規式引擎處理字串的方式是先依照正規式產生有限狀態機 (Python 採用的是
非確定性有限狀態機, nondeterministic finite sate machine, NFA), 它是一種有序的資料結構 (是一種物件), 狀態會在依序讀取與比對字串過程中移轉, 簡單來講, 如果比對途中發現不符就會跳出狀態機, 表示比對失敗 (又稱為不匹配); 否則就會走完整個字串, 停留在最後一個狀態, 表示比對成功 (又稱為匹配). 不過, 實際上, 狀態機的運作還包含回溯 (backtracking) 與貪婪 (greedy) 等較複雜的機制.
使用正規式進行樣式比對分為兩個時期, 一為編譯時期, 正規式首先會被編譯成有限狀態機 (在 Python 中即 Pattern 物件), 正規式必須先經過編譯才能拿來進行比對, 搜尋, 與取代等操作. 第二個時期為執行時期, 正規式的求值器 (evaluator) 會從目標字串逐一讀取字元, 依據有限狀態機來比對是否有匹配項目, 支援正規表示法的程式語言會提供相關函式或物件方法來處理編譯與執行正規式之作業.
狀態機可用有向的圖來表示, 例如正規式 ca*t 的有限自動機如下圖所示 :
開始執行比對時狀態機是在狀態 1, 它會從目標字串逐一讀取字元, 若讀到字元 c 就進入狀態 2, 在狀態 2 若讀取到字元 a 就繼續停留在此狀態, 讀到 a 以外字元就是比對失敗 (不匹配). 在狀態 2 若讀取到字元 t 則進入狀態 3 比對成功 (匹配), 若讀到 t 以外字元就算比對失敗 (不匹配) 並停止比對.
二. 正規式的語法 :
正規表達式 (簡稱正規式) 是由字面字元 (literal, 又稱文字常量, 即英文字母, 數字字元, 底線等) 與一些特殊字元 (稱為描述字元或詮釋字元 metacharacters, 主要是標點符號) 組成的樣式描述字串, 用來精簡地描述特定的字串樣式 (pattern). 如果以自然語言來比喻, 字面字元相當於單字, 而詮釋字元則是文法, 正規表示法的強大表達能力來自於其詮釋字元.
在 Python 中, 正規式其實就是一般的, 只不過為了避免裡面的字元被轉義, 通常會在字串前面加 r 或 R 以原始字串 (raw string) 來表示, 例如 :
pattern=r'abc[1-3]+' # 使用單引號
pattern=r"abc[1-3]+" # 使用雙引號
pattern=r'''abc[1-3]+''' # 使用單引號長字串
pattern=r"""abc[1-3]+""" # 使用雙引號長字串
此正規式中含有字面值 'abc' , 後面跟著的都是特殊字元, 其中 [1-3] 是字元集, + 是代表 1 次以上的量詞, 表示可出現 1 個以上的 1~3 的數字, 例如 'abc1', 'abc2', 'abc3', 'abc12', 'abc123', .... 等字串都會匹配此正規式. 注意, 正規式裡的字面值必須精確匹配, 例如正規式 r'cat' 可以匹配 'category' 但不能匹配 'car'.
前面冠 r 的原始字串並非一種新的字串型態, 它只是通知 Python 直譯器不要進行字元的轉義而已, 特殊字元若不使用原始字串的話會先被直譯器轉義而失去其作用. 若正規式中沒有特殊字元, 其實有無加 r 並無差別, 但若含有特殊字元就有差別了, 例如 :
>>> r'abc'=='abc' # 未含特殊字元時兩者相同
True
>>> r'abc\n'=='abc\n' # 含有特殊字元時兩者不同 (後者 \n 會被轉義)
False
因此最好養成在正規式前面冠 r 的習慣, 這樣比較保險.
正規式中的特殊字元是用來改變附近字元的涵義, 例如 + 並不是要尋找加號, 而是一個量詞 (次數) 修飾語, 用來搭配前面的字元或群組表示其發生的次數為 1 次或更多次. 如果要匹配的是這些特殊字元本身, 則必須在其前面加一個倒斜線來跳脫其特殊用途, 例如要比對加號需使用 \+.
正規表達式中的特殊字元可分成下列四類 :
- 描述字元 (metacharacter) :
用來指定一個特定的字元, 例如任何字元, 任何數字字母 /d, 任何文數字底線 /w 等, 但也包括非字元描述, 例如描述特定位置 (主要是列首 ^ 與列尾 $) 等, 它們都一次只匹配一個字元, 但可搭配量詞來匹配一個以上的字元. - 字元集 (character set) :
與描述字元一樣用來指定一個特定的字元, 但以 [] 列舉集合元素的方式來指定. - 群組或分組 (group) :
使用小括號 () 來將一部份正規式納為一個比對群組, 可將小表達式合併成更大的表達式. - 量詞 (quantifiers) :
此為運算子, 用來表示單個字元或群組重複之次數.
詮釋字元的用法摘要如下表 :
正規表達式 | 說明 |
. | 代表除換列字元 \n 以外的任何字元, 例如 a.c 匹配 abc, a8c 等 |
^ | 代表一個字串的開頭位置, 例如 ^abc 表示以 abc 開頭的字串 |
$ | 代表一個字串的結束位置, 例如 abc$ 表示以 abc 結尾的字串 |
* | 代表前一個表達式 (字元或分組) 可出現 0 次以上 |
+ | 代表前一個表達式 (字元或分組) 可出現 1 次以上 |
? | 代表前一個表達式 (字元或分組) 可出現 0 次或 1 次, 或放在 {} 後面設定為非貪婪 |
\ | 脫逸 (escape), 代表後面的 (特殊) 字元以一般字元 (原義) 處理 |
| | 選擇 (or), 代表前一個表達式或後一個表達式二選一, 例如 a|b 比對 a 或 b |
() | 群組 (group), 代表括號內的表達式形成一個群組, 例如 (ab)+ 比對 ab, abab 等 |
\num | 參照前面已經匹配的群組, num 為 1 起始的群組編號, 例如 \1 為群組 1 |
[abc.] | 字元集, 代表 a 或 b 或 c 任一個字元 |
[a-z] | 字元集, 代表 a~z 連續區間內的任一個字元, [a-c] 表示 a, b, c 任一個字元 |
[0-9] | 字元集, 代表 0~9 連續區間內的任一個字元, [1-3] 表示 1, 2, 3 任一個字元 |
[^abc] | 字元集, 代表不是 a 或 b 或 c 的任一個字元 |
{m} | 代表前一個項目 (字元或分組) 必須出現 m 次, 例如 a{2} 比對 aaabc 得 aa |
{m,} | 代表前一個項目 (字元或分組) 至少出現 m 次, 例如 a{2,} 比對 aaabc 得 aaa |
{m,n} | 代表前一個項目 (字元或分組) 出現 m~n 次, 例如 a{2, 4} 比對 aaaaabc 得 aaaa |
\d | 代表數字字元, 等於 [0-9] 或 [0123456789] |
\D | 代表非數字字元, 等於 [^0-9] 或 [^0123456789] |
\w | 代表數字, 英文字母, 或底線字元, 等於 [0-9a-zA-Z] 之簡寫 |
\W | 代表數字, 英文字母, 或底線以外的字元, 等於 [^0-9a-zA-Z] |
\s | 代表空白, 定位, Tab, 換列, 跳頁字元, 等於 [\r\t\n\f] |
\S | 代表空白, 定位, Tab, 換列, 跳頁字元等以外的字元, 等於 [^\r\t\n\f] |
\b | 代表詞邊界, 例如 ad\b 匹配 'bad boy' 與 'ad' 但不匹配 'adverb' |
\B | 代表不是詞邊界, 例如 ad\B 匹配 'adverb' 但不匹配 'bad boy' |
\A | 代表字串的開頭 |
\z | 代表字串的尾端 |
\n | 代表換列 (new line) 字元 |
\r | 代表回車 (carriage return) 字元 |
\t | 代表水平定位 (tab) 字元 |
\f | 代表進紙 (feed) 字元 |
\v | 代表垂直定位 (tab) 字元 |
用法說明如下 :
- 小括號 () 代表一個群組 (group), 它有兩個用途, 第一個是作為正規式運算子 (例如量詞或選擇等) 的作用單位, 它會大大地影響正規式的解析方式與含義 (跟算術裡括號的作用有一點點類似), 例如 cat+ 樣式中量詞 + 作用的對象是前一個字元 b, 它會匹配 'cat', 'catt', 'cattt', ... 等字串, 但 c(at)+ 樣式中量詞 + 作用的對象是 ab 群組, 它會匹配 'cat', 'catat', 'catatat', ... 等字串.
- 群組 (group) 的第二個用途是參照 (reference), 正規式引擎會替每個群組建立標記 (tag) 以便後續引用或參照, 可以搭配 \num 語法來參照前面已經匹配成功的群組, 例如 \1 參照前面的第一個分組, \2 參照前面的第二個分組等等. 在需要搜尋重複樣式時就必須用到群組的參照功能.
- 小括號 () 內若以 ? 開頭則做為改變比對模式的修飾符 (modifier) 用, 功能與旗標 (flag) 相同 , 但修飾符必須放在正規式的開頭, 例如 r'(?i)[a-z]' 匹配不分大小寫的一個英文字母. 常用的比對模式修飾符如下 :
(?i) 等於 re.IGNORECASE (不分大小寫模式)
(?s) 等於 re.DOTALL (單列模式)
(?m) 等於 re.MULTILINE (多列模式)
(?x) 等於 re.VERBOSE (註解模式) - 中括號 [] 代表一個字元集 (character set), 表示匹配這些字元集合中的單一個字元 (支援 Unicode), 故這些字元是 OR (或) 的關係, 例如 [cat] 表示只要字串中含有 c, a, 或 t 中的任一個即匹配成功. 相對來說字面值的正規式則是 AND (且) 的關係, 正規式 abc 表示必須三個字元依序同時出現才能匹配成功. 注意, 字元集中的字元預設是 Unicode 模式, 但可以在正規式最前面加上 (?a) 設定為 ASCII 模式.
- 除了 '-', ']', 以及 '\' 這三個特殊字元在字元集內需要跳脫 (escape) 以表示它們本身外, 其餘的所有字元都不需要跳脫. 在字元集中, 連字號 - 在不同位置用法不同, 如果連字號是在兩個字元中間, 是表示前面字元到後面字元的連續區間, 例如 [3-7] 等同於 [34567], 而 [a-d] 等同於 [abcd]. 如果連字號是在 [] 內的開頭或結尾, 那它就純粹是連字號而已不須跳脫, 但在中間就必須跳脫. 右中括號表示字元集結束, 倒斜線用來跳脫, 故在字元集內它們都必須跳脫.
- 字元集內的特殊字元除了 '-' 外還有一個放在開頭表示 not (否定) 意思的 '^' 字元, 用來排除它後面的所列舉的所有字元, 稱為排除型字元集, 例如 r'[^0-9]' 或 r'[^\d]' 表示匹配非數字字元; r'[^aiueo]' 表示匹配非母音字元等. 但 '^' 如果不是在開頭位置就只是普通字元, 沒有否定的意思, 例如 r'[^+-*/]' 表示匹配所有加減乘除以外的字元; 但 r'[+-*/^]' 則是加減乘除與次方字元的任一個.
- 描述字元 /d, /D, /w, /W, /s, /S 可視為是一些常用的字元集之簡寫, 可使正規式更為精簡, 例如 \d 其實等同於字元集 [0-9] 或選擇分組 (0|1|2|3|4|5|6|7|8|9), 它們與字元集一樣預設使用 Unicode 模式, 但可以在前面加上 (?a) 設定為 ASCII 模式.
- 選擇運算子 | 用來在左右兩側的表達式中做 2 選 1 (alternation) 匹配, 它在正規式語法中具有最低的優先順序. 例如 ab|cd 會匹配 'ab' 或 'cd', 但不匹配 'abcd'; 而 a(b|c)d 匹配 'abd' 或 'acd' 但不匹配 'abcd', 這種單一字元的選擇等同於 a[bc]d. 選擇運算子 | 與字元集的差別在字元集是匹配一個字元而已, 而選擇運算子則是匹配較多的字元.
- 特別注意, 量詞 {m,n} 的 m 與 n 中間只能是逗號, 不可含有空格, 否則比對會失敗. 其次, 問號 ? 在正規式中有兩種用途, 一是做為一個次數量詞, 它會修改前面字元或分組發生的次數為 0 次或 1 次; 二是放在 {m,n} 或 {m,} 等範圍量詞後面, 它會修改預設之貪婪模式為非貪婪模式.
Python 正規式求值器 (evaluator) 預設採取貪婪 (greedy) 的匹配策略, 所謂貪婪是指它會盡可能地達成最長的匹配, 為了達到這個目的會使用目標字串回溯技術 (backtracking). 例如用正規式 r'c.*t' 來比對目標字串 'cat' 字串, 第一個字元 c 匹配成功後, ".*" 表示要比對任何數量的跳行以外字元, 所以 'at' 會匹配成功, 至此已到達目標字串尾端, 求值器要比對剩下的 't' 樣式時已無字元可比對, 似乎比對失敗, 事實上求值器會回溯一個字元讀取到 t, 故最終還是會匹配成功, 例如 :
>>> import re
>>> if re.match(r'c.*t', 'cat'):
print('匹配成功')
匹配成功
此處匯入 re 模組後呼叫 re.match() 函式並傳入正規式 r'c.*t' 與目標字串 'cat', 比對結果會傳回 None (不匹配, 比對失敗) 或一個 Match 物件 (匹配, 比對成功). 此例顯示正規式的求值器利用回溯使得比對最後匹配成功. re 模組還有許多函式, 如下所述.
三. 利用 re 模組進行樣式比對, 搜尋, 與取代 :
Python 從 1.5 版開始就把支援 Perl 風格的正規式模組 re 模組加入了標準函式庫, 此模組事實上是內嵌於 Python 內的一個高度特殊化小型程式語言 (所以學習正規表示法事實上是在學習一種新語言), re 模組實作了可執行樣式比對, 搜尋與取代的有限狀態機 (Finite Automata), 正規式會先被編譯代表此狀態機的樣式物件, 然後利用此物件的方法或 re 模組的函式對目標字串進行樣式比對, 搜尋與取代等作業.
教學文件參考 :
1. 呼叫 re 模組的函式來比對, 搜尋, 或取代 :
首先介紹樣式比對, 搜尋, 與取代的第一種方式, 即直接呼叫 re 模組的函式, 此法虛傳入正規式或樣式物件, 目標字串, 以及備選的旗標等參數.
使用 re 模組前須先用 import 匯入 :
D:\Python>python
Python 3.7.2 (tags/v3.7.2:9a3ffc0492, Dec 23 2018, 23:09:28) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import re
>>> dir(re)
['A', 'ASCII', 'DEBUG', 'DOTALL', 'I', 'IGNORECASE', 'L', 'LOCALE', 'M', 'MULTILINE', 'Match', 'Pattern', 'RegexFlag', 'S', 'Scanner', 'T', 'TEMPLATE', 'U', 'UNICODE', 'VERBOSE', 'X', '_MAXCACHE', '__all__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '__version__', '_cache', '_compile', '_compile_repl', '_expand', '_locale', '_pickle', '_special_chars_map', '_subx', 'compile', 'copyreg', 'enum', 'error', 'escape', 'findall', 'finditer', 'fullmatch', 'functools', 'match', 'purge', 'search', 'split', 'sre_compile', 'sre_parse', 'sub', 'subn', 'template']
使用 eval() 函式搭配字串的 startswith() 方法可以將雙底線的內部變數過濾掉, 加上 type() 函式可以顯示每一個成員的類型 :
>>> import re
>>> members=dir(re)
>>> for mbr in members:
obj=eval('re.' + mbr)
if not mbr.startswith('_'):
print(mbr, type(obj))
A <enum 'RegexFlag'>
ASCII <enum 'RegexFlag'>
DEBUG <enum 'RegexFlag'>
DOTALL <enum 'RegexFlag'>
I <enum 'RegexFlag'>
IGNORECASE <enum 'RegexFlag'>
L <enum 'RegexFlag'>
LOCALE <enum 'RegexFlag'>
M <enum 'RegexFlag'>
MULTILINE <enum 'RegexFlag'>
Match <class 'type'>
Pattern <class 'type'>
RegexFlag <class 'enum.EnumMeta'>
S <enum 'RegexFlag'>
Scanner <class 'type'>
T <enum 'RegexFlag'>
TEMPLATE <enum 'RegexFlag'>
U <enum 'RegexFlag'>
UNICODE <enum 'RegexFlag'>
VERBOSE <enum 'RegexFlag'>
X <enum 'RegexFlag'>
compile <class 'function'>
copyreg <class 'module'>
enum <class 'module'>
error <class 'type'>
escape <class 'function'>
findall <class 'function'>
finditer <class 'function'>
fullmatch <class 'function'>
functools <class 'module'>
match <class 'function'>
purge <class 'function'>
search <class 'function'>
split <class 'function'>
sre_compile <class 'module'>
sre_parse <class 'module'>
sub <class 'function'>
subn <class 'function'>
template <class 'function'>
其中大寫者為 re 模組的常數, 主要為用來控制比對模式之旗標 (flag) :
re 模組常用旗標 | 說明 |
ASCII | 使用 ASCII 模式 (簡寫 re.A), 修飾符為 (?a) |
IGNORECASE | 不區分大小寫 (簡寫 re.I), 修飾符為 (?i) |
DEBUG | 互動模式下會印出除錯訊息 (無簡寫) |
LOCALE | 匹配文數字元與單詞邊界時遵守 LOCALE 區域設定 (簡寫 re.L) |
MULTILINE | 多列模式, 可跨越 \n 進行比對 (簡寫 re.M), 修飾符為 (?m) |
DOTALL | 單列模式, 比對包含跳行 \n 的所有字元 (簡寫 re.S), 修飾符為 (?s) |
UNICODE | 使用 Unicode 模式 (簡寫 re.U), 修飾符為 (?u) |
VERBOSE | 使用註釋模式 (簡寫 re.X), 修飾符為 (?x) |
大部分旗標都有其簡寫與修飾符 (modifier), 例如 re.IGNORECASE 可以用簡寫 re.I 代替; 而修飾符則是嵌入正規式中的旗標, 它有與旗標相同的功能, 因此使用修飾符就不需要傳入旗標 (重複也無妨), 使用修飾符的好處是正規式可以不修改即能通行於各種程式語言, 而旗標的用法因程式語言而異, 故可移植性 (portability) 沒有修飾符好.
常用的 re 模組函式用法摘要如下 :
re 模組常用函式 | 說明 |
match(pattern, msg) | 以 pattern 從頭比對 msg, 匹配傳回 Match 物件, 否則傳回 None |
fullmatch(pattern, msg) | 以 pattern 從頭至尾比對 msg 全符合傳回 Match 物件, 否則 None |
search(pattern, msg [, flag=0]) | 以 pattern 搜尋整個 msg, 匹配傳回 Match 物件, 否則傳回 None |
findall(pattern, msg [, flag=0]) | 在 msg 中搜尋匹配 pattern 樣式的所有子字串, 將結果以 list 傳回 |
sub(pattern, rep,msg[,flag=0]) | 在 msg 中搜尋匹配 pattern 樣式的所有子字串並以 rep 取代 |
split(pattern, msg[, flag=0]) | 將 msg 以匹配 pattern 樣式之子字串拆分, 將結果以 list 傳回 |
compile(pattern) | 編譯正規式 pattern, 傳回 Pattern 物件 |
注意, 除了 re.compile() 外, 其餘函式中的 pattern 參數可以是正規式字串, 也可以是 re.compile() 傳回的 Pattern 物件. re.compile() 只是單純地編譯正規式字串並傳回代表有限狀態機的 Pattern 樣式物件, 此樣式物件可以被重複使用. 其他函式的 pattern 參數若傳入的是正規式字串時其實會隱性地先呼叫 re.compile() 進行編譯動作 (因為正規式必須先行編譯才能使用) 以取得 Pattern 物件, 然後再執行比對, 搜尋或取代等作業.
用法說明如下 :
- re.compile() 會將正規式編譯成 Pattern 物件, 此 Pattern 物件可傳給 re.match(), re.fullmatch, re.search(), re.findall(), re.sub() 等函式當作第一參數, 也可以呼叫與這些函式的同名方法來執行比對, 搜尋, 或替換等操作.
- re.match() 是從目標字串的開頭比對指定樣式, 直到不符合的字元出現為止, 若匹配就將該字元與位置 (即索引 0) 存入 Match 物件中傳回, 若不匹配就傳回 None. 而 re.fullmatch() 是整個目標字串從頭到尾都符合樣式才算匹配, 若匹配就將整個目標字串與位置存入 Match 物件中傳回, 若不匹配就傳回 None. 兩者的差別是, re.match() 只要目標字串從開頭就符合樣式就匹配; 而 re.fullmatch() 則是從頭至尾整個都必須符合才匹配, 相當於把 re.match() 的正規式結尾加上 $.
- re.search() 是從頭開始搜尋目標字串, 並將第一組匹配的字串與位置存入 Match 物件中傳回, 若不匹配就傳回 None. 而 re.findall() 是從頭搜尋整個目標字串找出全部匹配的子字串, 將其放入串列中傳回, 若不匹配就傳回空串列. 所以兩者的差別是 re.search() 只要找到第一組匹配的子字串就收工了, 但 re.findall() 則繼續搜尋到最後. 匹配時的傳回值型態也不同, re.findall() 不管是否匹配都傳回一個串列; 而 re.search() 匹配時傳回 Match 物件, 不匹配傳回 None.
- re.match() 與 re.search() 都是在目標字串 msg 中搜尋特定樣式, 都是傳回 None 或 Match 物件, 兩者差別是 re.match() 必須是從目標字串開頭就符合 (直到不符合之字元出現為止) 才能匹配, 而 re.search() 則在整個字串任何位置第一個符合就匹配成功.
- re.sub() 會從頭搜尋整個字串, 將匹配的子字串以替換字串取代, 傳回替代後的目標字串, 若不匹配則傳回整個目標字串.
- re.split() 會從頭搜尋整個字串, 然後以匹配的子字串為分界拆分目標字串成為數個子字串後以串列傳回, 若不匹配則傳回整個目標字串.
以下用簡單的範例說明這些函式的基本用法, 例如要比對目標字串是否是數字字元開頭, 可呼叫 re.match() 來比對正規式 [0-9] 或 \d, 因為 re.match() 從開頭字元便要符合才會匹配, 例如 :
>>> import re
>>> pattern=re.compile(r"\d") # 比對一個數字字元, 也可以用 r'[0-9]'
>>> pattern
re.compile('\\d') # 實際的正規式是 '\\d' 跳脫倒斜線
>>> type(pattern)
<class 're.Pattern'> # 編譯後傳回一個 Pattern 物件
可見編譯後真正的正規式為 '\\d', 編譯時已經自動加上一個倒斜線來跳脫 (escape) 特殊符號 '\' 了. 用原始字串 r'\d' 表示正規式的好處就是不需要去顧慮特殊符號的跳脫問題, 如果不使用原始字串就要自行加上跳脫用的倒斜線, 即 re.compile('\\d').
接下來使用這個 Pattern 物件來比對目標字串 :
>>> match=re.match(pattern, '123abc') # 從頭比對目標字串 '123abc'
>>> match
<re.Match object; span=(0, 1), match='1'> # 匹配第一個字元為數字即結束比對
>>> match=re.match(pattern, 'abc123') # 從頭比對目標字串 'abc123'
>>> match # 傳回 None 印出來不會顯示
>>> type(match) # 呼叫 type() 確認傳回 None
<class 'NoneType'>
>>> if match: # 根據傳回值判斷 'abc123' 的比對結果
print('success')
else:
print('failure')
failure
可見用正規式 r'\d' 比對目標字串 'abc123' 不匹配, 但比對 '123abc' 則會匹配, 這是因為 re.match() 的功能是從目標字串開頭就必須符合, 故只有以數字開頭的字串才會匹配. 正規式 r'\d' 只是比對一個數字字元而已, 所以此例中目標字串 '123abc' 比對第一個字元 '1' 時就匹配而停止, 匹配的字元是第一個字元, 所以 Match 物件紀錄 match='1', 位置 span(0, 1) 即索引 0 .
但用正規式 r'\d' 比對目標字串 'abc123' 不會匹配, 因為第一個字元 'a' 就不符合而馬上停止比對並傳回 None. 因為 re.match() 的傳回值不是 None 便是 Match 物件, 剛好可用 if 來判斷並輸出結果.
如果要比對目標字串開頭的所有數字, 正規式要改為 r'\d+', 其中特殊符號 + 表示前面的字元必須出現至少一次, 故 r'\d+' 意思是數字字元必須連續出現 1 次以上, 例如 :
>>> pattern=re.compile(r'\d+') # 數字字元連續出現 1 次以上
>>> match=re.match(pattern, '123abc')
>>> match
<re.Match object; span=(0, 3), match='123'> # 匹配的子字串是 '123'
>>> match=re.match(pattern, 'abc123') # re.match() 要求從開頭就要符合故不匹配
>>> type(match)
<class 'NoneType'> # 比對失敗傳回 None
可見用正規式 r'\d+' 比對目標字串 'abc123' 不匹配, 因為開頭字元 'a' 就不符合了, 比對馬上停止並傳回 None, 但比對 '123abc' 則會匹配最前面的三個數字字元 '123', 因為正規式 r'\d+' 要比對 1 個以上數字字元直到遇到 'a' 不符合而停止.
上面的範例都是先呼叫 re.compile() 將正規式編譯成 Pattern 物件, 然後將其傳入 re.match() 當第一參數, 這種作法的好處是, 只需要編譯一次正規式, 所得到的 Pattern 物件可以一再地重複使用. 其實也可以不需要先呼叫 re.compile(), 直接將正規式字串傳入 re.match() 中當第一參數, 例如 :
>>> match=re.match(r'\d', '123abc') # 從頭比對目標字串 '123abc'
>>> match
<re.Match object; span=(0, 1), match='1'> # 匹配的子字串是 '1'
>>> match=re.match(r'\d+', '123abc')
>>> match
<re.Match object; span=(0, 3), match='123'> # 匹配的子字串是 '123'
此例在呼叫 re.match() 時直接傳入正規式字串當第一參數, 此函式會在隱性地在背後先呼叫 re.compile() 進行編譯後取得 Pattern 件再進行比對, 效能上比直接傳入 Pattern 物件要低. 所以如果相同的正規式要重複使用, 最好是先呼叫 re.compile() 先編譯成 Pattern 物件來用; 如果正規式只使用一次則沒差, 直接傳入正規式字串比較方便.
另外一個比對函式 re.fullmatch() 用來比對整個目標字串是否符合正規式, 它不僅要求從目標字串開頭就要符合樣式, 一直到目標字串結尾都要符合! 亦即 re.fullmatch() 是整個目標字串都符合時才匹配, 這相當於在 re.match() 的正規式結尾添加一個 '$' 的效果, 例如 :
>>> match=re.fullmatch(r'[a-z]+', 'abcxyz') # 整個字串從頭到尾都符合
>>> match
<re.Match object; span=(0, 6), match='abcxyz'>
>>> match=re.fullmatch(r'[a-z]+', 'hello world ') # 中間的空格不符合樣式 (不匹配)
>>> match
>>> type(match)
<class 'NoneType'>
此例正規式 r'[a-z]+' 字元集 [a-z] 表示一個英文小寫字母, + 表示這種字母有 1 個以上, 所以用它來比對目標字串 'abcxyz' 會匹配, 但比對 'abc123' 或 'hello world' 不匹配, 因為前者並非整個字串都是英文小寫字母 (含有數字), 而後者是中間有一個空格字元. 其實 re.fullmatch() 的功能大部分都可以用 re.match() 來模擬, 只要將傳入 re.match() 的正規式結尾加一個 '$' 即可, 例如 :
>>> match=re.match(r'[a-z]+$', 'abcxyz') # 等效的 re.match() 樣式
>>> match
<re.Match object; span=(0, 6), match='abcxyz'>
此例傳入 re.match() 的正規式字串結尾有個位置字元 '$', 表示目標字串必須以英文小寫字母結尾, 配合 re.match() 從頭開始就須符合才匹配的功能, 合起來就是目標字串從頭到尾都需符合, 相當於是 re.fullmatch() 的功能.
函式 re.search() 會從頭搜尋整個目標字串是否符合樣式, 當它找到第一組 (即最前面那組) 匹配的子字串時就會停止搜尋, 並將匹配的子字串放入 Match 物件中傳回; 若搜尋到目標字串結尾不匹配就傳回 None, 例如 :
>>> match=re.search(r'\d', 'abc123') # 搜尋第一組匹配的子字串
>>> match
<re.Match object; span=(3, 4), match='1'> # 匹配 1 個字元
此例是在目標字串中從頭開始搜尋第一組符合正規式 r'\d' (一個數字字元) 的子字串, 當找到索引 3 的 '1' 的第一組時匹配, 搜尋馬上停止並傳回匹配字元 '1' 及其位置 sapn(3, 4), 雖然後面的字元 '2', 與 '3' 也都符合, 但 re.search() 的個性是只取一瓢飲. 如果正規式是 r'\d+' (一個以上的數字字元) 的話, re.search() 會傳回更多匹配字元, 例如 :
>>> match=re.search(r'\d+', 'abc123') # 搜尋第一組匹配的子字串
>>> match
<re.Match object; span=(3, 6), match='123'> # 匹配 3 個字元
此例正規式 r'\d+' 匹配一個以上的數字字元, 所以當正規式求值器讀取到 '1' 發現匹配後會繼續讀取不會停止, 直到結尾或遇到第一個不符合字元為止, 並傳回第一組匹配的子字串 '123'. 此例若將目標字串改為 'abc123xyz' 結果相同, 正規式求值器在匹配後讀到第一個不符合的字元 'x' 才會停止.
與 re.search() 淺嚐即止不同的是, re.findall() 會在目標字串中找出全部匹配的子字串 (我全都要!), 稱為迭代搜尋 (iterated search), 所有匹配的子字串會被依序放進串列中傳回, 不匹配則傳回一個空串列. re.findall() 的搜尋方式為非重疊 (non-overlapping) 搜尋, 亦即搜尋是由左向右盡可能地做最長匹配, 當匹配一個子字串後, 會從它的右邊繼續搜尋, 所以匹配 '123' 後, 下一個匹配不會是已匹配過的 '123' 內之 '23' 與 '3'. 例如 :
>>> match=re.findall(r'\d', 'abc123') # 搜尋所有匹配的子字串
>>> match
['1', '2', '3'] # 傳回三個匹配子字串的串列
>>> match=re.findall(r'\d', 'abcxyz') # 搜尋所有匹配的子字串
>>> match
[] # 不匹配傳回空串列
此例是在目標字串 'abc123' 中搜尋全部匹配正規式 r'\d' 的子字串, 因為匹配的單位是一個數字字元, 所以總共會有 '1', '2', '3' 這三組匹配的子字串, 它們會被放在串列中傳回. 目標字串 'abcxyz' 則因為不匹配而傳回空字串.
如果正規式改為匹配一個以上數字字元的 r'\d+', 則匹配單位就不是單一個數字字元, 而是多個字元, 例如 :
>>> match=re.findall(r'\d+', 'abc123') # 搜尋所有匹配的子字串
>>> match
['123']
>>> match=re.findall(r'\d+', 'abc123xyz456')
>>> match
['123', '456']
上面第一個例子是在目標字串 'abc123' 中搜尋全部匹配正規式 r'\d+' 的子字串, 由於是搜尋一個以上的數字字元, 所以會在讀到 '1' 匹配後繼續搜尋直到尾端 '3' 後才停止, 故匹配子字串是 '123' 而非 '1', '2', '3', 因此傳回值是串列 ['123']. 第二個例子是在目標字串 'abc123xyz456' 中搜尋全部匹配正規式 r'\d+' 的子字串, 求值器找到了 '123' 與 '456' 這兩組匹配子字串.
函式 re.sub() 與 re.findall() 一樣會搜尋整個目標字串中匹配的子字串, 但 re.findall() 只是將搜尋到的全部匹配子字串傳回, 而 re.sub() 則是將這些匹配子字串以指定的字串替換掉後將結果字串傳回, 如果不匹配就傳回目標字串本身 (因為找不到匹配子字串就沒有替換的對象, 所以就整欉好好送回去啦), 例如 :
>>> result=re.sub(r'\d', 'xyz', 'abc123')
>>> result
'abcxyzxyzxyz'
此例以 'xyz' 取代目標字串中匹配正規式 r'\d' 的所有子字串, 由於 r'\d' 匹配一個數字字元, 所以會有 '1', '2', '3' 這三組匹配子字串, 所以替換後就出現三個而非一個 'xyz' 了. 如果匹配單元是多個數字字元的 r'\d+', 則結果就不同了, 例如 :
>>> result=re.sub(r'\d+', 'xyz', 'abc123') # 以 'xyz' 取代所有匹配子字串
>>> result
'abcxyz'
>>> result=re.sub(r'\d+', '+++', 'abc123xyz456') # 以 '+++' 取代所有匹配子字串
>>> result
'abc+++xyz+++'
此兩例匹配單位是多個數字字元, 前者匹配子字串 '123' 被 'xyz' 替換; 後者有兩個匹配子字串 '123' 與 '456', 它們都被 '+++' 替換掉了.
函式 re.split() 功能與 Python 字串物件的 spplit() 方法功能類似, 都可以將目標字串以特定分界符 (splitter) 為分界拆分為串列, 差別在於字串的 split() 方法是以固定的字元或字串當分界符; 而 re.split() 則是用正規式的匹配字串當作分界符, 彈性很大, 例如 :
>>> '1 2 3 4 5'.split() # 預設以空白 (含空格, 定位等) 為分界符
['1', '2', '3', '4', '5']
>>> '1 2 3 4 5'.split(' ') # 指定以空格 ' ' 為界拆分
['1', '2', '3', '4', '', '', '5']
此例目標字串的 4 與 5 之間有 3 個空格, 當 split() 方法不傳入參數時多餘的空格會被忽略; 但若指定以空格為界拆分時會無法辨識這是連續的多餘空格, 因而傳回串列中 '4' 與 '5' 中間有兩個空字串. 將空格改用 + 取代較容易觀察 :
>>> '1+2+3+4+++5'.split('+') # 以 '+' 為界拆分字串
['1', '2', '3', '4', '', '', '5']
如果使用正規表示法, 以正規式當作分界符就不會有這問題了 :
>>> re.split(r'\s+', '1 2 3 4 5')
['1', '2', '3', '4', '5']
>>> re.split(r'\++', '1+2+3+4+++5')
['1', '2', '3', '4', '5']
可見傳回串列中就沒有空字串了, 第一例的正規式 r'\s+' 中的 \s 意思是空白字元 (包含空格與 Tab 等), 後面的 + 表示前面的空白出現 1 個以上. 第一例的正規式 r'\++' 中的 \+ 表示 + 字元本身, 因為 + 在正規表示法中為特殊字元 (量詞), 故須用 \ 來跳脫量詞的意思, 後面那個 + 才是量詞, 表示前面的 + 字元出現 1 次以上.
2. 呼叫 Pattern 物件的方法來比對, 搜尋, 或取代 :
除了直接呼叫上述 re 模組的函式進行字串的比對, 搜尋, 與替代等作業外, 第二種作法是呼叫 Pattern 物件之方法, 因為 Pattern 物件亦實作了與 re 模組函式功能相同的 match(), fullmatch(), search(), findall() 以及 sub() 等方法, 例如 :
>>> import re
>>> pattern=re.compile(r"\d") # 編譯後傳回 Pattern 物件
>>> members=dir(pattern) # 查詢 Pattern 物件之成員
>>> for mbr in members: # 走訪 Pattern 物件的成員
obj=eval('pattern.' + mbr) # 取得成員
if not mbr.startswith('_'): # 跳過內部成員
print(mbr, type(obj))
findall <class 'builtin_function_or_method'>
finditer <class 'builtin_function_or_method'>
flags <class 'int'>
fullmatch <class 'builtin_function_or_method'>
groupindex <class 'dict'>
groups <class 'int'>
match <class 'builtin_function_or_method'>
pattern <class 'str'>
scanner <class 'builtin_function_or_method'>
search <class 'builtin_function_or_method'>
split <class 'builtin_function_or_method'>
sub <class 'builtin_function_or_method'>
subn <class 'builtin_function_or_method'>
這些物件方法的介面與上面 re 模組的同名函式功能相同, 唯一的差別只是不需要傳入正規式字串或 Pattern 物件 (因為它自己就是代表正規式的 Pattern 物件), 摘要如下表 :
Pattern 物件常用方法 | 說明 |
match(msg) | 以 pattern 從頭比對 msg, 匹配傳回 Match 物件, 否則傳回 None |
fullmatch(msg) | 與 match() 相同, 差別是頭尾都須符合 (正規式結為自動加上 $) |
search(msg [, flag=0]) | 以 pattern 比對整個 msg, 匹配傳回 Match 物件, 否則傳回 None |
findall(msg [, flag=0]) | 在 msg 中搜尋匹配樣式 pattern 的所有子字串, 將結果以 list 傳回 |
split(msg [, flag=0]) | 將 msg 以匹配 pattern 樣式之子字串拆分, 將結果以 list 傳回 |
sub(rep, msg[, flag=0]) | 在 msg 中搜尋匹配樣式 pattern 的所有子字串並以 rep 取代 |
例如 :
>>> import re
>>> pattern=re.compile(r"\d") # 比對一個數字
>>> pattern.match('123abc')
<re.Match object; span=(0, 1), match='1'> # 匹配第一個字元
>>> pattern=re.compile(r"\d+") # 比對 1 個以上字元
>>> pattern.match('123abc')
<re.Match object; span=(0, 3), match='123'> # 匹配前三個字元
>>> pattern.fullmatch('123') # 整個字串從頭到尾都符合
<re.Match object; span=(0, 3), match='123'>
>>> pattern.search('abc123') # 搜尋第一組匹配的子字串
<re.Match object; span=(3, 6), match='123'>
>>> pattern.findall('abc123xyz456') # 搜尋全部匹配的子字串
['123', '456']
>>> pattern.sub('+++', 'abc123xyz456') # 替換
'abc+++xyz+++'
>>> pattern=re.compile(r'\s+') # 以空白字元為界拆分
>>> pattern.split('1 2 3 4 5')
['1', '2', '3', '4', '5']
>>> pattern=re.compile(r'\++')
>>> pattern.split('1+2+3+4+++5') # 以 + 字元為界拆分
['1', '2', '3', '4', '5']
可見結果與呼叫 re 模組的函式是一樣的.
四. 正規式語法測試 :
了解如何使用 re 模組進行比對, 搜尋, 與替換後, 即可來測試上面所述的正規式語法 :
1. 列首列尾位置的錨定字元 (anchor) :
特殊字元 ^ 與 $ 不是用來匹配任何實際的字元, 而是用來匹配一列中的開頭與結尾位置, 也就是所謂的錨定 (anchor) 功能 : ^ 用來錨定列首位置; 而 $ 則是用來錨定列尾位置. 如果同時使用了 ^ 與 $ 表示整個目標字串必須符合該樣式才算匹配, 例如正規式 r'^hello' 會匹配以 'hello' 開頭的字串, 正規式 r'\d$' 會匹配以數字字元結尾之字串, 而正規式 r'^\d$' 則會匹配從頭至尾都是數字的字串. 比較特殊的是 r'^$', 此正規式可用來比對一個空列 (就是只有一個換列字元, 沒別的).
首先來測試用 '^' 字元來限制匹配發生於起始位置 :
>>> re.search(r'hello', 'hello world, hello tony') # 沒有用 '^' 限制匹配之起始位置
<re.Match object; span=(0, 5), match='hello'>
>>> re.search(r'^hello', 'hello world, hello tony') # 用 '^' 限制匹配於起始位置
<re.Match object; span=(0, 5), match='hello'>
此例目標字串中有兩個 'hello', 但因 re.search() 只匹配找到的第一組子字串即停止不會繼續往下找, 所以此例正規式有無加起始位置字元 '^' 結果相同, 都是匹配第一個 'hello'. 但同樣的目標字串如果用 re.findall() 遍尋就有差別了, 例如 :
>>> re.findall(r'hello', 'hello world, hello tony') # 沒有用 '^' 限制匹配之起始位置
['hello', 'hello']
>>> re.findall(r'^hello', 'hello world, hello tony') # 用 '^' 限制匹配之起始位置
['hello']
>>> re.findall(r'^hello', 'hi world, hello tony') # 找不到在字串開頭的 'hello'
[]
可見如果正規式沒有冠 '^' 的話, re.findall() 會遍尋所有匹配的字串, 所以會傳回兩個 'hello'; 但冠了 '^' 後便限制只能匹配在開始位置上的 'hello' 了, 如果目標字串開頭位置並無 'hello' 那就會傳回空串列.
正規式的尾端若是 '$' 字元則會限制只有在目標字串以 '$' 前面的樣式結尾才會匹配, 例如 :
>>> re.search(r'world$', 'hello world') # 匹配 (以 'world' 結尾)
<re.Match object; span=(6, 11), match='world'>
>>> re.search(r'world$', 'hello world!') == None # 不匹配 (不是以 'world' 結尾)
True
此例正規式 r'world$' 表示目標字串須以 'world' 結尾才匹配, 故第一例匹配, 但第二例因為多了一個結尾的驚嘆號, 所以不匹配. 下面是與數字有關的範例 :
>>> re.findall(r'\d+', '10+20=30') # 沒有用 '$' 限制匹配之結束位置
['10', '20', '30']
>>> re.findall(r'\d+$', '10+20=30') # 用 '$' 限制匹配之結束位置
['30']
第一例用 '$' 限制匹配之結束位置, 所以 re.findall() 會傳回全部匹配之數字, 第二例正規式 r\d+$' 只匹配結尾的數字字串, 所以只傳回最後面的 '30'.
若於正規式首尾同時使用 '^' 與 '$' 表示目標字串必須整個都符合這兩個位置標示字元之間的樣式才算匹配, 例如 r'^\d+$' 會匹配全部都是數字的字串 :
>>> re.findall(r'^\d+$', '10+20=30') # 不匹配 : 含有非數字 '+' 與 '='
[]
>>> re.findall(r'^\d+$', '102030') # 匹配 : 全為數字
['102030']
>>> re.match(r'^\d+$', '102030')
<re.Match object; span=(0, 6), match='102030'> # 比對是否全為數字
>>> re.fullmatch(r'\d+', '102030')
<re.Match object; span=(0, 6), match='102030'> # 比對是否全為數字
第一例因目標字串中含有非數字的 '+' 與 '=' 故不匹配; 第二例與第三例全為數字故匹配; 第四例則是用 re.fullmatch() 來比對, 由於此函式本來就是從頭到尾比對都要符合才匹配, 所以就不需要用到 '^' 與 '$' 了 (用也沒關係, 結果相同), 所以後兩例是等效的.
2. 字元集 (character set) :
字元集是放在中括號 [] 裡的字元集合, 但不管裡面有多少個字元, 它只代表一個字元, 亦即括號內只是列舉候選字元而已, 只有其中一個會出線, 雖然前後順序不重要, 但為了可讀性通常會照易讀的順序排列, 例如 [13579] 會比 [95173] 容易看懂. 從邏輯上來看, 字元集內的所有字元彼此是 OR (或) 的關係, 亦即只輸出它們之中的任一個字元, 相對而言, 字元集外的字元則是 AND (且) 的關係, 例如 r'gr[ae]y' 匹配 'gray' 或 'grey', 但 r'gray' 則只能匹配 'gray'.
字元集中有五個特殊字元用法必須注意, 其中 - 與 ^ 為字元集自己定義的詮釋字元, 它們與字元集外面的 - 與 ^ 用法不同 :
- 位於字元中間用來列舉範圍的連字號 - (字元集內的詮釋字元)
- 位於開頭表示否定的 ^ 字元 (字元集內的詮釋字元)
- 右中括號字元 ]
- 單引號 '
- 雙引號 "
除了引號與右中括號, 所有的特殊字元在字元集內都不需要跳脫 (但要跳脫也可以). 其中引號要視整個正規式使用哪一種括號而定, 用單引號則字元集內的單引號要跳脫; 用雙引號則字元集內的雙引號要跳脫 (故不管三七二十一, 字元集內的引號全部都跳脫最保險), 總之, 字元集內外的語法是不一樣的, 字元集有自己的詮釋字元 (即連字號 - 與 ^ 字元), 它們的用法在字元集內外是不同的. 如果說正規表示法是內嵌於程式語言中的小型語言, 則字元集可說是內嵌於正規表示法中的迷你語言.
要在字元集內窮舉 Unicode 編碼上的一段連續字元區間很長很麻煩, 字元集提供 "範圍標記法" 來簡化, 此法以中間的連字號表示一個 Unicode 值的區間, 例如 [0-9] 等於是 [012345679], 而 [a-z] 表示所有小寫字母. 若要表示連字號本身, 則連字號要放在中括號的開頭或結尾, 或者用倒斜線跳脫, 例如要列舉數字字元或連字號要用 [-0-9] 或 [0-9-] 表示, 此正規式列舉了 0~9 與 - 共 11 個字元. 注意, 使用範圍標記法時順序很重要, 必須 Unicode 值小的在前 Unicode 值大的在後, 否則會出現 'bad caracter range' 錯誤, 例如 [9-0] 或 [z-a] 就不是合法的正規式, 例如 :
>>> re.match(r'[9-0]', '123') # [9-0] 不合法, 應該用 [0-9]
Traceback (most recent call last):
......(略)......
raise source.error(msg, len(this) + 1 + len(that))
re.error: bad character range 9-0 at position 1
正確範例如下 :
>>> re.match(r'[0-9]', '123abc') # 從頭開始匹配一個數字字元
<re.Match object; span=(0, 1), match='1'>
>>> re.search(r'[0-9]+', 'abc123xyz') # 搜尋第一組 1 個以上的數字字元
<re.Match object; span=(3, 6), match='123'>
>>> re.findall(r'[a-z]+', 'abc123xyz') # 搜尋全部 1 個以上的小寫英文字母字元
['abc', 'xyz']
>>> re.search(r'#[0-9a-fA-F]+', 'color:#01FA7C;') # 搜尋 16 進位色碼
<re.Match object; span=(6, 13), match='#01FA7C'>
從上面最後一例的正規式可知, 字元集內可以使用多組範圍標記, 例如 r'[a-zA-Z]' 會匹配所有的英文字母, 各組範圍標記的前後關係不重要, 因此也可以寫成 r'[A-Za-z]'.
注意, 如果要比對連字號本身必須將連字號緊鄰中括號 [ 或 ], 否則必須用 \ 跳脫, 不能放在中間, 例如下面範例都是比對一個 0, 9 或 - 字元 :
>>> re.findall(r'[-09]', 'abc0-179') # 搜尋全部 0, 9 或 - 字元 (- 放開頭)
['0', '-', '9']
>>> re.findall(r'[09-]', 'abc0-179') # 搜尋全部 0, 9 或 - 字元 (- 放結尾)
['0', '-', '9']
>>> re.findall(r'[0\-9]', 'abc0-179') # 搜尋全部 0, 9 或 - 字元 (用 \ 跳脫)
['0', '-', '9']
字元集另外一個有特殊用途的是否定字元 '^', 如上所述, 此字元在正規式中也用來標示開始位置, 但在字元集內意思不一樣 (字元集內外有各自的詮釋字元), 放在 [ 後面開頭位置表示否定, 稱為排除型字元集, 表示匹配未列出隻字元, 例如 [^0-9] 表示非數字字元, 等於簡記法的 \D. 但要注意, 不能把 [^a] 理解為是 'a' 字元以外的任何字元都匹配, 因為這樣子空字元 '' 也應該要匹配才對; 實際上意思不是這樣, [^a] 正確的意思是 : 有一個字元 (所以不能是空字元), 當它不是 'a' 字元時就匹配.
範例如下 :
>>> re.match(r'[^0-9]', 'abc123') # 從頭比對非數字字元 (匹配 'a')
<re.Match object; span=(0, 1), match='a'>
>>> re.findall(r'[^0-9]', '123A4(5y6*7^8$9-0') # 搜尋全部非數字字元
['A', '(', 'y', '*', '^', '$', '-']
可見 re.match() 匹配開頭第一個非數字字元後就停止比對並傳回 Match 物件, 而 re.findall() 則是找出全部非數字字元後已串列傳回. 注意, 字元集裡的 '^' 若不是在開頭位置 (即 '[' 後面) 就是一般字元, 不需要用倒斜線 \ 跳脫, 例如 :
>>> re.findall(r'[abc\-^(*]', '1a2^3(4*5c6-') # 搜尋字元集內的指定字元
['a', '^', '(', '*', 'c', '-']
此例中連字號因為不是放在頭尾所以需要跳脫, 其它特殊字元都不需要跳脫 ('^' 不是在開頭位置屬於一般字元故不需跳脫).
3. 選項 (option) 或多選結構 (alternative) :
管線字元 '|' 是正規表示法的詮釋字元, 可用來串接無限多個子正規式, 整個選項結構被視為單一元素, 只要其中任一個子正規式匹配, 那麼整個選項結構就達成匹配; 但若每一個選項都不匹配, 則整個選項結構就不匹配. 所以就每個子正規式的比對結果來說 | 的各選項之間是 OR 的關係, 在邏輯上功能類似字元集, 某些正規式可分別用字元集或選項來實現, 例如 r'gr[ae]y' 等同於 r'gray|grey' 或更精簡的 r'gr(a|e)y', 但注意別無限上綱把兩者畫上等號, 它們的作用範圍有差別, 字元集是字元級別的 OR; 而選項結構則是子正規式級別的 OR. 除此之外, 字元集的候選範圍在中括號 [] 內部, 而選項結構卻可無限延伸, 但可以用群組來限制選項的範圍, 例如 r'gr(a|e)y 中 | 的範圍不會超過括號.
五. Match 物件與正規式的分組 :
特殊字元小括號 () 的用途是將正規式字串分成好幾個匹配群組, 這種用法稱為分群或分組 (grouping), 分組是正規表示法中的最重要的語法之一, 因為求值器會標記 (tagging) 前面已匹配的群組以便後續參照或引用 (reference).
呼叫 re.match() 或 re.search 函式進行比對或搜尋時會傳回 Match 物件, 此物件提供了許多方法來存取正規式中的群組匹配之結果. 可用 dir() 與 eval() 來檢視 Match 物件的成員 :
>>> import re
>>> match=re.match(r'\d', '123abc') # 匹配傳回
>>> members=dir(match) # 查詢 Match 物件之成員
>>> for mbr in members: # 走訪 Match 物件的成員
obj=eval('match.' + mbr) # 取得成員
if not mbr.startswith('_'): # 跳過內部成員
print(mbr, type(obj)) # 顯示成員之類型
end <class 'builtin_function_or_method'>
endpos <class 'int'>
expand <class 'builtin_function_or_method'>
group <class 'builtin_function_or_method'>
groupdict <class 'builtin_function_or_method'>
groups <class 'builtin_function_or_method'>
lastgroup <class 'NoneType'>
lastindex <class 'NoneType'>
pos <class 'int'>
re <class 're.Pattern'>
regs <class 'tuple'>
span <class 'builtin_function_or_method'>
start <class 'builtin_function_or_method'>
string <class 'str'>
藍色粗體者為較常用的物件成員, 其中 lastindex 為屬性 (整數), 記錄匹配的最後一個分組之索引 (也就是能存取的最大分組索引 n), 其餘為物件方法, 摘要說明如下表 :
Match 物件的方法 |
說明 |
group(n) |
傳回與第 n 個群組匹配的字串, 預設 n=0 會傳回整個匹配字串 |
groups() |
將各群組匹配之字串依序組成一個 tuple 傳回 |
groupdict() |
傳回所有具名群組之名稱與匹配字串組成的字典, 鍵:值=name:text |
start(n) |
傳回第 n 個群組匹配字串在目標字串中的起始位置索引 |
end(n) |
傳回第 n 個群組匹配字串在目標字串中的結束位置索引 |
span(n) |
將第 n 個群組匹配字串起始與結束索引以 tuple 傳回, 即 (start(n), end(n)) |
其中 group() 方法會傳回指定群組索引之匹配字串, 沒有傳入群組索引預設為 0, 即 group()=(0), 會傳回各群組匹配的全部字串; groups() 方法無傳入參數, 它是將各群組匹配的字串依序組成 tuple 傳回來; groupdict() 方法則是傳回具名群組織名稱與匹配字串組成之字典. 而 span(), start(), end() 都是用來查詢指定群組匹配字串在目標字串中的索引位置.
下面是分組的一個簡單範例 :
>>> import re
>>> match=re.match(r'(a+)(b+)', 'aabbbccc') # 含有兩個分組的正規式
>>> match
<re.Match object; span=(0, 5), match='aabbb'> # 匹配子字串 'aabbb'
>>> match.lastindex # 最大的分組索引
2
由 lastindex 屬性值可知有兩個匹配群組, 2 是最後一個分組的索引, 而群組 0 是全部匹配群組合在一起之匹配字串, 可以呼叫 Match 物件的 group() 方法查詢各群組之匹配字串, 例如 :
>>> match.group() # 各群組的匹配字串
'aabbb'
>>> match.group(0) # 各群組匹配的全部字串
'aabbb'
>>> match.group(1) # 群組 1 的匹配字串
'aa'
>>> match.group(2) # 群組 2 的匹配字串
'bbb'
>>> match.groups() # 傳回各群組匹配字串組成之 tuple
('aa', 'bbb')
>>> match.groupdict() # 傳回各具名群組名稱與匹配字串組成之 dict
{} # 無具名群組故傳回空字典
>>> match.span(1) # 第一群組匹配字串的索引範圍
(0, 2)
>>> match.start(1) # 第一群組匹配字串的開始索引
0
>>> match.end(1) # 第一群組匹配字串的結束索引 (不含)
2
>>> match.span(2) # 第二群組匹配字串的索引範圍
(2, 5)
>>> match.start(2) # 第二群組匹配字串的開始索引
2
>>> match.end(2) # 第二群組匹配字串的結束索引 (不含)
5
如果正規式沒有分組, 則 group(n) 方法只能傳入 0 或不傳入參數, 即只能存取整體的匹配字串, 存取與分組相關的資料會出現錯誤, 例如 :
>>> match=re.match(r'a+b+', 'aabbbccc') # 匹配 1 個以上之 a 與 b 字元
>>> match
<re.Match object; span=(0, 5), match='aabbb'>
>>> match.group() # 全部匹配字串
'aabbb'
>>> match.group(0) # 全部匹配字串
'aabbb'
>>> match.group(1) # 此匹配沒有分群
Traceback (most recent call last):
File "<pyshell>", line 1, in <module>
IndexError: no such group
電話號碼的比對就是分組派上用場之處, 以固網電話為例, 電話號碼分成前面的區碼與後面的用戶號碼兩段, 這兩段用正規式表示便可用兩個群組來代表, 這樣就能分別標記區碼與用戶號碼了. 不過電話號碼的記法習慣不同, 有用 - 號分隔的, 也有用小括號將趨碼括起來的, 它們相對應的正規式如下表所示 :
電話號碼 | 正規式 |
07-1234567 或 02-12345678 | (\d{2})-(\d{7, 8}) |
(07)1234567 或 (02)12345678 | (\(\d{2}\))(\d{7, 8}) |
(07)-1234567 或 (02)-12345678 | (\(\d{2}\))-(\d{7, 8}) |
注意, 台灣的固網用戶號碼長度一般是 7 碼, 但或 8 碼台北與台中則為 8 碼. 上表中第一種記法之正規式很直觀, 就是用量詞字元 {} 限定前面數字字元的長度, {2} 表示限定 2 碼, 而 {7, 8} 表示限定碼長為 7 碼或 8 碼. 第二種記法使用小括號 () 括住區碼, 但由於 () 在正規式中有特定用途 (就是分組啦), 要用到括號本身就必須在前面加倒斜線來跳脫 (escape), 第三種記法則只是在區碼小括弧後面多一個 dash. 其實後面這兩個記法可以用量詞 ? (0 或 1 個) 來合併為 (\(\d{2}\))-?(\d{7, 8}). 如果要比對這三種記法, 可以用管線符號 | 將其與第一個正規式合併 :
pattern=re.compile(r'(\d{2})-(\d{7,8})|(\(\d{2}\))-?(\d{7,8})')
注意, 量詞 {m,n} 的 m 與 n 中間不可有空格.
下面就用這個正規式樣本來從文本中搜尋電話號碼 :
>>> pattern=re.compile(r'(\d{2})-(\d{7,8})|(\(\d{2}\))-?(\d{7,8})')
>>> pattern.match('07-12345678') # 從開頭比對是否符合
<re.Match object; span=(0, 11), match='07-12345678'>
>>> pattern.search('家:07-12345678, 公司:02-87654321, 手機:0933-123456')
<re.Match object; span=(3, 14), match='07-12345678'> # 找到第一組匹配就停止
>>> pattern.findall("家:07-1234567, 公司:02-87654321, 手機:0933-123456")
[('07', '1234567', '', ''), ('02', '87654321', '', '')] # 沒有匹配的分組傳回空字串
>>> pattern.findall("家:(07)1234567, 公司:(02)-87654321, 手機:0933-123456")
[('', '', '(07)', '1234567'), ('', '', '(02)', '87654321')] # 沒有匹配的分組傳回空字串
此例傳入 match() 的字串開頭就符合樣式所以匹配 (若目標字串是 '家:07-1234567' 就不會匹配), search() 方法則找到第一組匹配字串就停止, 所以後面的 02-87654321 不會被找到; 而 findall() 則會依據分組傳回串列. 從兩個 findall() 範例可知, 每一個分組的比對結果都會被放進 tuple 中, 所以沒有匹配的分組會傳回空字串.
五. 用旗標與修飾符改變比對模式 :
以上的範例皆未用到旗標 (flag), 在 re 模組的函式與 Pattern 物件的方法中, 旗標都是有預設值的備選參數, 例如預設有分大小寫, 採用單列模式, 以及關閉註釋功能等. 旗標在正規表示法中的功能是用來改變預設的比對模式 (match mode), 在 re 模組中是以內建常數形式存在. 另外正規表示法還有所謂修飾符 (modifier) 的語法也可以修改比對模式, 每種旗標都有相對應的修飾符語法.
常用的比對模式旗標有下列四個 :
- re.IGNORECASE (簡寫 re.I) : 不分大小寫, 對應之修飾符為 (?i)
- re.DOTALL (簡寫 re.S) : 單列模式, 對應之修飾符為 (?s)
- re.MULTILINE (簡寫 re.M) : 多列模式, 對應之修飾符為 (?m)
- re.VERBOSE (簡寫 re.X) : 註解模式, 對應之修飾符為 (?x)
1. 不分大小寫模式旗標 re.IGNORECASE (或 re.I) :
此旗標是 re 模組的常數 re.IGNORECASE (簡寫為 re.I), 用來將正規式的比對模式由預設的有分大小寫更改為不分大小寫, 其值事實上是整數 2, 例如 :
>>> re.IGNORECASE
<RegexFlag.IGNORECASE: 2>
>>> re.I # 簡寫
<RegexFlag.IGNORECASE: 2>
True
正規表示法比對時預設是有分大小寫, 亦即 r'ok' 只匹配 'ok' 而不會匹配 'OK', 'Ok', 或 'oK'. 如果要匹配這四種字元排列必須用 r'[oO][kK]', 例如 :
>>> re.match(r'ok', 'OK') == None # 不匹配 (大小寫不符)
True
>>> re.match(r'[oO][kK]', 'OK') # 匹配 (不分大小寫)
<re.Match object; span=(0, 2), match='OK'>
>>> re.match(r'[oO][kK]', 'ok') # 匹配 (不分大小寫)
<re.Match object; span=(0, 2), match='ok'>
>>> re.match(r'[oO][kK]', 'oK') # 匹配 (不分大小寫)
<re.Match object; span=(0, 2), match='oK'>
>>> re.match(r'[oO][kK]', 'Ok') # 匹配 (不分大小寫)
<re.Match object; span=(0, 2), match='Ok'>
這種作法當字串較長時就會很繁冗, 但用 re.I 旗標就可以輕易解決此問題, 例如 :
>>> re.match(r'ok', 'ok', re.I)
<re.Match object; span=(0, 2), match='ok'>
>>> re.match(r'ok', 'OK', re.I)
<re.Match object; span=(0, 2), match='OK'>
>>> re.match(r'ok', 'Ok', re.I)
<re.Match object; span=(0, 2), match='Ok'>
>>> re.match(r'ok', 'oK', re.I)
<re.Match object; span=(0, 2), match='oK'>
>>> re.match(r'OK', 'ok', re.I)
<re.Match object; span=(0, 2), match='ok'>
可見傳入旗標 re.I 後不管目標字串的每個字元是大小寫都能匹配, 而且正規式也是不分大小寫.
除了使用 re.I 旗標外, 還可以在正規式前面冠上修飾符 (?i) 將比對模式改為不分大小寫 (修飾符看起來像是群組但其實不是, 顧名思義它就是修飾後面正規式的意義而已), 例如 :
>>> re.match(r'(?i)ok', 'OK')
<re.Match object; span=(0, 2), match='OK'>
>>> re.match(r'(?i)ok', 'Ok')
<re.Match object; span=(0, 2), match='Ok'>
>>> re.match(r'(?i)ok', 'oK')
<re.Match object; span=(0, 2), match='oK'>
>>> re.match(r'(?i)OK', 'ok')
<re.Match object; span=(0, 2), match='ok'>
使用修飾符的好處是正規式幾乎可以原封不動搬到其他程式語言中不需修改 (除了 Javascript 外都可以, Javascript 是在正規式尾端以 /i 指定不分大小寫),
2. 單列模式旗標 re.DOTALL (或 re.S):
此單列模式跟正規式中小數點字元的功能有關, 小數點常被誤認為是匹配任何字元的萬用字元, 其實在預設模式下它有一個例外, 即不包含換列字元 '\n', 用 re.match() 與 re.search() 進行比對與搜尋 r'.*' 樣式時若遇到換列字元會停止, 例如 :
>>> re.match(r'.*', 'Trust me,You can make it') # 無換列字元, 全部都匹配
<re.Match object; span=(0, 24), match='Trust me,You can make it'>
>>> re.match(r'.*', 'Trust me\nYou can make it') # 換列字元 \n 使比對 r'.*' 停止
<re.Match object; span=(0, 8), match='Trust me'>
>>> re.search(r'.*', 'Trust me\nYou can make it') # 換列字元 \n 使搜尋 r'.*' 停止
<re.Match object; span=(0, 8), match='Trust me'>
此例正規式 r'.*' 匹配 0 個或更多個除 \n 以外的字元, 當 re.match() 或 re.search() 讀取到 \n 字元時會因為不匹配而停止比對或搜尋, 結果只傳回 \n 字元前面的 'Trust me' 而已. 如果傳入旗標 re.DOTALL (或簡寫 re.S) 將比對模式從預設模式改為單列模式, 它會把小數點字元的例外情形 (不含 \n) 去除 (變成包含 \n), 使其能代表所有的字元 (此即 DOT=ALL 之意), 由於此旗標能跨越換列字元, 整個目標字串看起來似乎是單列一樣, 故稱為單列旗標, 例如 :
>>> re.match(r'.*', 'Trust me\nYou can make it', re.DOTALL) # 單列模式
<re.Match object; span=(0, 24), match='Trust me\nYou can make it'>
>>> re.search(r'.*', 'Trust me\nYou can make it',re.DOTALL) # 單列模式
<re.Match object; span=(0, 24), match='Trust me\nYou can make it'>
可見傳入 re.DOTALL (re.S) 後比對與搜尋就能跨越換列字元 \n 了.
小數點萬用字元不包含換列字元 \n 問題除了用單列模式旗標解決外, 還可以使用修飾符, re.DOTALL 旗標對應的修飾符為 (?s), 把它放在正規式最前面就可以切換到單列模式了, 例如 :
>>> re.match(r'(?s).*', 'Trust me\nYou can make it') # 使用單列模式修飾符
<re.Match object; span=(0, 24), match='Trust me\nYou can make it'>
>>> re.search(r'(?s).*', 'Trust me\nYou can make it') # 使用單列模式修飾符
<re.Match object; span=(0, 24), match='Trust me\nYou can make it'>
可見效果與使用旗標是一樣的.
另外, 萬用字元除了使用正規式 r'.*' 配合 re.DOTALL 旗標改變比對模式外, 萬用字元還可以用正規式 r'[\s\S]*' 來表示, 其中 \s 表示所有空白字元 (包括空格, TAB 等), 而 \S 表示非空白字元, 兩個互斥的集合放在字元集中相當於是所有字元, 這樣就不需要用到單列模式旗標了. 例如 :
>>> re.match(r'[\s\S]*', 'Trust me\nYou can make it')
<re.Match object; span=(0, 24), match='Trust me\nYou can make it'>
>>> re.search(r'[\s\S]*', 'Trust me\nYou can make it')
<re.Match object; span=(0, 24), match='Trust me\nYou can make it'>
可見效果與使用 re.DOTALL 旗標相同. 除了可用 [\s\S] 代表任何字元外, 也可以用 [\d\D] 或 [\w\W], 所以這種用法的缺點就是缺乏解讀上的單一性.
3. 多列模式旗標 re.MULTILINE (或 re.M) :
多列模式似乎與上面的單列模式相對應, 但其實兩個模式毫不相干, 單列模式改變的是小數點字元的比對規則; 而多列模式改變的則是 ^ 與 $ 的比對規則. 在預設模式下, ^ 與 $ 表示整個目標字串的開頭與結束位置, 而在多列模式下, ^ 與 $ 表示目標字串內每一列的開頭與結束位置, 這對於要做逐列比對樣式的工作非常有用, 例如下面這個多列字串 :
1. Hello World
Welcome
2. Trust me, you can make it
在預設模式下用 re.findall() 無法直接找出多列字串中全部數字字元開頭的列, 例如 :
>>> str='1. Hello World\nWelcome\n2. Trust me, you can make it'
>>> re.findall(r'^\d.*', str)
['1. Hello World']
這是因為正規式 r'^\d.*' 匹配以數字字元開頭的任意的長度字元, 但是在預設模式下小數點並不包含換列字元 \n, 所以碰到第一個 \n 就停止搜尋了. 但若改為多列模式, 則 ^ 與 $ 會被重新定義為每列的起始與結束位置, 這樣就可以直接用正規式 r'^\d.*' 來比對是否為數字開頭的列, 例如 :
>>> str='1. Hello World\nWelcome\n2. Trust me, you can make it'
>>> re.findall(r'^\d.*', str, re.MULTILINE) # 多列模式 ^ 字元表示每列開頭
['1. Hello World', '2. Trust me, you can make it']
此例的目標字串有三列, 其中第一列與第三列才是以數字字元開頭的, 所以傳回這兩列. 除了使用旗標, 還可以在正規式開頭冠上 (?m) 修飾符設定為多列模式, 例如 :
>>> str='1. Hello World\nWelcome\n2. Trust me, you can make it'
>>> re.findall(r'(?m)^\d.*', str)
['1. Hello World', '2. Trust me, you can make it']
可見結果與上面使用旗標是一樣的. 使用修飾符的好處是正規式可移植到除 Javascript 以外的程式語言皆可使用, 而旗標的用法則因語言而異.
如果要在每列結尾加上句點則要用 $ 字元, 例如 :
>>> re.sub(r'$', '.', str, re.MULTILINE)
'1. Hello World\nWelcome\n2. Trust me, you can make it.'
但似乎無效 (why?), 改用修飾符 (?m) 則可以 :
>>> re.sub(r'(?m)$', '.', str)
'1. Hello World.\nWelcome.\n2. Trust me, you can make it.'
4. 註解模式旗標 :
當正規式很長很複雜時可讀性會較差, 這時可將正規式以長字串分列表示, 同時以 re.VERBOSE (或簡寫 re.X) 旗標設定為註解模式, 這樣就可以在每列後面用 # 加上註解說明, 例如上面用來比對固網電話號碼的正規式 :
r'(\d{2})-(\d{7,8})|(\(\d{2}\))-?(\d{7,8})'
全都寫成一列不容易懂, 即使在底下以文字說明也不直觀, 改用註解模式即可將正規式分列說明 :
>>> pattern=r'''(\d{2})-(\d{7,8})| # 區碼無括弧, 例如 02-12345678
(\(\d{2}\))-?(\d{7,8}) # 區碼有括弧, 例如 (02)-12345678
'''
>>> re.match(pattern, '07-1234567', re.VERBOSE)
<re.Match object; span=(0, 11), match='07-1234567'>
>>> re.match(pattern, '02-87654321', re.X) # 簡寫
<re.Match object; span=(0, 11), match='02-87654321'>
除了使用 re.VERBOSE 或 re.X 旗標外, 還可以在正規式最前面冠上 (?x) 修飾符來設定為註解模式 (注意是小寫 x), 例如 :
>>> pattern=r'''(?x) # 註解模式修飾符
(\d{2})-(\d{7,8})| # 區碼無括弧, 例如 02-12345678
(\(\d{2}\))-?(\d{7,8}) # 區碼有括弧, 例如 (02)-12345678
'''
>>> re.match(pattern, '07-12345678') # 不需要旗標
<re.Match object; span=(0, 11), match='07-12345678'>
此例使用了修飾符 (?x) 所以函式中就不用傳入 re.VERBOSE 旗標了.
參考 :
PS : 這篇我寫很久了 (大概一年多), 因太多事情打斷一直寫不完, 每次重拾又掉入 start from scratch 地獄, 來來回回改了很多遍, 已經忘記第一版是長怎樣了, 哈哈, 今天終於搞定了 (花了三周). 但也別高興得太早, 正規表示法這魔頭是沒那麼好降伏的, 這玩意兒每次重新複習完都會讓你有 from zero to hero 的感覺, 沒有錯, 這時大概就是走火入魔了.