正则表达式

我们看到了,只要是处理字符串,就需要查找,不仅仅是精确的查找,可能还需要按照某种模式查找,比如我们看到了JSON的解析,其实也是匹配某种模式:{}[],这些分隔符号。后来人们发展出一些规范的表达方法,来表达不同的字符串模式,便于查找匹配。
正则表达式(regular expression) 定义了一种文本搜索模式。正则表达式在文本的搜索编辑的场景中很有用处。
正则表达式并不是Python发明的,可以说很久很久以前就出现了。1950年代,美国数学家Stephen Cole Kleene提出,后来随着Unix普及开。它从左往右逐个字符扫描文本,找到匹配的模式,继续往下扫描,模式可以使用一次或者多次。
实际上我们不用编程序就在用正则了。一个例子就是在命令窗口执行命令dir *.txt或者dir test?.txt就可以找出符合这个模式的文件来。
Python从1.5版本开始就支持正则表达式了,我们看一个例子:

import re

print(re.match('www', 'www.google.com'))
print(re.match('google', 'www.google.com'))

执行输出:

<re.Match object; span=(0, 3), match='www'>
None

说明第一个语句匹配上了,而第二个语句没有匹配上。
re.match 尝试从字符串的起始位置匹配一个模式,匹配成功re.match方法返回一个匹配的对象,否则返回None。函数的格式为:
re.match(pattern, string, flags=0),其中标志位用于控制正则表达式的匹配方式,如:是否区分大小写,多行匹配等等。
re.search 扫描整个字符串并返回第一个成功的匹配。并不是只从起始位置开始匹配。如上面的代码改成search()后的结果是:

<re.Match object; span=(0, 3), match='www'>
<re.Match object; span=(4, 10), match='google'>

可以使用group(num) 或 groups() 匹配对象函数来获取匹配表达式。举例:

sentence = "I am a programmer."
matchObj = re.match( r'(.*) am (.*?) (.*)', sentence)

if matchObj:
   print ("matchObj.group() : ", matchObj.group())
   print ("matchObj.group(3) : ", matchObj.group(3))
else:
   print ("No match!!")

匹配的结果有三段,第三段是代表职业(注意下标为3,因为规定0是特殊的),我们想识别这个。结果如下:

matchObj.group() :  I am a programmer.
matchObj.group(3) :  programmer.

这是简单的写法,不过这种写法性能不好,无法重用pattern对象。如果只用一次,可以这么简写。

compile 函数用于编译正则表达式,生成一个正则表达式( Pattern )对象,供 match() 和 search() 这两个函数使用。上例可以修改如下:

pattern=re.compile(r'(.*) am (.*?) (.*)' )
sentence = "I am a programmer."
matchObj = pattern.search( sentence)

findall(string[, pos[, endpos]])在字符串中找到正则表达式所匹配的所有子串,并返回一个列表。
finditer(pattern, string, flags=0)和 findall 类似,在字符串中找到正则表达式所匹配的所有子串,并把它们作为一个迭代器返回。
如:

pattern = re.compile(r'\d+')   # 查找数字
result1 = pattern.findall('abc 123 def 456')

结果返回

['123', '456']

如:

sentence="This  is    an example string."
pattern=re.compile(r'\w+')
it = pattern.finditer(sentence)
for match in it: 
    print (match.group() ,match.start(),match.end())

结果返回:

This 0 4
is 6 8
an 12 14
example 15 22
string 23 29

看一个识别数值的例子:

pattern=re.compile(r'^\d+(\.\d+)?')
sentence = "5.67"
matchObj = pattern.search(sentence)

if matchObj:
   print ("matchObj.group() : ", matchObj.group())
else:
   print ("No match!!")

运行结果:

matchObj.group() :  5.67

模式^\d+(.\d+)?代表的是以一个或多个数字开头后面可以跟一个小数点.之后再跟一个或多个数字。所以5.67, 5, 3.5都符合,但是.35或者1.2.3不符合。
再细说一下上面的模式串^\d+(.\d+)?,分成几段:
^        这是一个标记,规定文本要以后面的段开头
\d+      \d表示的是数字,+表示一个或者多个。结合前面的^,即规定了文本要以一个多个数字开头
(.\d+)   ()括号本身不是用来搜索的,只是给模式串分段的,表示里面的内容构成一个段。.表示的是小数点.,\d表示表示的是数字,+表示一个或者多个。整个段合在一起,表示以小数点打头后面有一个多个数字
?        这是一个标记,表示前面的段是可选项,可以出现也可以不出现。结合前面的段,表示小数点及数字可以有也可以没有。所以能同时匹配5.67和5两种情况。

下面是一个识别ip地址的例子:

pattern=re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')

这个模式串^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$比较简单,就是由点号.分隔开的四段数字,每段数字是一到三位数。

再看一个:

    pattern = re.compile(r'[\u4e00-\u9fa5]+')
模式串[\u4e00-\u9fa5]表示字符集合,\u4e00-\u9fa5代表unicode编码中的中文范围,所以这个模式串代表的是匹配中文。

从上面简单的例子,我们也可以感受到正则表达式的功用,在对文本的查找处理的时候非常有用,各类词法分析中都要用。文本编辑器,搜索引擎,开发环境,编译器或者解释器,都是要用到的。
计算机科学里面对它是有正式的定义的。后面我会讲到。
上面是一个一个的孤立的例子,那有没有一个全一点的清单让人了解如何写这些模式?有,但是并没有一个唯一的标准,实现过程中有不同的文法。现在比较广泛使用的有POSIX标准和Perl文法。这个也称为派别之争。世界上的事情,都有正反两面,有时候我们想有一个统一的标准,简化大家的工作,但是有时候又希望多样性,促进竞争和发展,不要被某一个思想或者标准垄断。
不同的语言使用的正则文法有差别,烦不胜烦,气得有的程序员一气之下自己搞一个正则解释器,重新造轮子。我后面也会讲怎么构造正则解释器。
列出一些常用的基本构造。

字符还有特殊字符\
x    字符 x
\\    反斜杠
\uhhhh    十六进制 0xhhhh代表的字符
\n    新行 ('\u000A')

字符集合
[abc]    a, b, c 
[^abc]    任意字符除了 a, b, c  (这是一个反向声明)
[a-zA-Z]    a到z或者A到Z (字符范围)
[a-z&&[def]]    d, e, f (交集)
[a-z&&[^bc]]    a 到z, 除了 b 和 c: [ad-z] 
[a-z&&[^m-p]]    a 到z, 除了 m 到 p: [a-lq-z]

特殊字符集合,注意大写是指反向
.    任意字符
\d    数字: [0-9]
\D    非数字: [^0-9]
\s    空格符号: [ \t\n\x0B\f\r]
\S    非空白符号r: [^\s]
\w    单词: [a-zA-Z_0-9]
\W    非单词: [^\w]

边界指示
^    一行行首
$    一行行尾
\b    单词边界
\B    非单词边界

通配符
X?    X, 一次或者没有
X*    X, 0次或者多次
X+    X, 1次或者多次
X{n}    X, n次
X{n,}    X, 至少n次
X{n,m}    X, 至少n次最多不超过m次

逻辑指示
XY    X后面跟着 Y
X|Y    X 或Y
(X)    X, 作为一组

好,我们还是举例子,进一步加深了解。
来一个身份证号码的验证。这个在中国的实际场景中很常见。身份证由15位或者18位数字组成(严格地说,18位的最后一位可以是X,校验符),分成几段,第一段是地区,六位数字,后面一段生日YYMMDD或YYYYMMDD格式,再后面的一段是三位数字,如果是18位的号码最后有一个校验位数字或者X。

pattern=re.compile(r'^(\d{6})(18|19|20)?(\d{2})([01]\d)([0123]\d)(\d{3})(\d|X|x)?$')
拆开看,分几段:
^(\d{6})        以六位数字开头
(18|19|20)?    接下来是一个可选的段,对于18位的号码,YYYY前两个Y
(\d{2})         这是生日的年份,YYYY后两个Y
([01]\d)        两位月份,01,02,...,09,10,11,12。所以第一位是0或1
([0123]\d)      两位日期,第一位只会出现0,1,2,3四个数字,第二位随意
(\d{3})         这一段是三个数字
(\d|X|x)?$      以这一段结尾,这一段是一位数字或者是X字符,?表示这一段可选。
上面这个就是一个基本达到实用程度的模式串了,能够初步过滤掉明显不对的身份证号码。

再看一个邮件格式校验的例子。这个也是实战常见的场景。邮件的格式是由@分成的两部分,后一部分由点号.分隔的多段,名字可以出现字母数字和特定的字符如_-.之类:

pattern=re.compile(r'[\w|.|-]+@(([a-zA-z0-9]-*){1,}\.){1,3}[a-zA-z\-]{1,}’)
拆开看,分几段:
[\w|.|-]+               这是邮件名,由\w以及.-符号组成,\w就是字母数字和_
@                     这是邮件分隔符号
(([a-zA-z0-9]-*){1,}\.){1,4}  一个或者最多四个域名子段,字段里面是字母数字和-
[a-zA-z\-]{1,}            最后一节域名子段,一个以上的字母及-

通过上面的例子,我们可以看出同一个规则可以有不同的方式表达,如\w就跟[a-zA-z0-9]是等效的,[a-zA-z-]{1,}跟[a-zA-z-]+也是等效的。
再来一个识别的程序。这段程序的目的是从HTML文本中识别出标签,并读出标签里的属性来。

sentence="<font face=\"Arial\" size=\"2\" color=\"red\">"
pattern=re.compile(r'<\s*font\s*([^>]*)\s*>')

it = pattern.finditer(sentence)
for match in it: 
    print (match.group() )
    pattern2=re.compile(r'([a-zA-Z]+)\s*=\s*\"([^\"]+)\"')
    it2 = pattern2.finditer(match.group())
    for match2 in it2: 
        print (match2.group(0))
        print (match2.group(1),match2.group(2))

简单解释一下,

文本是<font face="Arial" size="2" color="red">。模式串是<\s*font\s*([^>]*)\s*>,还是老办法,拆分段:
<            这是HTML标签的开头字符
\s*          后面可以跟多个空字符,\s与[ \f\n\r\t\v]等效
font         这一段标识 font标签
\s*          又可以跟多个空字符
([^>]*)       多个任意字符,除了>,到了>就表示结束了
\s*          又可以跟多个空字符
>            这是HTML标签的结尾字符
这个模式串里面并没有处理 face="Arial" size="2" color="red"的模式,我们是把它们看成一个整体,之后在细分。

我们用了finditer()语句找符合模式的部分,然后由group()语句读出内容。这个要解释一下。finditer()会按照模式把匹配的内容都找出来,按照组分开存放,下标从1开始,而规定group(0)表示全部内容。比如,我们用group(0)得到的结果是,而group(1)得到的结果是face="Arial" size="2" color="red"。这个结果的理解要回到模式<\sfont\s([^>])\s>,我们看里面有一段([^>]),这个就是分组,整个模式串中也只有这一个组,所以就得到了这个结果。拿到了这组的内容face="Arial" size="2" color="red"后,我们要继续模式匹配。于是用了一个新的模式串:([a-zA-Z]+)\s=\s*"([^"]+)",还是拆分看一下:

([a-zA-Z]+)    这是一个多个字母
\s*          随意个数空字符
=           =分隔符
\s*          随意个数空字符
"           “双引号里面是值
([^"]+)       除了双引号之外的任意多个字符
"           “双引号结尾

按照这个模式,finditer()就能找到三个匹配的串,face="Arial"和 size="2"以及 color="red"。由于我们给这个模式里面建了两个组([a-zA-Z]+)和([^"]+),所以match2.group(1)和match2.group(2)将会把key如face和value如 Arial取出来。
运行上面的程序,结果为:

<font face="Arial" size="2" color="red">
face="Arial"
face Arial
size="2"
size 2
color="red"
color red

这只是HTML一个标签的解析,由此想开出去,把HTML的各个标签都列出一个模式来,我们就可以对HTML文本进行很多解析。

除了匹配,我们还可以进行查找替换操作。下面有一个例子,代码如下:

sentence="This  is    an example string."
pattern=re.compile(r'\s+')
s=pattern.sub(" ",sentence)
print(s)

通过\s+匹配所有空格字符,包括空格制表等符号,统一用空格替换。

从应用来讲,Python里的正则表达式基本用法就是这样,你找任务自己练习,练得多了就熟悉了,熟了就生巧了。
下面,我简单地阐述一下理论。讨论的原因是为了知晓原理,并初步了解到我们自己如何分析正则表达式,进一步理解如何进行词法分析,为今后实现更大的任务做基础准备。也许这个过程很枯燥,但是不要着急,须知我们在做一件了不起的事情。循着这个进路,你最后会发现自己会写解释器编译器,理解人类语言,甚至自己也能发明一种新语言。知道那些改变人类的伟大发明怎么做出来的,窥探往圣如何启绝学安天下,是不是有一种无比荣耀的成就感?
正则表达式是有严格的定义的,我们一般采用递归定义如下:
对给定的有限字母表Σ, 下面的常量定义为正则表达式:

(空集) ∅ 
(空串) ε 
(字母) a in Σ

对于正则表达式R 和S, 运用下面的规则也产生正则表达式:

(连接) RS,例如 R = {"ab", "c"}, S = {"d", "ef"}. 那么, RS = {"abd", "abef", "cd", "cef"}.
(选择) R | S,这是 R 和 S的并集. 例如, R={"ab", "c"} 和 S={"ab", "d", "ef"},  那么R | S = {"ab", "c", "d", "ef"}.
(*) R*,这是由R中的元素按照任意数量次组合而成的集合。例如, {"ab", "c"}* = {ε, "ab", "c", "abab", "abc", "cab", "cc", "ababab", "abcab", ... }.

例子:

a|b* = {ε, "a", "b", "bb", "bbb", ...}
(a|b)* = {ε, "a", "b", "aa", "ab", "ba", "bb", "aaa", ...}
ab*(c|ε) = {"a", "ac", "ab", "abc", "abb", "abbc", ...}
(0|(1(01*0)*1))* = { ε, "0", "00", "11", "000", "011", "110", "0000", "0011", "0110", "1001", "1100", "1111", "00000", ... }

有了这个文法的定义,我们如何构造一个机制来识别某个串是否属于这个语言集合呢?理论上我们会用到状态自动机。如:

字符处理:正则表达式_字符处理

这个自动机就可以识别L((a|b)*abb)这种正则语言。判断方法就是把字符串一个个字符输进去,看是否能走到最后的接收状态,能就是属于该语言,不能则不属于。
1、一个有限的状态集合S;其中,一个状态S0被指定为开始状态,也就是最开始接受输入字符的状态,有且只有一个;S的一个子集F被指定为终止状态集合。
2、一个输入符号集合∑,即输入字母表(input alphabet);
3、一个转换函数T(transition function),为每个状态和∑中的每个符号都给出了相应的后续状态(next state)。
正则表达式方便人理解,而自动机更方便算法构造。对于每个正则表达式,都有一个自动机与之对应。像Tompson算法就是干这个的。这些就是自己做正则引擎的基础。
这样我们就从简单的正则表达式出发,逐步深入了解了复杂一点的用法,又进一步了解了词法分析的理论和自动机。这种逐次扩展的进路,是掌握技术的好方法。在学习技术时,不要拘泥于眼前的小技巧小景象,需要登高望远,见微知著。
古人云:见一叶落而知天下秋。