Tag Archives: Regex

递归正则表达式

这篇帖子提到了递归性质的正则表达式。Ruby 的正则表达式引擎 Oniguruma 的递归仍然是通过命名捕获组完成的,这里就做一个演示。

我们有一段很简单的代码字符串:

str = <<STRING_ENDS_HERE
void main() {
    int i;
    if (1) {
        int a;
        if (10) {
            int var;
            if (1) {
                int i;
            }
        }
        int b;
    }
}
STRING_ENDS_HERE

而我们希望通过正则表达式来取出语法正确的 if 语句代码块。

regex = /

(?<if>
    if\s*\(\d*?\)\s*{\s*
        (?:int\s*[a-zA-Z]\w*?;\s*|\g<if>)*
    \s*}\s*
)

/xm

这里我们限制了一个语法正确的 if 语句为:
if,跟着可能的空白,跟着 (,跟着一个数字,跟着 ),跟着 {,跟着可能的空白,跟着另一个语法正确的 if 语句,或者跟着一个 int 变量的声明,最后跟着 } 和可能的空白。那么我们:

p str[regex]

就能捕获到最外围的 if 语句。当 if 后面少了 ( 或 ),或是少了 { 或 },抑或是 int 变量声明少了 ;,就是一个语法错误,那么就不匹配我们上面所写的 regex。比如,我们故意去掉 int var 后面的分号:

str = <<STRING_ENDS_HERE
void main() {
    int i;
    if (1) {
        int a;
        if (10) {
            int var
            if (1) {
                int i;
            }
        }
        int b;
    }
}
STRING_ENDS_HERE

这次 p str[regex] 则是:

"if (1) {\n                int i;\n            }\n        "

因为外围的 if 内部有语法错误,所以只有最里层的 if 被匹配了。

这只是一个很简陋的语法匹配,但演示了如何使用递归匹配。

正则表达式引擎 Oniguruma

Ruby 1.9 使用了一个效率更高、功能更强大的正则表达式引擎——Oniguruma(鬼車)。相比于 Ruby 1.8 的正则表达式引擎来说,Oniguruma 所能描述的再也不仅仅是正则语言了。这其中的一个重要因素是由于递归表达式的出现——正则语言的一大特点就是没有递归。和大多数现代化的引擎一样,Oniguruma 甚至能用来描述上下文无关语言这样的形式语言。可是,“正则表达式”这个术语仍然被各种语言持续使用了下来。可能人们对正则表达式太熟悉了,熟悉到会觉得其名称改为“形式表达式”很别扭。

本帖主要介绍一个新功能:命名捕获组

对于能熟练使用正则表达式的朋友来说,捕获组并不陌生。未经转义的圆括号中的表达式就是一个捕获组,我们之后可以在表达式中引用这个捕获组。传统的引用方式是通过所谓的后向引用,即 \n 的形式,来引用在表达式中从左到右第 n 个出现的捕获组。

这种方式有很多弊端。首先,用户必须维护 1, 2, …, n 这些数字。一旦发生两个捕获组换位的情况,就要把所有的它们的后向引用都改掉,这对于较长的表达式来说无疑是力气活。如果我们能给捕获组命名,那么即便它的位置变了,名字却不会变,所以无须修改其引用处。

其次,对于代码阅读者来说(把整个表达式背下来的除外),一个数字只不过是一个密码,它本身并不能让读者直接回想起任何已知事物,读者只能从头开始阅读表达式,并按出现顺序找到对应的捕获组,这属于一个简单的破译过程。如果我们给捕获组命名,那么就可以通过一两个单词来简短地描述该捕获组所接受的语言。比如:将一个捕获组命名为“alphanum”,那么读者大概就能猜到这个捕获组匹配的是“alphanumeric”的值,也就是字母或数字。

Oniguruma 命名捕获组是通过 (?<name>regex) 的语法来完成的。name 是捕获组的名称,regex 是捕获组内部的正则表达式。

#coding: GBK

regex = /(?<hex>[0-9a-fA-F])\+\g<hex>/
str = '我们需要取出4+f这个子串!'
p str[regex] # "4+f"

如果我们在命名捕获组时配合正则表达式的“忽略空白符”选项(字面值后添加 `x’ 后缀),以及在捕获组子表达式后添加 {0} 表示匹配 0 次,就可以大大地提高表达式的可读性。

这个匹配电子邮件地址的正则表达式就是一个比较复杂的例子。这里,由于使用的是 %r 来构建正则表达式,所以其内部可以直接写普通的注释。每个子表达式后面跟上 {0},表示这里仅仅是定义并命名一个捕获组,并不参与实际匹配。再加上最后的 `x’ 后缀,表达式中的空白都会被忽略,所以可以对表达式进行缩进。这个例子就大量使用了命名捕获组,将匹配不同字符串片段的表达式分划到了不同的子表达式。这在便于维护的同时也提高了可读性,和代码模块化是一个道理。

正则表达式字面值的对象构建

之前曾谈到了字符串扣留的概念,那么对于 Ruby 的正则表达式 Regexp 类型来说,是否也有类似的常量池机制呢?

(注:以下输出的具体数字随环境而变,重要的是其前后的相等性)

我们可以做一个简单的测试:

2.times { p "foo".object_id }
2.times { p :foo.object_id }
2.times { p /foo/.object_id }

输出:

5712492
5712432
146408
146408
5713608
5713608

根据输出结果,我们似乎可以得出结论, Regexp 类型也有类似字符串扣留的机制。然而细心的人会进一步测试这段代码:

2.times { p Regexp.new(/foo/).object_id }

输出了两个不同的数字。这和之前的测试难道不是矛盾的吗?当然不是,这实际上和 Ruby 的语法分析器有关。

Ruby 支持正则表达式字面值(/…/)。对于官方的 Ruby 实现来说,其语法分析器会在接受一个正则表达式的单词(token)时生成该正则表达式对象。也就是说,字面值形式的正则表达式是在语法分析时生成的,也可以宏观地看作是在编译时生成的。因此,在运行时,虽然会多次调用 /foo/.object_id,但由于语法分析器只看到了一个正则表达式字面值,其接收者永远都是一个对象。

证明:

ObjectSpace.each_object(Regexp) { |r| p r }
/foo/

第一行是打印出当前的 Ruby 实例中所有 Regexp 对象,而这时解释器还没执行到第二行的字面值。实际的输出中,也确实包含 /foo/ 这个对象。

如果语法分析器看到两个正则表达式字面值,自然也就会生成两个 Regexp 对象了:

p /foo/.object_id, /foo/.object_id

输出:

5713896
5713812

正则表达式匹配“电子邮件地址”语言

电子邮件地址语法:http://en.wikipedia.org/wiki/Email_address#Syntax

正式语法定义在 RFC 5321、5322。

#coding: GBK

# local@domain

$pattern = %r<

# Define subpatterns.
(?<local>       \g<local_t>|\g<quoted>              ) {0}
(?<local_t>     \g<local_a>+(?:\.\g<local_a>+)*     ) {0}
(?<local_a>     \g<alphanum>|[!\#$\%&'*/=?^_`{|}~-] ) {0}
(?<alphanum>    [a-zA-Z0-9]                         ) {0}
(?<quoted>      ".*"                                ) {0}
(?<domain>      \g<hostname>|\[\g<ip_addr>\]        ) {0}
(?<hostname>    \g<label>(?:\.\g<label>)*           ) {0}
(?<label>       \g<alphanum>+(?:-+\g<alphanum>+)*   ) {0}
(?<ip_addr>     \g<zero_255>(?:\.\g<zero_255>){3}   ) {0}
(?<zero_255>    \d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]   ) {0}

# The root pattern is ...
\g<local>@\g<domain>

>x

def assert_match(str, expected)
  if !(expected ^ (str[$pattern] != str))
    puts "预期#{expected ? '要' : '不'}匹配:"
    puts str
    puts "结果#{expected ? '不' : '却'}匹配。"
    exit(1)
  end
end

assert_match('user@example.com', true)
assert_match("66rpg.USER@66rpg.com", true)
assert_match("death.king@test.com", true)
assert_match(".deathking@test.com", false)
assert_match("deathking.@test.com", false)
assert_match('china.66rpg.forums.moderators.zh99998@66rpg.com.cn', true)
assert_match('1993de?at!hk#i$n%g@localhost', true)
assert_match('death>ki/ng@localhost', false)
assert_match('e@t.c',true)
assert_match('e@t.c.', false)
assert_match('DEATHKING@66RPG.COM', true)
assert_match('"雷欧纳德 sama 的邮箱哦>_<"@66rpg.net', true)
assert_match('"!@#$%^&*"特殊字符君的邮箱哦%^&*<>:{"}"@example.org', true)
assert_match('deathking@[127.0.0.1]', true)
assert_match('deathking@192.168.0.1', true)					# 这是主机名
assert_match('deathking@32768.1348.1083.19735', true)				# 这也是主机名
assert_match('deathking@[192.168.0,1]', false)
assert_match('deathking@[8.0.8.256]', false)
assert_match('deathking@[8.300.8.255]', false)
assert_match('deathking@[8.30.260.255]', false)
assert_match('deathking@[8.0.8.255]', true)
assert_match('deathking@[-3.3.5.7]', false)
assert_match('deathking@[192.168.178.123.]', false)

puts '测试全部通过。'

string = '大家好,我的名字叫Deathking,我的Email:是deathking03323@66rpg.cn哦,常联系!'
puts string[$pattern]

大概是这样。细微处还可以稍作调整,比如那些特殊字符,虽然标准允许,但大多数电子邮件客户端都不会允许。还可以加上 IPv6 的匹配。

长度上限在表达式中没有处理,因为据我所知,两级以上的动态长度的字元,同时又有长度限制的语言并非正则语言,所以正则表达式无法做到。静态长度的字元自然可以限制长度,如:

(?:.){1,63}

  接受长度上限为 63 的字符串。但如果圆括号中的字元长度也是动态的,那正则表达式就无法描述了。