正则表达式就是一种描述文本内容组成规律的表示方式,常常用来简化文本处理的逻辑。正则常见的三种功能分别是:

  • 校验数据的有效性

  • 查找符合要求的文本

  • 对文本进行切割和替换等操作

# 元字符

元字符就是指那些在正则表达式中具有特殊意义的专用字符,元字符是构成正则表达式的基本元件。

# 贪婪模式

在正则中,表示次数的量词默认是贪婪的,在贪婪模式下,会尝试尽可能最大长度去匹配

回溯:后面匹配不上,会吐出已匹配的再尝试。

# 非贪婪模式

非贪婪模式就是在量词后面加上?,会找出长度最小且满足要求的

回溯:后面匹配不上,会匹配更长再接着尝试。

# 独占模式

贪婪模式和非贪婪模式都需要发生回溯才能完成相应的功能。

独占模式就是在量词后面加上+类似贪婪匹配,但匹配过程不会发生回溯,匹配不上即失败,因此在一些场合下性能会更好。

注意

并不是所有的语言都支持独占模式,比如 JavaScript、Python、Go 都不支持。

# 分组和编号

# 保存子组

括号在正则中可以用于分组,被括号括起来的部分 “子表达式” 会被保存成一个子组。

第几个括号就是第几个分组。

# 不保存子组

在括号里面的会被保存成子组,但有些情况下,我们可能只想用括号将某些部分看成一个整体,后续不用再用它,类似这种情况,在实际使用时,是没必要保存子组的。不保存子组的格式为:(?:正则)

不保存子组不但可以提高正则的性能,而且由于子组变少了,在子组计数时也更不容易出错。

\d{15}(\d{3})? // 保存子组
\d{15}(?:\d{3})? // 不保存子组
1
2

# 命名分组

由于编号得数在第几个位置,后续如果发现正则有问题,改动了括号的个数,还可能导致编号发生变化,因此一些编程语言提供了命名分组,这样和数字相比更容易辨识,不容易出错。命名分组的格式为:(?P<分组名>正则)

注意

命名分组只有部分编程语言才支持。

# 分组引用

大部分情况下,我们可以使用 “反斜扛 + 编号”,即 \number 的方式来进行引用,而 JavaScript 中是通过 $number 来引用的,如 $1。

// 查找重复的单词
(\w+)\1

// 替换日期格式
((\d{4})-(\d{2})-(\d{2})) ((\d{2}):(\d{2}):(\d{2})) // 匹配到的时间格式如:2023-03-17 18:00:00
日期\1 时间\5  \2年\3月\4日 \6时\7分\8// 替换后的结果为:日期2023-03-17 时间18:00:00  2023年03月17日 18时00分00秒
1
2
3
4
5
6

# 断言

在实际使用正则的过程中,\d{11} 能匹配上 11 位数字,但这 11 位数字可能是 18 位身份证号中的一部分。再比如,去查找一个单词,我们要查找 tom,但其它的单词,比如 tomorrow 中也包含了 tom。

在有些情况下,我们对要匹配的文本的位置会有一定的要求。为了解决这个问题,正则中提供了一些结构,只用于匹配位置,而不是文本内容本身,这种结构就是断言。

常见的断言有三种:单词边界行的开始或结束以及环视

# 转义

转义序列通常有两种功能。第一种功能是编码无法用字母表直接表示的特殊数据。第二种功能是用于表示无法直接键盘录入的字符(如回车符)。

# Unicode 正则

# Unicode 基础知识

Unicode 是计算机科学领域里的一项业界标准。它对世界上大部分的文字进行了整理、编码。Unicode 使计算机呈现和处理文字变得简单。

现有的 Unicode 字符分为 17 个平面,每组为一个平面(Plane),而每个平面拥有 65536(即 2 的 16 次方)个码值。然而,目前 Unicode 只用了少数平面,我们用到的绝大多数字符都属于第 0 号平面(U+0000-U+FFFF),即 BMP 平面。除了 BMP 平面之外,其它的平面都被称为补充平面

Unicode 相当于规定了字符对应的码值,这个码值得编码成字节的形式去传输和存储。

最常见的编码方式是 UTF-8,另外还有 UTF-16,UTF-32 等。

UTF-8 之所以能够流行起来,是因为其编码比较巧妙,采用的是变长的方法。也就是一个 Unicode 字符,在使用 UTF-8 编码表示时占用 1 到 4 个字节不等。最重要的是 Unicode 兼容 ASCII 编码,在表示纯英文时,并不会占用更多存储空间。而汉字在 UTF-8 中,通常是用三个字节来表示。

# Unicode 中的正则

  • 在编程语言中使用正则时,一定尽可能地使用 Unicode 编码

  • 在 Unicode 中,点号以及字符组,比如:\d、\w、\s 等等是否可以匹配上 Unicode 字符,不同语言支持的不太一样,具体需要通过测试来得知。

# Unicode 属性

在正则中使用 Unicode,还可能会用到 Unicode 的一些属性。这些属性把 Unicode 字符集划分成不同的字符小集合。常用的有三种:

  • 按功能划分的 Unicode Categories(也叫 Unicode Properties),比如标点、数字、空白符等,示例:\p{P} 表示标点符号,\p{N} 表示数字字符;

  • 连续区间划分的 Unicode Blocks,比如只是中日韩字符,示例:\p{Arrows} 表示箭头符号,\p{Bopomofo} 表示注音字母;

  • 按书写系统划分的 Unicode Scripts,比如汉语中文字符,示例:\p{Han} 表示汉语

注意

不同编程语言对这些 Unicode 属性的支持和使用也有差异。

# 表情符号

表情符号有如下特点:

  • 许多表情不在 BMP 内,码值超过了 FFFF。使用 UTF-8 编码时,普通的 ASCII 是 1 个字节,中文是 3 个字节,而有一些表情需要 4 个字节来编码。

  • 这些表情分散在 BMP 和各个补充平面中,要想用一个正则来表示所有的表情符号非常麻烦,即便使用编程语言处理也同样很麻烦。

  • 一些表情现在支持使用颜色修饰,可以在 5 种色调之间进行选择。这样一个表情其实就是 8 个字节了。

因此,表情符号不建议使用正则来处理,更建议使用专用的表情库来处理。

# 在 JS 中使用正则

# 校验文本内容

在 JavaScript 中没有 \A 和 \z,我们可以使用 ^ 和 $ 来表示每行的开头和结尾,默认情况下它们是匹配整个文本的开头或结尾(默认不是多行匹配模式)。

在 JavaScript 中校验文本的时候,不要使用多行匹配模式,因为使用多行模式会改变 ^ 和 $ 的匹配行为。

// 方法 1
/^\d{4}-\d{2}-\d{2}$/.test("2020-06-01") // true

// 方法 2
var regex = /^\d{4}-\d{2}-\d{2}$/
"2020-06-01".search(regex) === 0 // true

// 方法 3
var regex = new RegExp(/^\d{4}-\d{2}-\d{2}$/)
regex.test("2020-01-01") // true
1
2
3
4
5
6
7
8
9
10

方法 3 本质上和方法 1 是一样的,方法 1 写起来更简洁。

需要注意的是,在使用 RegExp 对象时,如果使用 g 模式,可能会有意想不到的结果,连续调用会出现第二次返回 false 的情况。比如:

var r = new RegExp(/^\d{4}-\d{2}-\d{2}$/, "g")
r.test("2020-01-01") // true
r.test("2020-01-01") // false
1
2
3

这是因为 RegExp 在全局模式下,正则会找出文本中的所有可能的匹配,找到一个匹配时会记下 lastIndex,在下次再查找时找不到,lastIndex 变为 0,所以才有上面现象。

var regex = new RegExp(/^\d{4}-\d{2}-\d{2}$/, "g")
regex.test("2020-01-01") // true
regex.lastIndex // 10
regex.test("2020-01-01") // false
regex.lastIndex // 0

// 为了加深理解,还可以看下面这个例子
var regex = new RegExp(/\d{4}-\d{2}-\d{2}/, "g")
regex.test("2020-01-01 2020-02-02") // true
regex.lastIndex // 10
regex.test("2020-01-01 2020-02-02") // true
regex.lastIndex // 21
regex.test("2020-01-01 2020-02-02") // false
1
2
3
4
5
6
7
8
9
10
11
12
13

因此要记住,JavaScript 中文本校验在使用 RegExp 时不要设置 g 模式

此外,在 ES6 中添加了匹配模式 u,如果要在 JavaScript 中匹配中文等多字节的 Unicode 字符,可以指定匹配模式 u,比如测试是否为一个字符,可以是任意 Unicode 字符。𝌆 (opens new window)

/^\u{1D306}$/u.test("𝌆") // true
/^\u{1D306}$/.test("𝌆") // false
/^.$/u.test("好") // true
/^.$/u.test("好人") // false
/^.$/u.test("a") // true
/^.$/u.test("ab") // false
1
2
3
4
5
6

# 提取文本内容

所谓内容提取,就是从大段的文本中抽取出我们关心的内容。比较常见的例子是网页爬虫,或者说从页面上提取邮箱、抓取需要的内容等。

如果要抓取的是某一个网站,页面样式是一样的,要提取的内容都在同一个位置,可以使用 xpath 或 jquery 选择器等方式,否则就只能使用正则来做了。

在 JavaScript 中,想要提取文本中所有符合要求的内容,正则必须使用 g 模式,否则找到第一个结果后,正则就不会继续向后查找了。

// 使用 g 模式,查找所有符合要求的内容
"2020-06 2020-07".match(/\d{4}-\d{2}/g) // 输出:["2020-06", "2020-07"]

// 不使用 g 模式,找到第一个就会停下来
"2020-06 2020-07".match(/\d{4}-\d{2}/) // 输出:["2020-06", index: 0, input: "2020-06 2020-07", groups: undefined]
1
2
3
4
5

如果要查找中文等 Unicode 字符,可以使用 u 匹配模式。

'𝌆'.match(/\u{1D306}/ug) // 使用匹配模式 u,𝌆
'𝌆'.match(/\u{1D306}/g) // 不使用匹配模式 u,null
1
2

# 替换文本内容

在 JavaScript 中替换和查找类似,需要指定 g 模式,否则只会替换第一个。

// 使用 g 模式,替换所有的
"02-20-2020 05-21-2020".replace(/(\d{2})-(\d{2})-(\d{4})/g, "$3年$1月$2日")
// 输出 "2020年02月20日 2020年05月21日"

// 不使用 g 模式,只替换一次
"02-20-2020 05-21-2020".replace(/(\d{2})-(\d{2})-(\d{4})/, "$3年$1月$2日")
// 输出 "2020年02月20日 05-21-2020"
1
2
3
4
5
6
7

# 切割文本内容

"apple, pear! orange; tea".split(/\W+/) // 输出:["apple", "pear", "orange", "tea"]

// 传入第二个参数的情况
"apple, pear! orange; tea".split(/\W+/, 1) // 输出:["apple"]
"apple, pear! orange; tea".split(/\W+/, 2) // 输出:["apple", "pear"]
"apple, pear! orange; tea".split(/\W+/, 10) // 输出:["apple", "pear", "orange", "tea"]
1
2
3
4
5
6

# 正则匹配原理

# 有穷状态自动机

有穷状态是指一个系统具有有穷个状态,不同的状态代表不同的意义。

自动机是指系统可以根据相应的条件,在不同的状态下进行转移。从一个初始状态,根据对应的操作(比如录入的字符集)执行状态转移,最终达到终止状态(可能有一到多个终止状态)。

正则引擎的具体实现就是有穷状态自动机。主要有 DFA(确定性有穷自动机)NFA(非确定性有穷自动机)两种,其中 NFA 又分为传统的 NFA 和 POSIX NFA。

NFA 和 DFA 是可以相互转化的。

# DFA 和 NFA 的工作机制

NFA 是以正则为主导,先看正则,再看文本

DFA 则是以文本为主导,先看文本,再看正则

一般来说,DFA 引擎会更快一些,因为整个匹配过程中,字符串只看一遍,不会发生回溯,相同的字符不会被测试两次。也就是说 DFA 引擎执行的时间一般是线性的。DFA 引擎可以确保匹配到可能的最长字符串。但由于 DFA 引擎只包含有限的状态,所以它没有反向引用功能;并且因为它不构造显示扩展,它也不支持捕获子组。

NFA 以正则为主导,它的引擎是使用贪心匹配回溯算法实现。NFA 通过构造特定扩展,支持子组和反向引用。但由于 NFA 引擎会发生回溯,即它会对字符串中的同一部分,进行很多次对比。因此,在最坏情况下,它的执行速度可能非常慢。

回溯是 NFA 引擎才有的,并且只有在正则中出现量词或多选分支结构时,才可能会发生回溯。

# POSIX NFA 和传统 NFA 的区别

传统的 NFA 引擎 “急于” 报告匹配结果,找到第一个匹配上的就返回了,所以可能会导致还有更长的匹配未被发现。

POSIX NFA 的应用很少,主要是 Unix/Linux 中的某些工具。POSIX NFA 在找到可能的最长匹配之前会继续回溯,也就是说它会尽可能找最长的,如果分支一样长,以最左边的为准。因此,POSIX NFA 引擎的速度要慢于传统的 NFA 引擎。

我们日常接触的一般都是传统的 NFA,所以通常都是最左侧的分支优先,在书写正则的时候务必要注意这一点。

# 正则优化建议

必须先保证正则的功能是正确的,然后再进行优化性能。

# 测试性能的方法

可以在 regex101 (opens new window) 上查看正则和文本匹配的次数,来得知正则的性能信息。

# 提前编译好正则

编程语言中一般都有 “编译” 方法,我们可以使用这个方法提前将正则处理好,这样不用在每次使用的时候去反复构造自动机,从而可以提高正则匹配的性能。

# 尽量准确表示匹配范围

比如我们要匹配引号里面的内容,除了写成 .+? 之外,还可以写成 [^"]+。使用 [^"] 要比使用点号好很多,虽然使用的是贪婪模式,但它不会出现点号将引号匹配上,再吐出的问题。

# 提取出公共部分

类似 abcd|abxy 这样的表达式,可以优化成 ab(cd|xy),因为 NFA 以正则为主导,会导致字符串中的某些部分重复匹配多次,影响效率。

因此 th(?:is|at) 要比 this|that 要快一些,但从可读性上看,后者要好一些,这个就需要用的时候去权衡,也可以添加代码注释让代码更容易理解。

如果是锚点,比如 (^this|^that)is 这样的,锚点部分也应该独立出来,可以写成比如 ^th(is|at)is 的形式,因为锚点部分也是需要尝试去匹配的,匹配次数要尽可能少。

# 出现可能性大的放左边

由于正则是从左到右看的,把出现概率大的放左边。比如域名中 .com 的使用是比 .net 多的,所以我们可以写成 .(?:com|net)\b 而不是 .(?:net|com)\b。

# 只在必要时才使用子组

在正则中,括号可以用于归组,但如果某部分后续不会再用到,就不需要保存成子组。

通常的做法是,在写好正则后,把不需要保存子组的括号中加上 ?: 来表示只用于归组。如果保存成子组,正则引擎必须做一些额外工作来保存匹配到的内容,因为后面可能会用到,这会降低正则的匹配性能。

# 警惕嵌套的子组重复

如果一个组里面包含重复,接着这个组整体也可以重复,比如 (.*)* 这个正则,匹配的次数会呈指数级增长,所以尽量不要写这样的正则。

# 避免不同分支重复匹配

在多选分支选择中,要避免不同分支出现相同范围的情况。

# 使用正则处理问题的思路

将问题分解成多个小问题,每个小问题见招拆招:

  • 某个位置上可能有多个字符的话,就⽤字符组。

  • 某个位置上有多个字符串的话,就⽤多选结构。

  • 出现的次数不确定的话,就⽤量词。

  • 对出现的位置有要求的话,就⽤锚点锁定位置。

在正则中比较难的是某些字符不能出现,这个情况又可以进一步分为组成中不能出现,和要查找的内容前后不能出现。

后一种用环视来解决就可以了。至于第一种:

如果是要查找的内容中不能出现某些字符,这种情况比较简单,可以通过使用中括号来排除字符组,比如非元音字母可以使用 [^aeiou] 来表示。

如果是内容中不能出现某个子串,比如要求密码是 6 位,且不能有连续两个数字出现。这个可以环视来解决,就是每个字符的后面都不能是 两个数字(要注意开头也不能是 \d\d)。

^((?!\d\d)\w){6}$
1

# 正则工具

# 正则资料

上次更新时间: 2023年03月21日 19:28:02