很多人学正则表达式一开始都栽在元字符上。尤其是系列教程看完前两篇、准备正式动手的时候概念好像都认识一写就懵.到底匹配什么[]和()有什么不同*和能不能互换这篇就把基本元字符一次讲透一共十个左右——.、[]、^、$、\、*、、?、()、|数量不大但组合起来能解决绝大多数文本处理问题。无论你是要写脚本清数据、做日志分析还是验证表单输入只要跟字符串打交道这十个字符值得花一个下午彻底搞懂。这篇适合三类人刚看完正则基础概念的新手、写过几个正则但心里没底的人、以及想系统整理元字符知识的老手。已经能闭眼写出复杂正则的朋友可以直接跳到第3节看实战。1. 先搞清楚元字符到底在干什么1.1 正则引擎在看什么正则表达式的执行过程可以理解成一台扫描车在字符串上从左到右溜达。它每到一个地方就问一句“当前这个位置符合你写的规则吗”符合就继续往下走不符合就挪一格重新试。关键是这台扫描车眼里有两种东西字符和位置。字符好理解就是一个一个的字、字母、数字、标点它们是“占地的”。位置则是字符与字符之间的缝隙还有字符串的开头和结尾它们是“不占地的”。元字符之所以叫元字符就是因为它们不是在匹配普通的字面内容而是在操控扫描车的行为让它去匹配某一种字符、让它控制匹配数量、让它只在特定位置动手。比如正则ab在字符串xab里匹配时引擎先看x发现不是a右移再看a匹配成功继续看b匹配成功。这个过程看起来简单但理解了它后面所有元字符的行为都顺理成章。1.2 元字符的三种职责基本元字符虽然多但功能上可以分成三类这也是我觉得学正则最有效的分类方式决定“匹配什么字符”字符组[]、点号.、预定义类\d、\w、\s以及转义符\。决定“匹配多少个”量词*、、?以及后面会遇到的{n,m}。决定“在哪里匹配”锚点^、$边界\b。至于分组()和逻辑或|更像是橡皮筋和胶水用来把前面的零件组合起来。这样一看元字符就不是十个孤立的符号了而是一套完整的工具箱。接下来逐个拆。2. 基础元字符逐个拆解2.1 字符组 []一个“多选一”的集合[abc]是正则里最直观的元字符意思是“匹配 a、b、c 中的任意一个字符”。注意是一个不是一串。比如[abc]在字符串abracadabra里会把里面所有 a、b、c 单挑出来每一个算一次匹配。字符组最常用的两个变化是范围和取反范围[0-9]匹配一个数字[a-z]匹配一个小写字母[A-Za-z0-9]三个范围叠加匹配字母数字。取反[^0-9]匹配一个非数字字符。这里的^写在字符组里面意思是“非”和后面要讲的行首锚点^不是一个含义不要混淆。实操里一个典型用法是匹配颜色值#[0-9A-Fa-f]{6}。#开头后面跟着六位十六进制字符{6}是精确次数这个先按下不表量词部分会讲。字符组里的特殊字符规则和外层不太一样。.、*、、?、$这些元字符在字符组内部基本都会退化成普通字符所以[.]匹配的就是一个点号不需要转义。但有几个字符要注意]、^、-。]会结束字符组^出现在开头表示取反-放在两个字符之间会被当作范围连接符。想匹配一个普通的-把它放在字符组开头或结尾就行比如[-0-9]或者干脆写成[\-0-9]一劳永逸。提示不同正则引擎对字符组内部特殊字符的处理略有差异如果拿不准对特殊字符统一加反斜杠转义是最稳的做法。2.2 点号 . 与转义符 \.是正则里最“任性”的字符它匹配除了换行符以外的任意单个字符。a.c能匹配abc、a1c、a c但不能匹配ac——因为点号也要占一个位置ac少一个字符也不能匹配跨行的a\nc。这个特性在实际使用中最容易出问题想匹配一个句号或者网址里的点如果忘了转义正则就会失控。比如想匹配www.demo-site.test如果写成www.demo-site.test那最后一个点号会匹配任意字符www.demo-siteXtest也能通过校验。正确写法是把点号转义成\.。转义符\有两个方向的作用这是新手最容易忽略的把元字符还原成字面量\.匹配点号\*匹配星号\\匹配反斜杠本身。把普通字母升级成预定义字符类\d匹配数字\w匹配字母数字下划线\s匹配空白字符。预定义类有对应的“大写取反”版本整理成表更清楚写法含义常见等价写法\d一个数字[0-9]\D一个非数字[^0-9]\w字母、数字、下划线[A-Za-z0-9_]\W非单词字符[^A-Za-z0-9_]\s空白字符[ \t\n\r\f]\S非空白字符[^ \t\n\r\f]需要提醒的是这个“等价”在多数场景下成立但不同引擎对 Unicode 的处理有差异。比如某些环境里\d会匹配全角数字\w会包含中文字符。要严格限定为 ASCII 范围最好用[0-9]、[A-Za-z0-9_]这种显式写法。想匹配反斜杠本身时因为反斜杠是转义符所以要写\\。如果是在 Python 字符串里写正则还要注意字符串自己的转义规则\d会被解释成字符d。这就是为什么 Python 生态强烈建议用原始字符串r\d少一层转义少一堆 Bug。2.3 锚点与边界匹配的是位置^和$是正则里的位置锚点。^匹配字符串或行的开头位置$匹配结尾位置。它们不消耗任何字符只负责“定位”。^cat要求字符串以cat开头^cat$要求整个字符串只有cat一个不多一个不少。这个差别非常关键如果不用锚点在concatenate里也能匹配到cat因为它出现在字符串中间一旦加上^cat$就只有独立成行的cat才能通过。位置锚点在多行模式下的行为值得提前打个预防针。很多工具和语言支持m标志开启后^和$从“整个字符串首尾”变成“每一行的首尾”。同一个正则在 A 工具里匹配整段文本的开头在 B 工具里匹配每行开头不一定是正则写错了常常是模式设置不同。\b是单词边界匹配的是“单词字符”和“非单词字符”之间的缝隙。\bcat\b能匹配a cat!里的cat但不会匹配concatenate里的cat因为这里的cat后面跟着c不是边界。中文环境要特别注意\b对中文词基本不友好中文字符在很多引擎里会被当作单词字符连续的中文字之间没有边界所以\b喜欢\b在“我喜欢你”里往往匹配不到。想匹配中文词边界得靠字符组或者后面的前后断言别硬套\b。2.4 量词*、、? 到底“管”到哪量词解决的是“多少个”的问题它们是附着在某个原子后面的修饰符。所谓原子可以是单个字符、字符组、点号也可以是分组。量词含义示例能匹配的内容*前一个原子出现 0 次或多次ab*a、ab、abb、abbb前一个原子出现 1 次或多次abab、abb、abbb不能是a?前一个原子出现 0 次或 1 次ab?a、ab{n,m}前一个原子出现 n 到 m 次a{2,4}aa、aaa、aaaa最容易踩的坑是搞不清量词到底作用在谁身上。ab*表示a后面跟着若干个b它匹配a、ab、abbb但如果你想把ab整体重复就得用分组写(ab)*它匹配空串、ab、abab、ababab。同理[ab]*不是匹配ab整体而是匹配任意个数的a或baabb、abab、空串都能过。还有一个重要特性量词默认是贪婪的意思是它会尽可能多吞字符。比如正则a.b去匹配字符串a123b456b引擎会从第一个a开始一直吃到最后一个b结果是a123b456b而不是中间的a123b。后面会讲到懒惰量词*?、?、??可以把行为改成“尽量少匹配”这里先知道有这个概念。?这个字符还有第二重身份当它跟在量词后面时表示“懒惰模式”比如*?、?当它单独出现时是量词“0 次或 1 次”。同一个符号两种角色这是很多初学者绕不过去的坎。判断方法很简单看它前面是什么。前面是一个普通字符、字符组或分组它就是“0 或 1 次”前面是*、、?或{n,m}它就是懒惰标志。2.5 分组与逻辑或() 和 |括号最基本的作用是改变量词的作用范围。(ab)把ab绑定成一个整体重复若干次。这看起来简单实际处理网址协议时非常有用https?://里的s?能匹配http://和https://但如果协议部分想用逻辑或表达就得靠括号。逻辑或|的优先级非常低是整个正则里最低的。cat|dog匹配cat或dog没问题但^cat|dog$不是“整行 cat 或整行 dog”而是“以 cat 开头或者以 dog 结尾”。想表达两个完整选项必须写成^(cat|dog)$。实战里这个坑出现频率极高。比如匹配网址协议想写“http 开头或 https 开头”如果写成http|https://引擎会解读成“匹配http或者匹配https://”后面的冒号斜杠就丢了。正确写法是(http|https)://或者直接https?://。括号还有一个副产品捕获组。每一对括号里的匹配内容会被单独保存下来方便后续提取和使用。比如(\d{4})-(\d{2})-(\d{2})匹配日期时三组分别存着年、月、日。有些引擎还支持(?:...)这种非捕获分组它只负责组合不保存内容适合不想破坏分组编号的场景。3. 实操拿一段日志把元字符串成一条线3.1 准备一份测试数据理论说再多不如动手跑一遍。我自己习惯用一小段虚拟日志来练手每次写正则都在这个样本上先验证。这里也用它来做演示2024-01-15 10:24:31 INFO order #A-1001 total 204.50 2024-01-15 10:24:35 INFO order #A-1002 total 99.00 2024-01-15 10:24:40 WARN retrying node-server-08 2024-01-15 10:24:45 ERROR invalid payload #B-999 missing field 2024-01-15 10:25:02 INFO order #A-1003 total 150.00 2024-01-15 10:25:07 INFO order #A-1004 total 12.75这段文本里有日期、时间、日志级别、订单编号、价格基本覆盖了常用元字符。3.2 需求一提取所有订单编号订单编号的格式是#加上一个大写字母、一个连字符、一组数字。要提取它们得先想清楚“什么字符、多少个、在哪”。大写字母[A-Z]连字符-不是元字符直接写数字至少一位[0-9]或\d前缀#不是元字符但为了将来可能开启扩展模式建议转义成\#最终正则#([A-Z]-\d)括号把字母和数字部分捕获下来。在 Python 里这样用import re log 2024-01-15 10:24:31 INFO order #A-1001 total 204.50 2024-01-15 10:24:35 INFO order #A-1002 total 99.00 2024-01-15 10:24:40 WARN retrying node-server-08 2024-01-15 10:24:45 ERROR invalid payload #B-999 missing field 2024-01-15 10:25:02 INFO order #A-1003 total 150.00 2024-01-15 10:25:07 INFO order #A-1004 total 12.75 orders re.findall(r#([A-Z]-\d), log) print(orders) # [A-1001, A-1002, B-999, A-1003, A-1004]这里findall只保留括号捕获的部分所以输出里没有#。如果不加括号返回的就是整段匹配文本包含#。动手写正则时先明确“我要的到底是哪一段”再决定括号放哪里。如果用的是命令行也有值得一提的差异。grep -E是扩展正则模式[0-9]的能正常工作但如果用基础grep不带-E会被当成普通加号必须写成[0-9]\才表示量词。同样的正则在不同工具里可能因为“扩展模式没开”而表现完全不同这不是正则本身错了。3.3 需求二找出价格超过 100 的订单这个需求不能光靠正则最务实的做法是让正则先把价格切出来再用普通代码做数值比较。正则负责“格式化提取”业务判断交给编程语言这才是高效组合。价格格式是total后面跟数字、小数点、两位小数。正则写成total (\d\.\d{2})括号捕获价格部分。\.是转义的点号只匹配小数点本身\d{2}是两位小数。matches re.findall(r#([A-Z]-\d) total (\d\.\d{2}), log) for code, price in matches: if float(price) 100: print(code, price) # A-1001 204.50 # A-1003 150.00两种捕获组同时出现时findall会返回元组列表每个元组对应一行的编号和价格。这个例子也说明正则的威力不只是“找到”而是“把有用的部分分组拎出来”。3.4 需求三校验时间格式是否合法日志时间看起来是HH:MM:SS但并不是任意三位数都合法。小时最大是 23分钟和秒最大是 59。用元字符组合可以写出很稳定的校验小时([01]\d|2[0-3])[01]\d匹配 00 到 192[0-3]匹配 20 到 23分钟和秒[0-5]\d匹配 00 到 59完整正则([01]\d|2[0-3]):([0-5]\d):([0-5]\d)这里最容易出的问题就是漏掉括号。如果不加括号写成[01]\d|2[0-3]:[0-5]\d:[0-5]\d|的低优先级会让它变成“匹配[01]\d或者匹配2[0-3]:[0-5]\d:[0-5]\d”完全不是想要的结果。这也是为什么我在前面强调遇到|一定要想清楚作用范围该加括号就加括号。4. 初学者最容易踩的坑4.1 高频误区速查表把日常答疑里见过最多的坑整理成一张表遇到问题先翻一下遇到的问题常见误解实际原因解决办法[0-9]匹配出连续好几个数字以为是整个数字串正则默认只匹配一个数字字符加量词[0-9]或\d[^a-z]以为是行首字母以为^在组内是锚点组内^表示取反把^移到字符组外面表示行首^cat|dog$想要“整行 cat 或整行 dog”以为|自动包住两端|优先级最低写^(cat|dog)$匹配网址时www.a.test意外通过以为.只匹配点点号匹配任意字符写www\.a\.testab*匹配不到abab以为*作用于ab*只作用于前一个字符b用分组(ab)*a.b匹配结果比预期长以为会取最短量词默认贪婪用懒惰量词.?或明确锚定范围\b匹配中文词失败以为\b对所有语言有效\b按英文单词字符定义中文语境别依赖\b用字符组或断言Python 里\d没匹配数字以为字符串写法等于正则写法字符串先转义了一层写原始字符串r\d\r\n文本里$匹配完还带\r以为$匹配行尾且清理干净$匹配在\n前\r还在前面行尾用\r?处理或先统一换行符不加-E的 grep 里不起作用以为所有工具都默认支持基础正则模式要写\用grep -E或写成{1,}这些坑的共同根源是没想清楚“元字符到底作用在哪个原子、匹配的是字符还是位置”。把根源抓住具体细节都好办。4.2 排查的通用思路碰到正则行为不符合预期别急着乱改按顺序检查三件事第一检查匹配范围。你的正则有没有锚定如果没写^和$它会在文本任意位置找匹配出现“意料之外”的结果很正常。先明确目标是匹配一段内容还是匹配整行、整串。第二检查量词和分组的位置。a*、[ab]*、(ab)*是三种完全不同的匹配逻辑先把原子画出来再看量词作用在谁身上。第三检查转义层数。在代码里写正则经常有两层转义叠加语言字符串一层正则引擎一层。遇到反斜杠失效先数一数有没有多转或少转一层。Python 用rJavaScript 用\\这是最常用的两种写法。5. 把元字符变成自己的工具5.1 一张“人话翻译卡”学元字符不用背定义我更建议把它们翻译成大白话贴在手边。这是我自己的习惯元字符人话翻译.任意一个字符换行除外[abc]a、b、c 里面挑一个[^abc]不是 a、b、c 的任意一个\d一个数字\w一个单词字符\s一个空白^开头的位置$结尾的位置\b单词边界的位置*前面那个东西来 0 次或多次前面那个东西来 1 次或多次?前面那个东西来 0 次或 1 次()把一段聚成一个整体遇到需求时先在脑子里把人话翻译一遍再对应到元字符思路会清晰很多。5.2 我坚持的四个小习惯第一个习惯是从短到长逐步叠加。不要一次写完一长串正则。先写一个能跑的最小片段确认能匹配了再往上面加条件。每加一个元字符就重新验证一次。出错时也能立刻知道是哪一步引入的问题。第二个习惯是先锚定再填内容。如果目标是匹配整行格式先把^...$搭出来再往中间填。很多“匹配过头”的问题都是因为少了锚点。第三个习惯是能分组就分组别省括号。尤其是带|的表达式(a|b)永远比裸写a|b更安全。括号不是累赘是边界感。第四个习惯是用真实样本反复验证。写正则最怕在理想语法里自嗨。拿一批真实数据跑一遍看看匹配到的数量符不符合预期匹配到的内容有没有混入杂质。数量对不上多半是字符范围写宽了或者量词用错了。我个人用下来的感觉是基本元字符并不是“十个字符十个定义”它们真正在做的是回答四个问题——要选谁、来几次、在哪找、怎么拼。下次写正则之前先在心里把这四个问题过一遍你可能会发现自己突然就会写了。