正则表达式这东西,平时写脚本、做表单校验、清洗日志,基本上绕不开。我对正则的初印象是“写的时候像天书,调试的时候像玄学”,但后来发现只要把几个核心概念拆明白,它其实就是一套文本匹配的规则。今天想认真聊聊正则里最基础也最容易被搞砸的一块:数字和数字范围的匹配表达。这篇文章会从字符类、量词、边界符这些底层知识说起,再一步步走到“1到100”“0到255”这类具体范围的正则写法。适合刚接触正则、或者写过几笔但总靠复制粘贴的朋友,也适合工作里经常需要处理数据校验的人。
很多人一开始接触正则都是从匹配数字开始的,但真正上手后发现,简单的\d+只是能“碰到数字”,离“准确匹配某个数字范围”还差很远。比如你要校验用户输入必须落在1到100之间,直接写[1-100]是行不通的;你要从日志里提取0到255的IP段,也不是写一个[0-255]就能解决。这篇内容就是把这些坑一个个说清楚,并给出可以照抄的写法。
1. 先搞清楚“数字”在正则里的几种长相
1.1 字符类与简写符:\d、[0-9] 到底差在哪
在正则里,匹配一个数字字符最直白的写法是[0-9],它表示“字符范围从0到9的任意一个字符”。这种方括号结构叫作字符类,里面可以并列多个字符或范围,例如[abc]匹配 a、b、c 中任意一个,[a-z]匹配任意小写字母,[0-9]就是匹配任意数字。字符类的本质是“单个字符的集合”,一次只匹配一个字符。
大部分正则引擎还提供了简写\d,意思也是“数字字符”。在很多地方,\d和[0-9]是可以互换的,但有一个容易被忽略的区别:在部分支持 Unicode 的引擎里,\d可能会额外匹配非 ASCII 数字,例如阿拉伯文、印地文等数字字符;而[0-9]是严格匹配 ASCII 的0到9。我曾经在一次数据处理里发现,用\d把一些奇怪的字符当成了数字,后来改成[0-9]就正常了。所以如果你在爬虫清洗或国际化的数据里发现数字匹配“跑偏”,优先检查这个点。
与数字相关的还有\D,它表示“非数字字符”,相当于[^0-9]。后面我们会经常用到这些基础写法。
1.2 匹配整数、负数、小数时容易踩的坑
\d+能匹配一串连续数字,但它只认数字本身。你要匹配一个负数-12,直接拿\d+去匹配,虽然能匹配到12,但前面的负号会被当成无关字符忽略掉,结果不符合预期。要匹配可选的负号,就得在数字前面加上-?,这里的?表示“它前面的字符出现0次或1次”。所以-?\d+可以同时匹配12和-12。
再看小数3.14。用\d+匹配时,会得到两个独立的结果:3和14,中间的点因为不是数字所以被跳过了。要匹配完整小数,必须把小数点和小数部分也写进去:-?\d+(\.\d+)?。这个表达式里,(\.\d+)?是一个整体,?表示整段可选;\.是转义后的点,因为点在正则里有“任意字符”的特殊含义,必须转义才能表示小数点。
不过-?\d+(\.\d+)?也有边界问题。它会匹配3.里的3?不会,因为(\.\d+)?若省略,就匹配3且点不是小数;若当前字符串是3.,正则匹配开头3成功,$未锚定时它会返回3,这不是完整小数。如果整体是“3.14abc”,它会匹配 “3.14”部分。如果需要严格的数字格式,必须配合边界符或锚点,后面会专门说。
2. 数字范围匹配的核心思路:把“连续数值”翻译成“字符组合”
2.1 为什么不能直接写 [1-100]
很多新手会误以为写[1-100]就能匹配1到100之间的数字。这个想法听起来很自然,但完全误解了字符类的工作方式。方括号里的内容表示的是“字符范围”和“字符集合”,不是“数值范围”。引擎会尝试把1-100解析成若干个单字符或字符范围,而不会把它当作从1到100的整数区间。
举个实际例子,[1-100]在多数引擎里的解析结果是:把1-1视为一个范围,即字符“1”,然后剩下的0再作为一个普通字符,整体等价于[10],意思是只匹配字符0或1。也就是说,你本来想匹配100个数值,结果只匹配了两个数字字符,完全不对。更糟糕的情况是,有些引擎遇到1-0这种起始值大于结束值的范围会直接报错。所以在正则里,不要试图用一个字符类表达数值区间。
那匹配一个数值范围到底应该怎么做?思路是:把连续的数值区间拆分成若干段“位数固定且首位受限”的数字字符串,再用并集符号|把这些段落连接起来。这也是整个第二部分的重点。
2.2 分区间拆解法:以“1到100”为例
目标匹配所有整数,范围从1到100,并且不考虑前导零,即001不算。我们先按十进制数的位数来分段:
- 一位数:1到9,写成
[1-9]; - 两位数:10到99,写成
[1-9][0-9]; - 三位数:只有100,写成
100。
然后把这三段用“或”连接,再给整体加上括号:([1-9]|[1-9][0-9]|100)。如果是在一段文本中搜索,而不是校验整个输入,建议加上单词边界\b,变成\b([1-9]|[1-9][0-9]|100)\b。这里的\b表示单词边界,后面会详细讲。
为什么不加锚点不行?如果拿[1-9]|[1-9][0-9]|100去匹配"123",正则引擎会在位置0尝试第一个分支[1-9],它成功匹配了单个字符1,于是返回结果"1";如果分支顺序把[1-9][0-9]放前面,则会返回"12"。不管是哪种,都只是原字符串的一部分,用来做表单校验就会误判。因此,校验整个输入时,推荐写法是^(?:[1-9]|[1-9][0-9]|100)$。这里用了非捕获分组(?:...),避免产生不必要的分组编号。
2.3 更多经典范围实战:0-255、0-99、1-999
学会了拆解,其他范围都是一样的思路。先看最常见的0到255,IP地址每一段的上限。把这个范围按百位分成五段:
- 0到9:
[0-9]; - 10到99:
[1-9][0-9]; - 100到199:
1[0-9]{2}; - 200到249:
2[0-4][0-9]; - 250到255:
25[0-5]。
合并后是(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])。观察一下这些分段:高位数字被限制在1、2、25这类具体值,后面的低位再用[0-9]放开。这是范围匹配的通用套路:先把区间按“最高位”分组,再写死高位的可选值,低位用通配。
再看0到99。如果允许前导0,直接\d{1,2}就可以;如果不允许前导0,就用(?:[0-9]|[1-9][0-9])。这里[0-9]覆盖0到9,[1-9][0-9]覆盖10到99。分段从小到大排列,逻辑上更清晰,也更容易让人检查有没有漏掉边界。
最后看1到999。这个范围很特殊,它不包含0,所以首位不能是0,后面的位数最多两位。因此可以直接写成[1-9][0-9]{0,2}。{0,2}表示前面的[0-9]出现0到2次,整体就能匹配一位、两位、三位数,且不会匹配0。如果目标是0到999,直接\d{1,3}即可,但要注意它也会匹配001这类带前导零的字符串。你要结合“格式”和“数值范围”两个维度去设计。
3. 数量词与边界:让数字匹配不“串门”
3.1 量词的作用:匹配1位或多位数字
到这里,你已经会写字符类和范围分支了。但正则里还有一个绕不开的东西:量词。量词用来控制前面一个字符或分组的重复次数。常见的几个:?表示0次或1次;*表示0次或多次;+表示1次或多次;{n}表示恰好n次;{n,m}表示n到m次。举个例子,\d匹配一个数字,\d+匹配连续的一个或多个数字,\d{3}匹配连续三个数字,\d{3,4}匹配三个或四个数字。
默认情况下,量词是贪婪的,也就是在满足规则的前提下尽量多匹配。例如对字符串"12345"使用\d+,它会尽可能把五位数字都吃掉,而不是只匹配一个1。这个特性在提取数字时很有用,但也会带来坑:当后面的正则也需要字符时,引擎会回溯,把一部分数字让出来。理解贪婪和回溯,能帮助你解释很多“为什么结果比我想要的多一位”的诡异现象。
如果你希望“尽量少重复”,也就是非贪婪,可以在量词后面加?,变成\d+?。但在数字匹配场景里,非贪婪通常不常用,更多的是配合边界来限定长度。比如要匹配年份,直接\d{4}就很明确。
3.2 边界符:\b 和 ^$ 的重要性
边界符是我认为新手最容易忽略、但影响最大的东西。正则匹配默认是“在文本里找一段子串”,如果你只想匹配数字本身,有时候会把更大数字的一部分当作结果。举个例子,文本abc123def456,用\d+全局匹配会得到123和456,这没问题。但如果文本是12345,你想匹配三位数,\d{3}会匹配到123,剩下45,这可能不是你要的。
\b是单词边界,代表一个单词字符和另一个非单词字符之间的位置。数字与字母、空格、标点之间的位置就是边界。所以\b\d{3}\b在12345中匹配不到,因为 123 后面是4,属于单词字符,123和4之间没有边界;但在abc 123 def中,123两边有空格,边界成立,能匹配到。
^和$是字符串的起始和结束锚点。用^\d+$表示整个字符串从头到尾都是数字,多一个字母都不行。这适合表单校验。很多初学者使用re.search(r'\d+', 'abc123')来做“是否数字”的校验,结果永远是真,因为 search 是搜索子串。正确的做法是re.fullmatch或^\d+$。这是一个经典错误,我至少见过十次。
3.3 可选符号与分组:匹配负数、带符号数字
如果数字允许正负号,可以在最前面加[+-]?,表示一个可选的正号或负号。于是匹配带符号整数可以写成[+-]?\d+。如果要同时取出符号和后面的数字,可以用捕获分组:^([+-]?)(\d+)$。这样group(1)拿到正负号,group(2)拿到纯数字部分。这种写法在解析用户输入、拆分数值和符号时非常实用。
分组还有一个更重要的作用:限制“或”的范围。比如你写^[+-]?100|[1-9]?\d$,这会被解析成“以可选符号开头接100”或者“以数字结尾”两个完全不同的整体,因为|的优先级很低,它会把两边所有内容都当成分支。如果想表达“可选符号,后面跟着100或者普通数字”,必须用括号包住后面的分支:^[+-]?(?:100|[1-9]?\d)$。很多入门者在这里翻车,写出来的正则看起来差不多,实际行为完全不是那么回事。
不过我也要提醒一句:如果业务要求的是“-100到100之间的整数”,并不建议把数值范围的判断全部塞进正则。工程上更常见的做法是用^[+-]?\d+$做格式校验,再用程序语言转成整数,然后判断是否在范围内。这样正则简单、代码清晰,也不会出现“-0”这种边界值要跟正则搏斗的情况。正则负责“形状”,程序负责“范围”,各司其职。
4. 实操:几个最常见的数字校验场景
4.1 表单里的“1-100”整数校验
需求来自某管理后台的年龄输入框:只允许1到100之间的整数,不能是小数、负数、空字符串,不能有前导零。也就是1、56、100合法,0、101、001、-5、3.14都不合法。
我用 Python 写个可直接跑的测试。正则用上文得到的^(?:[1-9]|[1-9][0-9]|100)$:
import re pattern = re.compile(r'^(?:[1-9]|[1-9][0-9]|100)$') for val in ['1', '56', '100', '0', '101', '001', '-5', '3.14']: print(f'{val!r:8} -> {bool(pattern.match(val))}')运行结果里,1、56、100是True,其余全是False。这里我用了match而不是search,因为match从字符串开头匹配,配合$能确保整个字符串符合要求。如果你用的不是 Python,注意有些语言里的match行为并不等同于fullmatch,建议使用^...$双锚点。另外,Python 的$匹配字符串末尾或者末尾换行符之前,如果输入里有换行,\n可能被忽略,需要更严格的话可以用\Z表示绝对末尾。
如果你允许输入001这种带前导零的写法,并且依然要求数值落在1到100,那正则要改成^(?:0*)(?:[1-9]|[1-9][0-9]|100)$,不过实际项目里我更推荐先strip()去掉首尾空格,再做格式校验。这种小细节能省很多事。
4.2 IP地址四段数字校验(0-255)
IP地址匹配是正则最经典的练习,核心就是匹配0到255。上文已经得到匹配单段0-255的表达式,现在把它重复四次,中间用点分隔。注意点必须转义,因为点在正则里是通配符。完整的 IPv4 校验正则:
ip_pattern = re.compile( r'^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}' r'(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$' )这个正则比“分区间拆解法”里直接写四段要更高效:第一到第三段用(...\.){3}复用,第四段再写一遍。注意这里把单段范围写成了25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9],顺序是从255往0倒着写的,这样如果输入是255,先尝试25[0-5]直接成功;如果是199,前几个分支都不满足,最后[1-9]?[0-9]也只能匹配前两位19,但因为整段表达式后面还要求.或$,正则引擎会判断匹配失败,然后回溯尝试其他组合,最终整个正则匹配失败。所以这种写法在锚定下是可靠的。把宽泛分支放最后,主要是为了逻辑清晰和减少无效回溯。
实测一下这个表达式:192.168.1.1完美通过,256.1.1.1失败,192.168.01.1会失败,因为[1-9]?[0-9]中的[1-9]?不会匹配0,于是只能匹配单个0,但后面还剩下1,而下一个预期字符是点,所以失败。如果你确实需要允许前导零的 IP 段,需要另写,但现代规范通常不建议带前导零。
4.3 日志里提取“时间/数字”数据
生产环境里出现最多的场景不是校验表单,而是从日志字符串里提取数字。比如一段日志:
2024-05-06 14:30:55,123 INFO user=42 cost=512ms我想提取用户ID和耗时。用户ID是user=后的数字:user=(\d+),耗时是cost=后的数字:cost=(\d+)。如果用findall或search,可以这样:
line = '2024-05-06 14:30:55,123 INFO user=42 cost=512ms' user_match = re.search(r'user=(\d+)', line) cost_match = re.search(r'cost=(\d+)', line) print(user_match.group(1), cost_match.group(1))这里用了捕获分组(\d+),group(1)就能拿到具体的数字字符串。如果日志里日期时间是2024-05-06 14:30:55,想要分别拿到月份、日期、小时,也可以写(\d{4})-(\d{2})-(\d{2})。但要注意,提取时\d+会尽量多吃数字,所以user=42没问题;如果字段之间没有分隔,比如user42cost512,就要用user=(\d+)cost=这样的上下文来限定。日志提取的另一个常见坑是:时间里有毫秒部分,如14:30:55,123,如果你只写:\d{2},可能会匹配到:30、:55,但注意不要把毫秒也当成秒。正则匹配是机械的,你得自己明确上下文的边界。
5. 常见问题与排查技巧实录
5.1 匹配不到或匹配过多:量词与边界问题
遇到“匹配不到”的情况,先别怀疑字符类写错,先看边界。例如正则\b\d{2}\b在字符串abc123def中匹配不到两位数字,因为23左边是1,右边是d,都不是边界。如果你希望匹配“任意连续的两位数字”,则应该去掉边界或使用(?<!\d)\d{2}(?!\d)这样的负向前后断言。后面这种写法叫零宽断言,入门阶段可以暂时不深究,但要知道边界符不是万能的。
“匹配过多”的典型例子:把\d+用在需要固定长度的字段上,比如匹配手机号,写\d+会把身份证号也匹配出来。解决办法是限定量词\d{11},并加上边界或锚点。另外,全局搜索时,如果你只匹配了一次,可能不是正则的问题,而是没有开启全局匹配模式。不同语言里,Python 的findall对应全局搜索,search只找第一个,JavaScript 则需要g标志。调试时先在在线正则工具里把匹配模式、全局开关看清楚,能省一半排查时间。
5.2 小数、科学计数法怎么办
正则里的小数问题,集中在“小数点是否必须”“点后是否必须有数字”“是否允许3.和.5这类写法”。如果业务上只接受小数点前后都有数字的常规小数,可以用^[+-]?\d+\.\d+$。如果要允许.5,但不要3.,可以写^[+-]?(?:\d+\.\d+|\.\d+)$。如果要允许3.,就把它加入分支。这些需求往往因人而异,所以没有“万能小数正则”,只有根据需求定制的写法。
科学计数法,比如1.23e-10,正则要复杂一些:^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$。这里[eE]匹配字母e或E,[+-]?匹配指数部分的符号。但说实话,在业务系统里如果遇到科学计数法,我更倾向先用正则做粗略格式判断,再用程序语言的float()或Decimal()尝试转换,用异常处理来兜底。因为科学计数法的正则很容易漏掉边界情况,比如1e5、1.0E+3、.5e-2。把这些交给内置解析器,往往更稳。
5.3 性能与可读性的平衡
有些初学者喜欢在一个正则里写完“数字格式 + 数值范围 + 小数限制 + 长度限制”,结果表达式又长又难维护。正则虽然强大,但不是万能的。我的经验是:能用语言内置的正则尽量简洁,范围比较交给代码。例如前面说的0到255,如果只是校验IP,你可以先匹配“每段是1到3位数字”,再用int(part)判断是否小于256。这样写出来的正则短,逻辑也清楚。
但有些场景必须用单条正则,比如在线规则引擎、配置项、不会编程的用户。这时才需要写完整的分段表达式。为了可读性,建议把正则分成多行,或者用注释模式。比如 Python 的re.VERBOSE可以在正则里加空白和注释,让结构一目了然:
pattern = re.compile(r''' ^ (?:25[0-5] # 250-255 |2[0-4][0-9] # 200-249 |1[0-9]{2} # 100-199 |[1-9]?[0-9] # 0-99 ) \. (?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]) \. (?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]) \. (?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9]) $ ''', re.VERBOSE)不过上面的写法虽然可读,但重复太多,实际代码里可以用字符串拼接生成。这又是另一个层面的工程设计了,不展开。
最后再分享一个我自己的习惯:每写完一条正则,我都会列一组“合法样本”和“非法样本”去测,至少包含边界值和畸形值。比如要匹配1-100,至少测试1、50、100、0、101、1000、01、-1、1.0。正则写得好不好,不是看它能匹配多少对的,而是看它能不能拒绝那些“差一点就对了”的。这个习惯帮我挡住了很多线上事故,也建议你从今天开始试试。