1.5 更长的正则表达式示例
我们现在将浏览一个深入的示列,它以不同的方式使用正则表达式来操作字符串。首先是一些实际上生成用于操作随机数(但不是太随机)的代码。示例1-5展示了gendata.py,这是一个生成数据集的脚本。尽管该程序只是将简单地将生成的字符串集显示到标准输出,但是该输出可以很容易重定向到测试文件。
示例1-5 用于正则表达式练习的数据生成器(gendata.py)
该脚本为正则表达式练习创建随机数据,然后将生成的数据输出到屏幕。要将该程序移植到Python 3,仅需要将print语句修改为函数,将xrange()函数修改为range(),以及将sys.maxint修改为sys.maxsize。
该脚本生成拥有三个字段的字符串,由一对冒号或者一对双冒号分隔。第一个字段是随机(32位)整数,该整数将被转换为一个日期。下一个字段是一个随机生成的电子邮件地址。最后一个字段是一个由单横线(-)分隔的整数集。
运行这段代码,我们将获得以下输出(读者将会从此获益颇多),并将该输出在本地另存为redata.txt文件。
Thu Jul 22 19:21:19 2004::izsp@dicqdhytvhv.edu::1090549279-4-11
Sun Jul 13 22:42:11 2008::zqeu@dxaibjgkniy.com::1216014131-4-11
Sat May 5 16:36:23 1990::fclihw@alwdbzpsdg.edu::641950583-6-10
Thu Feb 15 17:46:04 2007::uzifzf@dpyivihw.gov::1171590364-6-8
Thu Jun 26 19:08:59 2036::ugxfugt@jkhuqhs.net::2098145339-7-7
Tue Apr 10 01:04:45 2012::zkwaq@rpxwmtikse.com::1334045085-5-10
读者或者可能会辨别出来,但是来自该程序的输出是为正则表达式处理做准备的。后续将逐行解释,我们将实现一些正则表达式来操作这些数据,以及为本章末尾的练习留下很多内容。
逐行解释
第1~6行
在示例脚本中,需要使用多个模块。由于多种原因,尽管我们小心翼翼地避免使用from-import语句(例如,很容易判断一个函数来自哪个模块,以及可能导致本地模块冲突等),我们还是选择从这些模块中仅导入特定的属性,来帮助读者仅专注于那些属性,以及缩短每行代码的长度。
第8行
tlds是一组高级域名集合,当需要随机生成电子邮件地址时,就可以从中随机选出一个。
第10~12行
每次执行gendata.py,就会生成第5行和第10行之间的输出(该脚本对于所有需要随机整数的场景都使用random.randrange()函数)。对于每一行,我们选取所有可能范围(0~231–1 [sys.maxint])中的随机整数,然后使用time.ctime()函数将该整数转换为日期。Python中的系统时间和大多数基于POSIX的计算机一样,两者都使用从“epoch”至今的秒数,epoch是指1970年1月1日格林威治时间的午夜。如果我们选择一个32位整数,那么该整数将表示从epoch到最大可能时间(即epoch后的232秒)之间的某个时刻。
第13~16行
伪造邮件地址的登录名长度为4~7个字符(因此使用randrange(4,8))。为了将它们放在一起,需要随机选择4~7个小写字母,将所有字母逐个连接成一个字符串。random.choice()函数的功能就是接受一个序列,然后返回该序列中的一个随机元素。在该示例中,string.ascii_lowercase是字母表中拥有26个小写字母的序列集合。
我们决定伪造电子邮件地址的主域名长度不能多于12个字符,但是至少和登录名一样长。再一次使用随机的小写字母,逐个字母来组合这个名字。
第17~18行
该脚本的关键部分就是将所有随机数据放入输出行。先是数据字符串,然后是分隔符。然后将所有电子邮件地址通过登录名、“@”符号、域名和一个随机选择的高级域名组合在一起。在最终的双冒号之后,我们将使用用于表示初始时间的随机数字符串(日期字符串),后面跟着登录名和域名的长度,所有这些都由一个连字符分隔。
1.5.1 匹配字符串
对于后续的练习,为正则表达式创建宽松和约束性的版本。建议读者在一个简短的应用中测试这些正则表达式,该应用利用之前所展示的示例文件redata.txt(或者使用通过运行gendata.py生成的数据)。当做练习时,读者将需要再次使用该数据。
在将正则表达式放入应用中之前,为了测试正则表达式,我们将导入re模块,然后将redata.txt中的一个示例行赋给字符串变量data。如下所示,这些语句在所有展示的示例中都是常量。
>>> import re
>>> data = 'Thu Feb 15 17:46:04 2007::uzifzf@dpyivihw.gov::1171590364-6-8'
在第一个示例中,我们将创建一个正则表达式来提取(仅仅)数据文件redata.txt中每一行时间戳中一周的几天。我们将使用下面的正则表达式。
"^Mon|^Tue|^Wed|^Thu|^Fri|^Sat|^Sun"
该示例需要字符串以列出的7个字符串中的任意一个开头(“^”正则表达式中的脱字符)。如果我们将该正则表达式“翻译”成自然语言,读起来就会像这样:“字符串应当以“Mon”,“Tue”,. . . ,“Sat”或者“Sun”开头。
换句话说,如果按照如下所示的方式对日期字符串分组,我们就可以使用一个脱字符来替换所有脱字符。
"^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)"
括住字符串集的圆括号意思是:这些字符串中的一个将会有一次成功匹配。这是我们一开始就使用的“友好的”正则表达式版本,该版本并没有使用圆括号。如下所示,在这个修改过的正则表达式版本中,可以以子组的方式来访问匹配字符串。
>>> patt = '^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)'
>>> m = re.match(patt, data)
>>> m.group() # entire match
'Thu'
>>> m.group(1) # subgroup 1
'Thu'
>>> m.groups() # all subgroups
('Thu',)
我们在该示例所实现的这个特性可能看起来并不是革命性的,但是在下一个示例或者作为正则表达式的一部分提供额外数据来实现字符串匹配操作的任何地方,它确定有它的独到之处,即使这些字符并不是你所感兴趣字符的一部分。
以上两个正则表达式都是非常严格的,尤其是要求一个字符串集。这可能在一个国际化的环境中并不能良好地工作,因为所在的环境中会使用当地的日期和缩写。一个宽松的正则表达式将为:^\w{3}。该正则表达式仅仅需要一个以三个连续字母数字字符开头的字符串。再一次,将正则表达式转换为正常的自然语言:脱字符^表示“作为起始”,\w表示任意单个字母数字字符,{3}表示将会有3个连续的正则表达式副本,这里使用{3}来修饰正则表达式。再一次,如果想要分组,就必须使用圆括号,例如^(\w{3})。
>>> patt = '^(\w{3})'
>>> m = re.match(patt, data)
>>> if m is not None: m.group()
...
'Thu'
>>> m.group(1)
'Thu'
注意,正则表达式^(\w){3}是错误的。当{3}在圆括号中时,先匹配三个连续的字母数字字符,然后表示为一个分组。但是如果将{3}移到外部,它就等效于三个连续的单个字母数字字符。
>>> patt = '^(\w){3}'
>>> m = re.match(patt, data)
>>> if m is not None: m.group()
...
'Thu'
>>> m.group(1)
'u'
当我们访问子组1时,出现字母“u”的原因是子组1持续被下一个字符替换。换句话说,m.group(1)以字母“T”作为开始,然后变为“h”,最终被替换为“u”。这些是单个字母数字字符的三个独立(并且重叠)分组,与一个包含三个连续字母数字字符的单独分组相反。
在下一个(而且是最后)的示例中,我们将创建一个正则表达式来提取redata.txt每一行的末尾所发现的数字字段。
1.5.2 搜索与匹配……还有贪婪
然而,在创建任何正则表达式之前,我们就意识到这些整数数据项位于数据字符串的末尾。这就意味着我们需要选择使用搜索还是匹配。发起一个搜索将更易于理解,因为我们确切知道想要查找的内容(包含三个整数的数据集),所要查找的内容不是在字符串的起始部分,也不是整个字符串。如果我们想要实现匹配,就必须创建一个正则表达式来匹配整个行,然后使用子组来保存想要的数据。要展示它们之间的差别,就需要先执行搜索,然后实现匹配,以展示使用搜索更适合当前的需要。
因为我们想要寻找三个由连字符分隔的整数,所以可以创建自己的正则表达式来说明这一需求:\d+-\d+-\d+。该正则表达式的含义是,“任何数值的数字(至少一个)后面跟着一个连字符,然后是多个数字、另一个连字符,最后是一个数字集。”我们现在将使用search()来测试该正则表达式:
>>> patt = '\d+-\d+-\d+'
>>> re.search(patt, data).group() # entire match
'1171590364-6-8'
一个匹配尝试失败了,为什么呢?因为匹配从字符串的起始部分开始,需要被匹配的数值位于字符串的末尾。我们将不得不创建另一个正则表达式来匹配整个字符串。但是可以使用惰性匹配,即使用“.+”来表明一个任意字符集跟在我们真正感兴趣的部分之后。
patt = '.+\d+-\d+-\d+'
>>> re.match(patt, data).group() # entire match
'Thu Feb 15 17:46:04 2007::uzifzf@dpyivihw.gov::1171590364-6-8'
该正则表达式效果非常好,但是我们只想要末尾的数字字段,而并不是整个字符串,因此不得不使用圆括号对想要的内容进行分组。
>>> patt = '.+(\d+-\d+-\d+)'
>>> re.match(patt, data).group(1) # subgroup 1
'4-6-8'
发生了什么?我们将提取1171590364-6-8,而不仅仅是4-6-8。第一个整数的其余部分在哪儿?问题在于正则表达式本质上实现贪婪匹配。这就意味着对于该通配符模式,将对正则表达式从左至右按顺序求值,而且试图获取匹配该模式的尽可能多的字符。在之前的示例中,使用“.+”获取从字符串起始位置开始的全部单个字符,包括所期望的第一个整数字段。\d+仅仅需要一个数字,因此将得到“4”,其中.+匹配了从字符串起始部分到所期望的第一个数字的全部内容:“Thu Feb 15 17:46:04 2007::uzifzf@dpyivihw.gov::117159036”,如图1-2所示。
其中的一个方案是使用“非贪婪”操作符“?”。读者可以在“*”、“+”或者“?”之后使用该操作符。该操作符将要求正则表达式引擎匹配尽可能少的字符。因此,如果在“.+”之后放置一个“?”,我们将获得所期望的结果,如图1-3所示。
>>> patt = '.+?(\d+-\d+-\d+)'
>>> re.match(patt, data).group(1) # subgroup 1
'1171590364-6-8'
另一个实际情况下更简单的方案,就是把“::”作为字段分隔符。读者可以仅仅使用正则字符串strip(':: ')方法获取所有的部分,然后使用strip('-')作为另一个横线分隔符,就能够获取最初想要查询的三个整数。现在,我们不想先选择该方案,因为这就是我们如何将字符串放在一起,以使用gendata.py作为开始!
最后一个示例:假定我们仅想取出三个整数字段中间的那个整数。如下所示,这就是实现的方法(使用一个搜索,这样就不必匹配整个字符串):-(\d+)-。尝试该模式,将得到以下内容。
>>> patt = '-(\d+)-'
>>> m = re.search(patt, data)
>>> m.group() # entire match
'-6-'
>>> m.group(1) # subgroup 1
'6'
本章几乎没有涉及正则表达式的强大功能,在有限的篇幅里面我们不可能做到。然而,我们希望已经向读者提供了足够有用的介绍性信息,使读者能够掌握这个强有力的工具,并融入到自己的编程技巧里面。建议读者阅读参考文档以获取在Python中如何使用正则表达式的更多细节。对于想要更深入研究正则表达式的读者,建议阅读由 Jeffrey E. F. Friedl.编写的Mastering Regular Expressions。