はじめに
工場にて紙の検査書に手書きで記入していたものを、ペーパーレス化として Web 画面で入力するようにしたのですが、検証として「範囲 2.2 〜 2.4」のところで、「2.3」と入力したのですが、表示された値は「2.29」となっていたのです。
ITエンジニアなら浮動小数点数の誤差が原因と分かるのですが、これを仕様として通すわけには行きませんよね、現場からは不具合として扱われるでしょう。
※この WebアプリケーションはBlazorで作成したので、内部処理はC#で組んでいます。
誤差の原因
汎用的な入力フォームで数値範囲や小数精度や切り上げ/下げをマスタで設定しております。
今回は、範囲 2.2 〜 2.4、小数点第2位、切り捨てとして設定されていました。
型は、doubleを使用しました。対象が金額ならdecimalを採用したかも知れません。
private static double GetAccuracyValue(double value, int accracy, ActualModeStatus mode = ActualModeStatus.Floor)
{
double result = 0;
double calc = Math.Pow(10, accracy);
if (mode == ActualModeStatus.Floor)
{
// 切り下げ
result = Math.Truncate(value * calc) / calc;
}
else
{
// 切り上げ
if (Math.Sign(value) == -1)
{
result = Math.Floor(value * calc) / calc;
}
else
{
result = Math.Ceiling(value * calc) / calc;
}
}
return result;
}
小数点第2位なので、calc変数には100がセットされます。
result = Math.Truncate(value * 100) / 100
- value * 100 → 2.3 * 100 = 230.0(のはずですが、実際には浮動小数点の誤差で 229.99999999999997 になることがあります)
- Math.Truncate(229.99999999999997) → 小数点以下を切り捨てて 229
- 229 / 100 → 2.29
その他数値
ChatGPT で同様の数値があるか問いたところ、この中では「4.56」が当てはまりました。
| # | 入力値 | 内部表現(IEEE754 double) | 表示例(小数第2位まで丸め) | 備考 |
|---|---|---|---|---|
| 1 | 0.1 | 0.10000000000000000555… | 0.10 / 0.09 になる場合あり | 誤差顕著 |
| 2 | 0.2 | 0.20000000000000001110… | 0.20 / 0.21 になる場合あり | |
| 3 | 0.3 | 0.29999999999999998890… | 0.29 になる場合あり | |
| 4 | 1.1 | 1.1000000000000000888… | 1.10 / 1.11 | |
| 5 | 2.3 | 2.2999999999999998224… | 2.29 | 典型例 |
| 6 | 4.56 | 4.5599999999999996092… | 4.55 | |
| 7 | 123.45 | 123.4500000000000028… | 123.45(稀に123.46) | 桁数次第 |
負数の端数処理を負方向と0方向として値が変わるもの
| # | 入力値 | 切捨て | 切捨て (負方向) |
切捨て (0方向) |
切上げ | 切上げ (負方向) |
切上げ (0方向) |
|---|---|---|---|---|---|---|---|
| 1 | 2.345 | 2.34 | -2.35 | -2.34 | 2.35 | -2.34 | -2.34 |
| 2 | 4.567 | 4.56 | -4.57 | -4.56 | 4.57 | -4.56 | -4.56 |
対策
ChatGPT で解決策を問いたところ、1e-10 を足すという結論でした。
CSharp
public enum ActualModeStatus
{
Floor, // 切り下げ
Ceiling // 切り上げ
}
private static double GetAccuracyValue(double value, int accracy, ActualModeStatus mode = ActualModeStatus.Floor)
{
double result = 0;
double calc = Math.Pow(10, accracy);
if (mode == ActualModeStatus.Floor)
{
// 切り下げ
result = Math.Truncate((value + 1e-10 * Math.Sign(value)) * calc) / calc;
}
else
{
// 切り上げ
if (Math.Sign(value) == -1)
{
result = Math.Truncate((value - 1e-10) * calc) / calc;
}
else
{
result = Math.Ceiling(value * calc) / calc;
}
}
return result;
}
JavaScript
// mode: "floor" または "ceil" を指定
function getAccuracyValue(value, accuracy, mode = "floor") {
let result = 0;
const calc = Math.pow(10, accuracy);
if (mode === "floor") {
// 切り下げ
result = Math.trunc((value + 1e-10 * Math.sign(value)) * calc) / calc;
} else {
// 切り上げ
if (Math.sign(value) === -1) {
result = Math.trunc((value - 1e-10) * calc) / calc;
} else {
result = Math.ceil(value * calc) / calc;
}
}
return result;
}
結果
C#のみ記載しますが、JavaScriptでも同様の結果になりました。
2.3の値
double value = 2.3;
// 切り下げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Floor)); // 2.3
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Floor)); // -2.3
// 切り上げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Ceiling)); // 2.3
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Ceiling)); // -2.3
2.345の値
負数の切り上げは、0方向とする。
double value = 2.345;
// 切り下げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Floor)); // 2.34
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Floor)); // -2.34
// 切り上げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Ceiling)); // 2.35
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Ceiling)); // -2.34
4.56の値
double value = 4.56;
// 切り下げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Floor)); // 4.56
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Floor)); // -4.56
// 切り上げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Ceiling)); // 4.56
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Ceiling)); // -4.56
4.567の値
負数の切り上げは、0方向とする。
double value = 4.567;
// 切り下げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Floor)); // 4.56
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Floor)); // -4.56
// 切り上げ
Console.WriteLine(GetAccuracyValue(value, 2, ActualModeStatus.Ceiling)); // 4.57
Console.WriteLine(GetAccuracyValue(-value, 2, ActualModeStatus.Ceiling)); // -4.56
最後に
小数の切り捨て/切り上げなどをテストする際に適当な数値で合っているとして検証完了としてしまうと今回のような問題が発生します。根拠のある数値でテストする必要があります。
この記事を書く上で再度検証してみたところ、-2.345の小数第2位の切り上げが-2.35と仕様的に間違った出力になっていたことに気が付き、見直しをしました。
「0に近い方向」か「マイナス方向」にするのかは業務の仕様によりますので要確認です。
また、関数名もGetAccracyValue→GetAccuracyValueとuの一文字が足りなかったことに気が付きました。
まだ、改修版をリリースしていないので間に合います。