通常,在保留n位小数进行四舍五入的时候,会用到Math.Round方法,但默认的这个Round方法的实现可能与我们平常想象的不一样,在很早之前我就知道有这个差别,今天重新整理一下。简单来说,Math.Round方法除了提供了第二个保留小数位数的参数之外,还有一个名为MidpointRounding的枚举类型参数,这个参数在.NET Framework 4.8及.NET Core 3.0版本之前,只提供了两个Round nearest类型枚举值: ToEven和AwayFromZero。这两个值的区别只在于当遇到中值5时的舍入问题。在.NET Core 3.0及之后的版本中,又引入了三个枚举值:ToZero、ToNegativeInfinity和ToPositiveInfinity这三个值,这个三个值解决了是直接舍掉、整体向下还是整体向上舍入的问题。

.NET Core 3.0版本之前


在.NET Core 3.0之前的版本中,Math.Round中的MidpointRounding提供了ToEven和AwayFromZero的枚举值。

MidpointRounding.ToEven


这也是Math.Round的默认行为,也叫"银行家舍入"(Banker‘s rounding)即四舍六入五取偶。事实上这也是IEEE的规范,因此所有符合IEEE标准的语言都应该采用这样的算法。就是当遇到中值5时,舍入到最接近的偶数。其它小于5和大于5的数就和我们平常的四舍五入一样,当遇到中点值时:

  • Math.Round(3.75,1)=3.8
  • Math.Round(3.85,1)=3.8
  • Math.Round(-3.75)=-3.8
  • Math.Round(-3.85)=-3.8

在其它不为5的情况下,与正常的四舍五入结果一致:

  • Math.Round(3.74, 1)=3.7
  • Math.Round(3.84, 1)=3.8
  • Math.Round(3.76, 1)=3.8
  • Math.Round(3.86, 1)=3.9

这就是小于5舍,大于5进,等于5则凑到最近的偶数。这就与我们经常理解的四舍五入就有差别了,比如3.85,保留一位小数不应该四舍五入到3.9吗?这就是接下来的枚举值AwayFromZero

MidpointRounding.AwayFromZero


通过名称可以看到,它的含义是遇到中点值时,舍入到下一个远离0的数字(远离0主要是为了区分正数和负数)。这个就跟我们平常认识的四舍五入的概念一致了。

  • Math.Round(3.75,1,MidpointRounding.AwayFromZero)=3.8
  • Math.Round(3.85,1,MidpointRounding.AwayFromZero)=3.9
  • Math.Round(3.74, 1,MidpointRounding.AwayFromZero)=3.7
  • Math.Round(3.84, 1,MidpointRounding.AwayFromZero)=3.8
  • Math.Round(3.76, 1,MidpointRounding.AwayFromZero)=3.8
  • Math.Round(3.86, 1,MidpointRounding.AwayFromZero)=3.9

在遇到负数时,往远离0的方向舍入,比如:

  • Math.Round(-3.75,1,MidpointRounding.AwayFromZero)=-3.8
  • Math.Round(-3.85,1,MidpointRounding.AwayFromZero)=-3.9
  • Math.Round(-3.74, 1,MidpointRounding.AwayFromZero)=-3.7
  • Math.Round(-3.84, 1,MidpointRounding.AwayFromZero)=-3.8
  • Math.Round(-3.76, 1,MidpointRounding.AwayFromZero)=-3.8
  • Math.Round(-3.86, 1,MidpointRounding.AwayFromZero)=-3.9

这个我们平时的认知一致。

.NET Core 3.0及之后版本


在上述两个枚举值的基础上,MidpointRounding额外提供了三个枚举值,从含以上,这三个枚举的操作其实跟四舍五入没有关系,它们只是单纯在保留小数位时的进位策略。

MidpointRounding.ToZero


从名字上看,就是定向舍入到零,即向0进行运行,使得绝对值变小的策略。相当于保留小数点位数的值不变,之后的值移除。比如对7.5511,7.5551还是7.5581,保留两位小数,就是7.55,这个值比Round之前的值,都要小,看起来就是向零运动。对于负数-7.5511,-7.5551,-7.5581,保留两位小数结果都是-7.55,这个值比Round之前的值都要大,看起来也是向0运动。他们的绝对值都是比原来的值小。简单来说,就是把小数点之后的值直接删除,保留之前的值不做修改。

  • Math.Round(7.5511,2,MidpointRounding.ToZero)=7.55
  • Math.Round(7.5551,2,MidpointRounding.ToZero)=7.55
  • Math.Round(7.5581,2,MidpointRounding.ToZero)=7.55
  • Math.Round(-7.5511, 2, MidpointRounding.ToZero)=-7.55
  • Math.Round(-7.5551, 2, MidpointRounding.ToZero)=-7.55
  • Math.Round(-7.5581, 2, MidpointRounding.ToZero)=-7.55

MidpointRounding.ToNegativeInfinity


向下定向舍入的策略,向负无穷取舍,结果会小于等于原值,相当于Math.Floor。当数值大于0时,他的结果跟ToZero是一致的。当小于0时,向下取值,即向负无穷取值。比如对7.5511,7.5551还是7.5581,保留两位小数,就是7.55,这个值比Round之前的值,都要小,就是向负无穷运动。对于负数-7.5511,-7.5551,-7.5581,保留两位小数结果都是-7.56,这个值比Round之前的值,都要小,结果向负无穷运动。

  • Math.Round(7.5511,2,MidpointRounding.ToNegativeInfinity)=7.55
  • Math.Round(7.5551,2,MidpointRounding.ToNegativeInfinity)=7.55
  • Math.Round(7.5581,2,MidpointRounding.ToNegativeInfinity)=7.55
  • Math.Round(-7.5511, 2, MidpointRounding.ToNegativeInfinity)=-7.56
  • Math.Round(-7.5551, 2, MidpointRounding.ToNegativeInfinity)=-7.56
  • Math.Round(-7.5581, 2, MidpointRounding.ToNegativeInfinity)=-7.56

MidpointRounding.ToPositiveInfinity


向上定向舍入的策略,向正无穷取舍,结果会大于等于原值,相当于Math.Ceil。当数值小于0时,它的结果跟ToZero是一致的。当大于0时,向上取值,即向正无穷取值。比如对7.5511,7.5551还是7.5581,保留两位小数,就是7.56,这个值比Round之前的值要大,就是向正无穷运动。对于负数-7.5511,-7.5551,-7.5581,保留两位小数结果都是-7.55,这个值比Round之前的值都要大,结果向正无穷运动。

  • Math.Round(7.5511,2,MidpointRounding.ToPositiveInfinity)=7.56
  • Math.Round(7.5551,2,MidpointRounding.ToPositiveInfinity)=7.56
  • Math.Round(7.5581,2,MidpointRounding.ToPositiveInfinity)=7.56
  • Math.Round(-7.5511, 2, MidpointRounding.ToPositiveInfinity)=-7.55
  • Math.Round(-7.5551, 2, MidpointRounding.ToPositiveInfinity)=-7.55
  • Math.Round(-7.5581, 2, MidpointRounding.ToPositiveInfinity)=-7.55

源码分析


可以看到.NET Core 3.0中Math.Round的源码:

// System.Math
public unsafe static double Round(double value, int digits, MidpointRounding mode)
{
	if (digits < 0 || digits > 15)
	{
		throw new ArgumentOutOfRangeException("digits", SR.ArgumentOutOfRange_RoundingDigits);
	}
	if (mode < MidpointRounding.ToEven || mode > MidpointRounding.ToPositiveInfinity)
	{
		throw new ArgumentException(SR.Format(SR.Argument_InvalidEnumValue, mode, "MidpointRounding"), "mode");
	}
	if (Abs(value) < 1E+16)
	{
		double num = roundPower10Double[digits];
		value *= num;
		switch (mode)
		{
		case MidpointRounding.ToEven:
			value = Round(value);
			break;
		case MidpointRounding.AwayFromZero:
		{
			double value2 = ModF(value, &value);
			if (Abs(value2) >= 0.5)
			{
				value += (double)Sign(value2);
			}
			break;
		}
		case MidpointRounding.ToZero:
			value = Truncate(value);
			break;
		case MidpointRounding.ToNegativeInfinity:
			value = Floor(value);
			break;
		case MidpointRounding.ToPositiveInfinity:
			value = Ceiling(value);
			break;
		default:
			throw new ArgumentException(SR.Format(SR.Argument_InvalidEnumValue, mode, "MidpointRounding"), "mode");
		}
		value /= num;
	}
	return value;
}

可以看到,新增的ToZero内部是直接调用了Truncate函数,ToNegativeInfinity和ToPositiveInfinity则分别调用了Floor和Ceiling,这个与之前的分析一致。假设保留位数为digits。先将原值乘以倍数num,这个倍数它是使用一个数组保存的:

// System.Math
private static readonly double[] roundPower10Double = new double[16]
{
	1.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 10000000.0, 100000000.0, 1000000000.0,
	10000000000.0, 100000000000.0, 1000000000000.0, 10000000000000.0, 100000000000000.0, 1E+15
};

比如对于ToPositiveInfinity向正无穷取整,这相当于

double value=roundPower10Double[digits];
Math.Ceiling(price * value) / value;

而ToNegativeInfinity向负无穷取整,相当于:

double value=roundPower10Double[digits];
Math.Floor(price * value) / value;

把话说的更明白些


在Wikipedia上有各种Rounding的说明,它用y=round(x)来画出了各种round的函数图,其中的几种对应MidpointRounding的枚举。

首先看两种RoundToNearest:

对于MidpointRounding.ToEven,如下:

▲ y=Math.Round(x,MidpointRounding.ToEven)在坐标上的表示,实心点表示包含,空心点表示包含,比如x=0.5,y=0; x=1.5,y=2

接下来MidpointRounding.AwayFromZero。

▲ y=Math.Round(x,MidpointRounding.AwayFromZero)在坐标上的表示,实心点表示包含,空心点表示包含,比如x=0.5,y=1; x=1.5,y=2。

接下来是三个新的枚举。

首先是MidpointRounding.ToZero:

▲y=Math.Round(x,MidpointRounding.ToZero)在坐标上的表示,实心点表示包含,空心点表示包含,空心->实心暗含向0靠拢,当x=0.8,y=0; x=1.5,y=1。

接下来是MidpointRounding.ToNegativeInfinity:

▲y=Math.Round(x,MidpointRounding.ToNegativeInfinity)在坐标上的表示,实心点表示包含,空心点表示包含,空心->实心暗含向负无穷靠拢,当x=0.8,y=0; x=-1.5,y=-2。

最后是MidpointRounding.ToPositiveInfinity:

▲y=Math.Round(x,MidpointRounding.ToPositiveInfinity)在坐标上的表示,实心点表示包含,空心点表示包含,空心->实心暗含向正无穷靠拢,当x=0.8,y=1; x=-1.5,y=-1。

一则实例


在证券市场中,对于一般的股票涨跌停股价,一般是采用昨收的基础上正负10%,然后保留两位小数四舍五入得到涨跌停价,因为有四舍五入,所以算出来的涨跌停价格除以昨收价会出现涨停时涨幅超过10%,跌停时跌幅小于10%的情况。这是典型的Round nearest的算法,因为采用了舍入,会使得舍入的后的值比原先的值偏大或者偏小。

在另一个场景下,假如我们要设置一个放单的价格笼子,比如放单的价格只允许在当前价的正负2%(包含)范围内进行,那么就不能采用上述的两个算法了,只能采用ToNegativeInfinity或ToPositiveInfinity来计算了。

  • 对于当前价上涨2%的情况,可以使用Math.Round(price*1.02, 2, MidpointRounding.ToNegativeInfinity)计算,以保证舍入后的结果不会超过2%。
  • 对于当前价下跌2%的情况,可以使用Math.Round(price*0.98, 2, MidpointRounding.ToPositiveInfinity)计算,以保证舍入后的结果不会低于-2%。

如果使用的平台小于.NET Core 3.0,也可以使用Math.Floor和Math.Ceiling来做代替。

 

参考: