标志位:i g m s x u,一行选项改变整个引擎行为

正则的"全局设置面板"

同一个模式,配上不同标志(flag),行为可能天差地别。标志位是正则的全局设置面板:大小写、多行、单行、宽松排版……它们不属于模式本身,而是修饰整个匹配过程的开关。JavaScript 把标志写在字面量尾部 /pattern/i,Python 用 re.I 这类常量传入,Go 在模式里内联 (?i)——位置不同,语义相通。

常用标志对照(Python 与 JavaScript):

作用 Python JavaScript 一句话说明
忽略大小写 re.I i error 与 ERROR 一视同仁
多行模式 re.M m ^ $ 改锚每一行
点号匹配换行 re.S s . 不再跳过换行符
宽松模式 re.X 无(JS 不支持) 允许换行与注释排版
全局匹配 无(函数自带) g 找出所有匹配而非第一个
Unicode 处理 默认开启 u 按码点而非代理单元处理
粘性匹配 无 y 只从 lastIndex 处起配

Python 默认按 Unicode 解释 \w \b(re.U 是历史遗留的显式开关),而 JS 必须加 u 才启用完整码点语义——这张表里藏着不少跨语言陷阱,本章逐个拆解。

i:大小写无关的检索

日志检索里最常用。同一份日志里 error、Error、ERROR 混杂,一条 i 全收:

import re
print(re.findall(r"error", "Error: 磁盘满 ERROR: 超时 error: 重试", re.I))
# 输出:['Error', 'ERROR', 'error']

不加 re.I,后两种全部漏网。这是"结果看起来对、其实少一半"的典型来源:写检索类正则前先问自己,大小写变体要不要收。

s 与 m:两个容易搞反的"行"

re.S(dotall)只改一个字符的语义:让 . 匹配换行符。处理跨行的 HTML 片段、多行注释时没有它就是空手:

import re
block = "<p>第一行\n第二行</p>"
print(re.findall(r"<p>(.*?)</p>", block))
print(re.findall(r"<p>(.*?)</p>", block, re.S))
# 输出:[]
# 输出:['第一行\n第二行']

re.M(multiline)改的则是锚点语义:^ $ 从"整段首尾"升级为"每行首尾"。两者名字里都带"行",方向却完全相反——S 管 .,M 管 ^ $,这句口诀帮你避开 90% 的混淆。

x:给正则"排版"的权利

Python 的 re.X(verbose)允许在模式里随意换行、空格和写 # 注释,空白字符不再参与匹配。业务正则一长,它就是救命的:

import re
pattern = re.compile(r"""
    1[3-9]     # 第一段:现行号段,第二位 3-9
    \d{4}      # 第二段:年份段示例
    \d{5}      # 第三段:剩余编号
""", re.X)
print(pattern.findall("售后热线登记 13812348000"))
# 输出:['13812348000']

同一模式,打开 re.X 后从"天书"变成带注释的文档。注意两个代价:模式里的真空白要写成 [ ] 或 \s,字面量 # 要转义 \#。JavaScript 没有 x 模式,JS 程序员的长正则通常用拼接字符串加注释的方式弥补,或者迁移到支持 verbose 的构建流程。

g 与 lastIndex:JS 独有的状态陷阱

JS 的 g 标志让正则"记住"上次匹配到哪——每次 exec 或 test 后,lastIndex 前移,下次从那里继续。状态藏在正则对象里,复用时就炸了:

const re = /DD\d{6}/g;
console.log(re.test("订单 DD881202 已发"));   // true,lastIndex 移到 9
console.log(re.test("订单 DD881202 已发"));   // false!从 9 重新找,找不到了

同一对象第二次 test 返回 false,看起来像"随机 bug"。防御手法:要么每次循环新建字面量,要么统一用不维护状态的 String.match/matchAll。这个陷阱是 JS 正则面试题的常客,也是线上偶发 bug 的常客。

内联标志与作用域

标志也能写进模式本身:(?i) 全局生效。Python 3.11 起还支持局部作用域 (?i:...),只对括号内生效:

import re
print(re.findall(r"(?i:sku)-\d+", "SKU-101 sku-102 Sku-103"))
# 输出:['SKU-101', 'sku-102', 'Sku-103']

前缀 sku 大小写通吃,后面的编号规则不受影响。老项目里若看到 (?i) 出现在模式中段,要留意 Python 3.11 起它只影响其后的部分,跨版本行为有差异,以官方文档为准。

避坑提醒:标志是"隐形参数",review 正则时最容易漏看。经验做法是把标志写进变量名或常量名(如 RE_ORDER_ID_I),或统一用 re.compile(pattern, re.I | re.X) 集中声明——让"这个正则开了哪些开关"在代码里一眼可见,而不是埋在字面量尾巴上。

组合应用:多行标志 + 忽略大小写的清单扫描

标志真正的威力在组合。场景:从一段带缩进的 Markdown 笔记里,抓出所有无序列表项,且 * 和 - 两种符号都要认:

import re
notes = "- 买猫粮\n* 写周报\n  - 复盘会\n不匹配的普通行"
print(len(re.findall(r"(?m)^\s*[*-] ", notes)))
# 输出:3

(?m) 内联多行标志让 ^ 每行站岗,\s* 吃缩进,[*-] 字符组收纳两种符号。三个特性在一次匹配里各司其职——标志、字符组、量词的协作感,是单一特性演示给不了的。

再考虑大小写:日志级别 error/Error/ERROR 混排时,加 re.I 一并收录;若只想对级别字母忽略大小写、对后面的消息保持原样,Python 3.11+ 的 (?i:...) 局部作用域就有了用武之地。标志选择的思考顺序:先问作用范围(全文还是局部),再问开关清单(哪几个),最后才写代码——顺序反了就容易漏。

自测一题:re.findall(r"a.b", "a\nb", re.S) 和不加 re.S 各返回什么?(加:['a\nb'];不加:[]——. 默认不跨换行。)

一个高频疑问与一条铁律

问:标志能中途关闭吗? Python 3.11+ 支持 (?i:...) 开、(?-i:...) 关的局部作用域写法,老版本只有"从出现处生效到结尾"的 (?i);JS 的标志只能写在字面量尾部,不支持中途开关。需要精细控制时,优先拆成两次匹配,比在一条正则里玩标志杂技可维护得多。

铁律是:标志必须显式声明,绝不依赖默认值。团队代码里最危险的正则是"看起来没开任何标志"的那条——读者无从判断作者是否考虑过大小写、多行、换行匹配。把 re.compile(pattern, flags) 的 flags 参数永远写出来(哪怕只写 0 或一个空组合),评审时一眼可查。标志是正则的"隐形成本",显式化是控制它的唯一办法。

自测收尾:re.M 改变 ^ $,re.S 改变 .,re.I 改变大小写敏感,re.X 改变排版——四个"改变"对应四个高频 bug,把这句话默写一遍,本章就算过了。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。