正則表達式(二):Unicode諸問題(上)

來源: infoq  發布時間: 2011-02-28 21:42  閱讀: 2974 次  推薦: 0   原文鏈接   [收藏]  

  關于正則表達式的文檔很多,但大部分都是英文的,即便有中文的文檔,也翻譯或改編自英文文檔。在介紹功能時,這樣做沒有大問題,但真要處理文本,就可能會遇到一些英文開發或應用環境中難得見到的問題。比如中文之類多字節字符的匹配,就是如此。所以,這篇文章專門談談正則表達式如何處理多字節字符,更準確地說,是如何處理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 a3b7這個字節被“排除”了,所以正則表達式會顯示“罰”字不能由『[^收發]』匹配,這完全違背了我們的本意。

  總的來說,所以如果使用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中則不用

0
0
 
 
 

文章列表

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()