本当に怖いC++

夏も終わりに近づいてきましたね

こんにちは、エンジニアの馬淵です。

夏といえば肝試し。
今回は筆者が体験した怖い話を披露したいと思います。

※以降に出てくるサンプルコードは
オンラインコンパイラでclangの12.0を使用したものです。

あの日私は、確かに見たんだ

それは遠い昔のことです。
Vector2の加算結果を取得する関数が必要になりました。
そこで以下のようなコードを書いたのです。

struct Vector2 {
	float x;
	float y;
};

const Vector2& Add(const Vector2& a, const Vector2& b)
{
	Vector2 ret;

	ret.x = a.x + b.x;
	ret.y = a.y + b.y;

	return ret;
}

int main()
{
	Vector2 vec1 = { 1.0f, 0.0f };
	Vector2 vec2 = { 2.0f, 0.0f };

	const auto& vec = Add(vec1, vec2);
	printf("x : %f\n", vec.x);
	printf("y : %f\n", vec.y);

	return 0;
}

実行結果は以下のようになりました。

x : 3.000000
y : 0.000000

よし、正しく動作している。
簡単じゃないか。

コードを修正しただけなのに

次の日、少しだけを修正する事にしました。

int main()
{
	Vector2 vec1 = { 1.0f, 0.0f };
	Vector2 vec2 = { 2.0f, 0.0f };

	const auto& vec = Add(vec1, vec2);

	auto* p = new Vector2();	// Vector2を新たにnew.

	printf("x : %f\n", vec.x);
	printf("y : %f\n", vec.y);

	delete p;			// newしたものはちゃんとdelete.

	return 0;
}

実行結果は以下のようになりました。

x : 582460223772004192891174912.000000
y : 0.000000

何かがおかしい…
怖くなった私は、何事も無かったようにコードを元に戻すことにしました。
(※絶対に不具合を隠蔽するような行為はやめましょう

怖くなった私は落ち着くためにも別の作業に入ることにしました。
今度は最適化レベルを-O1に上げ、処理速度を向上させます。
すると実行結果は以下のようになったのです…

x : 0.000000
y : 0.000000

こうして私は、
撃ち抜かれた自身の足とともに眠れない夜を過ごすことになるのでした…

どこで道を間違えたのか

さて、茶番が長くなってしまいましたが
ここから本題に入ります。

これは一体何が起こったのでしょう。
ここで、clangのコンパイル結果を見てみます。

Add(Vector2 const&, Vector2 const&):
        lea     rax, [rsp - 8]
        ret

身に覚えのない結果が出力されています。
加算する関数のはずが、スタックポインタを返すだけの関数になってしまいました。
コンパイラのバグでしょうか。

false == true

残念ながらこのコードには正しく動作しています。
意図した挙動になっていないのに正しいとはどういう事でしょうか。
なぜなら未定義動作が含まれているからです。
未定義動作を含むコードを実行した場合、何が起こるかわかりません。
鼻から悪魔が出てて来たり、タイムトラベルを起こしても文句は言えません。

今回のケースではAdd関数でローカル変数を参照で返していることに原因があります。
(細かく言えば、この関数を実行するだけでは未定義動作になりません。)

const Vector2& Add(const Vector2& a, const Vector2& b)
{
	Vector2 ret;

	// スタック上に確保されているローカル変数のアドレスを返している.
	return ret;
}

int main()
{
	// Add関数が終了するとローカル変数retは破棄されるため、
	// vecは無効なアドレスを指していることになる.
	const auto& vec = Add(vec1, vec2);

	// 無効なアドレスを使用すると未定義動作を引き起こす.
	printf("x : %f\n", vec.x);
}

解決には相棒が必要だ

このような未定義動作から身を守るために、コンパイラに耳を傾けましょう。
オプションを-Wall -Wextra指定して、警告レベルを上げます。

warning: reference to stack memory associated with local variable 'ret' returned [-Wreturn-stack-address]

これでコンパイル時にわかるようになりましたね。

Sanitizerをご存じか

clangにはUndefinedBehaviorSanitizerという
未定義動作をランタイムで検出するツールがあります。

今回は未定義動作の代表例である、配列境界外参照を検知してみます。

int main()
{
	int values[10] = {};
	values[10] = 0;     // おっと...

	return 0;
}

コンパイルオプションで-fsanitize=undefined指定して、サニタイザを使用してみます。

/app/example.cpp:33:5: runtime error: index 10 out of bounds for type 'int [10]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.cpp:6:5 in

ちゃんと検知してくれましたね。

このSanitizerには他にも、
リークなどのメモリに関する問題を検知するAddressSanitizer
データ競合を検出するThreadSanitizerがあります。
機会があれば試してみてください。

まとめ

未定義動作はとても怖いものですが
C/C++を書く以上、避けられないものだと思います。
良いツールを使用したり、経験を積んだり、(規格を読んだり)しながら上手く解決していきましょう。

余談ですがC++23では
std::unreachableなる関数が用意されています。

この関数を呼び出すと、なんと未定義動作を引き起こします。(なんだってー!)
なんでもコンパイラはstd::unreachableは呼び出されるはずがないと仮定して
いい感じに最適化してくれるそうですよ。

おぉ…怖い…怖い…