上周帮人看一段交互脚本,问题出在一行if user_input.isdigit():上。用户输入12.5,判断直接返回 False,程序把一串合法小数当成非法输入弹了回去;而同一段代码里另一处用float()做转换,用户输入nan反倒顺利过关。两种错法,都栽在“Python 判断输入是否是数字”这件看起来一句话就能解决的事上。
这类需求几乎无处不在:命令行工具读参数、Web 表单收数据、CSV 批量清洗、配置文件解析、教学场景判断学生输入。写业务代码的人也都知道,需求里从来不会只写“判断是不是数字”,真实要求往往是“判断是不是合法的整数”“判断是不是允许带两位小数的金额”“判断是不是能安全转成 float 的字符串”。这些要求彼此并不等价,选错方法,代码在测试环境跑得好好的,上线遇到一条脏数据就炸。
下面这套内容,我把常见的四种判断方式挨个拆开:try/except强制转换、str的isdecimal/isdigit/isnumeric三兄弟、正则表达式、以及手写逐字符扫描。每种方法给出可直接复制的实现、讲清它到底在判断什么、在哪些输入上会给出反直觉的结果,最后落到场景选型和踩坑排查。不管你是刚学 Python 两三个月,还是已经写了两年代码但从没深究过这几行判断,看完都能直接拿去改自己项目里的那段校验逻辑。
1. 先想清楚:你要判断的到底是“哪门子数字”
1.1 一个看似一句话就能解决的问题
“判断输入是不是数字”这句话本身就模糊。用户输入42,是数字;输入-3.14,是数字;输入1e6,多数人认为是数字;输入1_000,Python 语法里合法,但业务上大概率不该接受;输入123(全角),肉眼是数字,字符串层面却不是 ASCII 数字;输入½,是人能读懂的数值,但任何转换函数都转不出来;输入nan,float()照单全收,可你的报表里绝不该出现它。
所以第一件事不是挑方法,而是先定义验收标准。我通常让提问方先把这三句话说清楚:允许整数还是必须允许小数、允许正负号吗、允不允许科学计数法。这三句话定下来,方法的范围基本就锁死了。
反过来,如果连验收标准都不定就直接写isdigit(),那后面必然要返工。我在代码评审里见过最多的返工原因是:前端传"12.50",后端用isdigit()判,判定失败,接口直接 400,而实际上前端只是把金额格式化了一下。这跟技术能力无关,是需求边界没对齐。
还有一个容易含糊的点:判断的输入到底是什么类型。input()拿到的永远是str,但你在别的函数里复用的校验工具,可能被人传进来int、float、None、True。float(True)返回1.0,float(None)抛TypeError,isinstance(1.5, str)是 False。判断函数如果不做类型兜底,在跨模块调用时就会出各种意外。所以下面所有实现,我都会带上类型检查,这算不上过度设计,是被真实故障逼出来的习惯。
1.2 四种方法的选型逻辑:从“够用”到“严谨”
四种方法可以按“判断依据”分成两类。try/except和手写扫描属于“转换/解析派”,它们回答的问题是“这个字符串能不能被解释成一个数”;isdecimal家族和正则属于“模式匹配派”,它们回答的问题是“这个字符串的形状符不符合我规定的样子”。
转换派的优点是宽容、便宜、和后续业务代码天然一致——你用float()判过,紧接着就能用float()转,不会出现“判断通过但转换失败”的尴尬。缺点也很明显:float()的宽容度超出多数人的想象,nan、inf、1_000、前后空格全都能过,而int()和float()的接受范围还不一样。
模式匹配派的优点是可控,你能精确指定允许的字符集和结构,报错信息也好写。缺点是容易写成“看起来对但漏了边界”的正则,而且模式匹配做了什么事、没做什么事,不测一遍根本不知道。\d在 Python 3 里默认匹配 Unicode 十进制数字这件事,坑过的人不在少数。
我的选型顺序是这样的:业务规则明确、需要给出具体错误提示的表单类场景,走正则;做数据清洗、要的是“能转就转,转不了算脏数据”,走try/except加清洗;单纯的性能敏感循环里做前置快筛,用isdecimal();需要极细粒度控制(比如“允许千分位但不允许科学计数法”这种组合规则),手写扫描最省心。下面四节逐个展开。
2. 四种方法逐个拆解
2.1 方法一:try/except 包住 float() 或 int()
这是最符合 Python 气质(EAFP,先做再处理错误)的写法,也是绝大多数场景下我优先推荐的第一版实现。
def is_number_by_cast(s, allow_nan=False, allow_inf=False): """能成功转成 float 就返回 True。""" if not isinstance(s, str): return False text = s.strip() if not text: # float('') 抛 ValueError,提前拦掉更省事 return False try: value = float(text) except ValueError: return False if not allow_nan and value != value: # NaN 是唯一不等于自己的值 return False if not allow_inf and (value == float("inf") or value == float("-inf")): return False return True这段代码里有三个细节值得单独说。第一是strip(),float(" 12 ")是能成功的,如果你不主动 strip,判出来的“是数字”和你后面拿去入库的值就对不上,一个是带空格的原始串,一个是不带空格的数值。第二是value != value这个 NaN 判断,比math.isnan()少一次函数调用,效果一样,但写法上更 hack 一点,团队里有人不熟的话建议还是用math.isnan换可读性。第三是inf的处理,float("inf")、float("Infinity")、float("infinity")、float("-inf")全部返回无穷大,如果你的下游要算平均值或者做累加,一条 inf 能把整个结果污染成 inf。
int()版本的差异在于:它只接受整数字符串,int("12.0")直接抛ValueError;它同样接受下划线int("1_000") == 1000;它接受前后空白;它还有个很多老版本才有的坑——Python 3 里已经没有大小限制,int("9" * 5000)能算出结果,但耗时随位数增长,如果你的校验函数放在高频接口上,超长数字串会变成一个隐性的性能入口。
提示:
float()对非 ASCII 数字一律拒绝,float("123")会抛ValueError。这一点和str.isdecimal()的行为完全相反,跨方法比较时必须记住。
适用面:数据清洗、配置文件读取、命令行参数解析。这类场景的容错策略是“尽量接受,接受不了再报错”,try/except的语义和它天然吻合。不适用面:需要精确告诉用户“第几位不合法”的场景,因为异常信息里的位置信息不够结构化。
2.2 方法二:isdecimal / isdigit / isnumeric 三兄弟的真实差别
这三个方法名字长得像,很多人随便挑一个用,结果在不同输入上表现各异。它们的关系是一个包含链:isdecimal()的结果为真的字符集合,是isdigit()的子集;isdigit()又是isnumeric()的子集。用 Python 验证一遍最直观:
samples = ["123", "123", "²", "½", "Ⅳ", "一二三", "12.5", "-1", ""] for s in samples: print(repr(s), s.isdecimal(), s.isdigit(), s.isnumeric())跑出来是这样的:
| 输入 | isdecimal | isdigit | isnumeric | 说明 |
|---|---|---|---|---|
123 | True | True | True | ASCII 数字,三者全过 |
123 | True | True | True | 全角数字,Unicode Nd 类别 |
² | False | True | True | 上标,有 Digit 属性但不是十进制 |
½ | False | False | True | 分数,只有 Numeric 属性 |
Ⅳ | False | False | True | 罗马数字 |
一二三 | False | False | True | 中文数字,有数值但不属于数字字符 |
12.5 | False | False | False | 三者都不认小数点 |
-1 | False | False | False | 三者都不认正负号 |
| 空串 | False | False | False | 空串一律 False,可以省略长度判断 |
这张表把两个关键结论摆在明面上了。第一,这三个方法天生不支持小数和负数,它们判断的是“这个字符串是不是完全由数字字符组成”,不是“这个字符串是不是一个数”。第二,三者对“数字字符”的定义逐级放宽,放得越宽,能接受的输入越离谱,离“能转成数字”就越远。
isdecimal()对应 Unicode 的 Nd(Decimal Number)类别,是最接近“十进制数字字符”的那一档。它认全角数字123,但float("123")会失败,所以如果你用isdecimal()判完再float()转,就会出现“判断通过、转换崩溃”的组合故障。反过来isdigit()认²,isnumeric()认½和一二三,这两种输入在任何转换函数里都活不下来。
所以我的实际用法是:绝大多数情况用isdecimal(),因为它最严格、最接近“纯十进制数字”的直觉。只有在做中文自然语言处理、需要识别“一二三”这类表达时,才会用到isnumeric(),而且那个场景下我也不会拿它去转换,而是直接映射成对应的整数。
注意:用
isdecimal()判完的数字,直接int()转不一定安全,因为全角数字能过判断但过不了int()。要么加s.isascii()一起判,要么老老实实用try/except。
2.3 方法三:正则表达式,把规则写在明面上
正则的好处是规则一目了然,改需求时改一行模式就行,不用动控制流。写数字校验的正则,核心是别漏结构、别多匹配。
import re # 允许正负号、整数部分、可选小数部分、可选科学计数法 NUMBER_PATTERN = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", re.ASCII) def is_number_by_regex(s): if not isinstance(s, str): return False return NUMBER_PATTERN.fullmatch(s) is not None这个模式我拆成四块看:[+-]?是可选符号位;(?:\d+(?:\.\d*)?|\.\d+)是主体,两条分支分别对应“整数部分必有、小数部分可有可无”和“整数部分为空、小数部分必有”,后者是为了覆盖.5这种写法;(?:[eE][+-]?\d+)?是可选指数部分;最后用fullmatch而不是match。
fullmatch和match的区别是这个主题下最容易踩的坑。re.match(r"\d+", "123abc")会返回匹配对象,因为它只要求从开头匹配,后面还有啥它不管。用它来写校验函数,等于把所有“以数字开头”的字符串全判成合法。同理,用^...$做首尾锚定在多数情况下等价,但在处理多行字符串时$会匹配行尾位置,行为会飘,所以我一律用fullmatch。
re.ASCII这个标志也值得单独说。Python 3 的\d默认匹配 Unicode Nd 类别,也就是说re.fullmatch(r"\d+", "123")是能匹配上的。如果你只想要 ASCII 数字,加re.ASCII,\d的范围就被限制在[0-9]。如果你的系统上游可能收到非 ASCII 数字(比如用户从别处复制粘贴),不加这个标志就会漏过去,然后在int()那里炸掉。
预编译也是必须的。re.compile一次,后面每次调用直接复用,比每次现编译快一个数量级。这个优化在单次调用里看不出来,在上万次循环里就是实打实的差别。
2.4 方法四:手写逐字符扫描,规则最细也最啰嗦
有些规则用正则写出来会变成一坨没人看得懂的东西,比如“允许千分位逗号,但逗号只能出现在整数部分且每组三位”“允许末尾百分号但必须紧跟数字”“不允许科学计数法”。这种情况下手写一个状态扫描反而清爽。
def is_number_by_scan(s): if not isinstance(s, str) or not s: return False i, n = 0, len(s) if s[i] in "+-": i += 1 if i == n: # 只有一个符号,非法 return False int_digits = 0 while i < n and "0" <= s[i] <= "9": i += 1 int_digits += 1 frac_digits = 0 if i < n and s[i] == ".": i += 1 while i < n and "0" <= s[i] <= "9": i += 1 frac_digits += 1 if int_digits + frac_digits == 0: # 光一个点、光一个符号,都非法 return False if i < n and s[i] in "eE": i += 1 if i < n and s[i] in "+-": i += 1 exp_digits = 0 while i < n and "0" <= s[i] <= "9": i += 1 exp_digits += 1 if exp_digits == 0: # 1e 后面啥也没有,非法 return False return i == n # 必须刚好走完整个串这段代码本质是个极简状态机:起始状态、可选的符号位、整数段、可选的小数点加小数段、可选的指数段、结束。最后那句return i == n是它的灵魂——只要中途遇到任何不认识的字符,指针就会停在那里,i != n,直接判非法。这个结构比正则更容易加规则,比如你想允许下划线分隔符,只需要在两个数字循环里加个分支;想允许千分位,就在整数段里加一个“逗号后必须还有三位数字”的检查。
用"0" <= s[i] <= "9"而不是s[i].isdigit(),是为了强制只认 ASCII 数字。.isdigit()会放行²和全角数字,跟后面的float()转换对不上,在这个场景里就是给自己挖坑。
手写扫描的代价是代码量。四五十行换一个判断函数,维护成本不低,所以我不建议把它当默认方案。但如果你的规则已经复杂到正则写出来需要注释三行才能看懂,那这五十行是值得的——至少半年后的你自己能读懂。
3. 实操过程:把四种方法封装成能直接用的工具
3.1 环境与文件组织
这一套东西我在 Python 3.8 到 3.12 上都跑过,标准库足够,不需要装任何第三方包。目录我习惯这样分:
number_check/ ├── number_utils.py # 四种实现 + 统一入口 ├── test_number_utils.py # 用例集,用 unittest 就够 └── benchmark.py # 性能对比脚本number_utils.py里只放纯函数,不做任何 IO,这样它既能被业务代码 import,也能被测试脚本直接调用。别小看这个约束,我见过有人把校验函数和日志、数据库连接写在一起,结果想跑个性能测试得先起一个数据库,完全是给自己找麻烦。
统一入口的设计思路是:对上层暴露一个is_valid_number(s, mode="strict"),内部按 mode 分派到四种实现。这样业务代码只依赖一个函数名,将来规则变了改内部就行,调用方一行不用动。
3.2 核心函数的封装与默认参数选择
from decimal import Decimal, InvalidOperation def is_valid_number(s, mode="float"): """ mode: "int" 仅整数,允许正负号 "float" 标准浮点写法,拒绝 nan/inf "decimal" 高精度场景,拒绝 nan/inf "digits" 只判断是否纯数字字符,不做转换 "loose" 宽容解析,先清洗再判断 """ if not isinstance(s, str): return False text = s.strip() if not text: return False if mode == "digits": return text.isdecimal() if mode == "int": if not text.lstrip("+-").isdecimal(): return False try: int(text) except ValueError: return False return True if mode in ("float", "decimal"): try: value = Decimal(text) except InvalidOperation: return False if not value.is_finite(): # 一次性挡掉 nan、inf、-inf return False return True if mode == "loose": cleaned = text.replace(",", "").replace(",", "").rstrip("%") try: float(cleaned) except ValueError: return False return True raise ValueError(f"未知模式: {mode}")这里有三个决策值得解释。第一,我为什么用Decimal而不是float做默认的浮点校验。Decimal("nan")和Decimal("inf")同样能构造成功,但Decimal多了is_finite()方法,一行就能把这三个特殊值全挡掉,比float那边要分别判 NaN 和 inf 干净。而且Decimal的数字解析规则比float更严格,Decimal("1_000")会抛InvalidOperation,这也更符合业务直觉。
第二,"loose"模式下我清掉了半角逗号和全角逗号。这个看着土,但在真实数据里太常见了——财务系统导出的金额带千分位,用户从微信复制的数字可能带全角逗号,多余空格更是家常便饭。当然这个清理是有前提的,得先确认你的数据里逗号只可能出现在数字中间,如果可能出现在别的地方就得改成精确的千分位校验。
第三,"digits"模式直接调isdecimal(),不做转换。这是给“只做前置筛选、后面另有转换逻辑”的场景用的,比如你在遍历十万行数据,先用最快的判断筛一遍,剩下的再走重逻辑。
3.3 用例设计与实测结果
测试用例我按“该过的”和“不该过的”两类设计,每类都塞了一些看起来像但其实不是的边界值。
import unittest class TestNumberCheck(unittest.TestCase): def test_should_pass(self): for s in ["0", "42", "-7", "+7", " 42 ", "3.14", "-0.5", ".5", "1.", "1e6", "1E-6", "-1.2e+3", "1,234", "1,234"]: self.assertTrue(is_valid_number(s, "loose"), s) def test_should_fail(self): for s in ["", " ", "abc", "12a", "1.2.3", "--1", "+", ".", "1e", "e5", "nan", "inf", "-inf", "NonE", "123a"]: self.assertFalse(is_valid_number(s, "loose"), s)跑下来几个反直觉的结果值得记录。"1."在"float"模式下通过,因为Decimal("1.")合法;.5同样通过。"12a"在"digits"模式下直接 False,因为isdecimal()一遇到a就返回 False。"123"在"digits"模式下是 True,在"float"模式下是 False,这个不一致是设计好的,不是 bug——digits模式的定义就是“纯数字字符”,转换成数值是另一回事。
如果业务上要求全角数字也要能入库,在进入判断之前加一行规范化就行:
import unicodedata text = unicodedata.normalize("NFKC", s)NFKC 会把全角数字、全角符号、连字等统一成半角形式,"123"变成"123","12.5"变成"12.5"。这一手在处理用户输入时特别好用,比手写字符映射靠谱,也不依赖任何第三方库。但要注意它会连带处理很多其他字符,如果你的输入里混了需要保留原样的特殊字符,得先确认影响范围。
3.4 四种方法的横向对比表
| 维度 | try/except 转换 | isdecimal 家族 | 正则 fullmatch | 手写扫描 |
|---|---|---|---|---|
| 代码量 | 5 行 | 1 行 | 2 行 + 模式 | 40 行左右 |
| 支持小数 | 是 | 否 | 是(可配) | 是(可配) |
| 支持负数 | 是 | 否 | 是(可配) | 是(可配) |
| 全角数字 | 否 | 是 | 取决于是否加 ASCII | 否 |
nan/inf | 能过,需额外拦 | 判不了 | 模式里不写就过不了 | 天然不过 |
| 报错信息可控性 | 差 | 中 | 好 | 最好 |
| 执行速度 | 成功快、失败慢 | 最快 | 中 | 最慢 |
| 规则可读性 | 中 | 高(规则极简时) | 高(模式短时) | 低 |
这张表我在团队内部当决策清单用。先看“支持小数/负数”这两列能不能满足需求,不满足的直接从选项里划掉;再看“全角数字”和“nan/inf”这两列,这通常是决策的分水岭;最后才比速度和代码量。
4. 常见问题与排查技巧实录
4.1 典型坑位速查表
这一张表是我这些年攒下来的,每一条都对应过真实的报障。
| 现象 | 原因 | 处理方式 |
|---|---|---|
"12.5".isdigit()返回 False | 三个 is* 方法都不认小数点和符号 | 小数场景换try/except或正则 |
| 判断通过,转换却抛异常 | 全角数字过isdecimal()但过不了float() | 加isascii()或先做 NFKC 规范化 |
"123abc"被判为合法 | 用了re.match而不是fullmatch | 一律改fullmatch |
nan混进了统计结果 | float("nan")合法,判断函数没拦 | 转完值判math.isnan,或改用Decimal.is_finite() |
1_000被当成非法 | Python 3.6+ 的int()/float()接受下划线 | 前端限制输入,或转换前先replace("_", "") |
| 空字符串没拦住 | "".isdecimal()是 False,但显式转换前漏判 | 统一在入口strip()后判长度 |
布尔值True被判成数字 | float(True)返回1.0 | 入口加isinstance(s, str)类型检查 |
| 大额金额少了一分钱 | 用float判完又用float存 | 金额场景全程Decimal |
表里最需要展开的是“判断通过、转换却抛异常”这一条。它的根因是判断逻辑和转换逻辑用了不同的实现,一个用isdecimal(),一个用float(),两者对字符集的定义不一致。解决办法有两个方向:要么把判断也换成和目标转换函数一致的方式(用try/except包float()),要么在判断前做一次规范化把非 ASCII 数字转掉。我更推荐第一种,因为“判断”和“使用”用同一个函数,天然不会漂移。
4.2 排查这类问题的三步走
遇到“明明应该是数字却判失败”或者“明明不是数字却判成功”,我按这个顺序查:
第一步,把输入值打印成repr(),别用普通 print。repr()会把不可见字符暴露出来——前后空格、零宽字符、全角符号、\r,这些东西在终端里肉眼看一模一样,.strip()有时候也清不掉零宽字符。很多“诡异 bug”到这一步就真相大白了。
第二步,把输入的长度和每个字符的码点打出来。[hex(ord(c)) for c in s]一行搞定,全角数字的码点是0xFF11到0xFF19,和 ASCII 的0x30到0x39差了十万八千里。看码点能立刻判断出是编码问题还是逻辑问题。
第三步,才是回头看判断方法本身。把这段输入丢进一个交互环境,分别跑isdecimal()、isdigit()、isnumeric()、float()、正则,五个结果一摆,哪个对哪个错一目了然。这一步我一般做成一个debug_number(s)的小函数放在工具模块里,需要的时候直接调,比临时敲命令快。
提示:排查输入来源时,别忘了
input()在某些终端环境下会把\r也读进来,尤其是在跨平台脚本里。strip()不带参数时能清掉\r\n\t这几种,但清不掉零宽空格\u200b,遇到顽固的“看起来是 42 却判失败”,试试用码点查。
4.3 性能对比与选型结论
性能这块我做过一轮粗略测试,用timeit各跑 10 万次,输入分别是"12345"(成功路径)和"12345a"(失败路径)。下面是相对量级,绝对数字取决于机器,看比例关系就行:
| 方法 | 成功路径相对耗时 | 失败路径相对耗时 |
|---|---|---|
isdecimal() | 1.0 | 1.0 |
| 正则(预编译) | 约 3 到 5 倍 | 约 3 到 5 倍 |
try/except包float() | 约 1.5 到 2 倍 | 约 15 到 25 倍 |
| 手写扫描 | 约 20 到 30 倍 | 约 20 到 30 倍 |
结论很清楚:isdecimal()是最快的,因为它是 C 层的一个纯字符遍历,没有任何额外对象构造。正则稍慢,慢在正则引擎的进入和退出开销,但它是稳定的,成功和失败的耗时差不多。try/except在成功路径上其实不慢,但失败路径要构造异常对象,开销一下子上去了——异常机制本来就是给“异常”用的,不该拿来做高频正常分支判断。手写扫描最慢,因为它跑在 Python 字节码层面,每个字符都要走一遍解释器循环。
所以如果你的场景是“绝大多数输入都合法、偶尔有脏数据”,try/except完全够用,甚至比正则还快。如果是“脏数据占比很高”的场景,比如清洗一个来源不明的日志文件,那失败路径的开销就会累积起来,这时候用正则或者先用isdecimal()快筛更划算。
5. 场景落地:三种真实需求下的写法
5.1 表单校验场景:错误信息比判断结果更重要
接口收数据时,用户需要知道“错在哪”,光返回一个False是不够的。这种场景我会用正则,因为可以按子模式分别给出提示。
INT_ONLY = re.compile(r"[+-]?\d+", re.ASCII) MONEY = re.compile(r"-?\d{1,12}(?:\.\d{1,2})?", re.ASCII) def validate_amount(raw): text = (raw or "").strip() if not text: return False, "金额不能为空" if not MONEY.fullmatch(text): return False, "金额格式应为最多两位小数的数字,例如 199.00" return True, NoneMONEY这个模式把整数部分限制在 12 位、小数部分限制在 1 到 2 位,这比“是不是数字”进了一大步——它同时做了格式校验和精度约束。用fullmatch保证整串匹配,用re.ASCII防止全角数字混进来。返回(bool, msg)的元组结构,上层直接拿msg拼响应,不用再写一堆 if-else 判断哪条规则没过。
这里有个经验:校验函数的返回值一定是“结果 + 原因”,哪怕现在调用方只用结果。因为需求早晚会变成“要给出具体错误提示”,到时候你再改函数签名,所有调用点都要跟着动。
5.2 数据清洗场景:宽容解析和严格校验要分开
清洗 CSV 或 Excel 导入的数据时,我从不指望一次判断就搞定。我的做法是分两层:第一层做规范化,把能救的救回来;第二层做严格判断,救不回来的标脏。
def clean_number_cell(raw): if raw is None: return None, "空值" text = unicodedata.normalize("NFKC", str(raw)).strip() text = text.replace(",", "").replace(" ", "") if text.endswith("%"): text = text[:-1] try: return float(text) / 100, None except ValueError: return None, "百分比格式错误" try: return Decimal(text), None except InvalidOperation: return None, "无法解析为数字"先 NFKC 把全角转半角,再去掉千分位逗号和中间夹杂的空格,然后判断百分号,最后用Decimal解析。每一步都在“做减法”,把不影响数值语义的装饰性字符剥掉。返回(值, 错误原因)的二元组,调用方可以一边入库有效数据、一边把错误行收集起来生成问题报告。
用Decimal而不是float的原因很实在:金额和统计数据用二进制浮点存储会有精度损失,0.1 + 0.2 != 0.3这种问题在数据清洗阶段埋下去,后面算汇总的时候就会以“差几分钱”的形式暴露出来,排查成本极高。
5.3 我踩过的几个坑和一点个人习惯
第一个坑是早年写爬虫时用isdigit()判断价格,抓到"1,299"直接判非法整行丢弃,结果那批数据里的千分位金额全丢了,等到做对账才发现。从那以后我给自己定了条规矩:判断函数的名字里必须体现它判的是什么。叫is_number太含糊,叫is_plain_ascii_decimal或者can_cast_to_float就没人会误解。
第二个坑是正则的match和fullmatch。这个错误我至少在三个不同项目里见过,包括我自己早期写的代码。现在的习惯是:只要写校验类正则,第一反应就是fullmatch,如果因为某些原因必须用match,就在旁边加一行注释说明为什么。
第三个坑跟类型有关。有一次把一个校验函数从“处理字符串”改成“处理任意输入”,结果float(True)返回1.0这件事让一个布尔字段被当成数字存进了数据库。修复很简单,入口加一句isinstance(s, str),但这个教训值得记:校验函数的第一件事永远是确认输入类型,不要相信调用方。
最后一个习惯:我所有项目的number_utils.py里都留了一个debug()函数,把输入的repr、长度、每个字符的码点、五种判断方法的结果一次性打出来。写的时候花五分钟,排查的时候能省五十分钟。这个函数不需要多优雅,能打印清楚就行。上面那些坑,有一大半是靠它定位出来的。