65.9K
CodeProject 正在变化。 阅读更多。
Home

CLR的货币类型

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (58投票s)

2008 年 7 月 30 日

Ms-PL

14分钟阅读

viewsIcon

220877

downloadIcon

2546

一个方便、高性能的 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 类围绕一个称为 LCIDInt32 标识符构建,因此使用它作为货币标识符似乎是合理的。然而,经过一些观察,事实证明这并不是一个稳健的方法:文化与货币的关系不仅不是 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:源代码已更新
© . All rights reserved.