正则二三事
2019-1-16学习它有什么用
- 终端中的各种工具都会用到, 比如: grep ack awk find 等等
- IDE 中的复杂的搜索替换
- 日常干活中的灵活使用, 邮箱校验, 手机校验, router 匹配等等
- 编译器前端, 词法分析几乎都是正则实现(正则的底层实现也是基于 DFA NFA, 它们是可以相互转换的)
预警
首先, 这里推荐俩个网站: http://regex101.com/, https://regexper.com/ 可以将你的正则可视化, 并且有释义.
regular expression
描述文本的有规律的表达式.
TIP
通过这个分享不可能让你立刻变成正则高手, 要么就理解概念多练, 要么就直接自己实现一个正则引擎, 别无他路.
碰到正则相关的问题的时候, 尽量通过自己的思考完成, 而不是通过 ctrl-c ctrl-v(即使你事后也看明白了为什么这样写)
下面的介绍都是基于 js 中的正则表达式(不同语言的正则功能可能会缺失, js 相对来说还是比较强的)
元字符
语法 | 含义 |
---|---|
. | 匹配除换行符以外的任意字符 |
\w | 匹配字母或者数字 |
\s | 匹配任意的空白符 |
\d | 匹配任意的数字 |
\b | 匹配单词的开始或者结束 |
^ | 匹配字符串的开始 |
$ | 匹配字符串的结束 |
其他不介绍, 大家应该都很熟悉, 介绍一下 \b
, 它是匹配单词的开始或者结束, 比如:
\b\w{7}\b
这个就是匹配刚好 六个字母/数字
的单词.
const str = 'Test case for regular expression';
const reg = /\b\w{7}\b/;
str.match(reg); // regular
转义
比如我们就想匹配 .
这个字符本身, 不想它被当做元字符来对待, 怎么办?
答案是: \.
, 类似就有 \*
, \\
等.
元字符的反义
有一些字符跟上面的元字符的含义是正好相反的, 有以下:
语法 | 含义 |
---|---|
\W | 匹配任意不是字母和数字的字符 |
\S | 匹配任意不是空白符的字符 |
\D | 匹配任意非数字的字符 |
\B | 匹配不是单词开头或结束的位置 |
[^x] | 匹配除了x以外的任意字符 |
[^aeiou] | 匹配除了aeiou这几个字母以外的任意字符 |
比如在将一些老的代码迁移到 def 发布的时候, 需要替换掉代码中的 http
为 https
, 你可以这样搜索:http[^s]//
, 那么这个只会匹配到 http://, 而不会匹配 https://
分组
我们都知道可以使用 *
以及 {m,n}
来重复提取单个字符, 比如 f*
\d*
\w{1,3}
, 那么如果想提取多个字符应该怎么办?
这里就需要用到分组的概念: 用小括号把子表达式包起来, 比如: (\d{1,3}\.){3}\d{1,3}
这个正则, 它表示匹配 1-3 个数字一个点
这个分组三次, 最后再加上一个 1-3位的数字
, 这其实是个 IP 匹配的正则, 不过它并不精确, 因为 ip 地址不能大于 255.
引用分组
比如我们想匹配 html tag 的时候, 经常需要在闭合标签中引用开标签的值, 比如简写为 <([a-z]+)><\/\1>
, 这里的 \1
就代表了第一个分组, 也就是 ([a-z]+)
的匹配结果.
TIP
分组从 1 开始计数, 而不是 0
还可以以指定的命名来分组, 语法如下: (?<name>exp)
, 引用自定义命名分组的方式也不一样, 语法是: \k<name>
那上面的正则就可以这样写: <(?<tag>[a-z]+)><\/\k<tag>>
不捕获分组
假如我们想匹配一个分组, 但是不想在结果中捕获它, 那么可以使用 ?:
关键字, 比如上面的正则, 如果改成 <(?:[a-z]+)><\/\1>
, 那么就匹配不到了, 因为开标签不在结果中被捕获了.
贪婪与懒惰
当正则匹配重复行为(比如 *
{}
这些关键字,)的时候, 通常默认的行为是尽可能多的匹配, 比如 a*b
匹配 aabab
的话, 它会匹配最长的 以a 开头, 以 b 结束的字符串, 结果是 aabab
. 这就是贪婪匹配.
与之对应的就是懒惰匹配, 它是在匹配成功的前提下尽可能少的匹配, 只需要在后面加上 ?
, 比如 a*?b
, 那么匹配结果就是 aab
零宽断言(也被称作环视)
上面提到的一些元字符是用于指定位置的, 比如 ^
$
\b
这些, 他们叫做: 零宽断言, 除此之外还有一些零宽断言, 如下:
语法 | 含义 | 术语 |
---|---|---|
(?=exp) | 匹配exp前面的位置 | 零宽先行断言 |
(?<=exp) | 匹配exp后面的位置 | 零宽后行断言 |
(?!exp) | 匹配后面跟的不是exp的位置 | 零宽负向先行断言 |
(?<!exp) | 匹配前面不是exp的位置 | 零宽负向后行断言 |
(?=exp) 也叫零宽先行断言,它匹配文本中的某些位置,这些位置的后面能匹配给定的后缀 exp
简单的例子:
\b\w+(?=ing\b)
这个正则就是匹配以 ing
结尾的单词的前面部分(不包括 ing 的部分)
const str = 'I am singing while you are dancing.'
const reg = /\b\w+(?=ing\b)/g;
str.match(reg) // [sing, danc]
整数千分位格式化:
const reg = /(\d)(?=(?:\d{3})+$)/g;
const n = '12345678';
n.replace(reg, '$1,'); // 12,345,678
(?<=exp) 也叫零宽后行断言,它匹配文本中的某些位置,这些位置的前面能给定的前缀匹配 exp
例子:
(?<=\bre)\w+\b
这个正则就是匹配以 re
开头的单词的后面部分(不包括 re 的部分)
const str = 'reading a book';
const reg = /(?<=\bre)\w+\b/g;
str.match(reg); // ading
匹配以空白符间隔的数字(不包括空白符): (?<=\s)\d+(?=\s)
(?!exp) 也叫零宽负向先行断言,只会匹配后缀 exp 不存在的位置
例子:
\d{3}(?!\d)
匹配三位数字,而且这三位数字的后面不能是数字
前面也有类似的功能字符: [^]
, 比如 http[^s]//
而零宽负向先行断言不会消费任何字符, 只是做匹配, 改写一下这个正则, 如下: http(?!s)://
提示
vscode 中需要开启 search.usePCRE2
设置, 就能在搜索中启用断言功能
发现区别了吗? 一个匹配 :
, 一个不匹配 :
, 因为 [^]
总是消费一个字符, 它用来消费了 :
, 而改写后的只是匹配位置, 不消费任何字符.
(?<!exp) 零宽负向后行断言来查找前缀 exp 不存在的位置
例子:
(?<![A-Za-z])\d{7}
匹配前面不是字母的七位数字
稍微复杂点的例子:
(?<=<(\w+)>).*(?=<\/\1>)
匹配不包含任何 attribute 的 html tag
案例
匹配文件中的 TODO 事项
function getTagRegex(tags) {
// "// TODO (jack) add some comments /This is comment"
return (
'\\s*' + // 注释后的空格
'(' +
tags.join('|') +
')' + // tags: TODO
'(?!\\w)' + // tag 后面不允许跟着别的字符, 以准确识别 tag
'\\s*' + // tag 后面可以跟空格
'(?:\\(([^)]*)\\))?' + // 匹配括号中的负责人: @jack
'\\s*:?' + // 匹配 :
'\\s*(.*?)' + // : 后面匹配 title: add some comments
'(?:\\s+/([^\\s]+)\\s*)?' // '/' 后面的具体内容: This is comment
);
}
性能
不恰当的使用正则会引起性能问题. 比如这个正则 ([\s\S]*)*
, 假如我们使用字符串 a_1a_2...a_n
来匹配它, 那么这个问题就等于 a_1a_2...a_n
有多少种划分方式, 结果是 2^(n-1)
, 是指数级的复杂度.
js 不支持的正则引擎
平衡组. 他可以用来匹配可嵌套的递归格式, 语法如下:
(?'group')
压栈(?'-group')
弹栈, 如果堆栈为空, 匹配失败(?(group)yes|no)
如果堆栈上存在以名为 group 的捕获内容的话,继续匹配 yes 部分的表达式,否则继续匹配 no 部分
js 中的 api
涉及到常见的 match
replace
split
test
exec
这里说一下 exec
, 因为 exec 有特殊的一个属性 lastIndex
, 它代表了上一次匹配的文本位置, 这个特性在做 parser 的时候会经常用到.