最近在產品開發過程中遇到一個問題,就是在對數值進行截取,例如說保留兩位小數時,最終得到的結果跟預期的在某些情況下會產生差異,這個差異的表現就是最后一位與預期的不一致,也就是說在“四舍五入”上出現了問題。所以,專門抽時間看了一下。
首先,我們需要確認一下舍入的規則,按照我們上小學的時候所學應該是“四舍五入”,也就是要保留的那一位之后的一位上的數字,如果是4就直接舍掉,如果是5,則在最后一位上加1。雖然簡單,但還是舉個例子。
例1(保留2位小數):
- 1.234 -> 1.23
- 1.235 -> 1.24
- -1.234 -> -1.23
- -1.235 -> -1.23
這種方法是我們所熟悉的,但是還需要向大家提供另外一種“舍入”算法,就是銀行家舍入(Bankers rounding)算法。相比較我們所熟悉的四舍五入來說,這才是一種更國際通行的。事實上這也是 IEEE 規定的舍入標准。因此所有符合 IEEE 標准的語言都應該是采用這一算法的。其算法規則是“四舍六入五取偶”。詳細點兒說,就是
- 一個小數,當舍去位小於5,那么就舍去這位;
- 當舍去位等於5的時候,那么去看舍去位前面一位數的奇偶性,如果是奇數,那么就舍去5,然后舍去位前面一位加1,相反:如果是偶數,那么就舍去5,舍去位保留偶數性質不變;
- 當舍去位大於5的時候,那么舍去位不要,舍去位前面一位加1;
- 這個法則對負數也起相同作用!
也一樣舉個例子,例2(保留2位小數):
- 1.234 -> 1.23
- 1.235 -> 1.24
- 1.236 -> 1.24
- 1.245 -> 1.24
- 1.255 -> 1.26
如果大家還理解不了的話,可以找個小朋友問一下,據不可靠消息透露,現在他們會學這種算法的。
背景知識普及完畢,下面說一下在.Net開發環境中的實際應用。因為剛剛也說過了,銀行家舍入算法是IEEE 規定的舍入標准,所以在.Net中實際上默認的舍入算法也是這個。
首先來重新認識一下Convert.ToInt32()方法
public static int ToInt32(decimal value);
public static int ToInt32(double value);
public static int ToInt32(float value);
這個方法是我們會經常用到的,不過在我執行下面的代碼之前,從來沒有想過,它還有這么個小坑。從執行結果可以很容易看出它遵循的就是銀行家舍入算法。
Console.WriteLine(Convert.ToInt32(12.5)); //12
Console.WriteLine(Convert.ToInt32(12.51)); //13
Console.WriteLine(Convert.ToInt32(13.5)); //14
Console.WriteLine(Convert.ToInt32(14.5)); //14
Console.WriteLine(Convert.ToInt32(15.5)); //16
至於使用(int)num進行的強制類型轉換,則是直接截斷小數部分,相當於Math.Truncate()方法。
啰嗦了好多了,下面是重點啦,對小數進行四舍五入。下面先列舉一下平時開發過程中經常會用到的舍入方法:
double num = 12.345;
Console.WriteLine(num.ToString("F2")); //12.35
我個人覺得,ToString方法極為好用,既可以精確的進行四舍五入(沒有找到明確的依據,但就目前測試的結果表現的確是這樣的),還可以保留末尾的0,在最終界面輸出時進行四舍五入,應該沒有比這更好的方法了。如果對結果是字符串不滿意,就再做一次Convert吧。
public static decimal Round(decimal d, int decimals);
public static double Round(double value, int digits);
Math.Round()方法也是我們最常用的一種小數截取方法,但是不管我們意識到了沒有,它們默認采用的都是銀行家舍入法。所以,在我們不經意間,就有與我們預期不一致的數據悄悄的產生了。還好,Round方法提供了一個重載,使用一個MidpointRounding參數來決定舍入的算法:
public static decimal Round(decimal d, int decimals, MidpointRounding mode);
public static double Round(double value, int digits, MidpointRounding mode);
public enum MidpointRounding
{
// Summary:
// When a number is halfway between two others, it is rounded toward the nearest even number.
ToEven = 0,
// Summary:
// When a number is halfway between two others, it is rounded toward the nearest number that is away from zero.
AwayFromZero = 1,
}
當使用MidpointRounding.ToEven時,其實就是默認的銀行家舍入法;而MidpointRounding.AwayFromZero,則是說當數值正好處於兩側的數字中間,也就是舍去位等於5,且其是最后一位時,返回距離0更遠的那個數字。雖然這個描述有些麻煩,但其實就是我們的四舍五入。
似乎問題已經解決了,調用Math.Round()時把MidpointRounding.AwayFromZero傳進去就可以了,算幾個數試試。
decimal dn = 2.155m;
Console.WriteLine(Math.Round(dn, 2, MidpointRounding.AwayFromZero)); //2.16
dn = 4.155m;
Console.WriteLine(Math.Round(dn, 2, MidpointRounding.AwayFromZero)); //4.16
看着還是沒什么問題,不過我們日常開發中decimal類型用的不是太多啊,更加常用的是浮點數,那就再用浮點數試一下。
double dn = 2.155;
Console.WriteLine(Math.Round(dn, 2, MidpointRounding.AwayFromZero)); //2.15
dn = 4.155;
Console.WriteLine(Math.Round(dn, 2, MidpointRounding.AwayFromZero)); //4.16
似乎跟預期的不大一樣啊,原因嘛,看到問題就很容易想得到了,萬惡浮點數啊,人家就是不精確,不能用==,不能說你看到它顯示成2.155,他就絕對是2.155,所以就會偶爾產生這樣跟我們預期不一樣的結果,解決方法也各種各樣,可以先轉換成decimal類型再做處理,也可以按前面說的ToString之后再做類型轉換,更可以自己來實現一個四舍五入的算法。下面提供兩個算法實現,其原理都是先加5,再截取,方法是提供了,但還是要先驗證啊:
private static double ChineseRound(double dblnum, int numberprecision)
{
int tmpNum = dblnum > 0 ? 5 : -5;
return Math.Truncate((Math.Truncate(dblnum * Math.Pow(10, numberprecision + 1)) + tmpNum) / 10) / Math.Pow(10, numberprecision);
}
private static double ChineseRound2(object objnum, int numberprecision)
{
double returnnum = 0;
if (objnum != null)
{
try
{
double dblnum = double.Parse(objnum.ToString());
int tmpNum = dblnum > 0 ? 5 : -5;
double dblreturn = Math.Truncate(dblnum * Math.Pow(10, numberprecision + 1)) + tmpNum;
dblreturn = Math.Truncate(dblreturn / 10) / Math.Pow(10, numberprecision);
returnnum = dblreturn;
}
catch { }
}
return returnnum;
}
最后寫個方法測試一下上面提到的幾種舍入的算法:
static void Main(string[] args)
{
var d = 2.155d;
var step = 0.01d;
var precision = 2;
for (var i = 0; i < 10; i++, d += step)
{
Console.WriteLine("{0}\t{1}\t{2}\t{3}\t{4}\t{5}\t{6}\t"
, d
, ChineseRound(d, precision)
, ChineseRound2(d, precision)
, Convert.ToDouble(d.ToString("F" + precision))
, Math.Round(d, precision)
, Math.Round(d, precision, MidpointRounding.AwayFromZero)
, Math.Round((decimal)d, precision, MidpointRounding.AwayFromZero));
}
d = -d;
for (var i = 0; i < 10; i++, d -= step)
{
Console.WriteLine("{0}\t{1}\t{2}\t{3}\t{4}\t{5}\t{6}\t"
, d
, ChineseRound(d, precision)
, ChineseRound2(d, precision)
, Convert.ToDouble(d.ToString("F" + precision))
, Math.Round(d, precision)
, Math.Round(d, precision, MidpointRounding.AwayFromZero)
, Math.Round((decimal)d, precision, MidpointRounding.AwayFromZero));
}
Console.ReadKey();
}
結果大家就自己執行一下看看吧,看看能發現什么問題。
PS.1 說一下最后那段代碼執行后得到的結論,ChineseRound這個方法還是逃不開浮點數的坑,而ChineseRound2在測試過程中還沒發現錯誤,這是讓人挺費解的一個地方。所以對ChineseRound方法做了一點兒小修改,結果就對了(至少針對目前的測試而言)
private static double ChineseRound(double src, int precision)
{
src = Convert.ToDouble(src.ToString()); //添加了這么一行代碼
int tmpNum = src > 0 ? 5 : -5;
return Math.Truncate((Math.Truncate(src * Math.Pow(10, precision + 1)) + tmpNum) / 10) / Math.Pow(10, precision);
}
PS.2 以前好像是沒寫過這樣做分享的文章,就是一點兒東西,但是寫得有些啰嗦,也不曉得能不能表述清楚,歡迎大家提意見啊
PS.3 寫作過程中,參考了一些互聯網上的文章,包括但不限於ChineseRound2方法的實現