amount2cn 设计文档
小写数字金额 → 中文大写金额转换.
- 原文参考:https://blog.csdn.net/liudglink/article/details/106820490
- 本文件为设计说明,代码实现请访问 gitee仓库
[零]用法与整体流水线
多文件程序(同属package main),无外部依赖,由go.mod管理,直接运行:
go run *.go1234.56# 命令行模式:逐个参数转换后打印go run *.go# 无参数:跑 main 中内置的自测用例DEBUG_MAT=1go run *.go1234.56# 打印 materialize 的下标序列到 stderrgo run *.go-d1234.56# 同上,-d 可出现在参数列表任意位置对外唯一入口arabToChinese(string) string,内部是严格单向流水线:
| 步骤 | 函数 | 职责 |
|---|---|---|
| STEP-1 | trimCaspAll | 清洗:去掉空格 / Tab / 千分位逗号 |
| STEP-2 | isZeroYuan | 零值字面量快捷返回(“零圆整”) |
| STEP-3 | parseAmount | DFA 校验与切分;失败则直接返回msgCodes[EC_MINUS_POS~EC_TOO_LONG] |
| STEP-4 | convertIntPart/convertDecPart | 只产出[]int下标序列,不拼字符串 |
| STEP-5 | materialize | 下标 → 字符串,全文件唯一的字符输出口 |
非法输入返回以[ERR]开头的中文提示串,调用方可用strings.HasPrefix(out, "[ERR]")判定转换失败.
设计上"解析"与"转换"两阶段解耦:
- DFA 阶段只负责"格式合法性校验"与"切分 负号 / 整数部分 / 小数部分",
不关心具体的中文怎么写;返回码 0 成功,1~5 为错误. - 转换阶段只做纯数值 → 中文的映射,无状态机,易单测,易维护.
核心改造点(详见 [三]):所有输出字符集中在digits/units/aux三张map[int]string中(键空间互不重叠;另有第四张msgCodes为返回码表,
不参与字符输出),转换逻辑只向[]int下标序列 append 下标,materialize()
统一把下标映射回字符并应用"壹拾"简写规则,实现字符定义与逻辑彻底解耦.
[一]DFA(确定性有限状态机)形式化设计
1. 状态集合 Q
Q = { BEGIN, MINUS, ZEROPRE, INTEGER, ERROR }| 状态 | 含义 |
|---|---|
BEGIN | 起始态 |
MINUS | 已读负号-(仅在开头合法) |
ZEROPRE | 前缀零模式(0.xx或-0.xx),用于禁止0XXX这类前置零 |
INTEGER | 整数数字状态(进入后允许任意 0-9 连续) |
ERROR | 拒绝态(对应返回码EC_MINUS_POS~EC_TOO_LONG) |
实现说明:ERROR只是形式化描述----代码从不"转移"进ERROR,一旦判定
非法就立即return,故state枚举里的errState从未被赋值(死常量,
保留仅为与状态图对齐,见 [五]).
2. 字母表 Σ
Σ = { '-', '.', '0', D(1-9), OTHER } D = '1'..'9',OTHER = 其他任意字符(非法)3. 状态转移函数 δ(current × input → next)
| 当前\输入 | '-' | '.' | '0' | '1'-'9' | OTHER |
|---|---|---|---|---|---|
BEGIN | MINUS | 无整数① | ZEROPRE | INTEGER | ERROR(4) |
MINUS | ERR(1) | 无整数② | ZEROPRE | INTEGER | ERROR(4) |
ZEROPRE | ERR(1) | 切小数③ | ERROR(3) | INTEGER | ERROR(4) |
INTEGER | ERR(1) | 切小数③ | INTEGER | INTEGER | ERROR(4) |
- ①
BEGIN遇.合法,表示无整数部分(如.19) - ②
MINUS遇.合法,表示无整数部分(如-.12) - ③
.最多出现一次,第二次出现 →ERROR(2)
实现说明:两个与状态图不完全对应的细节----
.之后状态一律置为INTEGER,等价于BEGIN/MINUS/ZEROPRE/INTEGER
四态在此合并,因为后续只需靠seenDot决定数字写进哪个缓冲区;- 小数点重复并非由状态机发现,而是
parseAmount开头用dotCount
预检拦截(码 2),所以转移表里那格实际不会走到.
4. 初态 / 终态(接受态)/ 拒绝态
- 初态:
BEGIN - 接受态:
BEGIN, MINUS, ZEROPRE, INTEGER
代码在扫描结束后并不校验终态,故这四个态全部算合法,
包括"一个数字都没有"的BEGIN与MINUS.退化输入(空串,-等)即由此产生,实际输出见 [四]. - 拒绝态:
ERROR(判定即立即返回,不参与终态校验)
5. 返回码体系(parseResult.ErrCode / msgCodes 键)
代码中应统一使用a2c_tables.go中定义的EC_*具名常量,避免裸写魔法数字。
| 码 | 常量名 | 含义 |
|---|---|---|
0 | EC_OK | 成功(此时直接返回转换结果,不会去查msgCodes,故msgCodes[EC_OK]虽定义为""却从未被读取,见 [五]) |
1 | EC_MINUS_POS | 负号-位置错误(不在开头) |
2 | EC_DOT_MULTI | 小数点.出现多次 |
3 | EC_LEAD_ZERO | 前置零违规(如0XXX,仅允许0.xx/-0.xx) |
4 | EC_BAD_CHAR | 非法字符(非 0-9 /-/.) |
5 | EC_TOO_LONG | 数值过大,整数部分位数超过units表支持的最大权位(见 [四]) |
成功用
EC_OK(0) 表示,非 0 即错误;错误提示串集中存于msgCodes[EC_MINUS_POS~EC_TOO_LONG].
6. DFA 状态转移图(flowchart)
注:
.之后状态统一置为INTEGER(四态合并),依靠seenDot决定数字
写入整数还是小数缓冲区;小数点重复由parseAmount开头dotCount预检拦截
(码 2),故转移表中.重复的情形实际不会走到.
其它非法输入(负号不在开头 / 小数点多次 / 非法字符)→ERROR(码 1 / 2 / 4)
7. 合法串示例
"1001000088080.01" "-0.20" ".19" "-.12" "80,493,071,641,101.12" (逗号在 DFA 之前由 trimCaspAll 清洗掉)[二]转换阶段(纯映射,无状态机)
负号→ 序列头部插入 “负”;不影响后续任何数值规则.
整数部分(convertIntPart):从左到右(高位 → 低位)逐位处理.
w = n - i= 剩余长度(含当前位),即原文 C 实现的CU_INT[w]索引.
权位单位取units[UNIT_BASE + w]:w%4 == 1→ 级末单位:w=1圆 /5萬 /9億 /13兆 /17京 /21垓 /25秭 …其余 → 拾 / 佰 / 仟(分别对应
w%4 = 2 / 3 / 0)数字非零:先结算待补的 “零”(见下),再输出 数字 + 该位权位单位
---- 注意非零位的单位是无条件输出的,不论它是 拾/佰/仟 还是 圆/萬/億.数字为零:
- a) 若其右侧(即更低位,下标
i+1 .. n-1)仍存在非零数字 → 置zeroPending,等遇到下一个非零数字时先补一个 “零”;
连续多个零因此只会补出一个 “零”. - b) 若该位恰是级末单位位(
w%4 == 1):w == 1→ 永远输出 “圆”;- 否则 → 仅当"本级 4 位"(下标
i-3 .. i,左端截断到 0)
非全零时才输出级单位;本级全零则不输出该级单位. - 例:
1000000000→ 拾億圆整.億位(i=1)本身是'0',但本级
实际只有 2 位 “10”,非全零,故仍输出 “億”. - 例:
100000001→ 壹億零壹圆整.萬级 4 位全零,故 “萬” 不出现.
- a) 若其右侧(即更低位,下标
边界:整数位数
n > maxUnitWidth()(即超过units表当前支持的最大权位,
由maxUnitWidth()动态计算,当前为 24 = 垓仟级)时由parseAmount直接返回
错误码EC_TOO_LONG,不再静默降级,见 [四].
小数部分(convertDecPart):处理 角(第 1 位)/ 分(第 2 位)/ 厘(第 3 位)/ 毫(第 4 位)/ 丝(第 5 位)/ 忽(第 6 位)六级.
角或分为 0 时该位整位跳过(.20→ 贰角;.02→ 贰分).
六位皆 0(无小数,或.00/.000000)→ 返回nil,由调用方补 “整”.
第 6 位后由parseAmount直接丢弃,不做四舍五入(见 [四]).整数与小数之间的 “零”:
整数部分有效(即intPart非空且非"0"),且小数以'0'开头(角=0)时,
在圆后补一个 “零” 再接小数:1.01 → 壹圆零壹分; 10.05 → 拾圆零伍分若整数部分为空或
"0",则既不输出 “圆”,也不补这个 “零”,直接接角分:.01 → 壹分; 0.5 → 伍角; .19 → 壹角玖分; -0.20 → 负贰角(注意:补零条件的判据是"整数部分有效",不是"输入以 0 开头".)
[三]字符表集中管理 + 下标序列统一输出(核心改造)
所有可能的"输出字符"都集中在三张map[int]string,键一律是"基址 + 偏移",
三个键空间互不重叠,故materialize可按键精确查表:
| 表 | 基址 | 键空间 | 值 |
|---|---|---|---|
digits | DIGIT_BASE=0 | 0~9 | 零~玖(偏移 d 即阿拉伯数字值本身) |
units | UNIT_BASE=100 | 101~(随表扩展) | 圆/拾/佰/仟/萬/億/兆/京/垓/拾/佰/仟/拾/佰/仟/秭 … |
aux | AUX_BASE=200 | 200~207 | 负/整/角/分/厘/毫/丝/忽 |
units表的键空间上界由maxUnitWidth()动态决定,随表中权位条目的增减自动变化;
当前最大键为UNIT_BASE + 25(对应 “秭” 级).整数最大可表示位数即等于maxUnitWidth()的返回值.
digits基址取 0,是因为它排在最前,不可能与他表冲突,偏移直接用阿拉伯数字值,
于是digits[d]里的d既是"数字值"也是"表键".既然如此,为什么还要显式定义DIGIT_BASE?一是让三表结构对称,一眼看出键空间;二是把键空间判定收敛成具名
谓词(见下);三是万一将来调整基址,所有DIGIT_BASE + d的写法不会静默失效.
另有一张不参与字符输出的码表:
msgCodes:返回码表,键 1~5 为五类错误提示串(键 0 虽定义为""但从未被读取).
语义常量(避免魔法数字,均指向上述表的固定键):
IDX_LING = DIGIT_BASE+0 (零) IDX_YI = DIGIT_BASE+1 (壹) IDX_YUAN = UNIT_BASE+1 (圆) IDX_FU / IDX_ZHENG / IDX_JIAO / IDX_FEN / IDX_LI / IDX_HAO / IDX_SI / IDX_HU = AUX_BASE+0/1/2/3/4/5/6/7“零” 在三处作"补零"下标(整数补零,圆后补零,零圆整),故与 “壹” 一样
提升为常量,不再裸写 0.
注:“拾” 没有独立常量----它在
units中随w%4==2重复出现(拾/拾萬/拾億…),
因此由materialize以units[key] == "拾"动态判定,而不是固定下标.
键空间谓词(键空间判定的唯一出处,与三表基址绑定):
isDigitKey(idx)idx ∈ [DIGIT_BASE, DIGIT_BASE+10)→ 是数字位isLevelUnit(key)key ≥ UNIT_BASE且(key-UNIT_BASE)%4 == 1→ 是节单位maxUnitWidth()遍历units表,返回当前支持的最大权位w(整数最大可表示位数)
转换逻辑(convertIntPart/convertDecPart)不直接拼接字符串,而是向
一个[]int下标序列 append:
- 数字位 →
digits的键(DIGIT_BASE + d;固定数字用IDX_LING/IDX_YI) - 权位单位 →
units的键(UNIT_BASE + w) - 辅助符 → 语义常量
IDX_FU/ZHENG/JIAO/FEN/LI/HAO/SI/HU指向的固定键
materialize(idxs []int) string是全文件唯一的"下标 → 字符"出口,内部三步:
- 定位最高位数字
firstDigit(供情形B 判定); - 扫一遍标记
skipSet("壹拾"简写,见下); - 按
skipSet过滤后依次查digits → units → aux拼接.
三表都未命中的越界下标直接跳过(不 panic,也不输出占位符).整体O(n):
步骤② 每个位置只向前看 1~2 个元素.debugMat为真时,额外把idxs完整打到
stderr 供校验:逐元素打印下标值与字符,被跳过的元素追加<-- SKIPPED.
"壹拾"简写规则(业务取舍,并非通行财务规范;在 materialize 中实施)
当 数字"壹"(IDX_YI)紧邻后接 单位"拾" 时,满足下列任一情形则跳过"壹"
(即 “壹拾” → “拾”):
情形A:“拾” 之后紧跟"节单位"(圆/萬/億/兆/京/垓,即
w%4==1)或已到序列末尾.1000000000 → 拾億圆整; 100000 → 拾萬圆整; 10 → 拾圆整情形B:“壹” 是序列中最高位(第一个)数字,且该 “拾” 属个级.
代码判据写作w ≤ 4;但既然该单位必须是 “拾”(w%4==2),候选w
只有2/6/10/14/18,故实际恒等于w == 2,即个级的拾位.12.10 → 拾贰圆壹角; 15 → 拾伍圆整
反之保留 “壹”:若 “拾” 之后是普通数字位,且 “壹” 并非最高位数字----
160000 → 壹拾陸萬圆整(不可写成 陸萬)若要恢复全式 “壹拾”,只需去掉
materialize中skipSet的置位逻辑,
其余代码(字符表,下标序列)无需改动.
这样做的好处:
- 字符定义与转换逻辑彻底解耦,改字形/改措辞只动表;
- 逻辑分支里只出现"下标",可读性高,易审计,无魔法数字;
- 统一出口,便于后续做去重,插值,调试打印下标等.
[四]边界与退化行为(以下均为实测确认的输出)
零值→ “零圆整”(带负号时前缀 “负”),有两条互不相同的到达路径:
a)
isZeroYuan字面量命中(在 DFA 之前,对清洗后整串做等值匹配):0 / 0. / 0.0 / 0.00 / . / .0 / .00b) 整数部分为空或
"0",且角分全为 0----走的是"整数与小数都产出空序列"
的分支,例如0.000(它并不在清单 a 里).
退化输入(DFA 接受,但一个数字也没有):
"" → 零圆整 "-" / "-0" / "-." / "-0.00" → 负零圆整(负号照常前置)超长(直接报错):整数位数
n > maxUnitWidth()(即超过units表当前支持的
最大权位,当前为 24 = 垓仟级)时,parseAmount在扫描结束后立即返回错误码EC_TOO_LONG,
由msgCodes[5]给出中文提示,不再静默降级为零值:12345678901234567890123456 → [ERR]数值过大,整数部分超出可表示的最大位数(units最大权位以上)(小数位则无此限制:最多收 6 位 角/分/厘/毫/丝/忽,超出截断,不四舍五入.)
小数截断:第 6 位起由
parseAmount直接丢弃,不做四舍五入
----1234.567→ 壹仟贰佰叁拾肆圆伍角陸分柒厘.前置零:
"00","0012"→ 错误码EC_LEAD_ZERO(只有0.xx/-0.xx允许).
[五]已知取舍 / 待办(动手改代码前请先读这一节)
- “壹拾” → “拾” 属本地业务取舍,不是通行财务大写规范(见 [三]).
errState常量从未被赋值----ERROR态在代码里以"立即 return" 表达.msgCodes[0]从未被读取----成功路径直接返回转换结果,不查表,该条目保留仅为让码表自洽.- 整数最大可表示位数由
units表规模动态决定(见maxUnitWidth()),
向units表新增权位条目后无需改动任何数字判断逻辑,超长上限自动跟随扩展.