第六章 正則表達式的構(gòu)建
對于一門語言的掌握程度怎么樣干厚,可以有兩個角度來衡量:讀和寫。
不僅要看懂別人的解決方案螃宙,也要能獨立地解決問題蛮瞄。代碼是這樣,正則表達式也是這樣谆扎。
與“讀”相比裕坊,“寫”往往更為重要,這個道理是不言而喻的燕酷。
對正則的運用,首重就是:如何針對問題周瞎,構(gòu)建一個合適的正則表達式苗缩?
本章就解決該問題,內(nèi)容包括:
- 平衡法則
- 構(gòu)建正則前提
- 準確性
- 效率
1. 平衡法則
構(gòu)建正則有一點非常重要声诸,需要做到下面幾點的平衡:
- 匹配預(yù)期的字符串
- 不匹配非預(yù)期的字符串
- 可讀性和可維護性
- 效率
2. 構(gòu)建正則前提
2.1 是否能使用正則
正則太強大了酱讶,以至于我們隨便遇到一個操作字符串問題時,都會下意識地去想彼乌,用正則該怎么做泻肯。但我們始終要提醒自己,正則雖然強大慰照,但不是萬能的灶挟,很多看似很簡單的事情,還是做不到的毒租。
比如匹配這樣的字符串:1010010001….
雖然很有規(guī)律稚铣,但是只靠正則就是無能為力。
2.2 是否有必要使用正則
要認識到正則的局限墅垮,不要去研究根本無法完成的任務(wù)惕医。同時,也不能走入另一個極端:無所不用正則算色。能用字符串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
其實淆九,可以直接使用字符串的substring
或substr
方法來做:
var string = "JavaScript";
console.log( string.substring(4) );
// => Script
2.3 是否有必要構(gòu)建一個復雜的正則
比如密碼匹配問題,要求密碼長度6-12位毛俏,由數(shù)字炭庙、小寫字符和大寫字母組成,但必須至少包括2種字符煌寇。
在第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. 準確性
所謂準確性,就是能匹配預(yù)期的目標阀溶,并且不匹配非預(yù)期的目標腻脏。
這里提到了“預(yù)期”二字,那么我們就需要知道目標的組成規(guī)則银锻。
不然沒法界定什么樣的目標字符串是符合預(yù)期的永品,什么樣的又不是符合預(yù)期的。
下面將舉例說明击纬,當目標字符串構(gòu)成比較復雜時鼎姐,該如何構(gòu)建正則,并考慮到哪些平衡更振。
3.1 匹配固定電話
比如要匹配如下格式的固定電話號碼:
055188888888
0551-88888888
(0551)88888888
第一步炕桨,了解各部分的模式規(guī)則。
上面的電話肯腕,總體上分為區(qū)號和號碼兩部分(不考慮分機號和+86的情形)献宫。
區(qū)號是0開頭的3到4位數(shù)字,對應(yīng)的正則是:0\d{2,3}
號碼是非0開頭的7到8位數(shù)字乎芳,對應(yīng)的正則是:[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}$/
第二步,明確形式關(guān)系奈惑。
這三者情形是或的關(guān)系吭净,可以構(gòu)建分支:
/^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}$/
其可視化形式:
img上面的正則構(gòu)建過程略顯羅嗦,但是這樣做肴甸,能保證正則是準確的导坟。
上述三種情形是或的關(guān)系崩侠,這一點很重要驻粟,不然很容易按字符是否出現(xiàn)的情形把正則寫成:
/^\(?0\d{2,3}\)?-?[1-9]\d{6,7}$/
雖然也能匹配上述目標字符串,但也會匹配(0551-88888888這樣的字符串彤叉。當然,這不是我們想要的村怪。
其實這個正則也不是完美的秽浇,因為現(xiàn)實中,并不是每個3位數(shù)和4位數(shù)都是一個真實的區(qū)號甚负。
這就是一個平衡取舍問題柬焕,一般夠用就行。
3.2 匹配浮點數(shù)
要求匹配如下的格式:
1.23梭域、+1.23斑举、-1.23
10、+10病涨、-10
.2富玷、+.2、-.2
可以看出正則分為三部分既穆。
符號部分:[+-]
整數(shù)部分:\d+
小數(shù)部分:\.\d+
上述三個部分赎懦,并不是全部都出現(xiàn)。如果此時很容易寫出如下的正則:
/^[+-]?(\d+)?(\.\d+)?$/
此正則看似沒問題幻工,但這個正則也會匹配空字符””铲敛。
因為目標字符串的形式關(guān)系不是要求每部分都是可選的。
要匹配1.23会钝、+1.23、-1.23工三,可以用/^[+-]?\d+\.\d+$/
要匹配10迁酸、+10、-10俭正,可以用/^[+-]?\d+$/
要匹配.2奸鬓、+.2、-.2掸读,可以用/^[+-]?\.\d+$/
因此整個正則是這三者的或的關(guān)系串远,提取公眾部分后是:
/^[+-]?(\d+\.\d+|\d+|\.\d+)$/
其可視化形式是:
img如果要求不匹配+.2和-.2,此時正則變成:
img當然儿惫,/^[+-]?(\d+\.\d+|\d+|\.\d+)$/
也不是完美的澡罚,我們也是做了些取舍,比如:
- 它也會匹配012這樣以0開頭的整數(shù)肾请。如果要求不匹配的話留搔,需要修改整數(shù)部分的正則。
- 一般進行驗證操作之前铛铁,都要經(jīng)過trim和判空隔显。那樣的話却妨,也許那個錯誤正則也就夠用了。
- 也可以進一步改寫成:
/^[+-]?(\d+)?(\.)?\d+$/
括眠,這樣我們就需要考慮可讀性和可維護性了彪标。
4. 效率
保證了準確性后,才需要是否要考慮要優(yōu)化掷豺。大多數(shù)情形是不需要優(yōu)化的捞烟,除非運行的非常慢。什么情形正則表達式運行才慢呢萌业?我們需要考察正則表達式的運行過程(原理)坷襟。
正則表達式的運行分為如下的階段:
- 編譯
- 設(shè)定起始位置
- 嘗試匹配
- 匹配失敗的話,從下一位開始繼續(xù)第3步
- 最終結(jié)果:匹配成功或失敗
下面以代碼為例生年,來看看這幾個階段都做了什么:
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;
當生成一個正則時婴程,引擎會對其進行編譯。報錯與否出現(xiàn)這這個階段抱婉。
regex.exec("123abc34def")
當嘗試匹配時档叔,需要確定從哪一位置開始匹配。一般情形都是字符串的開頭蒸绩,即第0位衙四。
但當使用test
和exec
方法,且正則有g
時患亿,起始位置是從正則對象的lastIndex
屬性開始传蹈。
因此第一次exec
是從第0位開始,而第二次是從3開始的步藕。
設(shè)定好起始位置后惦界,就開始嘗試匹配了。
比如第一次exec
咙冗,從0開始沾歪,去嘗試匹配,并且成功地匹配到3個數(shù)字雾消。此時結(jié)束時的下標是2灾搏,因此下一次的起始位置是3。
而第二次立润,起始下標是3狂窑,但第3個字符是“a”,并不是數(shù)字桑腮。但此時并不會直接報匹配失敗蕾域,而是移動到下一位置,即從第4位開始繼續(xù)嘗試匹配,但該字符是b旨巷,也不是數(shù)字巨缘。再移動到下一位,是c仍不行采呐,再移動一位是數(shù)字3若锁,此時匹配到了兩位數(shù)字34。此時斧吐,下一次匹配的位置是d的位置又固,即第8位。
第三次煤率,是從第8位開始匹配仰冠,直到試到最后一位,也沒發(fā)現(xiàn)匹配的蝶糯,因此匹配失敗洋只,返回null
。同時設(shè)置lastIndex
為0昼捍,即识虚,如要再嘗試匹配的話,需從頭開始妒茬。
從上面可以看出担锤,匹配會出現(xiàn)效率問題,主要出現(xiàn)在上面的第3階段和第4階段乍钻。
因此肛循,主要優(yōu)化手法也是針對這兩階段的。
4.1 使用具體型字符組來代替通配符银择,來消除回溯
而在第三階段育拨,最大的問題就是回溯。
例如欢摄,匹配雙引用號之間的字符。如笋粟,匹配字符串123”abc”456中的”abc”怀挠。
如果正則用的是:/".*"/
,害捕,會在第3階段產(chǎn)生4次回溯(粉色表示.*
匹配的內(nèi)容):
如果正則用的是:/".*?"/
绿淋,會產(chǎn)生2次回溯(粉色表示.*?
匹配的內(nèi)容):
因為回溯的存在,需要引擎保存多種可能中未嘗試過的狀態(tài)尝盼,以便后續(xù)回溯時使用吞滞。注定要占用一定的內(nèi)存。
此時要使用具體化的字符組,來代替通配符.
裁赠,以便消除不必要的字符殿漠,此時使用正則/"[^"]*"/
,即可佩捞。
4.2 使用非捕獲型分組
因為括號的作用之一是绞幌,可以捕獲分組和分支里的數(shù)據(jù)。那么就需要內(nèi)存來保存它們一忱。
當我們不需要使用分組引用和反向引用時莲蜘,此時可以使用非捕獲分組。例如:
/^[+-]?(\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 減少分支的數(shù)量郊艘,縮小它們的范圍
/red|read/
荷科,可以修改成/rea?d/
。此時分支和量詞產(chǎn)生的回溯的成本是不一樣的纱注。但這樣優(yōu)化后畏浆,可讀性會降低的。
小結(jié)
本章涉及的內(nèi)容并不多狞贱。
一般情況下刻获,針對某問題能寫出一個滿足需求的正則,基本上就可以了瞎嬉。
至于準確性和效率方面的追求蝎毡,純屬看個人要求了。我覺得夠用就行了氧枣。
關(guān)于準確性沐兵,本章關(guān)心的是最常用的解決思路:
針對每種情形,分別寫出正則便监,然用分支把他們合并在一起扎谎,再提取分支公共部分碳想,就能得到準確的正則。
至于優(yōu)化毁靶,本章沒有為了湊數(shù)胧奔,去寫一大堆。了解了匹配原理老充,常見的優(yōu)化手法也就這么幾種葡盗。