第 27 节 · 第四部分 进阶特性

正则表达式

正则表达式用一串模式描述要找的文字,Regex 这个类按模式去匹配、替换、切分。

时长 22:32授课计划第 10 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1正则表达式是什么

正则表达式是一种描述文本模式的搜索公式,像更精确的通配符,用来找出、验证或替换字符串里的内容,比如判断邮箱合不合法、从文字里提取日期。C# 里由 System.Text.RegularExpressions 命名空间提供。

视频里用扫描台演示:纸带是字符串,读头是模式,规则全对上的那段就是一个匹配。真实引擎里没有读头在滑,它在每个位置按规则试,试不通就退回来换一种试法。

模式由九类构建块组成,下面逐类看。例子来自一个测试程序:M(编号, 模式, 输入…) 找出所有匹配,R(编号, 模式, 替换串, 输入) 做一次替换,结果写在注释里。

2字符转义

反斜杠让普通字母变出特殊含义(\n 是换行),或让特殊字符变回原样(\. 是真的点号)。C# 字符串里反斜杠本身也是转义字符,直接写 "\d" 会报错:

C# · 第 7 行
7Console.WriteLine("\d");// 普通字符串里,\d 不是 C# 认识的转义
编译报错
Program.cs(7,26): error CS1009: 无法识别的转义序列
C# · 第 7-8 行
7Console.WriteLine("\\d");// 改法一:两个反斜杠,打印 \d
8Console.WriteLine(@"\d");// 改法二(推荐):@ 逐字字符串,反斜杠原样保留,打印 \d

转义表里还有 \t(Tab)、\r(回车)、\n(换行)等控制字符,Windows 的换行通常是 \r\n 两个字符;\040、\x20、 是用编码写的同一个空格。转义表最后一行的模式方括号没闭合,要去掉多出的半截才能用:

C# · 第 66-67 行
66M("E13a", @"\d+[\+-x\*]\d+\d+[\+-x\*\d+", "(2+2) * 3*9");// 方括号没闭合:抛出 RegexParseException(正则解析异常)
67M("E13", @"\d+[\+-x\*]\d+", "(2+2) * 3*9");// 去掉多出的半截:找到 2+2 和 3*9

3字符类

方括号里任意一个字符,开头加 ^ 表示“不在里面的”,减号表示范围;默认区分大小写:

C# · 第 73 行
73M("C03", @"[b-d]irds", "Birds Cirds Dirds", "birds cirds dirds");// b-d 是 b、c、d:大写的 0 个,小写的 3 个

. 匹配除换行外任意一个字符,\p{Lu} 按 Unicode(给世界各国文字统一编号的标准)类别匹配大写字母。常用简写 \w(单词字符)、\s(空白)、\d(数字),大写的是反义。\w 不只是 [a-zA-Z0-9_],汉字也算;\d 不只是 [0-9],全角数字也算:

C# · 第 77 行
77M("C07", @"\w", "Room#1", "正则_1");// 正则_1 四个全匹配:汉字也算单词字符
C# · 第 81-82 行
81M("C11", @"\d", "4 = IV", "45");// \d 认全角数字:45 找到 2 个
82M("C11e", @"[0-9]", "45");// [0-9] 不认:0 个

4定位点

定位点不匹配字符,只匹配一个位置,宽度是零:

C# · 第 85-86 行
85M("A01", @"^\d{3}", "567-777-");// ^ 开头:找到 567
86M("A02", @"-\d{4}$", "8-12-2012");// $ 末尾:找到 -2012
C# · 第 91 行
91M("A07", @"er\b", "never", "verb");// \b 单词边界:never 末尾的 er 匹配,verb 中间的不算

\A、\Z、\z 是整个字符串的开头和末尾(\z 末尾多个换行就不算),\G 要紧接上一个匹配。\b 不只是单词和空格之间,标点、字符串两头也算。

5分组构造

圆括号括出子表达式,用来捕获或限定范围。普通括号从 1 开始编号,第 0 组是整个匹配;(?<名字>) 是命名组,(?:) 只分组不保存。

C# · 第 94 行
94M("G01", @"(\w)\1", "deep");// (\w) 是第 1 组,\1 再匹配一次同样的字符:找到 ee

(?=)、(?!)、(?<=)、(?<!) 是零宽断言,只检查前后、不吃字符;(?>) 是原子组,成功后不再回头重试。下面这个实例用的是后发断言:

C# · 第 8-9 行
8string input = "1851 1999 1950 1905 2003";// 五个年份
9string pattern = @"(?<=19)\d{2}\b";// 前面挨着 19,后面两个数字再加单词边界
运行结果
99
50
05

19 本身不在结果里,因为断言只检查,不算进匹配。

6限定符

C# · 第 105-107 行
105M("Q01", @"\d*\.\d", ".0", "19.9", "219.9");// * 零次或多次:.0 前面没有数字也匹配
106M("Q02", @"be+", "been", "bent");// + 一次或多次:bee、be
107M("Q03", @"rai?n", "ran", "rain");// ? 零次或一次:ran、rain 都匹配

{n} 恰好 n 次,{n,} 至少 n 次,{n,m} n 到 m 次。限定符默认贪婪(能多吃就多吃),后面加 ? 变懒惰(能少吃就少吃):

C# · 第 115 行
115M("Q11", @"\d{2,}?", "166", "29", "1930");// 懒惰:166 得到 16;1930 拆成 19、30

{n,m}? 也一样,每次只取最少的位数。初学时先掌握 *、+、?、{n} 就够了。

7反向引用、备用构造

反向引用再匹配一次已捕获的内容:\1 按编号,\k<名字> 按组名(尖括号里不能有空格,写成 \k< char> 会抛出 RegexParseException)。备用构造用 | 表示“或”,还能写成条件形式 (?(条件)是|否):

C# · 第 122 行
122M("O01", @"th(e|is|at)", "this is the day. ");// th 后跟 e、is、at 之一:找到 this、the

8替换、杂项构造

替换串里用 $ 引用捕获到的内容:

C# · 第 126 行
126R("R01", @"\b(\w+)(\s)(\w+)\b", "$3$2$1", "one two");// 第 3、2、1 组倒过来拼,得到 two one

还有 ${名字}、$$(真正的 $)、$&(整个匹配)等。$& 那一行实测得到 $1.30:模式在末尾又匹配到一个空串,也被替换了一次,所以末尾多两个星号。杂项构造里,(?i) 从这里往后忽略大小写,(?#…) 是内联注释。

9Regex 类的常用方法

文件顶部写 using System.Text.RegularExpressions;,同一个模式:一个或多个数字。

C# · 第 8-16 行
8Regex r = new Regex(@"\d+");// 用模式创建 Regex 对象
9Console.WriteLine(r.IsMatch("abc123"));// IsMatch:有没有能匹配的,True(常用来验证表单)
10Console.WriteLine(r.IsMatch("123abc", 3));// 从下标 3 开始找,后面只剩字母,False
11Console.WriteLine(Regex.IsMatch("abc123", @"\d+"));// 静态写法,不用先创建对象,True
12foreach (Match m in r.Matches("a1b22c333"))// Matches:找出所有匹配,每个是一个 Match 对象
13 Console.WriteLine(m.Value);// 依次打印 1、22、333
14Console.WriteLine(r.Replace("a1b22c333", "#"));// Replace:所有匹配换成 #,得到 a#b#c#
15string[] parts = r.Split("a1b22c333");// Split:按匹配把字符串切开
16Console.WriteLine(parts.Length + " 段:" + string.Join(",", parts));// 4 段:a,b,c, 末尾数字后切出一个空字符串
查看完整输出(8 行)
运行结果
True
False
True
1
22
333
a#b#c#
4 段:a,b,c,

10三个实例

前两个实例里的 showMatch 是例子自带的辅助方法:先打印模式,再用 Matches 逐个打印匹配到的单词。

C# · 第 22 行
22showMatch(str, @"\bS\S*");// 单词边界 + 大写 S + 任意非空白:S 开头的单词
运行结果
Matching words that start with 'S': 
The Expression: \bS\S*
Splendid
Suns
C# · 第 22 行
22showMatch(str, @"\bm\S*e\b");// m 开头、e 结尾的单词:make、maze、manage、measure
C# · 第 10-14 行
10string input = "Hello World ";// 中间三个空格,末尾也有三个
11string pattern = "\\s+";// 普通字符串写两个反斜杠:一个或多个连续空白
12string replacement = " ";// 换成一个空格
13Regex rgx = new Regex(pattern);// 创建 Regex 对象
14string result = rgx.Replace(input, replacement);// 得到 "Hello World ":末尾三个空格也换成了一个

11GeneratedRegex

.NET 7 起可以用 [GeneratedRegex] 特性(方括号标签,后面会讲):它是类库带的源生成器(编译时自动替你写代码的工具),不是新语法,在编译阶段就把正则生成成 C# 代码。方法必须是 partial(分部:一个类或方法分几处写,另一半由编译器补上)方法,所在类型也要加 partial,否则报 CS0260。

C# · 第 7-11 行
7partial class Program// 所在的类必须加 partial
8{// Program 类开始
9 // 使用 [GeneratedRegex] 特性,编译器自动生成实现
10 [GeneratedRegex(@"\d{4}-\d{2}-\d{2}")]// 写上模式:日期
11 private static partial Regex DateRegex();// 只写声明,调用 DateRegex() 得到 Regex 对象

Main 里用 DateRegex().Matches 找出两个日期,再用另一个忽略大小写的 StartsWithSRegex() 判断 Splendid:

运行结果
找到日期:2024-06-18
找到日期:2025-01-01
True

传统写法里,静态 Regex.IsMatch 会缓存最近用过的模式(默认 15 个),同一个模式不会每次都重新解析;RegexOptions.Compiled 运行更快,但启动慢、占内存,AOT 发布(提前编译成原生程序)时不起作用。GeneratedRegex 最快、兼容 AOT,但模式必须是编译时常量:

C# · 第 8 行
8[GeneratedRegex(pattern)]// pattern 是普通静态变量,不是常量
编译报错
Program.cs(9,33): error CS8795: 分部方法“Program.NumRegex()”必须具有实现部分,因为它具有可访问性修饰符。
Program.cs(8,20): error CS0182: 特性实参必须是特性形参类型的常量表达式、typeof 表达式或数组创建表达式

建议:模式固定又是 .NET 7 以上,优先 GeneratedRegex;模式要运行时拼出来,只能用 new Regex;初学先用传统写法练熟。

C# · 第 9 行
9Regex r = new Regex(word + @"\w*");// word 是变量 "cat":拼出 cat\w*,找到 3 个单词

12实例:验证邮箱

C# · 第 7 行
7[GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", RegexOptions.IgnoreCase)]// ^ 到 $:整个字符串都要符合规则
运行结果
tom@example.com: 有效
bad-email: 无效
li.lei@school.edu.cn: 有效
missing@dot: 无效

模式要求 @ 前是字母、数字或 ._%+-,@ 后是域名、一个真正的点号、至少两个字母。教程里的两个邮箱是占位文字,这里换成了示例地址。bad-email 没有 @,missing@dot 的 @ 后面没有点号。

13小结

  • 字符类管什么字符,限定符管几次,定位点管位置;圆括号分组捕获,替换时用 $ 引用。
  • 用 Regex 类匹配、替换、切分,模式写成 @ 逐字字符串;模式固定又看重性能,用 GeneratedRegex。

下一节:程序运行时出了错,比如除以零、文件找不到,怎么把它接住,不让程序直接崩溃?

检查题

6 道,选完马上告诉你对不对。

  1. 1模式 [b-d]irds 去匹配 Birds Cirds Dirds birds,能匹配几个?

  2. 2输入 1930,模式 \d{2,}?(懒惰)能找到哪些匹配?

  3. 3在 C# 里想写匹配数字的模式,哪种写法能编译?

  4. 4教程实例 3 用 \s+ 替换成一个空格,输入 "Hello World ",结果是?

  5. 5Regex.Split("a1b22c333", @"\d+") 得到几段?

  6. 6下面哪种情况只能用 new Regex(pattern),不能用 GeneratedRegex?