1. コンピュータが数値を使う理由
コンピュータは文字や画像、音声といった様々なデータを数値で扱います。
例えば
- 文字 は、文字コード(ASCIIやUnicodeなど)を使って数値に変換されます
- 画像 は、各ピクセルの色をRGB値などの数値で表現します
- 音声 は、時間ごとの振幅をサンプリングし、数値として記録します
数値に変換することで、文字や画像、音声など異なる種類のデータを同じ方法で処理・保存・転送できるようになります。
2. 2進数の利点と制約
コンピュータは数値を2進数で管理します。これはコンピュータの回路が オン(1) と オフ(0) という2つの状態を物理的に表現しやすいためです。
利点
- 2進数は、ハードウェアでの処理がシンプルであり、高速に実行できる という特長があります
制約
- 2進数で計算を行う場合、人間の感覚(10進数)とは異なることがあります
- コンピュータは表現できる数値の範囲が 有限 です
これらの制約が計算の結果として丸め誤差や情報落ち、桁落ちといった現象に繋がります。次章以降では、これらの現象を実際の計算のサンプルを通して体験してみましょう。サンプルコードは主に JavaScript と Java を使用して解説しますが、同様の現象は C、Go、Python、PHP など他の多くのプログラミング言語でも発生します。
3. 丸め誤差を体験しよう
人間の感覚では問題にならないはずの計算が、コンピュータが計算すると想定外の結果になることがあります。この現象のひとつが 丸め誤差 です。ここでは、簡単な例を通じて丸め誤差を体験し、その原因を学びましょう。
問題がないケース
まず、丸め誤差が問題にならないケースを見てみます。
- JavaScript :
console.log(0.1 + 0.1 == 0.2);
// 実行結果: true (期待値: true)
- Java :
System.out.println(0.1 + 0.1 == 0.2);
// 実行結果: true (期待値: true)
丸め誤差が発生するケース
次に、丸め誤差が発生するケースを試してみましょう。
- JavaScript :
console.log(0.1 + 0.2 == 0.3);
// 実行結果: false (期待値: true)
- Java :
System.out.println(0.1 + 0.2 == 0.3);
// 実行結果: false (期待値: true)
なぜ問題が発生するのか?
数値を2進数で表現する際、0.1
や0.2
などの小数は無限小数(例えば、0.1
は0.0001100110011...
、0.2
は0.001100110011...
)となりますが、扱える桁数に限りがあるため、途中で切り捨てや四捨五入が行われます。この丸められた数値をもとに計算を行うため、誤差が累積して期待値との差が生じます。これを丸め誤差と呼びます。
補足
このケースの場合、0.1
、0.2
、0.3
のすべてが2進数では無限小数となりますが、0.1 + 0.1
は丸めた0.2
と一致するため問題ありません。一方、0.1 + 0.2
は丸めた0.3
と一致しないため問題が発生します。JavaでBigDecimal
を使い確認してみましょう。実際には浮動小数点数の規格であるIEEE 754 に基づいて計算されるため0.1 + 0.2
は0.30000000000000004
となります。
BigDecimal v1 = new BigDecimal(0.1d);
BigDecimal v2 = new BigDecimal(0.2d);
BigDecimal v3 = new BigDecimal(0.3d);
System.out.println(v1); // 0.1000000000000000055511151231257827021181583404541015625
System.out.println(v2); // 0.200000000000000011102230246251565404236316680908203125
System.out.println(v3); // 0.299999999999999988897769753748434595763683319091796875
System.out.println(v1.add(v1)); // 0.2000000000000000111022302462515654042363166809082031250 (丸められた0.2と同じ)
System.out.println(v1.add(v2)); // 0.3000000000000000166533453693773481063544750213623046875 (丸められた0.3とは不一致)
4. 情報落ちを体験しよう
情報落ちとは、桁が大きく異なる数値の計算結果が期待通りにならない現象のことです。情報落ちが発生するケースと発生しないケースを見ていきましょう。
問題がないケース
まずは、情報落ちが発生しないケースを試してみましょう。
- JavaScript :
console.log(10000000000.1 + 0.1 == 10000000000.1);
// 実行結果: false (期待値: false)
- Java :
System.out.println(10000000000.1 + 0.1 == 10000000000.1);
// 実行結果: false (期待値: false)
このケースでは計算結果が正しく得られます。10000000000.1 + 0.1
は正確に計算され、10000000000.2
となるため、10000000000.1
に一致しません。このため比較結果は期待通りfalse
となります。
情報落ちが発生するケース
次に、情報落ちが発生するケースを試してみましょう。
- JavaScript :
console.log(10000000000.1 + 0.0000001 == 10000000000.1);
// 実行結果: true (期待値: false)
- Java :
System.out.println(10000000000.1 + 0.0000001 == 10000000000.1);
// 実行結果: true (期待値: false )
このケースでは10000000000.1
に対して、桁が大きく異なる数(0.0000001
)を加算しましたが計算結果が変わらず、比較結果がtrue
になりました。本来であれば、10000000000.1 + 0.0000001
の結果は10000000000.1000001
となるべきです。しかし、小さな数値が 無視 されて計算結果に反映されていません。
なぜ問題が発生するのか?
情報落ちが発生する理由は、コンピュータが扱える数字の桁が有限であるためです。コンピュータは小数を浮動小数点の規格であるIEEE 754 に基づいて 符号、指数、仮数の形で表現します。
- 符号 : 数値が正か負かを表す (0: 正, 1: 負)
- 指数 : 数値のスケール (大きさ) を表す
- 仮数 : 実際の数値の中身を表す
下記の例ではわかりやすくするために10進数で説明していますが、実際には IEEE 754 に基づいて計算される点に注意が必要です。
$10000000000.1$ は $1.00000000001 \times 10^{10}$ と表現され、$0.0000001$ は $1.0 \times 10^{-7}$ と表現されます。
$1.0 \times 10^{-7}$ の場合、$10^{-7}$ が 指数 で1.0
が 仮数 です。
これらを計算する際、指数を揃えてから加算します。すると、次のようになります。
$$
10000000000.1 + 0.0000001 = (1.00000000001 \times 10^{10}) + (0.00000000000000001 \times 10^{10})
$$
このとき0.00000000000000001
が仮数で表せる桁数を超えるために切り捨てられ、計算結果が10000000000.1
のままとなります。仮数部で表せる桁数は単精度(float)では 約7桁、倍精度(double)では 約16桁 です。(表の十進変換桁数参照)
5. 桁落ちを体験しよう
桁落ちとはほぼ等しい数値同士を引き算した値が期待通りにならない現象です。まずは、桁落ちが発生しないケースと発生するケースを体験してみましょう。
問題がないケース
まず、桁落ちが発生しないケースを試してみましょう。
- JavaScript :
console.log(1.111112 + 1.111111 == 2.222223);
// 実行結果: true (期待値: true)
- Java :
System.out.println(1.111112 + 1.111111 == 2.222223);
// 実行結果: true (期待値: true)
桁落ちが発生するケース
次に、桁落ちが発生するケースを試してみましょう。
- JavaScript :
console.log(1.111112 - 1.111111 == 0.000001);
// 実行結果: false (期待値: true)
- Java :
System.out.println(1.111112 - 1.111111 == 0.000001);
// 実行結果:false (期待値: true)
このケースでは1.111112 - 1.111111
の結果は0.000001
となるべきですが、0.000001
と一致しません。
なぜ問題が発生するのか?
近い数字同士の引き算を行うと浮動小数点数の仮数部の桁が減少します。この減少した桁が0
で補完されるため期待する結果と一致しません。これを桁落ちといいます。IEEE 754表現の 仮数部に注目して計算の過程を見てみましょう。
- 1.111112 のIEEE 754表現 (64ビット):
符号:0
指数:01111111111
仮数:0001110001110001110101100000011000110001011100100111
- 1.111111 のIEEE 754 表現 (64ビット):
符号:0
指数:01111111111
仮数:0001110001110001110001010011111100111001110100011011
引き算を行うと仮数部は以下のようになります。
0001110001110001110101100000011000110001011100100111
- 0001110001110001110001010011111100111001110100011011
= 100001100011011110111101000001100
ここで計算結果の指数部と仮数部の調整(正規化)が行われます。
仮数部の最上位の 1
が省略され、下位ビットが0
で補間されます。結果、仮数部は以下のようになります。
0000110001101111011110100000110000000000000000000000
これが1.111112 - 1.111111
の結果の仮数部です。以下のコードで実際の計算結果と期待値の IEEE 754 表現を確認してみましょう。
System.out.println(String.format("%64s", Long.toBinaryString(Double.doubleToRawLongBits(1.111112-1.111111))).replace(' ', '0'));
// 0011111010110000110001101111011110100000110000000000000000000000
System.out.println(String.format("%64s", Long.toBinaryString(Double.doubleToRawLongBits(0.000001))).replace(' ', '0'));
// 0011111010110000110001101111011110100000101101011110110110001101
1.111112 - 1.111111
の計算結果が上記で確認した仮数部と同じになっていることが確認できます。
また、期待値である0.000001
とは下位ビットが異なっていることがわかります。
6. 計算誤差を減らすための対策
数値計算の誤差を完全に避けることはできませんが、誤差の影響を抑えるための対策はいくつか存在します。ここでは、特に重要で汎用性の高い3つの対策について紹介します。
1. 高精度な型を使用する
浮動小数点数の代わりに高精度な型(例: JavaScriptではbig.jsなど 、JavaではBigDecimal
など)を使用することで、丸め誤差を抑えることができます。
使用例 (Java)
BigDecimal v1 = new java.math.BigDecimal("0.1");
BigDecimal v2 = new java.math.BigDecimal("0.2");
BigDecimal v3 = new java.math.BigDecimal("0.3");
System.out.println(v1.add(v2).equals(v3));
// 実行結果: true (期待値: true)
注意点
- 10進数でも無限小数(例: 1 / 3 = 0.333...)はあるので、桁数制限や丸め処理が不要になるわけではありません
- 高精度な型では演算が遅くなるため、大量の計算が必要な場合にはパフォーマンスへの影響を考慮する必要があります
2. 計算順を入れ替える
計算の順序を工夫することで、誤差を最小限に抑えることができます。
誤差が生じやすい例
const result = 10 * 1.01 + 20 * 1.01;
console.log(result);
// 30.299999999999997
計算順を工夫した例
const result = (10 + 20) * 1.01;
console.log(result);
// 30.3
このように小数が発生する計算を減らしたり最後に行うように計算順を工夫することで丸め誤差を減らすことが可能です。
3. 中間計算結果を丸めすぎない
途中の計算結果を必要以上に丸めると、誤差が累積して結果に影響を及ぼすことがあります。
計算途中では可能な限り丸めず、最後に必要なタイミングで丸めるようにしましょう。
丸めすぎた例
double a = 1.005;
double b = 1.004;
double result = (Math.round(a) - Math.round(b)) * 1000; // 中間計算で丸める
System.out.println(result); // 結果: 0.0
最後に丸めた例
double a = 1.005;
double b = 1.004;
double result = (a - b) * 1000; // 中間計算を丸めない
System.out.println(Math.round(result)); // 結果: 1