数値と精度¶
JavaScript の数値は、言語仕様上ただ一種類 — 倍精度浮動小数点数(IEEE 754 の 64 ビット)です。整数も小数も、配列インデックスもビット演算の結果も、すべてこの一つの型で表現されます。この「単一の浮動小数点型ですべてをまかなう」という設計は言語を単純にする一方で、精度・符号・特殊値の扱いに独特の落とし穴を生みます。エンジンはこれらを仕様どおりに再現しなければなりませんが、素朴にホスト言語の数学ライブラリへ委ねると、細部で仕様と食い違うことがあります。この章では、浮動小数点数の精度と特殊値、符号付きゼロ、除算と剰余の符号、文字列との相互変換、そして任意精度整数 BigInt の実装を扱います。値をメモリ上でどう表現するか(NaN-boxing・整数ファストパス)は第I部「値の表現」で扱ったので、ここでは繰り返さず、演算と変換の側に焦点を当てます。
言語の数値としての IEEE 754¶
倍精度浮動小数点数は 64 ビットを符号 1 ビット・指数部 11 ビット・仮数部 52 ビットに分けて実数を近似します。表現できるのは有限個のビットパターンだけなので、実数直線上の値の大半は「もっとも近い表現可能な値」に丸められて格納されます。ここから、実装者が必ず理解しておくべき三つの性質が導かれます。
精度は相対的である¶
仮数部が 52 ビット(暗黙の先頭 1 を加えて有効 53 ビット)であるため、精度はおよそ十進 15〜17 桁です。重要なのは、この精度が絶対値ではなく相対値で決まる点です。値が大きくなるほど、隣り合う表現可能な値の間隔(ulp)も広がります。したがって「小数だから誤差が出る」のではなく、「どんな大きさの値でも一定の相対誤差を伴う」と理解するのが正確です。
安全な整数の範囲¶
整数については、絶対値が 2^53 以下であれば一つ残らず正確に表現できます。この境界を超えると、隣り合う整数の間隔が 2、4、… と広がり、連続する整数を区別できなくなります。仕様はこの境界を Number.MAX_SAFE_INTEGER(2^53 − 1)として公開しています。
「安全」とは、その整数自身が正確に表せるだけでなく、他のどの浮動小数点整数もその値に丸め込まれてこない、という意味です。桁数の大きい ID・カウンタ・通貨の最小単位などをこの範囲を超えて Number で扱うと、静かに値が壊れます。これが後述する BigInt が必要になる典型的な動機です。
なぜ 0.1 + 0.2 !== 0.3 なのか¶
十進の 0.1・0.2・0.3 は、いずれも二進では無限に循環する小数です。二進で有限桁に収まるのは分母が 2 の冪の分数だけであり、1/10 はそうではないからです。したがって格納される時点で、0.1 も 0.2 も「もっとも近い表現可能な二進小数」に丸められています。その二つを加えた結果は、0.3 を丸めた値とはビット単位で一致しません。
これはエンジンのバグではなく IEEE 754 の必然です。エンジンにできるのは、この加算を仕様どおり(正しく丸めた倍精度加算)に行うことだけで、結果を「見た目の十進」に合わせて勝手に補正してはいけません。等値比較にはイプシロン(許容誤差)を用いる、あるいは値を整数スケールで扱う、といった対処は利用側の責務です。
符号付きゼロ¶
IEEE 754 には +0 と -0 の二つのゼロがあります。多くの場面で両者は等しく振る舞い(+0 === -0 は true)、区別を意識せずに済みますが、エンジンは区別が観測される箇所では -0 を保存し、正規化してはならない箇所とを区別しなければなりません。
-0 が生じるのは、負の値に 0 を掛けたとき、負の小さな値がアンダーフローしたとき、-0 リテラルなどです。観測可能な差は主に次の箇所に現れます。
| 操作 | +0 |
-0 |
備考 |
|---|---|---|---|
1 / x |
+Infinity |
-Infinity |
逆数で符号が露見する |
Object.is(x, -0) |
false |
true |
厳密等価では区別できない |
x === 0 |
true |
true |
=== は区別しない |
Math.sign(x) |
+0 |
-0 |
符号を保って返す |
String(x) |
"0" |
"0" |
文字列化では消える |
Math.round・Math.floor 等 |
符号を伝播 | 符号を伝播 | 結果ゼロ時に -0 を返しうる |
実装上の要点は、演算結果のゼロに正しい符号を付けること、そして Object.is・1/x・Math 系のように符号を観測する経路で -0 を握りつぶさないことです。逆に、Map/Set のキー比較(SameValueZero)では -0 と +0 を同一視する必要があり、こちらは正規化側の要求になります。同じ「ゼロ」でも文脈ごとに要求が逆になるため、比較アルゴリズムを SameValue(Object.is)・SameValueZero・厳密等価で取り違えると、-0 の扱いがずれます。
特殊値と、ホストライブラリとの食い違い¶
NaN・±Infinity・±0 が絡む演算には、ECMAScript が細かく定めた規則があり、その一部はホスト言語の標準数学ライブラリの挙動と一致しません。素朴にホストの関数へ委譲すると、この差が仕様違反として表面化します。
べき乗と NaN 指数¶
もっとも有名な食い違いが指数演算(** / Math.pow)です。ECMAScript の Number::exponentiate は「指数が NaN なら、底が何であっても結果は NaN」と定めています。とりわけ底が 1 の場合が要注意です。
ところが C 言語の pow は、IEEE 754 / C 標準の規定により pow(1, y) を「y が NaN でも 1 を返す」と定めています。同様に pow(1, ±Infinity) も C では 1 ですが、ECMAScript では底が 1 でも指数が ±Infinity なら NaN です。つまりホストの pow をそのまま呼ぶと、1 ** NaN が 1 になってしまい仕様に反します。エンジンは指数演算の入口で NaN・±Infinity・±0・±1 といった特殊ケースを仕様の規則で先に捌き、一般ケースだけをホストへ委ねる必要があります。
主な差異を整理すると次のようになります。
| 式 | ECMAScript | C の pow |
|---|---|---|
1 ** NaN |
NaN |
1 |
1 ** Infinity |
NaN |
1 |
NaN ** 0 |
1 |
1 |
(-1) ** Infinity |
NaN |
1 |
NaN ** 0 のように「指数が ±0 なら底が NaN でも 1」という規則は両者で一致します。要は個々の規則を一つずつ突き合わせる必要があり、「ホストの pow に丸投げして通ればよい」とはいかない、ということです。
その他の特殊値規則¶
同種の注意が必要な演算は他にもあります。剰余(%)は結果の符号が被除数に従い、Infinity % x は NaN、x % 0 も NaN です。Math.max/Math.min は引数に一つでも NaN があれば NaN を返し、Math.max(+0, -0) は +0、Math.min(+0, -0) は -0 と符号付きゼロを区別します。比較演算では NaN はどの値とも順序づけできず、NaN < x・NaN > x・NaN === x はすべて false になります。これらもホストの単純な実装と一致するとは限らないため、仕様の表に沿って一つずつ検証するのが安全です。
整数除算と剰余の符号¶
浮動小数点の割り算そのものは IEEE 754 が正しく丸めてくれますが、値を「商」と「余り」に分解するコードには、符号にまつわる根深い罠があります。日付・時刻の分解(総ミリ秒を日・時・分へ割る)、桁区切りやページングの計算など、量を単位で割って商と余りに分けたい場面は多く、ここで符号の扱いを誤ると負の入力で静かに壊れます。
問題の核心は、除算の丸め方向と剰余の符号の定義が一貫していなければならない点です。JavaScript の % は「切り捨て(truncating)剰余」で、余りの符号は被除数に従います。一方、除算の商を得るのに Math.trunc(a / b)(ゼロ方向へ切り捨て)を使うか Math.floor(a / b)(負の無限大方向へ切り下げ)を使うかで、対になる剰余の定義が変わります。
-1 % 24 // -1(切り捨て剰余:符号は被除数に従う)
Math.trunc(-1 / 24) // -0 → 0(切り捨て除算)… (商 0, 余り -1) で整合
Math.floor(-1 / 24) // -1(切り下げ除算)
切り捨て除算 Math.trunc(a/b) と % は対になっており、b * Math.trunc(a/b) + (a % b) === a が成り立ちます。ところが「時刻の分解では余りを常に非負にしたい」といった理由で商だけ Math.floor に変えると、商と剰余の組が整合しなくなります。たとえば時分割で hour = Math.floor(totalHours / 24) と hourOfDay = totalHours % 24 を混在させると、totalHours = -1 のとき商は -1(前日側)なのに余りは -1(負)となり、「23 時」を表現できません。
正しくは、切り下げ除算には切り下げ剰余(ユークリッド的剰余)を対で使うことです。切り下げ剰余は次のように、% の結果を非負へ補正して得られます。
// 切り下げ(floor)除算と対になる剰余
function floorDiv(a, b) { return Math.floor(a / b); }
function floorMod(a, b) { return ((a % b) + b) % b; } // 結果の符号は b に従う
このペアなら b * floorDiv(a,b) + floorMod(a,b) === a が負の入力でも成り立ち、floorMod(-1, 24) === 23 と期待どおりになります。エンジンの内蔵関数(日付計算・数値フォーマットなど)で量を商と余りに分ける箇所は、どちらの丸めを採るかを決め、除算と剰余を必ず同じ流儀で対にするのが鉄則です。二つの流儀を取り違えて混ぜることが、この種のバグの唯一かつ最大の原因です。
入力 a(除数 b=24) |
切り捨て商 trunc(a/b) |
切り捨て余り a % b |
切り下げ商 floor(a/b) |
切り下げ余り floorMod(a,b) |
|---|---|---|---|---|
25 |
1 |
1 |
1 |
1 |
-1 |
0 |
-1 |
-1 |
23 |
-25 |
-1 |
-1 |
-2 |
23 |
-24 |
-1 |
0 |
-1 |
0 |
文字列から数値への変換¶
文字列を数値へ変換する Number(str) や単項 + の背後にある StringToNumber は、素朴な実装が取りこぼしやすい規則をいくつも抱えています。
トリムする空白は ASCII より広い¶
変換はまず前後の空白を除去しますが、この「空白」は ASCII のスペースやタブだけではありません。仕様の StrWhiteSpace は、WhiteSpace(TAB、VT、FF、SP、ノーブレークスペース U+00A0、BOM/ZWNBSP U+FEFF、および Unicode の「Space_Separator」カテゴリに属する各種空白 — U+1680、U+2000〜U+200A、U+202F、U+205F、U+3000 など)と、LineTerminator(LF、CR、行区切り U+2028、段落区切り U+2029)の和集合です。
Number(" 42") // 42(ノーブレークスペースはトリムされる)
Number("42") // 42(BOM もトリム対象)
Number(" 42") // 42(全角スペース)
Number("42") // NaN(ゼロ幅スペースは空白ではない)
ここで見落としやすいのが、見た目が空白でも U+200B(ゼロ幅スペース)は空白集合に含まれないことです。ASCII 空白だけをトリムする実装は Number(" 1") を NaN にしてしまい、逆に「空白っぽい文字は全部トリム」とすると U+200B を誤ってトリムしてしまいます。空白集合は仕様の定義そのままに厳密実装する必要があり、字句解析器が使う空白判定(第I部「字句解析」)と定義を共有できると取りこぼしが減ります。
数値リテラルとしての解析¶
トリムした後の本体は、10 進小数のほか、0x/0o/0b 接頭辞の 16 進・8 進・2 進、Infinity(符号付き可)を受け付けます。素朴な実装が誤りやすい点を挙げます。
- 先頭ゼロは 8 進ではない:
Number("010")は10です。ソースコードの数値リテラルにあった旧来の 8 進(先頭 0)は文字列変換には存在しません。8 進を意図するなら0o10を使います。 - 空文字と空白のみは
0:Number("")もNumber(" ")もNaNではなく0です(parseInt("")がNaNなのと対照的)。 - 指数・小数点の縁:
Number("5.")・Number(".5")・Number("1e3")はいずれも有効です。一方Number("1e")や末尾に余分な文字がある"1x"はNaNです。 parseInt/parseFloatとは別物: これらは先頭から読める範囲だけを解釈して途中で止まりますが、Number()は文字列全体が有効でなければNaNを返します。混同するとparseInt("0x10")(=16)とNumber("0x10")(=16)は一致しても、parseInt("10px")(=10)とNumber("10px")(=NaN)で挙動が分かれます。
数値から文字列への変換¶
逆方向の変換 — 数値を十進文字列にする — は、正しく実装するのが意外に難しい問題です。
最短往復表現¶
ECMAScript は Number.prototype.toString(基数 10)に対し、「その倍精度値に戻したとき元の値に一致する十進表現のうち、桁数が最小のもの」を要求します。これを最短往復(shortest round-trip)表現と呼びます。単に十分な桁数(17 桁)を出力すれば往復はしますが、0.1 が "0.1" ではなく "0.10000000000000001" と表示されてしまい仕様に反します。逆に桁を削りすぎると別の倍精度値に丸まってしまいます。「往復する中で最短」という条件を正確に満たすには、Grisu・Ryū・Dragon4 といった専用アルゴリズムが必要です。これらは倍精度値が代表する区間の境界を追いながら、往復可能な最小桁数を決定します。自前の printf("%.17g") 相当では最短にならないため、十進変換は専用実装に委ねるのが定石です。
基数変換と精度指定¶
toString(radix) は 2〜36 の任意基数への変換を要求します。整数部は基数で繰り返し割って桁を得ますが、小数部の基数変換は一般に無限桁になりうるため、どこで打ち切るかの規則が要ります。加えて toFixed(小数点以下の桁数固定)・toPrecision(有効桁数指定)・toExponential(指数表記)は、それぞれ丸めと桁揃えの規則が異なり、(0.1).toFixed(20) のように「倍精度が実際に表す値」がそのまま露出する縁のケースを含みます。これらも十進の最短表現とは別の丸め要求を持つため、共通の十進変換ルーチンの上に規則ごとの整形を重ねる構成が扱いやすくなります。
BigInt — 任意精度整数¶
Number が 2^53 を超える整数を正確に扱えない一方、BigInt は桁数に上限のない整数を常に正確に表現します(実用上はメモリが上限)。実装の中核は、大きな整数を固定幅の「桁のかたまり」の配列として持つことです。
リム表現¶
BigInt は符号と、リム(limb)と呼ばれる固定幅の無符号整数(典型的には 32 ビットまたは 64 ビット)の配列で表します。たとえば 64 ビットリムなら、値は各リムを 2^64 進の一桁と見なした多倍長数です。符号は絶対値(リム列)と分けて持つ「符号-絶対値」表現が一般的で、リム列は先頭(最上位)の 0 リムを持たない正規形に保ちます。ゼロはリム列が空(かつ符号は正)という一意な形にします。
基本演算の作り方¶
- 加減算: 筆算と同じく、下位リムから順にリムごとに足して桁上がり(carry)を次のリムへ伝播します。計算量はリム数に比例(O(n))。符号-絶対値表現では、同符号なら絶対値の加算、異符号なら大きい方から小さい方を引いて符号を決める、という場合分けが要ります。
- 乗算: 素朴には各リムの積を積み上げる筆算法で O(n·m)。桁数が非常に大きくなると Karatsuba 法などの分割統治で O(n^{1.585}) 程度に落とせますが、小さい入力では筆算の方が速いため、閾値で切り替えるのが定石です。
- 除算: もっとも実装が重いのが多倍長除算です。筆算による長除法(Knuth のアルゴリズム D が代表)で、商の各桁を推定し補正しながら進めます。除数の正規化(最上位リムの最上位ビットを立てる前処理)や商桁の推定と補正が絡み、加減乗に比べて格段に手間がかかります。剰余も同じ手順で同時に得られます。
- 十進文字列化: これも見落とされがちな重い処理です。多倍長数は内部では
2^64進なので、十進へ直すにはリムに収まる最大の 10 の冪(64 ビットなら10^19)で繰り返し割り、余りを十進の桁束として取り出します。素朴に「10^19で割る」を先頭から繰り返すと、除算が毎回 O(n) で全体 O(n²) になります。桁数の大きな BigInt を十進化する場面では、分割統治(数を上位半分・下位半分に分けて再帰的に文字列化する)で O(n·log²n) 程度へ改善できます。逆に十進文字列から BigInt を作る解析も、同様に素朴だと O(n²) になりうるため注意が要ります。
Number と BigInt の境界¶
両者は別の型で、算術演算子で混ぜられません。1n + 1 は暗黙変換されず TypeError を投げます。これは意図的な設計で、暗黙変換を許すと「どちらの精度に合わせるか」で静かな精度喪失が起きるためです。一方、比較演算子(==・< など)は型をまたいで数学的な値で比較でき、1n == 1 は true、2n > 1 も評価できます。
変換には非対称な注意が要ります。BigInt(n) は n が整数値の Number なら正確ですが、小数を持つ Number には使えず RangeError になります。逆に Number(big) は、big が 2^53 を超えると最も近い倍精度値へ丸められ、静かに精度を失います。
9007199254740993n // 正確
Number(9007199254740993n) // 9007199254740992 — 丸められる
BigInt(9007199254740993) // 元の Number が既に丸まっているので 9007199254740992n
最後の例が示すように、リテラル 9007199254740993 は Number として書いた時点で既に丸まっているため、それを BigInt() に渡しても正確な値は復元できません。大きな整数を正確に扱いたいなら、最初から BigInt リテラル(123n)や文字列から生成しなければなりません。
| 観点 | Number(倍精度) | BigInt |
|---|---|---|
| 表現 | IEEE 754 64 ビット固定幅 | 符号 + 可変長リム配列 |
| 整数の正確さ | 絶対値が 2^53 まで | 常に正確(メモリが上限) |
| 小数 | 表現できる(丸め誤差あり) | 整数のみ |
| 演算コスト | ほぼ定数(CPU 命令) | 桁数に依存(除算・十進化は特に重い) |
| 特殊値 | NaN・±Infinity・±0 |
なし(整数値のみ) |
| 相互運用 | — | 算術での混在は TypeError、比較は可 |
実装上の罠¶
数値まわりで繰り返し現れる、静かに壊れる罠を三つ挙げます。
- 除算と剰余の流儀の取り違え: 量を商と余りに分ける処理で、切り捨て除算と切り下げ剰余(あるいはその逆)を混ぜると、負の入力で商と余りが整合しなくなります。日付・時刻の分解や桁区切りで「たまに前日にずれる」「負のインデックスが出る」といった不具合の温床です。除算の丸め方向と剰余の符号定義を必ず対で選ぶのが唯一の予防策です。
- NaN 指数をホストの
powに委ねる:1 ** NaNや1 ** Infinityは ECMAScript ではNaNですが、C のpowは1を返します。特殊値ケースを仕様の規則で先に捌かず一般ライブラリへ丸投げすると、底が1や-1の縁で仕様と食い違います。指数・剰余・Math.max/minなど、特殊値規則を持つ演算は仕様の表に沿って個別検証が必要です。 - 空白集合を ASCII に狭める / 広げすぎる: 文字列から数値への変換でトリムすべき空白は、ノーブレークスペースや BOM、各種 Unicode 空白を含む一方、ゼロ幅スペース
U+200Bは含みません。ASCII 空白だけを見ると Unicode 空白付き文字列をNaNにし、「空白っぽい文字は全部除去」とするとU+200Bを誤って受理します。仕様のStrWhiteSpaceを厳密に写すこと。
まとめ¶
- JavaScript の数値は単一の倍精度浮動小数点型であり、精度は相対的、整数の正確さは
2^53までに限られる。0.1 + 0.2 !== 0.3はバグではなく IEEE 754 の必然で、エンジンは結果を補正してはならない。 - 符号付きゼロ
-0は、1/x・Object.is・Math系など観測される箇所では保存し、SameValueZero など正規化を要する比較とは扱いを分ける。 - 指数・剰余・
max/minなどの特殊値規則はホストの数学ライブラリと食い違う(1 ** NaNが代表)。特殊ケースを仕様の規則で先に捌き、一般ケースだけをホストへ委ねる。 - 量を商と余りに分けるときは、除算の丸め方向と剰余の符号定義を対で一貫させる。切り捨てと切り下げの混在が負の入力での不具合を生む。
- 文字列⇄数値変換は、トリムする空白集合を仕様どおり厳密に、数値の十進化は最短往復表現の専用アルゴリズムで実装する。
- BigInt はリム配列による任意精度整数で、加減乗は筆算、除算と十進文字列化が特に重い。Number は
2^53超で静かに精度を失うため、大きな整数は最初から BigInt として生成する。
参考文献¶
- IEEE 754 (浮動小数点演算の標準) — 倍精度の精度・特殊値・符号付きゼロの一次資料。
- ECMA-262: Number::exponentiate — NaN 指数を含むべき乗の特殊値規則。
- ECMA-262: StringToNumber と StrWhiteSpace — 文字列から数値への変換とトリムする空白集合の定義。
- ECMA-262: BigInt 型 — 任意精度整数の意味論と Number との境界。