敵に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 subscriptintの-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 = -1std::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つだけでも、今回の不具合の多くは、避けられます。


