Skip to content
Go back

C# 正则表达式:用好 Lookahead、Lookbehind 和高级模式语法

正则表达式里最容易被忽视的能力,往往藏在那些”不消耗字符”的断言里。

如果你用过 ^$\b,你已经接触过零宽断言了——它们检查位置,但不”吃掉”任何字符。Lookahead 和 Lookbehind 把这个思路推进一步:你可以断言当前位置前面或后面是某个任意模式,而这段模式完全不出现在最终的匹配结果里。这就是”匹配一个数字,但前面必须有美元符号,且结果里不包含美元符号”的做法。

本文覆盖 C# 中四种 Lookahead/Lookbehind、反向引用、条件模式、原子组和 .NET 独有的平衡组,每个都有具体可运行的示例。

零宽断言是什么

零宽断言匹配的是位置,不匹配字符。引擎在某个位置检验断言是否成立,成立则继续,但游标不向前移动。

^$\b 就是内建的零宽断言。Lookahead 和 Lookbehind 把这个机制开放给任意子模式。

四种基本形式:

语法名称含义
(?=pattern)正向 Lookahead当前位置后面必须匹配 pattern
(?!pattern)负向 Lookahead当前位置后面必须匹配 pattern
(?<=pattern)正向 Lookbehind当前位置前面必须匹配 pattern
(?<!pattern)负向 Lookbehind当前位置前面必须匹配 pattern

正向 Lookahead (?=pattern)

正向 Lookahead 要求当前位置后面跟随 pattern,但 pattern 不进入 match.Value

using System.Text.RegularExpressions;

// 只匹配后面紧跟 "px" 的数字
var regex = new Regex(@"\d+(?=px)");
var matches = regex.Matches("Font: 16px, Margin: 8px, Border: 2em");

foreach (Match m in matches)
{
    Console.WriteLine(m.Value);
}
// 16
// 8

px 是匹配条件,但不在结果里——这是 Lookahead 和直接写 \d+px 的本质区别。

在替换中插入文本

Lookahead 在 Regex.Replace 里特别有用:它标记一个插入点,而不消耗字符,所以周围的文本保持完整。

// 在每组三位数字前插入逗号(简化版千位分隔符)
var regex = new Regex(@"(?<=\d)(?=(\d{3})+\b)");
string result = regex.Replace("1234567", ",");
Console.WriteLine(result); // 1,234,567

负向 Lookahead (?!pattern)

负向 Lookahead 要求后面匹配 pattern,适合排除场景。

// 匹配不后跟 "bar" 的 "foo"
var regex = new Regex(@"foo(?!bar)");

Console.WriteLine(regex.IsMatch("foobar"));  // False
Console.WriteLine(regex.IsMatch("fooqwe"));  // True
Console.WriteLine(regex.IsMatch("foo"));     // True

实际场景:匹配标识符,但排除函数调用(后面跟 ():

// 匹配不后跟左括号的标识符(即非函数调用)
var regex = new Regex(@"[a-zA-Z_]\w*(?!\s*\()");
var input = "result = calculate(x) + offset + transform(y)";
var matches = regex.Matches(input);

foreach (Match m in matches)
{
    Console.WriteLine(m.Value);
}
// result
// x
// offset
// y

正向 Lookbehind (?<=pattern)

正向 Lookbehind 要求当前位置前面匹配 pattern,但前面这段不计入结果。

// 匹配美元符号后的金额,结果不含 $
var regex = new Regex(@"(?<=\$)\d+(?:\.\d{2})?");
var input = "Total: $42.99, Tax: $3.50, Tip: 15%";

foreach (Match m in regex.Matches(input))
{
    Console.WriteLine(m.Value);
}
// 42.99
// 3.50

提取键值对中的值

// 提取 name: 或 username: 后面的值,不区分大小写
var regex = new Regex(@"(?<=(?:name|username):\s*)\S+", RegexOptions.IgnoreCase);
var input = "Name: Alice, username: bob123, email: alice@test.com";

foreach (Match m in regex.Matches(input))
{
    Console.WriteLine(m.Value);
}
// Alice
// bob123

负向 Lookbehind (?<!pattern)

负向 Lookbehind 要求前面匹配 pattern

// 匹配不被 "font-" 前缀修饰的 "size"
var regex = new Regex(@"(?<!font-)size");

Console.WriteLine(regex.IsMatch("font-size: 16px")); // False
Console.WriteLine(regex.IsMatch("box-size: large"));  // True
Console.WriteLine(regex.IsMatch("size matters"));     // True

精确匹配独立数字:

// 精确匹配 "42",两侧不能是数字
var regex = new Regex(@"(?<!\d)42(?!\d)");

Console.WriteLine(regex.IsMatch("value: 42"));   // True
Console.WriteLine(regex.IsMatch("value: 142"));  // False
Console.WriteLine(regex.IsMatch("value: 420"));  // False

.NET 的可变长 Lookbehind

多数正则引擎(PCRE、ES2018 之前的 JavaScript)要求 Lookbehind 内的模式长度固定。.NET 从一开始就支持可变长 Lookbehind,可以在 Lookbehind 里用 *+ 和交替。

// 可变长 Lookbehind,在很多其他引擎里会报错
var regex = new Regex(@"(?<=https?://)\w+");
var input = "Visit http://example.com or https://www.devleader.ca";

foreach (Match m in regex.Matches(input))
{
    Console.WriteLine(m.Value);
}
// example
// devleader

反向引用

反向引用让你在模式里引用前面某个分组捕获到的内容,适合匹配重复词或对称定界符。

// 找出重复单词
var regex = new Regex(@"(?<word>\w+)\s+\k<word>", RegexOptions.IgnoreCase);
var match = regex.Match("the the quick brown fox fox over");

while (match.Success)
{
    Console.WriteLine($"Doubled: '{match.Value}'");
    match = match.NextMatch();
}
// Doubled: 'the the'
// Doubled: 'fox fox'

\k<word> 引用命名分组,\1\2 则引用编号分组。

匹配对称引号

// 属性值可以是单引号或双引号,但必须前后一致
var regex = new Regex(@"(?<q>['""])(?<value>[^'""]*)(\k<q>)");
var input = "class=\"highlight\" id='main'";

foreach (Match m in regex.Matches(input))
{
    Console.WriteLine(m.Groups["value"].Value);
}
// highlight
// main

非捕获组 (?:pattern)

非捕获组用于分组但不创建捕获记录,比捕获组性能更好,引擎无需追踪内容。

// 分组用于量词,但不需要捕获
var regex = new Regex(@"(?:red|green|blue)\s+\w+");
var match = regex.Match("The blue sky and red barn");

Console.WriteLine(match.Value);         // blue sky
Console.WriteLine(match.Groups.Count);  // 1(只有 group 0,即完整匹配)

原子组与贪婪控制

.NET 支持原子组 (?>...),引擎一旦匹配成功就不再回溯进这个组,可以防止灾难性回溯。

// 不用原子组:可能触发大量回溯
var slow = new Regex(@"(?:\w+\s?)*:");

// 用原子组:不回溯(.NET 5+)
var fast = new Regex(@"(?>(?:\w+\s?)*):");

var input = "this is a test with no colon at the end padding";

var sw = System.Diagnostics.Stopwatch.StartNew();
bool resultFast = fast.IsMatch(input);
Console.WriteLine($"Atomic: {sw.ElapsedMilliseconds}ms");

注意:若需要完全保证不触发灾难性回溯,RegexOptions.NonBacktracking 是更强的保证——但它不支持 Lookahead/Lookbehind。

条件模式 (?(condition)yes|no)

条件模式根据前面某个分组是否参与匹配,选择不同的子模式。常用于需要对称定界符的场景。

// 如果匹配到了开括号,则要求有对应的闭括号
var regex = new Regex(@"(?<open>\()?word(?(open)\))");

Console.WriteLine(regex.IsMatch("(word)"));  // True  -- 有括号,两端都满足
Console.WriteLine(regex.IsMatch("(word"));   // False -- 有开括号,缺闭括号
Console.WriteLine(regex.IsMatch("word"));    // True  -- 没有括号,条件不触发

这是一个少见但很有力的特性,在解析对称结构时非常实用。

命名分组 + Lookahead 的实战组合

组合命名分组和 Lookahead/Lookbehind,可以写出清晰又精准的模式。下面的例子提取 CSS 中的十六进制颜色值:

[GeneratedRegex(
    @"(?<=#)(?<hex>[0-9a-fA-F]{6}|[0-9a-fA-F]{3})",
    RegexOptions.IgnoreCase,
    matchTimeoutMilliseconds: 500)]
private static partial Regex HexColorPattern();

public static IEnumerable<string> ExtractHexColors(string css)
{
    foreach (ValueMatch vm in HexColorPattern().EnumerateMatches(css))
    {
        yield return css[vm.Index..(vm.Index + vm.Length)];
    }
}

// 使用:
var colors = ExtractHexColors("color: #ff0000; background: #fff; border: #1a2b3c");
foreach (var c in colors)
{
    Console.WriteLine(c);
}
// ff0000
// fff
// 1a2b3c

Lookbehind (?<=#) 确保只提取 # 之后的部分,而结果里不含 #

多个 Lookahead 的密码强度验证

多个 Lookahead 并列是实现复合校验的标准做法——每个 (?=...) 在同一位置独立扫描:

[GeneratedRegex(
    @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$",
    RegexOptions.None,
    matchTimeoutMilliseconds: 500)]
private static partial Regex StrongPasswordPattern();

// 断言分别要求:至少一个小写字母、大写字母、数字、特殊字符
// .{8,} 确保最小长度

Console.WriteLine(StrongPasswordPattern().IsMatch("Password1!"));  // True
Console.WriteLine(StrongPasswordPattern().IsMatch("password1!"));  // False(无大写)
Console.WriteLine(StrongPasswordPattern().IsMatch("PASSWORD1!"));  // False(无小写)

顺序不影响结果正确性,但影响性能:把最容易失败的断言放最前面,让引擎尽快短路。

.NET 独有的平衡组

.NET 正则有一个独特功能:平衡组,可以匹配嵌套结构。

// 匹配平衡括号(简化版,生产场景需要更完善的处理)
var regex = new Regex(@"((?:[^()]|(?<open>\()|(?<-open>\)))*(?(open)(?!)))");

Console.WriteLine(regex.IsMatch("(hello)"));          // True
Console.WriteLine(regex.IsMatch("(hello (world))"));  // True
Console.WriteLine(regex.IsMatch("(hello (world)"));   // False -- 不平衡

(?<-name>...) 语法用于递减计数器,(?(name)...) 在末尾检查计数是否归零。这是 .NET 正则独有的特性,大多数情况下用专门的解析器处理嵌套结构更合适。

[GeneratedRegex] 配合使用

上面所有特性都与 [GeneratedRegex] 兼容。生成的代码在编译期针对具体模式优化:

public partial class AdvancedPatterns
{
    // 提取版本号,但排除被字母前缀修饰的(如 v1.0 里的 1.0 不匹配)
    [GeneratedRegex(
        @"(?<![a-zA-Z])(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?",
        RegexOptions.None,
        matchTimeoutMilliseconds: 500)]
    public static partial Regex VersionPattern();
}

var matches = AdvancedPatterns.VersionPattern().Matches("Release 2.1.0, SDK 1.0, libv3.5");
foreach (Match m in matches)
{
    Console.WriteLine(
        $"Version: {m.Groups["major"]}.{m.Groups["minor"]}" +
        (m.Groups["patch"].Success ? $".{m.Groups["patch"]}" : ""));
}
// Version: 2.1.0
// Version: 1.0
// (v3.5 因为负向 Lookbehind 被排除)

调试复杂模式的几个方法

复杂模式调试不能靠”读出来”猜结果,需要系统方法:

var regex = new Regex(@"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})");
var match = regex.Match("2026-05-11");

foreach (string name in regex.GetGroupNames())
{
    var group = match.Groups[name];
    Console.WriteLine($"{name}: {(group.Success ? group.Value : "(not matched)")}");
}

什么时候不该用 Lookahead

RegexOptions.NonBacktracking 不支持 Lookahead、Lookbehind、反向引用和原子组。如果需要 O(n) 时间复杂度的保证(安全敏感场景、不可信输入),必须写不含零宽断言的模式。

常见的权衡方案是:先预验证结构,再提取内容,两步分开处理。

参考


Tags


Next

ASP.NET Core 万级并发的三个崩溃点:复现、定位与修复