简介:这是一份面向Python初、中级开发者的实用代码资源,核心解决阿拉伯数字转中文大写的编程需求,适用于财务报表、发票金额、合同文书等要求中文数字展示的场景。资源压缩包内含1个PDF文件,整体大小约30KB,内容聚焦,下载后即可阅读。目前已有11359人浏览学习,受到较多关注。文档完整呈现数字转中文的Python解决方案,由四个函数分工协作:_to_chinese4处理万以内数字,逐位拆解并拼接千百十等单位,同时处理一十转十的细节;_to_chinese8负责万级区间,在万与低位之间正确补零,避免连续零出现;_to_chinese16扩展至亿级区间,沿用同样的补零策略;主函数to_chinese统一调度,先完成类型检查与范围校验,再根据数值区间调用对应函数。整体算法支持16位以内的整数转换,示例调用print(to_chinese(9000))输出九千。读者既可快速复制函数用于财务系统,也可通过清晰的分段设计学习递归拆分、位权映射和字符串处理技巧,是一份兼具实用性与教学意义的参考代码。
1. 数字转中文:看起来是个查表题,做起来全是规则题
财务系统要打发票大写金额、语音模块要播报数字、报表摘要要把大额数据转成中文读数,需求一句话,落地却没法用一张映射表直接糊弄过去。整数读法、小数读法、货币大写是三条独立的语法规则:“零”要不要省略、单位在亿和万之间怎么跳、角分位和大写“整”的关系,全都不同。这篇文章从整数读数写起,逐步扩展到负号、小数和人民币大写,每一步都给出可运行的 Python 代码、参数说明和边界验证,最后整理我踩过的五个高频翻车点。适合正在写工具函数、中间层接口或者语音合成前处理逻辑的开发者。
2. 整数转中文:四位分组法是唯一靠得住的套路
2.1 为什么逐位拼接总会翻车:中文读法的分组习惯
中文读数和英文不一样,英文三位一组,中文四位一组,而且“万”和“亿”这两个单位是真实读出来的。很多第一次写数字转中文的人,习惯拿到数字后从高位到低位逐位拼接:千位读“几千”,百位读“几百”,最后拼上“万”“亿”。这个方案在小数字上看起来没问题,一旦出现 100050001 这种带空位和连续零的数字,立刻露馅。逐位拼接最常见的结果是“一亿零五万零一”被拼成“一亿五万一”,或者更离谱的“一亿万”。
问题的根源在于:中文读数不是按位读,而是按四位一组读,组与组之间再套上“万”“亿”这类大单位。一个组内千位是零、或者整个组为零时,需要额外补一个“零”字;但如果连续两组都是零,又只能出现一个“零”。这些规则没法用循环加判断硬凑,至少我在项目里用逐位方式写过两次,都倒在边界用例上。后来统一改成四位分组法,把问题拆成“组内读数”和“组间补零”两层,才稳定下来。
四位分组法的核心思路是:先把数字按 10000 整除取余拆成若干个四位组,从高到低依次处理。每个四位组内部用“千百十”单位,组之间用“万亿兆”单位。组内全零就跳过,但标记一下,让后面遇到的非零组在前面补一个“零”;组内非零但首位是零(也就是这个组小于 1000)时,同样要在前面补“零”。这两条规则,配合“十”开头的处理,基本能把所有整数读对。
2.2 最小可运行实现:int_to_cn 完整代码
我一般把整数转换拆成两个函数:一个负责四位组内部转换,一个负责组间拼接。这样便于独立测试,也方便后续金额大写复用同一套分组逻辑。
CN_DIGITS = "零一二三四五六七八九" CN_UNIT = ["", "十", "百", "千"] CN_BIG_UNIT = ["", "万", "亿", "兆"] def group_to_cn(group: int) -> str: """把一个 0-9999 的四位组转成中文读数,不含万/亿等大单位。""" s = "" pending_zero = False for pos in range(3, -1, -1): d = group // (10 ** pos) % 10 if d == 0: if s: pending_zero = True else: if pending_zero: s += "零" pending_zero = False s += CN_DIGITS[d] + CN_UNIT[pos] return s def int_to_cn(num: int) -> str: """把整数转成中文读数,支持负数,最大支持到 9999 亿。""" if num == 0: return "零" if abs(num) >= 10 ** 12: raise ValueError("超出兆范围的整数暂不支持") sign = "负" if num < 0 else "" num = abs(num) groups = [] while num: groups.append(num % 10000) num //= 10000 parts = [] read_any = False need_zero = False for idx in range(len(groups) - 1, -1, -1): g = groups[idx] if g == 0: if read_any: need_zero = True continue g_str = group_to_cn(g) if read_any and (need_zero or g < 1000): parts.append("零") parts.append(g_str + CN_BIG_UNIT[idx]) read_any = True need_zero = False result = sign + "".join(parts) if result.startswith("一十"): result = result[1:] return result逻辑说明:group_to_cn内部从千位往个位遍历,遇到空位时先标记 pending_zero,遇到下一个非零数字才真正插入“零”。这样可以避免“一零零一”这种叠零。int_to_cn把数字按 10000 拆组后,从高位往低位拼接,这里的关键是need_zero标记:只要中间出现过整个为零的组,并且后面还有非零组,就在该组前补一个“零”。g < 1000的判断处理的是“万组和亿组存在但当前组千位为空”的场景,比如 100050001 读“一亿零五万零一”,没有这个条件会输出“一亿五万零一”。
参数说明:int_to_cn的入参是 int,返回值是 str。group_to_cn的入参是 0 到 9999 的整数,不直接对外使用。范围上限是 9999 亿,超过会抛 ValueError,不是为了省事,而是避免“兆”后面单位层级混乱。如果你确实要支持到亿亿级,可以扩展CN_BIG_UNIT,但一般业务场景用不到。
2.3 边界值自测清单:用这组用例验证你的实现
写完函数第一件事不是接业务,而是跑一跑边界用例。下面是我在任何一次重构后都会执行的测试表,每组都对应一个独立的坑:
| 输入 | 期望输出 | 这个用例在测什么 |
|---|---|---|
| 0 | 零 | 零的特判 |
| 10 | 十 | “一十”的省略,很多实现会输出“一十” |
| 100000 | 十万 | 组间衔接,不能输出“一十万” |
| 100050001 | 一亿零五万零一 | 空组补零 + 组内首位为零 |
| 100000001 | 一亿零一 | 连续空组只保留一个零 |
| 101000 | 十万一千 | 组内满四位时不需要补零 |
| -101 | 负一百零一 | 负号处理 |
| 999999999999 | 九千九百九十九亿九千九百九十九万九千九百九十九 | 上限边界,单位完整拼接 |
这组用例建议直接保存成一个测试函数,每次改代码后跑一遍。我第一次实现时挂在了 100050001 上,输出的“一亿五万零一”在语音播报里听起来尤其别扭,后来才补上g < 1000这个条件。另外注意int_to_cn吃的是 int,如果你拿到的是字符串,直接int()转换前要确认里面没有逗号或空格,这个在第六章会再展开。
3. 加上负号和小数:自然读法里最容易混在一起的两条规则
3.1 小数部分为什么要逐位读,而不是继续分组
很多人在整数方案跑通之后,顺手就把小数部分也用四位分组法处理,结果读出来很奇怪:0.1234 被读成“零点一千二百三十四”,但中文口语里小数部分永远是逐位读的,“零点一二三四”才对。小数点的位置是规则的分水岭:点之前按整数规则读,点之后的每一位数字单独念,单位完全不参与。
负号的规则相对简单,在整数部分前面加“负”字即可。但要注意一个边界:-0.5 应该读“负零点五”,而 -0 在整数场景下应该读“零”而不是“负零”。所以负数判断要放在整数转换之前,不要在整数字符串拼完之后再判断开头字符,否则“-0”会被拼成“负零”。
还有一个容易被忽略的地方是小数部分的数字“0”不能吞掉。比如 10.10 读作“十点一零”,不是“十点一”。如果按整数思维去省略末尾的零,语音播报会读出“十点一”,和原金额差一位。小数位必须原样逐字输出,包括末尾的零。
3.2 number_to_cn:字符串输入 + 整数部分复用
到这里你会发现,输入类型最好是字符串而不是浮点数。float 会把 0.1 变成 0.100000000000000005,也会把大数悄悄转成科学计数法。所以我的统一入口直接收字符串,先清洗,再分发到不同分支。
def number_to_cn(text: str) -> str: """把十进制数字字符串转成中文自然读法,支持负数和小数。""" text = text.strip() if not text: raise ValueError("输入不能为空") if "e" in text.lower(): raise ValueError("请传入普通十进制字符串,不要使用科学计数法") sign = "" if text.startswith("-"): sign = "负" text = text[1:] if "." in text: int_part, _, dec_part = text.partition(".") if int_part == "": int_part = "0" int_cn = int_to_cn(int(int_part)) if dec_part == "": return sign + int_cn dec_cn = "".join(CN_DIGITS[int(d)] for d in dec_part) return sign + int_cn + "点" + dec_cn return sign + int_to_cn(int(text))逻辑说明:函数先对输入字符串做 strip 和科学计数法检查,然后把负号从数字部分剥离开,避免“-0”进入int_to_cn。有小数点时拆成整数部分和小数部分,整数部分复用上一章的int_to_cn,小数部分用CN_DIGITS[int(d)]逐位映射。partition(".")比split(".")安全,因为split在遇到多个点时行为不够明确,而partition只拆第一处。
参数说明:入参必须是字符串,这是有意设计的。如果你手里是 float,建议先转成字符串再调用,但不要直接str(0.1 + 0.2),那会得到那串经典的长尾数。更稳妥的做法是保留业务侧的原始字符串,或者用 Decimal 承接浮点运算结果。这个函数返回的读法偏向口语,适合语音播报和报表展示,不适合作为财务金额大写,那套规则在下一章单独讲。
3.3 自然读法和货币大写在规则上的分水岭
如果你以为“数字转中文”只需要一个通用函数,那财务同事大概率会让你返工。人民币大写是另一套完全独立的语法:数字用壹贰叁肆伍陆柒捌玖,单位变成拾佰仟,整数部分后必须带“元”,角分规则和“整”字的出现条件也都和口语读法无关。举个例子,10.10 在口语里读“十点一零”,在发票上要写“壹拾元壹角”,这里的“壹拾”不能省略成“拾”,和口语读法的“十”不一样。
我在接手财务相关需求前,试图在number_to_cn上加参数去兼容大写,最后发现代码里全是 if 分支,维护成本很高。正确的做法是拆成两个函数,各自维护自己的数字表和单位表,只在“四位分组”这个底层逻辑上复用。分组思想是大写金额和小数自然读法唯一能共享的部分,其余规则各写各的,谁都别想覆盖谁。
4. 金额大写:财务场景下数字转中文的正确打开方式
4.1 大写数字、单位以及“整”字的完整规则
人民币大写有几个硬性要求:数字必须用壹贰叁肆伍陆柒捌玖;元以内的位数单位用拾佰仟;整数部分后面接“元”;没有角分时在末尾写“整”,有角但没分时不写“整”。这些规则在银行票据和发票上有明确约定,做财务系统时务必要对齐,不能自己发挥。
角分位是容易出错的区域。规则是:角和分不同时存在时需要写“零”,比如 5.05 写作“伍元零伍分”,这个零不能省;金额在零到一元之间时,比如 0.05,直接写“伍分”,前面不带“零元”;0.50 写“伍角”,末尾不写“整”。另外,整数部分为零且角分为零时,要写“零元整”,这是很多实现会漏掉的边界。
还有一个中文大写特有的习惯:大写金额里“10元”必须写“壹拾元”,这个“壹”在金额场景不能省。这和口语读法里“十”不带“一”正好相反,所以金额大写绝不能直接复用自然读法的字符串,必须单独实现。
4.2 rmb_upper 实现:从字符串到财务大写
金额转换的安全做法是先用 Decimal 做两位小数量化,再做整数分组。Decimal 的量化能统一处理进位,否则浮点运算会让0.1 + 0.2的尾数混进分位。
from decimal import Decimal, ROUND_HALF_UP CN_UPPER = "零壹贰叁肆伍陆柒捌玖" CN_SMALL_UNIT = ["", "拾", "佰", "仟"] CN_BIG_UNIT = ["", "万", "亿", "兆"] def _int_upper(num: int) -> str: """整数部分转大写,规则和自然读法分开实现。""" if num == 0: return "" groups = [] while num: groups.append(num % 10000) num //= 10000 parts = [] read_any = False need_zero = False for idx in range(len(groups) - 1, -1, -1): g = groups[idx] if g == 0: if read_any: need_zero = True continue s = "" pending = False for pos in range(3, -1, -1): d = g // (10 ** pos) % 10 if d == 0: if s: pending = True else: if pending: s += "零" pending = False s += CN_UPPER[d] + CN_SMALL_UNIT[pos] if read_any and (need_zero or g < 1000): parts.append("零") parts.append(s + CN_BIG_UNIT[idx]) read_any = True need_zero = False return "".join(parts) def rmb_upper(amount: str) -> str: """金额字符串转人民币大写,支持负数,输入如 '100.10'。""" amount = amount.strip() negative = False if amount.startswith("-"): negative = True amount = amount[1:] if "e" in amount.lower(): raise ValueError("金额不要用科学计数法表达") d = Decimal(amount).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if negative and d > 0: d = -d if d == 0: return "零元整" if d < 0: sign = "负" d = -d else: sign = "" yuan = int(d) jiao = int((d * 10) % 10) fen = int((d * 100) % 10) result = sign if yuan > 0: result += _int_upper(yuan) + "元" elif jiao == 0 and fen == 0: result += "零元" if jiao == 0 and fen == 0: result += "整" else: if jiao > 0: result += CN_UPPER[jiao] + "角" elif yuan > 0 and fen > 0: result += "零" if fen > 0: result += CN_UPPER[fen] + "分" return result逻辑说明:_int_upper的整体结构和int_to_cn类似,但数字表换成大写、单位换成拾佰仟,并且不做“一十”省略。rmb_upper在入口处直接拒绝科学计数法,用Decimal.quantize把金额统一到两位小数,角分从量化后的值里拆出,避免出现0.1 + 0.2 == 0.30000000000000004这类浮点尾数污染大写结果。d == 0提前返回“零元整”,负数先取绝对值转换再加“负”前缀。
参数说明:入参 amount 是字符串,这是硬约束,不接受 float。如果你在业务层已经拿到 Decimal,直接传str(decimal_obj)即可。quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)表示四舍五入到分位,这是财务上报销场景最常用的规则,如果你的业务要求银行家舍入,需要在这里替换 rounding 参数。
4.3 几个容易出错的金额样例和输出对照
下面这几个样例是我按真实业务用例整理出来的,任何一个输出不对,都说明规则没吃透:
| 输入 | 期望输出 | 说明 |
|---|---|---|
| 100.00 | 壹佰元整 | 末尾整,无角分 |
| 10.10 | 壹拾元壹角 | 有角无分,不写整 |
| 5.05 | 伍元零伍分 | 角位为空时补零 |
| 0.50 | 伍角 | 零元不写 |
| 0.05 | 伍分 | 同上,且不补零 |
| 0.00 | 零元整 | 全零特判 |
| 100050001.01 | 壹亿零伍万零壹元零壹分 | 分组补零 + 金额补零叠加 |
这些用例和自然读法完全不能互通,尤其是 5.05 那条,在口语读法里是“五点零五”,但在大写里是“伍元零伍分”。每次改完rmb_upper我都拿这组例子过一遍,比我记规则可靠得多。
5. 数字转中文避坑清单:五个最常见的翻车点
5.1 现象:“一亿万”这种重复单位出现在结果里
输入 100000000(一亿)时,输出变成了“一亿万”。原因在于分组拼接时,个位组为零但循环没有正确跳过,导致在“亿”后面又拼了一个“万”单位。解决方法是把“组为零则跳过并标记 need_zero”这个逻辑放在拼接之前,确保零组不产生任何单位输出。我在另一个类似的实现中看到有人直接对每一位数字乘单位,结果 100000000 会拼出“一亿零万零万零”,这类问题只能靠分组处理根治。
5.2 现象:漏零和叠零是同一类问题的两种表现
100050001 被读成“一亿五万一”或者“一亿零零五万零一”都出现过,前者是漏补零,后者是补零逻辑被重复触发。原因在于把“组内空位”和“组间空组”混为一谈,两处都加了补零逻辑,又没有统一标记。解决方法是区分两个变量:pending_zero只管组内四位间的零,need_zero只管跨组的空档,组间拼接时两个条件用“或”合并,但每组只补一次。这个区分是那段时间我重构最频繁的地方,补上之后连续零用例全部通过。
5.3 现象:金额大写里出现“零元零角零分”的乌龙
输入 0.50 时输出“零元零角伍分”,而财务规范要求写“伍角”。原因是在零元分支没有区分“整数部分是否为零”就统一补了“零元”。解决方法是:只有当整数部分为零且角分也为零时才输出“零元整”;否则直接跳过元位,从角或分开始写。类似的还有 5.05 误写“伍元零角伍分”,需要明白角位为零但分位非零时,补“零”字是为隔开元位和分位,而不是为了报出“角”这个单位。
5.4 现象:科学计数法和浮点长尾把输入搞坏
输入 1000000000000000.0 时,直接int()转换得到科学计数法字符串,输出变成“一点零一加十五次方”之类的垃圾读法;输入 0.1 + 0.2 再转字符串则是“零点三零零零零零零零零零零零零零零四”。原因很简单:float 在表示超大数和某些小数时精度不足。解决方法是入口统一收字符串,并显式拒绝包含 e/E 的输入;涉及金额时使用 Decimal 量化。这个坑在语音播报场景尤其致命,读出来的内容用户一听就能发现不对。
5.5 现象:“负零”这种不存在的读法出现在输出里
输入 -0 时输出“负零”,读出来非常尴尬。原因是对负数做“先取绝对值再拼负号”的流程时,没有先判断零。解决方式是在int_to_cn和rmb_upper里都把num == 0或d == 0的特判放在最前面,零不参与符号逻辑。类似地,-0.0也应该走“零”而不是“负零”。这类边界在测试用例里很容易被忽略,我建议把 -0 和 -0.00 写进自测清单。
6. 进阶:统一输入边界并固化自测用例
6.1 统一入口:让所有输入先走字符串清洗
实际项目里你拿到的输入不会像文档里那么干净,可能带千分位逗号、首尾空格、人民币符号,甚至全角数字。我习惯在入口加一个清洗函数:去掉首尾空白、剥离逗号、把全角数字映射成半角。以下是一个极简版本:
def clean_number_input(text: str) -> str: text = text.strip().replace(",", "").replace(",", "") table = str.maketrans("0123456789.-", "0123456789.-") return text.translate(table)清洗之后再交给number_to_cn或rmb_upper,可以省掉大量调用方的重复处理。注意清洗函数不要顺手把空格从数字中间删掉,因为正常数字字符串中间不该有空格,如果有说明输入本身就是脏数据,应该让上层感知到而不要静默修正。
6.2 自测用例表:把这些数字一次性跑全
自测是这类工具函数最值得投入的部分。我通常把自然读法和金额大写各维护一组用例,存成字典形式,让 pytest 或 unittest 循环比对。下面是自然读法的核心用例,金额用例用 4.3 节那张表即可:
| 输入字符串 | 期望输出 |
|---|---|
| 0 | 零 |
| 10 | 十 |
| 15 | 十五 |
| 100000 | 十万 |
| 100050001 | 一亿零五万零一 |
| 100000001 | 一亿零一 |
| 101000 | 十万一千 |
| 0.5 | 零点五 |
| 100.10 | 一百点一零 |
| -3.14 | 负三点一四 |
| -0.00 | 负零点零零 |
把所有用例放进一个列表后,改一次代码跑一次,比手动输入演示强太多。工具函数最大的风险不是逻辑复杂,而是改了一处以为没影响,结果另一个角落的规则被连带破坏。固化用例相当于给行为上了保险。
6.3 我留下的习惯
现在只要涉及数字转中文,我会先把输入层统一成字符串,再把自然读法、金额大写拆成两个独立模块,最后把边界用例写成一个 fixture 文件放在测试目录里。第一版直接吃 float 的教训让我记住了:精度问题不是靠 round 能解决的,必须在输入边界就堵死。如果你也在维护这类函数,建议先看看自己手里有多少个“临时补丁”是因为输入类型混乱而打的,那才是真正该重构的地方。希望帮到你。
本文还有配套的精品资源,点击获取