【C++】size_tのループが止まる・-1 < 1uが偽になる原因とは?|符号なし整数の落とし穴を実測して、逆順ループとHP計算を安全に書く

【C++】size_tのループが止まる・-1 < 1uが偽になる原因とは?|符号なし整数の落とし穴を実測して、逆順ループとHP計算を安全に書く C++

敵に15ダメージを与えました。HPは10だったので、倒れるはずです。ところが、ダメージ後のHPを表示すると、4294967291と出ました。しかも、「まだ生きている」と判定されて、敵は倒れません。

原因は、HPをunsigned(符号なし整数)にしていたことでした。符号なし整数は、「0より小さくなる」ことがありません。0より下に引くと、一周して、とても大きな値になります。この性質は、HPだけでなく、vectorのsize()が返すsize_tでも同じで、「逆順ループが止まらない」「空のvectorで、条件が逆になる」といった不具合を作ります。

そこで、符号なし整数が引き起こす不具合を、MSVC(19.44、x64、/W4、Release)で、実際に動かして確かめました。

符号なし整数とは?

符号なし整数とは、

・0以上の値だけを表せる整数型。unsignedや、vector::size()の戻り値のsize_tが、これにあたる

です。負の値を表せないかわりに、正の範囲が、倍になります。0から1を引くと、エラーにはならず、その型で表せる、いちばん大きな値に戻ります(ラップアラウンド)。unsigned(32ビット)なら4294967295、size_t(64ビット)なら18446744073709551615です。

実験①:符号なしのHPから、ダメージを引く

▼s4_hp.cpp
#include <cstdio>

struct Enemy
{
    unsigned hp = 10;                 // HPを、符号なしにしてしまった
};

int main()
{
    Enemy e;
    unsigned damage = 15;

    e.hp = e.hp - damage;             // 10 - 15
    std::printf("ダメージ後の hp = %u\n", e.hp);

    if (e.hp <= 0)
    {
        std::printf("倒れた\n");
    }
    else
    {
        std::printf("まだ生きている\n");
    }
    return 0;
}
ダメージ後の hp = 4294967291
まだ生きている

10 - 15の結果は、-5ではなく、4294967291になりました。そのため、e.hp <= 0は、永遠に成り立たず、敵は倒れません。ビルドは、警告なしで通りました。

実験②:size_tで、逆順にループする

配列を末尾から先頭へ、逆順に回すとき、intの感覚で、i >= 0と書きたくなります。size()が返すのは、符号なしのsize_tなので、この書き方は、危険です。

▼s1_loop.cpp
#include <cstdio>
#include <vector>

int main()
{
    std::vector<int> v = { 10, 20, 30 };

    // 末尾から先頭へ、逆順に回したい
    int guard = 0;
    for (size_t i = v.size() - 1; i >= 0; --i)
    {
        std::printf("i = %zu\n", i);
        if (++guard >= 6)        // 止まらないので、6回で、強制的に打ち切る
        {
            std::printf("(止まらないので、打ち切った)\n");
            break;
        }
    }
    return 0;
}
i = 2
i = 1
i = 0
i = 18446744073709551615
i = 18446744073709551614
i = 18446744073709551613
(止まらないので、打ち切った)

i = 0の次に、18446744073709551615が出て、止まりませんでした(実験では、6回で、強制的に打ち切っています)。size_tのiは、0から1を引くと一周するので、i >= 0は、いつも真です。/W4でビルドしても、警告は出ませんでした。止めずに動かし続けると、配列の範囲外を読み続けることになります(今回は、6回で打ち切ったので、その先は確認していません)。

実験③:空のvectorで、size() – 1

▼s3_empty.cpp
#include <cstdio>
#include <vector>

int main()
{
    std::vector<int> v;         // 空

    std::printf("v.size() = %zu\n", v.size());
    std::printf("v.size() - 1 = %zu\n", v.size() - 1);

    if (v.size() - 1 > 0)
    {
        std::printf("「要素が2個以上ある」と判定された\n");
    }
    else
    {
        std::printf("「要素が1個以下」と判定された\n");
    }
    return 0;
}
v.size() = 0
v.size() - 1 = 18446744073709551615
「要素が2個以上ある」と判定された

空のvectorのsize() - 1は、-1ではなく、18446744073709551615でした。「要素が2個以上あるか」のつもりでsize() - 1 > 0と書くと、空のときだけ、「ある」と判定されます。要素がある場合は、正しく動くので、空のデータを渡されるまで、気づけません。

実験④:符号付きと符号なしを比べる

▼s2_cmp.cpp
#include <cstdio>

int main()
{
    int      a = -1;
    unsigned b = 1;

    std::printf("a = %d, b = %u\n", a, b);
    std::printf("a < b ? %s\n", a < b ? "true" : "false");        // 数学的には、-1 < 1 で true のはず
    std::printf("-1 < 1u ? %s\n", -1 < 1u ? "true" : "false");

    // 符号付きを、符号なしに変換すると、どうなるか
    std::printf("(unsigned)a = %u\n", (unsigned)a);
    std::printf("(size_t)a   = %zu\n", (size_t)a);
    return 0;
}
s2_cmp.cpp
s2_cmp.cpp(9): warning C4018: '<': signed と unsigned の数値を比較しようとしました。
a = -1, b = 1
a < b ? false
-1 < 1u ? false
(unsigned)a = 4294967295
(size_t)a   = 18446744073709551615

数学では、-1 < 1は真です。ところが、intの-1とunsignedの1を比べると、falseになりました。比べる前に、-1が、符号なしに変換されて、4294967295になるからです。

この場合、C4018(signedとunsignedの数値を比較しようとしました)という警告が、出ました。ただし、警告が出たのは、変数どうしを比べた9行目だけです。リテラル(-1 < 1u)を直接書いた行には、警告は出ませんでした。警告があてにできるのは、一部の書き方だけです。

実験⑤:負の添字を、at()に渡す

▼s6_at.cpp
#include <cstdio>
#include <exception>
#include <vector>

int main()
{
    std::vector<int> v = { 10, 20, 30 };
    int i = -1;

    try
    {
        std::printf("v.at(%d) を呼ぶ\n", i);
        std::printf("%d\n", v.at(i));        // intの-1が、size_tに変換される
    }
    catch (const std::exception& e)
    {
        std::printf("例外: %s\n", e.what());
    }
    return 0;
}
v.at(-1) を呼ぶ
例外: invalid vector subscript

intの-1を、at()に渡すと、size_tに変換されて、巨大な添字になります。at()は範囲を検査するので、invalid vector subscriptという例外になりました。[]の場合は、範囲を検査しないので、範囲外の読み書きになります(今回は、[]で試していません)。

直し方

①逆順ループを、安全に書く

逆順ループは、3通りに書き直して、要素が3個のvectorと、空のvectorの両方で、確かめました。

▼s5_fix.cpp
#include <cstdio>
#include <vector>

// 逆順に回す: 方法①  intにしてから回す
static void ReverseInt(const std::vector<int>& v)
{
    for (int i = (int)v.size() - 1; i >= 0; --i)
    {
        std::printf("%d ", v[i]);
    }
    std::printf("\n");
}

// 逆順に回す: 方法②  size_tのまま、「比べてから減らす」形にする
static void ReverseSizeT(const std::vector<int>& v)
{
    for (size_t i = v.size(); i-- > 0; )
    {
        std::printf("%d ", v[i]);
    }
    std::printf("\n");
}

// 逆順に回す: 方法③  逆向きイテレータ(範囲for文では書けないので、rbegin/rendを使う)
static void ReverseIter(const std::vector<int>& v)
{
    for (auto it = v.rbegin(); it != v.rend(); ++it)
    {
        std::printf("%d ", *it);
    }
    std::printf("\n");
}

int main()
{
    std::vector<int> full = { 10, 20, 30 };
    std::vector<int> empty;

    std::printf("[要素3個]\n");
    std::printf("  int      : "); ReverseInt(full);
    std::printf("  size_t   : "); ReverseSizeT(full);
    std::printf("  iterator : "); ReverseIter(full);

    std::printf("[空のvector]\n");
    std::printf("  int      : "); ReverseInt(empty);
    std::printf("  size_t   : "); ReverseSizeT(empty);
    std::printf("  iterator : "); ReverseIter(empty);

    // 「要素が2個以上あるか」は、引き算ではなく、そのまま比べる
    std::printf("empty.size() >= 2 ? %s\n", empty.size() >= 2 ? "true" : "false");
    return 0;
}
[要素3個]
  int      : 30 20 10 
  size_t   : 30 20 10 
  iterator : 30 20 10 
[空のvector]
  int      : 
  size_t   : 
  iterator : 
empty.size() >= 2 ? false

どの書き方も、要素3個では30 20 10と逆順に出て、空のvectorでは、何も出力せずに終わりました。それぞれの要点は、次のとおりです。

  • intにしてから回す:(int)v.size() - 1から始めて、i >= 0で回す。いちばん素直だが、要素数がintを超える場合は、不向き
  • size_tのまま、「比べてから減らす」:for (size_t i = v.size(); i-- > 0; )。i-- > 0で、0のときに、条件が偽になる
  • 逆向きイテレータ:rbegin()・rend()を使う。添字を使わないので、この種の間違いが、起きない

②C++20なら、std::ssizeを使う

▼s7_ssize.cpp(/std:c++20)
#include <cstdio>
#include <iterator>
#include <vector>

int main()
{
    std::vector<int> v = { 10, 20, 30 };

    // C++20: std::ssize は、符号付きの個数を返す
    for (auto i = std::ssize(v) - 1; i >= 0; --i)
    {
        std::printf("%d ", v[i]);
    }
    std::printf("\n");

    std::vector<int> empty;
    std::printf("empty: std::ssize(empty) - 1 = %lld\n", (long long)(std::ssize(empty) - 1));
    return 0;
}
30 20 10 
empty: std::ssize(empty) - 1 = -1

std::ssizeは、符号付きの個数を返します。空のvectorでも、size() - 1にあたる式が、-1になり、i >= 0の条件が、期待どおりに働きました。

③引き算ではなく、そのまま比べる

「要素が2個以上あるか」は、size() - 1 > 0ではなく、size() >= 2と書きます。上のs5_fix.cppの最後の行のように、空のvectorでも、falseと、正しく判定できました。HPのように、負の値になりうる値は、最初から、符号付きのintで持ちます。

符号なし整数の、よくある失敗例と対処法

①「0以上なので、unsignedにしておこう」と型を選ぶ

値が0以上になる意図だけで、unsignedにすると、引き算で、実験①のような事故になります。引き算をする値は、符号付きにしておくほうが、安全です。

②size()の戻り値を、そのままintと比べる

int iとv.size()を比べると、C4018の警告が出ます。警告を、(int)のキャストで黙らせる前に、値が負にならないかを確かめてください。

③警告が出ないので、安全だと思う

実験②・③・①では、/W4でも警告が出ませんでした。警告は、「出ないから、安全」の証拠にはなりません。空のデータや、0のデータでテストするのが、いちばん確実です。

注意点

  • 符号なし整数は、0から1を引くと、一周して最大値になる(unsignedは4294967295、size_tは18446744073709551615)
  • size_tのiにi >= 0と書くと、いつも真で、ループが止まらない(警告は出なかった)
  • size() - 1は、空のvectorで、巨大な値になる。「個数に1を足し引きして比べる」のをやめて、size() >= 2のように書く
  • -1 < 1uは、偽になる。変数どうしの比較にはC4018が出たが、リテラルには出なかった
  • 逆順ループは、i-- > 0・rbegin()・C++20のstd::ssizeで、安全に書ける
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、/W4 /O2 /MD)で行った。[]による範囲外アクセスは、試していない

この記事のポイント

  • 符号なし整数の「0を下回らない」性質は、引き算で、一周して巨大な値になることの裏返し
  • HPの10 - 15は、-5ではなく4294967291になり、敵が、倒れなくなった
  • 逆順ループは、i >= 0と書かない。i-- > 0か、rbegin()か、std::ssizeにする
  • 警告に頼らず、空・0のデータで動かす

unsignedは、「負の値が出ない」という安心ではなく、「負の値が、巨大な正の値に化ける」という、別の危険を連れてきます。引き算をする値には、符号付きを使う。この1つだけでも、今回の不具合の多くは、避けられます。

あわせて読みたい