数字转英文中文表达:规则引擎设计与边界处理实战 数字转英文和中文表达这件事乍一看像是编程入门练习题真动手写起来才发现坑比想象中多得多。我做过多语言账单系统、发票打印模块、语音播报文案生成几乎每隔一段时间就要跟这套逻辑打一次交道。表面需求很简单输入123输出one hundred twenty-three和一百二十三。但实际业务里会遇到小数、负数、超大数、金额大写、口语化读法、中文的“两”和“二”之争、英文的连字符与and规则还有各种边界值。这篇就把我这些年踩过的坑、总结的规则和可直接复用的实现思路完整摊开讲一遍不管你是刚接触字符串处理的新手还是需要落地到生产系统的开发者都能从中拿到能直接抄作业的东西。1. 先想清楚这个需求到底在解决什么问题1.1 需求边界比想象中宽很多人接到“数字转英文中文”这个标题第一反应是写个if-else把 0 到 9 映射一下就完事。我最初也这么干过结果上线第二天就收到反馈金额1001读成了“一千一”而不是“一千零一”。这就是典型的边界没想清楚。先把需求拆开看一个完整的数字转写模块至少要覆盖这几类输入整数正数、负数、零位数从个位到十几位甚至更大。小数小数点后的读法英文逐位读还是按数值读中文“点”后面怎么处理。特殊场景金额大写、序数词第一、第二、分数、百分比、电话号码式逐位读法。语言差异英文的百位and、连字符规则中文的“零”的省略规则、“两”的使用场景。所以这个项目的本质不是“翻译数字”而是建立一套从数值到自然语言字符串的映射规则引擎。理解这一点后面的架构设计才不会走偏。1.2 为什么值得单独做成一个模块有人会问这种逻辑随手写个函数不就行了为什么要当成一个项目来做我的经验是数字转写逻辑一旦散落在业务代码里维护成本会指数级上升。账单系统要英文报表要中文语音播报要口语化读法三处各写一份规则还不一致改一个 bug 要改三个地方。把它抽成独立模块的好处很直接规则集中、测试集中、复用方便。而且数字转写是典型的纯函数场景——同样的输入永远得到同样的输出没有副作用特别适合写单元测试把边界全部锁死。我现在的习惯是只要项目里出现两处以上数字转文字的需求立刻抽模块这个投入产出比非常高。1.3 适合谁来参考这篇内容对三类人最有价值一是正在做国际化、多语言项目的开发者账单、发票、报表场景几乎绕不开二是准备面试或刷算法题的同学这类题考察的就是边界处理能力三是对字符串处理和规则引擎感兴趣、想找一个完整小项目练手的人。下面从设计思路到实操代码一层层往下拆。2. 整体设计分组、映射、拼接三步走2.1 核心思路按位分组是通用解法不管是英文还是中文大数字的读法都遵循“分组”规律。英文每三位一组thousand、million、billion中文每四位一组万、亿、万亿。这个差异是设计时第一个要抓住的点。我的做法是先写一个通用的分组器把数字按目标语言的进制切分成小组每组内部单独转写最后按组拼接单位。这样英文和中文可以共享分组框架只在“组内转写”和“组单位”两处做差异化实现。用生活化的类比就像把一长串数字切成几段短号码每段单独念再在段与段之间加上“万”“亿”这样的连接词。具体分组规则语言分组进制组单位序列英文3 位空、thousand、million、billion、trillion中文4 位空、万、亿、万亿这个表看着简单但它决定了整个模块的骨架。分组器写对了后面就是填空题。2.2 为什么不用递归硬解有一种常见写法是用递归不断取模、除以 10 或 1000。递归不是不行但调试起来很痛苦尤其是中文“零”的省略规则递归层级一深中间状态很难追踪。我倾向于迭代式分组 显式状态标记每一步的中间结果都能打印出来看出问题定位快。这是工程实践和算法题解法的区别算法题追求代码短工程追求可维护、可调试。2.3 模块划分建议我通常把这个模块拆成四层输入校验层处理非法输入、超范围、精度问题。分组层把数字切成小组附带组序号。组内转写层英文和中文各一套映射表加规则。拼接层处理组间连接、零的省略、特殊读法。分层的好处是每层可以独立测试。比如组内转写层我可以用 0 到 9999 全量跑一遍确认每个数都读对再往上拼。这种自底向上的验证方式比一上来就测大数要高效得多。3. 英文转写规则细节全拆解3.1 基础映射表怎么建英文的 0 到 19 是不规则词必须硬编码20 到 90 的整十也是不规则词剩下的两位数用“整十 连字符 个位”拼。这三张表是整个英文转写的地基ONES [zero,one,two,three,four,five,six,seven,eight,nine, ten,eleven,twelve,thirteen,fourteen,fifteen,sixteen, seventeen,eighteen,nineteen] TENS [,,twenty,thirty,forty,fifty,sixty,seventy,eighty,ninety]注意forty不是fourty这个拼写错误我见过不止一次出现在生产代码里。映射表建好后0 到 99 的转写就是一个固定套路小于 20 查ONES否则查TENS加个位。3.2 百位与 and 的规则英文里 100 到 999 的读法是“X hundred and Y”。这里的and是英式英语的习惯美式英语里经常省略。我的建议是做成可配置项因为不同客户、不同地区的期望不一样。默认我倾向带and因为它在正式文书里更常见。百位转写的逻辑百位数字查ONES加hundred余数部分如果非零加and再接余数的转写。这里有个细节余数是 0 时不能加and否则会读成“one hundred and”尾巴拖个 and 很别扭。3.3 千位以上组单位拼接三位一组往上拼时每组的转写复用百位以内逻辑然后接组单位。比如1234567分成1 | 234 | 567读作one million two hundred thirty-four thousand five hundred sixty-seven。拼接时最容易错的是中间组为零的情况。比如1000001分成1 | 000 | 001中间的000组不发音但两边的组都要读结果是one million one。如果机械地按组拼接会读成one million zero thousand one这就错了。所以拼接层必须跳过值为零的组同时保证非零组之间的连接自然。3.4 连字符与空格的处理英文里 21 到 99 的非整十数中间用连字符比如twenty-one。但跨组拼接时用空格。这个细节在生成语音播报文案时特别重要因为连字符影响 TTS 引擎的断词。我的处理方式是组内转写返回带连字符的字符串拼接层统一用空格连接两层职责分开互不干扰。4. 中文转写零的省略是最大难点4.1 中文数字映射与单位中文的基础映射相对规整零一二三四五六七八九单位有个、十、百、千、万、亿。但规整不代表简单中文的难点全在“零”的处理和“两”的用法上。先看基础映射CN_DIGITS [零,一,二,三,四,五,六,七,八,九] CN_UNITS [,十,百,千] CN_GROUP_UNITS [,万,亿,万亿]四位一组组内用“十百千”组间用“万亿”。框架和英文一致差异在组内规则。4.2 零的省略规则中文读法里连续的零只读一个末尾的零不读组间的零要视情况补。举几个例子感受一下1001读“一千零一”中间的零要读。1100读“一千一百”末尾两个零都不读。10000读“一万”四个零全不读。10001读“一万零一”组间的零要补一个。我总结的规则是组内从高位往低位转写时遇到零先记一个标记等遇到下一个非零数字时补一个“零”末尾的标记直接丢弃。组与组之间如果低位组的值小于 1000也就是千位是零且低位组非零则要补一个“零”。这条规则我验证过大量用例基本覆盖所有情况。4.3 “两”和“二”的取舍这是中文特有的坑。2在什么情况下读“两”我的经验规则单独的数字、序数、小数、电话号码读“二”。量词前、百位以上作为开头时倾向读“两”比如“两百”“两千”“两万”。但“二十”不读“两十”“十二”也不读“十两”。严格来说这是语感问题没有绝对规则。工程上的做法是默认全用“二”在特定场景如金额口语播报提供“两”的替换开关。不要试图用一套规则覆盖所有语境那样只会越写越乱。4.4 十位的特殊读法10到19读“十”到“十九”不读“一十”。但110读“一百一十”这里的“一十”又要读出来。这个差异取决于“十”是不是最高位。我的处理是组内转写时如果当前是十位且高位没有数字省略“一”否则保留。这个判断在分组框架下很好实现因为组内转写知道自己的起始位。5. 完整实操从零写一个可复用的转换器5.1 环境与依赖这套逻辑用任何主流语言都能写我用 Python 演示是因为它可读性最好方便你对照移植。环境要求极低标准库足够不需要第三方包。如果你要处理超大数超过 JavaScript 安全整数范围Python 的原生大整数正好省事用其他语言的话记得用大数类型或字符串处理。5.2 英文转换核心实现ONES [zero,one,two,three,four,five,six,seven,eight,nine, ten,eleven,twelve,thirteen,fourteen,fifteen,sixteen, seventeen,eighteen,nineteen] TENS [,,twenty,thirty,forty,fifty,sixty,seventy,eighty,ninety] SCALES [,thousand,million,billion,trillion] def _under_1000(n, use_andTrue): parts [] if n 100: parts.append(ONES[n // 100] hundred) n % 100 if n and use_and: parts.append(and) if n 20: word TENS[n // 10] if n % 10: word - ONES[n % 10] parts.append(word) elif n 0: parts.append(ONES[n]) return .join(parts) def to_english(num, use_andTrue): if num 0: return zero neg num 0 num abs(num) groups [] while num 0: groups.append(num % 1000) num // 1000 words [] for i in range(len(groups) - 1, -1, -1): if groups[i] 0: continue chunk _under_1000(groups[i], use_and) if SCALES[i]: chunk SCALES[i] words.append(chunk) result .join(words) return (minus result) if neg else result这段代码的关键点在groups[i] 0时的continue它保证了中间零组被跳过。use_and参数控制百位and的开关方便适配不同地区习惯。5.3 中文转换核心实现CN_DIGITS [零,一,二,三,四,五,六,七,八,九] CN_UNITS [,十,百,千] CN_GROUPS [,万,亿,万亿] def _under_10000(n): if n 0: return s zero_flag False digits [] while n 0: digits.append(n % 10) n // 10 for pos in range(len(digits) - 1, -1, -1): d digits[pos] if d 0: zero_flag True else: if zero_flag and s: s 零 zero_flag False if pos 1 and d 1 and not s: s 十 else: s CN_DIGITS[d] CN_UNITS[pos] return s def to_chinese(num): if num 0: return 零 neg num 0 num abs(num) groups [] while num 0: groups.append(num % 10000) num // 10000 parts [] for i in range(len(groups) - 1, -1, -1): g groups[i] if g 0: continue chunk _under_10000(g) if CN_GROUPS[i]: chunk CN_GROUPS[i] parts.append(chunk) result for idx, p in enumerate(parts): if idx 0 and groups[len(groups) - 1 - idx] 1000: result 零 result p return (负 result) if neg else result中文这段的难点在组间补零的判断groups[len(groups) - 1 - idx] 1000这一句意思是当前组的高位千位是零时前面要补一个“零”。这个判断我调了很久才稳定建议你拿10001、100000001这类数反复验证。5.4 小数与负数的处理小数部分我建议逐位读而不是按数值读。3.14读three point one four和“三点一四”这样最不容易出错也符合大多数场景金额、科学计数的习惯。负数在开头加minus或“负”即可。如果业务需要“负三点一四”这种把符号处理放在最外层整数和小数部分分别转写再拼接。5.5 验证用例设计写完代码必须跑用例我习惯准备一张覆盖表输入英文期望中文期望0zero零13thirteen十三100one hundred一百1001one thousand and one一千零一10000ten thousand一万10001ten thousand and one一万零一1234567one million two hundred thirty-four thousand five hundred sixty-seven一百二十三万四千五百六十七这张表里的每个数都对应一个规则点跑通它基本就稳了。建议再加一批随机数做对拍用另一套独立实现哪怕是暴力递归版交叉验证。6. 常见问题与排查实录6.1 高频问题速查表现象可能原因排查方向中间零组被读出来拼接层没跳过零组检查分组循环里的零值判断中文末尾多个“零”零标记没在末尾丢弃检查组内转写的收尾逻辑英文forty拼错映射表手误全局搜索fourty大数精度丢失用了浮点类型改用整数或字符串处理负数符号位置错符号处理放在了拼接后符号应在最外层统一加6.2 踩过的坑浮点精度早期我用float接收输入结果0.1 0.2转出来是0.30000000000000004逐位读法直接读出一长串。后来统一改成字符串接收小数部分或者用Decimal类型问题才消失。这个坑在金额场景特别致命一定要在输入层就把类型定死。6.3 踩过的坑中文“零”的组间补位100000001这个数分成1 | 0000 | 0001正确读法是“一亿零一”。我最初的实现漏了组间补零读成了“一亿一”。修复方法就是前面代码里那句组间判断。这类 bug 的特点是只在特定数位组合下出现所以测试用例一定要覆盖“高位组非零、中间组全零、低位组非零”这种结构。6.4 实操心得先写测试再写实现这个模块我强烈建议测试先行。因为规则多、边界密先写实现很容易顾此失彼。我的做法是先把上面那张验证表写成测试用例然后让实现去跑跑不通就改改到全绿为止。这种开发方式在这个场景下效率极高比边写边想快得多。6.5 实操心得把规则做成配置英文的and、中文的“两”、小数的读法这些都有地区差异和场景差异。我的经验是把它们做成配置项默认值选最常见的特殊场景传参覆盖。这样一套代码能服务多个业务线不用为每个场景复制一份。配置项不要太多三五个足够多了反而增加理解成本。数字转写这个项目代码量不大但规则密度极高是锻炼边界思维的好素材。我做完第一版之后最大的体会是任何涉及自然语言的规则都不要相信自己的直觉一定要用用例去验证。你以为“一千一”是“一千一百”的省略但正式文书里就必须读全你以为“两”可以随便替换“二”但“十二”就不行。这些细节只有跑过大量真实数据才会暴露。后续如果要做语音播报还可以在这个模块上叠加口语化规则比如把“一百”读成“一百块”这种场景化处理那就是另一个层次的扩展了。