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

C# 中的 BigDecimal

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (20投票s)

2023年8月4日

CPOL

9分钟阅读

viewsIcon

35115

downloadIcon

534

本文介绍了一个类似于BigInteger的类,但它支持浮点值,如十进制数。

开始之前

正如你所见,表达式的优先级(包括括号)得到了尊重,并且如果我们需要的,我们可以要求100位小数(如果需要,还可以更多)。现在,我们来看正文。

引言

C#(以及.NET)目前不提供BigDecimal类。它们只提供了BigInteger类。

有趣的是,在我看来,BigIntegerBigDecimal之间唯一的真正区别在于,BigDecimal有一个小数点(在美国标准中是点),用于分隔整数和小数/浮点数。

所以,我想做的是:创建一个类,将任何数字存储为BigInteger,并且还有一个字段(我也将其设置为BigInteger类型)来告诉小数点确切的位置。

也许使用BigInteger作为小数点位置有点过了,`int`或`long`就足够了,但我希望能覆盖尽可能大的范围。

类的工作原理

这就是乐趣开始的地方。在阅读网上许多文章时,我看到需要将事物分成3个或更多部分。我决定从简单开始:一个整数(可以非常大,所以我使用了BigInteger),以及另一个数字来告诉小数点(小数点)的位置。

如果小数点位于末尾的第0位,这意味着该数字是整数。如果小数点位于数字位数的确切位置,这意味着数字紧跟在"0."之后 - 即,小数点位于末尾2位的57,变成0.57

事实上,在我最初的实现中,我不允许小数点超出整数的位数,但这不允许我生成像0.01这样的数字。在这种情况下,整数是1,但浮点位置是2,这比数字实际拥有的位数还要多(现在已修复/允许)。

构造函数

该类有两个主要的构造函数。其中一个只接收一个BigInteger。它假定没有浮点数(或者它位于末尾的第0位),并调用另一个构造函数。

然后,还有一个第二个构造函数,它接收整个数字作为BigInteger,以及一个值,该值告诉小数点的位置,从最后一个位置开始计数。所以,数字是123还是1000并不重要……第二个参数为2意味着小数点后将有两位数字。因此,分别是1.2310.00……但是,任何左侧(小数点前)的零或右侧(小数点后)的零都将被消除,因此数字将分别变为1.2310

也许这并不真的必要,但我开始编写代码时使用的是无符号类型……所以,而不是存储正数和负数的BigInteger,我总是将它们存储为正值,如果数字是负数,我只设置一个标志。

第一个运算符… operator +

我决定的第一个运算符是operator +(加法)。

我的第一个想法是:我需要使数字兼容。

该类仅存储整数,但小数点的位置可能值不同。因此,我们看到的1011.01都存储为101,但其中一个小数点位于0,而另一个小数点位于2

所以,我的解决方案是取小数点值最大的那个(在本例中为2),并将所有数字调整为使用该小数点。

这意味着小数点位于0101将变成小数点位于210100,而小数点位于2101将保持不变。

现在,只需将它们相加(10100 + 101 = 10201),并使用最高的小数点索引,将数字转换为102.01

整个代码看起来像这样

public static BigDecimal operator + (BigDecimal a, BigDecimal b)
{
  var digitFromEnd0 = a._decimalIndexFromEnd;
  var digitFromEnd1 = b._decimalIndexFromEnd;

  var maxIndex = BigInteger.Max(digitFromEnd0, digitFromEnd1);
  var intA = _MakeItHaveThisAmountOfFloatDigits(a, maxIndex);
  var intB = _MakeItHaveThisAmountOfFloatDigits(b, maxIndex);

  if (a._isNegative)
    intA = -intA;

  if (b._isNegative)
    intB = -intB;

  var sum = intA + intB;
  return new BigDecimal(sum, maxIndex);
}

方法_MakeItHaveThisAmountOfFloatDigits将在位数相同时返回输入数字,或者会在右侧添加一些零,直到达到正确的位数。

看起来是这样的:

private static BigInteger _MakeItHaveThisAmountOfFloatDigits
(
  BigDecimal value,
  BigInteger numberOfFloatDigits
)
{
  if (value._decimalIndexFromEnd == numberOfFloatDigits)
    return value._value;  // Already right size. Do nothing.

  var diff = numberOfFloatDigits - value._decimalIndexFromEnd;
  var multiplier = new BigInteger(1);
  for (var i=0; i<diff; i++)
    multiplier *= 10;

  var intPart = value._value * multiplier;
  return intPart;
}

其他运算符

operator - (减法)

减法运算符与加法运算符非常相似。我首先使两个数字具有相同的位数……也就是说,小数点右侧位数最多的那个需要乘以正确的10的幂,然后我进行减法。

构造新数字时,由于构造函数会负责删除小数点右侧的任何尾随零,因此一切都会正常工作。

这是operator -的全部代码:

public static BigDecimal operator - (BigDecimal a, BigDecimal b)
{
  var digitFromEnd0 = a._decimalIndexFromEnd;
  var digitFromEnd1 = b._decimalIndexFromEnd;

  var maxIndex = BigInteger.Max(digitFromEnd0, digitFromEnd1);
  var intA = _MakeItHaveThisAmountOfFloatDigits(a, maxIndex);
  var intB = _MakeItHaveThisAmountOfFloatDigits(b, maxIndex);

  if (a._isNegative)
    intA = -intA;

  if (b._isNegative)
    intB = -intB;

  var subtraction = intA - intB;
  return new BigDecimal(subtraction, maxIndex);
}

乘法 (operator *)

我以为乘法会很难实现……但事实证明它比加法和减法更容易实现。

我只需将两个值的整数表示相乘,然后将它们的小数点“索引”(从末尾开始)值相加。然后用这个创建新值。就是这样。

代码如下:

public static BigDecimal operator * (BigDecimal a, BigDecimal b)
{
  var digitFromEnd0 = a._decimalIndexFromEnd;
  var digitFromEnd1 = b._decimalIndexFromEnd;

  var intA = a._value;
  var intB = b._value;

  // It doesn't matter who is negative... as long as one is negative and the
  // other is not, we must make the result negative.
  bool isResultNegative = (a._isNegative != b._isNegative);
  var sum = a._value * b._value;
  if (isResultNegative)
    sum = -sum;

  var indexFromEnd = a._decimalIndexFromEnd + b._decimalIndexFromEnd;

  return new BigDecimal(sum, indexFromEnd);
}

除法 (operator / 和 Divide 方法)

除法更复杂,我在这里不解释。默认情况下,operator /将调用Divide方法,并将小数点后的位数设置为8。

理论上,你可以传入任何你想要的数字,但我注意到,超过一千位后,代码会变得非常慢……所以,除非你真的、真的需要,否则尽量不要设置那么大的位数。

示例应用程序默认使用100位小数。它只是对两个数字进行除法(需要使用.作为小数点,你不能独立于你的计算机区域/设置使用不同的分隔符),你可以看到它支持.NET提供的decimal无法处理的真正大的数字,例如4328947293473294729347329847329432作为被除数和23423432432432432423423作为除数会导致decimal出错,但BigDecimal可以轻松处理这些数字。

还有什么?

我实现了Power方法。我仍然不确定是否应该将其命名为Pow并使其成为static,或者将其保留为Power并作为实例方法。事实上,我倾向于将Divide设为实例方法,而不是将其保留为static方法。

无论如何,我编写了许多单元测试,但仍然发现了一些测试未能覆盖的错误(所以,我写了更多测试来证明问题……然后来证明修复)。

将代码视为仍在开发中,但特别是对于加法和减法,它应该效果相当好。

BigInteger不同,此类在调用ToString()时不会限制其生成的字符串大小,因此,如果你使用非常大的数字,请注意,查看BigDecimal中存储的值可能会减慢甚至崩溃你的调试器。

好了……就这些了。如果你愿意,请下载示例,看看它是否适合你。我希望它能对许多人有所帮助。并使用论坛在此报告你可能发现的任何错误。

一个额外的细节

我将此类设置为在0除以0时返回1。这是为了与公式中的简化相兼容,其中x/x总是为一。如果x0且你不进行简化,0/0在大多数情况下会抛出异常。在这个类中,它将完全匹配并返回1。这不是一个错误。这是该类的一个特性。

结论?

你来告诉我。对我来说,它已经帮助我解决了在处理BigInteger不适用的非常大的数字时遇到的十进制等问题。

BigRational

如果你下载了最新版本的示例,你可能会注意到还有一个BigRational类。这个类的想法很简单,但实现可能不像它应该的那样清晰。

为了避免在不断进行乘法、除法、然后乘法、然后除法(依此类推)时产生精度错误,BigRational类所做的是,在进行实际乘法时它会保持乘以分子,在除法时则乘以分母。它只会在我们要求将BigRational的值转换为BigDecimal时才执行实际的除法。

那是唯一可能会损失一些精度的时候,但假设我们在调用AsBigDecimal之前可能已经进行了数百次乘法和除法,我们将避免数百次精度损失。

AddSubtract方法可能有点令人困惑,因为它们实际上乘以了分母……这就是它们按预期工作的原因,因为只有在我们已经进行除法(或分母为1)的情况下,它们才能直接相加。

此外,为了避免数值无限增长,所有操作(AddSubtractMultiplyDivide)都会调用一个方法来“压缩”数字。例如,如果分子分母都是偶数,我们可以将两者都除以2。这意味着两个数字都变小了,但它们表示的比率保持不变。所以,如果你存储10050但实际类存储的是21,请不要感到惊讶。

BigDecimal不同,这是一个类。我曾想让它成为一个结构体,但我需要分母从1开始,而C#会自然地允许创建一个“默认”结构体(带有零),而不是试图处理分母0的情况,所以我决定将BigRational设为一个实际的类。如果我收到足够多的请求,我可能会改变这个决定。

好吧……希望你喜欢这两个类。

历史

  • 2023年8月9日:将一些floatDecimalCount重命名为floatDigitCount。在Decimal类中,所有数字预期都是十进制的。还修复了余数/模测试以及正负十进制之间的比较;
  • 2023年8月8日:向示例应用程序添加了MathParser的更新版本。现在用户可以编写自己的表达式,而不仅仅是处理除法。表达式支持基本运算,如+ - * / %,以及一个名为“prior”的变量,用于获取上次执行的值,以及允许设置小数精度的DividePower方法;
  • 2023年8月7日:使用BigInteger并采用二进制/分治优化,自己实现了Power
  • 2023年8月5日:将“nominator”重命名为“numerator”。Denominator保持不变;
  • 2023年8月5日:添加了BigRational的解释,并使其支持负数Power/指数;
  • 2023年8月4日:初始版本。
© . All rights reserved.