ちょっと前にblueskyで見かけた話題。もとは「GraphQLのスキーマではintが32ビットしかなくて、64ビット整数とかないのがイケてない」といった話だったかなと思う。直感的にはこれは「Javascriptではすべてが倍精度浮動小数点数だから64bit intがないから」ということになるが、よくよく調べてみるといろいろややこしい歴史的事情があるようだ。
たしかにJSにはもともとひとつのNumber型しかなく、いわゆるdouble型(倍精度浮動小数点)だけで数値を表現してきた。IEEE754の倍精度浮動小数点数は仮数部が52ビットあるので、実際には32ビット整数ていどであれば全て誤差なく表現できる。なので32ビット整数または倍精度浮動小数点数がどちらも使えるというふうに理解されてきた。
そうはいっても不便なので、現代のJSにはBigIntがある。ES2020で導入されたらしい。ただし普通の数値リテラルはすべてNumberであり、BigIntにするためにはnを後置する必要がある(0n
みたいに。または明示的にコンストラクタを呼ぶ)。BigIntとNumberはimplicitには変換されず、明示的に変換する必要があり、取り扱いはちょっと面倒ではある……が、存在するのは有り難い。ともあれ、BigIntはあるので「すべてがdoubleなので64ビット整数は扱えない」というのは現代では厳密にいうと正しくない。
一方、ウェブのAPIというものはだいたいJSONをフォーマットとして利用している。元々の話題であるGraphQLでもそうで、レスポンスはたいていJSONだろう。そしてJSON自体はBigIntなどが導入されるよりはるか昔に制定された規格なので、BigIntであったり整数に後置する記号(nとか)のことなんか考えられていない。このためにBigIntを利用できない。というわけでJSONは仕様上ではdoubleしかない、というふうに理解・整理できそうではある。
……かと思いきや、実はJSONの仕様上では数値の桁数などに制限はない。あくまでも表記的なフォーマットの話だから、数値データでは数字をいくらでも連ねてもよい。つまりフォーマットとしては決してdoubleに縛られているわけではない。
がしかし、その数値データをどのようにデコードするかというロジックはデコーダ側の実装にゆだねられている。JavascriptではJSON.parseによってデコードするのが普通だが、ECMAscriptの仕様で定義されるJSON.parseの挙動によればJSONの数値はすべてNumberになることを定めているので、どれだけ大きな桁数の数値であってもけっきょくはdoubleの精度までしか扱えないし、BigIntにはできない、ということになる。
つまるところ、「JSONは昔のJSの仕様がもとになっているのでBigIntのことなんて知らんし」「JSにおけるJSONの扱いにおいてはBigIntのことはなかったことになっている」ということになっている。お互いがそれぞれ新しい状況に対応できていない感じ。
どうにかならないもんだろうか。ちょっと考えてみる。
案1:ECMAscriptのJSON.parseの仕様を改めdoubleで表現しうる以上の整数はBigIntにデコードするように変更する。ただこれをしてしまうと、値の大きさによってBigIntかNumberか型が変わってしまうので、デコード結果を扱うのが面倒になる。先述したようにJSではBigIntとNumberはimplicitには変換しない方針なのも大きい。小さな値のBigIntを含むオブジェクトをJSON.stringifyしたものをJSON.parseすると違うデータになる(Numberになっちゃうから)という問題もある。
案2:JSONの仕様を変更してnのような後置を認める。これはJSの利用者からすると有り難いが、世界中に無数にあるいろんなプログラミング言語のJSONライブラリとの互換性が崩れるという難しい問題がある。互換性の問題が発生してしまう。新しい仕様への対応を済ませてサーバ側が新しいフォーマットを返すようにしたら古いクライアントで動作しなくなるといったことも起こる。
というわけでなかなか簡単な解決策はないのであった。sigh。
JSON5みたいな類似フォーマットで対応できてないのかな、と思ってみたが、JSON5も不可であった。BigInt登場以前からあるからかな。
YAMLの場合にはBigIntに対応できている。そしてYAMLはJSONの厳密な拡張である(つまり、正当なJSONは正当なYAMLである)。なのでJSON.parseのかわりにYAML.parseすることでBigIntにできる可能性はある。npmのyamlパッケージだと intAsBigInt というオプションがあって、これで全部BigIntにできるようだ。ただし、あまりにもトリッキーなので実用することはなさそうな気がする。
もちろん全く他のシリアライゼーションフォーマットを採用するという手もある。たとえばBSONにも64bit整数はあるし、messagepackにもある。毛色は違うがprotocol buffersにもある。ちなみに、protobufにはJSONデコーダ/エンコーダもあるが、protobuf/jsonは64ビット整数を文字列にエンコードしている。スキーマがあるので、どの文字列を64ビット整数としてデコードしてほしいかがわかっているので、こういうことができる。ただ、調べたかぎりでは任意長のいわゆるBigIntをサポートするフォーマットはなさそうだ。言語によってはBigIntが外部ライブラリになってしまったりするからね、C++とか……。
けっきょくいちばん現実的なのは「64ビット整数やBigIntはネイティブにはサポートせず文字列にする。適宜文字列からBigIntなどに変換するロジックを持つ」という一種のstatus quoなのだな。変換は場合によってはわりと面倒なのでフレームワークが適切に対応してくれないと厳しいが、そのためには「どの文字列はBigIntであるべきか」という情報、つまりスキーマ定義が必要になってしまうので自動化しづらい。TypeScriptの型情報とかをうまく使ったりできたら面白いんですけどね。できるのか知らないけど。
それでいうと、元の話題にもどるとGraphQLではスキーマがあるのだからint64を持っていてBigIntで取り扱うということはできそうではある。ただそうするとエンコーダ・デコーダに専用のライブラリが必要になってしまうのが痛いという判断なのかもしれない。
ところで、JavascriptのJSON.stringifyはBigIntをエンコードできない、というのはこの記事を書くために手元で試すまで知らなかった。BigInt……めんどうくさい……。