正则表达式的构建

本文摘抄自javascript正则表达式迷你书

正则表达式是匹配模式,要么匹配字符,要么匹配位置

对于一门语言的掌握程度怎么样,可以有两个角度来衡量:读和写。

不仅要看懂别人的解决方案,也要能独立地解决问题。代码是这样,正则表达式也是这样。

与“读”相比,“写”往往更为重要,这个道理是不言而喻的。

对正则的运用,首重就是:如何针对问题,构建一个合适的正则表达式?

1. 平衡法则

构建正则有一点非常重要,需要做到下面几点的平衡:

  • 匹配预期的字符串
  • 不匹配非预期的字符串
  • 可读性和可维护性
  • 效率

2. 构建正则前提

2.1 是否能使用正则?

正则太强大了,以至于我们随便遇到一个操作字符串问题时,都会下意识地去想,用正则该怎么做。但我们始终要提醒自己,正则虽然强大,但不是万能的,很多看似很简单的事情,还是做不到的。

比如匹配这样的字符串:1010010001…。

虽然很有规律,但是只靠正则就是无能为力。

2.2 是否有必要使用正则?

要认识到正则的局限,不要去研究根本无法完成的任务。同时,也不能走入另一个极端:无所不用正则。能用字符串 API解决的简单问题,就不该正则出马。

比如,从日期中提取出年月日,虽然可以使用正则:

var string = "2017-07-01";
var regex = /^(\d{4})-(\d{2})-(\d{2})/;

console.log( string.match(regex) );
// => ["2017-07-01", "2017", "07", "01", index: 0, input: "2017-07-01"]

其实,可以使用字符串的 split方法来做,即可:

var string = "2017-07-01";
var result = string.split("-");
console.log( result );
// => ["2017", "07", "01"]

比如,判断是否有问号,虽然可以使用:

var string = "?id=xx&act=search";
console.log( string.search(/\?/) );
// => 0

其实,可以使用字符串的 indexOf方法:

var string = "?id=xx&act=search";
console.log( string.indexOf("?") );
// => 0

比如获取子串,虽然可以使用正则:

var string = "JavaScript";
console.log( string.match(/.{4}(.+)/)[1] );
// => Script

其实,可以直接使用字符串的 substringsubstr方法来做:

var string = "JavaScript";
console.log( string.substring(4) );
// => Script

2.3 是否有必要构建一个复杂的正则?

比如密码匹配问题,要求密码长度 6-12 位,由数字、小写字符和大写字母组成,但必须至少包括 2 种字
符。

在前面,我们写出了正则是:

/(?!^[0-9]{6,12}$)(?!^[a-z]{6,12}$)(?!^[A-Z]{6,12}$)^[0-9A-Za-z]{6,12}$/

其实可以使用多个小正则来做:

var regex1 = /^[0-9A-Za-z]{6,12}$/;
var regex2 = /^[0-9]{6,12}$/;
var regex3 = /^[A-Z]{6,12}$/;
var regex4 = /^[a-z]{6,12}$/;
function checkPassword (string) {
  if (!regex1.test(string)) return false;
  if (regex2.test(string)) return false;
  if (regex3.test(string)) return false;
  if (regex4.test(string)) return false;
  return true;
}

3. 准确性

所谓准确性,就是能匹配预期的目标,并且不匹配非预期的目标。

这里提到了“预期”二字,那么我们就需要知道目标的组成规则。

不然没法界定什么样的目标字符串是符合预期的,什么样的又不是符合预期的。

下面将举例说明,当目标字符串构成比较复杂时,该如何构建正则,并考虑到哪些平衡

3.1 匹配固定电话

比如要匹配如下格式的固定电话号码:

055188888888
0551-88888888
(0551)88888888

第一步,了解各部分的模式规则。

上面的电话,总体上分为区号和号码两部分(不考虑分机号和 "+86" 的情形)。

区号是 "0" 开头的 3 到 4 位数字,对应的正则是:0\d{2,3}

号码是非 "0" 开头的 7 到 8 位数字,对应的正则是:[1-9]\d{6,7}

因此,匹配 "055188888888" 的正则是:/^0\d{2,3}[1-9]\d{6,7}$/

匹配 "0551-88888888" 的正则是:/^0\d{2,3}-[1-9]\d{6,7}$/

匹配 "(0551)88888888" 的正则是:/^\(0\d{2,3}\)[1-9]\d{6,7}$/

第二步,明确形式关系。

这三者情形是或的关系,可以构建分支:

/^0\d{2,3}[1-9]\d{6,7}$|^0\d{2,3}-[1-9]\d{6,7}$|^\(0\d{2,3}\)[1-9]\d{6,7}$/

提取公共部分:

/^(0\d{2,3}|0\d{2,3}-|\(0\d{2,3}\))[1-9]\d{6,7}$/

进一步简写:

/^(0\d{2,3}-?|\(0\d{2,3}\))[1-9]\d{6,7}$/

其可视化形式:

上面的正则构建过程略显罗嗦,但是这样做,能保证正则是准确的

上述三种情形是或的关系,这一点很重要,不然很容易按字符是否出现的情形把正则写成:

/^\(?0\d{2,3}\)?-?[1-9]\d{6,7}$/

虽然也能匹配上述目标字符串,但也会匹配 "(0551-88888888" 这样的字符串。当然,这不是我们想要的。

其实这个正则也不是完美的,因为现实中,并不是每个 3 位数和 4 位数都是一个真实的区号。

这就是一个平衡取舍问题,一般够用就行

3.2 匹配浮点数

要求匹配如下的格式:

1.23、+1.23、-1.23
10、+10、-10
.2、+.2、-.2

可以看出正则分为三部分。

符号部分:[+-]

整数部分:\d+

小数部分:\.\d+

上述三个部分,并不是全部都出现。如果此时很容易写出如下的正则:

/^[+-]?(\d+)?(\.\d+)?$/

此正则看似没问题,但这个正则也会匹配空字符 ""

因为目标字符串的形式关系不是要求每部分都是可选的。

要匹配 "1.23"、"+1.23"、"-1.23",可以用/^[+-]?\d+\.\d+$/

要匹配 "10"、"+10"、"-10",可以用/^[+-]?\d+$/

要匹配 ".2"、"+.2"、"-.2",可以用/^[+-]?\.\d+$/

因此整个正则是这三者的或的关系,提取公众部分后是:

/^[+-]?(\d+\.\d+|\d+|\.\d+)$/

其可视化形式是:

如果要求不匹配 "+.2" 和 "-.2",此时正则变成:

当然,/^[+-]?(\d+\.\d+|\d+|\.\d+)$/ 也不是完美的,我们也是做了些取舍,比如:

它也会匹配 "012" 这样以 "0" 开头的整数。如果要求不匹配的话,需要修改整数部分的正则。一般进行验证操作之前,都要经过 trim 和判空。那样的话,也许那个错误正则也就够用了。也可以进一步改写成:/^[+-]?(\d+)?(\.)?\d+$/,这样我们就需要考虑可读性和可维护性了。


4. 效率

保证了准确性后,才需要是否要考虑要优化。大多数情形是不需要优化的,除非运行的非常慢。什么情形正则表达式运行才慢呢?我们需要考察正则表达式的运行过程(原理)。

正则表达式的运行分为如下的阶段:

  1. 编译
  2. 设定起始位置
  3. 尝试匹配
  4. 匹配失败的话,从下一位开始继续第 3 步
  5. 最终结果:匹配成功或失败

下面以代码为例,来看看这几个阶段都做了什么:

var regex = /\d+/g;
console.log( regex.lastIndex, regex.exec("123abc34def") );
console.log( regex.lastIndex, regex.exec("123abc34def") );
console.log( regex.lastIndex, regex.exec("123abc34def") );
console.log( regex.lastIndex, regex.exec("123abc34def") );
// => 0 ["123", index: 0, input: "123abc34def"]
// => 3 ["34", index: 6, input: "123abc34def"]
// => 8 null
// => 0 ["123", index: 0, input: "123abc34def"]

具体分析如下:

var regex = /\d+/g

当生成一个正则时,引擎会对其进行编译。报错与否出现这这个阶段

regex.exec("123abc34def")

当尝试匹配时,需要确定从哪一位置开始匹配。一般情形都是字符串的开头,即第 0 位

但当使用 testexec方法,且正则有 g 时,起始位置是从正则对象的 lastIndex属性开始

因此第一次 exec 是从第 0 位开始,而第二次是从 3 开始的

设定好起始位置后,就开始尝试匹配了

比如第一次 exec,从 0 开始,去尝试匹配,并且成功地匹配到 3 个数字。此时结束时的下标是 2,因此下一次的起始位置是 3。

而第二次,起始下标是 3,但第 3 个字符是 "a",并不是数字。但此时并不会直接报匹配失败,而是移动到下一位置,即从第 4 位开始继续尝试匹配,但该字符是 "b",也不是数字。再移动到下一位,是 "c" 仍不行,再移动一位是数字 "3",此时匹配到了两位数字 "34"。此时,下一次匹配的位置是 "d" 的位置,即第8 位

第三次,是从第 8 位开始匹配,直到试到最后一位,也没发现匹配的,因此匹配失败,返回 null。同时设置 lastIndex为 0,即,如要再尝试匹配的话,需从头开始

从上面可以看出,匹配会出现效率问题,主要出现在上面的第 3 阶段和第 4 阶段。

因此,主要优化手法也是针对这两阶段的

4.1 使用具体型字符组来代替通配符,来消除回溯

而在第三阶段,最大的问题就是回溯

例如,匹配双引用号之间的字符。如,匹配字符串 123"abc"456 中的 "abc"。

如果正则用的是:/".*"/,会在第 3 阶段产生 4 次回溯(粉色表示 .* 匹配的内容):

如果正则用的是:/".*?"/,会产生 2 次回溯(粉色表示 .*? 匹配的内容):

因为回溯的存在,需要引擎保存多种可能中未尝试过的状态,以便后续回溯时使用。注定要占用一定的内存

此时要使用具体化的字符组,来代替通配符.,以便消除不必要的字符,此时使用正则 /"[^"]*"/,即可。

4.2 使用非捕获型分组

因为括号的作用之一是,可以捕获分组和分支里的数据。那么就需要内存来保存它们。

当我们不需要使用分组引用和反向引用时,此时可以使用非捕获分组。

例如,/^[-]?(\d\.\d+|\d+|\.\d+)$/可以修改成:/^[-]?(?:\d\.\d+|\d+|\.\d+)$/

4.3 独立出确定字符

例如,/a+/ 可以修改成 /aa*/

因为后者能比前者多确定了字符 "a"。这样会在第四步中,加快判断是否匹配失败,进而加快移位的速度

4.4 提取分支公共部分

比如,/^abc|^def/ 修改成 /^(?:abc|def)/

又比如, /this|that/修改成 /th(?:is|at)/

这样做,可以减少匹配过程中可消除的重复

4.5 减少分支的数量,缩小它们的范围

/red|read/ 可以修改成 /rea?d/

此时分支和量词产生的回溯的成本是不一样的。但这样优化后,可读性会降低的


5. 本章小结

般情况下,针对某问题能写出一个满足需求的正则,基本上就可以了

至于准确性和效率方面的追求,纯属看个人要求了。我觉得够用就行了

关于准确性,本章关心的是最常用的解决思路:

针对每种情形,分别写出正则,然用分支把它们合并在一起,再提取分支公共部分,就能得到准确的正则

至于优化,本章没有为了凑数,去写一大堆。了解了匹配原理,常见的优化手法也就这么几种

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,386评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,142评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,704评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,702评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,716评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,573评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,314评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,230评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,680评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,873评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,991评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,706评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,329评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,910评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,038评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,158评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,941评论 2 355

推荐阅读更多精彩内容