正则表达式

Soul Lv2

这一篇我们来快速的过一下正则表达式相关的基础知识,算是在将 shell 脚本语言之前的一个基础,只追求一个基本的了解,这玩意实际的规范值得花大几十万字去讲解,但在更多的时候我们只需要了解一些基本的用法,更复杂的还是在用的时候查吧。
我们首先来看一下相关的定义:

正则表达式(Regular Expression,简称 Regex 或 RegExp)是一种用来匹配字符串中字符组合的模式。
正则表达式是一种用于模式匹配和搜索文本的工具。
正则表达式提供了一种灵活且强大的方式来查找、替换、验证和提取文本数据。
正则表达式可以应用于各种编程语言和文本处理工具中,如 JavaScript、Python、Java、Perl 等。

正则表达式的本质是一个字符串,提供了部分特殊的字符表示不同的特殊含义,例如 abcd 就是一个合法的正则表达式,它匹配的内容就是 abcd, 这些无特殊意义的字符我们称之为普通字符。
相应的,还有一些有特殊含义的字符,我们称之为元字符,常用的元字符如下:

  • *:匹配前面的模式零次或多次。
  • +:匹配前面的模式一次或多次。
  • ?:匹配前面的模式零次或一次。
  • {n}:匹配前面的模式恰好 n 次。
  • {n,}:匹配前面的模式至少 n 次。
  • {n,m}:匹配前面的模式至少 n 次且不超过 m 次。
  • [ ]:匹配括号内的任意一个字符。例如,[abc] 匹配字符 “a”、”b” 或 “c”。
  • [^ ]:匹配除了括号内的字符以外的任意一个字符。例如,[^abc] 匹配除了字符 “a”、”b” 或 “c” 以外的任意字符。
  • ^:匹配字符串的开头。
  • $:匹配字符串的结尾。
  • \b:匹配一个单词边界,也就是指单词和空格间的位置。例如, ‘er\b’ 可以匹配”never” 中的 ‘er’,但不能匹配 “verb” 中的 ‘er’。
  • \B:匹配非单词边界。’er\B’ 能匹配 “verb” 中的 ‘er’,但不能匹配 “never” 中的 ‘er’。
  • \:转义字符,用于匹配特殊字符本身。
  • .:匹配任意字符(除了换行符)。
  • |:用于指定多个模式的选择。
    是不是有点复杂?我们逐个来看。

常用元字符

首先是最常用的三个:*,+,?,以+为例子,abc+d 可以匹配下面这些文本

1
2
3
4
abccd
abccccd
abcccccccd
abcc...cd(出现无穷多个c)

+可以匹配前面的内容一次或多次,注意,至少需要再出现一次,所以 abcd 无法被匹配。
那么?的作用就很明显了,?可以匹配 0 次或 1 次。但是 * 是可以匹配任意次的,也就是说,* 实际上表示的是任意的内容,例如 *.txt 就表示任意的以 .txt 结尾的文本
当然,请注意,正则表达式是一种模式匹配,只要满足特定的模式就会被匹配到,所以请注意,上面的模式实际上可以与下面这些文本匹配

1
2
3
asd.txt
asd.txtx
asd.txtasdasda

你或许会表示这完全不符合我的预期,所以我们这里再引入两个特定的字符:^和$
,二者分别代表字符串的开头和结尾,所以如果你希望严格的匹配满足任意名称 txt 文件,我们可以写成下面的形式

1
*.txt$

类似的,如果要严格的限制从字符串第一个字符开始匹配,那么我们可以选择使用^来表示字符串的开头。
更进一步的,如果我们需要控制特定的匹配次数,就可以使用{n}或者{n, m}来表示不同的次数, 在前者中,n 表示出现的次数,在后者中 n 表示最小值,m 表示最大值,二者中任何一者在不需要时可以省略,例如{,3}表示 0 到 3 次,{3,
}表示 3 到无穷次。例如 3{2}即表示字符串 33.
在了解了上面的内容后我们自然而然的拓展出了两个需求:
首先,我们需要的不是匹配一个简单字符的重复,而是某种模式的重复,所以我们自然而然的引入了 () , () 可以使其内容被是做一个子串同时进行匹配,例如使用 (asd)+d 可以用来匹配

1
2
asdasdd
asdasdasdd

等内容。值得一提的是括号的内部实际上也可以使用一些元字符,例如可以写成 (asd+)+, 相信你可以明白其中的含义。同时括号也支持嵌套。
其次,我们需要明确的规定可以在某些特定的字符中进行匹配,比如说我希望只匹配 asd 三个字母中的任意一个,那么就可以写作 [asd], 此时可以匹配 asd 三个字母中的任意一种。如果我希望的是之匹配全部的小写英文字母呢,我们可以这样写 [a-z],如果是大写字母可以使用 [A-Z], 如果是全部的大小写字母可以使用 [A-Za-z], 其中的-表示范围,这个范围根据 ascii 表确定,类似的如果希望匹配任意的数字可以使用 [0-9]。同时,还可以使用 [^asd] 来表示除了 asd 三个字母外的任意字符.
但是你会发现 [] 提供的自由度还是不够,如果我们希望匹配的是某几个单词中的一个而不是某几个字母中的一个,我们该怎么做?可以使用 | 来表达或的关系,例如 asd|dsa 可以被用来匹配字符串 asd 或者 dsa,那么现在来做一个测试,现在需要匹配 0 到 999 的数字,该怎么写呢?

1
0|[1-9][0-9]{0,2}

最后,如果我们想要匹配某些元字符本身可以使用 \ 来表示转义。除了我们常用的 \n \r 等转义字符外,正则表达式中还有几个比较常用的转义字符
\d

  • 匹配任意数字,等价于 [0-9]

\D

  • 匹配任意非数字,等价于 [^0-9]

\w

  • 匹配任意单词字符(字母、数字、下划线),等价于 [a-zA-Z0-9_]

\W

  • 匹配任意非单词字符,等价于 [^a-zA-Z0-9_]

\s

  • 匹配任意空白字符(空格、制表符、换行符等)

\S

  • 匹配任意非空白字符

到此常用的元字符就介绍完了,我们接下来介绍修饰符

修饰符

修饰符被放在正则表达式的最后,用于控制特定的匹配行为,这一特性在不同的语言中被支持的程度不同,所以我只介绍几个比较常用的通常被大多数语言支持的修饰符。

  • i, 表示忽略大小写差异,例如 abc/i 可以匹配 abc, ABC, abC 等
  • g,表示匹配全部的匹配项,而不是在发现第一个匹配项后停止。通常在 vim, shell 等场景下使用的比较多,例如在字符串 “ababab” 中,/ab/g 会匹配所有三个 “ab”
  • m,多行模式,改变^和$的匹配模式,对于多行的字符串,使其匹配每行的开头和结尾而不是整个字符串的开头和结尾
  • s,单行模式,忽略换行符

分组和引用

接下来讲讲分组和引用的相关内容,我们上面提到了 () 可以将一长串正则表达式归结为 1 组,这些组可以在后面被我们直接使用,在部分语言中也可以直接获取这些组的内容,我们这里以 java 为例,假设我们现在有一个 URI, 我们如何来获取这个 URI 的各个部分呢?可以参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class UriSplitter {
public static void main(String[] args) {
String uri = "https://www.example.com:8080/path/to/resource";
String regex = "^([a-zA-Z]+)://([^:/]+)(:\\d+)?(/.*)?$";

Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(uri);

if (matcher.matches()) {
String protocol = matcher.group(1);
String domain = matcher.group(2);
String port = matcher.group(3);
String path = matcher.group(4);

System.out.println("Protocol: " + protocol);
System.out.println("Domain: " + domain);
System.out.println("Port: " + (port != null ? port : "default"));
System.out.println("Path: " + (path != null ? path : "/"));
} else {
System.out.println("URI does not match the pattern");
}
}
}

在上面的例子中我们使用了一个比较复杂的正则表达式

^([a-zA-Z]+)://([^:/]+)(:\\d+)?(/.*)?$

那么这个正则表达式各部分的含义是什么呢

  • ^ 表示字符串的开头
  • ([a-zA-Z]+) 第一组,用来表示协议部分,任意的英文字母出现任意次(最少一次)
  • :// 分割部分
  • ([^:/]+) 这是第二组,域名部分,任意的非 ^ 或者 / 字符出现任意次,如果
  • (:\\d+)? 第三组是对端口部分的匹配,这里我需要解释一下\d, 由于在字符串中\就表示转义,而我们此处的转义不是发生在字符串中而是发生在正则表达式中所以第一个\是对\的转义,实际相当于字符串中存在了一个无特殊含义的字符, 而在正则表达式的分析中则检测到\d, 进行转义
  • (/.*)? 这部分是对路径的匹配,不必多说
    那么最终的输出结果就是
1
2
3
4
Protocol: https
Domain: www.example.com
Port: :8080
Path: /path/to/resource

上面我们提到了我们可以在正则表达式内外引用分组,上面展现的是外部对分组的引用,实际上正则表达式内部我们也可以引用这些分组,就像下面这样:

1
(\w+) \1

上面的 \1 就表示对第一个分组的引用,上面的正则表达式可以用来匹配两个重复出现的单词,例如 hello hello, 但无法匹配 hello world
此外,上面的分组都对内容发生了捕获,即在进行匹配时缓存了组内匹配到的内容(上面的例子中是 hello),在后文中我们可以通过 \数字 直接引用这些被缓存的文本。但这种缓存很多时候是不必要的,所以我们可以通过非捕获组 (?:regex) 来避免这种捕获,例如

1
(?:\w) (?:\w)

这种分组是无法通过上面提到的 \number 来引用的

断言

接下来讲一讲断言,断言不会发生真正的匹配,而是判断当前字符串前或后是否满足某种情况,断言包括四种,先行断言,负向先行断言,后行断言,负向后行断言,我们首先看第一种
先行断言的格式为 (?=pattern), 要求判断当前位置之后是否满足特定的条件,例如

1
[a-z](?=\d)

上面的表达式可以匹配后面跟数字的单个字符,例如 a1 中的 a。
负向先行断言则表示当前位置之后是否不满足特定的条件,格式为 (?!pattern), 例如

1
[a-z](?!\d)

可以用来匹配后面不跟数字的单个字符,例如 a/ 中的a
然后是后行断言,用来判断当前字符串前是否满足特定的规则,格式为 (?<=pattern) 例如

1
(?<=[a-z])\d

可以用来匹配前面是字母的数字,例如 a1 中的 1
负向后行断言的格式为 (?<!pattern),可以被用来匹配当前字符串前是否不满足特定条件,例如

1
(?<![a-z])\d

可以被用来匹配 \1 中的 1.

贪婪匹配与懒惰匹配

你是否注意到了一问题,对于下面的表达式

1
<\w+>

如果要求匹配

1
<a>test<\a>

那么最终的结果是 <a> 还是整个字符串呢,最终的结果必然是整个字符串,因为正则匹配默认遵循贪婪匹配的原则,即每次匹配都尽可能匹配更多的字符,我们也可以选择懒惰匹配,即每次匹配都尽可能匹配最少的字符。通在 *``+ 或 ? 限定符之后放置 ?,可以将表达式从”贪婪”表达式转换为”非贪婪”表达式或者最小匹配。
例如

1
<\w+?>

就会匹配 <a>

结语

好吧,正则就讲到这里吧,不得不指出的是我上面的内容是选取了不同语言的最大公约数,由于实现不同,不同语言的正则引擎互相独立,各自有一些独特的特性,但理论上来说上面提到的特性应该是全部支持的,不过即使这样也不一定保证可以直接用,例如 javascript 或这 shell 中正则表达式通常以 / 开头表示表达式的开始,在部分强类型语言中 \ 本身有转义的含义所以需要使用 \\ 来表示正则表达式的 \. 诸如此类特性还是需要根据语言的特性而决定。

  • 标题: 正则表达式
  • 作者: Soul
  • 创建于 : 2025-07-30 07:01:20
  • 更新于 : 2025-07-29 18:47:42
  • 链接: https://soulmate.org.cn/posts/2f57a694/
  • 版权声明: 本文章采用 CC BY-NC 4.0 进行许可。