LoginSignup
11
8

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-03-15

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

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

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

2017/4/23更新 Go1.8での改善を確認しました。

1. 発生事象

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

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

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

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での改善(未確認 2017/4/23更新 Go1.8での改善を確認しました。

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

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

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))
    // 略
}

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

11
8
1

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
11
8