写程序的人迟早都要面对一个问题:你拿到了一段文本,想查出里面到底有没有手机号、邮箱或者 IP;你想把一坨日志里所有 404 的请求行挑出来;你想把用户输入里的多余空格统一干掉。如果全靠手工循环和字符串截取,不仅写起来累,改起来更要命。正则表达式就是为解决这类“按模式处理文本”而生的工具。虽然教科书通常把它放到靠后的章节,比如第 16 章,但实际项目里,它出现的频率一点不比 if/else 低。
这篇内容我不打算给你照搬手册,而是把它当成一个“文本处理经验帖”来写。你会看到正则表达式语法里真正值得背的内容、在 C# 里判断 IP 地址并计算单网段最大可用主机数的方法、在 grep 命令行里安全使用正则的避坑姿势,以及 Delphi 这种老干部框架下怎么低门槛地把正则用起来。通篇都是我在实际代码里踩过、调过、优化过的东西,尽量用大白话讲透,并且保证你照着能直接抄作业。
1. 正则表达式到底是什么,为什么要单独开一章
我习惯把正则表达式理解成一种“在文本上做模式匹配的小型编程语言”。绝大多数编程语言里字符串函数已经很强了,但它只能做点对点的精确匹配:contains 判断有没有,replace 替换指定串,split 按固定分隔符切开。可现实中文本没有这么规矩,电话号码有区号也可能没区号,日志行的前缀有时候是 INFO 有时候是 WARNING,IP 地址第四段可能是 1 也可能是 255。这种情况要继续用精确函数去写,代码就会越来越长、判断越来越碎,最后自己都看不懂分支到底覆盖了什么。正则表达式的价值,就是把那些“符合某类形状”的文本统一抽象成一个表达式,让代码只关心“这种形状是否存在”,而不是一个字符一个字符地判断。
当然这里有个容易被怀疑的问题:为什么很多教编程的书都把正则放到偏后面的第 16 章、第 17 章?我个人的理解是,它和函数、数组这类基础语法不太一样——正则的调试思维偏向“声明式”。你告诉它“开头必须是字母,后面是 6 到 8 位数字”,剩下的是引擎自己去跑状态机。新人如果没写过几次文本处理,很容易背了一堆规则却不知道用在哪。所以它像“内力”而不是“招式”,前期铺垫够了再用,效果要比一上来硬啃好得多。
1.1 我眼里的正则表达式解决的核心问题
核心问题我一直觉得只有三个:校验、提取、替换。校验是判断一段输入是否符合格式;提取是从一段混合文本里捞出子串;替换是把符合规则的内容改成另一种形态。这三个动作覆盖了绝大多数文本处理需求。比如注册页验证手机号是校验,从一段乱糟糟的备注里找出所有订单编号是提取,把用户输入的多个空格折成一个空格是替换。无论你用 Python、Java、C# 还是命令行 grep,本质都在这三件事上打转。
只要把这三点想清楚,后面写表达式时会更有方向。因为你不会急着去看语法大全,而是先问自己:我到底要匹配哪类字符,这个模式可能出现多少次,我关心的部分需不需要在后续代码里引用。带着这三个问题去写,比从第一个元字符背到最后一个元字符要实用得多。
1.2 会用正则的人,和不会用正则的人,差别在哪
区别不在“背了多少语法”,而在“边界感”。同样校验一个 IPv4 地址,新手的答案可能是 \d+\.\d+\.\d+\.\d+,这个写法确实能把“192.168.1.1”匹配出来,但也会高高兴兴地把“999.1.1.1”放过去。懂正则的人会想:IPv4 每一段的范围是 0 到 255,那匹配规则不能只写“数字出现 1 到 3 次”,而是要分 1-199、200-249、250-255 几种情况来组合。
这种“边界感”,本质上是对数据格式本身的理解。正则表达式只是把这种理解翻译成引擎能读懂的东西。所以我说正则不是背出来的,是“想清楚边界以后写出来的”。后面所有实战案例,也都是围绕这个思路展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 正则表达式语法大全:先记得住,再说熟练
网上搜索“正则表达式语法大全”,出来的表都特别长,看起来和查字典一样。真要这么学,容易劝退。我建议把它压成三块来记:字符怎么表示,次数怎么控制,分组和位置怎么处理。这三块能覆盖 90% 的日常需求。至于反向预查、命名分组这些高级能力,等你把一个基础模式写熟了再回头翻不迟。
2.1 字符匹配三兄弟:字符原义、字符集、预定义转义
正则表达式的第一层含义是“匹配哪个字符”。最简单的情况,字面量 abc 就匹配字符串里的 abc。但这显然不够用,所以有了字符集和转义符。
| 写法 | 含义 | 典型例子 |
|---|---|---|
[abc] |
匹配 a/b/c 中任意一个 | b[ae]t 可匹配 bat、bet |
[a-z] |
匹配小写字母 | [A-Za-z0-9] 匹配字母或数字 |
[^0-9] |
排除数字,注意是排除不是取反后的匹配 | [^,] 匹配非逗号字符 |
\d |
一个数字,等价于 [0-9] |
\d{4} 匹配四位数字 |
\w |
一个单词字符,字母/数字/下划线 | 常用于匹配变量名 |
\s |
一个空白字符,含空格、Tab、换行 | 处理带缩进的文本 |
. |
匹配任意字符,默认不匹配换行 | 在日志中很常用 |
用字符集是个好习惯。有人会把“一个数字”直接写成 [0-9],也有人直接写 \d,都行。但如果你匹配的是十六进制数,就必须写成 [0-9A-Fa-f],这时候没有现成的 \h 可以偷懒。另一点容易忽略的,是 . 在正则里的特殊含义。很多新手看到 a.b 以为只匹配 a.b,实际它还能匹配 a1b、a-b。如果确实要匹配一个点号,得写成 \.,或者在字符集里写 [.]。IP 地址每段中间的“点”,就必须转义,这也是后面 IP 校验很容易翻车的原因。
2.2 量词决定数量,贪婪与懒惰决定脾气
光知道匹配什么字符还不够,因为大多数输入都不会只有一位。量词解决的就是“匹配多少次”的问题。
| 量词 | 含义 |
|---|---|
* |
重复 0 次或多次,越多越好 |
+ |
重复 1 次或多次 |
? |
重复 0 次或 1 次,也可用于关闭贪婪 |
{n} |
恰好 n 次 |
{n,} |
至少 n 次 |
{n,m} |
n 到 m 次 |
这里有三个重点。第一,* 并不是“没有上界”这么简单,它的默认行为是贪婪。举个例子,你用 <.+> 去匹配 <a>123</a>,它会从第一个 < 一直爬到最后一个 >,结果匹配到的是整个 <a>123</a>,而不是你心里想的 <a>。要让它在满足规则的前提下尽可能少吃字符,就得在量词后面加个问号:<.+?>。这个“最小匹配”的思路,在抓 HTML 标签和处理日志时特别重要,我见到的误匹配问题,有一大半都是贪婪引起的。
第二,量词和字符组合时要考虑空匹配。比如 \d* 可以匹配空字符串,这在写提取逻辑时容易返回一堆没意义的空结果。你用 .* 过滤行时也要小心,如果目标内容不存在,它不会报错,而是直接匹配成空,后面代码拿空串去处理又会产生新问题。
第三,在字符集内部,量词符号就是普通字符。[.*] 只匹配一个点号或一个星号,不需要你在点号前面再写一遍反斜杠。这也是新手经常会多写、写错的地方。
2.3 分组、反向引用与零宽断言
分组就是用小括号把一部分表达式圈起来。它有两个作用:第一是括号内的内容当成一个整体来使用量词,比如 (ab)+ 匹配连续出现的 ab;第二是把括号里匹配到的内容记住,后续代码能把这个捕获组拿出去用。提取邮箱里的用户名,或者从请求日志里把 URL 后面的 query 参数取出来,都是靠捕获组的。
反向引用是正则里一个反直觉但很好用的功能。([a-z]+)-\1 里的 \1 表示“与第一个捕获组匹配到的内容完全相同”。如果你要判断一个字符串里是否有连续两个相同的单词,直接写 (\w+)\s+\1 就完成了。不过注意,不同语言和工具里反向引用的写法有差异,有些支持 \1,有些要求写成 $1,后面对 Delphi 讲的时候我还会再强调一次。
零宽断言则是“只卡位置、不吃字符”。最常见的三个:^ 匹配字符串开头,$ 匹配字符串结尾,\b 匹配单词边界。还有一个常用的是前向肯定断言 (?=...),比如你匹配一个数字前面必须跟随着“¥”,但不想让“¥”出现在匹配结果里,就可以写 (?<=¥)\d+。平时如果只做数据清洗,这类断言用得不算频繁,但查日志时用来定位“前后文条件”还是很舒服的。
写到这里,语法的主干已经齐了。接下来我直接用三个实际项目把这一章从“认识了”推到“会用了”。
3. 一个经典案例:C# 里判断 IPv4 地址并计算单网段最大可用主机数
有朋友在搜“c# 输入一个字符串用正则表达式判断其是否是一个ip地址并计算其对应的单网段最大”,这问题非常典型,因为它在实际网络配置工具里经常出现:你拿到一个 IPv4 地址,第一件事是验格式,第二件事是算出它所在网段里最多能分配多少个可用 IP。要注意这里问的是“单网段最大”,结合上下文,我理解成给定一个 IPv4 地址,如果配上相应子网掩码,它所属的那个网段里有多少个可用主机地址。我先给个可跑通的做法,然后再告诉你哪些细节最容易看走眼。
3.1 太草率的“\d{1,3}”会放过 999,写法要一板一眼
先看判断 IP 的正则。IPv4 地址是四段“0-255”的数字用点号连接。盲目写 ^(\d{1,3}\.){3}\d{1,3}$,表面看着没问题,实际上能匹配 999.999.999.999,这当然不是合法 IP。而且它没有强制开头到结尾,就把某些字符串内部也误判了。正规做法是每一段都拆成三种情况:250 到 255 是 25[0-5],200 到 249 是 2[0-4]\d,0 到 199 则写成 1?\d?\d,即一位数、两位数或 1 开头的三位数都能覆盖。
下面这段是在 C# 里写的正则:
csharp复制using System.Text.RegularExpressions;
string pattern = @"^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$";
bool IsIPv4WithRegex(string input)
{
if (string.IsNullOrWhiteSpace(input)) return false;
return Regex.IsMatch(input, pattern);
}
注意几个关键点:^ 和 $ 把整串卡死,避免“192.168.1.999”混进去;点号写成 \. 而不是直接写 .;用 (?:...) 非捕获分组,是因为这里我们只想知道整个字符串是不是 IP,不需要把每一段都当捕获组存下来。这样写出来之后,“256.1.1.1”“1.2.3.4.5”都会被正确拒绝。
如果只用正则判断,其实还有一个小坑:某些输入前面或后面带空格,比如 " 192.168.1.1"。前面的语法用了 ^ 和 $,所以空格会导致失败。要宽容空格可以改成 ^\s*...\s*$,但表单输入通常不应该偷偷修掉用户误填的空格,直接从业务上 trim 掉再判断更合适。
3.2 从判断到计算:转成 uint 再做位运算
正则只能解决“格式对不对”。要计算网段和主机范围,就得把 IP 转成整数。原理很简单:IPv4 的每一段都是一个 0 到 255 的数,合在一起看成一个 32 位无符号整数。比如 192.168.1.10,点分十进制是人类视角,32 位二进制才是计算机视角,而子网掩码就是用来切分这 32 位里哪部分是网络号、哪部分是主机号的。
C# 里转换代码可以这样写:
csharp复制using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
static bool IsIPv4ByParse(string input)
{
if (IPAddress.TryParse(input, out IPAddress? ip))
return ip.AddressFamily == AddressFamily.InterNetwork;
return false;
}
static uint IpToUint(string ip)
{
string[] parts = ip.Split('.');
uint result = 0;
for (int i = 0; i < 4; i++)
{
uint octet = uint.Parse(parts[i]);
result = (result << 8) | octet;
}
return result;
}
static string UintToIp(uint value)
{
return $"{value >> 24 & 0xFF}.{value >> 16 & 0xFF}.{value >> 8 & 0xFF}.{value & 0xFF}";
}
这里 (result << 8) | octet 的意思是:每读一段,把前面已经拼好的结果往左移 8 位,把当前这一段塞到空出来的低位。第一段不用移位,第二段移动 8 位,第三段移动 16 位,第四段落在最低 8 位,于是整个 32 位整数就拼好了。反过来从整数显示成点分十进制,就一截一截右移并和 0xFF 做按位与,取出低 8 位。
3.3 单网段里的“最大可用主机数”,别漏了网络号与广播号
真正算网段的时候,掩码才是关键。子网掩码写成前缀长度,例如 /24 表示前 24 位是网络号,后 8 位是主机号。如果给一个单个 IP 要“计算其对应的单网段最大”,必须要有一个前提:你知道它配合的掩码是多少。最常见的是不配任何额外信息时按自然分类或默认 /24 来算,但这个假设要从业务接口确认。下面用一个自定义前缀长度做演示,传入 prefix 后先算出网络号:
csharp复制static uint GetNetworkAddress(uint ip, int prefixLength)
{
if (prefixLength == 0) return 0;
// 掩码:前缀位为1,其余位为0
uint mask = prefixLength == 32
? 0xFFFFFFFF
: (uint)(0xFFFFFFFF << (32 - prefixLength));
return ip & mask;
}
static void ShowSubnetInfo(string ip, int prefixLength)
{
if (!IsIPv4WithRegex(ip))
{
Console.WriteLine("IP 格式不合法");
return;
}
uint ipValue = IpToUint(ip);
uint network = GetNetworkAddress(ipValue, prefixLength);
uint wildcard = 0xFFFFFFFF >> prefixLength;
uint broadcast = network | wildcard;
// 可用主机数 = 2^(32-prefix) - 2
// prefixLength 为 31 时没有可用主机,但实际 /31 有特殊用途,这里按通用公式演算
double totalAddresses = Math.Pow(2, 32 - prefixLength);
double usableHosts = prefixLength >= 31 ? 0 : totalAddresses - 2;
Console.WriteLine($"IP: {ip}/{prefixLength}");
Console.WriteLine($"网络号: {UintToIp(network)}");
Console.WriteLine($"广播地址: {UintToIp(broadcast)}");
Console.WriteLine($"网段内地址总数: {totalAddresses:0}");
Console.WriteLine($"网段内最大可用主机数: {usableHosts:0}");
}
这个公式背后的逻辑很直观:32 位 IP 里,主机位占了 32 - prefixLength 位,所以地址总数是 2 的“剩余位数”次方。每个网段有两个特殊地址,最低位全 0 的网络号,以及最高位按主机位取 1 形成的广播地址,它们不能分配给普通主机,所以要用总数减 2。如果写工具的时候忘了减 2,会让你把广播地址也当成可用主机发出去,这在真实网络环境里属于很隐蔽但严重的失误。
在实际的项目里我一般不在业务层只依赖正则做最终判断,会再用 IPAddress.TryParse 二次兜底。原因很简单:正则负责格式合法性,.NET 的网络解析类负责语义合法性,两层校验配合对用户更友好,输出错误提示时也能区分“格式不对”和“根本不是一个 IP 地址”。
4. grep 命令中的正则表达式,写错了不是没结果,是删错文件
“如何在 grep 命令中使用正则表达式”是运维和开发必搜的一类问题。grep 最直观的用途就是在文件或命令输出里搜行。它内置了一套正则引擎,但又跟很多编程语言里的正则不完全一样,主要差异在“要不要加 -E”、反斜杠要不要写两次,以及 shell 引号会不会吃掉特殊字符。
4.1 BRE 和 ERE:grep 默认和 -E 的区别
grep 在默认情况下使用的是基础正则表达式(BRE),它的写法有点复古:量词 + 和 ? 不是直接使用的字符功能,得写成 \+ 和 \?,而 {} 花括号量词也要写成 \{n,m\}。等你用 grep -E 开启扩展正则表达式(ERE),+、?、|、() 才回到符合现代直觉的写法。
举例来说,要在文件里找连续三个数字的行:
bash复制grep '[0-9]\{3\}' test.txt # BRE 写法
grep -E '[0-9]{3}' test.txt # ERE 写法
我第一次用 grep 时,就因为没加 -E,写出了 grep -E 还好,默认却怎么都匹配不到。现在我的习惯是:凡是要写稍微复杂一点模式,一律 grep -E,降低心智负担;简单的字面量搜索就直接 grep '关键字'。如果你用的 grep 版本支持,也可以直接上 grep -P 启用 PCRE,这时能用的特性几乎和编程语言里一致。但是小心,-P 在不同机器上支持度不是百分百,生产环境脚本里尽量少用。
4.2 在 shell 命令行写正则要小心的三个坑
第一个坑是引号。正则里的 $、*、?、| 这些字符在 shell 里都有特殊含义。比如 $ 在双引号里还会触发 shell 变量展开,坑得更隐蔽。所以涉及到正则,搜索模式最好用单引号包起来。grep -E '^192\.168\.' access.log,这个写法里单引号保证点号、脱字符都原样传给 grep,而不是先被 shell 解释掉。
第二个坑是转义层级。你在编程语言里写正则已经要转义一层,到了命令行里如果再用双引号,就可能有 shell 又转义一层,两层叠加很容易乱。我见过有人写 grep "^192\\.168\\." file,这到底是在给 shell 转义还是给 grep 转义,往往自己都说不清。坚决用单引号,通常能避免这一层烦恼。
第三个坑是中文环境下字符类的边界。有些 locale 下 [[:alpha:]] 匹配到的东西可能比预想范围大。最好在脚本开头显式设置 LC_ALL=C,保证字符集按 ASCII 语义处理,避免不同机器跑出不一致结果。虽然这是环境配置问题,但正则写对了、结果仍不对时,你大概率在这趴着。
4.3 拿 grep 验证 IP 地址的实际写法
单独用 grep 做 IP 校验不是最常用场景,但“从日志里找出所有访问来源 IP”这种需求太常见了。我可以给一个简单可用的例子,在所有访问日志里筛出看起来像 IPv4 的行:
bash复制grep -E '((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' access.log
这段正则和前面 C# 里的思路一致,只是把每一段 0-255 的写法做了等价变化,[01]?[0-9][0-9]? 用来匹配 0-199。实际项目里如果只做粗筛,也可以适当放宽,比如先捞所有含数字加点的行,再到程序里精判。性能上这样反而更快。还有一个经验是,不要用 grep 直接把模式里的 IP 提取出来就结束,因为 grep 默认输出整行,而不是单独打印匹配的 IP。你要提取本身,得用 grep -oE 加 -o 参数,它只输出匹配上的那部分文本,再配 sort/uniq 就能看到去重后的 IP 列表:
bash复制grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort | uniq -c | sort -rn
严格审查时,([0-9]{1,3}\.){3}[0-9]{1,3} 会把 999 也捞进来,但当你只是快速看看来源分布时,先用宽松匹配把候选集缩小,再交给后续精确判断,通常效率比一开始上超长正则要高得多。
5. Delphi 用正则表达式:老开发框架里的新玩法
很多人一搜“delphi 正则表达式”,第一反应是:Delphi 这种老古董还支持正则吗?其实从 Delphi XE 起,官方就在 System.RegularExpressions 单元里提供了正则支持,底层基于 PCRE,能力并不弱。哪怕你守着老项目,也可以用这个单元在字符串校验、数据清洗、日志解析里少掉不少头发。
5.1 别自己造轮子,TRegEx 就能用
Delphi 里的 TRegEx 使用方式非常接近普通类库。最简单的判断,先 uses System.RegularExpressions:
pascal复制uses
System.RegularExpressions;
if TRegEx.IsMatch(Edit1.Text, '^\d{4}-\d{2}-\d{2}$') then
ShowMessage('日期格式正确,类似 2025-06-01')
else
ShowMessage('日期格式不对');
这里 ^\d{4}-\d{2}-\d{2}$ 从字面上理解就是:四位数字开头,横杠,两位数字,横杠,两位数字,然后结束。因为前面说了我们要判断整个输入,所以必须有 ^ 和 $。写完后实际测试里最常出的问题反而是反斜杠和 Delphi 字符串转义的“裤腰带互相绊脚”:\d 在 Delphi 字符串字面量里要写成 '\d' 这种单引号字符串,这不是正则的问题,而是因为 Delphi 字符串用单引号,反斜杠并不需要像 C# 那样在普通字符串里转义,所以你写 '\d' 就能正常工作。如果你从网上下了一段 C# 代码没经过大脑直接粘进去,看到 "\\d" 变成了 Pascal 里两个反斜杠,匹配对象就变成字面意义的“一个反斜杠加一个 d”,那自然怎么都匹配失败。
5.2 常见 Delphi 正则混淆点:反斜杠和字符串转义
Delphi 的历史版本里,字符串类型的处理方式有过调整。现在主流版本默认字符串是零终止的 UnicodeString,单引号内反斜杠不具备转义意义。因此常见的正则写法是这样的:
pascal复制var
Pattern: string;
Match: TMatch;
begin
Pattern := 'ID[:=]\s*(\d+)';
if TRegEx.IsMatch(Content, Pattern) then
begin
Match := TRegEx.Match(Content, Pattern);
if Match.Success then
ShowMessage('抓到ID号: ' + Match.Groups[1].Value);
end;
end;
这里 Groups[1] 对应正则里的第一个捕获组,也就是括号括起来的 (\d+)。Delphi 的 TMatch.Groups 索引从 0 开始,第 0 组是整个匹配文本,第 1 组才是你真正想提取的 ID。这个细节和 C#、Java 一致,但和很多脚本语言“先取 group(1) 是第一个括号”的习惯也能对上,不冲突。只要你别把 Groups[0] 当成第一个括号就行,我见过有人把整段匹配结果当成 ID 存库,后来查数全串了。
5.3 实际项目中的一段去重/提取示例
举一个我在一个老 Delphi 维护项目里真处理过的需求:界面上有一段备注,里面可能以“#订单号:123456”格式夹着多个订单号,要全提出来拼到另一个字段。正则写法很简单:
pascal复制var
Regex: TRegEx;
Match: TMatch;
Found: string;
begin
Regex := TRegEx.Create('#订单号[::]\s*(\d{5,12})');
Found := '';
Match := Regex.Match(Content);
while Match.Success do
begin
Found := Found + Match.Groups[1].Value + ',';
Match := Match.NextMatch;
end;
// 去掉最后多出来的逗号
if Found <> '' then
Delete(Found, Length(Found), 1);
end;
这段代码里我把中文冒号和英文冒号都放进字符集 [::],避免用户输入不统一导致漏抓。正则默认的 \d{5,12} 是按数量卡订单号长度的,项目里订单号确实是 5 到 12 位数字。真正上线后还遇到过一个意外情况:备注里既有“订单号”也有“订单号码”,后面这个“码”字会破坏匹配,好在我取的是捕获组而不是整体,问题才暴露得早。后来我把前面的前缀改成 订单号\s*[码]?,才把一种业务叫法覆盖全。
Delphi 里 TRegEx 还支持编译选项,比如 roIgnoreCase 用来忽略大小写。如果源文本里既有 #order 也有 #Order,用 TRegExOptions() 里带 roIgnoreCase 是很省事的。但别为了省事啥都忽略大小写——密码、验证码、文件扩展名这些大小写敏感的场景,忽略大小写可能把一个必须区分的检查变成漏网之鱼。
6. 常见问题与排查技巧实录
这部分整理的都是我在实际排查时经常遇见的问题,写成一份快查表,方便你以后遇到类似的“诡异情况”快速定位。
6.1 IP 正则常见翻车现场
| 现象 | 原因 | 正确做法 |
|---|---|---|
192.168.1.1 匹配不上 |
点号被当成任意字符,匹配的文字不是点号 | 写成 \. 或用 [.] |
999.1.1.1 被当成合法 IP |
只写了 \d{1,3},没限制范围 |
按 0-255 拆三段组合 |
abc192.168.1.1xyz 被误判 |
缺少 ^ 和 $ 锚定 |
明确要求整串匹配时加首尾锚定 |
| 前导空格导致校验失败 | 输入没有 trim 就直接匹配 | 业务层先 Trim,或者表达式里预留 \s* |
| 只靠正则、不考虑网段 | 正则只能判格式,无法算语义 | 第二层 IPAddress.TryParse 验证 |
很多人在踩过一遍之后会总结出最长、最严谨的 IP 正则,恨不得一个表达式把 IPv4、IPv6 全吃了。我不建议这么干。可读性差倒是其次,关键是正则越长,维护成本和出 bug 概率越高。更合理的是按场景拆:入口校验用完整规则,日常日志粗筛用宽松规则,内部判断再用语言自带的函数兜底。
6.2 灾难性回溯:一运行就卡死
正则引擎在实现上分了两种流派:DFA 和 NFA,大部分编程语言运行时用的是回溯型 NFA。好处是功能强,支持反向引用和零宽断言,坏处是一旦模式写得不好,在某些输入上可能会出现灾难性回溯,简单说就是匹配时间随着输入长度指数级上升,程序像卡死一样。
最容易触发的模式是多个量词嵌套。网上常见那句“匹配 HTML 标签”的 <.*>.*</.*> 就是危险先生。嵌套的量词会让引擎反复试错,中间再加一个很长、很接近但偏不匹配的字符串时,CPU 能烧得很高。我的经验是:先检查有没有连续的 .*;能限定字符类的尽量限定,比如用 [^<]* 而不是 .*;处理 HTML 时别强写一个万能正则,还是用解析器更靠谱。实际项目里遇到性能问题,我会先在工具站上缩小输入量跑,确认是正则写法问题后再优化,不会一上来就怀疑正则库的性能。
6.3 这些心得我建议你记在本子上
第一,正则里加注释很难,所以不要写“一眼看不懂的宇宙正则”。把一段复杂表达式拆成几段,在代码里提前写好匹配逻辑,再分别用有意义的变量名保存,比硬塞进一行强一百倍。第二,不要相信一次写出来的正则,务必准备一份边界测试集。判断 IP 就准备 0.0.0.0、255.255.255.255、256.0.0.1、1.2.3、空字符串,五个样例跑完,大部分低级错误都藏不住。第三,时刻区分“贪婪”和“懒惰”。很多提取结果多了是贪婪,少了还是贪婪,看完结果自己都不知道为什么。只要你在写示例时产生过“它为什么多吞了一段”的疑问,十有八九就是量词的贪婪性格在作祟,默认不写的量词都是在有能力多吞时就多吞。
我从第一次接触正则到现在,最大的体会是:它不需要你背完整本语法书,真正需要的是把“我要匹配的东西长什么样”这个问题想透。想清楚边界,表达式的框架就出来一半;框架对了,剩下的字符、量词、分组只是往里填砖。愿你下次遇到文本处理问题,第一反应不再是写十层 if,而是拿起正则,一击命中。
