遅まきながら、明けましておめでとうございます。
諸般の事情で浮動小数点と取っ組み合っています。
懸命な読者であれば、浮動小数点表記は"eyes only"なものであり、データの交換に用いるのは慎むべきであることはご存知かと思われますが、実はそうも言ってられなくなっていることに気づいた人はどれくらいいらっしゃるのでしょうか…
そう。JSONです。そのままJavaScriptでeval
できちゃう、RFC7159で規定された、今や世界で最も利用されているであろうデータ交換フォーマット。
JavaScriptでeval
できちゃうだけあって、数値はJavaScriptの数値リテラルそのものです。
Hexadecimal Floating Pointなんてお洒落なものは使えません(Hex Floatについては記事を改めて)。
つまり、Cにおけるdouble
、JavaScriptにおけるNumber
を、復元できる形で文字列化する必要性は、JSON以前と以後では1e-64
が1e+64
になるぐらい変わっているのです。
ところで、今やほとんどの言語は、浮動小数点数をそのまま文字列化できます。「%
...e
だっけf
だっけg
だっけ?桁数指定はいくつが適切か…」とか悩まなくとも、print(number)
できちゃうのです。しかしその文字列を読み込んだ時、元の値を復元できるでしょうか?
調べてみた
-
考え方としては、1bitづつ仮数部を増やしていき、それを文字化した後数値化して、元の数値と一致すればOK、不一致が出たらNGです。
-
文字列変換はなるべく暗示的に行ってます。なので暗示的な文字列変換を持たないCは除外しています。C++の
iostream
は暗示的な文字列変換をやってくれはしますが、ここで確認したいのは「ストリーム」ではなく「バッファー」なのでこれも除外。 -
ideoneやrepl.itやIBM Swift Sandboxなど、Webで実行可能な環境がある場合はリンクしてあります。
Java
JavaScript
さすがJSONのふるさとだけあって、きちんと復元できるように文字列化してくれてます。手元で調べた限り、node.jsだけではなく、今日日のブラウザはいずれも大丈夫なようです。
Perl 5
Not OK http://ideone.com/J7kVwv
残念ながら、Perl本体のみならず、JSON::XSなどのXSも、Perl APIで文字列化していればやはりNGです。
バグレポート: https://rt.perl.org/Public/Bug/Display.html?id=127182
Perl 6
rakudoを実行できるWeb環境が見当たらなかったのでソースも。
#!/usr/bin/env perl6
use v6;
my Num $finish = Num(@*ARGS ?? @*ARGS[0] !! 2.0);
die if +$finish == 0;
my Str $format = '%.17g';
my Num $d = $finish / 2;
my Num $f = $d;
for 0..53 {
my Str $s = ~$d;
die "Round trip failed for {$d}: off by {+$s - $d}" if +$s != $d;
say sprintf "%s = 0x%sp0", $s, $d.base(16,16);
last if $d == $finish;
$f /= 2, $d += $f
}
1 = 0x1.0000000000000000p0
1.5 = 0x1.8000000000000000p0
1.75 = 0x1.C000000000000000p0
1.875 = 0x1.E000000000000000p0
1.9375 = 0x1.F000000000000000p0
1.96875 = 0x1.F800000000000000p0
1.984375 = 0x1.FC00000000000000p0
1.9921875 = 0x1.FE00000000000000p0
1.99609375 = 0x1.FF00000000000000p0
1.998046875 = 0x1.FF80000000000000p0
1.9990234375 = 0x1.FFC0000000000000p0
1.99951171875 = 0x1.FFE0000000000000p0
1.999755859375 = 0x1.FFF0000000000000p0
1.9998779296875 = 0x1.FFF8000000000000p0
1.99993896484375 = 0x1.FFFC000000000000p0
Round trip failed for 1.99996948242188: off by 5.10702591327572e-15
in block <unit> at ./dsd-test.p6 line 10
バグレポート: https://rt.perl.org/Public/Bug/Display.html?id=127184
Python 2
NOT OK https://repl.it/BcF7/0
ideoneではなぜかassertionで止まらないのでrepl.itで。
Python 3
Python 2との比較のためにこちらもrepl.itで。
コードはPython 2の場合と全く同一です。
Ruby
Swift
1.0
1.5
1.75
1.875
1.9375
1.96875
1.984375
1.9921875
1.99609375
1.998046875
1.9990234375
1.99951171875
1.999755859375
1.9998779296875
1.99993896484375
Round trip failed for 1.99996948242188: off by 5.10702591327572e-15
これが一番意外だったかも。
バグレポート: https://bugs.swift.org/browse/SR-491
NGな言語実装の問題点
桁数が足りない、これに尽きます。それもたいていの場合わずか1桁。
回避策
C99 Hexadecimal Floating Point を使う
別の記事にも詳しく書く予定ですが、C99から導入された Hexadecimal Floating Point (hexfloat)を使うと、この問題が一気に解決します。例えばπ=0x1.921fb54442d18p+1
、e=0x1.5bf0a8b145769p+1
といった塩梅です。
しかし残念ながら、登場が遅すぎてネイティブでサポートされている環境が少ないのが難点です。ああ、JSONでこれが使えたら…
とはいえ、実装は10進数の場合よりずっと簡単です。私もJS用とPerl 6用を書きました。
どちらもコメント込みで100行切ってます。そんなに難しくないのですから、各言語とももっと積極的にサポートして欲しいものです。
"%.17g" でフォーマットする
とはいえ、問題なのは明日の言語より今日のデータ。全ての浮動小数点数リテラルを、生まれる前に置き換えたい。全てのJSON、過去と未来の全ての浮動小数点数リテラルを、この手で、という願いは、ネ申Excelに対する反逆より大変そうです。
で、結論から言うと、sprintf
のフォーマットをサポートしている環境なら、"%.17g"
を使えばいいということになります。
-
Printing double without losing precisionによると、Cの
double
、IEEE 754のbinary64の精度を10進数で復元するのに必要な(仮数部の)桁数は、17桁。
-
ただしそれ以下で十分な場合、それ以下で済ませたい。なので
"%.16e"
とか"%.16f"
とかは避けたい。 -
ここで
printf
のフォーマットについておさらいすると…
double in either normal or exponential notation, whichever is more appropriate for its magnitude. 'g' uses lower-case letters, 'G' uses upper-case letters. This type differs slightly from fixed-point notation in that insignificant zeroes to the right of the decimal point are not included. Also, the decimal point is not included on whole numbers. - https://en.wikipedia.org/wiki/Printf_format_string#Type_field
とあります。実際に使ってみると、整数なら小数点なしで表示してくれますし、17桁も必要なければ端折ってくれますし、指数表記の方がコンパクトになる場合はそうしてくれます。
ここでNGな言語実装を見てみると、どうも"%.16g"
相当のことをやっているように見受けられます。特にSwiftは不思議ちゃんで、標準の文字列化メソッド(.description
)では一桁足りないのに、REPLだと
% swift
Welcome to Apple Swift version 2.1.1 (swiftlang-700.1.101.15 clang-700.1.81). Type :help for assistance.
1> import Foundation
2> log(2.0).description
$R0: String = "0.693147180559945"
3> log(2.0)
$R1: Double = 0.69314718055994529
と逆に一桁多いのです。
で、実際に"%.17g"
が使える処理系で試したのが以下のとおりです。
C: 計画通り
C++: 計画通り
Java: あれ?なんか違うかも?
- http://ideone.com/2e4Svz
- 末尾の
0
を省略してくれない。%.16f
っぽい。
Perl 5: 計画通り
- http://codepad.org/O1PzVIFZ
- お古のPerl 5.8でも動く!
Perl 6: あれ?なんか違うかも?
sprintf()
の実装自体にバグ!
#!/usr/bin/env perl6
use v6;
my $log2 = log(2);
for 0..20 {
say sprintf "%%.%dg:\t%.{$_}g", $_, $log2;
}
%.0g: 1
%.1g: 0.7
%.2g: 0.69
%.3g: 0.693
%.4g: 0.6931
%.5g: 0.69315
%.6g: 0.693147
%.7g: 0.6931472
%.8g: 0.69314718
%.9g: 0.693147181
%.10g: 0.6931471806
%.11g: 0.69314718056
%.12g: 0.69314718056
%.13g: 0.6931471805599
%.14g: 0.69314718055995
%.15g: 0.693147180559945
%.16g: 0.693147180559945
%.17g: 0.693147180559945
%.18g: 0.693147180559945
%.19g: 0.693147180559945
%.20g: 0.693147180559945
バグレポート: https://rt.perl.org/Public/Bug/Display.html?id=127201
Ruby: 計画通り
Python 2: 計画通り
- http://ideone.com/OJR8or
- Python 3でも動く!
Swift: 計画通り
-
String(format: ...)
がLinuxではNGなのでOS Xで動作確認したソースを以下に。
#!/usr/bin/env swift
#if os(OSX)
import Cocoa
#elseif os(iOS)
import UIKit
#elseif os(Linux)
import Glibc
#endif
let finish = 2.0
var d = finish / 2
var f = d
for i in 0...53 {
let s = String(format:"%.17g", d)
if Double(s)! != d {
print("Round trip failed: \(Double(s)! - d)")
break;
}
print(s)
f /= 2
d += f
}
というわけで、迷ったら"%.17g"
でフォーマットしとけというのは覚えておいても良さげです。(まあJavaやPerl 6のように、フォーマットが違ったりバグがあったりする場合を除けば)。覚えにくいのでデフォルトにしてもらいたいぐらいなのですが、[efg]
のデフォルトは6桁なのはなぜだろう…
Dan the Bit-Picking Coder