一、理解子表达式
假设需要找出所有重复的 HTML 不间断空格,将其用其他内容替换。
代码语言:javascript复制mysql> set @s:='Hello, my name is Ben Forta, and I am
'> the author of multiple books on SQL (including
'> MySQL, Oracle PL/SQL, and SQL Server T-SQL),
'> Regular Expressions, and other subjects.';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:=' {2,}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ ------ ------
| c | s | i |
------ ------ ------
| 0 | | NULL |
------ ------ ------
1 row in set (0.00 sec)
是 HTML 中不间断空格的实体引用(entity reference)。模式 {2,} 应该匹配连续两次或更多次重复出现的 ,结果却事与愿违。为什么会这样?因为{2,}指定的重复次数只作用于紧挨着它的前一个字符,在本例中,那是一个分号。如此一来,该模式可以匹配 ;;;;,但无法匹配 。
二、使用子表达式进行分组
这就引出了子表达式的概念。子表达式是更长的表达式的一部分,划分子表达式的目的是为了将其视为单一的实体来使用。子表达式必须出现在字符 ( 和 ) 之间。( 和 ) 是元字符,如果需要匹配 ( 和 ) 本身,就必须使用转义序列 ( 和 )。
代码语言:javascript复制mysql> set @r:='( ){2,}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ -------------- ------
| c | s | i |
------ -------------- ------
| 1 | | 143 |
------ -------------- ------
1 row in set (0.00 sec)
( ) 是一个子表达式,它被视为单一的实体。因此,紧随其后的 {2,} 将作用于整个子表达式,而不仅仅是分号。再来看一个例子,这次是用一个正则表达式来查找 IP 地址。IP 地址的格式是以英文句号分隔的 4 组数字,例如 12.159.46.200。因为每组可以包含 1~3 个数字字符,所以这 4 组数字可以统一使用模式 d{1,3} 来匹配。
代码语言:javascript复制mysql> set @s:='Pinging hog.forta.com [12.159.46.200]
'> with 32 bytes of data:';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ --------------- ------
| c | s | i |
------ --------------- ------
| 1 | 12.159.46.200 | 24 |
------ --------------- ------
1 row in set (0.00 sec)
每个 d{1,3} 匹配 IP 地址里的一组数字。4 组数字之间由 . 分隔,因此,在正则表达式中要转义为 .。在这个例子里,模式 d{1,3}.(最多匹配3个数字字符和随后的.)连续出现了3次,所以同样可以用重复来表示。下面是同一个例子的另一种写法。
代码语言:javascript复制mysql> set @r:='(\d{1,3}\.){3}\d{1,3}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ --------------- ------
| c | s | i |
------ --------------- ------
| 1 | 12.159.46.200 | 24 |
------ --------------- ------
1 row in set (0.01 sec)
该模式与之前那个有着同样的效果,但这次使用了另一种语法。将表达式 d{1,3}. 放入 ( 和 ) 之中,使其成为一个子表达式。(d{1,3}.){3} 表示该子表达式重复出现 3 次(它们对应着 IP 地址里的前 3 组数字),随后的 d{1,3} 用来匹配 IP 地址里的最后一组数字。
有些用户喜欢把表达式的某些部分加上括号,形成子表达式,以此提高可读性,因此,上面的模式可以写成 (d{1,3}.){3}(d{1,3})。这种做法完全没有问题,对表达式的实际行为也没有任何不良影响(但根据具体的正则表达式实现,这可能会影响性能)。利用子表达式进行分组非常重要,有必要再来看一个例子,它完全不涉及重复次数问题。下面的例子尝试匹配用户记录中的年份。
代码语言:javascript复制mysql> set @s:='ID: 042
'> SEX: M
'> DOB: 1967-08-17
'> Status: Active';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='19|20\d{2}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ ------ ------
| c | s | i |
------ ------ ------
| 1 | 19 | 21 |
------ ------ ------
1 row in set (0.00 sec)
这个例子需要构造模式去查找 4 位数的年份,但为了更加准确,明确地将前两位数字限定为 19 和 20。模式里的 | 是 OR(或)操作符,19|20 可以匹配19或20,因此,模式 19|20d{2} 应该匹配以 19 或 20 开头的 4 位数字(19或20的后面再跟着两位数字)。显然,这样并不管用。因为 | 操作符会查看其左右两边的内容,将模式 19|20d{2} 解释为 19 或 20d{2},也就是把 d{2} 解释为以 20 开头的那个表达式的一部分。换句话说,它匹配的是数字 19 或以 20 开头的任意 4 位数字。最终的结果只匹配到了19。正确答案是把 19|20 划分为一个子表达式。
代码语言:javascript复制mysql> set @r:='(19|20)\d{2}';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ ------ ------
| c | s | i |
------ ------ ------
| 1 | 1967 | 21 |
------ ------ ------
1 row in set (0.00 sec)
把选项全都归入一个子表达式里,这样 | 就知道打算匹配的是出现在分组中的选项之一。(19|20)d{2} 因此正确地匹配到了 1967,其他以 19 或 20 开头的 4 位年份数字自然也得以匹配。对于再往后的一些日期(从现在算起100年内),要是需要修改这段代码,使其也能够匹配以21开头的年份,只要把这个模式改成(19|20|21)d{2}就可以了。
三、子表达式的嵌套
子表达式允许嵌套。事实上,子表达式还可以多重嵌套。子表达式嵌套能够构造出功能极其强大的正则表达式,但这难免会让模式变得难以阅读和理解,多少有些让人望而却步。其实大多数嵌套子表达式并没有它们看上去那么复杂。为了演示嵌套子表达式的用法,再去看看刚才那个匹配 IP 地址的例子。
IP 地址由 4 个字节构成,每组数字的取值范围也就是单个字节的描述范围,即 0~255。(d{1,3}.){3}d{1,3} 这个模式能匹配 345、700、999 这些无效的 IP 地址数字。有一点很重要。写一个能够匹配预期内容的正则表达式并不难。但是写一个能够考虑到所有可能场景,确保将不需要匹配的内容排除在外的正则表达式可就难多了。
如果有办法设定有效的取值范围,事情会简单得多,但正则表达式只是匹配字符,并不真正了解这些字符的含义。所以就别指望数学运算了。有没有别的办法呢?也许有。在构造一个正则表达式的时候,一定要定义清楚想匹配什么,不想匹配什么。一个有效的 IP 地址中每组数字必须符合以下规则。
- 任意的 1 位或 2 位数字。
- 任意的以 1 开头的 3 位数字。
- 任意的以 2 开头、第二位数字在 0 到 4 之间的 3 位数字。
- 任意的以 25 开头、第三位数字在 0 到 5 之间的 3 位数字。
当依次罗列出所有规则之后,模式该是什么样子就变得一目了然了。
代码语言:javascript复制mysql> set @s:='Pinging hog.forta.com [12.159.46.200]
tract_index(@s, @r, 0, '') i;
'> with 32 bytes of data:';
Query OK, 0 rows affected (0.00 sec)
mysql> set @r:='(((25[0-5])|(2[0-4]\d)|(1\d{2})|(\d{1,2}))\.){3}(((25[0-5])|(2[0-4]\d)|(1\d{2})|(\d{1,2})))';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ --------------- ------
| c | s | i |
------ --------------- ------
| 1 | 12.159.46.200 | 24 |
------ --------------- ------
1 row in set (0.00 sec)
该模式成功的原因要归功于一系列嵌套子表达式。先来说说由 4 个子表达式构成的 (((25[0-5])| (2[0-4]d)|(1d{2})|(d{1,2})).)。(d{1,2}) 匹配任意的一位或两位数字(0~99);(1d{2}) 匹配以 1 开头的任意 3 位数字(100~199);(2[0-4]d) 匹配数字200~249;(25[0-5]) 匹配数字250~255。每个子表达式都出现在括号中,彼此之间以 | 分隔,意思是只需匹配其中某一个子表达式即可,不用全都匹配。随后的 . 用来匹配 . 字符,它与前 4 个子表达式合起来又构成了一个更大的子表达式(4 组数字选项和 .),接下来的 {3} 表示该子表达式匹配到的内容要重复 3 次。最后,数值范围又重复出现了一次,这次省略了尾部的 .,用来匹配 IP 地址里的最后一组数字。通过把每组数字的取值范围都限制在 0 到 255 之间,这个模式准确无误地做到了匹配有效的 IP 地址,排除无效的 IP 地址。值得注意的是,这 4 个表达式如果按照更符合逻辑的顺序书写,反倒是不行的。
代码语言:javascript复制mysql> set @r:='(((\d{1,2})|(1\d{2})|(2[0-4]\d)|(25[0-5]))\.){3}((\d{1,2})|(1\d{2})|(2[0-4]\d)|(25[0-5]))';
Query OK, 0 rows affected (0.00 sec)
mysql> select regexp_count(@s, @r, '') c, regexp_extract(@s, @r, '') s, regexp_extract_index(@s, @r, 0, '') i;
------ -------------- ------
| c | s | i |
------ -------------- ------
| 1 | 12.159.46.20 | 24 |
------ -------------- ------
1 row in set (0.00 sec)
注意,这次未能匹配结尾的 0。为什么会这样?因为模式是从左到右进行评估的,所以当有 4 个表达式都可以匹配时,首先测试第一个,然后测试第二个,以此类推。只要有任何模式匹配,就不再测试选择结构中的其他模式。在本例中,(d{1,2}) 匹配结尾的 200 中的 20,因此后面其他模式都没有进行评估。
像上面这个例子里的正则表达式看起来挺吓人的。理解的关键是要将其分解开,每次只分析一个子表达式,把它搞明白。按照先内后外的原则来进行,而不是从头开始,逐个字符地去阅读。嵌套子表达式其实远没有看上去那么复杂。