9. 正则表达式

时间:2024-10-02 13:41:33

        编程工具和技术是以一种混乱、进化的方式生存和传播的。获胜的并不总是最好或最杰出的工具,而是那些在合适的利基市场中发挥足够好的功能,或者恰好与另一项成功的技术相结合的工具。

        在本章中,我将讨论这样一种工具--正则表达式。正则表达式是一种描述字符串数据模式的方法。正则表达式是一种描述字符串数据中模式的方法,它是一种独立的小语言,是 JavaScript 以及其他许多语言和系统的一部分。

        正则表达式既非常笨拙,又非常有用。它们的语法令人费解,JavaScript 为它们提供的编程接口也很笨拙。但它们却是检查和处理字符串的强大工具。正确理解正则表达式将使你成为一名更高效的程序员。

创建正则表达式

        正则表达式是一种对象类型。它既可以用 RegExp 构造函数构造,也可以用正斜杠 (/) 字符将模式括起来写成文字值。

这两个正则表达式对象表示相同的模式:一个 a 字符后接一个 b 字符后接一个 c 字符。

在使用 RegExp 构造函数时,模式被写成普通字符串,因此通常的规则适用于反斜线。

第二种符号,即模式出现在斜线字符之间,对反斜线的处理略有不同。首先,由于正斜线会结束模式,因此我们需要在希望成为模式一部分的正斜线前加上反斜线。此外,不属于特殊字符代码(如 \n)的反斜线将被保留,而不是像在字符串中那样被忽略,从而改变模式的含义。某些字符(如?和+)在正则表达式中具有特殊含义,如果要表示字符本身,则必须在其前面加上反斜线。

测试匹配度

        正则表达式对象有许多方法。最简单的方法是测试。如果你向它传递一个字符串,它将返回一个布尔值,告诉你该字符串是否包含表达式中的匹配模式。

        由非特殊字符组成的正则表达式只表示该字符序列。如果 abc 出现在我们要测试的字符串的任何位置(而不仅仅是开头),test 将返回 true。

字符集

        调用 indexOf 也可以查出一个字符串是否包含 abc。正则表达式非常有用,因为它允许我们描述更复杂的模式。

        比方说,我们想匹配任何数字。在正则表达式中,将一组字符放在方括号中,表达式的这一部分就会匹配方括号中的任何字符。

下面两个表达式都匹配所有包含数字的字符串:

        在方括号内,两个字符之间的连字符 (-) 可用来表示一系列字符,其排序由字符的 Unicode 编号决定。在这种排序中,字符 0 至 9 紧挨在一起(代码 48 至 57),因此 [0-9] 涵盖了所有字符,并可与任何数字匹配。

一些常见字符组有自己的内置快捷键。数字就是其中之一: \d "与[0-9]的意思相同。

\d

只有包含数字字符,为true;否则为:false

\w

只有包含字母数字字符("单词字符),为true;否则为:false

\s

只有包含空白字符(空格、制表符、换行符及类似字符),为:true;否则为:false

\D

只有数字,为:false;否则为:true

\W

只有字母和数字,为:false;否则为:true

\S

只有非空格、制表符、换行符及类似字符,为:false,否则为:true

.

只有换行符(\t),为false;否则为:true

你可以用下面的表达式来匹配日期和时间格式,如 01-30-2003 15:20:

        这个正则表达式看起来非常糟糕,不是吗?其中一半是反斜线,产生了背景噪音,很难发现实际表达的模式。稍后我们将看到这个表达式的一个稍加改进的版本。

        这些反斜线代码也可以用在方括号内。例如,[\d.] 表示任何数字或句点字符。方括号内的句号本身就失去了特殊含义。其他特殊字符也是如此,如加号 (+)。

要反转一组字符,即表示要匹配除这组字符以外的任何字符,可以在开头的括号后写入一个护理符 (^)。

不止0和1。

国际字符

        由于 JavaScript 最初的实现比较简单,而且这种简单的实现方式后来被固定为标准行为,因此 JavaScript 的正则表达式对于英语中没有出现的字符非常迟钝。例如,在 JavaScript 的正则表达式中,“单词字符 ”仅指拉丁字母表中的 26 个字符(大写或小写)之一、十进制数字以及下划线字符。像 é 或 β 这样的字符肯定是单词字符,但它们不会匹配 \w(而会匹配大写字母 \W,即非单词类别)。

        一个奇怪的历史巧合是,\s(空白)不存在这个问题,它可以匹配所有 Unicode 标准认为是空白的字符,包括非破折空格和蒙古语元音分隔符。

        在正则表达式中使用 \p 可以匹配 Unicode 标准指定了特定属性的所有字符。这样,我们就能以一种更加国际化的方式匹配字母等字符。不过,同样是出于与原始语言标准兼容的考虑,只有在正则表达式后面加上 u 字符(Unicode 标准)时,这些字符才能被识别。

\p{L}

任何字母字符

\p{N}

任何数字字符

\p{P}

任何标点符号

\P{L}

任何非字母(大写 P 是反过来的)

\p{Script=Hangul}

指定脚本中的任何字符(参见第 5 章)

        使用 \w 进行文本处理可能需要处理非英语文本(甚至是包含 “cliché ”等借词的英语文本),但这是一种负担,因为它不会将 “é ”等字符视为字母。虽然 \p 属性组比较啰嗦,但它们更强大。

        另一方面,如果要对数字进行匹配以对其进行操作,通常需要对数字进行匹配,因为将任意数字字符转换为 JavaScript 数字并不是 Number 这样的函数所能完成的。

重复图案的某些部分

        我们现在知道了如何匹配一个数字。如果我们想匹配一个整数--一个或多个数字的序列,该怎么办呢?

        在正则表达式中,如果在某个元素后加上加号 (+),则表示该元素可能重复出现多次。因此,/\d+/ 可以匹配一个或多个数字字符。( 匹配 1 次或多次 )

        星号 (*) 具有类似的含义,但也允许模式匹配零次。带有星号的内容永远不会阻止模式匹配--如果找不到合适的文本匹配,它只会匹配零次。( 匹配 0 次或多次 )

        问号(?)表示模式中的某个部分是可选的,这意味着它可能出现零次,也可能出现一次。在下面的示例中,u 字符是允许出现的,但当它缺失时,模式也会匹配:( 匹配 0 次或 1 次 )

        要表示一个模式应出现的精确次数,请使用大括号。例如,在一个元素后面加上{4},就要求该元素必须出现四次。也可以用这种方法指定一个范围: {2,4}表示元素必须至少出现两次,最多出现四次。

下面是日期和时间模式的另一个版本,它允许使用一位数和两位数的日、月和小时。它也更容易解读。

使用大括号时,也可以省略逗号后的数字,指定开放式范围。例如,{5,} 表示五次或五次以上。

分组子表达式

        要同时对多个元素使用 * 或 + 等运算符,必须使用括号。就后面的操作符而言,正则表达式中被括号括起来的部分算作一个元素。

第一个和第二个 + 字符分别只适用于 boo 和 hoo 中的第二个 o。第三个 + 字符适用于整组 (hoo+),可匹配一个或多个类似的序列。

示例中表达式末尾的 i 使该正则表达式不区分大小写,可以匹配输入字符串中的大写 B,即使该模式本身是全小写的。

匹配和分组

        测试方法是匹配正则表达式的最简单方法。它只会告诉你是否匹配,而不会告诉你其他信息。正则表达式还有一个 exec(执行)方法,如果没有找到匹配,该方法将返回空值,否则将返回一个包含匹配信息的对象。

        exec 返回的对象有一个索引属性,它告诉我们成功匹配的字符串从哪里开始。除此之外,该对象看起来像(实际上也是)一个字符串数组,其第一个元素是匹配成功的字符串。在前面的示例中,这就是我们要查找的数字序列。

字符串值也有类似的匹配方法。

        当正则表达式包含用括号分组的子表达式时,与这些分组匹配的文本也会显示在数组中。整个匹配总是第一个元素。下一个元素是与第一组(表达式中开头括号在前的那一组)匹配的部分,然后是第二组,依此类推。

console.log(/(he)(ll)(o)/.exec("hello"));
输出:
[
  'hello',
  'he',
  'll',
  'o',
  index: 0,
  input: 'hello',
  groups: undefined
]

这句话的意思就是: 数组的第一个元素总是整个匹配的结果, 后续元素依次是每个分组匹配的内容。

        如果一个分组最终没有被匹配(例如,后面跟了一个问号),那么它在输出数组中的位置将保持未定义。当一个分组被多次匹配时(例如,后面有一个 +),只有最后一次匹配的分组才会出现在数组中。

console.log(/(\d)+/.exec('123'));//1-N
console.log(/(\d)*/.exec('123'));//0-N
console.log(/(\d)?/.exec('123'));//0-1
//输出
[ '123', '3', index: 0, input: '123', groups: undefined ]//返回最后一个
[ '123', '3', index: 0, input: '123', groups: undefined ]//返回最后一个
[ '1', '1', index: 0, input: '123', groups: undefined ]//返回第一个

如果只想使用括号进行分组,而不想让它们显示在匹配数组中,可以在开头的括号后加上?

(?:...):表示一个非捕获组,它不会在结果中产生单独的返回值。

组对于提取字符串的部分内容非常有用。如果我们不仅要验证字符串中是否包含日期,还要提取日期并构建一个对象来表示日期,那么我们可以在数字模式周围加上括号,然后直接从执行结果中提取日期。

不过,我们首先要绕个弯,讨论一下 JavaScript 中表示日期和时间值的内置方法。

日期类

        JavaScript 有一个标准的 Date 类,用于表示日期或时间点。只要使用 new 创建一个日期对象,就能得到当前的日期和时间。

您还可以为特定时间创建对象。

JavaScript 使用的惯例是,月号从 0 开始(因此 12 月是 11),而日号从 1 开始。这种做法既混乱又愚蠢。要小心。

最后四个参数(小时、分钟、秒和毫秒)是可选的,如果没有给出,则视为零。

时间戳以 UTC 时区自 1970 年开始的毫秒数存储。这遵循了 “Unix 时间 ”的约定,“Unix 时间 ”是在那个时候发明的。1970 年之前的时间可以使用负数。日期对象上的 getTime 方法会返回这个数字。可以想象,这个数字很大。

如果给 Date 构造函数一个参数,该参数将被视为毫秒计数。您可以通过创建一个新的 Date 对象并调用 getTime 或调用 Date.now 函数来获取当前毫秒数。

Date 对象提供了 getFullYear、getMonth、getDate、getHours、getMinutes 和 getSeconds 等方法来提取其组成部分。除了 getFullYear 方法,还有 getYear 方法,它提供的是年份减去 1900(如 98 或 125)后的值,基本没有什么用处。

在表达式中我们感兴趣的部分加上括号,就可以从字符串中创建日期对象了。

下划线 (_) 绑定将被忽略,仅用于跳过 exec 返回的数组中的完整匹配元素。

边界和前瞻

        不幸的是,getDate 也可以从字符串 “100-1-30000 ”中提取日期。匹配可能发生在字符串的任何地方,因此在这种情况下,它只会从第二个字符开始,到倒数第二个字符结束。

        如果我们想强制要求匹配必须跨越整个字符串,可以添加标记符 ^ 和 $。 粗体符匹配输入字符串的起点,而美元符号匹配输入字符串的终点。因此,/^\d+$/ 会匹配完全由一个或多个数字组成的字符串,/^!/ 会匹配任何以感叹号开头的字符串,而 /x^/ 则不会匹配任何字符串(字符串开头前不能有 x)。

        还有一种 \b 标记用于匹配单词边界,即一边是单词字符,另一边是非单词字符的位置。遗憾的是,这些标记使用了和\w 一样简单的单词字符概念,因此不是很可靠。

请注意,这些边界标记并不匹配任何实际字符。它们只是强制执行给定条件在模式中出现的位置成立。

        前瞻测试也有类似的功能。它们提供了一个模式,如果输入与该模式不匹配,就会导致匹配失败,但实际上并不向前移动匹配位置。它们写在 (?= 和) 之间。

来自GPT的解释

  • 边界符号
  • ^ 表示字符串的开始,$ 表示字符串的结束。因此,正则表达式 /^\d+$/ 意思是“匹配整个字符串必须完全由一个或多个数字组成”。例如,“123”会匹配,但“100-1-30000”不会,因为后者不是完全由数字构成。
  • \b 是单词边界,表示单词字符(字母、数字或下划线)和非单词字符之间的边界。比如,\bword\b 只会匹配“word”,而不会匹配“sword”或“wording”。
  • 前瞻(lookahead)
  • 前瞻用于检查某个模式是否出现在当前匹配位置之后,但并不实际消耗字符。例如,(?=pattern) 会检查后面是否有“pattern”,但不会将其包含在匹配结果中。比如,正则表达式 \d(?=abc) 会匹配后面跟着“abc”的数字,但匹配结果中不会包括“abc”。

第一个示例中的 e 是匹配所必需的,但不是匹配字符串的一部分。(?! ) 表示否定的前瞻性,第二个示例只匹配后面没有空格的字符。

选择模式

        比方说,我们想知道一段文字中是否不仅包含一个数字,而且数字后面还包含猪、牛、鸡中的一个单词或它们的复数形式。

        我们可以编写三个正则表达式,然后依次测试它们,但有一个更好的方法。管道符 (|) 表示在左边的模式和右边的模式之间进行选择。我们可以在这样的表达式中使用它:

括号可用于限制管道运算符所适用的模式部分,您还可以将多个此类运算符放在一起,在两个以上的备选方案中进行选择。

匹配机制

        从概念上讲,当您使用 exec 或 test 时,正则表达式引擎会在字符串中寻找匹配项,首先尝试从字符串的开头开始匹配表达式,然后从第二个字符开始,依此类推,直到找到匹配项或到达字符串的末尾。它要么会返回能找到的第一个匹配项,要么根本找不到任何匹配项。

为了进行实际匹配,引擎会像处理流程图一样处理正则表达式。这是上例中家畜表达式的流程图:

        如果我们能找到一条从图的左边到右边的路径,那么我们的表达式就匹配了。我们在字符串中保留一个当前位置,每次移动经过一个方框时,我们都要验证当前位置后的字符串部分是否与该方框匹配。

回溯

        正则表达式 /^([01]+b|[\da-f]+h|\d+)$/ 可以匹配后跟 b 的二进制数、后跟 h 的十六进制数(即 16 进制,字母 a 至 f 代表数字 10 至 15),或者不带后缀字符的十进制数。这就是相应的图表:

        在匹配这个表达式时,即使输入的内容实际上并不包含二进制数,也往往会输入最上面的(二进制)分支。例如,在匹配字符串 “103 ”时,只有在匹配到 3 时才会发现我们进入了错误的分支。该字符串确实与表达式匹配,只是与我们当前所在的分支不匹配。

        因此,匹配器会回溯。当进入一个分支时,它会记住当前位置(在本例中,位于字符串的起点,刚刚经过图中的第一个边界框),这样如果当前分支不成功,它就可以返回并尝试另一个分支。对于字符串 “103”,在遇到 3 字符后,匹配器开始尝试十六进制数字分支,但由于数字后面没有 h,所以再次失败。然后它又尝试十进制数分支。这个分支符合要求,最终报告了一个匹配结果。

        一旦发现完全匹配,匹配器就会停止。这意味着,如果有多个分支可能匹配一个字符串,则只使用第一个分支(按分支在正则表达式中出现的位置排序)。

        回溯也会发生在 + 和 * 等重复运算符上。如果将 /^.*x/ 与 “abcxe ”匹配,.* 部分将首先尝试使用整个字符串。然后,引擎会意识到它需要一个 x 来匹配该模式。由于字符串末尾没有 x,星形运算符会尝试匹配少一个字符。但是,匹配器在 abcx 之后也没有找到 x,因此它再次回溯,将星形运算符匹配到 abc。现在,它在需要的地方找到了一个 x,并报告从 0 到 4 位置的匹配成功。

        正则表达式可以进行大量的回溯。当一个模式能以多种不同的方式匹配一段输入时,就会出现这个问题。例如,如果我们在编写二进制正则表达式时感到困惑,可能会不小心写成 /([01]+)+b/ 这样的表达式。

        如果试图匹配一长串没有尾部 b 字符的 0 和 1,匹配器会先通过内循环,直到用完所有数字。然后,它注意到没有 b,于是回退一个位置,通过一次外循环,然后再次放弃,试图再次从内循环中回退。它将继续尝试通过这两个循环的所有可能路径。这意味着每增加一个字符,工作量就会增加一倍。即使只有几十个字符,匹配也会耗费很长时间。

替换法

字符串值有一个 replace 方法,可用于用另一个字符串替换部分字符串。

第一个参数也可以是正则表达式,在这种情况下,正则表达式的第一个匹配项将被替换。如果在正则表达式后添加 g 选项(表示全局),字符串中的所有匹配都会被替换,而不仅仅是第一个匹配。

将正则表达式与替换结合使用的真正优势在于,我们可以在替换字符串中引用匹配的组。例如,我们有一个包含人名的大字符串,每行一个人名,格式为 Lastname, Firstname。如果我们想交换这些名字并去掉逗号,以获得 Firstname Lastname 格式,我们可以使用下面的代码:

替换字符串中的 $1 和 $2 指的是模式中的括号组。$1替换为与第一组匹配的文本,$2替换为与第二组匹配的文本,依此类推,直至 $9。

替换的第二个参数可以是函数,而不是字符串。每次替换时,都会调用函数,并将匹配组(以及整个匹配)作为参数,然后将函数的返回值插入新字符串。

这里有一个例子:

这段代码接收一个字符串,查找所有出现的数字后跟字母数字单词,然后返回一个字符串,该字符串中的每一个数字都少一个。

(\d+) 组最终作为函数的金额参数,而 (\p{L}+) 组则与单位绑定。函数会将 amount 转换为数字--这总是有效的,因为它之前匹配了 \d+,并在只剩下一个或零的情况下做了一些调整。

贪婪

        我们可以使用 replace 来编写一个函数,删除 JavaScript 代码中的所有注释。下面是第一次尝试:

        操作符 | 之前的部分匹配两个斜线字符,后面是任意数量的非新行字符。多行注释的部分涉及的内容更多。我们使用 [^](不在空字符集中的任何字符)来匹配任何字符。这里我们不能只使用句号,因为块注释可以在新行中继续,而句号字符不能匹配换行符。

但最后一行的输出似乎出了问题。为什么呢?

        正如我在 “回溯 ”一节中所述,表达式的 [^]* 部分将首先尽可能多地匹配。如果这导致模式的下一部分失败,匹配器就会向后移动一个字符,然后从那里重新开始匹配。在示例中,匹配器首先尝试匹配整个字符串的其余部分,然后从这里开始向后移动。在后退 4 个字符后,它会找到 */ 的出现并进行匹配。这并不是我们想要的结果--我们的目的是匹配单个注释,而不是一直匹配到代码末尾,找到最后一个注释块的末尾。

        由于这种行为,我们说重复运算符(+、*、?和 {})是贪婪的,这意味着它们会尽可能多地匹配,并从那里开始回溯。如果在它们后面加上问号(+?, *?, ??, {}?),它们就会变得不贪心,开始时尽可能少匹配,只有当剩余的模式不适合较小的匹配时,才会匹配更多。

        在这种情况下,这正是我们想要的。通过让星号匹配到 */ 的最小字符段,我们只消耗了一个注释块,而没有更多。

正则表达式程序中的许多错误都可以追溯到无意中使用了贪婪运算符,而非贪婪运算符的效果会更好。在使用重复运算符时,应首选非贪婪变体。

动态创建 RegExp 对象

        在某些情况下,您在编写代码时可能不知道需要匹配的确切模式。例如,您想测试一段文本中的用户姓名。您可以创建一个字符串,然后使用 RegExp 构造函数。

在创建字符串中的\s 部分时,我们必须使用两个反斜线,因为我们是将它们写入普通字符串,而不是斜线包围的正则表达式。RegExp 构造函数的第二个参数包含正则表达式的选项,在本例中,“gi ”表示全局和大小写不敏感。

但如果用户的名字是 “dea+hl[]rd ”呢?这样就会产生一个毫无意义的正则表达式,实际上无法匹配用户的姓名。

为了解决这个问题,我们可以在任何具有特殊含义的字符前添加反斜线。

搜索方法

        虽然字符串的 indexOf 方法不能使用正则表达式调用,但另一种方法 search 可以使用正则表达式。和 indexOf 一样,它也会返回找到正则表达式的第一个索引,如果没有找到,则返回-1。

遗憾的是,我们无法指出匹配应从给定的偏移量开始(就像我们可以使用 indexOf 的第二个参数一样),而这通常是有用的。

lastIndex 属性

        exec方法同样没有提供从字符串中给定位置开始搜索的便捷方法。但它提供了一种不方便的方法。

        正则表达式对象具有属性。其中一个属性是 source,它包含创建表达式的字符串。另一个属性是 lastIndex,在某些有限的情况下,它控制着下一个匹配将从哪里开始。

        在这些情况下,正则表达式必须启用全局(g)或粘性(y)选项,并且必须通过执行方法进行匹配。同样,如果能允许向 exec 传递一个额外的参数,就可以减少混乱,但混乱是 JavaScript 正则表达式接口的一个基本特征。

        如果匹配成功,exec 调用会自动更新 lastIndex 属性,使其指向匹配结果之后(+1)。如果没有找到匹配结果,lastIndex 将被设回 0,这也是它在新构建的正则表达式对象中的值。

        全局选项和粘性选项的区别在于,启用粘性选项后,只有直接从 lastIndex 开始,匹配才会成功,而启用全局选项后,匹配会提前搜索可以开始匹配的位置。(lastIndex表示开始搜索的位置)

        在多个执行调用中使用共享的正则表达式值时,这些对 lastIndex 属性的自动更新可能会导致问题。你的正则表达式可能会意外地从上一次调用留下的索引开始。

        全局选项的另一个有趣作用是改变了字符串匹配方法的工作方式。当使用全局表达式调用时,match 不会返回一个类似于 exec 所返回的数组,而是会查找字符串中所有与模式匹配的字符串,并返回一个包含匹配字符串的数组。

因此,请谨慎使用全局正则表达式。在需要使用全局正则表达式的情况下--调用替换和需要显式使用 lastIndex 的地方--通常需要使用全局正则表达式。

常见的做法是在字符串中查找正则表达式的所有匹配项。我们可以使用 matchAll 方法来实现这一目的。

此方法返回一个匹配数组。为 matchAll 提供的正则表达式必须启用 g。

解析 INI 文件

        在本章的最后,我们将讨论一个需要使用正则表达式的问题。想象一下,我们正在编写一个程序,从互联网上自动收集敌人的信息。(在这里,我们不会实际编写该程序,而只是编写读取配置文件的部分。对不起)。配置文件看起来是这样的

这种格式是一种广泛使用的文件格式,通常称为 INI 文件,其具体规则如下:

  • 空行和以分号开头的行将被忽略。
  • 以[]包装的行开始一个新的部分。
  • 包含字母数字标识符和 = 字符的行在当前部分添加一个设置。
  • 其他均无效。

        我们的任务是将这样一个字符串转换成一个对象,该对象的属性包含写在第一个章节标题之前的设置字符串和章节的子对象,这些子对象包含章节的设置。

        由于格式必须逐行处理,因此将文件分割成不同行是一个好的开始。我们在第 4 章中看到了分割方法。不过,有些操作系统不仅使用换行符来分行,还使用换行后的回车符(“\r\n”)。鉴于 split 方法也允许使用正则表达式作为其参数,我们可以使用类似 /\r?\n/ 这样的正则表达式来分割,这样就可以在行间同时使用“\n ”和“\r\n”。

        代码会遍历文件的每一行,并建立一个对象。顶部的属性直接存储在该对象中,而章节中的属性则存储在单独的章节对象中。章节绑定指向当前章节的对象。

        有两种重要的行--节标题行或属性行。当一行是常规属性时,它被存储在当前节中。如果是节标题,则会创建一个新的节对象,并将节设置为指向它。

        注意反复使用 ^ 和 $ 是为了确保表达式匹配整行,而不仅仅是其中的一部分。如果不使用这两种表达式,代码在大多数情况下都能正常工作,但在某些输入情况下却会出现奇怪的行为,这可能是一个难以追踪的错误。

        if (match = string.match(...))模式利用了赋值表达式 (=) 的值就是赋值这一事实。我们通常无法确定对 match 的调用是否会成功,因此只能在 if 语句中测试这一点,才能访问结果对象。为了不破坏令人愉快的 else if 表单链,我们将匹配的结果赋值给一个绑定,并立即使用该赋值作为 if 语句的测试。

        如果一行不是章节标题或属性,函数会使用表达式 /^\s*(;|$)/ 检查它是注释还是空行,以匹配只包含空白或空白后有分号(使行的其余部分成为注释)的行。如果一行不符合任何预期形式,函数就会抛出异常。

代码单位和字符

        JavaScript 正则表达式中的另一个标准化设计错误是,在默认情况下,. 或 ? 等运算符的作用对象是代码单元(如第 5 章所述),而不是实际字符。这意味着由两个代码单元组成的字符会表现得很奇怪。

问题在于第一行中的“???? ”被视为两个代码单元,而{3}只应用于第二个单元。同样,圆点匹配的是一个代码单 元,而不是组成玫瑰表情符号的两个代码单元。

您必须在正则表达式中添加 u(Unicode)选项,以使其正确处理此类字符。

总结

正则表达式是表示字符串模式的对象。它们使用自己的语言来表达这些模式。

/abc/ 字符序列

/[abc]/ 一组字符中的任何字符

/[^abc]/ 不在字符集中的任何字符

/[0-9]/ 字符范围内的任何字符

/x+/ 模式 x 的一次或多次出现

/x+?/ 一次或多次出现,无冗余

/x*/ 零次或多次出现

/x?/ 零次或一次出现

/x{2,4}/ 出现 2 到 4 次

/(abc)/ 一组

/a|b|c/ 几种模式中的任意一种

/d/ 任何数字字符

/w/ 字母数字字符("单词字符)

/s/ 任何空白字符

/./ 除换行符外的任何字符

/\ 任何字母字符

/^/ 输入开始

/$/ 输入结束

/(?=a)/ 前瞻性测试

        正则表达式有一个 test 方法,用于测试给定字符串是否与之匹配。正则表达式还有一个 exec 方法,当找到匹配时,它会返回一个包含所有匹配组的数组。该数组有一个索引属性,表示匹配从哪里开始。

        字符串有一个 match 方法,用于与正则表达式匹配,还有一个 search 方法,用于搜索正则表达式,只返回匹配的起始位置。它们的 replace 方法可以用替换字符串或函数替换与模式匹配的字符串。

        正则表达式可以有选项,选项写在斜线后。i 选项使匹配不区分大小写。g 选项使表达式成为全局表达式,除其他外,这将导致 replace 方法替换所有实例,而不仅仅是第一个实例。y 选项使表达式具有粘性,这意味着在查找匹配时,它不会向前搜索和跳过字符串的一部分。u 选项开启了 Unicode 模式,启用了 \p 语法,并修复了处理占用两个代码单元的字符时出现的一些问题。

        正则表达式是一种锋利的工具,但使用起来并不方便。正则表达式极大地简化了某些任务,但在应用于复杂问题时,很快就会变得难以驾驭。要知道如何使用正则表达式,部分工作就是要克制自己的冲动,不要把正则表达式无法表达的东西硬塞进正则表达式中。

练习

        在进行这些练习的过程中,你几乎不可避免地会对某些正则表达式莫名其妙的行为感到困惑和沮丧。有时,将正则表达式输入到 debuggex.com 等在线工具中,看看它的可视化是否符合你的意图,并尝试它对各种输入字符串的响应方式,会对你有所帮助。

Regexp 高尔夫

        代码高尔夫是指用尽可能少的字符来表达特定程序的游戏。同样,“正则表达式高尔夫 ”也是指编写尽可能少的正则表达式来匹配给定的模式,而且只能匹配该模式。

        请为下列每个项目编写一个正则表达式,以测试给定模式是否出现在字符串中。正则表达式应只匹配包含该模式的字符串。当您的表达式有效时,看看是否可以将其变得更小。

规则要求:

  1. car 和 cat
  2. pop 和 prop
  3. ferret,ferry和ferrari
  4. 任何以 ious 结尾的单词
  5. 空白字符,后面跟句号、逗号、冒号或分号
  6. 超过6个字母的单词
  7. 没有字母 e(或 E)的单词

请参考本章摘要中的表格以获得帮助。用几个测试字符串测试每个解决方案。

// Fill in the regular expressions

verify(/.../,
       ["my car", "bad cats"],
       ["camper", "high art"]);

verify(/.../,
       ["pop culture", "mad props"],
       ["plop", "prrrop"]);

verify(/.../,
       ["ferret", "ferry", "ferrari"],
       ["ferrum", "transfer A"]);

verify(/.../,
       ["how delicious", "spacious room"],
       ["ruinous", "consciousness"]);

verify(/.../,
       ["bad punctuation ."],
       ["escape the period"]);

verify(/.../,
       ["Siebentausenddreihundertzweiundzwanzig"],
       ["no", "three small words"]);

verify(/.../,
       ["red platypus", "wobbling nest"],
       ["earth bed", "bedrøvet abe", "BEET"]);


function verify(regexp, yes, no) {
  // Ignore unfinished exercises
  if (regexp.source == "...") return;
  for (let str of yes) if (!regexp.test(str)) {
    console.log(`Failure to match '${str}'`);
  }
  for (let str of no) if (regexp.test(str)) {
    console.log(`Unexpected match for '${str}'`);
  }
}

答案:

verify(/(car|cat)/, ["my car", "bad cats"], ["camper", "hight art"]);
verify(/(pop|prop)/,["pop culture", "mad props"],["plop", "prrrop"]);
verify(/(ferret|ferry|ferrari)/,["ferret", "ferry", "ferrari"],["ferrum", "transfer A"]);
verify(/\b\w*ious\b/,["how delicious", "spacious room"],["ruinous", "consciousness"]);
verify(/\b\s(.|;|:|,)\b/, ["bad punctuation ."], ["escape the period"]);
verify(/\b\w{6,}\b/, ["Siebentausenddreihundertzweiundzwanzig"], ["no", "three small words"]);
verify(/\b(?!\w*e)(?!\w*E)\w+\b/, ["red platypus", "wobbling nest"], ["earth bed", "bedrøvet abe", "BEET"]);