だいぶ前に納品したAccessアプリケーションで、月末に動く端数の集計処理が正しくないのでは?とお客様のご指摘。
賃金台帳の合計と突き合わせると確かに違うようで、明細行を電卓で足し合わせた値とも違っている。
Accessのレポート(1月の出勤簿)は労基署に提出予定とのことで、即行で応急処置したので対処方法を共有する。
デバッグ開始
total = total + Int(n)
において、total
の値が途中で検算とズレている。
丸めに使っている変数n
のデータ型を確認する。
Debug.Print VarType(n)
5
vbDouble の値が返ってきた。Double … つまり倍精度浮動小数点数型である。
集計値がズレ始めたタイミングで中身を確認する。
Debug.Print n, Int(n)
4 3
倍精度浮動小数点数の4
を、Int関数(小数点以下の値を切り捨てるVBAの組込み関数)に通すと3
が返ってくる。
この謎が解けた人は、この先は読まなくて良い。
見た目は4
だが、本当に4
なのか?を確認する。
Debug.Print 4 = n
False
どうやら4
では無いらしい。
4
との差を確認する。
Debug.Print 4 - n
8.88178419700125E-16
$ 8.88178419700125*10^{-16} $ という極めて小さな値が返ってきた。
どのくらい小さいかって言うと、地球と太陽間の距離に対して1mmも無いくらいだ。
整理すると以下のようになる。
Dim n As Double
n = 4 - 8.88178419700125E-16
Debug.Print n, Int(n), Round(n)
n = 4.99999999999999
Debug.Print n, Int(n), Round(n)
4 3 4
4.99999999999999 4 5
限りなく4
に近く、見た目も4
だが、Int関数を通すと仕様通り3
が返ってくるので大きな誤差になる、ということのようだ。
このような場合、一般的には、Int関数をRound関数で置き替えるのがセオリーであり、VBAのRound関数は偶数丸めであることに留意すれば普通に使うことができる。
しかしながら、特殊な事情で、見た目上の数字(今回の場合は 4
)とその合計を一致させておく必要があれば、苦肉の策だが、組込み関数Intをユーザ関数Int2で置き替えるしかない。
Public Function Int2(n) As Long
Dim str As String, pos As Integer
str = CStr(n)
pos = InStr(str, ".")
If pos > 0 Then
Int2 = CInt(Left(str, pos - 1))
Else
Int2 = CInt(str)
End If
End Function
数値を文字列に変換し、小数点より前の数字を取り出すことで、見た目通りの数値で評価されるようになる。
その上で、集計箇所のIntをInt2に修正する。
total = total + Int2(n)
解決した。
応急処置的に対処してしまったが、そもそも設計レベルで見直すべき事柄である。
今までこんなことは無かったというので、稀なケースかもしれないし、Officeが64bitに変わったせいかもしれない。
定かで無いが不具合には変わりなく、修正済モジュールをBAS形式でエクスポートし、先方のアプリケーションにインポートして頂いた。
データベースとモジュールが分離されていないAccessアプリケーションでは、このようなリリース手順になる。
教訓
浮動小数点演算では、プログラミング言語に関係なく誤差は発生する。
かなり昔の話だが、Javaの開発案件で集計結果が合わない、と新人から相談を受けたことがある。
早速コードレビューすると原因発覚。補助通貨単位(ドルの場合はセントとかのアレだ)を小数点で表してDouble型の変数に格納していたからだった。
Double型はプリミティブ型としては極めて大きい(小さい)値を扱えたが、浮動小数点方式の性質上、2進数で完全に表せない10進数の小数では無限小数となり、データ型の精度を超える桁は丸められてしまう。
例示すると、10進数の0.1
を2進数に変換すると0.0001100110011…
となり、0011
の部分を永遠に繰り返すことになり(循環小数)、Double型に格納すると切り捨てられてしまう。
多少の誤差はあっても性能を優先する科学技術計算ならともかく、金額計算に浮動小数点は不向きなのだ。
なお、Excelの計算でもIEEE754(浮動小数点数の標準規格)に準じて設計されているため、同じような誤差は発生しているが、それが目立たないよう巧妙に工夫されているので通常の使い方で問題になることは無い。
で、誤差を回避する方法だが、すべて整数にする(小数点以下が2桁固定なら100倍するとか)か、java.math.BigDecimal
のような専用クラスを使うのが定石。BigDecimalは10進数値を文字列として扱うためパフォーマンスは落ちるものの、厳密に正しい数値を表現できる。