正则表达式(二):Unicode 诸问题(上)

  • 余晟

2011 年 2 月 22 日

话题:Java.NETRuby语言 & 开发架构

关于正则表达式的文档很多,但大部分都是英文的,即便有中文的文档,也翻译或改编自英文文档。在介绍功能时,这样做没有大问题,但真要处理文本,就可能会遇到一些英文开发或应用环境中难得见到的问题。比如中文之类多字节字符的匹配,就是如此。所以,这篇文章专门谈谈正则表达式如何处理多字节字符,更准确地说,是如何处理 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 个字节(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 编码。

Java.NETRuby语言 & 开发架构