【C++】floatの比較で「0.1+0.2==0.3」が偽になる原因とは?|誤差の出方を実測して、イプシロン比較・時間の足し込み・到着判定の直し方を整理

【C++】floatの比較で「0.1+0.2==0.3」が偽になる原因とは?|誤差の出方を実測して、イプシロン比較・時間の足し込み・到着判定の直し方を整理 C++

if (a + b == 0.3)と書いたのに、この条件は、通りません。ところが、同じ書き方をfloatでやると、今度は通ってしまいます。しかも、それが「正しく動いている」わけではないことが、この記事のいちばんの落とし穴です。

浮動小数点の誤差は、「小数の計算は、少しずれる」という説明だけだと、どれくらいずれて、どこで困るのかが見えません。そこで、doubleとfloatで、同じ計算を実際に動かして、ずれの大きさと、困る場面を測りました。ゲームで使う、経過時間の足し込みと、到着判定も確かめています。

検証は、Visual Studio 2022付属のMSVC(19.44、x64)を、Release(/O2 /MD)で動かして行いました。

浮動小数点の誤差とは?

浮動小数点の誤差とは、

・0.1のような小数を、2進数では、ぴったりとは表せないため、いちばん近い値で代用していることから生じるズレ

です。10進数の1/3が、0.333…と、割り切れないのと同じように、0.1も、2進数では割り切れません。float(約7桁)やdouble(約16桁)に収めるときに、丸められます。丸めた値どうしを足したり比べたりすると、結果が、期待とわずかにずれます。

実験①:0.1 + 0.2 と 0.3 を比べる

▼e1_basic.cpp
#include <cstdio>

int main()
{
    volatile double da = 0.1, db = 0.2, dc = 0.3;     // 定数畳み込みを避けるため、volatileで読む
    volatile float  fa = 0.1f, fb = 0.2f, fc = 0.3f;

    double ds = da + db;
    float  fs = fa + fb;

    std::printf("[double] 0.1 + 0.2 = %.17g\n", ds);
    std::printf("[double] 0.3       = %.17g\n", (double)dc);
    std::printf("[double] 0.1 + 0.2 == 0.3 ? %s\n", ds == dc ? "true" : "false");
    std::printf("[float ] 0.1f + 0.2f = %.9g\n", (double)fs);
    std::printf("[float ] 0.3f        = %.9g\n", (double)fc);
    std::printf("[float ] 0.1f + 0.2f == 0.3f ? %s\n", fs == fc ? "true" : "false");
    return 0;
}
[double] 0.1 + 0.2 = 0.30000000000000004
[double] 0.3       = 0.29999999999999999
[double] 0.1 + 0.2 == 0.3 ? false
[float ] 0.1f + 0.2f = 0.300000012
[float ] 0.3f        = 0.300000012
[float ] 0.1f + 0.2f == 0.3f ? true

doubleでは、0.1 + 0.2が0.30000000000000004になり、0.3(実際には0.29999999999999999)と、一致しません。==は、falseです。

ところが、floatでは、0.1f + 0.2fも0.3fも、同じ0.300000012になって、==がtrueになりました。丸めの方向が、たまたまそろっただけです。「floatでは動いたから、問題ない」と判断すると、値を変えたとたんに、壊れます。

実験②:0.1を10回足しても、1.0にならない

▼e2_loop.cpp
#include <cstdio>

int main()
{
    // 0.1を10回足して、ちょうど1.0になるかを、1回ずつ表示する
    std::printf("[float]\n");
    volatile float x = 0.0f;
    for (int i = 1; i <= 10; ++i)
    {
        x = x + 0.1f;
        std::printf("  %2d回目: x = %.9g%s\n", i, (double)x, x == 1.0f ? "  (== 1.0f)" : "");
    }
    std::printf("  10回足したあと x == 1.0f ? %s\n", x == 1.0f ? "true" : "false");

    std::printf("[double]\n");
    volatile double y = 0.0;
    for (int i = 1; i <= 10; ++i)
    {
        y = y + 0.1;
    }
    std::printf("  10回足したあと y = %.17g\n", (double)y);
    std::printf("  y == 1.0 ? %s\n", y == 1.0 ? "true" : "false");

    // 「xが1.0fになるまで回す」ループは、途中で追い越してしまう
    int count = 0;
    volatile float z = 0.0f;
    while (z != 1.0f && count < 20)
    {
        z = z + 0.1f;
        ++count;
    }
    std::printf("[float] 'z != 1.0f の間回す' を20回で打ち切ったとき: count = %d, z = %.9g\n", count, (double)z);
    return 0;
}
[float]
   1回目: x = 0.100000001
   2回目: x = 0.200000003
   3回目: x = 0.300000012
   4回目: x = 0.400000006
   5回目: x = 0.5
   6回目: x = 0.600000024
   7回目: x = 0.700000048
   8回目: x = 0.800000072
   9回目: x = 0.900000095
  10回目: x = 1.00000012
  10回足したあと x == 1.0f ? false
[double]
  10回足したあと y = 0.99999999999999989
  y == 1.0 ? false
[float] 'z != 1.0f の間回す' を20回で打ち切ったとき: count = 20, z = 2.00000024

floatで0.1fを、10回足した結果は、1.00000012でした。doubleでも、0.99999999999999989で、どちらも、ちょうど1.0にはなりません。5回目に0.5ぴったりになるのは、0.5が2進数で割り切れる値だからで、0.1の足し算が正確なわけではありません。

いちばん危ないのは、最後のループです。z != 1.0fの間、回し続けるつもりで書くと、1.0fにちょうど当たることがなく、追い越して、止まりません。20回で打ち切ったときには、zは2.00000024まで進んでいました。

実験③:ゴールに着いたかを、==で判定する

ゲームでは、移動量を足し込んで、ゴールに着いたかを調べる場面があります。秒速3.3で、ゴール5.0へ向かうキャラクターで、判定の書き方を3通り比べました。

▼e6_goal.cpp
#include <algorithm>
#include <cmath>
#include <cstdio>

int main()
{
    const float goal  = 5.0f;
    const float speed = 3.3f;                 // 秒速
    const float dt    = 1.0f / 60.0f;

    // ① == で到着判定する
    {
        volatile float pos = 0.0f;
        int frame = 0;
        const int limit = 600;                 // 10秒ぶんで打ち切り
        while (pos != goal && frame < limit)
        {
            pos = pos + speed * dt;
            ++frame;
        }
        std::printf("[== で判定]     %d フレーム後: pos = %.7f, 到着判定 = %s\n", frame, (double)pos, pos == goal ? "true" : "false");
    }
    // ② >= で到着判定して、行きすぎたぶんは、ゴールに合わせる
    {
        volatile float pos = 0.0f;
        int frame = 0;
        while (pos < goal && frame < 600)
        {
            pos = pos + speed * dt;
            ++frame;
        }
        float overshoot = pos - goal;
        std::printf("[>= で判定]     %d フレーム後: pos = %.7f (行きすぎ %.7f)\n", frame, (double)pos, (double)overshoot);
        pos = goal;                            // 到着したら、位置をゴールにそろえる
    }
    // ③ 1フレームで動ける量を、残りの距離で制限する(MoveTowards)
    {
        float pos = 0.0f;
        int frame = 0;
        while (pos != goal && frame < 600)
        {
            float step = speed * dt;
            float remain = goal - pos;
            pos = pos + std::min(step, remain);   // 残りより大きく動かない
            ++frame;
        }
        std::printf("[MoveTowards]   %d フレーム後: pos = %.7f, 到着判定 = %s\n", frame, (double)pos, pos == goal ? "true" : "false");
    }
    return 0;
}
[== で判定]     600 フレーム後: pos = 33.0001259, 到着判定 = false
[>= で判定]     91 フレーム後: pos = 5.0049982 (行きすぎ 0.0049982)
[MoveTowards]   91 フレーム後: pos = 5.0000000, 到着判定 = true
判定の書き方到着までのフレーム結果
pos != goalの間、動く600フレームで打ち切りゴールを通り過ぎて、33.0まで進んだ(到着判定は偽のまま)
pos < goalの間、動く91フレーム到着したが、5.0049982と、行きすぎた
残りの距離で、1回の移動量を制限(MoveTowards)91フレームちょうど5.0で止まった
秒速3.3、1/60秒ずつ動かした結果

==での到着判定は、一度も成立せず、キャラクターは、ゴールを通り過ぎ続けました。>=(または<で回す)なら、到着は判定できますが、少し行きすぎます。1回に動ける量を、残りの距離までに制限する方法なら、ゴールの位置にぴったり止まり、==でも、判定できました。

誤差の比べ方:絶対誤差と相対誤差

==が危ないなら、「十分近ければ、同じ」と判定します。これが、イプシロン(許容する誤差)を使った比較です。大きく分けて、2通りあります。

  • 絶対誤差:fabs(a - b) <= eps。差の大きさそのものを、見る
  • 相対誤差:fabs(a - b) <= eps * max(|a|, |b|)。値の大きさに対する、差の割合を見る
▼e3_compare.cpp
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <algorithm>

// ① 絶対誤差で比べる
static bool NearAbs(double a, double b, double eps = 1e-9)
{
    return std::fabs(a - b) <= eps;
}

// ② 相対誤差で比べる(大きい方の大きさに対する割合)
static bool NearRel(double a, double b, double eps = 1e-9)
{
    double scale = std::max(std::fabs(a), std::fabs(b));
    return std::fabs(a - b) <= eps * scale;
}

// ③ 絶対誤差と相対誤差の合わせ技(0付近は絶対、大きい値は相対)
static bool NearBoth(double a, double b, double absEps = 1e-12, double relEps = 1e-9)
{
    double diff = std::fabs(a - b);
    if (diff <= absEps) return true;
    return diff <= relEps * std::max(std::fabs(a), std::fabs(b));
}

struct Case
{
    const char* label;
    double a, b;
    const char* truth;      // 人間が「同じとみなしたいか」
};

int main()
{
    volatile double s = 0.1;
    volatile double big = 1.0e10;
    Case cases[] = {
        { "0.1+0.2 と 0.3",                      s + 0.2,            0.3,           "同じ" },
        { "1e10+0.1+0.2 と 1e10+0.3",            big + s + 0.2,      big + 0.3,     "同じ" },
        { "1e-12 と 2e-12(2倍違う)",              1.0e-12,            2.0e-12,       "違う" },
        { "1e10 と 1e10+1(相対 1e-10)",           1.0e10,             1.0e10 + 1.0,  "同じ" },
        { "1.0 と 1.001(0.1%違う)",               1.0,                1.001,         "違う" },
    };

    std::printf("%-34s %-6s %-8s %-8s %-8s\n", "ケース", "本来", "絶対誤差", "相対誤差", "合わせ技");
    for (const Case& c : cases)
    {
        std::printf("%-34s %-6s %-8s %-8s %-8s\n", c.label, c.truth,
                    NearAbs(c.a, c.b) ? "同じ" : "違う",
                    NearRel(c.a, c.b) ? "同じ" : "違う",
                    NearBoth(c.a, c.b) ? "同じ" : "違う");
    }
    std::printf("\nFLT_EPSILON = %g, DBL_EPSILON = %g\n", (double)FLT_EPSILON, DBL_EPSILON);
    return 0;
}

5つのケースで、3通りの判定(絶対誤差・相対誤差・両方を組み合わせたもの)を比べました。「本来、同じとみなしたいか」は、人間が決めた期待値です。

ケース本来絶対誤差相対誤差合わせ技
0.1+0.2と0.3同じ同じ同じ同じ
1e10+0.1+0.2と1e10+0.3同じ違う同じ同じ
1e-12と2e-12(2倍違う)違う同じ違う同じ
1e10と1e10+1(相対で1e-10)同じ違う同じ同じ
1.0と1.001(0.1%違う)違う違う違う違う
太字は、期待とずれた判定。eps は、絶対誤差・相対誤差ともに 1e-9、合わせ技の絶対側は 1e-12

【重要】イプシロンは、値の大きさに合わせないと、別の穴が開く

表を見ると、絶対誤差だけでは、2通りの失敗が出ています。

  • 値が大きいとき(1e10付近):誤差も大きくなるので、同じとみなしたい値を、「違う」と判定してしまう
  • 値が小さいとき(1e-12付近):2倍違う値を、「同じ」と判定してしまう

相対誤差なら、この2つは、期待どおりになりました。ところが、「絶対と相対の合わせ技」にしても、3行目の1e-12と2e-12は、「同じ」と判定されました。絶対側の許容を1e-12にしていたため、差が1e-12ぴったりの値は、絶対側で通ってしまったのです。イプシロンは、「このくらい違っても、同じにしたい」という、扱っている値の大きさに合わせて、決める必要があります。

なお、floatの機械イプシロン(1.0の次に大きい値との差)は、FLT_EPSILON = 1.19209e-07、doubleでは、DBL_EPSILON = 2.22045e-16でした。これは、「1.0付近」での目安で、値が大きくなるほど、隣り合う値の間隔も広がります。FLT_EPSILONを、そのまま、あらゆる値の許容誤差にするのは、避けてください。

時間の足し込みは、floatだと、ずれる

次に、ゲームの経過時間を、floatに、1/60秒ずつ足し込む場面です。60FPSで、1時間ぶん(216000フレーム)を、足しました。doubleとの比較も載せています。

▼e4_accum.cpp
#include <cstdio>

int main()
{
    // ① ゲームの経過時間を、1/60秒ずつfloatで足していく(60FPSで1時間ぶん = 216000フレーム)
    volatile float  tf = 0.0f;
    volatile double td = 0.0;
    const int frames = 60 * 60 * 60;
    for (int i = 0; i < frames; ++i)
    {
        tf = tf + 1.0f / 60.0f;
        td = td + 1.0 / 60.0;
    }
    std::printf("60FPSで1時間(%d フレーム)ぶん足した結果 (正解は 3600 秒)\n", frames);
    std::printf("  float : %.6f 秒 (誤差 %.6f 秒)\n", (double)tf, (double)tf - 3600.0);
    std::printf("  double: %.6f 秒 (誤差 %.6f 秒)\n", (double)td, (double)td - 3600.0);

    // ② floatの整数カウンターは、2^24(16777216)で止まる
    volatile float counter = 16777210.0f;
    for (int i = 0; i < 12; ++i)
    {
        counter = counter + 1.0f;
        std::printf("  counter + 1 -> %.1f\n", (double)counter);
    }
    return 0;
}
60FPSで1時間(216000 フレーム)ぶん足した結果 (正解は 3600 秒)
  float : 3597.206787 秒 (誤差 -2.793213 秒)
  double: 3600.000000 秒 (誤差 0.000000 秒)
  counter + 1 -> 16777211.0
  counter + 1 -> 16777212.0
  counter + 1 -> 16777213.0
  counter + 1 -> 16777214.0
  counter + 1 -> 16777215.0
  counter + 1 -> 16777216.0
  counter + 1 -> 16777216.0
  counter + 1 -> 16777216.0
  counter + 1 -> 16777216.0
  counter + 1 -> 16777216.0
  counter + 1 -> 16777216.0
  counter + 1 -> 16777216.0

doubleは、ぴったり3600.000000秒でした。一方、floatは3597.206787秒で、約2.8秒、足りませんでした。5分ごとの誤差を、記録したのが、次のグラフです。

60FPSで経過時間をfloatに足し込んだときの、正解との差。5分ごとに記録したもの
誤差は、時間とともに一方向に増え続けるのではなく、プラスとマイナスに振れながら、1時間で約-2.8秒になった
経過(分) float合計(秒) 誤差(秒)
       5       299.9709   -0.0291
      10       599.8976   -0.1024
      15       899.8244   -0.1756
      20      1200.3949    0.3949
      25      1501.4203    1.4203
      30      1802.4457    2.4457
      35      2103.0662    3.0662
      40      2401.8943    1.8943
      45      2700.7224    0.7224
      50      2999.5505   -0.4495
      55      3298.3787   -1.6213
      60      3597.2068   -2.7932

ここでも、floatの大きな値に、小さな値を足すとき、足す側の下の桁が、丸められて捨てられていきます。合計が大きくなるほど、1/60の端数が、うまく入りきらなくなる、ということです。

実験の後半は、floatの整数カウンターです。16777210.0fから、1.0fずつ足していきました。

16777216.0(2の24乗)に達したところで、足し算が、止まりました。floatの仮数部は、24ビットぶんの精度しかなく、16777216.0f + 1.0fは、16777216.0fに、丸められてしまうからです。フレーム数やスコアを、floatで数えると、ある時点から、増えなくなります。

直し方:整数で数えて、必要なときに割る

「足し込むと、誤差が積もる」なら、足し込まないのが、いちばん確実です。時間は、フレーム数(整数)で数えて、必要なときに、割ります。金額なら、円の小数ではなく、最小単位(銭やセント)の整数で持ちます。

▼e5_int.cpp
#include <cstdio>

int main()
{
    // ① 小数にしたくないものは、最小単位の整数で持つ(0.10, 0.20, 0.30 を、1/100単位の10, 20, 30にする)
    int priceA = 10;
    int priceB = 20;
    int total  = 30;
    std::printf("整数(1/100単位): %d + %d == %d ? %s\n", priceA, priceB, total,
                priceA + priceB == total ? "true" : "false");

    // ② 時間を足し込まず、フレーム数を整数で数えて、必要なときに割る
    long long frames = 0;
    for (int i = 0; i < 60 * 60 * 60; ++i)
    {
        ++frames;
    }
    double seconds = (double)frames / 60.0;
    std::printf("フレーム数 %lld を 60 で割った時間: %.6f 秒 (正解は 3600 秒)\n", frames, seconds);
    return 0;
}
整数(1/100単位): 10 + 20 == 30 ? true
フレーム数 216000 を 60 で割った時間: 3600.000000 秒 (正解は 3600 秒)

整数なら、10 + 20 == 30は、いつでもtrueです。フレーム数216000を60で割った時間も、ぴったり3600秒になりました。

floatの比較で、よくある失敗例と対処法

①「floatでは、==で動いた」と、そのままにする

実験①のとおり、floatで一致するのは、丸めが、たまたまそろっただけです。計算の順番や、値を変えると、すぐに崩れます。==は、整数とフラグ以外では、使わないのが安全です。

②イプシロンに、決め打ちの値を使う

1e-5のように、固定の値をコピペして使うと、値が大きい場面でも、小さい場面でも、判定がずれます。値の大きさに合わせるか、相対誤差を使います。

③「ちょうど到着」を、位置の==で判定する

実験③のとおり、位置を足し込む移動では、ゴールの座標に、ぴったり一致しません。距離で判定するか、残りの距離で、移動量を制限します。

④経過時間や、スコアを、floatで足し続ける

1時間で約2.8秒ずれ、2の24乗で止まります。長く動くゲームでは、時間はdoubleか、整数のフレーム数で持ちます。deltaTimeの記事で扱っている、フレーム間の差分(deltaTime)そのものを、floatで使うこと自体は問題ありません。問題になるのは、それを足し込んで、長期間の合計に使うときです。

注意点

  • 0.1のような小数は、2進数で割り切れないので、近い値で代用されている
  • doubleでは0.1+0.2==0.3が偽だが、floatでは、たまたま真になった(今回の環境)
  • 0.1を10回足した結果は、floatで1.00000012、doubleで0.99999999999999989。!= 1.0fで回すと、止まらない
  • 到着判定は、==だと成立しなかった。残りの距離で、移動量を制限すると、ちょうど止まった
  • 絶対誤差だけでは、大きい値で「違う」、小さい値で「同じ」と、両方向に間違えた。イプシロンは、値の大きさに合わせる
  • floatに1/60を足し込むと、1時間で約-2.8秒。floatの整数カウンターは、16777216で止まる
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、/O2 /MD)で行った。丸めの結果は、コンパイラや設定で変わりうる

この記事のポイント

  • 浮動小数点の==は、たまたま通ることがあるので、通ったことが、安全の証拠にならない
  • 比べるなら、イプシロンを使う。ただし、値の大きさに合わせた許容誤差を選ぶ
  • 足し込む処理は、誤差が積もる。時間はフレーム数(整数)、金額は最小単位の整数にして、必要なときに割る
  • 到着判定は、位置の一致ではなく、残りの距離で、移動を止める形にする

小数の計算で「少しずれる」ことは、避けられません。避けられないものは、ずれないふりをせずに、ずれてもよい形に、書き直す。それが、==をやめる、本当の理由でした。

あわせて読みたい