以下のような経緯で、BigDecimalをいじっていて、知らなかったことがいくつか出てきたので、まとまりが無いですがとりあえずわかったことを順に書いていきます。
前提知識として、BigDecimalはDoubleなどで表しきれない大きな数を表すためのクラスです、そしてscala.math.BigDecimalは、java.math.BigDecimalの薄いラッパーです。単に、BigDecimalといった場合、どちらのこと言ってるのかわかりにくい・・・。
- play2scalaz という自作のライブラリで、テストが途中で停止する(?)問題に遭遇
- スレッドダンプ取得してみたら、BigDecimalがあやしい
- Play2のJsonの数字を表すJsNumberは、内部で一旦scalaのBigDecimalとして保持している*1
- なので、play2scalazのテストで、BigDecimalからJsonのArbitrary*2を生成していた。
- 停止する原因としては、Scalacheck内部のデフォルトのBigDecimalのArbitraryが、すごく巨大、もしくはすごく極小の値を生成する。
- また、JavaのBigDecimalはそのような、あり得ないくらい巨大な数*3の処理の際に
- ものすごく効率が悪くなる
- 数GByteのあり得ないような大きな配列を一気に生成しようとしてOutOfMemory発生
- java.math.BigDecimal内部の処理中に、Intのオーバーフローかアンダーフローを起こして、あり得ない例外(java.lang.NegativeArraySizeExceptionなど)を投げてくる場合がある
- さすがにjava.lang.NegativeArraySizeExceptionを投げてしまうのは、javaのBigDecimal自体のバグな気がする
- Java側のバグだとしても、OutOfMemoryを発生させるような引数の条件がある程度わかってるなら、ScalacheckのBigDecimalのArbitraryは、対応するべきだとは思う
- しかし、詳細な条件はもうすこしJavaのBigDecimalのコードそのものを読んだり、詳細に実験しないとわからない
- Scalacheckにissue報告だけした https://github.com/rickynils/scalacheck/issues/82
- とりあえず、現状のScalacheckのBigDecimalのArbitrary使うのはやめましょう
同じBigDecimalの話だけど、少し話変わって、Scalaz内のBigDecimalのtypeclassのインスタンスのテストを書こうとしようとした際の話
- Scalaz7.1.0-M4現在、scala.math.BigDecimalは、Enum、Monoid、Showのインスタンスである。
- また、掛け算に対して、別のMonoidのインスタンス(Tagged Typeを使っている)にもなっている
- まず、テストを書いたら、BigDecimalMultiplicationのほうは、Semigroupのlawを満たさない
- 結論から言うと、scalaのBigDecimalを普通に生成して掛け算する場合、デフォルトでは"丸めモード"がONになっており*4、掛け算するときに情報が消える場合がある。
- java.math.MathContextというものが存在し、大雑把に説明すると"どのくらいの精度の計算結果を保持するか?"を表現するクラスがある。
- "四捨五入", "切り捨て", "切り上げ", "0に近い方に丸め" など色々ある
- 結局、厳密にするには、それらのモードのなかで、
java.math.MathContext.UNLIMITED
を指定しないといけなかった。*5 - 掛け算のほうのMonoidを修正後、それですべてのテストが通ったと思いきや、Scala2.9.3の場合だけ、Enumのlawも満たさないというテスト結果が出た
- これも結論を先に言うと、Scala2.10.xとScala2.9.xでscala.math.BigDecimalのコードが微妙に変更されていた
- Scala2.9の場合は、足し算や引き算などの演算の際に、自身のMathContextを、内部のjava.math.BigDecimalのメソッドに引き渡していた
- Scala2.10の場合は、MathContextを引き渡すのをやめていた
- MathContextを渡さないオーバーロードのほうは、
java.math.MathContext.UNLIMITED
を渡したと同じ動作になるみたい? - つまり、Scala2.10のほうは、強制的に"精度を一番失わない厳密なモード"で足し算などを行うように変更されていた。
- そのissue報告したのは、spireの作者のErik Osheimさん
- https://issues.scala-lang.org/browse/SI-4981
- https://github.com/scala/scala/commit/3a1332c451c8bd9b987ab3d
- 結局、直接scala.math.BigDecimalの+や-のメソッドを使うのではなく、scala.math.BigDecimalが内部に保持している、java.math.BigDecimalのメソッドを直接操作することによって、Scala2.9.3の場合もEnumのtypeclassのlawを満たすようになった
- "とにかく絶対にlawを満たすべき"という立場だったら、これはScalazのバグなので、
java.math.MathContext.UNLIMITED
を使用するようにScalazを修正するべきだが、果たしてこれはどうすべきか・・・? - Scalazを直すべきかどうか躊躇してる理由は「
java.math.MathContext.UNLIMITED
を使用した場合に、どのくらいのオーバーヘッドが発生するのか?」といった現実的な問題とのトレードオフとか、その他なにかデメリットがあるのか、自分がよくわかってないから - 少なくとも、掛け算の方のMonoidは、MathContext.UNLIMITEDを使うとオーバーフローする場合があるみたい*6なので、微妙か・・・
たぶんそのうちScalazにissue報告するかな・・・
- 足し算でもオーバーフローするのか・・・*7すると、どの演算(掛け算、足し算)においても、例外投げずに完璧にMonoidのlaw満たすようにするのは不可能ということか・・・
あと、twitterでつぶやいていたら、以下の様な反応もらいました
そういえば、javaのBigDecimalは
BigDecimalのJavadoc
compareTo と違い、このメソッドは、2 つの BigDecimal オブジェクトが値もスケールも同じである場合にだけ等しいと見なします (したがって、このメソッドでは 2.0 と 2.00 は等しくない)。
という、奇妙な仕様になってるらしいですが、ScalaではMathContextを比較しないようです。つまり、ScalaのBigDecimalは、Javaと違ってcompareToとequalsに整合性があるらしいです
https://github.com/scala/scala/blob/v2.10.3/src/library/scala/math/BigDecimal.scala#L218-L220
*1:FloatやDoubleに収まる範囲だとしても
*2:scalacheckのクラス。テストのためのランダムデータ生成のためのクラス
*3:例えば、Scalacheckは、桁数がInt.MaxValueを超えるようなものを生成しようとする
*4:MathContextを明示的に指定しない場合は、これ https://github.com/scala/scala/blob/v2.10.3/src/library/scala/math/BigDecimal.scala#L26 が使われる
*5:追記したが、そうするとオーバーフローした場合に例外投げるので、これはこれで微妙・・・
*6: val a = new java.math.BigDecimal(new java.math.BigInteger("5"), Int.MinValue); a.multiply(a, java.math.MathContext.UNLIMITED)
*7:val a = new java.math.BigDecimal(new java.math.BigInteger("1"), Int.MinValue, java.math.MathContext.UNLIMITED); a.add(new java.math.BigDecimal(1), java.math.MathContext.UNLIMITED)