ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python进制转换实战:36进制与十进制互转原理、代码与工程坑

Python进制转换实战:36进制与十进制互转原理、代码与工程坑 1. 为什么业务代码里要自己搞36进制转换1.1 一次邀请码事故引出的进制问题先说个我自己的经历。之前给一个活动系统做兑换码最开始图省事直接把数据库自增ID发给用户。结果上线第二天就有人摸出规律领了码 10086马上猜到下一个是 10087后台兑换接口又没做防重放差点被薅秃。后来想了个最简单的方案把ID转成36进制再发出去。这样纯数字的长ID会变成一串字母数字混合码比如10086在36进制下是7QY直观上就比一串数字安全不少。但真动手做的时候才发现Python里想把十进制整数转成36进制字符串居然没有现成的format支持——format函数的b、o、d、x只覆盖2、8、10、16进制。用int()解析36进制字符串倒是原生支持但那是反向的。于是只能自己封装转换函数。这一折腾就牵扯出一堆细节负数怎么处理、要不要补位、大小写怎么统一、遇到非法字符怎么报错、性能能不能扛住线上调用量。这篇就系统地聊聊Python里36进制和10进制互相转换的原理、实现和工程坑讲的是我在真实项目里踩完之后留下来的最终版本。1.2 36进制、16进制、62进制的选型对比在动手写代码之前先得搞清楚为什么选36进制而不是别的。进制选型本质是字符集和码位长度之间的权衡。进制字符集10亿整数所需位数大小写敏感典型场景16进制0-9 A-F约8位否颜色值、哈希摘要36进制0-9 A-Z约6位否邀请码、兑换码、电话播报62进制0-9 A-Z a-z约5位是URL短链、压缩码16进制的位数在数字变大后明显偏长10亿级别的ID要8位字符码值不够紧凑。62进制虽然压缩率最高但引入了大小写字母存在两个隐患一是人工抄写时容易把O和0、I和l搞混二是如果你做的大小写不敏感匹配62进制的信息量会被打折。36进制刚好卡在中间——字符集避开大小写歧义码位长度比16进制短约四分之一适合通过短信、电话、纸质单据分发。当然36进制也不是万能的。如果码值要尽可能短且分发渠道是纯URL链接62进制优势更明显如果只需要展示十六进制片段16进制也够用。选36进制通常意味着人可读、可手输、不敏感三个需求同时存在。2. int(x, 36) 只能做一半的活内置转换的真实边界2.1 int() 解析36进制的正确用法Python内置函数int()支持从2到36任意进制的字符串解析用法是int(s, base)base传36即可。它遵循一条核心规则字符串里的每个字符必须落在0-9和A-Z范围内且字母不区分大小写。int(7QY, 36) # 10086 int(7qy, 36) # 10086大小写不敏感 int(Z, 36) # 35 int(10, 36) # 36注意不要和十进制10混淆这个函数对前导零很宽容int(0007QY, 36)照样返回10086。它还支持正负号int(-7QY, 36)返回-10086。所以如果你只是想把一串已经确定的36进制文本解析成整数直接用int()就够了。但这里有个容易忽视的分工问题int()帮你完成了36进制字符串到10进制整数这半条路而10进制整数到36进制字符串这半条路在标准库里没有对应的格式化功能。这就是为什么很多教程只讲一半真正写业务代码时还得自己补另一半。2.2 format() 只支持 2/8/16 进制36进制要另想办法Python的format()函数和f-string格式说明符只开放了b、o、d、x四种进制输出你想要format(10086, 36)会直接抛ValueError。这是语言层面没做不是用法问题。市面上常见的替代方案有两个import numpy as np np.base_repr(10086, base36) # 输出 7QYnumpy的base_repr支持2到36进制的整数转字符串还能通过padding参数补零。但为了一个进制转换引入numpy依赖在很多轻量服务里是不划算的。另一个方案是自己在项目里维护一个DIGITS 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ常量用循环手写转换逻辑。我个人的判断标准很简单项目里如果已经用了numpy顺手用base_repr没毛病否则就手写一个独立函数代码量也就十行左右还能顺便解决负数、补位、非法字符这些numpy和int()都不太友好的问题。2.3 业务代码里最容易踩的三个坑用内置int(s, 36)解析时有几个坑我在生产环境里都真实遇到过。第一个坑是Python 3.6以后int()支持数字下划线分隔符int(1_0, 36)不会报错而是静默解析成十进制的36。如果你的36进制字符串来自用户输入某个用户手滑在中间敲了个下划线代码不会抛异常而是给你一个完全不同的数值后续逻辑拿这个值去查数据库查出来是别人的数据非常隐蔽。第二个坑是int()对首尾空白是容忍的int( 7QY , 36)会正常解析。这本身是好事但它同时也吞掉了空字符串以外的一些脏输入。如果输入是\u2003这种全角空格或制表符混在里面int()的行为会因版本和Unicode规范差异产生不确定性稳妥的做法是进函数前先做strip()。第三个坑是异常信息太笼统。int(A, 36)抛的是ValueError: invalid literal for int() with base 36。在批量清洗历史数据时你很难从报错里判断是哪一条记录、坏在哪个字符上。手写解析函数时把错误信息里带上原始字符串和非法字符排查效率会高很多。3. 手写十进制转36进制短除法、字符映射与负数处理3.1 短除法原理与手工推导十进制转任意进制的标准做法是短除法不断用目标进制去除原数记录每次的余数最后把余数序列反转拼接。以350为例转成36进制350 ÷ 36 9 余 26 9 ÷ 36 0 余 9余数从下往上读是9、26把26映射成字符Q因为A10B11...Q26得到最终结果9Q。验证一下9 × 36 26 350正确。注意余数可能超过9这时必须通过字符映射转换成字母。映射规则0到9对应字符0到910到35对应A到Z。这个映射表是整个转换逻辑的核心写错一位就是全线崩溃。3.2 Python实现从除数到字符串反转直接上我线上在用的版本DIGITS 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ def to_base36(num: int) - str: if num 0: return 0 sign if num 0: sign - num -num chars [] while num 0: num, remainder divmod(num, 36) chars.append(DIGITS[remainder]) return sign .join(reversed(chars))这里有两个实现细节值得说。一是用divmod一次性拿到除法的商和余数比分别用num // 36和num % 36更清晰也少一次求余运算。二是先取余再反转因为短除法的余数顺序是反的直接拼接会得到错误的字符串。负数处理的原则是符号前置绝对值照常转换。把负数取绝对值后走同一套逻辑最后在结果前面拼一个负号这和十进制字符串的书写习惯一致。3.3 扩展补位、负数、大小写控制业务上经常需要固定长度的码值比如兑换码统一6位不足补零。补位要放在符号处理之后def to_base36_padded(num: int, length: int) - str: sign - if num 0 else num abs(num) chars [] while num 0: num, rem divmod(num, 36) chars.append(DIGITS[rem]) s .join(reversed(chars)) or 0 s s.zfill(length) return sign s注意zfill对带负号的字符串不友好-12.zfill(4)的结果是-012不是-0012所以必须先处理符号再补位。大小写控制要看下游需求。短信里的兑换码我建议统一用大写用户输错成小写时解析端统一upper()再转换两头一致就不会出问题。如果不想让码值看起来太规整可以输出时混入小写字母但这会让大小写不敏感场景的校验逻辑变复杂得不偿失。4. 手写36进制转十进制加权累加与输入校验4.1 权值累加原理从36进制字符串转回十进制数学原理是每一位的数值乘以36的幂次再求和。比如3QQ是26所以值是3 × 36 26 134。工程实现上不需要真的计算幂次用累加方式更高效从左到右遍历每遇到一个新字符就把当前结果乘以36再加上该字符对应的数值。这样3Q的计算过程是0 - 0 × 36 3 3 - 3 × 36 26 134。循环次数和字符串长度一致复杂度O(n)对超长字符串也能线性处理。4.2 健壮版实现异常字符、大小写统一完整的解析函数要处理输入清洗、字符合法性和业务异常三件事def from_base36(s: str) - int: if not isinstance(s, str): raise TypeError(输入必须是字符串) s s.strip() if not s: raise ValueError(空字符串无法解析) sign 1 if s[0] in -: if s[0] -: sign -1 s s[1:] value 0 for ch in s.upper(): if 0 ch 9: digit ord(ch) - ord(0) elif A ch Z: digit ord(ch) - ord(A) 10 else: raise ValueError(f非法字符 {ch}36进制只允许 0-9 和 A-Z) value value * 36 digit return sign * value几个设计决策说明一下。先用strip()去掉首尾空白避免用户输入时不小心带空格然后单独处理正负号这比在循环里判断更清晰字符转数字用的是ord()差值计算比查表快一点而且可读性也不差。非法字符分支把具体字符带进异常信息这在清洗脏数据时能直接定位问题。4.3 为什么不建议直接照抄 int(s, 36)也许你会问既然内置int()能解析为什么还要自己写除了前面说的下划线陷阱和异常信息模糊还有一个更实际的原因int()的行为受Python字面量规则影响它把字符串当作Python代码里的数字字面量来解析而不是当作纯36进制数据。举个例子int()会接受Unicode数字字符比如某些阿拉伯-印度数字这在多语言输入环境下可能引入你完全没预料到的解析结果。而手写解析函数把所有字符等同于0-9和A-Z的有限集合行为完全可控。另外如果你的转换结果后面还要接数据库查询、做缓存键或者要和其他系统对账自己封装一层函数方便统一埋点、统一日志。这些扩展需求是内置函数给不了的。5. 性能实测与函数取舍百万次转换的差距在哪里5.1 三种实现的基准测试既然手写函数是纯Python循环肯定比不过C实现的内置int()。但具体差多少还是实测一下心里有数。我拿了1000万个整数做十进制转36进制的对比测试测试对象是自写函数、numpy.base_repr、以及一个用int()解析的反向对照组。测试环境和代码大致这样import timeit setup_code from __future__ import annotations DIGITS 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ def to_base36(num: int) - str: if num 0: return 0 sign if num 0: sign - num -num chars [] while num 0: num, remainder divmod(num, 36) chars.append(DIGITS[remainder]) return sign .join(reversed(chars)) 单次调用速度上手写函数大概比int()解析慢8到10倍毕竟一个是C循环一个是Python循环。但落到真实业务量级情况完全不同。一次兑换码校验通常要解析1到2个36进制字符串手写函数单次耗时在微秒级别一秒能处理几十万次普通后端服务完全跑不满这个量级。numpy.base_repr就更特殊了它内部有数组转换的开销单次调用比手写函数还慢只有你在批量生成大量码值时才有优势。5.2 测试结果解读与实际建议性能结论可以浓缩成三句话解析方向36进制转10进制能用内置int(s, 36)就用性能好且代码少。生成方向10进制转36进制自写函数足够应付99%的业务场景不要为了省几行代码去引入numpy。如果单次请求需要转换几十万个数字再考虑numpy向量化或PyPy这类JIT方案。我自己的最终工具函数是混搭的对外暴露的自定义接口负责清洗和校验内部解析逻辑直接调用int(s, 36)和自写的to_base36。这样既有内置函数的速度又保住了业务层的可控性。这个外壳内核的思路在处理其他格式转换时也通用。6. 落到真实项目邀请码、短链与序列号的改造细节6.1 自增ID转邀请码长度裁剪与补零最典型的场景是数据库自增ID转邀请码。假设用户ID是126789先转36进制126789 ÷ 36 3521 余 3333对应X3521 ÷ 36 97 余 2929对应T97 ÷ 36 2 余 2525对应P2 ÷ 36 0 余 2。结果反向拼起来是2PTX。验证2 × 36³ 25 × 36² 29 × 36 33 126789没问题。这里有个容易忽略的点纯粹用ID转出来的码值会泄露业务规模。你发出去的邀请码是2PTX用户拿到的码以2开头可能就能猜出当前用户总量级在十万左右。想避免这一点可以在转码前给原始ID乘一个固定系数再加上偏移量让码值和真实ID之间不再是线性映射同时保留逆运算能力。码值长短也可以做裁剪控制。比如只取最后6位作为短码前提是你接受冲突概率。我的做法是先转成36进制再按需要的位数从字符串里取子串如果包含截断的确定性没关系只要保证生成规则和校验规则一致。6.2 短链接场景的36进制 vs 62进制短链接是进制转换的另一个高频场景。传统方案是维护一个全局自增ID转成短码。这时候用36进制还是62进制要看你把链接发到哪里。微信、邮件、短信这类渠道经常对URL做大小写归一化你辛辛苦苦用62进制把码值压缩到5位结果渠道端把大写字母统一成小写链接就废了。这些渠道上老老实实用36进制最稳。如果链接是用户在浏览器里手动粘贴大小写敏感无所谓的纯URL场景62进制能再省一位5位码支持接近9亿组合比36进制高一个量级。我实际做过的方案是双轨制对外短码用36进制保持兼容性内部数据库记录用数值类型主键两者通过转换函数解耦。这样即便以后要升级到62进制只需替换转换层不动业务表结构。6.3 带校验位的36进制序列号如果要生成像激活码、礼品卡这类需要防误输的序列号进制转换可以和其他技巧配合使用。36进制本身的字符集天然排除了I、O这类易混淆字符再叠加一个校验位能进一步降低人工输入错误。简单思路把36进制字符串里每个字符映射成数值加权求和后取模36把余数对应的字符附加在末尾。验证时重新计算校验字符和末尾字符对比不一致就拒绝。这套逻辑和银行卡号的Luhn算法思路类似只是把十进制换成了36进制。具体的加权因子建议不要全用1尽量用和字符串长度互质的数列这样单字符错误和相邻字符换位都容易被捕获。校验位逻辑一定要和业务主流程分离做成独立函数方便单测覆盖。6.4 实战心得让码值看起来毫无规律的诀窍最后一个实用技巧是我在多个项目里反复验证过的。如果你希望邀请码、订单号看起来完全随机不要直接用to_base36(id)这种单调映射。可以先把ID做一次位混淆对整数做几次乘法、取模、异或再转36进制。def obfuscate_id(seed: int) - int: x (seed * 2654435761) 0xFFFFFFFF x ((x 16) ^ x) * 2246822519 return ((x 16) ^ x) 0xFFFFFFFF这是个经典的整数散列变换输入连续ID时输出在32位空间里看起来乱序分布但因为是确定性函数反向解码时重新乘一次逆元就能还原。缺点是没有密码学强度恶意用户多采样几个码值还是能逆向出规律。真正需要防伪的场景直接用HMAC或AES截断生成码值不要在自己造的混淆算法上浪费时间。我个人在实际项目里的排序是普通邀请码用36进制加偏移足够涉及钱、券、积分这类资产凭证的一律上真随机数加数据库唯一索引不碰进制混淆。分清场景才不会在安全上翻车。
返回列表