【C++】ローカル変数への参照を返すとどうなる?|Releaseでは動いてしまうダングリング参照を、DebugとReleaseで実測して直す

【C++】ローカル変数への参照を返すとどうなる?|Releaseでは動いてしまうダングリング参照を、DebugとReleaseで実測して直す C++

ローカル変数への参照を、関数から返しました。実行してみると、ちゃんと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 を読む: 111
push_back の前: v.data() = 000002E72EF4F2B0, first = 111
push_back の後: v.data() = 000002E72EF51240, v[0] = 111
確保し直された? はい
古い参照 first を読む: -572662307

push_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
添字で持った先頭 = 111
score = 42, name = Slime, buf = Slime
添字で持った先頭 = 111

Debugと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つが、ダングリングを、早い段階で捕まえる、いちばん手軽な方法です。

あわせて読みたい