一、为什么要嵌入条件
(123)456-7890 和 123-456-7890 都是可接受的北美电话号码格式,而 1234567890、(123)-456-7890 和 (123-456-7890) 虽然都包含数目正确的数字字符,但格式都不对。如果要编写一个只匹配可接受格式的正则表达式,下面是最容易想到的解决方案。
代码语言:javascript复制mysql> set @s:='123-456-7890
'> (123)456-7890
'> (123)-456-7890
'> (123-456-7890
'> 1234567890
'> 123 456 7890';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='\(?\d{3}\)?-?\d{3}-\d{4}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, 'n') c, regexp_extract(@s, @r, 'n') s, regexp_extract_index(@s, @r, 0, 'n') i;
------ --------------------------------------------------------- ------------
| c | s | i |
------ --------------------------------------------------------- ------------
| 4 | 123-456-7890,(123)456-7890,(123)-456-7890,(123-456-7890 | 1,14,28,43 |
------ --------------------------------------------------------- ------------
1 row in set (0.00 sec)
代码语言:javascript复制 (? 匹配一个可选的左括号。注意,这里必须对 ( 进行转义,d{3} 匹配前3位数字,
)? 匹配一个可选的右括号,-? 匹配一个可选的连字符,d{3}-d{4} 匹配剩余的 7 位数字
(中间用一个连字符分隔)。该模式没有匹配到最后两行,这是正确的,但匹配到了第3行和第4行,
这就不正确了(第3行的)后面多了一个-,第4行少了一个配对的))。
把 )?-? 替换为 [)-]? 可以排除第3行(只允许出现 ) 或 -,两者不能同时存在),但第 4 行还是无法排除。正确的模式应该只在电话号码里有一个 ( 的时候才去匹配 )。更准确地说,如果电话号码里有一个 (,模式就需要去匹配 );如果不是这样,那就得去匹配 -。这种模式如果不使用条件处理根本无法编写。并非所有的正则表达式实现都支持条件处理。MySQL 正则表达式还没有实现条件处理,报错为:
代码语言:javascript复制ERROR 3690 (HY000): The regular expression contains a feature that is not implemented in this library version.
二、正则表达式里的条件
正则表达式里的条件要用 ? 来定义。? 匹配前一个字符或表达式,如果它存在的话。?= 和 ?<= 匹配前面或后面的文本,如果它存在的话。嵌入式条件语法也使用了 ?,这并没有什么让人感到吃惊的地方,因为嵌入式条件不外乎以下两种情况:根据反向引用来进行条件处理;根据环视来进行条件处理。
1. 反向引用条件
反向引用条件仅在一个前面的子表达式得以匹配的情况下才允许使用另一个表达式。听起来很费解,还是用一个例子来说明:要把一段文本里的<img>标签全都找出来;不仅如此,如果某个<img>标签是一个链接(位于<a>和</a>标签之间)的话,还要匹配整个链接标签。用来定义这种条件的语法是 (?(backreference)true),其中 ? 表明这是一个条件,括号里的 backreference 是一个反向引用,仅当反向引用立即出现时,才对表达式求值。
代码语言:javascript复制set @s:='<!-- Nav bar -->
<div>
<a href="/home"><img src="/images/home.gif"></a>
<img src="/images/spacer.gif">
<a href="/search"><img src="/images/search.gif"></a>
<img src="/images/spacer.gif">
<a href="/help"><img src="/images/help.gif"></a>
</div>';
set @r:='(<[Aa]\s [^>] >\s*)?<[Ii][Mm][Gg]\s [^>] >(?(1)\s*<\/[Aa]>)';
(<[Aa]s [^>] >s*)? 匹配一个 <A> 或 <a> 标签(以及可能存在的任意属性),这个标签可有可无(因为这个子表达式的最后有一个?)。接下来,<[Ii][Mm][Gg]s [^>] >匹配一个<img>标签(大小写均可)及其任意属性。(?(1)s*</[Aa]>) 的起始部分是一个条件:?(1) 表示仅当第一个反向引用(<A>标签)存在,才继续匹配 s*</[Aa]>,换句话说,只有当第一个 <A> 标签匹配成功,才去执行后面的匹配。如果 (1) 存在,s*</[Aa]> 匹配结束标签 </A> 之前出现的任意空白字符。
?(1) 检查第一个反向引用是否存在。在条件里,反向引用编号(本例中的1)在条件中不需要被转义。因此,?(1)是正确的,?(1)则不正确(但后者通常也能用)。刚才使用的模式只在给定条件得到满足时才执行表达式。条件还可以有else表达式,仅当给定的反向引用不存在(也就是不符合条件)时才执行该表达式。用来定义这种条件的语法是(?(backreference)true|false)。此语法接受一个条件和两个分别在符合/不符合该条件时执行的表达式。这种语法提供了电话号码问题的解决方案。
代码语言:javascript复制set @s:='123-456-7890
(123)456-7890
(123)-456-7890
(123-456-7890
1234567890
123 456 7890';
set @r:='(\()?\d{3}(?(1)\)|-)\d{3}-\d{4}';
(()?负责检查左括号,但我们这次将其放入了括号中,这样就得到了一个子表达式。随后的 d{3} 匹配 3 位数字的区号。依赖于是否满足条件,(?(1))|-) 匹配 ) 或 -。如果 (1) 存在(也就是找到了一个左括号),必须匹配 );否则,必须匹配 -。这样一来,括号就只能成对出现。如果没有使用括号,电话区号和其余数字之间的 - 分隔符必须被匹配。第 4 行因为左括号 ( 没有与之匹配的右括号 ),所以嵌入条件被视为无关文本,完全被忽略了。
MySQL 正则表达式还不支持嵌入式条件,只能通过把所有符合条件的组合都用“或”列出来实现。
代码语言:javascript复制mysql> set @s:='123-456-7890
'> (123)456-7890
'> (123)-456-7890
'> (123-456-7890
'> 1234567890
'> 123 456 7890';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='(\d{3}-\d{3}-\d{4})|([(]\d{3}[)]\d{3}-\d{4})';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, 'n') c, regexp_extract(@s, @r, 'n') s, regexp_extract_index(@s, @r, 0, 'n') i;
------ ----------------------------------------- ---------
| c | s | i |
------ ----------------------------------------- ---------
| 3 | 123-456-7890,(123)456-7890,123-456-7890 | 1,14,44 |
------ ----------------------------------------- ---------
1 row in set (0.00 sec)
嵌入了条件的模式一眼看上去非常复杂,这意味着调试工作会变得非常困难。比较好的办法是,先构建和测试整个模式的各个组成部分,再把它们组合到一起。
2. 环视条件
环视条件允许根据向前查看或向后查看操作是否成功来决定要不要执行表达式。环视条件的语法与反向引用条件的语法大同小异,只需把反向引用(括号里的反向引用编号)替换为一个完整的环视表达式就行了。
作为一个例子,请思考一下怎样匹配美国邮政编码(U.S ZIP code)。美国邮政编码有两种格式,一种是 12345 形式的 ZIP 编码,另一种是 12345-6789 形式的 ZIP 4 编码。只有 ZIP 4 编码才必须使用连字符。下面是一种解决方法。
代码语言:javascript复制mysql> set @s:='11111
'> 22222
'> 33333-
'> 44444-4444';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='\d{5}(-\d{4})?';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, 'n') c, regexp_extract(@s, @r, 'n') s, regexp_extract_index(@s, @r, 0, 'n') i;
------ ------------------------------ -----------
| c | s | i |
------ ------------------------------ -----------
| 4 | 11111,22222,33333,44444-4444 | 1,7,13,20 |
------ ------------------------------ -----------
1 row in set (0.00 sec)
d{5} 匹配前 5 位数字。(-d{4})? 匹配一个连字符和后 4 位数字,这部分要么都出现,要么都不出现。但是,如果不想匹配那些错误格式的 ZIP 编码呢?比如说,例子中的第 3 行末尾有一个不应该出现在那里的连字符。下面这个例子直截了当地演示了环视条件的用法。
代码语言:javascript复制set @r:='\d{5}(?(?=-)-\d{4})';
d{5} 匹配前 5 位数字。接下来是 (?(?=-)-d{4}) 形式的条件。这个条件使用向前查看 ?=- 来匹配(但不消耗)一个连字符,如果符合条件(连字符存在),那么 -d{4} 将匹配该连字符和随后的 4 位数字。这样一来,33333- 就被排除在最终的匹配结果之外了。它有一个连字符,所以满足给定条件,但末尾缺少额外的 4 位数字。向前查看和向后查看(肯定式和否定式皆可)都可作为条件,也可使用可选的 else 表达式(语法和之前看到的一样,即|expression)。环视条件用的并不是很多,因为使用更简单的方法往往可以实现差不多的结果。例如 MySQL 如下可以这样做。
代码语言:javascript复制mysql> set @r:='^\d{5}(-\d{4})?$';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, 'm') c, regexp_extract(@s, @r, 'm') s, regexp_extract_index(@s, @r, 0, 'm') i;
------ ------------------------ --------
| c | s | i |
------ ------------------------ --------
| 3 | 11111,22222,44444-4444 | 1,7,20 |
------ ------------------------ --------
1 row in set (0.00 sec)