查看原文
其他

Python 教学 | “小白”友好型正则表达式教学(三)

分享技巧的 数据Seminar 2023-05-08

目录

引言

一、Python正则函数

二、正则常量

三、分组

    1.捕获分组

    2.非捕获分组

四、断言(环视)

总结

相关内容

Python教学

“本文共9062个字,阅读约需23分钟,欢迎指正!”

引言

在之前的两期文章中,我们介绍了 Python 正则表达式主要语法,包括基本元字符、量词元字符和 Python 正则函数。通过学习使用这些元字符,我们认识到了什么是正则表达式以及正则表达式的工作原理;同时也了解了正则函数re.findall()的用法。本期文章我们将学习 Python 正则表达式其他正则函数的使用方法,并介绍这些方法的应用场景。除此之外,我们还将介绍 Python 正则表达式中的一些高级用法。

一、Python 正则函数

在正则表达式教学的首期文章,我们就已经初步介绍了 Python 中的几个常见的正则函数,如下表所示。

正则函数描述
re.compile(pattern)正则表达式编译函数,当一个正则表达式(pattern)需要多次使用时,可以使用此函数事先进行编译,而不是在每一次调用之前都重新编译一次,这样做可以提高正则匹配效率。
re.search(pattern, string)正则搜索函数,扫描字符串 string 并查找与正则表达式 pattern 相匹配的对象,查找结果是一个 Match 对象,其中包含首个匹配成功的文本以及该文本在 string 中的位置区间。如果没有符合条件的文本,则返回结果是 None
re.match(pattern, string)正则搜索函数,与 re.search 几乎一模一样,区别在于 re.match 只能从 string 的开头处进行扫描和匹配,常用于字符串验证。
re.findall(pattern, string)正则查找函数,扫描字符串 string 并查找所有与正则表达式 pattern 相匹配的对象,将搜索到的所有结果存放在一个列表中并返回。如果没有匹配到任何结果,则会返回一个空列表 []
re.sub(pattern, repl, string, count)正则替换函数,扫描字符串 string 并查找所有与正则表达式 pattern 相匹配的对象,然后将所有匹配到的对象都替换为 repl,无论替换多少次,最后都会返回替换后的 string
re.split(pattern, string)正则分割函数,以所有与正则表达式 pattern 相匹配的对象为分割点,将字符串 string 切分为多个子文本,并将它存放在一个列表中返回。

通过之前的的两期文章,我们已经熟悉了正则函数re.findall()的使用方式和功能,即从一段文本中找出所有符合正则表达式规则的字符串,并将它们存放在一个列表中返回。该函数的主要使用场景是找出所有匹配项,例如从一段文本中找出所有括号内的内容,找出所有指定的关键词等等。除了findall函数,Python 正则表达式中还包含其他重要的正则函数,下面我们来逐一介绍它们的功能和使用场景。

1. re.compile()

正则表达式是一个具有规则的字符串,在正则表达式的匹配过程中,正则引擎不能直接根据这个规则字符串去进行匹配操作,想要让规则字符串正常工作,需要先将输入的规则字符串编译为一个正则对象。只有这样,正则引擎才能理解输入的正则表达式。re.compile()函数就是那个将正则表达式字符串编译为正则对象的函数。或许你会问,我们在使用re.findall()函数进行匹配的时候,不是没有使用re.compile()提前编译吗?就像下面的代码一样。

# 匹配所有的连续四个数字
re.findall('\d{4}''2022/12/15')  

虽然我们没有在上面的代码中看到compile()函数的身影,但只要我们阅读 Python 正则表达式库re的源代码就会发现,几乎所有正则函数(除compile()之外)的工作过程都分为两步,第一步是编译正则对象,第二步才是匹配工作。也就是说大部分正则函数都会隐式地调用compile()函数。所以上面的代码也就等价于下面的代码。

pattern = re.compile('\d{4}')    # 编译正则表达式, 只接收规则字符串,编译后赋值给变量 pattern
pattern.findall('2022/12/15')  # 使用编译后的正则对象调用正则函数 findall(),此时只接收待匹配的字符串

当我们需要使用一个正则表达式去进行多次匹配时,如果使用re直接调用正则函数(re.函数名()),那么每一次匹配都会先进行编译,这样相当于做了很多次重复无用的工作。这个时候我们就可以对正则表达式进行预编译,然后再使用预编译后的正则对象去调用正则函数(正则对象.函数名()),那么就只需要进行一次编译操作,如果待处理的数据量很大,这将为我们节省不少时间。

图片来源于博客http://t.csdn.cn/Vbrmd

根据上图可知,当匹配次数来到一万次的时候,不预编译花费的时间已经显著高于预编译。如果匹配次数更多,那么预编译操作带来的时间收益将更多大。虽然预编译能够节省时间,但从代码易用性方面来说,不预编译的代码更简短,好理解,所以在本文后续的内容中,我们将依然使用不预编译的方式介绍其他正则函数。

2. re.search()

我们已经知道re.findall()的功能是找出所有匹配项,如果说它的侧重点在于“有多少”,那么re.search()函数的关注点则是“有没有”。这两个正则函数的参数用法完全一样(第一个参数是正则表达式,第二个参数是待匹配的字符串)。它们之间不同的地方是,re.findall()的返回值是一个列表,且无论有没有匹配到,都有返回结果。而re.search()的返回值是一个Match对象,如果想查看被匹配到的内容,还需要再调用group()属性,如果没有匹配到符合规则的内容,则没有任何返回值,即返回值是空值 None,在这种情况下调用group()属性,程序会直接报错。例如:

# 查找企业经营范围中的特殊关键词:养殖、种植、种养、培育
re.search('养殖|种植|种养|培育''黄牛养殖、水产养殖、农作物种植、果蔬种植;饲料销售。')
# 得到:<re.Match object; span=(2, 4), match='养殖'>

re.search('养殖|种植|种养|培育''黄牛养殖、水产养殖、农作物种植、果蔬种植;饲料销售。').group()
# 得到:'养殖'

# 当待匹配文本中没有符合正则表达式条件的片段时
re.search('养殖|种植|种养|培育''刺绣。工艺品制造').group()
# 程序报错:AttributeError: 'NoneType' object has no attribute 'group'

当我们只想知道待匹配文本中是否包含符合正则表达式规则的字符串的时候,可以使用布尔函数bool()re.search()函数的结果转为布尔值(True 或 False),如果匹配到相关内容,bool()返回True,匹配不到则返回 False。代码如下。

bool(re.search('养殖|种植|种养|培育''黄牛养殖、水产养殖、农作物种植、果蔬种植;饲料销售。'))
# 得到:True
bool(re.search('养殖|种植|种养|培育''刺绣。工艺品制造'))
# 得到:False

3. re.match()

re.search()函数十分相似,唯一的不同是,re.match()会从文本的开头处匹配。相当于在正则表达式的每个子表达式的前面都加上一个位置元字符^。例如下面两行代码就是等价的。

re.search('养殖|种植|种养|培育''刺绣。工艺品制造')
re.match('^养殖|^种植|^种养|^培育''刺绣。工艺品制造')

4. re.split()

这是一个正则分割函数,作用是以正则表达式对应的内容为分割点,将文本分割为若干项,并存放在一个列表中返回。我们知道,字符串本身就可以通过split()函数进行分割操作,例如:

'养殖|种植|种养|培育'.split('|')
# 得到:['养殖', '种植', '种养', '培育']

上述代码将字符串"养殖|种植|种养|培育"根据字符|分割为四项,存放在列表中。不过字符串本身的split()函数只能以一个固定的字符串为分割点,而正则函数re.split()则可以控制多个分割点,实现对文本的快速分割,常用于文本分析。re.split()函数的使用示例如下。

re.split(',|、|以及''黄牛养殖,水产养殖、农作物种植、果蔬种植以及饲料销售')
# 得到:['黄牛养殖', '水产养殖', '农作物种植', '果蔬种植', '饲料销售']

5. re.sub()

我们在之前的文章中曾提到过,正则表达式对文本有三种操作:查找、替换和分割。常用的正则函数中,re.search()re.match()re.findall()是查找函数,re.split()是分割函数,而re.sub()就是接下来要介绍的正则替换函数。与其他正则函数相比,re.sub()多了两个参数。

re.sub(pattern, repl, string, count)
  • 第一个参数pattern是正则表达式字符串,在正则替换函数中表示待处理字符串中即将被替换掉的部分;
  • 第二个参数repl是替换后的字符串;
  • 第三个参数string是待处理的字符串;
  • 第四个参数count表示替换的最大次数,默认值为 0,为 0 并不代表一次都不替换,而是最大次数替换;
  • re.sub()函数的用法如下:
# 将字符串末尾最后一个括号以及其中的内容替换为空字符,即去除末尾括号
re.sub('([^)]*?)$''''利润总额差额(合计平衡项目)(元)')
# 得到:'利润总额差额(合计平衡项目)'

上面这个例子可能不太容易理解,如果换一个简单的例子,相信你会一目了然。

# 将日期中的正斜杠 / 替换为短横杠 - ;没有指定 count 参数,表示全部替换
re.sub('/''-''2023/05/6')
# 得到:'2023-05-6'

这个函数的功能与字符串替换函数有共同之处,他们都可以将字符串中的指定内容替换为其他内容,使用字符串替换函数完成上述操作的代码如下。

'2023/05/6'.replace('/''-')
# 得到:'2023-05-6'

不过常规的字符串替换函数只能将具体的内容替换为另一个具体的内容,而正则替换函数不仅可以这样做,还可以将符合正则表达规则的内容替换为固定内容。

二、正则常量

在上一节介绍的所有正则函数中,都还隐藏着一个共同的参数:flags,这是一个标志位参数,用于满足一些特殊的匹配需求。例如re.findall()函数完整的参数列表应该是下面这样的。

re.findall(pattern, string,flags=0)

参数flags可以接收正则常量,其默认值为 0,表示以常规方式进行匹配。接收正则常量后,正则函数将按照指定方式进行匹配。官方文档中共有 7 个正则常量,笔者认为常用的有两个,如下表所示。

正则常量正则常量简称含义
re.IGNORECASEre.I忽略大小写进行匹配
re.DOTALLre.S使用该模式后,元字符.可以匹配任意字符,包括换行符(默认情况下不能匹配换行符)。常用于长文本的处理

如果希望正则表达式在匹配时忽略英文字母大小写,那么可以使用下面的代码(设置参数flags=re.I)。

re.findall('python''Python  PYTHON  python', flags=re.I)   # 使用忽略大小写模式
# 得到:['Python', 'PYTHON', 'python']

如果不指定参数flags=re.I,那么所得结果将只有一项。

re.findall('python''Python  PYTHON  python')
# 得到:['python']

如果希望元字符.能够匹配包括换行符\n在内的任意字符,可以设置参数flags=re.S,示例如下。

# 定义一个包含换行符的字符串
TEXT ="""
(一段说明
性文字)
"
""
# 匹配括号中的内容,常规模式匹配,元字符"."无法匹配到换行符
re.findall('(.*?)', TEXT)       
# 得到:[]

# 将 flags 参数设置为正则常量 re.S,于是"."就可以匹配换行符了
re.findall('(.*?)', TEXT, re.S) 
# 得到:['(一段说明\n性文字)']

当希望在匹配时同时满足以上两种情况,可以使用符号|将多个正则常量连在一起传给参数flags。使用方式如下。

TEXT ="""
PYTHON is an 
interesting programming language)
"
""
re.findall('python is.*$', TEXT, flags=re.S|re.I)
# 得到:['PYTHON is an \ninteresting programming language)\n']

下文两节将简单介绍 Python 正则表达式的一些相对高级的用法和相关知识,不过这些用法实用性有限且学习成本稍高,大家可以根据自身需求进行选择性学习。


三、分组

在 Python 正则表达式中,还有一种特殊的元字符组合()(注意是半角括号,即英文状态下的输入的括号),这是一种表示“分组”的结构。

1. 捕获分组

re.findall('(\d{4})-(\d{1,2})-(\d{1,2})''2023-05-6')
# 得到:[('2023', '05', '6')]

在上面这个正则表达式中,共包含三个分组(\d{4})(\d{1,2})(\d{1,2}),这些分组依次用于匹配日期文本中的年、月和日对应的数字。根据re.findall()函数的说明,返回值应该是包含匹配结果(字符串)的列表,为什么上面这个代码的返回结果却是一个包含元组的列表呢?这是因为我们在正则表达式中使用了捕获分组(将表达式直接写在括号内就是捕获分组的书写方式),当在re.findall()函数中使用捕获分组时,函数返回值将会是包含被捕获分组内容的元组的列表(就像上述代码的返回值一样)。而如果在re.search()函数中使用捕获分组,则会得到不一样的效果。

re.search('(\d{4})-(\d{1,2})-(\d{1,2})''2023-05-6').group()
# 得到:'2023-05-6'

可以看到使用re.search().group()可以直接返回匹配结果'2023-05-6',实际上re.search()函数的group()属性也是有一个参数的,其默认值为 0,即group()等价于group(0)。为 0 时将会返回所有分组的综合匹配结果;不为 0 的时候,可以根据分组的数量设置参数值,以上面的正则表达式为例,当存在 3 个分组时,可以设置group()的参数为 1,2 或 3。为 1 时将返回第一个分组的捕获结果,为 2 时将返回第二个分组的捕获结果……代码如下。

re.search('(\d{4})-(\d{1,2})-(\d{1,2})''2023-05-6').group(1)
# 得到:'2023'
re.search('(\d{4})-(\d{1,2})-(\d{1,2})''2023-05-6').group(2)
# 得到:'05'
re.search('(\d{4})-(\d{1,2})-(\d{1,2})''2023-05-6').group(3)
# 得到:'6'

那么捕获分组有什么用呢?这个就要谈到 Python 正则表达式的另一个知识点——反向引用了。什么是反向引用?当我们使用捕获分组捕获到指定内容后,可以在后续的表达式中通过转义符\+数字n的方式反向引用前面第n个分组的捕获结果。为了方便理解,下面我们使用re.sub()函数举一个简单的例子。

# 提取日期中的年、月、日对应的数字,将他们之间的分隔符修改为对应的中文单位
re.sub('(\d{4})-(\d{1,2})-(\d{1,2})', r'\1年\2月\3日''2023-05-6')
# 得到:'2023年05月6日'

在上面的正则代码中,共有三个分组,在被替换的repl参数部分,我们使用\1\2\3分别反向引用正则表达式中第 1,2,3 个分组的捕获结果,再向他们之间插入对应的单位,最终就得到了一个新的日期格式。除此之外我们还可以调换反向引用的位置,将上文的年月日修改为其他格式(例如xx日xx月xx年)。分组还有一个重要作用,这个作用在非捕获分组中体现更多。

2. 非捕获分组

下面我们通过一个实际案例来介绍一下非捕获分组

我们想要从工商部门采集的农民专业合作社数据中,得到每一家合作社的成员数量。由于早期的成员信息主要靠人工填写,所以其中出现了一些不规范的情况,例如:

xx村13组张三、李四等25人

共14位

张三代表18名

为了得到更为准确的成员数量,我们需要使用正则表达式获取表述中对应的数字。如果我们直接从表述中匹配数字,那么有可能得到不相关的数字,例如xx村13组张三、李四等25人中的13,所以在匹配时还需要限制对应数字的前后文。于是我们就可以使用下面的正则表达式。

re.findall('(?:等|共|代表)\d+|\d+(?:人|名|位|个|号人|户|成员)''xx村13组张三、李四等25人')
# 得到:['等25']
re.findall('(?:等|共|代表)\d+|\d+(?:人|名|位|个|号人|户|成员)''14成员')
# 得到:['14成员']
re.findall('(?:等|共|代表)\d+|\d+(?:人|名|位|个|号人|户|成员)''张三代表18名')
# 得到:['代表18']

上面的正则表达式中分为两个部分(?:等|共|代表)\d+\d+(?:人|名|位|个|号人|户|成员),前者表示数字(\d+)的前面必须出现“等”、“共”、“代表”这些关键词;后者表示数字的后面必须出现“人”“名”“位”等关键词。中间使用逻辑“或”元字符|将这两部分连接起来,表示上述两种情况,符合其中一种即可。

我们发现,这一次使用re.findall()函数,返回值又变成了包含字符串的列表。这是因为虽然这里使用了分组,但使用的是非捕获分组,可以看到每个分组的最前面都添加字符组合?:,这就是表示非捕获分组的方式,分组,但是不捕获,那么非捕获分组的意义何在呢?笔者认为至少有两点。

  • 其一,可以简化正则表达式,如果不使用非捕获分组,那么面对上面这种情况,就需要将分组中的每一种情况都拆分出来,改写后的正则表达式变得十分冗长;
  • 其二,常规的元字符都是以一个独立的字符为单位进行匹配,虽然可以使用量词元字符控制匹配字符的次数,但如果需要处理某种固定字符组合,常规模式就不好处理了,例如在HTML中,&nbsp;就是一种特殊的字符组合,表示不间断空格。如果想要匹配多个连续的不间断空格,只能借助非捕获分组。代码如下。
re.findall('(?:&nbsp;)+''&nbsp;&nbsp;&nbsp;&nbsp;')
# 得到:['&nbsp;&nbsp;&nbsp;&nbsp;']

也就是说非捕获分组还具有自定义最小匹配单位的功能,在上面的正则表达式中,字符串&nbsp;就是一个最小匹配单位。

四、断言(环视)

断言(assertion),也称环视、预查、零宽断言。是一种限制待匹配文本前后文内容,但又不获取他们的正则表达式语法。我们在介绍非捕获分组的时候,使用了一个匹配成员数量的例子。

re.findall('(?:等|共|代表)\d+''xx村13组张三、李四等25人')
# 得到:['等25']

在上面这个例子中,虽然我们通过限制数字之前或之后的关键词来匹配正确的内容,但是匹配结果中包含了这些限制性文本,例如上面代码的匹配结果'等25'中就含有字,那么如何才能设置限制,但又不匹配这些 辅助性的文字呢?这就要使用 Python 正则表达式中的“断言”来实现了,请看下面的代码。

re.findall('(?<=等|共)\d+''xx村13组张三、李四等25人')
# 得到:['25']

在上面的正则表达式中,我们在数字\d+的前面使用(?<=等|共)来限制数字前面的内容,表示数字前面必须出现关键字“等”或“共”。括号中的字符组合?<=就是一种表示断言的元字符,而且是一种肯定式向后断言,其含义是待匹配文本必须出现在指定关键字的后面。细心的同学可能注意到,上面的例子中是不是少了一个关键词“代表”。是少了,但这是笔者故意漏掉的,因为在正则表达式断言中,如果使用|连接多个匹配项,那么这些匹配项的长度必须一致。因为“代表”的长度是 2,所以不能与“等”和“共”出现在一起。这也算是正则表达式的一个小小缺陷,不过如果真的遇到了类似问题,我们仍可以另辟蹊径,“曲线救国”!解决方案示例代码如下。

re.findall('(?<=等|共)\d+|(?<=代表)\d+''xx村13组张三、李四代表25人')
# 得到:['25']

前面我们介绍了肯定式向后断言,实际上在正则表达式中,共有四种断言模式,如下表所示。

断言模式元字符组合含义
肯定式向后断言(?<=)待匹配的内容必须出现在指定内容之后
否定式向后断言(?<!)待匹配的内容必须不能出现在指定内容之后
肯定式向前断言(?=)待匹配的内容必须出现在指定内容之前
否定式向前断言(?!)待匹配的内容必须不能出现在指定内容之前

前面我们已经介绍过(肯定式)向后断言的用法,那么否定式向后断言的用法与前者的使用方法是类似的,只需要将元字符组合从?<=变成?<!即可,这里我们不再过多介绍了。使用向前断言时,需要注意不仅断言的元字符组合要用对,断言语句的位置也要从前面变到后面。下面我们依然使用匹配成员数量的案例,(肯定式)向前断言示例代码如下。

# 匹配数字,但是数字必须出现在“名”、“位”、“人”、“户”等关键字的前面
re.findall('\d+(?=名|位|人|户)''xx村13组张三、李四代表25人')
# 得到:['25']

注意向前断言语句(?=名|位|人|户)的位置在待匹配数字\d+的后面,如果要使用否定式向前断言语句,那么将元字符组合?=替换成?!就可以了。

好了,以上就是 Python 正则表达式的基本内容了。

总结

通过三期文章,我们已经向大家详细介绍了 Python 正则表达式的基础内容以及几个正则表达式的高级用法。还是那句话,想要熟练使用正则表达式,还是得多多实践。祝大家学习顺利!

相关内容

      Python教学



        星标⭐我们不迷路!想要文章及时到,文末“在看”少不了!

        点击搜索你感兴趣的内容吧


        往期推荐



        Python 教学 | “小白”友好型正则表达式教学(一)

        Python 教学 | “小白”友好型正则表达式教学(二)

        Python教学 | 盘点 Python 数据处理常用标准库

        Python教学 | 最常用的标准库之一 —— os

        案例分享:使用 Python 批量处理统计年鉴数据(上)

        案例分享:使用 Python 批量处理统计年鉴数据(下)




        数据Seminar




        这里是大数据、分析技术与学术研究的三叉路口


        文 |《社科领域大数据治理实务手册》


            欢迎扫描👇二维码添加关注    

        点击下方“阅读全文”了解更多

        您可能也对以下帖子感兴趣

        文章有问题?点此查看未经处理的缓存