正则表达式(regular expression)是一个罕见的例子,它既有极其深刻的理论背景,又成为了一种极其常用的工具。
Wikipedia 上对正则表达式的说明如下:
正则表达式(英语:Regular Expression,在代码中常简写为 regex、regexp 或 RE),又称正规表示式、正规表示法、正规运算式、规则运算式、常规表示法,是计算机科学的一个概念。正则表达式使用单个字符串来描述、匹配一系列符合某个句法规则的字符串。在很多文本编辑器里,正则表达式通常被用来检索、替换那些符合某个模式的文本。许多程序设计语言都支持利用正则表达式进行字符串操作。例如,在 Perl 中就内建了一个功能强大的正则表达式引擎。正则表达式这个概念最初是由 Unix 中的工具软件(例如 sed 和 grep)普及开的。
英文里 regular 本是“正规”的意思,在翻译 regular expression 时,可能是为了和太过通用的“正规”一词区分开来,使用了“正则”作为技术名词特有的译法。
在正则表达式中,还有两个词儿需要了解:
所有主流的程序设计语言都内置对 regex 的支持,实际上就是内置了一个“正则表达式引擎”,这个引擎能够拿着我们写好的规则(pattern)去任何字符串里寻找匹配的子串,还提供找到之后替换等功能。
我们来看看 Python 里的例子:
import re
str = 'The quick brown fox jumps over the lazy dog'
pattern = re.compile(r'\wo\w')
re.findall(pattern, str)
['row', 'fox', 'dog']
首先引入 Python 自带的正则表达式模块 re
,然后用 re
提供的 compile()
函数把我们书写的正则规则 \wo\w
编译成一个 pattern,这个规则里的 \w
表示任意字母数字或者下划线,而 o
就是字母 ‘o’。
这个规则字符串前面的
r
表示 raw string,Python 不会对其中的\
做转义处理。
最后调用 re
提供的 findall()
函数用这个 pattern 去寻找 str
中所有满足这个规则的匹配(matches)。
输出结果是所有 matches 组成的列表。
正则引擎不仅可以寻找匹配,还可以捕获(capture)匹配中的一个片段,可以将其替换(replace 或 substitute)成别的字符串。我们来看下面一个例子:
str = 'The white dog wears a black hat.'
pattern = r'The (white|black) dog wears a (white|black) hat.'
repl = r'The \2 dog wears a \1 hat.'
re.sub(pattern, repl, str)
'The black dog wears a white hat.'
和 re.find()
或者 re.findall()
不一样,re.sub()
有三个参数,第一个是用来匹配的规则 pattern
,第二个是找到 matches 之后执行替换的目标模板 target
,第三个是被操作的字符串。这里需要留意的是:
pattern
中有两个小括号 ()
,这两个小括号的作用就是“捕获”匹配到的内容,规则中第一对小括号捕获的内容会被存在临时变量 $1
中,第二对小括号对应的内容则存在 $2
中,依此类推;target
中有一个 \1
和一个 \2
,代表这里要换成 $1
和 $2
的值。于是上面的代码就把原句中的 white 和 black 交换了位置。
试试看下面的代码,能预测它的输出吗?
repl = r'The \1 dog wears a \1 hat.'
re.sub(pattern, repl, str)
'The white dog wears a white hat.'
小结一下:
使用 regex 的最主要场景有二:
顺便,在自学的过程中,想尽一切办法把一切术语用简单直白的“人话”重新表述,是特别有效的促进进步的行为模式,也可以检验你是不是真的搞懂学会了。
另外,我们还需要个测试文本文件,用来当作下面练习使用正则表达式时的字符串,因为有时长一点才能说明一些问题,所以存在一个文件中比直接写在代码里好。我们已经把这个文件放在 assets
自目录下,文件名是 sample.txt
。你可以打开这个文件看看,里面有很多用来测试 regex 的字符串,是个不错的测试基底。
下面的代码就拿这个文件做测试:
with open('assets/sample.txt', 'r') as f:
str = f.read()
pattern = r'beg[iau]ns?'
re.findall(pattern, str)
['begin', 'began', 'begun', 'begins', 'begin']
学到这里的你已经不是“啥都不懂”的人了,你已经知道一个事实:编程语言无非是用来运算的。
所谓的运算,就有操作符(operators)和操作元(operands),而操作符肯定是有优先级的,不然的话,那么多操作元和操作符放在一起,究竟先操作哪个呢?就和四则运算(括号由内向外处理、先乘除后加减、同级别从左到右等)是一个道理。
Regex 本身就是个迷你语言(mini language),它也有很多操作符,操作符也有优先级;而它的操作元有个专门名称,叫做原子(atom)。
看看下面列出的 regex 操作符优先级,你会对它有相当不错的了解:
排列 | 原子与操作符优先级 | (从高到低) |
---|---|---|
1 | 转义符号 (Escaping Symbol) | \ |
2 | 分组、捕获 (Grouping or Capturing) | (...) (?:...) (?=...) (?!...) (?<=...) (?<!...) |
3 | 数量 (Quantifiers) | a* a+ a? a{n, m} |
4 | 序列与定位(Sequence and Anchor) | abc ^ $ \b \B |
5 | 或(Alternation) | a|b|c |
6 | 原子 (Atoms) | a [^abc] \t \r \n \d \D \s \S \w \W . |
下面我们来看看所有这些东西到底是什么。
Regex pattern 中最基本的元素被称为原子(atom),包括下面这些类型:
最基本的原子,就是本义字符,它们都是单个字符。
本义字符包括从 a
到 z
,A
到 Z
,0
到 9
,还有下划线 _
,它们所代表的就是它们的字面值。
相当于 Python 中的 string.ascii_letters
、string.digits
及 _
。
以下字符在 regex 中都有特殊含义:\
+
*
.
?
-
^
$
|
(
)
[
]
{
}
<
>
。
所以你在写规则时,如果需要匹配这些字符,建议都在前面加上转义符 \
,比如,你想匹配 $
,那你就写 \$
,或者想匹配 |
那就写 \|
。
跟过往一样,所有的细节都很重要,它们就是需要花时间逐步熟悉到牢记。
原子的集合还是原子。
Regex 使用方括号 []
来标示集合原子,[abc]
的意思就是这个位置匹配 a
或者 b
或者 c
,即 abc
中任一字符。
如下面的例子 beg[iau]n
能够匹配 begin
、began
和 begun
:
str = 'begin began begun bigins begining'
re.findall(r'beg[iau]n', str)
['begin', 'began', 'begun', 'begin']
在集合原子中,我们可以使用两个操作符:-
(表示一个区间)和 ^
(表示排除)。
[a-z]
表示从小写字母 a
到 z
中的任意一个字符。[^abc]
表示 abc
以外的其它任意字符;注意,一个集合原子中,^
符号只能用一次,只能紧跟在 [
之后,否则不起作用。类别原子,是指能够代表“一类字符”的原子,类别有特殊转义符定义,包括下面这些:
\d
任意数字;等价于 [0-9]
\D
任意非数字;等价于 [^0-9]
\w
任意本义字符;等价于 [a-zA-Z0-9_]
\W
任意非本义字符;等价于 [^a-zA-Z0-9_]
\s
任意空白;相当于 [ \f\n\r\t\v]
(注意,方括号内第一个字符是空格符号)\S
任意非空白;相当于 [^ \f\n\r\t\v]
(注意,紧随 ^
之后的是一个空格符号).
除 \r
\n
之外的任意字符;相当于 [^\r\n]
类别原子挺好记的,如果你知道各个字母是哪个词的首字母的话:
d
是 digits;w
是 word characters;s
是 spaces。另外,在空白的集合 [ \f\n\r\t\v]
中:\f
是分页符;\n
\r
是换行符;\t
是制表符;\v
是纵向制表符(很少用到)。各种关于空白的转义符也同样挺好记忆的,如果你知道各个字母是那个词的首字母的话:
f
是 flip;n
是 new line;r
是 return;t
是 tab;v
是 vertical tab。str = '<dl>(843) 542-4256</dl> <dl>(431) 270-9664</dl>'
re.findall(r'\d\d\d\-', str)
['542-', '270-']
str = 'never ever verb never however everest'
re.findall(r'er\b', str)
['er', 'er', 'er', 'er']
re.findall(r'er\B', str)
['er', 'er']
re.findall(r'nev', str)
['nev', 'nev']
re.findall(r'^nev', str)
['nev']
^
和 $
在 Python 语言中也可以用 \A
\Z
。事实上,每种语言或多或少都对 regex 有自己的定制,不过本章讨论的绝大多数细节,都是通用的。
我们可以用圆括号 ()
将多个单字符原子组合成一个原子,这么做的效果是 ()
内的字符串将被当作一整个原子,可以被随后我们要讲解的数量操作符操作。这个语法叫做分组或者组合(grouping)。
另外,()
这个操作符,除了用于 grouping,还用于捕获(capturing),前面看到过,后面还会讲。
所以现在你应该可以分清楚,er
、[er]
和 [(er)
](https://regexper.com#(er) 含义各不相同。
er
是两个原子,'e'
和紧随其后的'r'
;[er]
是一个原子,或者'e'
或者'r'
;(er)
是一个原子,'er'
。
下一节中讲到数量操作符的时候,会再次强调这点。
数量操作符有这几个:+
?
*
{n, m}
。
它们是用来限定位于它们之前的原子出现的个数;不加数量操作符代表出现一次且仅出现一次,而加上数量操作符的含义分别是:
+
代表前面的原子必须至少出现一次,即 出现次数 ≧ 1;例如,go+gle
可以匹配 google
gooogle
goooogle
等;?
代表前面的原子最多只可以出现一次,即 0 ≦ 出现次数 ≦ 1;例如,colou?red
可以匹配 colored
或者 coloured
;*
代表前面的原子可以不出现,也可以出现一次或者多次,即 出现次数 ≧ 0;例如,520*
可以匹配 52
520
52000
5200000
520000000000
等;{n}
代表前面的原子确定出现 n 次;{n,}
代表前面的原子出现至少 n 次;{n, m}
代表前面的原子出现至少 n
次,至多 m
次;例如,go{2,5}gle
,能匹配 google
gooogle
goooogle
或 gooooogle
,但不能匹配 gogle
和 gooooooogle
。如下例:
with open('assets/sample.txt', 'r') as f:
str = f.read()
re.findall(r'go+gle', str)
['google', 'gooogle', 'goooogle', 'goooooogle']
re.findall(r'go{2,5}gle', str)
['google', 'gooogle', 'goooogle']
re.findall(r'colou?red', str)
['coloured', 'colored']
re.findall(r'520*', str)
['520', '52000', '5200000', '520000000', '520000000000']
数量操作符是对它之前的原子进行操作的,换言之,数量操作符的操作元是操作符之前的原子。
上一节提到,要注意区别:er
、[er]
和 (er)
各不相同。
er
是两个原子,'e'
之后'r'
[er]
是一个原子,或者'e'
或者'r'
;(er)
是一个原子,'er'
str = 'error wonderer severeness'
re.findall(r'er', str)
['er', 'er', 'er', 'er']
re.findall(r'[er]', str)
['e', 'r', 'r', 'r', 'e', 'r', 'e', 'r', 'e', 'e', 'r', 'e', 'e']
re.findall(r'(er)', str)
['er', 'er', 'er', 'er']
这里我们还看不出 er
和 (er)
的区别,但是,加上数量操作符就不一样了——因为数量操作符只对它之前的那一个原子进行操作:
re.findall(r'er+', str)
['err', 'er', 'er', 'er']
re.findall(r'[er]+', str)
['err', 'r', 'erer', 'e', 'ere', 'e']
re.findall(r'(er)+', str)
['er', 'er', 'er']
或操作符 |
是所有操作符中优先级最低的,数量操作符的优先级比它高,所以,在 |
前后的原子被数量操作符(如果有的话)操作之后才交给 |
操作。简单理解就是规则被 |
分成若干段,各段里出现一种就匹配成功。
下面例子里,begin|began|begun
能够匹配 begin
或 began
或 begun
。
str = 'begin began begun begins beginn'
re.findall(r'begin|began|begun', str)
['begin', 'began', 'begun', 'begin', 'begin']
注意:前面讲的集合原子方括号中的 |
()
不会被当作特殊符号,而是被当作字符本身,没有或操作符和分组的作用。
捕获(capture)使用的是圆括号 ()
。使用圆括号可以捕获匹配中的特定片段,并将其值暂存进一个带有索引的列表,第一个片段是 $1
,第二个是 $2
,依此类推。随后,我们可以在替换的过程中使用 \1
\2
中来引用所保存的值。
比如前面我们讲过的例子:
str = 'The white dog wears a black hat.'
pattern = r'The (white|black) dog wears a (white|black) hat.'
repl = r'The \2 dog wears a \1 hat.'
re.sub(pattern, repl, str)
'The black dog wears a white hat.'
repl = r'The \1 dog wears a \1 hat.'
re.sub(pattern, repl, str)
'The white dog wears a white hat.'
有时,你并不想捕获圆括号中的内容,在那个地方你使用括号的目的只是分组(grouping),而非捕获,所以不希望捕获那里的值占用一个 $
标号,那么就在圆括号内最开头加上 ?:
:
str = 'The white dog wears a black hat.'
pattern = r'The (?:white|black) dog wears a (white|black) hat.'
re.findall(pattern, str)
['black']
repl = r'The \1 dog wears a \1 hat.' # 之前的一处捕获,在替换时可被多次引用
re.sub(pattern, repl, str)
'The black dog wears a black hat.'
实际上在 Python 代码中使用正则表达式匹配和捕获以及随后的替换,有更灵活的方式,因为可以对那些值直接编程,re.sub()
中的 repl
参数甚至可以接收另外一个函数作为参数,具体可以查阅 Python 的官方文档。
非捕获匹配,还有几个预查(look ahead, look behind)操作符。
所谓 look ahead 和 look behind,意思就是匹配一个部分时看看这个部分前后是不是符合某个规则,前后的部分只进行规则检查但不参与匹配:
(?=pattern)
:look ahead positive assert,例如 Windows(?=95|98|NT|2000)
能匹配 Windows2000
中的 Windows
(注意匹配的只有 Windows
,而不是 Windows2000
),但不能匹配 Windows3.1
中的 Windows
;(?!pattern)
:look ahead negative assert,例如 Windows(?!95|98|NT|2000)
能匹配 Windows3.1
中的 Windows
,但不能匹配 Windows2000
中的 Windows
;(?<=pattern)
:look behind positive assert,这是检查匹配部分之前的字符,例如 (?<=95|98|NT|2000)Windows
能匹配 2000Windows
中的 Windows
,但不能匹配 3.1Windows
中的 Windows
;(?<!pattern)
:look behind negative assert,例如 (?<!95|98|NT|2000)Windows
能匹配 3.1Windows
中的 Windows
,但不能匹配 2000Windows
中的 Windows
。再次提醒,这几个都是非捕获的。
在 Python 中控制标记(flag)是模块 re
下的一组布尔值,通过设置其真假会影响正则引擎的匹配和替换逻辑。
另外也可以在 pattern 最开始写一个特殊的 (?)
结构来只影响当前这一个 pattern(这叫 inline flag)。
下面是所有标记的简要介绍,供参考:
A
/ASCII
,默认为 False
\d
, \D
, \w
, \W
, \s
, \S
, \b
, 和 \B
等只限于 ASCII 字符(?a)
re
module:re.A
re.ASCII
I
/IGNORECASE
,默认为 False
(?i)
re
module:re.I
re.IGNORECASE
G
/GLOBAL
,默认为 True
(?g)
re
module:不能更改,默认为 TRUEL
/LOCALE
,默认为 False
\d
, \D
, \w
, \W
, \s
, \S
, \b
, 和 \B
等等的内容(?L)
re
module:re.L
re.LOCALE
M
/MULTILINE
,默认为 True
^
和 $
匹配行首和行尾时,会增加换行符之前和之后的位置。(?m)
re
module:re.M
re.MULTILINE
S
/DOTALL
,默认为 False
.
完全匹配任何字符,包括换行;没有这个标志,.
匹配除了 n
r
之外的任何字符。(?s)
re
module:re.S
re.DOTALL
X
/VERBOSE
,默认为 False
\
之后。这样做的结果是允许将注释写入 Pattern,这些注释会被 Regex 解析引擎忽略。注释用 #
号来标识,不过该符号不能在字符串或反斜杠之后。(?x)
re
module:re.X
re.VERBOSE
前面快速的过了一遍 regex 的主要语法和用法,其实很多常用的 regex 规则都不需要自己的写,可以直接在网上找到,比如这里,这里,还有这里。
有个特别经常被问而且特别难写完美的,是用来校验 Email 合法性的规则,有人甚至专门为这个建了个站点叫 Email Address Regular Expression That 99.99% Works,里面有个难得一见的怪兽级 regex,你可以去瞻仰一下。
p.s. 大部分情况下并不需要追求那么全面完整,大致够用就可以了,我们在介绍字符串数据的一章有一些例子。
ELIZA 是人类史上第一个“聊天机器人”程序,上个世纪六十年代(1966)实现的,核心就是使用正则表达式匹配对方语句中的关键词汇,再经过替换,形成看似有意义的“回应”——事实上,ELIZA 完全不知道自己在说什么……
几年后,就是七十年代初(1972),另外一个聊天机器人 Parry 被写了出来,随后第二年,这两个聊天机器人在 ARPANET “碰面”好好地聊了一下:
ELIZA: Good Evening. Tell me your problems. Please terminate input with a period or a question mark.
PARRY: People get on my nerves sometimes.
ELIZA: I am not sure I understand you fully.
PARRY: You should pay more attention.
ELIZA: Suppose you should pay more attention.
PARRY: You're entitled to your own opinion.
ELIZA: What makes you think I am entitled to my own opinion?
PARRY: It bothers me just to be around people in general.
ELIZA: Please go on.
PARRY: I went to the track at Bay Meadows a while back.
ELIZA: What does that suggest to you?
PARRY: I went to the races.
......
这俩的完整聊天记录可以在这里看到,不知道你看了是什么感觉……