Go言語でJSON内の整数は10進数6桁しか表現できない

この記事ではAWS ec2上のCentOS7にインストールしたgo 1.7を使っています。

Go言語で簡単なJSON文字列〜内部構造体の変換を行うと、整数が10進数6桁しか表現できません。本記事では、この事象〜原因〜回避策について紹介したいと思います。

なお、Goのライブラリソースを見ると、Go1.8からは状況が改善されているようです。~~（未確認）~~

<font color="Red">2017/4/23更新 Go1.8での改善を確認しました。</font>

# 1. 発生事象

JSON文字列 {"key": 数値} を受け取って、内部構造体に変換し、再度JSON文字列に逆変換するプログラムを作成します。

```go:marshal.go
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の表現を作っており、そこに問題がありました。

```go:encode.go抜粋
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万を境に、整数表現と指数表現が切り替わることがわかりました。

```go:fmtfloat.go
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での改善（~~未確認~~ <font color="Red">2017/4/23更新 Go1.8での改善を確認しました。</font>）

最新のGo1.8においては、問題のencode.goの実装が変わっています。

整数において、絶対値が10の21乗以上であれば指数表現を、それ未満であれば整数表現を使うように変更されています。なお、20桁近い数値は、float64の仮数部の表現範囲を超えていますので、大きな整数（実態はfloat64）では正確な記録ができずに、誤差が発生すると思われます。

```go:encode.go抜粋
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))
	// 略
}
```

<font color="Red">以降、2017/4/23更新</font>

実際に、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をコールしなくなったためだとわかります。