正则表达式这东西,我刚入行那会儿觉得它就是个“玄学符号堆”,后来被一个日志分析的活儿逼着啃了一个周末,才真正尝到甜头。现在不管是写脚本过滤脏数据、抓网页提取关键字段,还是做后端接口的参数校验,我都会条件反射地先想想“能不能用正则解决”。它像一把瑞士军刀,体积小,但几乎每个项目里都有一两个场景非它不可。这篇就把我这些年用正则的经验、踩过的坑、以及从Java、PHP到爬虫场景下的实际用法通通理一遍,希望能帮你少走点弯路。
1. 正则表达式的核心思路与选型逻辑
1.1 正则到底解决了什么“痛点”
先说个最直观的例子:给你一段用户提交的文本,里面有手机号、邮箱、身份证号、订单号混在一起,你需要把它们逐个挑出来,并且校验格式是否正确。如果用字符串截取的办法,得写一堆indexOf、substring,遇到变长格式就崩。正则的做法是定义一套“模式”,让引擎去文本里做模式匹配,一条规则就能覆盖几百种情况。
本质上,正则是一种描述字符串形态的声明式语言。它不关心字符串的具体内容,只关心结构的“形状”。比如\d{11}不用管数字是什么,只要是11位数字就命中。这种“按形状匹配”的思路,让它在文本处理领域几乎是唯一解。日常常见的场景包括:
- 校验类:手机号、身份证、邮箱、URL、IP地址是否合法。
- 提取类:从HTML源码里抓链接、从日志里抽时间戳和错误码。
- 替换类:批量把文本中的日期格式从
2023-10-01转成01/10/2023。 - 切割类:按分隔符拆分CSV行,或者把长文本按标点切分。
我在项目里很少写“一次性用完就丢”的正则,更喜欢把常用的表达式沉淀成工具类或配置文件。后面你会看到,正则的维护成本主要不在写,而在理解,所以规范命名和加注释比写出一个炫技的“一行流”重要得多。
1.2 流派差异:传统NFA与PCRE兼容性
刚上手的时候很容易被不同语言的正则写法搞懵。Java正则、PHP正则、Python正则、JavaScript正则,看着差不多,实际细节差很多。这背后是正则引擎实现的历史问题:绝大多数编程语言用的是传统型NFA(非确定型有限自动机),而grep、awk等命令行工具早期用的是POSIX标准的那套。
这导致最典型的差异就是字符类、转义、命名分组和回溯行为的区别。例如\d在Java和PHP里默认表示[0-9],在Python里也是,但如果你穿了re.ASCII标志就只匹配ASCII数字;而JavaScript的\d始终是ASCII数字。再看命名分组:Python写(?P<name>...),Java写(?<name>...),PHP写(?P<name>...)或者(?<name>...)都行。新手最容易踩的坑就是跨语言复制正则不调整语法,结果运行时报错或者匹配结果完全不对。
所以我的建议是:先掌握元字符和逻辑组合的通用知识,再针对你使用的语言查一遍该语言支持的“方言特性”。不要试图背住所有语言的写法,但得知道去哪里查,以及哪些特性是跨语言通用的(比如.*?、[a-z]、^、$这些基本都一致)。
1.3 盲写 vs 可视化工具
很多人一上来就打开IDE开始敲正则,敲半天发现匹配不对,然后又瞎加了一堆转义符号,最后整个表达式变成一团乱麻。我现在的习惯是先在可视化工具里把正则“画”出来,确认匹配逻辑无误,再放进代码里。常用的工具有 regex101、regulex、regexper 这类,能实时显示分组、量词作用范围,有些还能分析回溯的步骤数。这类工具对新手特别友好,因为你能直观看到(abc)+和abc+的区别到底在哪。
不过要提醒一点:可视化工具默认的语言引擎往往是PCRE或者JavaScript,如果你最终要跑在Java或者PHP里,最好切换一下右侧的“Flavor”选项,或者至少记住它们的语法差异。否则在工具里测试通过,复制到 IDE 里可能直接抛异常(比如(?<name>...)在Python旧版本里不支持)。
2. 核心语法细节与实操要点
2.1 元字符和量词的“语义边界”
很多人用正则用了很久,可能还是靠“试”而不是靠“懂”。我觉得最关键的点在于理解匹配的粒度。举个例子,a+匹配一个或多个a,听上去很简单,但a+?是非贪婪匹配,它尽可能少地匹配字符,也就是只匹配一个a。这个差异在提取HTML标签的时候尤为重要。
再比如[a-z]+和[a-z]*的区别:+表示至少一次,*表示零次也可以。用*的时候,正则引擎实际上可以在任何位置匹配到一个“空字符串”,这会导致split或replace时出现一些莫名其妙的结果。我遇到过有人用[0-9]*去校验字符串全是数字,结果空字符串也能通过校验,这就是没有分清“零次”和“至少一次”造成的。
还有一个容易被忽视的是^和$的语义。在Java默认模式下,^匹配整个字符串的开头,$匹配整个字符串的结尾;但在JavaScript里,^和$在m标志(多行模式)下会变成匹配每一行的开头和结尾。如果不注意,你拿Java的^\d{11}$去JS里配一个多行文本,可能只校验了第一行或者碰巧匹配了中间某一行。所以能用matches()的就别用find(),matches()要求整个字符串完全匹配,find()只要找到子串就算成功,语义差别极大。
2.2 字符类的陷阱与转义规则
字符类里的“减号”是另一个经典坑。写[a-z]没问题,但如果你要匹配“a、-、z”这三个字符,直接写[a-z]会被当成范围。正确做法是把减号放在字符类的最前面或最后面:[-az]或者[az-]。同样,^在字符类开头表示取反,[^a-z]表示匹配任意非小写字母。如果想匹配字面意义的^,需要写成[\^]或者把^放到非开头位置。
转义要分两层看:一层是正则语法本身的转义,另一层是宿主语言字符串里的转义。在Java里写\d,实际上字符串中要写成"\\d",因为Java字符串的反斜杠本身需要转义。在PHP单引号字符串里写'\\d'也会得到一个反斜杠加d,很多时候你需要的反而是'\\d'传入正则函数时被解释为\d。就是这种“双重转义”让很多人抓狂。我的建议是,遇到复杂的反斜杠组合时,先在工具里调试通过,再复制到代码里,然后跑个最小测试用例确认。
2.3 分组与反向引用的实战价值
分组除了能提取片段,还能用来做“前后一致”的匹配。比如要匹配“ABAB”这种重复形态的字符串,可以用([A-Z])\1。\1表示引用第一个分组捕获的内容。这个特性在处理重复单词、成对标签时特别有用。HTML里匹配成对标签,本质是捕获开标签的名称,然后在闭标签处反向引用,类似<(/?)([a-zA-Z]+)>再配合逻辑判断。当然,真要解析HTML还是建议用专门的解析库,正则只能处理结构规整、不嵌套的简单情况,嵌套标签是正则的天然短板。
非捕获分组(?:...)是我个人非常喜欢的一个符号。它表示把这一段内容当作整体参与量词或分支判断,但不保存捕获内容。例如校验电话号码时,(?:010|021)-?\d{8}把区号的选择作为一个整体,而(?:)不产生额外分组编号,后面对\1、\2的引用不会乱。这个习惯能避免“分组编号偏移”的坑。我见过有人写了一大串(\d{4})-(\d{2})-(\d{2}),后面要扩展到支持YYYY/MM/DD,直接在分支里加了(\d{4})/(\d{2})/(\d{2}),结果分组编号全变了,后面的引用全错。这时候把不需要的分组改成非捕获结构,问题就没了。
2.4 贪婪、懒惰与占有:性能的胜负手
正则性能卡顿通常不是匹配本身的问题,而是回溯。经典例子是用(.*)*去匹配超长字符串,这种“嵌套量词”在极端输入下会产生指数级的回溯路径,甚至让CPU飙满。写过爬虫的人可能都经历过,一条正则跑在几千行HTML上,卡了好几秒。这里有几个经验:
- 能用字符类限定范围,就不要用点号匹配万物。比如提取HTML标签属性值,用
"[^"]*"就比".*"安全得多,它不会跨界匹配。 - 能用非贪婪量词解决的问题,不要用贪婪量词勉强凑。比如
title="(.*?)"比title="(.*)"在多个title=并存时更容易定位到最近的引号。 - 能不用回溯就不回溯,实在需要处理嵌套结构,建议换状态机或者解析库。
说得有点抽象,给个具体案例:我想从一段日志中提取“时间戳后面跟着的日志级别和消息”,日志行长这样:
2025-05-01 12:00:01 ERROR 连接超时,重试第3次我一开始写(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\w+)\s+(.*),看起来没问题。但如果日志里某些行中间有多余空格,或者消息很短,甚至消息为空,(.*)就能匹配空字符串,这没问题,但引擎为了匹配“尽可能多”,会先把整行吞掉,再一点点“吐”出来找$。对于单行文本无所谓,但如果把很多行合并成一个长字符串再用find(),这个贪婪的.*就会吞掉后面十几行的内容。改成(.*?)后,它会匹配到换行前为止(取决于是否开启DOTALL),行为立刻符合直觉了。
3. 多语言与爬虫场景下的实战过程
3.1 Java中的校验落地:以身份证号为例
Java正则用得最多的地方就是表单校验。身份证号这类文本,如果只做基础格式校验,其实很简单,但很多人忽略了几点细节。我拿“Java 身份证号码如何用正则表达式校验”这个场景展开说说。
身份证号码是18位,前17位是数字,最后一位可能是数字或X。最基础的正则:^\d{17}[\dXx]$。这个能挡住“长度不对”“含字母且不是X”等明显错误,但是挡不住“出生日期不可能”这类逻辑问题。比如999999199999999999这串也能匹配,显然不是合法身份证。所以黑盒校验需要更细:身份证的第7到14位是出生年月日,并且要知道年月日是否真实存在。单靠正则做不到完整校验,但可以用更精细的正则做前置过滤,例如:
^\d{6}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$这个表达式把年份限制在18、19、20开头,月份限制在01到12,日期限制在01到31。它仍然无法校验2月30日,但已经能过滤掉大量伪数据。真正严谨的校验必须配合代码逻辑:通过LocalDate解析日期并抛出异常,再计算校验位。正则在这里的作用是“快筛”,把格式明显不对的请求直接挡在门外,避免进入下游逻辑。
Java代码里我会这样写:
private static final Pattern ID_CARD_PATTERN = Pattern.compile("^\\d{6}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$"); public boolean isIdCard(String idCard) { if (idCard == null || !ID_CARD_PATTERN.matcher(idCard).matches()) { return false; } // 这里再做日期合法性和校验位计算 return validateBirthDate(idCard) && validateCheckDigit(idCard); }注意我用的是matches()而不是find()。很多初学者直接Pattern.matches(regex, input)就是整串匹配,也可以,但如果你用matcher.find(),会发现“包含18位身份证格式的任意文本”也能通过,因为find()是“包含匹配”。Java的matches()必须全字符串匹配,这点和JavaScript里的test()不同,JavaScript的test()是“找子串”,除非你用^和$把它夹住。所以跨语言移植时,第一件事就是确认API的匹配语义。
3.2 PHP中的捕获与替换:从日志里筛数据
PHP的正则主要用preg_match、preg_match_all、preg_replace。我最常干的一件事就是从乱七八糟的日志字符串里批量提取用户IP和访问路径。PHP的preg_match_all会把所有匹配结果塞进一个三维数组,初看很反直觉,但理解了PREG_PATTERN_ORDER和PREG_SET_ORDER之后就顺了。默认是前者,意思是$matches[0]是所有完整匹配,$matches[1]是所有第一个分组,以此类推。如果你想要“每一条记录一个二维数组”这种结构,就用PREG_SET_ORDER。
举个例子,日志里有这么几行:
192.168.1.10 - - [01/May/2025:12:00:01 +0800] "GET /api/users HTTP/1.1" 200 1024 192.168.1.11 - - [01/May/2025:12:00:02 +0800] "POST /api/orders HTTP/1.1" 500 512要提取IP、请求方法和路径,可以这样写:
$pattern = '/^([\d.]+) .*?\[(.*?)\] "(\w+) (\S+?) HTTP\/[\d.]+" (\d{3})/m'; preg_match_all($pattern, $logText, $matches, PREG_SET_ORDER); foreach ($matches as $m) { echo "IP={$m[1]}, TIME={$m[2]}, METHOD={$m[3]}, PATH={$m[4]}, STATUS={$m[5]}\n"; }这里有两个细节:一是正则末尾加了m修饰符,让^和$能匹配每一行的开头和结尾,这样preg_match_all才能逐行命中;二是(\S+?)用来匹配路径,避免路径中的空格或者后面HTTP版本号干扰。如果不加?,贪婪模式下(\S+)也能匹配到空格前为止,因为\S本身就是非空白字符,所以这里贪婪非贪婪其实都一样。真正的坑在于.*?匹配时间戳那一部分,如果日志行里出现多个[,贪婪版本会匹配到最靠后的一个],非贪婪则从前向后找,效率也更高。
PHP的正则函数返回false表示编译失败,这在调试时很有用。一旦表达式里有不平衡的括号,或者使用了当前PHP版本不支持的语法,preg_match会直接返回false,还给出一个preg_last_error()错误码。我遇到过在正则里用了(?<name>),但服务器PHP版本是5.2,运行时直接报“unrecognized character after (?”,换成(?P<name>)才解决。这提醒我:无论是Java还是PHP,正则语法向后兼容性并不完美,升级依赖时要回归测试正则相关的功能。
3.3 爬虫里的正则实战:从HTML中提取结构化数据
爬虫场景下,正则往往和requests、BeautifulSoup、lxml这些库混着用。很多爬虫教程一上来就说“用正则抓数据”,但我的经验是:能用XPath或CSS选择器,不要用正则解析HTML。原因很简单,HTML是嵌套结构,正则不具备真正的“递归匹配”能力。HTML还可能出现在注释里、属性值里、JavaScript代码里,用正则会误伤。比如你想提取所有<div class="title">的内容,一个不小心就把嵌套在内部的同名div也匹配进来。
但是正则并不是不能用于爬虫,它适合处理那些“HTML里夹杂的JSON数据”和“特定格式的字符串”。比如页面里有一段var userId = "12345";的JS代码,你不想引入一个完整的HTML解析器,就写一个正则:
/var userId\s*=\s*"(\d+)"/i这种场景很常见。再比如从script标签里提取JSON字段,因为页面可能是SSR渲染,数据被塞进一个window.__INITIAL_STATE__ = {...}变量里,这时候用正则抓最外层的一对花括号,再用json.loads去解析,比硬用解析器更省事。注意,花括号嵌套也会让正则陷入困境,所以我一般先通过定位window.__INITIAL_STATE__找到等号位置,然后用“括号计数”或者直接截取到;</script>前面,再交给JSON解析。正则只承担“定位锚点”的责任,不承担“完整解析”的责任。
我之前写过一个抓取商品列表的小爬虫,页面中商品信息是以JSON-LD方式嵌入的,结构比较规整。我的做法是先re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S),把JSON-LD块整体提取出来,再json.loads。这样即使里面有嵌套的},只要第一个匹配是非贪婪的(.*?)</script>,就能正确切到第一个闭标签。不要在这个场景里尝试写一个能匹配“任意平衡花括号”的正则,那是在挑战正则引擎的边界,没有意义。
3.4 常用正则速查表与选型建议
分享一批我项目里沉淀下来的常用正则,几乎可以直接复制使用。
| 用途 | 正则 | 说明 |
|---|---|---|
| 手机号(中国大陆) | ^1[3-9]\d{9}$ | 目前主流号段,不含虚拟运营商 |
| 邮箱 | ^[\w.+-]+@[\w-]+(\.[\w-]+)+$ | 简版,没有做域名合法性校验 |
| URL | ^https?://[\w.-]+(:\d+)?(/[\w./?%&=-]*)?$ | 够用,但不要用于复杂URI |
| IPv4 | `^(?:(?:25[0-5] | 2[0-4]\d |
| 日期 YYYY-MM-DD | `^\d{4}-(0[1-9] | 1[0-2])-(0[1-9] |
| 时间戳 HH:mm:ss | `^([01]\d | 2[0-3]):[0-5]\d:[0-5]\d$` |
| 用户名 | ^[a-zA-Z][a-zA-Z0-9_]{2,15}$ | 字母开头,可含数字下划线,长度3-16 |
| 中文字符 | [\u4e00-\u9fa5] | 常用简版,实际汉字区间更大 |
| 整数或小数 | ^-?\d+(\.\d+)?$ | 支持负数和浮点 |
这些不是“银弹”,比如[\u4e00-\u9fa5]在Java和Python里写法相同,但在PHP里要写成/[\x{4e00}-\x{9fa5}]/u,因为PHP需要启用u修饰符才能正确处理Unicode。跨语言时这种差异最坑,所以我在表格里标注的用途是“参考”,真正落地前一定要在目标语言里跑一遍最小用例。
选型上有几个原则:校验场景用^...$锚定整个字符串;提取场景不要用锚定,并且要合理使用非贪婪量词;替换场景注意区分替换全部还是替换第一个(Java的replaceAll和replaceFirst,PHP的preg_replace默认替换全部)。如果你在做一个ETL流程,数据量大但是结构固定,可以优先考虑“正则先行过滤,再交给结构化工具处理”的组合拳。
4. 常见问题与排查技巧实录
4.1 明明能匹配,代码里却输出不了结果
这是新手咨询量最高的问题。现象是你在regex101里用同样的表达式和测试字符串能高亮命中,但放到Java/Python里却输出空。排查顺序一般是这样:
- 检查字符串是不是被转义坏了。Java里
\d没写成"\\d",实际变成d的某种控制字节,编译正则时不报错但匹配永远不命中。 - 检查调用方法。
find()和matches()不同,test()和match()也不同,先确认自己用的是“整串全匹配”还是“包含查找”。 - 检查是否默认区分大小写。正则里
[a-z]不会匹配A,要么加i修饰符,要么显式写成[a-zA-Z]。 - 检查输入字符串里是否有不可见字符,比如BOM头、全角空格、换行符。这在读文件场景下特别多。
我实际遇到过从网页复制文本到程序里,字符串里带了一个不换行空格(\u00A0),\s在某些语言的正则里默认不匹配这个字符,导致边界匹配失败。解决方法是用\p{Zs}或者先做replaceAll("\\u00A0", " ")预处理。
4.2 匹配结果比预期多或者少
多匹配常见原因是贪婪量词范围过大。比如要从文本中提取所有书名号里的内容,有人写《.*》,如果一行里有多个书名号,比如“《红楼梦》与《西游记》”,这个正则可能从第一个《一直吞到最后一个》为止。应该写成《.*?》。
少匹配常见原因是对字符类理解不完整。比如要匹配带小数点的数字,只写了\d+\.\d+,结果整数123没匹配上,且123.这种畸形数字也匹配不上。如果你的需求是“能容忍缺省小数部分”,就应该写成\d+(\.\d+)?。这本质上是用“可选分组”扩展表达能力。
4.3 性能突然变慢:回溯与灾难性回溯
我印象最深的一次事故是处理一批用户昵称,逻辑是“不允许出现连续重复字符”。我写了一个正则用来找连续重复的字母:([a-zA-Z])\1。这个很快。但后来需求升级为“找出存在至少三个重复的子串”,有人写了([a-zA-Z]+)\1\1,结果在大文本上卡死了。这就是典型的灾难性回溯,因为外层的[a-zA-Z]+有无数种切分方式,引擎需要穷举组合。排查时可以用超时控制来保护:Java里可以设置Pattern.compile后由Matcher配合region限制搜索范围,或者干脆在正则里加“原子组”(?>...)来阻止回溯。PHP没有内置超时参数,所以更要注意别写无界嵌套。
我的建议是,写正则时心里估算一下“分支和量词的笛卡尔积”。如果某个量词修饰的逻辑既能匹配长串又能匹配短串,并且后面还有相邻的量词,就要警惕回溯爆炸。能用字符类[^"]*或者[0-9]+之类限定字符集合的,就不要用.。
4.4 编码问题:中文、Unicode与修饰符
中文匹配的坑在PHP里非常典型。用/[\x{4e00}-\x{9fa5}]+/必须加u修饰符,写成/[\x{4e00}-\x{9fa5}]+/u,否则会报preg_match(): Compilation failed。原因是不加u时,PHP按字节处理字符串,而中文字节序列会被拆散。Java里没有这个问题,因为Java的字符串本来就是UTF-16编码的char序列,[\\u4e00-\\u9fa5]可以直接用。Python里如果字符串是str类型,re模块默认按Unicode处理,但没特殊需求建议写re.UNICODE或直接使用\w的unicode版本。
还有一个容易被忽略的点:正则里写\b单词边界时,对中文支持并不友好。\b基于“字母数字”与“非字母数字”的边界判定,在中文文本里,每个汉字和空格之间也可能构成边界,导致\b测试\b这类表达式产生反直觉的结果。如果要在中文文本中提取特定词,不建议用\b包裹,直接用查找即可。
4.5 工具和调试经验
我把自己的排错流程分享出来,你可以参考:
- 写正则前先明确“匹配成功”的判定标准:是“字符串里存在”还是“整个字符串完全匹配”。这会决定加不加
^和$。 - 在可视化工具中分段测试。把一个复杂正则拆成若干小块,分别验证每个块的命中情况,再拼接。这种“分而治之”的方法能迅速定位是哪个分支导致整体失败。
- 用“最小失败用例”逐步加字符。比如匹配失败时,先把字符串删到只留下一个能代表场景的关键字,再逐步扩展开来,观察哪一步开始失配。
- 在代码里写好注释。我会在每个重要正则上方用注释写清楚它的输入样例、输出样例和兼容性说明。这样三个月后再看,不至于靠猜。
- 定期回归。正则和日期解析一样,新的数据格式来了就可能失效。建议把典型的合法/非法数据做成单元测试,每次调整表达式都跑一遍,防止老功能被改坏。
5. 进阶技巧:从“会写”到“用得巧”
5.1 把正则拆成可维护的“半成品”
我见过有些代码里一条正则长达两百个字符,全挤在一行,虽然能跑,但没有任何人愿意去改它。我的做法是用“字符串拼接”或者“注释分组”把正则拆成有名字的块。比如校验身份证号的正则,我会这样组织:
地区码 = \d{6} 出生日期 = (18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01]) 顺序码 = \d{3} 校验码 = [\dXx] 整体 = ^地区码出生日期顺序码校验码$在Java里,我可以把不同的片段定义为静态常量,再拼接到最终Pattern里。这样做的好处是,将来出生日期的规则变了,只需要调整对应的常量,不用动整个表达式。还能在常量上加注释说明“这里为什么允许31日”。
5.2 利用正则做“渐进式清洗”
处理脏数据时,正则经常被用来做“分层清洗”。我最近在处理一批用户地址文本,里面混合了电话、邮箱、QQ号、微信号,我的策略不是写一个超级正则去匹配所有内容,而是写几个小正则按优先级依次提取并替换成占位符:
- 先提取并移除邮箱:
[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9.]+ - 再提取并移除手机号:
1[3-9]\d{9} - 再提取纯数字的较长串:
\d{5,} - 剩下文本做分词或人工复核。
这种渐进式清洗的好处是,每一层独立可测,清洗结果可追溯,不会因为一条巨大正则在某一条数据上失效而全盘失败。这也是我在团队里比较推崇的做法:正则是流水线上的刀具,不是一次性的多功能瑞士军刀。
5.3 正则与解析器配合的“最佳分工”
HTML/XML/JSON这类结构化数据,解析器是主场,正则顶多当侦察兵。我做了这么多年爬虫,经验很明确:能用XPath、CSS选择器或json.loads解决的,绝不用纯正则去啃。正则在爬虫里的高价值场景反而是“解析器不好处理的部分”,比如从JS变量里提取数据、从内联样式中抽URL、从混乱的日志中切字段。举个例子,一个网页里有很多链接,如果只想抓取“以/item/开头且不以#结尾”的链接,XPath可能要先过滤出所有a[href],再遍历判断。用正则直接扫描HTML文本也能做到,但可能误伤JavaScript里的字符串。所以我会先用解析器拿到DOM树,提取所有href属性值,再对属性值列表逐个跑正则做规则判断。这样分工最干净。
5.4 修正常见的“坏味道”写法
写正则时间久了,慢慢能看出一些“坏味道”,遇到了我会主动修正:
- 用
.*匹配任意字符但不考虑换行。这是最常见的坏味道。大多数语言默认.不匹配换行,所以跨行文本会直接失配。改法是明确使用[\s\S]*或者re.DOTALL/(?s)修饰符,并且使用非贪婪版本。 - 用大量
|连接静态字符串。比如(cat|dog|bird)这种写法没错,但如果列表很长,维护起来很痛苦。建议把分支列表放在代码里,拼装模式字符串时用re.escape转义。 - 过度使用
(?:)来避免捕获。适度用非捕获分组没错,但如果你发现一长串都是(?:...),考虑用字符类或者原子组替代。 - 总是在正则里写死空格。输入数据的空白可能是不定长的,使用
\s+比" "更友好,注意使用\s时可能匹配到换行,如果不想匹配换行,就改成[ \t]+。
6. 结尾:我的一些个人体会
正则这门手艺,入门容易精通难,但真正精通的标志不是能写出多复杂的表达式,而是知道什么时候不用正则。我见过太多同学拿着正则去解析JSON、去匹配HTML嵌套标签,最后搞出一堆补丁,维护成本比用解析器高好几倍。反过来,在一些数据清洗、格式校验、日志提取的小场景里,正则又是性价比最高的工具,短短一行就能省掉几十行字符串处理代码。
我个人在实际操作中的一个习惯是:每次写完一个比较复杂的正则,都会在代码里放三组测试用例——一组是合法输入,一组是非法输入,一组是边界输入(空字符串、超长字符串、含特殊字符的字符串)。这让我在改需求时特别安心。还有一个小技巧:如果你要匹配的字符串里包含/、#、~这类字符,而你的语言支持自定义定界符(比如PHP的#...#),用定界符能省掉大量的转义。另外,命名分组一定要用起来,(?<year>\d{4})比(\d{4})在后续代码里清晰得多。
最后再分享一个建议:不要试图背正则,而是要会查、会用工具、会拆解问题。网上那些“史上最全正则表达式合集”,大部分你根本用不到,真正需要的时候,按场景搜索、测试、验证,然后存进自己的工具库就好。希望这篇能帮你在正则这条路上少踩一些坑,遇到文本处理任务时,能有底气地说一句“这个我懂,能用正则解决”。