ローカル変数への参照を、関数から返しました。実行してみると、ちゃんと42が出ます。何度か動かしても、問題はありません。そのまま、安心して先へ進みました。
ところが、同じコードを、Debugビルドで動かすと、別の関数を1つ呼んだだけで、値が-1046223990に化けました。Releaseで「動いていた」のは、たまたま、消えた変数の中身が、まだ残っていただけだったのです。この、「消えたものを指している」状態が、ダングリング(宙ぶらりん)です。
そこで、ダングリングが起きる代表的な4つの場面を、DebugとReleaseの両方で動かして、何が見えるのかを確かめました。検証は、Visual Studio 2022付属のMSVC(19.44、x64)で行っています。
ダングリング参照とは?
ダングリング参照とは、
・すでに消えてしまった(寿命が終わった)オブジェクトを、まだ指している参照やポインタ。読んでも書いても、未定義動作になる
です。ローカル変数は、関数が終わると、スタックから消えます。その場所を指したままの参照やポインタは、使った時点で、別の値や、別の変数の中身を見ることになります。
実験①:ローカル変数への参照を返す
▼d1_ref.cpp
#include <cstdio>
// ローカル変数への参照を、返してしまう
const int& GetScore()
{
int score = 42;
return score;
}
// 呼び出しのあとに、別の関数がスタックを使う(よくある状況)
static int Noise(int n)
{
int buf[16];
for (int i = 0; i < 16; ++i) buf[i] = n + i;
int sum = 0;
for (int i = 0; i < 16; ++i) sum += buf[i];
return sum;
}
int main()
{
const int& r = GetScore();
std::printf("呼び出し直後 : %d\n", r);
int dirty = Noise(1000);
std::printf("別の関数を呼んだ後: %d (Noiseの結果 = %d)\n", r, dirty);
return 0;
}まず、ビルドの時点で、警告が出ました。
d1_ref.cpp
C:\blogtest\dng\d1_ref.cpp(7) : warning C4172: ローカル変数またはテンポラリ : score のアドレスを返しますC4172(ローカル変数またはテンポラリのアドレスを返します)です。警告に気づければ、ここで止められます。気づかずに、実行すると、次のとおりです。
呼び出し直後 : 42
別の関数を呼んだ後: 42 (Noiseの結果 = 16120)呼び出し直後 : 42
別の関数を呼んだ後: -1046223990 (Noiseの結果 = 16120)Releaseでは、別の関数を呼んだあとでも、42のままでした。一方、Debugでは、最初は42でしたが、別の関数を呼んだあとは、-1046223990に変わりました。別の関数が、同じスタックの場所を使って、書き換えたからです。
つまり、Releaseで動いたのは、「正しいから」ではなく、「まだ、上書きされなかったから」です。条件が変われば、いつでも、値が変わります。
実験②:ローカル配列の先頭を、ポインタで返す
▼d2_ptr.cpp
#include <cstdio>
#include <cstring>
// ローカルの配列の先頭を、返してしまう
const char* GetName()
{
char buf[16];
strcpy_s(buf, sizeof(buf), "Slime");
return buf;
}
static void Noise()
{
char buf[64];
std::memset(buf, 'X', sizeof(buf));
buf[63] = '\0';
std::printf("(別の関数: %c...)\n", buf[0]);
}
// 文字を、表示できるものだけ、そのまま出す(表示できないバイトは、16進数で出す)
static void Dump(const char* s, int n)
{
for (int i = 0; i < n; ++i)
{
unsigned char c = (unsigned char)s[i];
if (c == 0) { std::printf("<00>"); break; }
std::printf(c >= 0x20 && c < 0x7f ? "%c" : "<%02X>", c);
}
std::printf("\n");
}
int main()
{
const char* name = GetName();
Noise();
std::printf("name = ");
Dump(name, 12);
return 0;
}(別の関数: X...)
name = Slime<00>(別の関数: X...)
name = <07><00>こちらも、C4172の警告は出ます。Releaseでは、Slimeと、正しく表示されました。Debugでは、<07><00>という、何の意味もないバイトが出ました(表示できないバイトは、<と>で囲んだ16進数で出しています)。charの配列を返すのは、昔のC言語の、よくある書き方ですが、C++では、std::stringを返すのが安全です。
実験③:string_viewに、一時オブジェクトを渡す
警告が出ない例も、あります。std::string_viewは、文字列の「覗き窓」で、中身を持ちません。元の文字列が消えると、窓は、消えた場所を指します。
▼d3_view.cpp
#include <cstdio>
#include <string>
#include <string_view>
static std::string MakeName()
{
return std::string("long_enemy_name_that_does_not_fit_in_sso");
}
int main()
{
// ①const参照で受ける: 一時オブジェクトの寿命が、参照と同じまで延びる(安全)
const std::string& ref = MakeName();
std::printf("[const参照] %s\n", ref.c_str());
// ②string_viewで受ける: 一時オブジェクトは、この行の終わりで消える
std::string_view sv = MakeName();
std::string pad(200, 'Z'); // 解放されたメモリが、再利用されやすいように、別の確保を挟む
std::printf("[string_view] 先頭12文字: ");
for (size_t i = 0; i < 12; ++i)
{
unsigned char c = (unsigned char)sv.data()[i];
std::printf(c >= 0x20 && c < 0x7f ? "%c" : "<%02X>", c);
}
std::printf(" (pad[0] = %c)\n", pad[0]);
return 0;
}[const参照] long_enemy_name_that_does_not_fit_in_sso
[string_view] 先頭12文字: long_enemy_n (pad[0] = Z)[const参照] long_enemy_name_that_does_not_fit_in_sso
[string_view] 先頭12文字: <DD><DD><DD><DD><DD><DD><DD><DD><DD><DD><DD><DD> (pad[0] = Z)const std::string&で受けた場合は、一時オブジェクトの寿命が、参照と同じまで延びるので、安全です。一方、std::string_viewで受けた場合は、一時オブジェクトが、その行の終わりで消えます。Releaseでは、消えたはずの文字列が、まだ、そのまま読めました(long_enemy_n)。Debugでは、消えたメモリが0xDDで埋められるため、<DD>が12個並びました。しかも、このコードは、警告なしで、ビルドが通ります。
実験④:vectorの要素への参照を持ったまま、追加する
▼d4_vec.cpp
#include <cstdio>
#include <vector>
int main()
{
std::vector<int> v = { 111, 222, 333 };
int& first = v[0]; // 先頭要素への参照
const int* before = v.data();
std::printf("push_back の前: v.data() = %p, first = %d\n", (const void*)before, first);
for (int i = 0; i < 100; ++i)
{
v.push_back(1000 + i); // 容量が足りなくなると、メモリが確保し直される
}
const int* after = v.data();
std::printf("push_back の後: v.data() = %p, v[0] = %d\n", (const void*)after, v[0]);
std::printf("確保し直された? %s\n", before != after ? "はい" : "いいえ");
std::printf("古い参照 first を読む: %d\n", first); // 解放済みのメモリを読んでいる
return 0;
}push_back の前: v.data() = 0000016A4EE7C340, first = 111
push_back の後: v.data() = 0000016A4EE7F790, v[0] = 111
確保し直された? はい
古い参照 first を読む: 111push_back の前: v.data() = 000002E72EF4F2B0, first = 111
push_back の後: v.data() = 000002E72EF51240, v[0] = 111
確保し直された? はい
古い参照 first を読む: -572662307push_backで、容量が足りなくなると、vectorは、メモリを確保し直して、中身を引っ越します。v.data()のアドレスが変わっていることが、実行結果で確認できました。引っ越し前の場所を指すfirstは、無効です。
Releaseでは、無効になったfirstを読んでも、111と、元の値が読めました。Debugでは、-572662307でした。これは、16進数で0xDDDDDDDD、つまり、解放済みのメモリに入れられる目印の値です。こちらも、警告は出ません。
vectorへのpush_backの前後で、参照やポインタ、イテレータが無効になることは、vectorのeraseの記事の、イテレータ無効化と、同じ仕組みです。
直し方
▼d5_fix.cpp
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
// ①値で返す
int GetScore() { return 42; }
// ②std::stringで返す(コピー、またはムーブされる)
std::string GetName() { return std::string("Slime"); }
// ③呼び出し側が用意した置き場所に、書き込ませる
void GetNameInto(char* out, size_t size) { std::snprintf(out, size, "%s", "Slime"); }
int main()
{
int score = GetScore();
std::string name = GetName();
char buf[16];
GetNameInto(buf, sizeof(buf));
std::printf("score = %d, name = %s, buf = %s\n", score, name.c_str(), buf);
// vector の要素は、参照ではなく、添字で持っておく(追加で、確保し直されても安全)
std::vector<int> v = { 111, 222, 333 };
size_t firstIndex = 0;
for (int i = 0; i < 100; ++i) v.push_back(1000 + i);
std::printf("添字で持った先頭 = %d\n", v[firstIndex]);
return 0;
}score = 42, name = Slime, buf = Slime
添字で持った先頭 = 111score = 42, name = Slime, buf = Slime
添字で持った先頭 = 111DebugとReleaseで、同じ結果になりました。直し方は、次のとおりです。
- 値で返す:
intのような小さい型は、そのまま返す。std::stringのような型も、返す側で作って返せば、コピーやムーブで、安全に渡せる - 呼び出し側に、書き込ませる:呼び出し側が用意した置き場所(
charの配列など)に、サイズを渡して書き込ませる vectorの要素は、参照ではなく、添字で持つ:追加で確保し直されても、添字なら、いつでも、有効な要素を指せるstring_viewは、元の文字列より長生きさせない:一時オブジェクトからは、作らない
ダングリング参照の、よくある失敗例と対処法
①「動いたから、大丈夫」と、そのままにする
実験①・②のとおり、Releaseでは、動いてしまいます。Debugでも動かして、値が化けないかを、確かめてください。
②C4172の警告を、無視する
この警告は、ほぼ確実に、バグを教えてくれます。警告をエラー扱いにする設定(/WX)も、選択肢です。
③警告が出ないので、安全だと思う
string_viewや、vectorの参照は、警告が出ませんでした。「持っている参照が、いつまで有効か」を、コードを書くときに、意識するしかありません。
④staticで、ごまかす
ローカル変数をstaticにすれば、返せますが、全員が、同じ変数を共有することになります。呼び出しのたびに上書きされて、別の不具合の原因になります。
注意点
- ローカル変数への参照・ポインタを返すと、
C4172の警告が出た(MSVC、/W3) - Releaseでは、消えた値が、そのまま読めてしまうことがある。Debugでは、
0xDDなどで、値が化けた std::string_viewを、一時オブジェクトから作ると、警告なしで、消えたメモリを指すvectorへの追加で、確保し直されると、それまでの参照・ポインタは、無効になる(警告なし)- 直し方は、値で返す、呼び出し側に書き込ませる、添字で持つのいずれか
- 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64)の、Release(
/O2 /MD)とDebug(/Od /MDd)で行った。値は、コンパイラや、直前の処理で変わりうる
この記事のポイント
- ダングリングは、「動いた」ことが、安全の証拠にならない。Releaseで42が出ても、Debugでは化けた
- 警告(
C4172)は、出たら必ず、直す。出ない場面(string_view・vector)もある - 直し方は、値で返す、呼び出し側に書き込ませる、添字で持つ
「正しく動いている」ように見えるバグは、あとから見つけるほど、手間がかかります。Debugでも動かす。警告を読む。この2つが、ダングリングを、早い段階で捕まえる、いちばん手軽な方法です。
あわせて読みたい
- 【C++】初期化していない変数の値はどうなる?|Debugでは動くのにReleaseだけ挙動が変わる原因を、同じコードで3通り実測
- 【C++】0xC0000005「アクセス違反」の原因とは?|落ちたときのアドレスを読むだけで、nullptr・解放済み・未初期化を見分ける方法
- 【C++】vectorのeraseをループ内で使うと落ちる・消し残る原因とは?|「たまたま動く」コードを実測して、erase-removeで安全に消す書き方を整理
- 【C++】参照(&)とは?|ポインタとの違い・使い分けを初心者向けに解説
- 【C++】スマートポインタ(unique_ptr/shared_ptr)とは?|new/deleteを卒業する安全なメモリ管理

