JavaScript/TypeScriptメモ

JavaScriptで0.1はなぜ0.1と表示されるのか?

JavaScriptで0.1はなぜ0.1と表示されるのか?

この記事では、正しい結論を導きだせていない可能性が高いですので、ご注意ください。

もちろん、この記事を読んでみて「ここが違う」という指摘は大歓迎です。ご意見がありましたら、ぜひお知らせください

chromeのコンソールで、「0.1」「0.1+0.2」の計算結果を表示すると、それぞれ「0.1」「0.30000000000000004」と表示されます。

console.log(0.1);
//0.1

console.log(0.1+0.2);
//0.30000000000000004

この記事では、JavaScriptの仕様書を見ながら、下記の疑問について調べてみました。

  • なぜ、console.logで表示する結果が、この真の数字と異なるのか?
  • なぜ、「0.1」は「0.1」、「0.1+0.2」は「0.30000000000000004」と表示されるのか?

この挙動の鍵はNumber.toStringメソッドにあるらしいので、その中身を重点的に見ていきます。

JavaScriptで「0.1」「0.1+0.2」は、どう扱われるか?

まず、これらの値がJavaScriptで、実際に「どのような値」として管理されているかを確認します。

確認するには、Number.toFixedメソッドで、引数に十分大きな値を指定しましょう。

実際に、実行してみると、下記のようになります。

console.log((0.1).toFixed(56));
//0.10000000000000000555111512312578270211815834045410156250

console.log((0.1+0.2).toFixed(56));
//0.30000000000000004440892098500626161694526672363281250000

これが、JavaScriptで管理される「数値」の実態です。

一方で、単に、console.logで値を表示すると、内部的に、Number.toStringメソッドが呼び出され、冒頭のように、適当に端数処理された値が返されます。

なぜ、このような挙動を示すのか、Number.toStringメソッドの挙動を調べていきます。

※ちょっと探してみたのですが、仕様書上、console.log呼び出し時に、内部的にNumber.toStringが呼び出される、という記載を見つけることができませんでした。この記事では、これ以上深入りはしませんが、もし、仕様書のどこに書かれているかご存じの方がいらしたら、ぜひ、教えてください。

例1. (123456).toString()

まずは、手軽な例として(123456).toString()を実行したときの挙動を見ていきます。

仕様書の該当箇所は、「ECMAScript 2021 Language Specification」6.1.6.1.20 Number::toStringです。

まず、処理対象の数「x」として、「123456」が渡され、処理が行われます。

The abstract operation Number::toString takes argument x (a Number). It converts x to String format.

次に、処理手順の「1.」~「4.」です。

ここでは、「NaN」「+0」、「-0」、負の数、「Infinity」についての処理が記載されています。

  1. If x is NaN, return the String "NaN".
  2. If x is +0𝔽 or -0𝔽, return the String "0".
  3. If x < +0𝔽, return the string-concatenation of "-" and ! Number::toString(-x).
  4. If x is +∞𝔽, return the String "Infinity".

今回の「x」(=「123456」)は、どれにも該当しないので、「5.」の処理に進みます。

条件を満たす「n」「k」「s」の値を計算する

処理手順「5.」では、「n」「k」「s」の3つの「整数」値を計算します。

  1. Otherwise, let n, k, and s be integers such that k ≥ 1, 10k-1 ≤ s < 10k, s × 10n - k is ℝ(x), and k is as small as possible. Note that k is the number of digits in the decimal representation of s, that s is not divisible by 10, and that the least significant digit of s is not necessarily uniquely determined by these criteria.

以下、詳細に見ていきます。

「s」と「k」を決める

「s」は整数値で、かつ、「s × 10n - k」が「ℝ(x)」となるような数です。

「ℝ(x)」が何かは、後で考えるとして、とりあえず、今のところは「ℝ(x)」は「x」と同じ値(=「123456」)と考えて、先に進みましょう。

「s」は整数で、かつ、元の数「x」に10を何回か乗じてできる数なので、「s」の候補は「123456」「1234560」・・・といった数のうちの、いずれかになります。

そして、それぞれの「s」に対して、「k」の値を計算すると、次のようになります。

s sとkの関係 k
123456 106-1 ≦ 123456 < 106 6
1234560 107-1 ≦ 1234560 < 107 7
12345600 108-1 ≦ 12345600 < 108 8

先ほどの仕様書を読むと、**「k」は「1以上」でかつ「できるだけ小さい数」**にしないといけません。

この中で「k」が一番小さくなるのは、**「s」が「123456」、「k」が「6」**です。これが、目的の「s」「k」の値となります。

この **「k」は「元数値の桁数」**を表しています。

「Number型」と「普通の数値」

※たぶん、この項で書いている解釈は間違えています(結果は合っていると思いますが、過程が間違えているはず)。正しい解釈をご存じの方がいらしたら、ぜひ教えてください

次に「n」を決めていきたいのですが、その前に、一つ、概念を整理しておきましょう。

仕様書を見ると、「x」と「n」「k」「s」は、「s × 10n - k is ℝ(x)」という関係を満たす、と書かれています。

この文中に出てくる「ℝ(x)」は、「ECMAScript 2021 Language Specification」5.2.5 Mathematical Operationsで定義されています。

A conversion from a Number or BigInt x to a mathematical value is denoted as "the mathematical value of x", or ℝ(x).

「mathmatical value」というのは、プログラミングとは関係ない、普通の算数で使う「数値」(以下、「普通の数値」と表記します)を表しています。

そして「ℝ(x)」は、Number型「x」に対応する「普通の数値」を表すのです。

たとえば、Number型「123456」に対応する「普通の数値」は「123456」です。ですから、「ℝ(123456)」は、普通の数値「123456」という数値を表すことになります。

仕様書上「Number型」の世界と、「普通の数値」の世界は、明確に区分されています。

たとえば、先ほどの「s × 10n - k is ℝ(x)」は、「is」の左側も右側も「普通の数値」で書かれています。

ですから、この文章は、「Number型」の世界ではなく「普通の数値」の世界で考えたときに、「s × 10n - k = ℝ(x)」という意味に解釈することになります。

「n」を決める

では、実際に、先ほどの関係から「n」を決めてみましょう。

先ほど出てきた関係式に、「s」「k」「x」をあてはめると、次のようになります。

「123456 × 10n - 6 = 123456」

ですから、「n」は「6」であることがわかります。

最終的に、元の数と、算出した「n」「k」「s」を表にまとめると次のようになります。

元の数 n k s
123456 6 6 123456

これで、「処理手順5」は終わりです。

「n」「k」「s」の値に応じて、文字列変換を行う。

次に、「処理手順6」に進みます。

  1. If k ≤ n ≤ 21, return the string-concatenation of:
    • the code units of the k digits of the decimal representation of s (in order, with no leading zeroes)
    • n - k occurrences of the code unit 0x0030 (DIGIT ZERO)

今回は、このケースに該当するので、この手順に従って表示する値を決定します。

  • まず「s」の数値を「k」桁分出力(=「123456」)し
  • 次に「n-k」桁分「0」を出力(=今回は何もなし)します

結果、「123456」という文字列が出力されることになります。

例2. (12.3456).toString()

次に、(12.3456).ToString()を実行したときの挙動を見ていきます。

「処理手順1」~「処理手順4」までは、先ほどと同じように無視することができます。

そこで、「処理手順5」の部分を見ていきます。

条件を満たす「n」「k」「s」の値を計算する

「s」と「k」を決める

「s」と「k」に関しては、結果的には、さきほどとまったく同じになりますが、一応、手順を追ってみましょう。

「s」は整数値で、かつ、「s × 10n - k」の形に書けないといけません。

ですから、「s」は「12.3456」に「10を何回か掛けて整数値にしたもの」(=「123456」「1234560」・・・)のうちのどれかになります。

それぞれの「s」に対して、「k」の値は、次のように計算できます。

s sとkの関係 k
123456 106-1 ≦ 123456 < 106 6
1234560 107-1 ≦ 1234560 < 107 7
12345600 108-1 ≦ 12345600 < 108 8

「k」を、「1以上」でかつ「できるだけ小さい数」にしようとすると、先ほどと同じく、**「s」が「123456」、「k」が「6」**の組み合わせを採用することになります。

「n」を決める

「s × 10n - k = ℝ(x)」という関係式に、「s」「k」「x」をあてはめると、次のようになります。

「123456 × 10n - 6 = 12.3456」

ここから、「n」は「2」であることがわかります。

参考のため、先ほどの「123456」の場合と、今回の「12.3456」の場合について、元の数と、算出した「n」「k」「s」を表にまとめてみると、次のようになります。

元の数 n k s
123456 6 6 123456
12.3456 2 6 123456

「n」が変わりましたが「k」「s」はまったく同じになりました。

「n」「k」「s」の値に応じて、文字列変換を行う。

次に、「処理手順6」以降に進みましょう。

  1. If k ≤ n ≤ 21, return the string-concatenation of:
    • the code units of the k digits of the decimal representation of s (in order, with no leading zeroes)
    • n - k occurrences of the code unit 0x0030 (DIGIT ZERO)

今回は、「k」が「6」、「n」が「2」なので、「処理手順6」の条件を満たしません。

次に進みます。

  1. If 0 < n ≤ 21, return the string-concatenation of:
    • the code units of the most significant n digits of the decimal representation of s
    • the code unit 0x002E (FULL STOP)
    • the code units of the remaining k - n digits of the decimal representation of s

「n」が「2」なので、この「処理手順7」の条件を満たします。

そこで、次のように文字列を出力します。

  • 「s」の数値を「n」桁分(=「12」)
  • 「.」を出力し
  • 「s」の数値の残り「k-n」桁分(=「3456」)を出力します。

その結果、「12.3456」という文字列が出力されます。

例3. (0.1).toString()

それでは、今までの議論を踏まえて、いよいよ、(0.1).ToString()を実行したときの挙動を見ていきます。

今回も、「処理手順1」~「処理手順4」までは、先ほどとまったく同じで条件を満たさないので、「処理手順5」の部分を見ていきます。

解釈1(誤った解釈?)

※たぶん、この項で書いている解釈は間違えています。が、正しい解釈がわからないので、とりあえず晒しておきます。正しい解釈をご存じの方がいらしたら、ぜひ教えてください

「x」と「ℝ(x)」の値を計算する

まず、今回指定した「0.1」という数値は、JavaScriptの引数として渡されている数です。ですから、これは「普通の数値」ではなく、**「Number型の数値」**です。

そして、「Number型」では「0.1」という数を正確に表すことができず、「0.10000000000000000555111512312578270211815834045410156250」として扱われます。

ですから、今回のtoStringメソッドの引数「x」の真の値は「0.10000000000000000555111512312578270211815834045410156250」となります。

また、「s」「n」「k」は、「普通の数値」の世界で「s × 10n - k = ℝ(x)」という関係を満たす必要があります。

ここで、右辺の、「ℝ(x)」は、「x」を「普通の数値」に変換した「0.10000000000000000555111512312578270211815834045410156250」になります

これから、この「ℝ(x)」に一致するような「s」「k」「n」を探していきます。

「s」と「k」を決める

「s」は整数値で、かつ、「ℝ(x)」が「s × 10n - k」の形に書けないといけません。

ですから、「s」は「0.10000000000000000555111512312578270211815834045410156250」に10を何回か掛けて整数値にしたものになります。

先ほどまでの議論を考えると、「s」は、そのような整数値のうちで最小のものになるはずです。

ですから、「s」は「1000000000000000055511151231257827021181583404541015625」になります。

その場合、「k」は、次のようになります。

s sとkの関係 k
100000000000000005551115123125782702118
1583404541015625
1055-1 ≦ 123456 < 1055 55

つまり、**「s」が「1000000000000000055511151231257827021181583404541015625」、「k」が「55」**になります。

「n」を決める

「s × 10n - k = ℝ(x)」に、「s」「k」「ℝ(x)」をあてはめると、次のようになります。

「1000000000000000055511151231257827021181583404541015625 × 10n - 55 = 0.1000000000000000055511151231257827021181583404541015625」

通常の数の世界での等式ですから、端数処理は一切行いません

これを計算すると、「n」は「0」になります

元の数 n k s
0.100000000000000005551115123
1257827021181583404541015625
0 55 100000000000000005551115123
1257827021181583404541015625

「n」「k」「s」の値が決まりましたので、次の処理手順に移りましょう。

「n」「k」「s」の値に応じて、文字列変換を行う。

  1. If k ≤ n ≤ 21, return the string-concatenation of:
    • the code units of the k digits of the decimal representation of s (in order, with no leading zeroes)
    • n - k occurrences of the code unit 0x0030 (DIGIT ZERO)

今回は、「k」が「55」、「n」が「0」なので、「処理手順6」の条件を満たしません。

次に進みます。

  1. If 0 < n ≤ 21, return the string-concatenation of:
    • the code units of the most significant n digits of the decimal representation of s
    • the code unit 0x002E (FULL STOP)
    • the code units of the remaining k - n digits of the decimal representation of s

「n」が「0」なので、この「処理手順7」の条件を満たしません。

次に進みます。

  1. If -6 < n ≤ 0, return the string-concatenation of:
    • the code unit 0x0030 (DIGIT ZERO)
    • the code unit 0x002E (FULL STOP)
    • -n occurrences of the code unit 0x0030 (DIGIT ZERO)
    • the code units of the k digits of the decimal representation of s

「n」が「0」なので、この「処理手順8」の条件を満たします。

そこで、次のように文字列を出力します。

  • 「0」を出力
  • 「.」を出力
  • 「0」を「-n」桁分出力(今回は「n」は「0」なので、何も出力しない)
  • 「s」の数値を「k」桁分(=「1000000000000000055511151231257827021181583404541015625」)出力。

その結果、「0.1000000000000000055511151231257827021181583404541015625」という文字列が出力される、、、はずです。

ところが、実際の出力結果は「0.1」なので、これと一致しません

仕様書の、どこを読み間違えているんでしょう?

解釈2(一部旧仕様書に沿って)考えてみる

「処理手順5」の古い仕様書を確かめる

古いJavaScriptの仕様書の、「処理手順5」に相当する記述を見てみると、微妙に違う表現がされているように見えます。

「Standard ECMA-262 5.1 Edition」9.8.1 ToString Applied to the Number Type

  1. Otherwise, let n, k, and s be integers such that k ≥ 1, 10k−1 ≤ s < 10k, the Number value for s × 10n−k is m, and k is as small as possible. Note that k is the number of digits in the decimal representation of s, that s is not divisible by 10, and that the least significant digit of s is not necessarily uniquely determined by these criteria.

そこで、旧仕様書に沿って、「処理手順5」の挙動を考えてみようと思います。

以下、「ECMAScript 2021 Language Specification」を「新仕様書」、「Standard ECMA-262 5.1 Edition」を「旧仕様書」と表記していきます。

旧仕様書と新仕様書では大きく違う点が2つあります。

1つ目は、変数名です。旧仕様書では「元の値」は「m」、新仕様書では「元の値」は「x」で表されています。

2つ目は、「s」「n」「k」の計算方法です。引用した文章中の、強調している部分に注目してほしいのですが、ここでは「Number value for s × 10n−k is m」という表現がされています。

「Number value for x」という表現は、**「普通の数値」の世界の数「x」に対応する「Number型の数」**を意味しています。

「Standard ECMA-262 5.1 Edition」8.5 The Number Type

そして、先ほどの文章は、「Number value for s × 10n−k」と「m」(これは、両方ともNumber型の数)が等しいと言っているわけですから、「s」「n」「k」と、元の数「m」が「Number型の数として等しい」という意味に解釈できます

Number型同士で数値を比較する場合には、少し数値がずれていても一致すると判定されることがあります。

たとえば「0.1000000000000000055511151231257827021181583404541015625」は、少なくとも、0.099999999999999999≦m≦0.100000000000000012を満たす「m」とは等しいと判定されます。

console.log(0.1000000000000000055511151231257827021181583404541015625 === 0.099999999999999999)
// true

console.log(0.1000000000000000055511151231257827021181583404541015625 === 0.100000000000000012)
// true

ですから、この範囲に収まるように「s」「n」「k」を選べば良い、ということになります。

(補足)「解釈1」と「解釈2」の違い

先ほどの「解釈1」(=新仕様書)では「s × 10n−k = ℝ(x)」は「普通の数値」の世界で考えていました。

一方で「解釈2」(=旧仕様書)では「s × 10n−k = x」は「Number型」の世界で考えています。

この点が両者で、大きく異なるように感じます。

が、実際には、新旧仕様書で、ここの計算ロジックが変わるわけがないと思うので、何か、私が読み間違いをしている気がします。

「s」と「k」を決める

「s」は整数値で、かつ、「m」が「s × 10n - k」の形に書けないといけません。

「k」は元の数「m」の桁数を表すのですから、「m」として、できるだけ桁数が小さくなるものを選ぶ必要があります。

先ほど見たとおり、「m」は、「0.099999999999999999 ≦ m ≦ 0.100000000000000012」 の範囲で自由に選んでいいわけです。そこで、この中から、桁数が一番短くなりそうな「0.1」を選びましょう。

その場合、**「s」は「1」(=「0.1」に10を1回掛けたもの)、「k」は「1」**になります。

s sとkの関係 k
1 100 ≦ 1 < 101 1

「n」を決める

「s × 10n - k = m」(※JavaScriptのNumber型としての等式)に、「s」「k」「m」をあてはめると、次のようになります。

「1 × 10n - 1 = 0.1000000000000000055511151231257827021181583404541015625」

「n」に「0」を入れると、左辺は「1 × 100 - 1」=「0.1」となり、「Number型の世界」では、左辺と右辺は一致します

console.log(0.1 === 0.1000000000000000055511151231257827021181583404541015625)
// true

ということで、算出した「n」「k」「s」を表にまとめると、次のようになります。

元の数 n k s
0.1000000000000000055511151231257827021181583404541015625 0 1 1

「n」「k」「s」の値に応じて、文字列変換を行う。

ここからは、新仕様書に戻って、「処理手順6」以降に進みましょう。

  1. If k ≤ n ≤ 21, return the string-concatenation of:
    • the code units of the k digits of the decimal representation of s (in order, with no leading zeroes)
    • n - k occurrences of the code unit 0x0030 (DIGIT ZERO)

今回は、「k」が「1」、「n」が「0」なので、「処理手順6」の条件を満たしません。

次に進みます。

  1. If 0 < n ≤ 21, return the string-concatenation of:
    • the code units of the most significant n digits of the decimal representation of s
    • the code unit 0x002E (FULL STOP)
    • the code units of the remaining k - n digits of the decimal representation of s

「n」が「0」なので、この「処理手順7」の条件を満たしません。

次に進みます。

  1. If -6 < n ≤ 0, return the string-concatenation of:
    • the code unit 0x0030 (DIGIT ZERO)
    • the code unit 0x002E (FULL STOP)
    • -n occurrences of the code unit 0x0030 (DIGIT ZERO)
    • the code units of the k digits of the decimal representation of s

「n」が「0」なので、この「処理手順8」の条件を満たします。

そこで、次のように文字列を出力します。

  • 「0」を出力
  • 「.」を出力
  • 「0」を「-n」桁分出力(今回は「n」は「0」なので、何も出力しない)
  • 「s」の数値を「k」桁分(=「1」)出力。

その結果、「0.1」という文字列が出力されることになります。

これで、実際の挙動と同じ動きになりました。

例4. (0.1+0.2).toString()

最後に、(0.1+0.2).ToString()を実行したときの挙動を見ていきます。

今回も、「処理手順1」~「処理手順4」までは、先ほどとまったく同じで条件を満たさないので無視できます。

そこで、「処理手順5」の部分を見ていきます。

条件を満たす「n」「k」「s」の値を(一部旧仕様書に沿って)計算する

「s」と「k」を決める

先ほどの「例3」と同じように、旧仕様書に沿って、「s」「k」の値を算定してみます。

まず、元の値「m」は、「0.3000000000000000444089209850062616169452667236328125000」です。

console.log((0.1+0.2).toFixed(55));
// 0.3000000000000000444089209850062616169452667236328125000

また、少なくとも「0.3000000000000000167 ≦ m ≦ 0.3000000000000000721」の範囲の数は、Number型の数値として一致します。

console.log(0.30000000000000004440892098500626161694526672363281250 === 0.3000000000000000167);
// true

console.log(0.30000000000000004440892098500626161694526672363281250 === 0.3000000000000000721);
// true

「k」は元の数「m」の桁数を表すのですから、「m」として、できるだけ桁数が小さくなるものを選ぶ必要があります。

この範囲内で、桁数が一番小さくなる数は「0.30000000000000002」「0.30000000000000003」・・・「0.30000000000000007」の6通りあります。

(新仕様書、旧仕様書のどちらでも)仕様書上は、この中から、任意の数値を選べるようです。そこで、今回は「0.30000000000000004」を、元の数「m」として先に進みましょう。

さて、「s」は整数値で、かつ、「m」が「s × 10n - k」の形に書けないといけないのですから、「s」は「30000000000000004」になります

そして、「k」は「17」になります

s sとkの関係 k
30000000000000004 1017-1 ≦ 123456 < 1017 17

「n」を決める

まず、「s × 10n - k = m」の左辺を計算します。

「s × 10n - k」に「s」「k」をあてはめましょう。

「30000000000000004 × 10n - 1 = 0.30000000000000004440892098500626161694526672363281250」

なお、等号は、「Number型」の世界での等号であることに注意してください。

そして、この数式を成り立たせようとすると、「n」は「0」になることがわかります。

実際、「n」に「0」を入れて、左辺を計算すると、「30000000000000004 × 100 - 17」=「0.30000000000000004」となります。

なお、この左辺の計算は、「普通の数値」の世界で行うことになります。

旧仕様書では、左辺は「the Number value for s × 10n−k」という表現をされているので、「s × 10n−k」の計算は「普通の数値」の世界で行い、その後、それに対応する「the Number value」(=Number型の値)に変換をすることになります。

そして、JavaScriptのNumber型として、左辺と右辺を比較すると、両者は一致していることがわかります。

console.log(0.30000000000000004 === 0.30000000000000004440892098500626161694526672363281250)
// true

ということで、算出した「n」「k」「s」を表にまとめると、次のようになります。

元の数 n k s
0.30000000000000004440892098500626161694526672363281250 0 17 30000000000000004

「n」「k」「s」の値に応じて、文字列変換を行う。

ここからは、新仕様書に戻って、「処理手順6」以降に進みましょう。

  1. If k ≤ n ≤ 21, return the string-concatenation of:
    • the code units of the k digits of the decimal representation of s (in order, with no leading zeroes)
    • n - k occurrences of the code unit 0x0030 (DIGIT ZERO)

今回は、「k」が「17」、「n」が「0」なので、「処理手順6」の条件を満たしません。

次に進みます。

  1. If 0 < n ≤ 21, return the string-concatenation of:
    • the code units of the most significant n digits of the decimal representation of s
    • the code unit 0x002E (FULL STOP)
    • the code units of the remaining k - n digits of the decimal representation of s

「n」が「0」なので、この「処理手順7」の条件を満たしません。

次に進みます。

  1. If -6 < n ≤ 0, return the string-concatenation of:
    • the code unit 0x0030 (DIGIT ZERO)
    • the code unit 0x002E (FULL STOP)
    • -n occurrences of the code unit 0x0030 (DIGIT ZERO)
    • the code units of the k digits of the decimal representation of s

「n」が「0」なので、この「処理手順8」の条件を満たします。

そこで、次のように文字列を出力します。

  • 「0」を出力
  • 「.」を出力
  • 「0」を「-n」桁分出力(今回は「n」は「0」なので、何も出力しない)
  • 「s」の数値を「k」桁分(=「30000000000000004」)出力。

その結果、「0.30000000000000004」という文字列が出力されることになります。

これで、実際の挙動と同じ動きになりました。

まとめ

仕様書の読み方が、どこか間違えているのだろうと思います。

どう読むのが正しいのか教えてください。