先日こういうツイートが流れてきた。
Q:なぜ金融系では未だにCOBOLが使われるんですか?
— 遊撃部長F/S&RWAs (@fstora) 2024年6月6日
A:お手元にExcelがありましたら任意のセルに「=4.8-4.7-0.1」って入れてみてください。
Q:なぜ金融系では未だにCOBOLが使われるんですか?
A:お手元にExcelがありましたら任意のセルに「=4.8-4.7-0.1」って入れてみてください。
普段我々がゴリゴリ馬車馬のように使っているソフトウェアでよく利用されている浮動小数点型、すなわちfloat
やdouble
などは特定の算術に弱いことが知られている。というかもうこの手の話題はあまりに拡散されてしまったので、なぜかネット民はみんな知っている基礎教養、三毛別羆事件とかデーモンコアみたいな感じになっている。
ちなみにこれはCOBOLかそうではないか、という軸が問題になっているのではなく、浮動小数点型を利用するか、それともBigDecimal
のような十進演算のために用意された型を利用するか、という軸の問題であって、しかもそれも正確な軸の取りかたではない。
というのも、BigDecimal
でカバーされない問題があるのだ。例えば、BigDecimal
型を利用しても(1 / 3) * 3
の結果はすぐ壊れる。
scala> (BigDecimal(1) / BigDecimal(3)) * BigDecimal(3) val res0: BigDecimal = 0.9999999999999999999999999999999999
ふつう、この演算の結果は1を期待するはずだ。
ゆえにこれは、正確にはどの数値表現を選ぶかという話題である。float
かBigDecimal
かという問題軸ではなく、ほげ言語かCOBOLか、という問題軸でもない。
数の表現で「二進」とか「十進」とか言ってるのは【表現の際に有限桁で打ち切るから】であり【無限精度の実数表現であれば「二進」とか「十進」とかつける必要はない】ので、「十進」(decimal)と呼ばれるデータ型があったらその時点で【正確さが何らかの形で犠牲になる】ことは確定なんですね
— mod_poppo@技術書典16 あ07 (@mod_poppo) 2024年6月8日
Spire -- 正確な数値計算のためのライブラリ
じゃあ何すか、COBOL以外では4.8 - 4.7 - 0.1
も(1 / 3) * 3
もできないってことっすか・・・!
とそんな我々のためにありがたいライブラリがあるのを昨日行なわれたScalaMatsuriで教えてもらった。その名もSpire(スパイア)。
Spireはプログラミング言語Scalaのためのライブラリで、汎用的で、高速で、正確な数値計算を提供してくれる。これを使えば先程の数式は何の問題もなく解を出せる:
println(r"4.8" - r"4.7" - r"0.1") // => 0
そんなSpireの便利な使い方について紹介する。
Scalaの利用
初めてScalaを聞いた人のためにDockerを使った方法を紹介する。Docker経由でScalaの実行コマンドであるScala CLIを利用しよう。
以下のエイリアスを設定することでDockerだけで開発できる:
alias scala-cli="docker run --rm -it \ -v /etc/passwd:/etc/passwd:ro \ -v /etc/group:/etc/group:ro \ -v $HOME:/home/`id -u -n` \ -v .:/app \ -w /app \ -u `id -u`:`id -g` \ virtuslab/scala-cli --server=false"
このエイリアスはホームディレクトリをマウントし、ホストにライブラリのキャッシュを保存してくれる。がDockerなので若干モッサリした挙動になるのは許してほしい。
Dockerを利用せず、Scala CLIをホストにインストールするには、以下の手順に従う:
# Linux curl -sSLf https://scala-cli.virtuslab.org/get | sh # macOS brew install Virtuslab/scala-cli/scala-cli
Scala Scriptのファイル作成
まずはファイルを作成しよう。spire.scala.sc
を適当な場所に保存する:
//> using scala 3.4.2
.scala.sc
はScalaをスクリプトとして実行するための拡張子だ。Scala Scriptについては以下の記事を参考にしてほしい:
ライブラリのインストール
さらに、ライブラリの依存性を追加しよう。次の行を追記する:
//> using dep "org.typelevel::spire::0.18.0"
Spireをインポートする
以下の行を書くことでSpireの機能を使えるようになる:
import spire._ import spire.math._ import spire.implicits._
次はいよいよ実践だ。
Rational
Spireは有理数を表現するためのRational
型が用意されている。数値からRationalを作るにはRational(10)
のように1つだけ引数を渡す。分子と分母を利用したい場合はRational(1, 3)
のように分子・分母の順で2つ引数を渡せばいい。
val r1 = Rational(42) val r2 = Rational(1, 3)
Rational
型は通常の数値同様に演算ができる。
println(r1 * r2)
この時点で以下のようなスクリプトになっているはずだ:
//> using scala 3.4.2 //> using dep "org.typelevel::spire::0.18.0" import spire._ import spire.math._ import spire.implicits._ val r1 = Rational(42) val r2 = Rational(1, 3) println(r1 * r2)
スクリプトを実行してみよう。scala-cli run ファイル
で実行できる。
ちなみに初回はライブラリやコンパイラをネットから拾ってくるので時間がかかるが、二度目からはずっと高速に起動する。
% scala-cli run spire.scala.sc
14
42 * (1 / 3)
が計算された。
Rational
の便利なところは、有理数を有理数のまま保持してくれるところだ:
println(Rational(7, 9))
7/9
この便利な性質によって、正確に計算ができる:
println(((7.0 / 9.0) * 1000000000) * (9.0 / 7.0) / 1000000000) // => 1.0000000000000002 println(((Rational(7) / Rational(9)) * Rational(1000000000)) * (Rational(9) / Rational(7)) / Rational(1000000000)) // => 1
r記法
ちなみにRationalを書くための便利な記法としてr""
が用意されている:
println(r"7/9" * r"1000000000" * r"9/7" / r"1000000000") // => 1
r
記法を使うと自動的に最初からRationalとして読み込まれるため、最初の計算も問題なく行える:
println(r"4.8" - r"4.7" - r"0.1") // => 0
RationalをBigDecimalに変換する
最終的にユーザに表示するなどの理由でRationalを十進に戻すにはtoBigDecimal
メソッドを呼ぶとよい。このメソッドは、欲しい精度(ケタ数)と丸めモードを教えることでBigDecimal
を返してくれる:
println((r"1" / r"3").toBigDecimal(4, java.math.RoundingMode.HALF_EVEN)) // => 0.3333
これはとても便利だ。
Polynomial
Spireの特筆すべき面白い機能としてPolynomial
型、つまり多項式を表現するための型がある。この機能を使うと、動的に式を組み立てて正確な計算ができる。
そんな機能をいつ使うのかと思うかもしれないが、携帯電話の料金プランについて考えてみよう。年齢による割引、月払いか年払いかによる割引、クーポンで一定料金が割引される仕組み・・・。契約プランは動的な計算に満ちている。
例えば以下のようなプランを考えてみよう:
- 大人と子供の別がある。子供は月間料金が10%引きになる
- 月払いか年間払いの別がある。年間で払うと年間料金から2000円が割引となり、実際はこれを12で割った額を月額料金から割引する
- クーポンを持っている場合は月間料金が1%引きになる
- 割引順序は年間割、年齢割、クーポンの順で行わなければならない
オエッ!結構難しいしこれらを矛盾無く組み立てるのは結構難しい。適当にやるとすぐ終わりそうだが、これはお金の問題だ。絶対にミスできない。
こういうとき、Polynomial
があると非常に便利だ。実際にやってみよう。
まずは大人/子供、月間/年間の区別を付けるためにenumを用意しよう:
enum Person: case Adult case Child enum Duration: case Annual case Monthly
Polynomialを作るにはpoly
記法を使う。この結果、Polynomial[Rational]
型の値ができる。まず年齢による割引を定義してみよう:
val ageDiscount = client match case Person.Adult => poly"1x" case Person.Child => poly"0.9x"
非常に直感的に記述できた。この調子で他の割引も定義してみよう:
val durationDiscount = planDuration match case Duration.Annual => val constantDiscount = r"2000/12" // 注意: スペースを入れない poly"x - $constantDiscount" case Duration.Monthly => poly"x" val couponDiscount = if hasCoupon then poly"0.99x" else poly"x"
Polynomial
にはcompose
メソッドがあるので、複数の割引を決まった順序で合成できる:
couponDiscount.compose(ageDiscount).compose(durationDiscount)
x.compose(y)
はy
してからx
なのでこの順序になる。
これで完成だ。calculateComplicatedMonthlyPricingDiscount
という名前にしよう:
def calculateComplicatedMonthlyPricingDiscount( client: Person, planDuration: Duration, hasCoupon: Boolean, ): spire.math.Polynomial[spire.math.Rational] = { // We are mobile network carrier company. // We have many complecated payment pricing and discount. import spire._ import spire.math._ import spire.implicits._ val ageDiscount = client match case Person.Adult => poly"1x" case Person.Child => poly"0.9x" val durationDiscount = planDuration match case Duration.Annual => val constantDiscount = r"2000/12" // caveat: no space between 2000 and /. poly"x - $constantDiscount" // Discount 2000 yen from annual fee. case Duration.Monthly => poly"x" val couponDiscount = if hasCoupon then poly"0.99x" else poly"x" // First apply duration discount, next age discount, then coupon discount. couponDiscount.compose(ageDiscount).compose(durationDiscount) }
この計算がうまく機能するか確認してみよう。「子供,年間プラン、クーポンあり」の場合を計算してもらう。Polynomial
に実際に値を代入するにはメソッドのようにf(2000)
のように値を渡せばよい。今回はベースとなる月額が2000円だとしよう。
import spire.syntax.literals.r val basePrice = r"2000" val discounted = calculateComplicatedMonthlyPricingDiscount( Person.Child, Duration.Annual, hasCoupon = true, )(basePrice) println( discounted.toBigDecimal(4, java.math.RoundingMode.HALF_EVEN), ) // => (2000 - (2000 / 12)) * 0.9 * 0.99 // => 1633.5000 println("without Spire:") println( (2000 - (2000 / 12)) * 0.9 * 0.99, ) // => 1634.094
Spireを使わない場合はけっこう大きな誤差が出たのに対して、Spireを使った場合は誤差なく計算ができている。気になる人は手で計算してみよう。
ほかの型
Spireはほかにも、Algebraic
やComplex
といった面白い型をたくさん用意してくれている。興味が湧いた人は調べてみよう。
あわせて読みたい
僕は十進小数をデフォルトにすることに反対しているという認識はなかったのでちょっとズレていると思います。場合によっては十進小数にも限界がある、Scalaではこういうやり方があるよね、という話をしていたつもりだった。
元ツイートに関連した記事です。輩と呼ぶのはいささか言いすぎと思いますけど言ってることはだいたいそうだと思います。
ハードウェアの要素もあるのを忘れたらアカンやろ、という記事。我々のPCでfloatの計算が速いのはそういうコプロセッサが載ってるからなんですよね。十進コプロセッサがマシンに載るようになったらちょっと世の中は変わるかもしれないね。