🔢

新しくプログラミング言語を作る際に数値型をどうするべきか

2023/02/27に公開

この記事は、新しくプログラミング言語を設計する際に数値型をどうするべきかについて、私の持論をまとめたものです。

数の体系

JavaScript(BigInt以前)やLua(〜5.2)などは唯一の数値型が浮動小数点数型で、整数も実数も同じ「number」型で表現します。ミニマルな言語を作るのならそういう設計もアリかもしれませんが、ネイティブコンパイルも視野に入る実用的な言語を作るなら整数と実数を一緒くたにする設計はやめた方が良いと思います。

特に、JavaScriptにコンパイルする言語を作るからと言って、数値型の設計まで真似る必要はありません。

整数を浮動小数点数で表現すると、思わぬ性能低下の要因になったりします。最近(2023年2月)、次のツイートが話題になりました:

https://twitter.com/mhevery/status/1626002464469323777

これは正のゼロと負のゼロが値として区別され、正のゼロは内部的に整数扱いされるのに対し負のゼロはそうではないことによるもののようです。こういう問題が起きるのは高度な最適化を実装した処理系ならではかもしれませんが。

整数型と浮動小数点数型の他に数値型を実装するなら、有理数、複素数などが考えられます。Schemeは整数、有理数、実数(浮動小数点数)、複素数の階層を持っており、numerical towerと呼んでいます。リッチな数値型を持たせたい場合は参考になるかもしれません。

安直に「number」という名前を使わない

数値型に安直に「number」型という名前を付けるべきではありません。整数型ならinteger、実数の浮動小数点数ならfloatやreal、複素数ならcomplexなど、どういう「数」を表すのかわかる名前を付けるべきです。

悪い例として、PureScriptは浮動小数点数型を Number と呼んでいます。Number 型とは別に Int 型があるので、 Number 型は本当に浮動小数点数を想定する型であり、 Float とか Float64 とか Double とか名付けられるべきだった代物なのです。

整数のオーバーフローの挙動

固定長整数を提供する場合はオーバーフロー時のことを考えなくてはなりません。選択肢としては、以下が考えられます:

  • 例外を発生させる
    • 例:Standard MLの符号付き整数
  • wrap aroundする
    • 例:Java, Lua 5.3
  • 浮動小数点数に変換する
    • 例:PHP
    • JavaScriptなどの「通常の整数も浮動小数点数として扱う」はこれの亜種と言えるかもしれません。
  • 未定義動作とする
    • 例:Cの符号付き整数
  • そもそも固定長整数を提供せず、多倍長整数を常用する
    • 例:Ruby, Python

教育的に良いのは「例外を発生させる」か「多倍長整数を常用する」かと思います。

足し算、引き算、掛け算がオーバーフローしうることは容易に想像がつきますが、割り算や単項マイナス、絶対値もオーバーフローを起こす可能性があります。2の補数表現での最小値が絡む場合です。

wrap aroundするような演算をC言語で実装する場合は、未定義動作を回避するようにすべきです。具体的には、一旦符号なし整数にキャストしたり、条件分岐で個別にチェックします。Lua 5.3の実装を参考にすると良いかもしれません。

浮動小数点数

言語処理系を作成する際は、ある程度浮動小数点数の知識が必要です。

最適化

数学的な実数を想定して最適化を実装すると、プログラムの観測可能な挙動が変化してしまう可能性があります。例えば、 x + 0.0x に変換してしまうと、 x が負のゼロやsignaling NaNの時に結果が変わってしまいます。

GCCの浮動小数点数の最適化に関するルールの一部は以下にまとまっています:

まあ、あれもこれも考えていたらほとんどの最適化ができなくなるので、「signaling NaNは無視する」というような方針を採用するのもいいかもしれません。signaling NaNを無視するのであれば x * 1.0x にしたり、x - 0.0x にしたりする最適化が可能になります。

ドメイン特化言語であれば浮動小数点数の細かい性質は無視してガンガン最適化するのもアリでしょう。ディープラーニングの用途でゼロの符号とか気にしても仕方がなさそうです(再現性を気にするならともかく)。

ゼロの符号

負のゼロを考慮し忘れると、問題が起きる可能性があります。プログラムのオブジェクトコードの定数テーブルを出力する際に、値の同値性を判定するために == を使うと、正のゼロと負のゼロが共通化されてしまうのです。MinCamlにはこの問題があります:

ゼロの符号について最低限知っておくべき性質をいくつか挙げておきます:

  • よくある等価演算子 == では等価として扱われます:0.0 == -0.0
  • 逆数やatan2などに与えると違いを観測できます:1.0 / 0.0 != 1.0 / (-0.0)
    • よって、正負のゼロを混同した最適化やコード生成を行うと、プログラムの振る舞いが変化する可能性があります。
  • 負のゼロはゼロとの乗除算 (-1) * 0 やアンダーフロー (-1e-300) * 1e-300 や符号反転で生成される可能性があります。通常の丸めモードでは、足し算や引き算の結果として負のゼロが現れるのは入力に負のゼロが含まれる場合に限ります。

複素数

浮動小数点数で構成される複素数を実装する際に、数学的な定義の式の通りに計算すると望ましくない結果が得られます。

例えば、 1e300 + 1e300i の絶対値を計算するときに sqrt(x * x + y * y) で計算してしまうと、本来の結果は 1.4142135623730952e+300 と、倍精度浮動小数点数の範囲で表せるにも関わらず、途中の x * x がオーバーフローするため無限大が返ってきてしまいます。不必要なオーバーフロー・アンダーフローです。絶対値は hypot 関数で計算するとこの問題を回避できます。

また、 1 / (1e300 + 1e300i) を計算する時に定義式の通りに (x - yi) / (x * x + y * y) とした場合も同様です。

別の問題として、 (inf + inf i) * (1 + 0 i) を計算するとき、結果は何らかの無限大になってほしいですが、素朴に乗算を実装すると実部、虚部ともにNaNになってしまいます。

この辺の話題については、以前私のブログで取り上げました:

ここに書いた問題の対策を行うには、多少のコストがかかります。計算速度が重要な言語の場合は、プログラマーの裁量で使える「数学的な定義の式で計算するモード」を用意すると良いかもしれません。C言語には #pragma STDC CX_LIMITED_RANGE という機能があります。

min / max

浮動小数点数の小さい方・大きい方を返す関数 min / max の仕様を決める際には、2点ほど注意事項があります。

まず、ゼロの符号を扱うか、無視するかです。-0 < +0 とする、つまり min(-0, +0) = min(+0, -0) = -0, max(-0, +0) = max(+0, -0) = +0 とするのが綺麗ですが、素朴に実装してしまうとこの性質を満たしません。

次に、NaNをどうするかです。大きく分けると「NaNを伝播させる」「NaNを入力の欠落として扱い、NaNではない入力があればそれを返す」の二つの流儀があります。素朴に実装してしまうとこのいずれの性質も満たしません。

詳しくは以前記事を書きました:

十六進表記

最近のIEEE 754では浮動小数点数の十六進表記が規定されています。例えば 0xcafe.cafep0 というやつです。なので、整数リテラルの直後にドットによるメンバーアクセスを書けるようにしてしまうと、後から浮動小数点数の十六進表記を導入したくなった時に困ります。

既存のいくつかの言語について、整数リテラルの直後にメンバーアクセスを書けるかどうか紹介しておきます。

Luaはそもそもリテラルに対して直接メンバーアクセスできず、カッコで囲う必要があります("%.5f":format(math.pi) とは書けず、 ("%.5f"):format(math.pi) と書く必要がある)。なので、Lua 5.2で十六進浮動小数点数リテラルを導入しても互換性は壊れませんでした。

JavaScriptは基本的にリテラルに対してメンバーアクセスできますが、字句解析の都合で十進整数リテラルの直後にはドットによるメンバーアクセスを書けません。字句解析の都合なので、スペースを空ければ大丈夫です(1.toString() はダメだが 1 .toString() はOK。また、1.0.toString()1..toString() もOK)。一方、十六進整数リテラルの直後にはドットによるメンバーアクセスが書ける(0xcafe.cafep00xcafe["cafep0"] と等価)ので、十六進浮動小数点数リテラルを導入すると互換性が壊れてしまいます。

Perlは十六進浮動小数点数リテラルに対応している上に、十六進浮動小数点数リテラルの文法に合致しない場合は整数リテラルの直後にドットを連ねたものとして扱われます。つまり、print 0x42.cafep066.7929382324219 を出力しますが、 print 0x42.cafe66cafe を出力します(Perlの . は文字列連結演算子です)。字句解析の際に先読みを行なっていると考えられます。

十進浮動小数点数のサポート

教育用途を考慮する言語を作る際は、十進浮動小数点数をサポートできないか考えるべきです。通常の二進浮動小数点数は十進小数をうまく扱えない、いわゆる「0.1 + 0.1 + 0.10.3 にならない」問題で悪名高く、教育者・学習者にそれを回避する手段を与えることは価値があると考えられます。

もっぱら教育目的がメインの言語であれば、デフォルトの浮動小数点数型を十進にしても良いかもしれません。

自分で実装するのが大変だったら、mpdecimalあたりを使うのが良さそうです。CPythonもmpdecimalを使っているようです。

おまけ:配列のサイズと int

配列のサイズやインデックスはどういう型で指定されるべきでしょうか。CやC++は size_t という専用の型を持っていますが、他の言語では int 型を配列のサイズやインデックスにも使うものも多いです。

int 型のサイズが言語仕様で決まっておらず、実行環境によって32ビットか64ビットか決まる場合は良いでしょう。int 型のサイズが64ビット固定の場合でも、(当面は)問題ないでしょう。

しかし、言語仕様で int を32ビットに固定しつつ、それを配列のサイズやインデックスにも使うような言語の場合は問題が生じます。そう、64ビット環境で扱える配列のサイズが不必要に制限されてしまうのです。

具体的には、Javaや.NETがこの問題に引っ掛かっています。.NETには LongLength みたいなプロパティーが用意されているようですが、どっちみち旧来のコードでは大きな配列を扱えないことになります。

追記:TypeScriptとnumber型

「JavaScriptにコンパイルする言語を作るからと言って、数値型の設計まで真似る必要はありません。」と書きましたが、JavaScriptにコンパイルする著名な言語の中に、数値型を number(と bigint)だけにしているものがあります。そう、TypeScriptです。

JavaScriptにコンパイルするという前提からすると数値型を number だけにするという選択は自然にも見えますが、そもそもこの前提は妥当なのでしょうか。成功した言語は別のコンパイルターゲットも増やしたくなるものです。ScalaやKotlinはJVM言語として始まりましたが、今はJavaScriptやネイティブコードを生成するバックエンドも持っています。PureScriptはaltJSとして始まりましたが、非公式には(C++等の言語を経由して)ネイティブコンパイルする派生版もあるようです。同じように、TypeScriptをネイティブコンパイル(AOTコンパイル)するバックエンドを追加できないものでしょうか。

TypeScript(あるいはそのサブセット)のネイティブコンパイルを夢想した時、真っ先に障害になるのが「number 型をどうするか」という問題です。普通に浮動小数点数を使って表現すると、CPUの整数演算器を使えません。また、整数に変換する際の処理(ToInt32, ToUint32)にも(Armの一部のCPU以外では)コストがかかるでしょう。

現在の高速なJavaScript処理系では、32ビット整数として表現できる数値は内部的に32ビット整数(あるいは31ビット整数?)として表現する最適化を実装しています。その表現方法をAOTコンパイルでも使おうとすると、 number 型の演算のたびに条件分岐を入れる必要があって大変そうです。

私は実際に実験したわけではないのでこの辺の話はあくまで想像です。誰か試した人がいたら教えてください。

まあ、TypeScriptの number 型の問題がなかったとしても、AOTコンパイルのためには不健全な型システムや構造的部分型をどうするかという問題が待ち受けているので、どっちみちネイティブコンパイルはうまくいかないのかもしれません(なのであくまで「夢想」です)。それでも、数値型が number になっていることの弊害はあると思います。

ちなみに、TypeScriptの少し前に登場したDartは intdouble を分けており、ネイティブコンパイルができるようです。int はネイティブコンパイルの際には64ビット整数となり、JavaScriptにコンパイルする際には浮動小数点数を使うようです。「ネイティブコンパイルができる」要因としてはクラスベースであることや型の健全性に関するものもあるとは思いますが。

また、TypeScriptの派生言語としてAssemblyScriptがありますが、これも整数型と浮動小数点数型を分けています。

Discussion