实数转中文大写的问题,虽然不能算是太难,但却也不是那种能一气呵成,一蹴而就的简单问题,一步到位的想法很容易就会陷入泥潭;正确的做法应该 是对转换的规律抽丝剥茧,由浅入深一步一步完成转换步骤,如此便能水到渠成……敏捷Programming的思想很适用于解决此类问题,借此机会正好和大家分享一些敏捷Programming 的经验。

开始之前,先看一下大写位进换数情况先,这里以目前财务体系的中法换算为准:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
> 个=10的0次方
> 十=10的1次方
> 百=10的2次方
> 千=10的3次方
> 万=10的4次方
> 亿=10的8次方
> 兆=10的12次方
> 京=10的16次方
> 垓=10的20次方
> 杼=10的24次方
> 穰=10的28次方
> 沟=10的32次方
> 涧=10的36次方
> 正=10的40次方
> 载=10的44次方
> 极=10的48次方
> 恒河砂=10的52次方
> 阿僧祇=10的56次方
> 不可思议=10的60次方

中法换算中,“个十百千”可认为是基本单位,而从开始会复用这些基本单位,用完这4个基本单位之后就会有新的单位出现。然后再利用这4个基本单位,如此循环往复。因此,首要解决的就是“个十百千”这个级别上的转换问题,一则是因为它最简单,二则是它的转换逻辑还可以为以后更高层次的转换所复用。

先考虑最简单的情形,即不带零和小数的情况:

1
2
3
4
5
6
[Test]
public void SimpleConvert()
{
    CurrencyConvert convert = new CurrencyConvert(1234M);
    Assert.AreEqual(“壹仟贰佰叁拾肆元整”, convert.ToString());
}

没有小数,也先不考虑零的情况。在我们编写类来通过测试之前,先分析一下大写转换规律:

上表中,第三行,白底部分我们称为数字,灰底部分称为位字,红色的箭头代表我们分析金额时的顺序,是从右至左,第一个数字的位字为,此位字不作输出;第二个数字的位字为;第三个为;第四个为。那么,只要从右向左分别得出数字及其位字,依次插入到列表的头,再串联起来就可以得到其大写形式了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class CurrencyConvert
{
    private static string[] symbols1 = new string[] { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" };
    private static string[] symbols2 = new string[] { "", "拾", "佰", "仟" };
    private decimal _source;
    public CurrencyConvert(decimal source)
    {
        _source = source;
    }
    public override string ToString()
    {
        string sourceString = _source.ToString("F");
        List<string> list = new List<string>();
        for (int i = sourceString.Length - 1, j = 0; i >= 0; i--, j++)
        {
            list.Insert(0, symbols2[j]);
            list.Insert(0, symbols1[int.Parse(sourceString[i].ToString())]);
        }
        return string.Join("", list.ToArray()) + "元整";
    }
}

现在考虑带零的情况,零的转换有以下几种情况:

  1. 零后面不打印位字(如1024为“壹仟零贰拾肆元整”)
  2. 当有多个零连续出现的时候只打印一个零(如1004为“壹仟零肆元整”)
  3. 连续的零一直延伸到个位的时候不打印零(如1200为“壹仟贰佰元整”) 针对这带零的情况编写单位测试:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[Test]
public void SimpleWithZeroConvert()
{
    CurrencyConvert convert1 = new CurrencyConvert(1024M);
    Assert.AreEqual("壹仟零贰拾肆元整", convert1.ToString());
    CurrencyConvert convert2 = new CurrencyConvert(1004M);
    Assert.AreEqual("壹仟零肆元整", convert2.ToString());
    CurrencyConvert convert3 = new CurrencyConvert(1200M);
    Assert.AreEqual("壹仟贰佰元整", convert3.ToString());
}

情况一容易解决,只要稍加判断当前数字是否为零即可;第二个也不难,只要判断最后一个插入的是不是零即可,但是在进行这个判断之前要先判断列表 是否为空,否则取值时会引发异常;第三个情况,看起来相对复杂一点,但其实在第二点判断列表是否为空的时候这个问题也同时被解决了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class CurrencyConvert
{
    private static string[] symbols1 = new string[] { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" };
    private static string[] symbols2 = new string[] { "", "拾", "佰", "仟" };
    private decimal _source;
    public CurrencyConvert(decimal source)
    {
        _source = source;
    }
    public override string ToString()
    {
        string sourceString = _source.ToString("F");
        List<string> list = new List<string>();
        for (int i = sourceString.Length - 1, j = 0; i >= 0; i--, j++)
        {
            int number = int.Parse(sourceString[i].ToString());
            if (number == 0)
            {
                if (list.Count > 0 && list[0] != symbols1[0])
                    list.Insert(0, symbols1[number]);
            }
            else
            {
                list.Insert(0, symbols2[j]);
                list.Insert(0, symbols1[number]);
            }
        }
        return string.Join("", list.ToArray()) + "元整";
    }
}

好,接下来,在向更高位迈进之前,先来解决一下小数的问题。相对来讲,小数的问题比较容易处理,我可不想在最困难的部分解决之后还要来料理这些细节。小数部分,大概需要考虑以下几种情形:

  1. 如果被转换数小于1,那么不打印“整数部分”的信息,直接打印小数部分且前面不打印“零”
  2. 如果被转换数大于1,则先打印“整数部分”,再打印“小数部分”,中间有多个零时只打印一个零
  3. 如果不存在小数部分,则返回“整”(这个在前面整数部分的测试用例已被覆盖)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Test]
public void DecimalConvert()
{
    CurrencyConvert convert1 = new CurrencyConvert(0.1234567M);
    Assert.AreEqual("壹角贰分叁厘肆毫伍丝陆忽", convert1.ToString());
    CurrencyConvert convert2 = new CurrencyConvert(0.001M);
    Assert.AreEqual("壹厘", convert2.ToString());
    CurrencyConvert convert3 = new CurrencyConvert(0.01M);
    Assert.AreEqual("壹分", convert3.ToString());
    CurrencyConvert convert4 = new CurrencyConvert(1.1M);
    Assert.AreEqual("壹元壹角", convert4.ToString());
    CurrencyConvert convert5 = new CurrencyConvert(1.001M);
    Assert.AreEqual("壹元零壹厘",convert5.ToString());
}

如上所示,小数部分,我们假定精确到“忽”,余下部分就忽略掉。如下图所示,小数部分的拼装顺序和整数部分的拼装顺序略有不同。这时,应该对程序进行重构,把总的拼装逻辑分为整数部分小数部分

重构后的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class CurrencyConvert
{
    private static string[] symbols0 = new string[] { "角", "分", "厘", "毫", "丝", "忽" };
    private static string[] symbols1 = new string[] { "零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖" };
    private static string[] symbols2 = new string[] { "", "拾", "佰", "仟" };
    private decimal _source;
    public CurrencyConvert(decimal source)
    {
        _source = source;
    }
    public override string ToString()
    {
        string[] ary = _source.ToString("F").Split('.');
        return ConvertIntegerPart(ary[0]) + ConvertDecimalPart(ary.Length == 2 ? ary[1] : string.Empty);
    }
    private string ConvertIntegerPart(string integerPart)
    {
        if (string.IsNullOrEmpty(integerPart) || decimal.Parse(integerPart) == 0M)
            return string.Empty;
        List<string> list = new List<string>();
        for (int i = integerPart.Length - 1, j = 0; i >= 0; i--, j++)
        {
            int number = int.Parse(integerPart[i].ToString());
            if (number == 0)
            {
                if (list.Count > 0 && list[0] != symbols1[0])
                    list.Insert(0, symbols1[number]);
            }
            else
            {
                list.Insert(0, symbols2[j]);
                list.Insert(0, symbols1[number]);
            }
        }
        if (list.Count > 0) list.Add("元");
        return string.Join(string.Empty, list.ToArray());
    }
    private string ConvertDecimalPart(string decimalPart)
    {
        if (string.IsNullOrEmpty(decimalPart) || decimal.Parse(decimalPart) == 0M)
            return "整";
        List<string> list = new List<string>();
        for (int i = 0; i < Math.Min(decimalPart.Length, symbols0.Length); i++)
        {
            int number = int.Parse(decimalPart[i].ToString());
            if (number == 0)
            {
                if ((list.Count == 0 && _source > 1M) || (list.Count > 0 && list[list.Count - 1] != symbols1[0]))
                    list.Add(symbols1[number]);
            }
            else
            {
                list.Add(symbols1[number]);
                list.Add(symbols0[i]);
            }
        }
        return string.Join(string.Empty, list.ToArray());
    }
}

完成了这个部份之后,我突然想到,如果传进去的是 0 ,则程序会正常输出“零元”吗?,所以我写多了一个测试用例:

1
2
3
4
5
6
[Test]
public void ZeroConvert()
{
    CurrencyConvert convert = new CurrencyConvert(0M);
    Assert.AreEqual("零元", convert.ToString());
}

这是一个十分特殊的的case,这时候左右部分都为空,而且原来的拼装法则也用上不了,所以我把它放在了总拼装函数ToString之前作一个特殊化判断。

1
2
3
4
5
6
public override string ToString()
{
    if (_source == decimal.Zero) return "零元";
    string[] ary = _source.ToString("F").Split('.');
    return ConvertIntegerPart(ary[0]) + ConvertDecimalPart(ary.Length == 2 ? ary[1] : string.Empty);
}

好了,相对简单的部份解决了。现在来研究“万元”以上的情况:

再往左的情况就是红色部份会被下一个单位替换,这种简单重复而已。最右边空的其实就是个位,由此我们可以总结出拼装的规律:

  1. 数字每4个一组,从右至左使用“个万亿兆京……”为单位 需要再重构一次,把原来的整数转换逻辑提取为组的转换ConvertGroup
  2. 每组数字从右至左使用“个十百千”为单位串联 原来的整数转换逻辑
  3. 当某一组和其右边所有组全为零时,这些组将不被打印 对ConvertGroup的结果稍加判断即可实现 依旧先从简单开始,编写第一点的测试用列:
1
2
3
4
5
6
[Test]
public void TenThousandAboveConvert()
{
    CurrencyConvert convert1 = new CurrencyConvert(123456789012M);
    Assert.AreEqual("壹仟贰佰叁拾肆亿伍仟陆佰柒拾捌万玖仟零壹拾贰元整", convert1.ToString());
}

接下来是实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private string ConvertIntegerPart(string integerPart)
{
    if (string.IsNullOrEmpty(integerPart) || decimal.Parse(integerPart) == 0M)
        return string.Empty;
    List<string> list = new List<string>();
    // 开始新的转换逻辑
    const int groupMaxLength = 4;
    int residue = integerPart.Length % groupMaxLength;
    int groupsCount = integerPart.Length / groupMaxLength + (residue > 0 ? 1 : 0);
    int lastGroupCount = residue == 0 ? groupMaxLength : residue;
    for (int i = groupsCount - 1, j = 0; i >= 0 && j < symbols3.Length ; i--, j++)
    {
        int groupStart = i == 0 ? 0 : lastGroupCount + (i - 1) * groupMaxLength;
        int groupLength = i == 0 ? lastGroupCount : groupMaxLength;
        list.Insert(0, symbols3[j]);
        list.Insert(0, ConvertGroup(integerPart.Substring(groupStart, groupLength)));
    }
    // 完成新的转换逻辑
    if (list.Count > 0) list.Add("元");
    return string.Join(string.Empty, list.ToArray());
}
// 原先的简单转换逻辑被提取出来
private string ConvertGroup(string group)
{
    List<string> list = new List<string>();
    for (int i = group.Length - 1, j = 0; i >= 0; i--, j++)
    {
        int number = int.Parse(group[i].ToString());
        if (number == 0)
        {
            if (list.Count > 0 && list[0] != symbols1[0])
                list.Insert(0, symbols1[number]);
        }
        else
        {
            list.Insert(0, symbols2[j]);
            list.Insert(0, symbols1[number]);
        }
    }
    return string.Join(string.Empty, list.ToArray());
}

组的分割还挺不容易,调试了许久,可惜看起来还是不太优雅,暂时就先这样吧,起码它已经能正确运行。好,现在加上零的情况:

1
2
3
4
5
6
7
8
[Test]
public void TenThousandAboveConvert()
{
    CurrencyConvert convert1 = new CurrencyConvert(123456789012M);
    Assert.AreEqual("壹仟贰佰叁拾肆亿伍仟陆佰柒拾捌万玖仟零壹拾贰元整", convert1.ToString());
    CurrencyConvert convert2 = new CurrencyConvert(12300M);
    Assert.AreEqual("壹万贰仟叁佰元整", convert2.ToString());
}

支持这个真是相当简单,只要对组的拼装结果进行判断就行了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (int i = groupsCount - 1, j = 0; i >= 0 && j < symbols3.Length ; i--, j++)
{
    int groupStart = i == 0 ? 0 : lastGroupCount + (i - 1) * groupMaxLength;
    int groupLength = i == 0 ? lastGroupCount : groupMaxLength;
    string groupString = ConvertGroup(integerPart.Substring(groupStart, groupLength));
    if (!string.IsNullOrEmpty(groupString))
    {
        list.Insert(0, symbols3[j]);
        list.Insert(0, groupString);
    }
}

最后,综合测试:

1
2
3
4
5
6
[Test]
public void FinnalTest()
{
    CurrencyConvert convert = new CurrencyConvert(120004000001.001M);
    Assert.AreEqual("壹仟贰佰亿零肆佰万零壹元零壹厘", convert.ToString());
}

测试用例是我们假想出来程序可能遇到的情况,通常这些用例都是有代表性的,但往往不能覆盖每种情形,因此,完成单元测试后,我做了一个简单的winform来做烟雾测试,确定程序能正常运行。