正则表达式必知必会 - 环视

2023-10-14 09:52:40 浏览数 (3)

一、环视简介

        还是先来看一个例子:要把一个 Web 页面的页面标题提取出来。HTML 页面标题是出现在 <title> 和 </title> 标签之间的文字,而这对标签又必须位于 HTML 代码的 <head> 部分里。

代码语言:javascript复制
mysql> set @s:='<head>
    '> <title>Ben Forta's Homepage</title>
    '> </head>';
Query OK, 0 rows affected (0.00 sec)

mysql> set @r:='(?<=<head>[\s]{0,10}<title>).*(?=</title>[\s]{0,10}</head>)';
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    |
 ------ ---------------------- ------ 
|    1 | Ben Forta's Homepage | 15   |
 ------ ---------------------- ------ 
1 row in set (0.00 sec)

        (?<=<head>[\s]{0,10}<title>) 是一个向后查看操作,它匹配但不消耗 <title>;(?=</title>[\s]{0,10}</head>) 则是一个向前查看操作,它匹配但不消耗 </title>。最终返回的匹配结果仅包含标题文字,这是该正则表达式所消耗的全部内容。为减少歧义,在这个例子里应该对 < 进行转义,也就是把 (?<=< 替换为 (?<=<。        MySQL 正则表达式向后查看不支持 *,但支持区间范围:

代码语言:javascript复制
mysql> set @r:='(?<=<head>.*<title>).*(?=</title>.*</head>)';
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;
ERROR 3695 (HY000): The look-behind assertion exceeds the limit in regular expression.

mysql> set @r:='(?<=<head>.{0,}<title>).{0,}(?=</title>.*</head>)';
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;
ERROR 3695 (HY000): The look-behind assertion exceeds the limit in regular expression.

mysql> set @r:='(?<=<head>.{0,100}<title>).*(?=</title>.*</head>)';
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    |
 ------ ---------------------- ------ 
|    1 | Ben Forta's Homepage | 15   |
 ------ ---------------------- ------ 
1 row in set (0.00 sec)

二、向前查看

        向前查看指定了一个必须匹配但不用在结果中返回的模式。向前查看其实就是一个子表达式,而且从格式上看也确实如此。向前查看模式的语法是一个以?=开头的子表达式,需要匹配的文本跟在=的后面。

        有些正则表达式文档使用术语“消耗”(consume)来表述“匹配和返回文本”的含义。向前查看“不消耗”(not consume)所匹配的文本。看一个例子。下面的文本中包含了一系列 URL 地址,任务是提取每个地址的协议部分。

代码语言:javascript复制
mysql> set @s:='http://www.forta.com/ https://mail.forta.com/ ftp://ftp.forta.com/';
Query OK, 0 rows affected (0.00 sec)

mysql> set @r:='\w ?(?=:)';
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       |
 ------ ---------------- --------- 
|    3 | http,https,ftp | 1,23,47 |
 ------ ---------------- --------- 
1 row in set (0.00 sec)

        在上面列出的 URL 地址里,协议名与主机名之间以一个 : 分隔。模式 \w ? 懒惰匹配任意单词,子表达式 (?=:) 匹配 :。但是注意,被匹配到的 : 并没有出现在最终的匹配结果里。?= 告诉正则表达式引擎:匹配 : 只是为了向前查看,不用消耗该字符。为了更好地理解 ?= 的作用,再来看一个同样的例子,但这次不使用向前查看元字符。

代码语言:javascript复制
mysql> set @s:='http://www.forta.com/
    '> https://mail.forta.com/
    '> ftp://ftp.forta.com/';
Query OK, 0 rows affected (0.00 sec)

mysql> set @r:='\w ?(:)';
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       |
 ------ ------------------- --------- 
|    3 | http:,https:,ftp: | 1,23,47 |
 ------ ------------------- --------- 
1 row in set (0.00 sec)

        子表达式 (:) 正确匹配并消耗了 :,该字符被作为最终匹配结果的一部分返回。这两个例子的区别在于,匹配 : 的时候前者使用的模式是 (?=:),而后者使用的模式是 (:)。两种模式匹配到的东西是一样的,都是紧跟在协议名后面的那个 :,不同之处是匹配到的 : 是否出现在最终的匹配结果之中。在使用向前查看的时候,正则表达式解析器将向前查看并处理 : 匹配,但不会把它包括在最终结果里。模式 w ?(:) 查找文本并包含 :,模式 w ?(?=:) 查找文本,但不包含 :。

        向前查看和向后查看其实是有返回结果的,只不过结果永远都是零长度字符串。因此,环视操作有时也被称为零宽度(zero-width)匹配操作。任何子表达式都可以转换为向前查看表达式,只要在其之前加上一个 ?= 即可。在同一个搜索模式里可以使用多个向前查看表达式,出现的位置没有任何限制。

三、向后查看

        正如刚看到的那样,?= 是向前查看的,它查看已匹配文本之后的内容,但不消耗这些内容。因此,?= 被称为向前查看操作符。除了向前查看,许多正则表达式实现还支持向后查看,也就是查看出现在已匹配文本之前的内容,向后查看操作符是 ?<=。

        分清 ?= 与 ?<= 的一个办法:包含指向文本后方箭头(< 符号)的操作符就是向后查看。因为要匹配文本相对于模式的方向(对应“向前查看”的“前”)与文本阅读方向正相反,记忆向后查看 < 号的方向容易引起误解,所以可以直接将 “?<=” 读成“向……之后查看”。?<= 的用法与 ?= 一样,它必须出现在一个子表达式里,后面跟随要匹配的文本。下面是一个例子。从某个数据库里搜索出了一份产品清单,但只需要产品价格。

代码语言:javascript复制
mysql> set @s:='ABC01: $23.45
    '> HGG42: $5.31
    '> CFMX1: $899.00
    '> XTC99: $69.96
    '> Total items found: 4';
Query OK, 0 rows affected (0.00 sec)

mysql> set @r:='\$[0-9.] ';
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          |
 ------ ----------------------------- ------------ 
|    4 | $23.45,$5.31,$899.00,$69.96 | 8,22,35,50 |
 ------ ----------------------------- ------------ 
1 row in set (0.00 sec)

        匹配 字符,[0-9.] 匹配价格,匹配结果正确。但如果不想让

代码语言:javascript复制
mysql> set @r:='(?<=\$)[0-9.] ';
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          |
 ------ ------------------------- ------------ 
|    4 | 23.45,5.31,899.00,69.96 | 9,23,36,51 |
 ------ ------------------------- ------------ 
1 row in set (0.00 sec)

        问题迎刃而解。(?<=[0-9.] 匹配一个 字符和一个美元金额;(?<=)[0-9.] 也匹配一个 字符和一个美元金额。这两个模式所查找的东西是一样的,它们之间的区别体现在最终的匹配结果里。前者的匹配结果包含 字符,后者的匹配结果不包含 字符,虽然它必须通过匹配

        向前查看模式的长度是可变的,其中可以包含 . 和 等量词,所以非常灵活。向后查看模式则只能是固定长度。几乎所有的正则表达式实现都有此限制。

四、否定式环视

        到目前为止,向前查看和向后查看通常都是用来匹配文本,主要用于指定作为匹配结果返回的文本位置(指明所需匹配之前或之后的文本)。这种用法被称为肯定式向前查看(positive lookahead)和肯定式向后查看(positive lookbehind)。术语“肯定式”(positive)是指要执行的是匹配操作。环视还有一种不太常见的形式叫作否定式环视(negative lookaround)。否定式向前查看(negative lookahead)会向前查看不匹配指定模式的文本,否定式向后查看(negative lookbehind)则向后查看不匹配指定模式的文本。要想否定环视操作,用 ! 代替 =。下表列出了所有的环视操作。

种类

说明

(?=)

肯定向前查找

(?!)

否定向前查找

(?<=)

肯定向后查找

(?<!)

否定向后查找

        一般来说,凡是支持向前查看的正则表达式实现也都支持肯定式向前查看和否定式向前查看。类似地,凡是支持向后查看的正则表达式实现也都支持肯定式向后查看和否定式向后查看。来看一个例子。下面是一段包含数值的文本,其中既有价格又有数量,先来获取价格。

代码语言:javascript复制
mysql> set @s:='I paid $30 for 100 apples,
    '> 50 oranges, and 60 pears.
    '> I saved $5 on this order.';
Query OK, 0 rows affected (0.00 sec)

mysql> set @r:='(?<=\$)\d ';
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    |
 ------ ------ ------ 
|    2 | 30,5 | 9,63 |
 ------ ------ ------ 
1 row in set (0.00 sec)

        这和先前的例子差不多。d 匹配数值,(?<=

代码语言:javascript复制
mysql> set @r:='\b(?<!\$)\d \b';
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        |
 ------ ----------- ---------- 
|    3 | 100,50,60 | 16,28,44 |
 ------ ----------- ---------- 
1 row in set (0.00 sec)

        d 还是匹配数值,但这次只匹配数量,不匹配价格。表达式 (?<!

代码语言:javascript复制
mysql> set @r:='(?<!\$)\d ';
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           |
 ------ ------------- ------------- 
|    4 | 0,100,50,60 | 10,16,28,44 |
 ------ ------------- ------------- 
1 row in set (0.00 sec)

        因为没有使用单词边界,所以 30 里的 0 也出现在了最终的匹配结果里。这是因为那个字符 0 的前一个字符是 3 而不是 字符,它完全符合模式 (?<!

        环视可以更精细地控制最终的返回结果。环视操作允许利用子表达式来指定文本匹配操作的发生位置,但同时又不会消耗匹配到的文本(不出现在最终的匹配结果里)。肯定式向前查看使用 (?=) 来定义,否定式向前查看使用 (?!) 来定义。有些正则表达式实现还支持肯定式向后查看(相应的操作符是 (?<=))和否定式向后查看(相应的操作符是 (?<!))。

0 人点赞