diff --git a/9.md b/9.md index ec8b6e1..4eaabfb 100644 --- a/9.md +++ b/9.md @@ -1,9 +1,25 @@ ## 九、正则表达式 +> 原文:[Regular Expressions](https://eloquentjavascript.net/09_regexp.html) +> +> 译者:[飞龙](https://github.com/wizardforcel) +> +> 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) +> +> 自豪地采用[谷歌翻译](https://translate.google.cn/) +> +> 部分参考了[《JavaScript 编程精解(第 2 版)》](https://book.douban.com/subject/26707144/) + +> 一些人遇到问题时会认为,“我知道了,我会用正则表达式。”现在它们有两个问题了。 +> +> Jamie Zawinski +> > Yuan-Ma said, 'When you cut against the grain of the wood, much strength is needed. When you program against the grain of the problem, much code is needed.' > > Master Yuan-Ma,《The Book of Programming》 +![](img/9-0.jpg) + 程序设计工具技术的发展与传播方式是在混乱中不断进化。在此过程中获胜的往往不是优雅或杰出的一方,而是那些瞄准主流市场,并能够填补市场需求的,或者碰巧与另一种成功的技术集成在一起的工具技术。 本章将会讨论正则表达式(regular expression)这种工具。正则表达式是一种描述字符串数据模式的方法。它们形成了一种小而独立的语言,也是 JavaScript 和许多其他语言和系统的一部分。 @@ -14,7 +30,7 @@ 正则表达式是一种对象类型。我们可以使用两种方法来构造正则表达式:一是使用`RegExp`构造器构造一个正则表达式对象;二是使用斜杠(`/`)字符将模式包围起来,生成一个字面值。 -``` +```js let re1 = new RegExp("abc"); let re2 = /abc/; ``` @@ -25,7 +41,7 @@ let re2 = /abc/; 第二种写法将模式写在斜杠之间,处理反斜杠的方式与第一种方法略有差别。首先,由于斜杠会结束整个模式,因此模式中包含斜杠时,需在斜杠前加上反斜杠。此外,如果反斜杠不是特殊字符代码(比如`\n`)的一部分,则会保留反斜杠,不像字符串中会将其忽略,也不会改变模式的含义。一些字符,比如问号、加号在正则表达式中有特殊含义,如果你想要表示其字符本身,需要在字符前加上反斜杠。 -``` +```js let eighteenPlus = /eighteen\+/; ``` @@ -33,7 +49,7 @@ let eighteenPlus = /eighteen\+/; 正则表达式对象有许多方法。其中最简单的就是`test`方法。`test`方法接受用户传递的字符串,并返回一个布尔值,表示字符串中是否包含能与表达式模式匹配的字符串。 -``` +```js console.log(/abc/.test("abcde")); // → true console.log(/abc/.test("abxde")); @@ -50,7 +66,7 @@ console.log(/abc/.test("abxde")); 下面两个表达式都可以匹配包含数字的字符串。 -``` +```js console.log(/[0123456789]/.test("in 1992")); // → true console.log(/[0-9]/.test("in 1992")); @@ -77,7 +93,7 @@ console.log(/[0-9]/.test("in 1992")); 因此你可以使用下面的表达式匹配类似于`30-01-2003 15:20`这样的日期数字格式: -``` +```js let dateTime = /\d\d-\d\d-\d\d\d\d \d\d:\d\d/; console.log(dateTime.test("30-01-2003 15:20")); // → true @@ -91,7 +107,7 @@ console.log(dateTime.test("30-jan-2003 15:20")); 你可以在左方括号后添加脱字符(`^`)来排除某个字符集,即表示不匹配这组字符中的任何字符。 -``` +```js let notBinary = /[^01]/; console.log(notBinary.test("1100100010100110")); // → false @@ -105,7 +121,7 @@ console.log(notBinary.test("1100100010200110")); 在正则表达式某个元素后面添加一个加号(`+`),表示该元素至少重复一次。因此`/\d+/`可以匹配一个或多个数字字符。 -``` +```js console.log(/'\d+'/.test("'123'")); // → true console.log(/'\d+'/.test("''")); @@ -120,7 +136,7 @@ console.log(/'\d*'/.test("''")); 元素后面跟一个问号表示这部分模式“可选”,即模式可能出现 0 次或 1 次。下面的例子可以匹配`neighbour`(`u`出现1次),也可以匹配`neighbor`(`u`没有出现)。 -``` +```js let neighbor = /neighbou?r/; console.log(neighbor.test("neighbour")); // → true @@ -132,7 +148,7 @@ console.log(neighbor.test("neighbor")); 这里给出另一个版本的正则表达式,可以匹配日期、月份、小时,每个数字都可以是一位或两位数字。这种形式更易于解释。 -``` +```js let dateTime = /\d{1,2}-\d{1,2}-\d{4} \d{1,2}:\d{2}/; console.log(dateTime.test("30-1-2003 8:45")); // → true @@ -144,7 +160,7 @@ console.log(dateTime.test("30-1-2003 8:45")); 为了一次性对多个元素使用`*`或者`+`,那么你必须使用圆括号,创建一个分组。对于后面的操作符来说,圆括号里的表达式算作单个元素。 -``` +```js let cartoonCrying = /boo+(hoo+)+/i; console.log(cartoonCrying.test("Boohoooohoohooo")); // → true @@ -158,7 +174,7 @@ console.log(cartoonCrying.test("Boohoooohoohooo")); `test`方法是匹配正则表达式最简单的方法。该方法只负责判断字符串是否与某个模式匹配。正则表达式还有一个`exec`(执行,execute)方法,如果无法匹配模式则返回`null`,否则返回一个表示匹配字符串信息的对象。 -``` +```js let match = /\d+/.exec("one two 100"); console.log(match); // → ["100"] @@ -170,14 +186,14 @@ console.log(match.index); 字符串也有一个类似的match方法。 -``` +```js console.log("one two 100".match(/\d+/)); // → ["100"] ``` 若正则表达式包含使用圆括号包围的子表达式分组,与这些分组匹配的文本也会出现在数组中。第一个元素是与整个模式匹配的字符串,其后是与第一个分组匹配的部分字符串(表达式中第一次出现左圆括号的那部分),然后是第二个分组。 -``` +```js let quotedText = /'([^']*)'/; console.log(quotedText.exec("she said 'hello'")); // → ["'hello'", "hello"] @@ -185,7 +201,7 @@ console.log(quotedText.exec("she said 'hello'")); 若分组最后没有匹配任何字符串(例如在元组后加上一个问号),结果数组中与该分组对应的元素将是`undefined`。类似的,若分组匹配了多个元素,则数组中只包含最后一个匹配项。 -``` +```js console.log(/bad(ly)?/.exec("bad")); // → ["bad", undefined] console.log(/(\d)+/.exec("123")); @@ -200,14 +216,14 @@ console.log(/(\d)+/.exec("123")); JavaScript 提供了用于表示日期的标准类,我们甚至可以用其表示时间点。该类型名为`Date`。如果使用`new`创建一个`Date`对象,你会得到当前的日期和时间。 -``` +```js console.log(new Date()); // → Mon Nov 13 2017 16:19:11 GMT+0100 (CET) ``` 你也可以创建表示特定时间的对象。 -``` +```js console.log(new Date(2009, 11, 9)); // → Wed Dec 09 2009 00:00:00 GMT+0100 (CET) console.log(new Date(2009, 11, 9, 12, 59, 59, 999)); @@ -220,7 +236,7 @@ JavaScript 中约定是:使用从 0 开始的数字表示月份(因此使用 时间戳存储为 UTC 时区中 1970 年以来的毫秒数。 这遵循一个由“Unix 时间”设定的约定,该约定是在那个时候发明的。 你可以对 1970 年以前的时间使用负数。 日期对象上的`getTime`方法返回这个数字。 你可以想象它会很大。 -``` +```js console.log(new Date(2013, 11, 19).getTime()); // → 1387407600000 console.log(new Date(1387407600000)); @@ -233,7 +249,7 @@ console.log(new Date(1387407600000)); 通过在希望捕获的那部分模式字符串两边加上圆括号,我们可以从字符串中创建对应的`Date`对象。 -``` +```js function getDate(string) { let [_, day, month, year] = /(\d{1,2})-(\d{1,2})-(\d{4})/.exec(string); @@ -253,7 +269,7 @@ console.log(getDate("30-1-2003")); 另一方面,如果我们想要确保日期字符串起始结束位置在单词边界上,可以使用`\b`标记。所谓单词边界,指的是起始和结束位置都是单词字符(也就是`\w`代表的字符集合),而起始位置的前一个字符以及结束位置的后一个字符不是单词字符。 -``` +```js console.log(/cat/.test("concatenate")); // → true console.log(/\bcat\b/.test("concatenate")); @@ -268,7 +284,7 @@ console.log(/\bcat\b/.test("concatenate")); 那么我们可以编写三个正则表达式并轮流测试,但还有一种更好的方式。管道符号(`|`)表示从其左侧的模式和右侧的模式任意选择一个进行匹配。因此代码如下所示。 -``` +```js let animalCount = /\b\d+ (pig|cow|chicken)s?\b/; console.log(animalCount.test("15 pigs")); // → true @@ -284,7 +300,7 @@ console.log(animalCount.test("15 pigchickens")); 为了进行实际的匹配,引擎会像处理流程图一样处理正则表达式。 这是上例中用于家畜表达式的图表: -![](../Images/00302.jpeg) +![](img/9-1.svg) 如果我们可以找到一条从图表左侧通往图表右侧的路径,则可以说“表达式产生了匹配”。我们保存在字符串中的当前位置,每移动通过一个盒子,就验证当前位置之后的部分字符串是否与该盒子匹配。 @@ -306,21 +322,19 @@ console.log(animalCount.test("15 pigchickens")); 正则表达式`/\b([01]+b|\d+|[\da-f]h)\b/`可以匹配三种字符串:以`b`结尾的二进制数字,以`h`结尾的十六进制数字(即以 16 为进制,字母`a`到`f`表示数字 10 到 15),或者没有后缀字符的常规十进制数字。这是对应的图表。 -![]() +![](img/9-2.svg) 当匹配该表达式时,常常会发生一种情况:输入的字符串进入上方(二进制)分支的匹配过程,但输入中并不包含二进制数字。我们以匹配字符串`"103"`为例,匹配过程只有遇到字符 3 时才知道进入了错误分支。该字符串匹配我们给出的表达式,但没有匹配目前应当处于的分支。 因此匹配器执行“回溯”。进入一个分支时,匹配器会记住当前位置(在本例中,是在字符串起始,刚刚通过图中第一个表示边界的盒子),因此若当前分支无法匹配,可以回退并尝试另一条分支。对于字符串`"103"`,遇到字符 3 之后,它会开始尝试匹配十六进制数字的分支,它会再次失败,因为数字后面没有`h`。所以它尝试匹配进制数字的分支,由于这条分支可以匹配,因此匹配器最后的会返回十进制数的匹配信息。 -![](../Images/00303.jpeg) - 一旦字符串与模式完全匹配,匹配器就会停止。这意味着多个分支都可能匹配一个字符串,但匹配器最后只会使用第一条分支(按照出现在正则表达式中的出现顺序排序)。 回溯也会发生在处理重复模式运算符(比如`+`和`*`)时。如果使用`"abcxe"`匹配`/^.*x/`,`.*`部分,首先尝试匹配整个字符串,接着引擎发现匹配模式还需要一个字符`x`。由于字符串结尾没有`x`,因此`*`运算符尝试少匹配一个字符。但匹配器依然无法在`abcx`之后找到`x`字符,因此它会再次回溯,此时`*`运算符只匹配`abc`。现在匹配器发现了所需的`x`,接着报告从位置 0 到位置 4 匹配成功。 我们有可能编写需要大量回溯的正则表达式。当模式能够以许多种不同方式匹配输入的一部分时,这种问题就会出现。例如,若我们在编写匹配二进制数字的正则表达式时,一时糊涂,可能会写出诸如`/([01]+)+b/`之类的表达式。 -![](../Images/00304.jpeg) +![](img/9-3.svg) 若我们尝试匹配一些只由 0 与 1 组成的长序列,匹配器首先会不断执行内部循环,直到它发现没有数字为止。接下来匹配器注意到,这里不存在`b`,因此向前回溯一个位置,开始执行外部循环,接着再次放弃,再次尝试执行一次内部循环。该过程会尝试这两个循环的所有可能路径。这意味着每多出一个字符,其工作量就会加倍。甚至只需较少的一堆字符,就可使匹配实际上永不停息地执行下去。 @@ -328,14 +342,14 @@ console.log(animalCount.test("15 pigchickens")); 字符串有一个`replace`方法,该方法可用于将字符串中的一部分替换为另一个字符串。 -``` +```js console.log("papa".replace("p", "m")); // → mapa ``` 该方法第一个参数也可以是正则表达式,这种情况下会替换正则表达式首先匹配的部分字符串。若在正则表达式后追加`g`选项(全局,Global),该方法会替换字符串中所有匹配项,而不是只替换第一个。 -``` +```js console.log("Borobudur".replace(/[ou]/, "a")); // → Barobudur console.log("Borobudur".replace(/[ou]/g, "a")); @@ -346,7 +360,7 @@ console.log("Borobudur".replace(/[ou]/g, "a")); 如果我们在替换字符串中使用元组,就可以体现出`replace`方法的真实威力。例如,假设我们有一个规模很大的字符串,包含了人的名字,每个名字占据一行,名字格式为“姓,名”。若我们想要交换姓名,并移除中间的逗号(转变成“名,姓”这种格式),我们可以使用下面的代码: -``` +```js console.log( "Liskov, Barbara\nMcCarthy, John\nWadler, Philip" .replace(/(\w+), (\w+)/g, "$2 $1")); @@ -361,7 +375,7 @@ console.log( 这里给出一个小示例: -``` +```js let s = "the cia and fbi"; console.log(s.replace(/\b(fbi|cia)\b/g, str => str.toUpperCase())); @@ -370,7 +384,7 @@ console.log(s.replace(/\b(fbi|cia)\b/g, 这里给出另一个值得讨论的示例: -``` +```js let stock = "1 lemon, 2 cabbages, and 101 eggs"; function minusOne(match, amount, unit) { amount = Number(amount) - 1; @@ -393,7 +407,7 @@ console.log(stock.replace(/(\d+) (\w+)/g, minusOne)); 使用`replace`编写一个函数移除 JavaScript 代码中的所有注释也是可能的。这里我们尝试一下: -``` +```js function stripComments(code) { return code.replace(/\/\/.*|\/\*[^]*\*\//g, ""); } @@ -417,7 +431,7 @@ console.log(stripComments("1 /* a */+/* b */ 1")); 而这便是我们想要的情况。通过让星号尽量少地匹配字符,我们可以匹配第一个`*/`,进而匹配一个块注释,而不会匹配过多内容。 -``` +```js function stripComments(code) { return code.replace(/\/\/.*|\/\*[^]*?\*\//g, ""); } @@ -435,7 +449,7 @@ console.log(stripComments("1 /* a */+/* b */ 1")); 这里给出一个示例。 -``` +```js let name = "harry"; let text = "Harry is a suspicious character."; let regexp = new RegExp("\\b(" + name + ")\\b", "gi"); @@ -449,7 +463,7 @@ console.log(text.replace(regexp, "_$1_")); 为了能够处理这种情况,我们可以在任何有特殊含义的字符前添加反斜杠。 -``` +```js let name = "dea+hl[]rd"; let text = "This dea+hl[]rd guy is super annoying."; let escaped = name.replace(/[^\w\s]/g, "\\$&"); @@ -464,7 +478,7 @@ console.log(text.replace(regexp, "_><_")); 但还有一个`search`方法,调用该方法时需要传递一个正则表达式。类似于`indexOf`,该方法会返回首先匹配的表达式的索引,若没有找到则返回 –1。 -``` +```js console.log(" word".search(/\S/)); // → 2 console.log(" ".search(/\S/)); @@ -481,7 +495,7 @@ console.log(" ".search(/\S/)); 所谓的极少数情况,指的是当正则表达式启用了全局(`g`)或者粘性(`y`),并且使用`exec`匹配模式的时候。此外,另一个解决方案应该是向`exec`传递的额外参数,但 JavaScript 的正则表达式接口能设计得如此合理才是怪事。 -``` +```js let pattern = /y/g; pattern.lastIndex = 3; let match = pattern.exec("xyzzy"); @@ -495,7 +509,7 @@ console.log(pattern.lastIndex); 全局和粘性选项之间的区别在于,启用粘性时,仅当匹配直接从`lastIndex`开始时,搜索才会成功,而全局搜索中,它会搜索匹配可能起始的所有位置。 -``` +```js let global = /abc/g; console.log(global.exec("xyz abc")); // → ["abc"] @@ -506,7 +520,7 @@ console.log(sticky.exec("xyz abc")); 对多个`exec`调用使用共享的正则表达式值时,这些`lastIndex`属性的自动更新可能会导致问题。 你的正则表达式可能意外地在之前的调用留下的索引处开始。 -``` +```js let digit = /\d/g; console.log(digit.exec("here it is: 1")); // → ["1"] @@ -516,7 +530,7 @@ console.log(digit.exec("and now: 1")); 全局选项还有一个值得深思的效果,它会改变`match`匹配字符串的工作方式。如果调用`match`时使用了全局表达式,不像`exec`返回的数组,`match`会找出所有匹配模式的字符串,并返回一个包含所有匹配字符串的数组。 -``` +```js console.log("Banana".match(/an/g)); // → ["an", "an"] ``` @@ -527,7 +541,7 @@ console.log("Banana".match(/an/g)); 一个常见的事情是,找出字符串中所有模式的出现位置,这种情况下,我们可以在循环中使用`lastIndex`和`exec`访问匹配的对象。 -``` +```js let input = "A string with 3 numbers in it... 42 and 88."; let number = /\b(\d+)\b/g; let match; @@ -576,7 +590,7 @@ outputdir=/home/marijn/enemies/davaeorn 由于我们需要逐行处理这种格式的文件,因此预处理时最好将文件分割成一行行文本。我们使用第 6 章中的`string.split("\n")`来分割文件内容。但是一些操作系统并非使用换行符来分隔行,而是使用回车符加换行符(`"\r\n"`)。考虑到这点,我们也可以使用正则表达式作为`split`方法的参数,我们使用类似于`/\r?\n/`的正则表达式,这样可以同时支持`"\n"`和`"\r\n"`两种分隔符。 -``` +```js function parseINI(string) { // Start with an object to hold the top-level fields let currentSection = {name: null, fields: []}; @@ -618,7 +632,7 @@ city=Tessaloniki`)); 另一个问题是,默认情况下,正则表达式使用代码单元,而不是实际的字符,正如第 5 章中所讨论的那样。 这意味着由两个代码单元组成的字符表现很奇怪。 -``` +```js console.log(/\ud83c\udf4e{3}/.test("\ud83c\udf4e\ud83c\udf4e\ud83c\udf4e")); // → false console.log(/<.>/.test("<\ud83c\udf39>")); @@ -633,7 +647,7 @@ console.log(/<.>/u.test("<\ud83c\udf39>")); 尽管这是刚刚标准化的,在撰写本文时尚未得到广泛支持,但可以在正则表达式中使用`\p`(必须启用 Unicode 选项)以匹配 Unicode 标准分配了给定属性的所有字符。 -``` +```js console.log(/\p{Script=Greek}/u.test("α")); // → true console.log(/\p{Script=Arabic}/u.test("α")); @@ -720,7 +734,7 @@ Code Golf 是一种游戏,尝试尽量用最少的字符来描述特定程序 需要帮助时,请参考本章总结中的表格。使用少量测试字符串来测试每个解决方案。 -``` +```js // Fill in the regular expressions verify(/.../, @@ -770,7 +784,7 @@ function verify(regexp, yes, no) { 思考一下可以区分这两种引号用法的模式,并手动调用`replace`方法进行正确替换。 -``` +```js let text = "'I'm the cook,' he said, 'it's my job.'"; // Change this call. console.log(text.replace(/A/g, "B")); @@ -781,7 +795,7 @@ console.log(text.replace(/A/g, "B")); 编写一个表达式,只匹配 JavaScript 风格的数字。支持数字前可选的正号与负号、十进制小数点、指数计数法(`5e-3`或`1E10`,指数前也需要支持可选的符号)。也请注意小数点前或小数点后的数字也是不必要的,但数字不能只有小数点。例如`.5`和`5.`都是合法的 JavaScript 数字,但单个点则不是。 -``` +```js // Fill in this regular expression. let number = /^...$/; diff --git a/img/9-0.jpg b/img/9-0.jpg new file mode 100644 index 0000000..efb3400 Binary files /dev/null and b/img/9-0.jpg differ diff --git a/img/9-1.svg b/img/9-1.svg new file mode 100644 index 0000000..4bec21c --- /dev/null +++ b/img/9-1.svg @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + Created with Raphaël 2.1.0 + + + + " " + + + + + + + boundary + + + + boundary + + + + Group #1 + + + + "chicken" + + + + "cow" + + + + "pig" + + + + digit + + + + "s" + + + + + + diff --git a/img/9-2.svg b/img/9-2.svg new file mode 100644 index 0000000..a2fe450 --- /dev/null +++ b/img/9-2.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + Created with Snapword boundarygroup #1One of:01bOne of:digit-afhdigitword boundary diff --git a/img/9-3.svg b/img/9-3.svg new file mode 100644 index 0000000..493c9bc --- /dev/null +++ b/img/9-3.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + Created with Raphaël 2.1.0 + + + + "b" + + + + Group #1 + + + + One of: + + + + "1" + + + + "0" + + + + + +