一句话概括:正则表达式是一门"描述文本规律"的迷你语言——学会它,你就拥有了用几行"符号密码"替代成百上千行字符串处理代码的超能力。这篇文章会用类比 + 递进式案例,把正则从入门讲到实战,再讲到性能陷阱,让你彻底告别"复制粘贴正则、不敢改一个字符"的状态。
目录
- 正则表达式到底是什么,为什么值得学
- 字符匹配基础:普通字符、元字符、转义
- 字符集与量词:正则的"骰子"与"计数器"
- 分组与捕获:把匹配结果"切开装盒"
- 断言:只看不吃的"隐形规则"
- 贪婪与非贪婪:一个经常翻车的细节
- 常用场景实战:验证、提取、替换、拆分
- C# 中 Regex 类的完整用法
- 性能陷阱:灾难性回溯是怎么炸掉你的服务器的
- 正则表达式速查表
- 写在最后 & 课后练习
一、正则表达式到底是什么,为什么值得学
1.1 一个真实的痛点
假设产品经理甩给你一个需求:“校验用户输入的手机号是否合法”。你可能会这样写:
staticboolIsValidPhone(stringphone){if(phone.Length!=11)returnfalse;if(!phone.StartsWith("1"))returnfalse;foreach(charcinphone){if(!char.IsDigit(c))returnfalse;}returntrue;}十行代码,还只是"及格线"的校验(真实手机号第二位还有取值范围限制)。而用正则表达式:
boolisValid=Regex.IsMatch(phone,@"^1[3-9]\d{9}$");一行代码,规则清晰,还能随时调整。这就是正则表达式的价值——它把"描述文本规律"这件事,从"写一大段逻辑代码"压缩成了"写一段规则表达式"。
1.2 正则表达式是什么?
正则表达式(Regular Expression,简称 Regex)是一种用于描述字符串"模式"(Pattern)的迷你语言,通过一套特殊符号,来表达"什么样的文本才算匹配"。
它不是某种编程语言独有的功能,而是几乎所有主流语言(C#、Java、Python、JavaScript……)、几乎所有代码编辑器的"查找替换"功能里,都通用的一套标准。学一次,处处能用,这正是它性价比极高的原因。
二、字符匹配基础:普通字符、元字符、转义
2.1 最简单的正则:普通字符原样匹配
Regex.IsMatch("hello world","world")// True,因为字符串里包含"world"Regex.IsMatch("hello world","World")// False,默认区分大小写正则表达式默认做的是"子串查找"——只要文本中任意位置能找到匹配的片段,就算成功,不需要整个字符串完全相等。
2.2 元字符:正则世界的"特殊符号"
除了普通字符,正则还有一批具有特殊含义的符号,叫"元字符":
| 元字符 | 含义 | 示例 | 匹配 |
|---|---|---|---|
. | 匹配任意单个字符(默认不含换行符) | a.c | “abc”、“a1c”、“a c” |
^ | 匹配字符串开头 | ^Hello | 必须以 Hello 开头 |
$ | 匹配字符串结尾 | world$ | 必须以 world 结尾 |
\d | 匹配任意数字(等价于[0-9]) | \d\d\d | “123”、“789” |
\D | 匹配任意"非数字" | ||
\w | 匹配字母、数字、下划线(等价于[a-zA-Z0-9_]) | ||
\W | 匹配任意"非单词字符" | ||
\s | 匹配任意空白字符(空格、制表符、换行) | ||
\S | 匹配任意"非空白字符" | ||
\b | 单词边界(不消耗字符,仅做位置判断) | \bcat\b | 匹配独立的 “cat”,不匹配 “category” 中的 cat |
一个直观例子:
Regex.IsMatch("2026-09-22",@"^\d{4}-\d{2}-\d{2}$")// True,标准日期格式Regex.IsMatch("hello","h.llo")// True,. 匹配了 e2.3 转义:当你想匹配元字符本身
如果你真的想匹配一个字面意义上的.(比如网址中的点),需要用反斜杠\转义:
Regex.IsMatch("www.baidu.com",@"www\.baidu\.com")// 需要转义 . ,否则 . 会被当成"任意字符"// 反面教材:忘记转义Regex.IsMatch("wwwXbaiduXcom",@"www.baidu.com")// 竟然也是 True!因为 . 匹配了任意字符 X这是新手最容易踩的坑之一——忘记转义.,会导致正则的匹配范围比你想象的宽松得多。凡是要匹配元字符本身(. ^ $ * + ? ( ) [ ] { } | \),都需要在前面加\。
C# 小贴士:写正则表达式建议始终使用
@逐字字符串前缀(如@"\d+"),这样反斜杠就不用再对 C# 字符串本身做二次转义(否则\d要写成\\d,可读性直线下降)。
三、字符集与量词:正则的"骰子"与"计数器"
3.1 字符集[...]:从一组候选字符里选一个
Regex.IsMatch("cat","[bc]at")// True,匹配 "bat" 或 "cat"Regex.IsMatch("hat","[bc]at")// False,h 不在候选范围内// 范围写法Regex.IsMatch("5","[0-9]")// True,等价于 \dRegex.IsMatch("K","[A-Z]")// True,任意大写字母// 取反:^ 放在方括号开头,表示"除了这些字符之外"Regex.IsMatch("x","[^0-9]")// True,x 不是数字手机号校验里的[3-9]就是字符集的典型应用——第二位数字只能是 3~9 之间的某一个。
3.2 量词:控制"重复几次"
这是正则表达式里最核心、也最容易记混的部分。
| 量词 | 含义 | 示例 |
|---|---|---|
* | 出现 0 次或多次 | ab*c匹配 “ac”、“abc”、“abbbc” |
+ | 出现 1 次或多次 | ab+c匹配 “abc”、“abbc”,但不匹配 “ac” |
? | 出现 0 次或 1 次(可选) | colou?r匹配 “color” 和 “colour” |
{n} | 精确出现 n 次 | \d{6}匹配恰好 6 位数字 |
{n,} | 至少出现 n 次 | \d{6,}匹配 6 位及以上数字 |
{n,m} | 出现 n 到 m 次 | \d{6,11}匹配 6 到 11 位数字 |
手机号正则再拆解一次,现在你已经能完全看懂:
^1[3-9]\d{9}$ ^ 字符串开头 1 字面量数字 1 [3-9] 第二位,3到9之间任意一个数字 \d{9} 后面精确跟着 9 位数字 $ 字符串结尾 合计:1 + 1位[3-9] + 9位数字 = 恰好 11 位,且以 1 开头,第二位不为 0~2四、分组与捕获:把匹配结果"切开装盒"
4.1 为什么需要分组?
假设你要从日志里提取日期的"年、月、日"分别是多少,而不只是"判断格式对不对"——这时就需要分组捕获。
stringlog="访问时间:2026-09-22 用户ID:1001";Matchmatch=Regex.Match(log,@"(\d{4})-(\d{2})-(\d{2})");if(match.Success){Console.WriteLine($"完整匹配:{match.Value}");// 2026-09-22Console.WriteLine($"年:{match.Groups[1].Value}");// 2026Console.WriteLine($"月:{match.Groups[2].Value}");// 09Console.WriteLine($"日:{match.Groups[3].Value}");// 22}圆括号()就是"分组"——它做了两件事:1)把内部的正则当作一个整体来处理量词;2)把匹配到的内容单独"记住",可以在结果中通过Groups[索引]取出来,索引 0 永远是整个匹配的完整内容。
4.2 命名分组:告别"数字索引,一看就懵"
Matchmatch=Regex.Match(log,@"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})");Console.WriteLine(match.Groups["year"].Value);// 2026Console.WriteLine(match.Groups["month"].Value);// 09(?<名字>...)语法能给每个分组起一个有意义的名字,大幅提升正则的可读性和可维护性——强烈建议在正则较复杂时使用命名分组,而不是靠数数字索引。
4.3 非捕获分组:只想复用规则,不想占用分组编号
// 想匹配"http://"或"https://"开头的网址,但不关心协议本身Regex.IsMatch("https://example.com",@"(?:https?)://\S+")(?:...)表示"只分组、不捕获"——当你只是想给一段正则套上括号控制优先级或量词范围,但并不需要单独取出这段匹配结果时,用非捕获分组能避免污染分组编号,也有轻微的性能优势。
五、断言:只看不吃的"隐形规则"
5.1 什么是断言?
断言(Assertion):检查某个位置前后是否符合特定模式,但这部分内容本身不算入最终匹配结果(也叫"零宽度匹配")。
这是正则表达式里相对进阶、但威力巨大的特性。
5.2 四种断言一览
| 断言类型 | 语法 | 含义 |
|---|---|---|
| 正向先行断言 | (?=...) | 后面必须跟着…,但不包含它 |
| 负向先行断言 | (?!...) | 后面不能跟着… |
| 正向后行断言 | (?<=...) | 前面必须是…,但不包含它 |
| 负向后行断言 | (?<!...) | 前面不能是… |
5.3 实战理解:提取价格数字,但不要货币符号
stringtext="价格:¥199.90 元";// 正向后行断言:只匹配"¥"后面紧跟的数字,¥符号本身不算入结果Matchm=Regex.Match(text,@"(?<=¥)\d+\.?\d*");Console.WriteLine(m.Value);// 199.90(不包含 ¥ 符号本身)5.4 密码强度校验:断言的"高光时刻"
要求密码"必须同时包含大写字母、小写字母、数字,长度至少 8 位",用多个先行断言拼接:
stringpattern=@"^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$";Regex.IsMatch("Abcd1234",pattern)// True:有大写、小写、数字,长度够Regex.IsMatch("abcd1234",pattern)// False:缺少大写字母Regex.IsMatch("Abcdefgh",pattern)// False:缺少数字这段正则的巧妙之处:三个(?=.*X)断言互不冲突地"叠加检查"——因为断言是"零宽度"的,它们都从字符串同一个起始位置开始检查,谁都不会"吃掉"字符影响后面的判断,最后再用.{8,}真正消费掉整个字符串确认长度。这正是断言相比"拆成多个条件分别 if 判断"更优雅的地方——一行正则搞定原本需要好几个布尔条件的校验逻辑。
六、贪婪与非贪婪:一个经常翻车的细节
6.1 问题现场
stringhtml="<div>第一段</div><div>第二段</div>";Matchmatch=Regex.Match(html,"<div>.*</div>");Console.WriteLine(match.Value);// 输出:<div>第一段</div><div>第二段</div> ← 把两个 div 都匹配进去了!你可能以为它只会匹配第一个<div>...</div>,结果它"贪心"地把整个字符串都吃掉了。
6.2 原理:量词默认是"贪婪"的
*、+、{n,m}这些量词,默认会尽可能多地匹配字符,直到整个正则表达式匹配失败才会"回吐"字符——这种行为叫贪婪匹配(Greedy)。
在上面的例子中,.*会先尝试匹配到字符串末尾,发现后面还得跟一个</div>,于是从末尾往前"回吐"字符,一直吐到能匹配上最后一个</div>为止——结果就是匹配了尽可能长的一段,而不是你想要的"最短的那一段"。
6.3 解决方案:非贪婪模式
在量词后面加一个?,就能让它变成"非贪婪(Lazy/Non-greedy)"——尽可能少地匹配:
Matchmatch=Regex.Match(html,"<div>.*?</div>");Console.WriteLine(match.Value);// 输出:<div>第一段</div> ← 现在只匹配了第一段!| 贪婪写法 | 非贪婪写法 | 区别 |
|---|---|---|
* | *? | 0次或多次,尽量多 vs 尽量少 |
+ | +? | 1次或多次,尽量多 vs 尽量少 |
{n,m} | {n,m}? | n到m次,尽量多 vs 尽量少 |
记忆口诀:?跟在量词后面时,就像给这个"贪吃的家伙"套上了缰绳,让它变得"克制"。
⚠️重要提醒:用正则解析 HTML/XML 本身是一种"能用但不推荐"的做法——正则擅长处理规律性强、结构扁平的文本,而 HTML 存在嵌套、属性顺序不固定等复杂结构,真实项目中解析 HTML 请使用专门的解析库(如 HtmlAgilityPack)。上面的例子只是用来讲解贪婪/非贪婪概念,不建议照搬去解析真实网页。
七、常用场景实战:验证、提取、替换、拆分
7.1 场景一:邮箱格式校验
stringpattern=@"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$";Regex.IsMatch("test@example.com",pattern)// TrueRegex.IsMatch("invalid-email",pattern)// FalseRegex.IsMatch("a.b+c@sub.domain.co",pattern)// True解读:[\w.+-]+匹配用户名部分(允许字母数字下划线、点、加号、减号),@字面匹配,[\w-]+匹配域名主体,\.[a-zA-Z]{2,}匹配顶级域名(至少2个字母)。
提醒:真正"完全符合 RFC 标准"的邮箱正则极其复杂(官方标准正则有上百个字符),实际业务中通常只需要一个"过滤明显错误输入"的宽松校验,真正确认邮箱有效性还是要靠发送验证邮件。不要陷入"写一个完美正则"的过度设计陷阱。
7.2 场景二:从文本中批量提取信息
stringtext="联系人:张三 13800001111,李四 13900002222,王五 13712345678";MatchCollectionmatches=Regex.Matches(text,@"1[3-9]\d{9}");foreach(Matchminmatches)Console.WriteLine(m.Value);// 13800001111// 13900002222// 13712345678Regex.Matches(复数)会返回所有匹配项,而不是像Regex.Match(单数)那样只返回第一个。
7.3 场景三:替换与格式化
stringtext="我的手机号是13800001111,请勿泄露";// 简单替换:脱敏处理,只保留前3位和后4位stringmasked=Regex.Replace(text,@"1[3-9]\d{9}",m=>{stringphone=m.Value;returnphone.Substring(0,3)+"****"+phone.Substring(7);});Console.WriteLine(masked);// 我的手机号是138****1111,请勿泄露Regex.Replace的高阶用法是传入一个MatchEvaluator委托(本例中用 Lambda 实现),可以对每一处匹配结果做自定义加工,而不只是简单的字符串替换——这在日志脱敏、数据清洗等场景中非常实用。
引用分组的替换写法:
stringinput="2026-09-22";stringoutput=Regex.Replace(input,@"(\d{4})-(\d{2})-(\d{2})","$3/$2/$1");Console.WriteLine(output);// 22/09/2026,通过 $1 $2 $3 引用分组重新排列7.4 场景四:拆分字符串
stringcsv="苹果, 香蕉, 橙子,葡萄";// 按逗号加任意数量空格拆分,普通 Split(',') 处理不了这种不规则空格string[]fruits=Regex.Split(csv,@",\s*");foreach(stringfinfruits)Console.WriteLine(f);// 苹果// 香蕉// 橙子// 葡萄普通的string.Split(',')只能按固定字符拆分,遇到"分隔符前后空格数量不固定"这种情况就无能为力,而Regex.Split可以用模式来描述分隔规则,灵活得多。
八、C# 中 Regex 类的完整用法
8.1 静态方法 vs 实例方法
// 方式一:静态方法,适合"只用一次"的场景boolisMatch1=Regex.IsMatch("hello",@"h.llo");// 方式二:先创建 Regex 实例,适合"同一个正则要反复使用"的场景Regexregex=newRegex(@"h.llo");boolisMatch2=regex.IsMatch("hello");性能提示:如果同一个正则表达式会被反复使用成千上万次(比如在循环中、高并发接口里),强烈建议创建Regex实例并复用,而不是每次都调用静态方法——静态方法内部虽然有缓存机制,但显式复用实例、配合RegexOptions.Compiled选项,性能会更优:
// RegexOptions.Compiled:把正则编译成 IL 代码,牺牲一点点初始化时间,换取大幅提升的匹配速度// 适合"正则会被高频调用"的场景,不适合"只用一次就丢弃"的场景staticreadonlyRegexPhoneRegex=newRegex(@"^1[3-9]\d{9}$",RegexOptions.Compiled);8.2 常用 RegexOptions
| 选项 | 作用 |
|---|---|
IgnoreCase | 忽略大小写 |
Multiline | 让^$匹配每一行的开头结尾,而不只是整个字符串的开头结尾 |
Singleline | 让.也能匹配换行符 |
Compiled | 编译为原生代码,提升重复调用性能 |
IgnorePatternWhitespace | 忽略正则中的空白和注释,方便给复杂正则加注释 |
8.3 核心 API 速查表
| 方法 | 作用 | 返回值 |
|---|---|---|
Regex.IsMatch(input, pattern) | 判断是否匹配 | bool |
Regex.Match(input, pattern) | 获取第一个匹配 | Match |
Regex.Matches(input, pattern) | 获取所有匹配 | MatchCollection |
Regex.Replace(input, pattern, replacement) | 替换匹配内容 | string |
Regex.Split(input, pattern) | 按模式拆分字符串 | string[] |
8.4IgnorePatternWhitespace:给复杂正则加注释
stringpattern=@" ^ # 字符串开头 1 # 手机号固定以1开头 [3-9] # 第二位在3~9之间 \d{9} # 后面9位数字 $ # 字符串结尾 ";boolisValid=Regex.IsMatch("13800001111",pattern,RegexOptions.IgnorePatternWhitespace);当正则表达式变得复杂时,这个选项能让你像写普通代码一样给每一部分加注释,大幅提升复杂正则的可维护性——这是很多人不知道、但工程中极其实用的一个技巧。
九、性能陷阱:灾难性回溯是怎么炸掉你的服务器的
9.1 一个看起来人畜无害的正则
stringpattern=@"^(a+)+$";stringinput="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab";// 危险!这行代码可能会卡死程序几十秒甚至更久boolresult=Regex.IsMatch(input,pattern);这个正则看起来平平无奇,但当输入是大量重复字符 + 结尾不匹配时,会触发所谓的"灾难性回溯(Catastrophic Backtracking)",让匹配时间随着输入长度呈指数级爆炸增长。
9.2 为什么会这样?
(a+)+这种"量词嵌套量词"的写法,是灾难性回溯的头号元凶。内层a+可以匹配 1~n 个 a,外层()+又可以重复分组多次——对于同一段全是 a 的字符串,存在大量种不同的"切分方式"都能匹配到同样的内容(比如 “aaaa” 可以被切成 “a+a+a+a”、“aa+aa”、“aaaa” 等等),当最终因为结尾的b缺失而匹配失败时,正则引擎会尝试遍历所有这些切分方式,才能确认"确实无法匹配"——切分方式的数量是指数级的。
9.3 如何避免
- 避免"嵌套量词"结构,比如
(a+)+、(a*)*、(a|a)*这类模式,通常可以简化:(a+)+完全等价于更简单、不会回溯爆炸的a+。 - 给用户输入的正则设置超时时间,防止恶意构造的输入拖垮服务:
try{boolresult=Regex.IsMatch(input,pattern,RegexOptions.None,TimeSpan.FromSeconds(1));}catch(RegexMatchTimeoutException){Console.WriteLine("正则匹配超时,可能存在性能风险,已终止");}- 写正则前,先用在线工具(如 regex101.com)测试极端输入,尤其是当正则会应用在"用户可控的输入"上时——这类被称为 ReDoS(Regular Expression Denial of Service)的攻击,是真实存在过的生产事故原因,值得每个开发者警惕。
十、正则表达式速查表
━━━━━━━━━━━━━ 基础元字符 ━━━━━━━━━━━━━ . 任意单个字符(不含换行) ^ 字符串/行开头 $ 字符串/行结尾 \d \D 数字 / 非数字 \w \W 单词字符 / 非单词字符 \s \S 空白字符 / 非空白字符 \b 单词边界 ━━━━━━━━━━━━━━━ 量词 ━━━━━━━━━━━━━━━ * 0次或多次 + 1次或多次 ? 0次或1次 {n} 精确n次 {n,} 至少n次 {n,m} n到m次 *? +? ?? {n,m}? 对应的非贪婪版本 ━━━━━━━━━━━━━━ 字符集 ━━━━━━━━━━━━━━ [abc] a、b、c 中任意一个 [a-z] a到z范围内任意字符 [^abc] 除了a、b、c之外的任意字符 ━━━━━━━━━━━━━━ 分组 ━━━━━━━━━━━━━━━ (...) 捕获分组 (?<name>...) 命名捕获分组 (?:...) 非捕获分组 | 或(在分组内表示多选一) ━━━━━━━━━━━━━━ 断言 ━━━━━━━━━━━━━━━ (?=...) 正向先行断言 (?!...) 负向先行断言 (?<=...) 正向后行断言 (?<!...) 负向后行断言十一、写在最后
正则表达式常被戏称为"写的时候一气呵成,读的时候一脸懵逼",但只要理解了它背后的核心逻辑——元字符描述"是什么",量词描述"重复几次",分组描述"如何拆解",断言描述"周围环境"——你会发现它其实非常有规律可循,不是玄学,而是一门语法紧凑的"迷你编程语言"。
给你三条实用建议:
- 复杂正则写完,一定要在 regex101.com 这类在线工具上可视化调试,它能逐段高亮解释每一部分的含义,比对着代码肉眼分析效率高得多。
- 别为了"炫技"把所有逻辑塞进一个正则——可读性和可维护性,往往比"一行正则解决所有问题"更重要。适当拆成多个步骤,或者加上
IgnorePatternWhitespace注释,长期收益更大。 - 涉及用户输入的正则,一定要考虑 ReDoS 风险,必要时设置超时保护。
📝 课后练习
- 写一个正则,校验身份证号格式(18位,最后一位可以是数字或大写X)。
- 用
Regex.Replace实现一个"驼峰命名转下划线命名"的转换(如UserName→user_name),提示:需要用到先行/后行断言或分组配合。 - 找出下面这个正则潜在的灾难性回溯风险,并尝试改写成安全版本:
^(\d+)+$
觉得有收获?欢迎点赞收藏,下次面对一大段正则表达式时,你已经能像拆解句子语法一样,一段段读懂它的逻辑 😉