2019年8月15日 | ブログ記事

C++未定義動作入門

eiya

この記事は授業や独学でC/C++を覚えたけど良く分からないバグを踏みがちという方を想定読者としています。

注:この記事は2019/08に書かれました。参照している情報は当時のものであり、著者はC99,C11,C++17,C++20に明るく無いため説明が不十分な可能性があります。

これは夏のブログリレー2019の6日目の記事です。多分。

18Bのeiyaです。プログラミングをします。C++を書きます。Cを書きません。Unityは書けません。悲しい。

CやC++を書いていると「実行するたびに結果が変わる」とか「手元の環境と他の人の環境で結果が変わる」とか「書いたとおりに動かない」とかいうことがたまによくあります。この記事ではそういった事例の原因となる†やばいコード†の例を少しだけ紹介しようと思います。
せっかくなら真面目に未定義動作について書きたかったんですが、僕はC++警察をやっていないのでそこまでしっかり書けませんでした。いつか勉強してリベンジしたい。
色々な人が色々な引っかかり方をしているので、検索すると他にも色々と出てくると思います。

一応C++14まではチェックしているつもりですが、不正確な説明があったらごめんなさい。

用語

多くの場合、C/C++が先ほど紹介したような変な挙動をするのは規格で以下のように定義されている動作のいずれかです。

全てに共通することは、「手元の環境と他の人の環境で結果が変わる可能性がある」ことです。

処理系定義の動作は環境を変えなければ必ず同じ動作をします。sizeof(int*)の値などがこれに相当します。

未規定の動作は規格で決められたいくつかの候補の内どれか一つの動作をします。
例えば、int a=10;int b = ++a + a;でのbの値は2122のどちらかです。
環境を変えなければ同じ結果になる可能性が高いですが、規格では同じになることは保証されていないようです(自信が無い)。

未定義の動作は規格で動作が定められていません。意図していないのに実行するたびに結果が変わる場合はまずこれを疑うべきでしょう。(もちろん時刻や乱数を使っている等、変化することが自然である場合を除きます)
これは絶対にやっては行けません。「異常終了する」事さえ決まっていません。そのため、書いたとおりに動かないという事態になったり、異常発生後しばらく何事もなかったかのように動いてから唐突に異常終了したりします。規格としてはPCがシャットダウンされたり、SNSを乗っ取ったりして良いことになっています。実際に条件によっては未定義動作を上手くコントロールしてハッキングすることが出き、CTF(セキュリティコンテストのようなもの)のpwnという分野の問題になっています。
符号有り整数のオーバーフローも未定義動作に含まれます。

ここからは、初心者がやりがちな具体例を見ていきます。

ちなみにC++の規格に関する用語の日本語訳は結構ぶれるので、検索する際はそのつもりで居ると良いでと思います。正確を期すならば、特に機械翻訳らしき文は原文と見比べながら読むのが良いでしょう。

式の評価順

f(a + b, c + d)とあったとき、a + bが先に実行されるか、c + dが先に実行されるかは未規定の動作です。
他にも、(a) + (b)での(a)(b)等がこれにあたります。
C++17では一部の式の評価順が規定されましたが、今はまだC++17未満の環境も多い為、避けるべきでしょう。
例1:C++14以前でのmapを使った事故

#include <iostream>
#include <map>

int main()
{
	std::map<int, int> m;
	//実際のコードではここにmに代入する処理が入る
	m[-1] = m.size();//番兵として、-1(最小値)にsizeを入れたい
	std::cout << m[-1] << std::endl;//C++14以前:0又は1(未規定) C++17以後:0
}

std::map[]には「対応する要素が存在しない場合は生成して返す」という機能があり、m[-1]が先に処理された場合はm[-1]が生成され、sizeが1増えてしまいます。
そのため、m[-1]が先に処理されるか、m.size()が先に処理されるかで結果が変わってしまいます。
例2:関数呼び出し順の事故

#include <iostream>

int getdistance(const int data[], int dist[]);
int backtrace(const int data[], const int dist[]);
int solve(int m, int result);

int main()
{
    int data[1000];
    int dist[1000];
    //dataの入力
    
    std::cout << solve(getdistance(data, dist), backtrace(data, dist)) << endl;
    //↑正しくは
    //int m = getdistance(data, dist);
    //int result = backtrace(data, dist);
    //std::cout << solve(m, result) << std::endl;
}

関数が三つあり、getdistanceでdistに書き込み、backtraceはその書き込まれたdistを使って計算を行い、最後にsolveを呼び出しているシチュエーションです。
ここでsolve(getdistance(data, dist), backtrace(data, dist))のように纏めてしまうと、backtrace(data, dist)が先に呼ばれてしまった場合に間違った挙動をすることになります。
関数の引数に関数の戻り値を使う場合はよく考えましょう。
今回は引数ですが、関数内でグローバル変数を用いている場合にも要注意です。特に、Cの標準関数にはstrtokを始めとして隠れたグローバル変数を参照する関数が存在するので注意しましょう。

配列の範囲外アクセス

他の言語と異なり、C/C++では(規格では)範囲外アクセスを行っていません。
範囲外アクセスは未定義動作です。その場で終了するとは限りませんし、エラーを出さずに変な動作をするかもしれません。コンパイラの癖を使うと、ハッキングもできます。
下の例は、範囲外アクセスをした結果、抜けられないはずの無限ループを抜けて、正常終了してしまう例です。

#include <stdio.h>
int main()
{
    int flag = 1;
    int a[5];
    int i = 0;
    while(flag) {
        a[i] = 0;
        i += 1;
        printf("i = %d\n", i);
    }
    puts("FIN!?");
}

実行結果の一例

i = 1
i = 2
i = 3
i = 4
i = 5
i = 6
i = 7
FIN!?

これは偶々こうなるというだけで、未定義動作なので何かの拍子にすぐに結果が変わるはずです。例えば-O2オプションを付けて最適化すると、無限ループになります。

前者の理屈はバッファオーバーラン CTFとかで検索すると良いと思います。
後者の理屈は最適化で変数flagが消えているからと思われます。
ただし、未定義動作に対し、どうしてそのような挙動をするのかを探るのは(ハッキングするのでなければ)本質的に無意味です。ただ「未定義動作だから」の一言で終わらせることをお勧めします。楽しい事例もあるので。

classのmalloc

C++ではclassやstructに対してmallocを使うことは未定義動作です。
(もう少し厳密にはPOD型でない型に対してコンストラクタを呼ばないことは未定義動作です)
必ずmalloc/freeではなくnew/deleteを使うようにしてください。

本当はnew/deleteですらなくてunique_ptrを使って欲しい。

未定義動作は何故存在するのか

そもそもC/C++には何故未定義動作が存在するのでしょう。
裏を取っていないのですが、僕は恐らく速さの為だと思っています。

例えば配列の範囲外アクセスを「未定義動作」ではなく「プログラムを終了する」という風に規定した場合、a[i] = 0;という一文に対し、コンパイラは内部的に次のようなコードを生成しなければなりません。

if(i < 0 || size <= i){
    プログラムを終了する;
}
a[i] = 0;

if文が増え、処理が重くなっているのが分かると思います。また、sizeという変数も計算しなければなりません。この結果、メモリ使用量や計算時間が増えてしまうのが分かると思います。
よくC/C++が速いと言われますが、その速さの所以の一端はここにあります。他の言語にあるようなエラーチェックや高度な機能が無い分、それを速さに回しているのです。

Cは1970年頃に開発された言語で、当時のコンピュータのスペックを考えれば動かすプログラムはギリギリまで切り詰める必要がありました。恐らく、その環境下では「プログラマが気を付ければおこらないはず」のエラーチェックにコストを割く余裕が無かったのでしょう。
もう一つの要素として、規定していない方がコンパイラが作りやすく、コンパイラに最適化の余地が残しやすいというのもあったように思います。
C/C++は今に至るまでその系譜を受け継いできています。

ただ、C/C++の派生言語の多くはエラーチェックをしっかりと行っていることを考えると、今のメモリもCPUも豊富に使える時代にはCの速さは不要かもしれませんね。

最後に

最初にも書きましたが、色々な方が記事を書かれています。
是非自分でも調べて見て下さい。
最後に僕が調べて面白いなと思ったものを載せておきます。

明日は@Komichiさんの記事が公開される予定です。お楽しみに!

この記事を書いた人
eiya

プログラミングをやっています。 メイン言語はC++14 競プロ/ゲーム制作/CTF

この記事をシェア

このエントリーをはてなブックマークに追加

活動の紹介

カテゴリ

タグ