Skip to content
Published on

浮動小数点:なぜ 0.1 + 0.2 は 0.3 ではないのか

Authors

はじめに — 誰もが戸惑う一行

少しでもプログラミングをしたことがあれば、誰もが一度はこの場面に出くわします。コンソールを開いて、ごく単純な足し算をさせただけなのに、結果がおかしいのです。

>>> 0.1 + 0.2
0.30000000000000004

初めて見るとコンピュータが壊れたのかと思います。小学生でも知っている0.1足す0.2は0.3なのに、この高価な機械はなぜ0.30000000000000004のような奇妙な答えを出すのでしょうか。Pythonだけではありません。JavaScript、Java、C、Go、Rubyなど、ほとんどの主流言語が同じ答えを出します。

結論から言うと、これはバグではありません。言語の欠陥でも、CPUの誤りでもありません。これはコンピュータが実数を格納する方式、IEEE 754浮動小数点の必然的な結果です。そしてその根は、驚くほど単純な一つの事実に要約されます。コンピュータは2進法で数を格納するのに、0.1は2進法では正確に書けないのです。この記事はその理由を土台から掘り下げます。

コンピュータは2進法で生きる

私たちが10進法を使うように、コンピュータは2進法を使います。10進法で小数0.75が「10分の7足す100分の5」であるように、2進法で小数は「2分の1、4分の1、8分の1……」の和で表されます。

  10進法:  0.75 = 7/10 + 5/100

  2進法:   0.11 = 1/2 + 1/4 = 0.5 + 0.25 = 0.75  (正確)

いくつかの数は2進法できっちり収まります。0.5は2分の1なので0.1(2進)、0.25は4分の1なので0.01(2進)、0.75は0.11(2進)で正確です。分母が2のべき乗である分数は、2進法で有限の桁数で表せます。

問題はそうでない数です。0.1は10分の1ですが、10は2のべき乗ではありません。この数を2進法で書こうとすると、終わらない無限小数になります。

  10進法 0.1 を2進法で:
  0.0001100110011001100110011001100110011...  (0011 が永遠に繰り返す)

これは10進法で3分の1を書こうとすると0.333333...が終わらないのと、まったく同じ現象です。3分の1が10進法で有限に書けないように、10分の1は2進法で有限に書けません。進法が違うだけで原理は同じです。

コンピュータは無限の桁数を格納できません。だからどこかで切り捨てねばなりません。0.1を格納する瞬間、コンピュータは本当の0.1ではなく「0.1に最も近い、有限桁数で表せる2進数」を格納します。この微細な誤差がすべての物語の始まりです。

IEEE 754 — 実数を入れる標準の器

では、その有限の桁数をどう配置するのでしょうか。ここにIEEE 754という標準があります。今日ほぼすべてのハードウェアがこの方式で実数を格納します。核心となる考え方は科学的記数法(scientific notation)です。

私たちがとても大きな数や小さな数を扱うとき6.022 × 10^23のように書くように、浮動小数点も数を三つの部分に分けます。符号(sign)、仮数(mantissa または significand)、指数(exponent)です。

  値 = (-1)^符号 x 仮数 x 2^指数

  符号     : 正か負か (1ビット)
  指数     : 小数点をどこに置くか (桁を動かす)
  仮数     : 有効数字 (精度を担う)

最も広く使われる64ビット倍精度(double)は、この64ビットを次のように分けます。

  64ビット double:
  [ 符号 1ビット ][ 指数 11ビット ][ 仮数 52ビット ]

  32ビット float:
  [ 符号 1ビット ][ 指数 8ビット ][ 仮数 23ビット ]

ここで「浮動(floating)」という名前の意味が現れます。小数点の位置が固定されず、指数に応じてふわふわと動きます。指数を大きくすればとても大きな数を、小さくすればとても小さな数を、同じビット数で表せます。この柔軟さのおかげで、浮動小数点は原子の大きさから銀河の大きさまで広い範囲を扱います。

しかし核心となる制約があります。仮数のビット数が有限であることです。doubleは仮数が52ビットしかないので、有効数字をおよそ10進15〜17桁までしか持てません。それ以上の精度は捨てられます。だから0.1のように無限に続く2進小数は52ビットで切られ、その切られた値が格納されます。

だから 0.1 + 0.2 はなぜ 0.3 ではないのか

これで最初の謎を解けます。コンピュータに0.1を格納すると、実際に格納されるのは本当の0.1よりごくわずかに大きい値です。0.2も同じく、ごくわずかに違う値が格納されます。

  格納したい値      実際に格納される値 (近似)
  0.1        ->    0.1000000000000000055511151231257827021181583404541015625
  0.2        ->    0.2000000000000000111022302462515654042363166809082031250

この二つの近似値を足すと、誤差も一緒に足されます。その和は本当の0.3の近似値とも微細にずれます。

  0.1(近似) + 0.2(近似) = 0.3000000000000000444089...
  0.3 自体の近似値       = 0.2999999999999999888977...

  二つの値が違う! だから 0.1 + 0.2 == 0.3 は偽である

つまり三つの異なる丸め誤差(0.1の誤差、0.2の誤差、そしてその和を再び格納するときの誤差)が重なって、目に見える0.30000000000000004が出ます。コンピュータは完璧に正確に計算しました。ただ、そもそも格納した材料が本当の0.1と0.2ではなかっただけです。

一つ慰めになる事実は、この誤差がランダムではなく決定的(deterministic)であることです。同じ演算は常に同じ誤差を出します。だから再現可能で、予測可能で、管理できます。問題は誤差の存在そのものではなく、それを知らずにコードを書くときに生じます。

実数を正確に比較するな — イプシロン

浮動小数点を扱うとき最もよくある間違いは、二つの実数を==で直接比較することです。上で見たように0.1 + 0.2は0.3と正確には等しくないので、こういうコードは予想と違う動きをします。

if 0.1 + 0.2 == 0.3:
    print("等しい")
else:
    print("等しくない")   # 実際にはこちらが出力される

正しいアプローチは「正確に等しいか」ではなく「十分に近いか」を問うことです。二つの値の差がとても小さい許容誤差(イプシロン, epsilon)より小さければ、等しいとみなします。

def close_enough(a, b, epsilon=1e-9):
    return abs(a - b) < epsilon

print(close_enough(0.1 + 0.2, 0.3))   # True

ただしここにも落とし穴があります。固定のイプシロン(例: 1e-9)は、値の大きさによって適切でないことがあります。とても大きな数どうしを比較するときは、その程度の差がかえって自然な誤差より小さくて失敗し、とても小さな数どうしでは寛容すぎることがあります。だから実務では、絶対誤差と相対誤差を一緒に考える方式を使います。

  絶対誤差の比較:  |a - b| < eps
    小さな数には適切、大きな数には不適切

  相対誤差の比較:  |a - b| <= eps * max(|a|, |b|)
    値の大きさに比例して許容値を調整

  実務のライブラリは両者を結合する
  (例: Python の math.isclose は相対と絶対を一緒に見る)

Pythonのmath.isclose、NumPyのnumpy.allcloseのような標準関数は、この結合方式をすでに実装しています。自分でイプシロンを選ぶのが難しければ、こうした実績ある関数を使うのが安全です。

お金は絶対に float で扱うな

浮動小数点の誤差が最も危険に現れる場所が金融計算です。お金は正確でなければなりません。1円の誤差も会計では許されず、そういう誤差が数百万件累積すれば実際の損失になります。ところがfloatでお金を扱うと、まさにその誤差が忍び込みます。

# 悪い例: float でお金の計算
price = 0.1
total = 0.0
for _ in range(10):
    total += price
print(total)          # 0.9999999999999999  — 1.0 ではない!

0.1を10回足せば1.0になるはずですが、先に見た理由で微細にずれます。こういう値で請求書を作ったり残高を比較したりすれば災難です。解決策は二つの方向です。

1. 整数で扱う(最小単位を使う). 金額を円ではなく、最小の通貨単位(例: 円、または通貨によってはセント)の整数で格納します。1,234.56ドルなら123456セントで格納する具合です。整数演算は誤差がまったくないので完璧に正確です。画面に表示するときだけ小数点を入れます。

# 良い例: 整数(セント)で扱う
price_cents = 10          # 0.10 ドルを 10 セントに
total_cents = 0
for _ in range(10):
    total_cents += price_cents
print(total_cents / 100)  # 1.0  — 正確!

2. 10進型を使う(decimal). 大半の言語は、10進法をそのまま扱う十進(decimal)型を提供します。Pythonのdecimal.Decimal、JavaのBigDecimalが代表的です。これらは内部で10進の桁を格納するので、0.1を本当の0.1として扱います。

from decimal import Decimal

a = Decimal("0.1")
b = Decimal("0.2")
print(a + b)              # 0.3  — 正確!
print(a + b == Decimal("0.3"))   # True

ここに重要な細部があります。Decimalを作るときは必ず文字列で渡さねばなりません(Decimal("0.1"))。もしDecimal(0.1)のようにfloatを渡すと、すでに誤差の混ざったfloat値がそのまま入り、十進の利点が消えます。材料が汚染されれば、どれほど精密な器も無駄です。

まとめるとこうです。性能が重要で単位が明確な大量計算には整数方式が、可読性と任意精度が重要な会計ロジックにはdecimal方式が向きます。どちらにせよfloatだけは避けねばなりません。

NaN、無限大、そして負のゼロ

IEEE 754は普通の数のほかにいくつかの特別な値を定義します。これらを知らないと思わぬバグに出くわします。

無限大(Infinity). 表現可能な最大の数を超えると(オーバーフロー)、あるいは0でない数を0で割ると無限大になります。正の無限大と負の無限大が別々にあります。

print(1e308 * 10)   # inf   (オーバーフロー)
print(-1e308 * 10)  # -inf

NaN (Not a Number). 定義されていない演算の結果です。0を0で割ったり、無限大から無限大を引いたり、負の数の平方根を求めたりするとNaNになります。NaNの最も悪名高い性質は、自分自身とも等しくないことです。

nan = float("nan")
print(nan == nan)   # False!  — NaN は何とも等しくない。自分自身とさえ
print(nan != nan)   # True

# だから NaN の検査は == ではなく専用関数で行う
import math
print(math.isnan(nan))   # True

この性質は最初は奇妙に見えますが、標準がそう定めたものです。おかげで「値がNaNか」を検査する慣用句がx != xでもあります。自分と違えばNaNという意味です。ただし明示的にisnan関数を使うほうが読みやすいです。

負のゼロ (-0.0). 浮動小数点には正のゼロと負のゼロが別々にあります。二つは==で比較すると等しいと出ますが、微細な状況で違う動きをします。例えば0で割るとき、符号によって正の無限大と負の無限大に分かれます。

print(0.0 == -0.0)      # True   (比較では等しい)
print(1.0 / 0.0)        # inf にするには別途処理が必要 (Python は例外)
# C, Java などでは:  1.0/0.0 -> +inf,  1.0/-0.0 -> -inf

負のゼロはたいてい気にする必要がありませんが、符号が意味を持つ数値計算(極限、複素数、特定の物理シミュレーション)では微妙な違いを生みます。

累積誤差 — 小さな誤差が積もるとき

ここまで見た誤差は、一つ一つはとても小さいです(およそ小数16桁目)。しかし演算を数百万回繰り返すと、この小さな誤差が積もって(accumulate)目に見える大きさに育つことがあります。特に大きさが大きく異なる数を足すとき深刻です。

# 大きな数に小さな数を足し続けると、小さな数が「飲み込まれる」
big = 1e16
small = 1.0
print(big + small)        # 1e16  — small が消えた!
print(big + small == big) # True

ここで何が起きたのでしょうか。doubleの仮数は有効数字約16桁しかありません。1e16はすでにその精度の端にあるので、ここに1.0を足すと、その1は表現可能な桁の下に押し出され、丸められて消えます。これを吸収誤差(absorption)と呼びます。大きな数が小さな数を飲み込むのです。

この現象は多くの数の和を求めるとき実際の問題になります。素朴に左から順に足すと、和が大きくなるほど、後に足される小さな値がだんだん無視されます。これを緩和する有名な技法がカハンの加算(Kahan summation)です。各段階で捨てられた誤差を別に覚えて、次の段階で補償する方式です。

def kahan_sum(numbers):
    total = 0.0
    compensation = 0.0   # 捨てられた低次のビットを覚える
    for x in numbers:
        y = x - compensation
        t = total + y
        compensation = (t - total) - y   # 今回失った誤差
        total = t
    return total

カハンの加算は誤差補償によって、素朴な和よりはるかに正確な結果を出します。データ分析、科学計算、グラフィックスのように膨大な実数演算を扱う分野では、こうした数値安定性の技法が重要です。核心の教訓は、誤差は個別には小さくても繰り返しの中で育つこと、そして演算の順序と方法が精度に影響することです。

実務ルールのまとめ

浮動小数点を安全に扱うための実践ルールを圧縮すると次のとおりです。

  • 実数を==で比較しないこと. 代わりにイプシロンに基づく近似比較(math.iscloseなど)を使う。
  • お金をfloatで扱わないこと. 整数(最小単位)またはdecimal型を使う。
  • decimalは文字列で生成すること. Decimal("0.1")であってDecimal(0.1)ではない。
  • NaNは専用関数で検査すること. x == nanは常に偽なのでisnanを使う。
  • 大きさが大きく異なる数の和に注意すること. 必要ならカハンの加算のような安定した技法を使う。
  • 表示用の丸めと計算用の精度を区別すること. 画面には小数第2位まででも、内部計算はより高い精度で保つ。
  • 精度が極端に重要なら任意精度ライブラリを使うこと. ただし速度を犠牲にする。

これらのルールに共通する精神は、「浮動小数点は近似である」という事実を常に意識することです。近似だと知って扱えば強力な道具であり、正確な値だと思い込めば静かに間違った結果を出す落とし穴です。

おわりに

0.1 + 0.2が0.3でないのは、コンピュータの誤りではなく、有限のビットで無限の実数を表そうとする根本的な妥協の結果です。コンピュータは2進法で数を格納するのに、10分の1のような多くの10進小数が2進法では無限に続くので、どこかで丸められるほかありません。IEEE 754はこの妥協を符号・指数・仮数で精巧に収め、広い範囲と実用的な精度を引き換えにします。

この事実を理解すれば、浮動小数点はもはや気まぐれな魔法ではなく、規則を知る者にとって予測可能な道具になります。正確な比較が必要ならイプシロンを、正確なお金が必要なら整数かdecimalを、安定した大量加算が必要なら数値安定の技法を使えばよいのです。核心は一つです。浮動小数点は実数を真似る近似であって、実数そのものではないということ。この一文を覚えれば、大半の浮動小数点の落とし穴を避けられます。

参考資料