量词、字边界、预查的现代写法

量词的"严格语义"

备忘把 `+` / `*` / `?` 描述为"至少 1 次 / 0 到多次 / 0 到 1 次",这是对的。.NET 下要补两点:

  • 它们默认是贪婪的——会尽量多匹配。`.*` 不会在第一个停下的地方停。可以用 `*?` / `+?` / `??` 改写为懒惰。
  • 可以有精确数量:`{n}` / `{n,}` / `{n,m}`,比 `+` 之类的"语义模糊"更不容易写错。
// 贪婪 vs 懒惰:抓尖括号里的内容
var greedy  = Regex.Match("<a>1</a><a>2</a>", @"<a>.*</a>");   // 整段都被吞下
var lazy    = Regex.Match("<a>1</a><a>2</a>", @"<a>.*?</a>");  // <a>1</a>

字边界不是空格

备忘把"字边界"解释为"单词和空格之间的位置"——这是最常见的简化版,更准确的说法是一边是单词字符 `\w`、另一边不是的位置。中文环境下整段都是非 `\w`,所以"字边界"在中文字符串里几乎不工作;想做"中文分词"必须借助外部库(结巴、HanLP)。

Regex.IsMatch("hello world", @"\bhello\b");  // true
Regex.IsMatch("中文测试",    @"\b中文\b");      // false,中文不是 \w

非捕获组与预查

备忘里把 `?:` 列为"非捕获元之一",并把 `?=` 和 `?!` 解释为"正向/负向预查"。语义没错,但容易混。这里给一份对照:

<>(?!...)
语法名称是否消耗字符是否捕获
(...)捕获组是是
(?:...)非捕获组是否
(?=...)正向预查否否
负向预查否否
(?<=...)反向预查否否
(?<!...)反向负预查否否

用途:

  • 只想分组、不想污染 `Match.Groups` → 用 `(?:...)`。
  • 想"前面是 X、后面是 Y,但 X 和 Y 不算进结果" → 用预查。
// 抓"价格",要求前面是 ¥ 但不要把 ¥ 抓进结果
var m = Regex.Match("总价¥199起", @"(?<=¥)\d+");
Console.WriteLine(m.Value);  // 199

修饰符 i、g、m、s

备忘提到 `i`(不区分大小写)和 `g`(尽可能多匹配)。.NET 下还有 `m`(多行模式:影响 `^` `$`)和 `n`(显式捕获、忽略未命名组)。这些是模式内联修饰符,可以写在表达式局部:

// 只对这一段忽略大小写
var p = new Regex(@"(?i)abc(?-i)def");

备忘里把 `g` 解释为"尽可能多的匹配"——这是 JavaScript 里的语义。.NET 的 `Regex.Matches` **默认就是全局匹配**,根本没有 `g` 这个修饰符;要做"非贪婪"得用 `*?` `+?` 或精确数量。

"组合成数组"其实是 `MatchCollection`

备忘那条"通过多个子表达式组合成的正则会将字符串变为一个数组"指的就是 `MatchCollection`。.NET 下:

  • 全部匹配用 `Regex.Matches(input)`,得到 `MatchCollection`(`IEnumerable<Match>`)。
  • 只看第一个用 `Regex.Match(input)`,省去创建集合的开销。
  • `(pattern)` 捕获组用 `match.Groups[i]` 取出,编号从 1 开始。

字符类里的 `]` 写法

备忘里写"若要匹配列表中的 `]` 字符,请将它放在第一位,紧跟在 `[` 后面"。这是对的——`[]...]` 是合法的,`[]...]` 之外的写法会立刻结束字符类。容易忘的是同样的规则适用于 `^`(取反只能放最前)、`-`(范围不能是首字符或末字符)。

.NET 特有

命名组比数字组更稳

var p = new Regex(@"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})");
var m = p.Match("2026-08-03");
Console.WriteLine(m.Groups["month"].Value);  // 08

优势:

  • 改表达式时少出错——加一个新组不会让所有编号往后挪。
  • 调试时可读性更好。
  • 可以同时拿到数字和命名引用(数字组永远在)。

超时是必备的

默认 `Regex` 不设超时,遇到病理输入可能让 CPU 跑到 100%。两类设置方法:

// 1. 实例级
var p = new Regex(@"...", RegexOptions.None, TimeSpan.FromMilliseconds(100));

// 2. AppDomain 级(兜底)
AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(1));

命中超时抛 `RegexMatchTimeoutException`,**不要吞掉**——通常意味着这条输入触发了 ReDoS,下一步是修复模式而不是重试。

`RegexOptions.Compiled` 的取舍

  • 首次匹配慢(要把表达式编译成 IL),后续匹配快 2–10 倍。
  • 长期复用的 `Regex` 实例才值得开;短生命周期的反而更慢。
  • .NET 7+ 还有 `RegexOptions.NonBacktracking`,牺牲部分模式换取无回溯,适合"绝对不能 ReDoS"的场景。
// 静态字段 + Compiled 是 .NET 下的最佳实践
private static readonly Regex IpRegex = new(
    @"\b\d{1,3}(\.\d{1,3}){3}\b",
    RegexOptions.Compiled | RegexOptions.CultureInvariant,
    TimeSpan.FromMilliseconds(50));

平衡组:唯一在 .NET 里能用的"递归"

Java/JavaScript/Python 都不支持平衡组。.NET 的平衡组可以匹配**任意层嵌套**的括号或 XML 标签:

// 匹配最外层 <div>...</div>,无论里面嵌套多少层
var p = new Regex(
    @"<div>(?<depth>)|</div>(?<-depth>)|.*?(?(depth)(?!))",
    RegexOptions.Singleline);
// 配合 Replace + MatchEvaluator 可做精确裁剪

语法要点:

  • `(?<name>)` 把当前组栈 +1。
  • `(?-name>)` 把当前组栈 -1,要求该组存在。
  • `(?(name)yes|no)` 条件分支;`(?!))` 是经典的"栈空则停"判定。

平衡组是 .NET 唯一能匹配任意嵌套的能力;如果需要兼容其他语言,建议改用专用解析器。

.NET 7+ 的源生成 Regex

把 `Regex` 改成 partial 类 + 源生成器,零反射、AOT 友好、启动更快:

public partial class IpParser
{
    [GeneratedRegex(@"\b\d{1,3}(\.\d{1,3}){3}\b")]
    private static partial Regex IpRegex();
}

新项目优先用它替代 `RegexOptions.Compiled`;老项目可以把热路径逐步替换。

回溯与 ReDoS

回溯爆炸是 Regex 最大的工程风险。典型模式:

// 危险:嵌套量词 + 共享字符类
var evil = new Regex(@"^(\d+)+$");
evil.IsMatch(new string('1', 30));  // 毫秒级
evil.IsMatch(new string('1', 40));  // 显著变慢
evil.IsMatch(new string('1', 100)); // 几乎卡死

防御写法:

  1. 量词精确化:能用 `{1,3}` 就别用 `+`;用 `[^,]+` 比用 `.+` 更安全。
  2. 避免重叠:`\d+` 后跟 `\d+` 会让正则引擎尝试所有切分点。改成 `\d+\.\d+`。
  3. 原子化:.NET 不直接支持原子组,但可以用 `(?>...)` 模拟——一旦匹配就不允许回溯。
  4. 用 NonBacktracking:`.NET 7+` 的 `RegexOptions.NonBacktracking` 用 Thompson NFA 实现,无回溯,性能可预测。
  5. 必须设超时:见上节。超时是"ReDoS 防御网",不是性能优化。
// NonBacktracking:放弃部分模式(如反向引用、平衡组),换取确定性的 O(n)
var p = new Regex(@"\b\d{1,3}(\.\d{1,3}){3}\b", RegexOptions.NonBacktracking);

使用准则

  1. 用 GeneratedRegex(.NET 7+)或 RegexOptions.Compiled 长期持有实例,不要每条调用都 new Regex。
  2. 任何用户输入都走超时,命中超时优先改模式而不是加重试。
  3. 能不用正则就不用——简单 Split / IndexOf / Span<char> 切片往往更清晰、更快。
  4. 中文/Unicode 场景开 RegexOptions.ECMAScript?不开。.NET 默认就是 Unicode 友好的,ECMAScript 反而把 `\d` 收紧到 ASCII 0-9。
  5. 需要解析 HTML/XML/JSON/CSV,**用专用解析器**,不要用正则魔改。
参考资料