regex cookbook

今天介绍的这本书是一本正则表达式(Regular Expression)指南。正则表达式是做什么的呢?

几乎所有的 Text Editor(文本编辑器)都支持简单的关键词搜索。你在《金瓶梅》中搜索「西门庆」,会得到所有这三个字出现的位置(共 4750 处)。但这种字面搜索可能会包含无意义的结果,如「打开西门庆祝节日」。因此,要想让搜索更准确,我们要给它附加一些条件,比如规定,如果「庆」后面是「祝」,则把结果舍掉。

这种「可编程」的搜索就叫做「正则表达式」。虽然正则表达式程序员用的比较多,但是我可以肯定地说,它是屠龙刀,而且绝对不会出现「无龙可屠」的情况。只要你经常处理文本,哪怕只是写书评,都一定能用到它,和是否编程没什么关系。

本书特色

1. 口味多(flavor)

这本书叫做 Cookbook(烹饪),即然是烹饪,就免不了众口难调的问题。一种编程语言,以及某些专门为正则表达式开发的「库」,叫做 flavor(口味)。正则表达式的一个现实问题是:flavor 太多且规则不完全一致,例如 Java 这个 flavor 允许 lookbehind(正则表达式的「后视」功能),而 JavaScript 完全不支持。因此当我们要用正则表达式的时候,一定要弄清楚和什么 flavor 一起。

这本书的一大特色是它介绍了 .NET、Java、JavaScript、XRegExp、PCRE、Perl、Python、Ruby 等 8 种流行的 flavor。好处一是,很有可能它包括你熟悉的语言,立马可用。更好的是,对于不感兴趣的语言直接跳过,无形中看书速度快了好几倍。

2. 菜单全(recipe)

这本书一共 9 章,前 3 章介绍了基础知识。从第 4 章开始,一直到结束都是具体的recipe(例题),每个 recipe 按照 flavor 的不同分别解释原理,非常详细。如果你有一定的基础,你可以把它当成一本练习册,直接跳到第 4 章,对照解析研究这些例子,很有意思。大多例子还是挺实用的,如删掉重复行,判断密码的强弱,等等。

下面我介绍两个我实际使用的例子,flavor 是 JavaScript。用什么 flavor 看个人的需求,我经常用 JS,因为它在 iPad OS 中应用比较广泛,仅此而已。

例1: 替换双引号

很长时间我一直用 iA Writer 写文章,它的引号是直引号 “,如:

"你好!" 他说。

为了提高可读性,我想把所有的双引号都换成直角引号:

「你好!」 他说。

也就是说,左 “ 变成 「,右 “ 变成 」。

倘若用 Text Editor 直接替换,可想而知,结果要么是:

「你好!「 他说。

或者:

」你好!」 他说。

解决办法可以使用正则表达式,JavaScript code 如下:

const txt = `"你好!" 他说。`
const regex = /"(.*?)"/g;

let replace = txt.replace(regex, "「$1」");
console.log(replace); 

const regex = /”(.*?)”/g; 这一行就是正则表达式,结果是:

「你好!」 他说。

注意如下情况,假设文本变为:

const txt = `"你好!" 他说。"你好!" 她说。`

这里有两对引号,我们希望结果是:

「你好!」 他说。「你好!」 她说。

然而,正则表达式会输出:

「你好!" 他说。"你好!」 她说。

可见,中间的两个引号被「吞掉」了,因为默认情况,正则表达式比较 greedy,它倾向于匹配尽量多的字符,因此它会把第一个左引号和第二个右引号看作一对。避免的方法是,注意 const regex = /”(.*?)”/g; 中的 「?」,它的作用是告诉正则表达式,匹配范围要尽量少,只要遇到匹配就算。

例 2: 整理 txt 文件

以《金瓶梅》为例,网上下载的 txt 文件格式不理想,比如:

-------------
分节阅读 1
……

 第一回    西门庆热结十弟兄  武二郎冷遇亲哥嫂
 ……
	 二八佳人体似酥,腰间仗剑斩愚夫。
		虽然不见人头落,暗里教君骨髓枯。
 
	 这一首诗,是昔年大唐国时,一个修真炼性的英雄,入圣超凡的豪杰,到后来位居紫府,名列仙班,率领上八洞群仙,救拔四部洲沉苦一位仙长,姓吕名岩,道号纯阳子祖师所作。
……

分节阅读 2
……
-------------

这里有几个问题:

  1. 章回标题没有 Markdown 的标识「## 」。
  2. 很多无意义的 ————-。
  3. 无意义的「分节阅读 xxx」,xxx 是一个数
  4. 正文每行前后有很多空格。
  5. 正文内有很多空格,如「第一回」和「西门庆」,以及「十兄弟」和「武二郎」之间。

正则表达式最擅解决此类问题,因为每个问题都能精确转换为一个定义,例如,第一个问题,相当于要求,凡是以「第xxx回」(xxx 最少一个汉字,如「一」,最多三个,如「九十九」)打头的行,前面插入「## 」(两个 # 及一个空格)。只要定义明确,就很好办。

举例 code 如下,

const cpbd = Pasteboard.paste();

let str = cpbd.replace(/-+/g, ""); //删除 ------

str = str.replace(/^\s+/gm, "").replace(/\s+$/gm, ""); //去掉行前后空格

str = str.replace(/(第.{0,3}回)/gm, "## $1"); // 加 ## 

str = str.replace(/分节阅读.*/g, ""); //删除 分节阅读xxx

str = str.replace(/(## 第.{0,3}回)(\s+)(\S+)(\s+)(\S+)$/gm, "$1 $3 $5"); //去掉正文内多余空格

这里的正则表达式是 JavaScript replace() 函数的第一个参数,第二个参数是替换后的内容。整理后的结果是:

## 第一回 西门庆热结十弟兄 武二郎冷遇亲哥嫂

……

二八佳人体似酥,腰间仗剑斩愚夫。
虽然不见人头落,暗里教君骨髓枯。

这一首诗,是昔年大唐国时,一个修真炼性的英雄,入圣超凡的豪杰,到后来位居紫府,名列仙班,率领上八洞群仙,救拔四部洲沉苦一位仙长,姓吕名岩,道号纯阳子祖师所作。

……

《金瓶梅》八十万字,在我的 iPad 上,我复制到 Clipboard,执行这段 JS code,再复制回 Clipboard,毫无压力。

效率

最后简单谈谈效率。我们已经看到,正则表达式的关键是抽象出某种规律,作为定义的条件。这至少带来两个后果:

  1. 条件越严格,结果越准确。
  2. 条件越严格,操作成本越高。

对于第一条,「严格」和「准确」是相对的。就说本文开始的例子,在《金瓶梅》中搜索「西门庆」,条件是很宽松的,只要含有「西门庆」三个字即可。但它的结果也是最冗余的,不能处理任何特殊情况,像是「打开西门庆祝节日」。如果要精炼结果,就得加上限制。如规定,假如「西门庆」和「祝」一起出现,则把结果舍掉,这样行不行呢?不行,因为它连「与西门庆祝寿」这种合理的结果都删掉了。

因此,用正则表达式,难度往往在于如何定义清晰简明的规律。而定义规则既取决于问题的复杂性,又涉及到设计正则表达式本身的成本,这就是第二个后果。

给复杂问题设计并维护一个正则表达式很费时间,你永远想不到前面有什么坑在等着,一旦出错,debug 费时费力。所以对于具体问题,先要想好,你是想要一个模糊结果但搜索简单,还是精确的结果但需要精心设计正则表达式。

其实对于「西门庆」这个例子,直接搜索就是最简单的方法,因为它几乎没有歧义,「打开西门庆祝节日」是我编的,《金瓶梅》中不存在。但是,如果单搜「西」、「门」、「庆」,显然就困难多了,那就不得不思考如何添加限制条件,成本也就随之上升。

总之,即使你从不编程,正则表达式也绝对值得熟悉一下。

Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems. (有些人啊,一遇到问题,就想,「没事儿,我会正则表达式。」于是他们有了俩问题。)

Just kidding.