#Round関数
DelphiのRound関数はVBAのそれと同様に偶数丸め(あるいは「銀行家の丸め」)として実装されています。これの問題点については有史以来指摘されてきており、各所で回避策が提示されてきました。
Delphi Tips 浮動小数点数を整数に丸めるときの注意
http://www2.big.or.jp/~osamu/Delphi/tips.cgi?index=0073.txt
くろねこ研究所 [Delphi] 切り捨て、四捨五入、切り上げ
https://www.blackcat.xyz/article.php/ProgramingFAQ_del0044
#SimpleRoundTo関数
上記のくろねこ研究所さんでも指摘されていますが、Delphi2007以降にはSimpleRoundTo関数が追加され、Embarcaderoのサイトでも詳細が記載されています。
DocWiki 浮動小数点数の丸めルーチン
http://docwiki.embarcadero.com/RADStudio/Berlin/ja/%E6%B5%AE%E5%8B%95%E5%B0%8F%E6%95%B0%E7%82%B9%E6%95%B0%E3%81%AE%E4%B8%B8%E3%82%81%E3%83%AB%E3%83%BC%E3%83%81%E3%83%B3
ここには次のような記述があります。
- 常に "Round half away from zero"(絶対値で四捨五入)
- SimpleRoundTo は、従来学校で習ってきた丸め手法です。
#Excelと結果が違う
さてここでExcelを持ち出すのはビジネス実務の現場ではExcelはデファクトスタンダードですから、Excelの結果と違う場合にそれがなぜなのかを説明しなくてはならないからです。そしてなんと、SimpleRoundTo関数の結果はExcelと微妙に違っているのです。
検証環境
- Windows10 Proffesional(1803)
- Delphi10.2Tokyo Update3(Delphi 10.2 バージョン 25.0.29899.2631 )
- CPU AMD Ryzen 7 2700
偶数丸めの例題としてよく使用される数字を使って検証してみました。
- 対象: 1.245 桁数:-2 Delphi SimpleRoundTo: 1.25 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi SimpleRoundTo: 1.25 Excel Round: 1.26
げげ!早速違います。マイナス側も見てみましょう。
- 対象:-1.245 桁数:-2 Delphi SimpleRoundTo:-1.25 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi SimpleRoundTo:-1.25 Excel Round:-1.26
やっぱり違います。Excelの計算結果はどうみても普通の四捨五入なのでSimpleRoundTo関数はなにかが違うようです。
ちなみに「常に rmNearest(銀行型丸め) 」と解説されているRoundTo関数の計算結果もSimpleRoundTo関数と一致しています。どうやら、SimpleRoundTo関数はRoundTo関数と同じ結果を返すようです、ってそれじゃ意味ないじゃん。
#0.5加算法による四捨五入
それでは、従来からの偶数丸めの回避策として提示されてきた0.5を加算して切り捨てるアルゴリズムではExcelと同じになるのでしょうか。
function Round4And5(pValue: Double; pKeta: Integer): Double;
Var
wVal: Double;
wKeta: Integer;
Begin
wKeta := Trunc(Power(10, Abs(pKeta)));
If pKeta < 0 Then
begin
wVal := (pValue / wKeta) + 0.5;
Result := Trunc(wVal) * wKeta;
end
else
begin
wVal := pValue * wKeta + 0.5;
Result := Trunc(wVal) / wKeta;
end;
End;
上記の関数で計算した結果は以下の通りとなりました。
- 対象: 1.245 桁数:-2 Delphi Round4and5: 1.25 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi Round4and5: 1.26 Excel Round: 1.26
当然一致しますね。ではマイナス側はどうでしょうか。
- 対象:-1.245 桁数:-2 Delphi Round4and5:-1.24 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi Round4and5:-1.25 Excel Round:-1.26
これはダメですね。マイナスの時は+0.5ではなく-0.5じゃないとダメですね。
function Round4And5Kai(pValue: Double; pKeta: Integer): Double;
Var
wVal: Double;
wKeta: Integer;
wHalf: Double;
begin
wKeta := Trunc(Power(10, Abs(pKeta)));
if(pValue >= 0) then wHalf := 0.5 else wHalf := -0.5;
If pKeta < 0 Then
begin
wVal := (pValue / wKeta) + wHalf;
Result := Trunc(wVal) * wKeta;
end
else
begin
wVal := pValue * wKeta + wHalf;
Result := Trunc(wVal) / wKeta;
end;
end;
- 対象: 1.245 桁数:-2 Delphi Round4and5Kai: 1.25 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi Round4and5Kai: 1.25 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi Round4and5Kai:-1.25 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi Round4and5Kai:-1.25 Excel Round:-1.26
あれれ、プラスもマイナスも微妙に違います。プラスの結果が修正前と違うのはいったいなんなんだ・・・
#RoundMode
このあたりまで来て、どうにもおかしいということで先ほどのDocWikiをよく見るとRoundModeというものがあることに気が付きます。
- RoundTo関数:常に rmNearest(銀行型丸め)
- SimpleRoundTo関数:常に "Round half away from zero"(絶対値で四捨五入)
これ、本当なんだろうか・・・。検証してみることにします。
現在のRoundModeはGetRoundModeで取得することが出来ます。デフォルトはrmNearestです。TRoundingModeにはこの他に、rmUp, rmDown, rmTruncate があります。DocWikiには以下のように解説されています。
- rmNearest 最も近い整数値に丸めます。
- rmDown 切り下げます。
- rmUp 切り上げます。
- rmTruncate 値を切り捨てます(正の数は切り下げ、負の数は切り上げます)。
デフォルトはrmNearestなので、rmTruncateを調べてみることにしました。rmUpとrmDownは結果がなんとなく予想できるので未検証です。
#RoundModeをrmTruncateにしてみると
さて、RoundModeを変更するにはSetRoundMode関数を使用します。
- 対象: 1.245 桁数:-2 Delphi SimpleRoundTo: 1.25 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi SimpleRoundTo: 1.26 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi SimpleRoundTo:-1.25 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi SimpleRoundTo:-1.26 Excel Round:-1.26
おお、ぴったり一致しました。RoundTo関数はどうでしょうか。
- 対象: 1.245 桁数:-2 Delphi RoundTo: 1.24 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi RoundTo: 1.25 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi RoundTo:-1.24 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi RoundTo:-1.25 Excel Round:-1.26
みごとに切り捨て側に丸められています。また、自作アルゴリズムは以下のようになりました。
-
対象: 1.245 桁数:-2 Delphi Round4and5: 1.24 Excel Round: 1.25
-
対象: 1.255 桁数:-2 Delphi Round4and5: 1.25 Excel Round: 1.26
-
対象:-1.245 桁数:-2 Delphi Round4and5:-1.23 Excel Round:-1.25
-
対象:-1.255 桁数:-2 Delphi Round4and5:-1.24 Excel Round:-1.26
-
対象: 1.245 桁数:-2 Delphi Round4and5Kai: 1.24 Excel Round: 1.25
-
対象: 1.255 桁数:-2 Delphi Round4and5Kai: 1.25 Excel Round: 1.26
-
対象:-1.245 桁数:-2 Delphi Round4and5Kai:-1.24 Excel Round:-1.25
-
対象:-1.255 桁数:-2 Delphi Round4and5Kai:-1.25 Excel Round:-1.26
これらも、RoundModeがrmNearestの時と結果が違います。なんで違うの!Truncate関数のせいなのかもですが、切り捨て関数の挙動がRoundModeで違うなんて知らないですよ普通。
#まとめ
Delphiでの四捨五入でExcelの計算結果と一致させたかったら、RoundMode=rmTruncateでSimpleRoundTo関数を使え!
ということですが、SimpleRoundTo関数がRoundModeの影響を受けるとはEmbarcaderoのサイトにも書いてないので訂正が必要ですね。
また、記事中では触れませんでしたがBCDRoundTo関数というのもあり、そちらも検証しています。Excelとは一致しません。
まさかAMD CPUだからということもないでしょうが、Intel CPUでもちらっとやってみて同じ結果でした。
#追記
お金の計算するならCurrency型でしょ、という指摘があり、確かにそうだよなと自作版をCurrency型で再テストしてみました。
- 対象: 1.245 桁数:-2 Delphi Round4and5kai Currency型: 1.25 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi Round4and5kai Currency型: 1.26 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi Round4and5kai Currency型:-1.25 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi Round4and5kai Currency型:-1.26 Excel Round:-1.26
とぴったり一致しました。また、rmTruncateで実行した場合はこうなります。ありゃー。
- 対象: 1.245 桁数:-2 Delphi Round4and5kai Currency型: 1.2399 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi Round4and5kai Currency型: 1.25 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi Round4and5kai Currency型:-1.2399 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi Round4and5kai Currency型:-1.25 Excel Round:-1.26
Currency型はお金の計算ではこちらを使えとなっていて「最下位 4 桁が暗黙に小数点以下の桁を表す位取り 64 ビット整数として格納されます。代入文や式で他の実数型と混在させた場合は、Currency 型の値に対して自動的に 10,000 での除算または乗算が行われます。」とドキュメントにもあります。なので、こういうことをすると対応できません。
- 対象: 1.2454545 桁数:-6 Delphi Round4and5kai Currency型: 1.2455 Excel Round: 1.245455
- 対象: 1.2454545 桁数:-6 Delphi SimpleRoundTo : 1.245455 Excel Round: 1.245455
つまり、銭単位での四捨五入とかにはCurrencyで実装した±0.5アルゴリズムを使え。小数部4桁超にはrmTruncateでSimpleRoundToを使え?
#さらに追記
Win32とWin64で挙動が違うよねということで、Extended型の関与が考えられる(Win64ではExtended型はDouble型のエイリアスになっている)のですが、よくよく見るといったんDouble型で受けて変換している、、、
procedure CalcRound;
var
wValue: Double;
wDigit: ShortInt;
begin
try
wValue := StrToFloat(Edit1.Text);
wDigit := StrToInt(Edit2.Text);
LblRoundTo.Caption := FloatToStr(RoundTo(wValue, wDigit));
LblRound.Caption := FloatToStr(Round(wValue));
LblSimpleRoundTo.Caption := FloatToStr(SimpleRoundTo(wValue, wDigit));
LblBCDRoundTo.Caption := BCDToStr(BCDRoundTo(StrToBcd(Edit1.Text), wDigit));
LblRound4and5.Caption := FloatToStr(Round4and5(wValue, -1 * wDigit));
LblRound4and5Kai.Caption := FloatToStr(Round4and5Kai(wValue, -1 * wDigit));
except on E: Exception do
begin
LblRoundTo.Caption := '-';
LblRound.Caption := '-';
LblSimpleRoundTo.Caption := '-';
LblBCDRoundTo.Caption := '-';
LblRound4and5.Caption := '=';
LblRound4and5Kai.Caption := '-';
end;
end;
end;
で、wValueをExtendedにしてWin32でテストしたところ、SimpleRoundTo関数でも結果はExcelと一致しました。Extendedは意味ないという頭があったのでDoubleにしてしまっていましたが、Win32では生きてました。rmNearestです。
- 対象: 1.245 桁数:-2 Delphi SimpleRoundTo Extended型Win32: 1.25 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi SimpleRoundTo Extended型Win32: 1.26 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi SimpleRoundTo Extended型Win32:-1.25 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi SimpleRoundTo Extended型Win32:-1.26 Excel Round:-1.26
で、そのままWin64にしてみると、やっぱり元に戻って一致しなくなります。Extendedじゃないんだから当然ですね。
- 対象: 1.245 桁数:-2 Delphi SimpleRoundTo Extended型Win64: 1.24 Excel Round: 1.25
- 対象: 1.255 桁数:-2 Delphi SimpleRoundTo Extended型Win64: 1.26 Excel Round: 1.26
- 対象:-1.245 桁数:-2 Delphi SimpleRoundTo Extended型Win64:-1.24 Excel Round:-1.25
- 対象:-1.255 桁数:-2 Delphi SimpleRoundTo Extended型Win64:-1.26 Excel Round:-1.26