CLR的货币类型






4.90/5 (58投票s)
一个方便、高性能的 CLR Money 类型,它可以处理算术运算、货币类型、格式化以及精确的分配和舍入,不会造成损失。
引言
世界上有大量的应用程序会处理货币金额。这里有一个方便、高性能的 CLR Money 类型,它可以处理算术运算、货币类型、格式化以及精确的分配和舍入,不会造成损失。
背景
CLR 中不包含原生的 Money 类型。这可以看作是一个缺点,或者是一个合理的设计决策,因为这是一个面向对象的框架,而添加您自己的类型来封装所需的数据和行为,这几乎就是它的目的所在。
然而,Money 类型是如此基础且如此普遍,以至于它的缺失很明显。毕竟,其他基础类型都是内置的。Martin Fowler 在 《企业应用架构模式》[PEAA] 中讨论了这一点。
世界上有相当一部分计算机都在处理货币,所以钱不是任何主流编程语言中的一等数据类型,这总是让我感到困惑。缺乏这样一个类型会带来问题,其中最明显的问题就是围绕货币。……面向对象编程的好处在于,您可以通过创建一个 Money 类来解决这些问题。当然,令人惊讶的是,没有任何主流的基类库实际做到这一点。(第 488 页)
Using the Code
使用这个 Money
类型很简单:它的使用方式与其他数值类型一样。
Money m1 = 1.25;
Money m2 = 0.75;
Money total = m1 + m2;
Money difference = m1 - m2;
您可以创建不同于当前文化货币的 Money
实例
Money m3 = new Money(1.25, Currency.Eur);
……但是您不能对不同货币类型的两个实例执行操作
// throws an InvalidOperationException due to different currencies
total = m2 + m3;
要无损地分配金额,请使用 MoneyDistributor
Money amountToDistribute = 0.05M;
// two decimal places
MoneyDistributor distributor = new MoneyDistributor(amountToDistribute,
FractionReceivers.LastToFirst,
RoundingPlaces.Two);
Money[] distribution = distributor.Distribute(0.3M);
Assert.Equal(3, distribution.Length);
Assert.Equal(new Money(0.01M), distribution[0]);
Assert.Equal(new Money(0.02M), distribution[1]);
Assert.Equal(new Money(0.02M), distribution[2]);
使用起来就是这么简单!但是,如果您对类型设计的输入、原理和遇到的困难案例感兴趣,请继续阅读……
CLR Money 类型的方法分析
本实现中的方法
Money
在 [PEAA] 中,Martin Fowler 和 Matt Foemmel 的示例将 Money 显示为一个具有值语义的对象。在 CLR 中,值类型是一个一等实体,因此将这两者结合起来,使 Money
成为一个值类型是合理的。这也有助于处理作者提到的性能问题,因为值类型不会在堆上进行引用计数,从而减少 GC 的压力,提高性能。
同样在 [PEAA] 中,用于存储值的底层类型是 Int64
(long)。然后作者将值乘以 10 的某个幂(0-3)来表示小数部分。在此类型中,我保留了整数值的存储,但选择将小数部分表示为完全独立的 Int32
,该部分乘以 10^9(适合 Int32
的最大 10 的幂)。这允许存储更小的小数,这对于中间计算很有用。因此,该类型是固定小数点的数值表示。这正是关系型数据库通常存储货币的方式,所以这是一个自然的匹配。另一种选择是用 System.Decimal
来表示货币值。这种方法的缺点是 System.Decimal
是二进制浮点类型,当我们处理它们进行计算时,二进制浮点类型会给我们带来各种麻烦,如果不极其小心地处理舍入或计算误差的累积。通过处理整数并进行缩放来表示小数,可以避免这些问题。
我选择将明确分配金额的任务分离到另一个类:MoneyDistributor
。这样做的原因是我认为它有助于提高可读性和审慎使用,并且在 money 类上同时拥有 divide 操作和 allocate 操作似乎存在一些利益冲突。通过将无损(或增益!)小数金额的责任分离到一个单独的类中,我迫使 Money
承认它实际上无法很好地自分配,而是将其委托给另一个类。此外,这允许对 MoneyDistributor
进行子类化以获得自定义行为,而 Money
由于是值类型,已不再能这样做。
货币
关于货币的方面,CLR 中没有可用的类型。Java 遵循 ISO 4217 标准,因此添加类似的东西是相当安全的——只需遵循规范。我最初的想法是创建一个带有静态字段的 Currency
类:每个货币对应一个字段。然而,Jason Hunt 的实现 使用了一个 CultureInfo
实例来表示货币,这让我思考 BCL 中是否已经存在类似的东西。由于 CultureInfo
类围绕一个称为 LCID
的 Int32
标识符构建,因此使用它作为货币标识符似乎是合理的。然而,经过一些观察,事实证明这并不是一个稳健的方法:文化与货币的关系不仅不是 1 对 1,而且也不是 N 对 1,因为有些文化使用多种货币。最后,我使用了 ISO 规范来生成一些代码来加载查找表,并保持与给定货币的 ISO 数字代码相关的所有内容。这段代码是一个 Int32
,它是我 Currency
实例中的唯一字段,这允许我将 Currency
表示为值类型。这使得 Money
值的序列化非常简单:它没有任何引用字段!要指出的 Currency
的最后一个功能:它实现了 IFormatProvider
,因此当对 Money
调用 ToString()
时,会将其关联的 Currency
实例传递过去,并按预期格式化。
其他方法
当我开始处理这个类型时,我没有找到 CLR 的 Money 类型实现。有两点值得注意:第一是我没有搜索到正确的关键词(显然,没有人直接称 CLR 为“CLR”;每个人都通过“C#”或“.NET”等方式迂回地提及它),第二是一个好的实现在我撰写本文的同一天,完全独立地,在世界的另一端发布了!让我们比较一下这些其他实现,目标是找到一个明显存在的最佳方法。
值类型还是引用类型 | 货币类型 | 支持 ISO 4217 | 固定还是浮点 | 处理分配 | 算术运算 | 处理格式化 | Currency 实例格式化值 | 解析格式化字符串 | |
Jason Hunt 的 | 参考 | CultureInfo / RegionInfo | 否 | 浮点 | 否 | 是 | 是* | 否 | 否 |
Michael R. Brumm 的 | 值 | 枚举/表查找 | 否 | 浮点 | 否 | 否 | 是* | 否 | 是 |
chimeric69 的 | 值 | CultureInfo / RegionInfo | 否 | 浮点 | 否 | 是 | 是 | 否 | 是 |
Pascal Lindelauf 的 | 参考 | 自定义引用类型 / 枚举 | 是 | 浮点 | 是 | 是 | 是* | 否 | 否 |
本实现 | 值 | 自定义值类型 / 表查找 | 是 | 固定 | 是 | 是 | 是 | 是 | 否 |
* (非 IFormattable) |
值类型与引用类型
鉴于值类型在 CLR 中是一等公民,将其作为 Money 类型的基础似乎很自然。Money
的给定实例被认为等于另一个实例,当其值相等时。这被称为具有值语义。Fowler 将具有此特性的类型称为 “值对象”。另一方面,当将其定义为值类型而非引用类型时,您会失去一定的灵活性,即:继承。如果您想将资金分配/分配代码与 Money 类型一起保留,然后在子类中修改它,或者如果您的数据库映射层依赖于引用类型来包装数据库映射代码,这可能会成为一个问题。我通过将分配责任委托给单独的类来处理第一个问题,而第二个问题可能表明是时候采用一个更健壮的映射层了。鉴于值类型本身是可序列化的,将其存储在数据库中应该不难。根据您使用的是浮点还是定点格式来存储值,有几种不同的存储方式。更多信息请参见“固定还是浮点”。
货币类型
这里的主要决定是确定是使用自定义类型,还是组合使用,或者使用 System.Globalization.CultureInfo
/System.Globalization.RegionInfo
来表示货币,如果使用自定义类型,它应该是完整的类型还是枚举。虽然 CultureInfo
/RegionInfo
方法立刻吸引人,但它留下了一些难以解决的模糊性,因为某些地区使用多种货币,而不同的货币则在多个地区中使用。另一个缺点是,ISO 4217 标准中涵盖货币的各个方面无法直接访问:尤其是数字代码和指数。自定义类型方法允许您处理这个问题,但枚举方法需要您在表或通过某个静态访问器方法查找此信息。这些可以封装在 Money 类型中,这样用户就不必处理枚举之外的具体细节。在我看来,自定义类型方法是最稳健的,因为有大量数据需要封装(请参阅“支持 ISO 4217”)。实际类型可以是一个引用类型,但由于数据很少更改,并且以数字代码作为键,一个具有该代码作为键值到静态查找表的值类型是高效的,并且允许将值嵌入到 Money 类型中,使 Money 值非常便携。通过从 ISO 规范生成这些静态查找表并枚举 CultureInfo
/RegionInfo
,您可以通过一个封装在值类型中的 Int32
来获取有关货币的所有所需信息。
支持 ISO 4217
管理货币的全球标准将影响任何 Currency 类型的设计。至少它应该如此。如果使用 CultureInfo
/RegionInfo
,那么这个标准就会被间接遵循。然而,正如在“货币类型”中所指出的,使用 CultureInfo
/RegionInfo
来表示货币存在模糊性,而在其自己的类型中完全实现该标准可以解决这些问题。该标准涵盖了每种货币的几个组成部分:货币名称、符号、用于指示货币最小常用单位的指数、三字母代码以及数字代码。正是这些额外的信息是如何将 Currency 表示为自定义类型的主要因素,如在“货币类型”中所更全面地指出的。自定义类型的方法允许您处理这个问题,但枚举方法需要您在表或通过某个静态访问器方法查找此信息。这些可以封装在 Money 类型中,这样用户就不必处理枚举之外的具体细节。在我看来,自定义类型方法是最稳健的,因为有大量数据需要封装(请参阅“支持 ISO 4217”)。实际类型可以是一个引用类型,但由于数据很少更改,并且以数字代码作为键,一个具有该代码作为键值到静态查找表的值类型是高效的,并且允许将值嵌入到 Money 类型中,使 Money 值非常便携。通过从 ISO 规范生成这些静态查找表并枚举 CultureInfo
/RegionInfo
,您可以通过一个封装在值类型中的 Int32
来获取有关货币的所有所需信息。
无损处理分配
将资金分配到多个除法或分配可能会导致单位分数丢失或获得,然后这些分数可能会被后续的计算或存储和检索放大。处理金钱的人不喜欢这样。在 [PEAA] 中,分配是通过选择小数点后的位数来处理的,然后将除法的商截断到该位数,然后将所有商的总和从初始金额中减去,并将余数以最小小数增量分配到商中。我对此文本的修改包括包含所需的精度(从小数点后 0 到 9 位)以及三种分配方法的枚举:从前到后、从后到前和随机。
固定还是浮点
这是一个问题,对于那些对使用浮点数后果没有深刻理解的人来说,它的答案肯定会引起一些惊讶。如果您处理过那些在基数 2 中无法精确表示、但在基数 10 中可以精确表示的分数,那么您将损失金钱,而且可能损失的不仅仅是最初看起来那样的小数单位分数。这就是为什么对于 Money 类型,它必须是存储在基数 10 中的固定格式。举个例子,1/3 在基数 10 或基数 2 中都无法精确表示。在基数 10 中,将一个单位分成三份的常见方法是将小数单位分配给最后一个分配,例如,$1 分成三份是:$0.33、$0.33 和 $0.34。0.33 和 0.34 都可以用基数 10 表示;但是,它们不能用有限长度的基数 2 数字精确表示,并且在这样做时会丢失一些精度。这种损失在操作过程中经常会累积或放大。这里只有一个正确的答案:固定基数 10。
选择固定基数 10 数字的另一个好处是:大多数关系型数据库都有一个精确的数字类型,它以基数 10 存储值,因此在将这些值存储和检索到数据库时不会丢失。这对于基数 2 数字并不总是成立,并且数据库映射代码需要考虑这一点,如果它们被用作替代。如果您的数据库没有基数 10 数字来存储货币,或者它不足,那么开发一种使用 12 字节(一个 long 和一个 int)或 16 字节(一个 long 和两个 int)带货币的自定义策略是很容易的。
算术运算
在 CLR 中,您可以在自定义类型上定义运算符。鉴于 Money 涉及大量的算术运算,这样做是很自然的。唯一应该怀疑的是乘法和除法,因为使用它们来获得需要加到特定总数(或起始金额)而不产生任何损益的金额分配通常是不可能的。[PEAA] 在 money 类型上使用了一个“allocate”方法来替换这些情况下的除法。可以决定是将此方法保留在 money 类型中,还是将其委托给专用类。第一种方法意味着您不需要了解一个单独的类型,大多数好的 IDE 都会向您显示 allocate 方法,而敏锐的开发人员应该会注意到它,但是 Money 类中关于如何以及何时进行除法存在一些张力。第二种方法意味着您有一个明确定义的责任,使用它的代码会稍微突出一些,表明正在发生一些特殊的事情,并且能够进行子类化以获得自定义行为(假设 Money 是一个值类型),但需要发现和学习第二个类。我更喜欢单独的类:这可以被认为是纯粹的风格,但是如果需要更改分配行为(尽管在我压力下,我无法承认知道何时需要这种行为),这种方法会更清晰。
处理格式化
CLR 中有一个用于支持将类型格式化为字符串表示的模式:IFormattable
。支持此接口并满足开发人员对 ToString()
应如何工作的期望是有意义的。
Currency 实例格式化 Money 值
在支持 IFormattable
的基础上,有一个标准的格式化字符串用于指示值应格式化为货币:“C”。为了普遍支持这一点,您可以将格式化提供程序传递给 IFormattable.ToString()
或 String.Format()
,它将用于帮助正确格式化类型。使用 Currency 实例作为 IFormattable
实例似乎也很自然。这唯一的问题是当使用不同于 Money 值所表示的货币时可能会产生的混淆:如果未检查货币并抛出异常,Money 值将以不同的货币表示,但具有原始货币的数值。这时可能需要某种货币转换,但这对我来说似乎过于复杂和笨拙。
解析格式化字符串
格式化的反向操作:将字符串解析为 Money 和相关 Currency 的实例。没有接口提供 Parse()
和(在 2.0 及更高版本中)TryParse()
,但它们作为静态方法存在于 BCL 的原始类型上。鉴于我们希望使我们的 Money
类型尽可能类似于 BCL 的原始类型,以便制造它是 BCL 一部分的错觉,实现这两个方法就成了我们的工作。
源代码更新说明
- 2013-03-18:项目已更新至 VS2012,在 Money 中实现了舍入,在 Money 中实现了 IComparible,添加了 Money 扩展方法以在无需创建 MoneyDistributor 实例的情况下进行分配,在 Money 和 Currency 上添加了 TryParse 静态方法,在 Money 上添加了调试器可视化。
历史
- 2008-07-30:第一个版本。
- 2008-08-01:源代码已更新
- 2013-03-18:源代码已更新