この記事ではAWS ec2上のCentOS7にインストールしたgo 1.7を使っています。
Go言語で簡単なJSON文字列〜内部構造体の変換を行うと、整数が10進数6桁しか表現できません。本記事では、この事象〜原因〜回避策について紹介したいと思います。
なお、Goのライブラリソースを見ると、Go1.8からは状況が改善されているようです。(未確認)
2017/4/23更新 Go1.8での改善を確認しました。
1. 発生事象
JSON文字列 {"key": 数値} を受け取って、内部構造体に変換し、再度JSON文字列に逆変換するプログラムを作成します。
package main
import (
"os"
"fmt"
"bytes"
"reflect"
"encoding/json"
"io/ioutil"
)
// https://play.golang.org/p/WHRgvgrsG4
func unmarshalUseNumber(b []byte, i interface{}) error {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
if err := dec.Decode(i); err != nil {
return err
}
if dec.More() {
remaining, _ := ioutil.ReadAll(dec.Buffered())
return fmt.Errorf("unexpected values after JSON element %q", remaining)
}
return nil
}
func marshaltest(jsonstring1 string, uselongint bool) string {
fmt.Println("INPUT :", jsonstring1)
// unmarshal
var jsonstruct interface{}
if uselongint {
unmarshalUseNumber([]byte(jsonstring1), &jsonstruct)
} else {
json.Unmarshal([]byte(jsonstring1), &jsonstruct)
}
if jsonstruct == nil {
fmt.Println("Unmarshal failed !!")
return ""
}
// print struct information
numbervalue := jsonstruct.(interface{}).(map[string]interface{})["key"]
fmt.Print("Unmarshaled:", numbervalue)
fmt.Println(" Type:", reflect.TypeOf(numbervalue))
// marshal
jsonstring2, err := json.Marshal(jsonstruct)
if err != nil {
panic(err)
}
fmt.Println("OUTPUT:", string(jsonstring2))
return string(jsonstring2)
}
func main() {
// setup numberstring & jsonstring
var numberstring = "null"
if len(os.Args) >= 2 {
numberstring = os.Args[1]
}
var USE_LONGINT = false
if len(os.Args) >= 3 {
USE_LONGINT = true
}
_ = marshaltest("{\"key\":" + numberstring + "}", USE_LONGINT)
}
なお、Go言語で、構造体からバイト列への変換をマーシャル(marshal)、逆変換をアンマーシャル(unmarshal)と呼びます。聞きなれない言葉ですが、マーシャル=整列、という意味ですので、よく使われる「シリアライズ」と、同じ意味だと思います。今回のプログラムでは、コマンドラインで受け取った数値をもとにJSON文字列を組み立て、それをアンマーシャルし、その結果をマーシャルして元に戻すことを試みて、途中経過を標準出力します。
実行結果は、以下のようになり、10進数6桁を超える数値(±100万)に対しては、OUTPUTが指数表現になってしまいます。
$ go run marshal.go 999999
INPUT : {"key":999999}
Unmarshaled:999999 Type: float64
OUTPUT: {"key":999999}
$ go run marshal.go 1000000
INPUT : {"key":1000000}
Unmarshaled:1e+06 Type: float64
OUTPUT: {"key":1e+06}
$ go run marshal.go -999999
INPUT : {"key":-999999}
Unmarshaled:-999999 Type: float64
OUTPUT: {"key":-999999}
$ go run marshal.go -1000000
INPUT : {"key":-1000000}
Unmarshaled:-1e+06 Type: float64
OUTPUT: {"key":-1e+06}
2. 原因
2.1 float64の利用は原因ではない
実行結果をよく見ると、全てTypeがfloat64になっています。すなわち、Go言語でJSONを扱おうとすると、内部構造体にアンマーシャルした際に、数値がfloat64になってしまうということです。
なお、内部構造体を厳密に型指定してint等を使って構造体を定義してから変換すれば、この事象は回避可能です。しかし、それでは、一般の自由なJSONを簡単かつ統一的に扱うことは困難です。そこで、Go言語では、自由なJSONを簡単に扱う場合に、interface{}という言語仕様を使います。この、interface{}を使うと、数値の型は一律にfloat64として扱われてしまいます。
実は、JSON本家のJavaScriptでは、この場合に限らず整数型というのは厳密には存在せず、内部表現としては全てfloat64になっているそうです。float64の仮数部は52bitありますので、10進数15桁くらいの整数は表現できそうです。よって、今回の問題である10進数6桁しか表現できないことに対して、float64が使われていることは原因ではありません。
2.2 float64の表現が原因
float64を利用することは原因ではないとすると、float64をJSON文字列に戻すマーシャルが悪さをしている可能性があります。
よって、マーシャルの中で、10進数6桁までと7桁以上で、整数表現と指数表現を切り分けている箇所を探してみました。結果としては、マーシャル関連のソースである、{Goインストールディレクトリ}/src/encoding/json/encode.go の中のfloatEncoderという型の操作で、float64の表現を作っており、そこに問題がありました。
type floatEncoder int // number of bits
func (bits floatEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
f := v.Float()
if math.IsInf(f, 0) || math.IsNaN(f) {
e.error(&UnsupportedValueError{v, strconv.FormatFloat(f, 'g', -1, int(bits))})
}
b := strconv.AppendFloat(e.scratch[:0], f, 'g', -1, int(bits))
if opts.quoted {
e.WriteByte('"')
}
e.Write(b)
if opts.quoted {
e.WriteByte('"')
}
}
ソースの該当箇所を見ると、AppendFloatを、fmt='g'でコールしていることがわかります。このfmt='g'の意味は、「数値が大きい時は指数表現を、小さい時は整数表見をする」というもので、原因の可能性が高いです。
実際に、AppendFloatと同等の動きをするFormatFloatにおいて、fmt='g'でテストしみた結果、±100万を境に、整数表現と指数表現が切り替わることがわかりました。
package main
import (
"fmt"
"strconv"
)
func main() {
fmt.Println(strconv.FormatFloat(999999, 'g', -1, 64))
fmt.Println(strconv.FormatFloat(1000000, 'g', -1, 64))
fmt.Println(strconv.FormatFloat(-999999, 'g', -1, 64))
fmt.Println(strconv.FormatFloat(-1000000, 'g', -1, 64))
}
$ go run fmtfloat.go
999999
1e+06
-999999
-1e+06
2.3 謎仕様の理由を推測
この謎仕様の理由を推測してみました。
floatの中で整数を表現する、という手法において、32bit時代はfloat32を利用していました。float32の仮数部は23bitであり,10進数で±約1600万までが表現できます。計算誤差が出ないように1桁余裕を確保して、±100万より内側を、整数として使うようにしたのかもしれません。
3. 当面の回避方法
マーシャル/アンマーシャルには、ローレベルの関数としてエンコード/デコードを使っており、デコードにおいてUseNumberを有効にすると、数値を、float64ではなく、Number型に変換します。Number型は、実態はstringであるとともに、int、float64、stringへのキャストをすることで、様々な表見の数字を統一的に扱えるようになっています。
さらに、アンマーシャルと互換性がありそのまま置き換えられる形で、ローレベル関数を隠蔽したunmarshalUseNumber関数が https://play.golang.org/p/WHRgvgrsG4 に公開されています。これを予め、冒頭のプログラムmarshal.goに組み込んであります。
これを実行すると、内部的にjson.Number型(実際には文字列型)で数値が管理され、整数表現での桁数の制約が取り除かれます。(int64の範囲を超えると、intにキャストできなくなりますので、整数としての内部的な利用はできなくなると思います)
以下に実行結果を示します。±100万はもちろんのこと、int64の範囲をはるかに超える整数も表現ができてます。(float64ではない証拠として、下1桁を1として、丸め誤差が無いことを確かめています)
$ go run marshal.go 1000000 true
INPUT : {"key":1000000}
Unmarshaled:1000000 Type: json.Number
OUTPUT: {"key":1000000}
$ go run marshal.go -1000000 true
INPUT : {"key":-1000000}
Unmarshaled:-1000000 Type: json.Number
OUTPUT: {"key":-1000000}
$ go run marshal.go 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001 true
INPUT : {"key":1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001}
Unmarshaled:1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001 Type: json.Number
OUTPUT: {"key":1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001}
なお、今回は省略しますが、mgoを通してMongoDBに、このjson.Number型を含むJSONをBSONシリアライズしてインサートすると、int64を優先して登録されるようです。(通常のfloat64の場合は、もちろんMongoDBにはfloat64で登録されます)
4. Go1.8での改善(未確認 2017/4/23更新 Go1.8での改善を確認しました。)
最新のGo1.8においては、問題のencode.goの実装が変わっています。
整数において、絶対値が10の21乗以上であれば指数表現を、それ未満であれば整数表現を使うように変更されています。なお、20桁近い数値は、float64の仮数部の表現範囲を超えていますので、大きな整数(実態はfloat64)では正確な記録ができずに、誤差が発生すると思われます。
type floatEncoder int // number of bits
func (bits floatEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
f := v.Float()
if math.IsInf(f, 0) || math.IsNaN(f) {
e.error(&UnsupportedValueError{v, strconv.FormatFloat(f, 'g', -1, int(bits))})
}
b := e.scratch[:0]
abs := math.Abs(f)
fmt := byte('f')
if abs != 0 {
if bits == 64 && (abs < 1e-6 || abs >= 1e21) || bits == 32 && (float32(abs) < 1e-6 || float32(abs) >= 1e21) {
fmt = 'e'
}
}
b = strconv.AppendFloat(b, f, fmt, -1, int(bits))
// 略
}
以降、2017/4/23更新
実際に、Go1.8に更新して、実行結果を見てみます。
$ go version
go version go1.8 linux/amd64
$ go run marshal.go 999999
INPUT : {"key":999999}
Unmarshaled:999999 Type: float64
OUTPUT: {"key":999999}
$ go run marshal.go 1000000
INPUT : {"key":100000}
Unmarshaled:100000 Type: float64
OUTPUT: {"key":1e+06}
$ go run marshal.go -999999
INPUT : {"key":-999999}
Unmarshaled:-999999 Type: float64
OUTPUT: {"key":-999999}
$ go run marshal.go -1000000
INPUT : {"key":-1000000}
Unmarshaled:-1e+06 Type: float64
OUTPUT: {"key":-1000000}
$ go run marshal.go 1e20
INPUT : {"key":1e20}
Unmarshaled:1e+20 Type: float64
OUTPUT: {"key":100000000000000000000}
$ go run marshal.go 1e21
INPUT : {"key":1e21}
Unmarshaled:1e+21 Type: float64
OUTPUT: {"key":1e+21}
$ go run marshal.go 100000000000000000000
INPUT : {"key":100000000000000000000}
Unmarshaled:1e+20 Type: float64
OUTPUT: {"key":100000000000000000000}
$ go run marshal.go 100000000000000000001
INPUT : {"key":100000000000000000001}
Unmarshaled:1e+20 Type: float64
OUTPUT: {"key":100000000000000000000}
実際に、20桁まで整数表現ができるようになっています。また、20桁だと誤差が出ることがわかります。
なお、誤差のため、20桁の全ての数値が整数表現できるわけではないことを、注意しておく必要があります。例えば、1e21-1は本来20桁ですが、誤差のため1e21と見なされてしまいます。
ちなみに,FormatFloat単独の動作は、Go1.8でも変わっていませんでした。マーシャルでの実行結果が変わるのは、encode.goの中で、AppendFloatをコールしなくなったためだとわかります。