正則表達式(二):Unicode諸問題(上)
關于正則表達式的文檔很多,但大部分都是英文的,即便有中文的文檔,也翻譯或改編自英文文檔。在介紹功能時,這樣做沒有大問題,但真要處理文本,就可能會遇到一些英文開發或應用環境中難得見到的問題。比如中文之類多字節字符的匹配,就是如此。所以,這篇文章專門談談正則表達式如何處理多字節字符,更準確地說,是如何處理Unicode編碼的文本(為什么只提到Unicode編碼,而沒有提到其它編碼,理由在后面詳述)。
首先介紹關于編碼的基礎知識:
通常來說,英文編碼較為統一,往往采用ascii編碼或兼容ascii的編碼(即編碼表的前127位與ascii編碼一致,常用的各種編碼,包括Unicode編碼都是如此)。也就是說,英文字母、阿拉伯數字和英文的各種符號,在不同編碼下的表示是一樣的,比如字母A,其編碼總是41,常見的編碼中,英文字符和半角標點符號的編碼都等于ascii編碼,通常只用一個字節表示。
但是中文的情況則不同,常見的中文編碼有GBK(CP936)和Unicode兩種,同一個中文字符在不同編碼下的值并不相同,比如“發”字,GBK編碼的值為b7 a2,用兩個字節表示;而Unicode編碼的值(也就是代碼點,Code Point)為53 d1。如果用UTF-8編碼保存,需要3個字節(e5 8f 91);用UTF-16編碼保存,需要4個字節(fe ff 53 d1)。
正因為中文字符需要多個字節來表示,常見的正則表達式的文檔就有可能無法覆蓋這種情況。比如常見的資料都說,點號『.』可以匹配“除換行符\n之外的任意字符”,但這可能只適用于“單字節字符”,因為點號匹配的其實只是“除換行符\n之外的任意字節”而已。不信,我們可以來試試看(以下例子中,程序均使用UTF-8編碼):
Python 2.x >>> re.search('^.$', '發') == None # True PHP 4.x/5.x preg_match('/^.$/', '發') // 0 Ruby 1.8 irb(main):001:0> '發' =~ /^.$/ # nil
之所以會出現這種情況,是因為正則表達式無法正確將多個字節識別為“單個字符”,讓點號『.』能正確匹配。不過在Python 3.x、Java、.NET和Ruby 1.9中,字符串默認都是采用Unicode編碼,所以不存在上面的問題。如果你使用的是Python 2.x、Ruby 1.8或PHP,也可以顯式指定采用Unicode模式。
Python 2.x >>> re.search('^.$', u'發') == None #False PHP 4.x/5.x preg_match('/^.$/u', '發') // 1 Ruby 1.8 irb(main):001:0> '發' =~ /^.$/u # 0
如果你細心就會發現,在Python 2.x中,我們指定的字符串使用Unicode編碼,而文檔里說了,正則表達式也可以指定Unicode模式的;相反,在PHP和Ruby中,我們指定正則表達式使用Unicode編碼,而字符串并沒有指定。這到底是怎么回事呢?
我們知道,正則表達式的操作可以簡要概括為“用正則表達式去匹配字符串”,它涉及兩個對象:正則表達式和字符串。對字符串來說,如果沒有設定Unicode模式,則多字節字符很可能會拆開為多個單字節字符對待(雖然它們并不是合法的ascii字符),Python 2.x中就是如此,“發”字在沒有設定Unicode編碼時,變成了3個單字節字符構成的字符串,點號『.』只能匹配其中的單個“字符”。如果顯式將正則表達式設定為Unicode字符串(也就是在 u'發' ),則“發”字視為單個字符,點號可以匹配。
而且,如果你在正則表達式的字符組里使用了中文字符,表示正則表達式的字符串,也應該設定為Unicode字符串,否則正則表達式會認為字符組里不是單個字符,而是3個單字節字符:
Python 2.x >>> re.search('^[我]$', u'我') == None # True >>> re.search(u'^[我]$', u'我') == None # False
另一方面,在PHP和Ruby中并不存在“Unicode字符串”,所以我們無法修改字符串的屬性。但是,設定正則表達式為Unicode模式,正則表達式也可以正確識別字符串中的Unicode字符。所以,如果你用PHP或Ruby的正則表達式處理Unicode字符串,一定不要忘記指定Unicode模式。
點號『.』對Unicode字符的匹配“我”(采用UTF-8編碼)。
字符串 |
正則表達式 |
語言 |
是否顯式指定Unicode模式 |
可否匹配 |
我 |
^.$ |
Java |
否(無須指定) |
可以 |
^.$ |
JavaScript |
否(無法指定) |
由瀏覽器的實現決定 |
|
/^.$/ |
PHP |
否 |
不可以 |
|
/^.$/u |
PHP |
是 |
可以 |
|
/^.$/ |
Ruby 1.8 |
否 |
不可以 |
|
/^.$/u |
Ruby 1.8 |
是 |
可以 |
|
/^.$/ |
Ruby 1.9 |
否 |
可以 |
|
^.$ |
.NET |
否 |
可以 |
|
^.$ |
Python 2.x |
否 |
不可以 |
|
^.$ |
Python 3 |
否 |
可以 |
注:PHP和Ruby的正則表達式本身是不包含分隔符(分隔符可以有很多種,常見的是反斜線/)的,但PHP指定Unicode模式必須在后一個分隔符之后寫u,所以在這里將分隔符也寫出來。
不過,如果你熟悉Python語言,會發現Python也可以指定正則表達式使用Unicode模式,這又是怎么回事呢?
不妨回頭仔細想想你讀過的文檔,正則表達式中的『\d』和『\w』,都是如何解釋的?或許你的第一反應是:『\d』等價于『[0-9]』,『\w』等價于『[0-9a-zA-Z_]』。因為有些文檔說明了這種等價關系,有些文檔卻說:『\d』匹配數字字符,『\w』匹配單詞字符。然而這只是針對ascii編碼的規定,在Unicode編碼中,全角數字0、1、2之類,應該也可以算“數字字符”,由『\d』匹配;中文的字符,應該也可以算“單詞字符”,由『\w』匹配;同樣的道理,中文的全角空格,應該也可以算作“空白字符”,由『\s』匹配。所以,如果你在Python中指定了正則表達式使用,『\d』、『\w』、『\s』就能匹配全角數字、中文字符、全角空格。
Python 2.x(字符均為全角) >>> re.search('(?u)^\d$', u'1') == None # True >>> re.search('(?u)^\w$', u'發') == None # True >>> re.search('(?u)^\s', u' ') == None # True
老實說,這樣的規定有時候確實讓人抓狂,假設你希望用正則表達式『\d{6,12}』來驗證一個長度在6到12之間的數字字符串,卻沒留意『\d』能匹配全角數字,驗證就不夠嚴密了。
下面的表格列出了常見語言中的匹配規定:
語言 |
『\w』『\d』『\s』的匹配規則 |
Java |
均只能匹配ascii字符 |
JavaScript |
均只能匹配ascii字符 |
PHP |
均只能匹配ascii字符 |
Ruby 1.8 |
默認情況下只能匹配ascii字符,Unicode模式只影響『\w』的匹配 |
Ruby 1.9 |
均可以識別Unicode字符 |
.NET |
均可以識別Unicode字符 |
Python 2.x |
默認情況下只能匹配ascii字符,Unicode模式下均可以識別Unicode字符 |
Python 3 |
默認情況下均可以識別Unicode字符,但可以顯式指定ascii |
注1:一般來說,單詞邊界『\b』能匹配的位置是:一端是『\w』,一端不是『\w』(也可以什么都沒有),其中『\w』的規定與『\w』一樣,但Java中則不是這樣,細節比較復雜,這里不展開,有興趣的讀者可以自己試驗。
注2:在Python 3中可以在表達式之前添加『(?a)』指定ascii模式。
雖然常見的中文字符編碼有GBK和Unicode兩種,但如果需要使用正則表達式處理中文,我強烈推薦使用Unicode字符,不僅是因為正則表達式提供了對Unicode的現成支持,而且因為GBK編碼可能會有其它問題。比如:我們要求匹配“收”字或者“發”字,很自然會想到使用字符組『[收發]』,這思路是對的,但如果采用GBK編碼,正則引擎見到的很可能不是“兩個字符構成的字符組”,而是“四個字節構成的字符組”。
使用GBK編碼,[收發]的解釋『ca d5 b7 a2』
如果我們用『[收發]』來匹配字符“罰”(它的GBK編碼是b7 a3),就會產生錯誤——雖然“罰”字既不等于“收”也不等于“發”,但“罰”和『[收發]』卻可以匹配一個字節。
GBK編碼的情況:
罰 b7 a3
[收發] ca d5 b7 a2
Unicode編碼的情況(因為Unicode編碼能正確識別,無論采用UTF-8還是UTF-16,Unicode字符都會正確轉化為Unicode編碼點)
罰 7f5a
[收發] 6536 53d1
“罰”的Unicode編碼是7f5a,無論如何也不會發生錯誤匹配。
如果出于某些限制,只能使用GBK編碼,也有一個偏方準確保證『[收發]』的匹配,就是把字符組『[收發]』改成多選分支『(收|發)』。此時如果要匹配成功,只能是兩個連續的字節ca d5或者b7 a2,而“罰”字兩個字節為b7 a3,無法匹配。
但這樣也會有問題,因為在GBK編碼下字符串被當作“字節序列”來對待。比如字符串 “賬珍”對應四個字節,d5 ca d5 e4,其中正好出現了“收”字對應的兩個字節ca d5,正則表達式就可能在此處匹配成功。
更重要的問題在于排除型字符組的匹配,仍然使用上面的例子,假如我們希望匹配一個“收”和“罰”之外的字符,自然的思路就是使用排除型字符組『[^收發]』。但是通過上面的講解,我們已經知道,這樣“排除”的并不是2個字符,而是4個字節:ca d5 b7 a2。但“罰”字的GBK編碼為b7 a3,b7這個字節被“排除”了,所以正則表達式會顯示“罰”字不能由『[^收發]』匹配,這完全違背了我們的本意。
總的來說,所以如果使用GBK編碼(或者說非Unicode編碼),對此類問題基本是無解的。因此,根本的辦法還是使用Unicode編碼。
推薦使用Unicode的另一個理由是,使用Unicode,我們可以指定字符的代碼點范圍,實現“匹配一個中文字符”的表達式。因為正則引擎能正確識別的多字節字符一般只有Unicode字符,所以即便我們知道GBK編碼中中文字符的編碼范圍,也無法指定一個字符組來匹配其中的字符,而Unicode編碼則可以。
在Unicode編碼表里,代碼點4e00-9fff歸類為“CJK 統一表意符號”CJK Unified Ideographs(參見http://en.wikipedia.org/wiki/CJK_Unified_Ideographs),涵蓋了絕大多數中文字符,我們可以某個字符組里匹配此范圍中任何一個代碼點,但是在不同語言中指定這個范圍的辦法并不同。作為本文的結尾,在這里列出各種語言中能匹配“中文字符”的字符組:
語言 |
表示法 |
注釋 |
Java |
[\u4e00-\u9fff] |
|
JavaScript |
[\u4e00-\u9fff] |
所操作字符串必須是Unicode編碼 |
PHP |
[\x{4e00}-\x{9fff}] |
必須指定Unicode模式 |
Ruby |
[\u{4e00}-\u{9fff}] |
Ruby 1.8不支持這種記法,Ruby 1.9中必須顯式指定Unicode模式 |
.Net |
[\u4e00-\u9fff] |
|
Python |
[\u4e00-\u9fff] |
Python 2.x中必須同時指定字符串和正則表達式都使用Unicode字符串;Python 3中則不用 |