LoginSignup
35
34

More than 5 years have passed since last update.

"%.17g" = doubleの出力をポータブルにする魔法の呪文?

Last updated at Posted at 2016-01-06

遅まきながら、明けましておめでとうございます。

諸般の事情で浮動小数点と取っ組み合っています。

懸命な読者であれば、浮動小数点表記は"eyes only"なものであり、データの交換に用いるのは慎むべきであることはご存知かと思われますが、実はそうも言ってられなくなっていることに気づいた人はどれくらいいらっしゃるのでしょうか…

そう。JSONです。そのままJavaScriptでevalできちゃう、RFC7159で規定された、今や世界で最も利用されているであろうデータ交換フォーマット。

JavaScriptでevalできちゃうだけあって、数値はJavaScriptの数値リテラルそのものです。

Hexadecimal Floating Pointなんてお洒落なものは使えません(Hex Floatについては記事を改めて)。

つまり、Cにおけるdouble、JavaScriptにおけるNumberを、復元できる形で文字列化する必要性は、JSON以前と以後では1e-641e+64になるぐらい変わっているのです。

ところで、今やほとんどの言語は、浮動小数点数をそのまま文字列化できます。「%...eだっけfだっけgだっけ?桁数指定はいくつが適切か…」とか悩まなくとも、print(number)できちゃうのです。しかしその文字列を読み込んだ時、元の値を復元できるでしょうか?

調べてみた

  • 考え方としては、1bitづつ仮数部を増やしていき、それを文字化した後数値化して、元の数値と一致すればOK、不一致が出たらNGです。

  • 文字列変換はなるべく暗示的に行ってます。なので暗示的な文字列変換を持たないCは除外しています。C++のiostreamは暗示的な文字列変換をやってくれはしますが、ここで確認したいのは「ストリーム」ではなく「バッファー」なのでこれも除外。

  • ideonerepl.itIBM Swift Sandboxなど、Webで実行可能な環境がある場合はリンクしてあります。

Java

OK http://ideone.com/OSeb5S

JavaScript

OK http://ideone.com/qonDEO

さすが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

OK https://repl.it/BcFb/0

Python 2との比較のためにこちらもrepl.itで。

コードはPython 2の場合と全く同一です。

Ruby

OK http://ideone.com/BrfiBO

Swift

Not OK http://swiftlang.ng.bluemix.net/#/repl/937bf49dd2f110c6f4c0fd942de051d734b3e051887b1c6b762c39e2c7956146

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+1e=0x1.5bf0a8b145769p+1といった塩梅です。

しかし残念ながら、登場が遅すぎてネイティブでサポートされている環境が少ないのが難点です。ああ、JSONでこれが使えたら…

とはいえ、実装は10進数の場合よりずっと簡単です。私もJS用とPerl 6用を書きました。

どちらもコメント込みで100行切ってます。そんなに難しくないのですから、各言語とももっと積極的にサポートして欲しいものです。

"%.17g" でフォーマットする

とはいえ、問題なのは明日の言語より今日のデータ。全ての浮動小数点数リテラルを、生まれる前に置き換えたい。全てのJSON、過去と未来の全ての浮動小数点数リテラルを、この手で、という願いは、ネ申Excelに対する反逆より大変そうです。

で、結論から言うと、sprintfのフォーマットをサポートしている環境なら、"%.17g"を使えばいいということになります。

  • Printing double without losing precisionによると、CのdoubleIEEE 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: あれ?なんか違うかも?

Perl 5: 計画通り

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: 計画通り

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

35
34
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
34