web-dev-qa-db-ja.com

整数オーバーフローはループのどの時点で未定義の動作になりますか?

これは私の質問を説明するための例であり、ここでは投稿できないはるかに複雑なコードが含まれます。

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

aは3番目のループでオーバーフローするため、このプログラムには私のプラットフォームで未定義の動作が含まれています。

それはプログラム全体に未定義の動作をさせますか、またはオーバーフローが実際に発生しますの後でのみですか?コンパイラは潜在的にawillオーバーフローとなるため、ループ全体が未定義であり、オーバーフローの前にすべてが発生する場合でもprintfsを実行することをわざわざ宣言できませんか?

(タグ付けされたCとC++は異なりますが、両方の言語の回答が異なる場合、それらの回答に興味があるためです。)

85
jcoder

純粋に理論的な答えに興味がある場合、C++標準では「タイムトラベル」に対する未定義の動作が許可されています。

[intro.execution]/5:整形式プログラムを実行する適合実装は、同じプログラムと同じ入力を使用して、抽象マシンの対応するインスタンスの可能な実行の1つと同じ観測可能な動作を生成します。ただし、そのような実行に未定義の操作が含まれる場合、この国際標準は、その入力でそのプログラムを実行する実装に要件を課しません(最初の未定義の操作の前の操作に関しても)

そのため、プログラムに未定義の動作が含まれている場合、プログラム全体の動作は未定義です。

107
TartanLlama

まず、この質問のタイトルを修正しましょう。

未定義の動作は、(具体的には)実行の領域ではありません。

未定義の動作は、コンパイル、リンク、ロード、実行のすべてのステップに影響します。

これを強化するためのいくつかの例は、網羅的なセクションがないことを念頭に置いてください。

  • コンパイラは、未定義の動作を含むコードの一部が実行されないことを前提とすることができるため、それらにつながる実行パスはデッドコードであると想定できます。 すべてのCプログラマーが未定義の動作について知っておくべきこと を参照してください。
  • リンカは、(名前で認識される)弱いシンボルの複数の定義が存在する場合、すべての定義が One Definition Rule のおかげで同一であると想定できます。
  • ローダー(動的ライブラリを使用する場合)は同じものを想定できるため、最初に見つかったシンボルを選択します。これは通常、(ab)Unix上のLD_PRELOADトリックを使用して呼び出しをインターセプトするために使用されます。
  • ダングリングポインターを使用すると、実行が失敗する可能性があります(SIGSEV)

これは、未定義の動作についてとても怖いです:事前に、正確な動作が発生することを予測することはほとんど不可能であり、この予測は、の更新ごとに再検討する必要がありますツールチェーン、基盤となるOS、...


マイケルスペンサー(LLVM開発者)によるこのビデオの視聴をお勧めします: CppCon 2016:My Little Optimizer:Undefined Behavior is Magic

31
Matthieu M.

16ビットのintをターゲットとする積極的に最適化するCまたはC++コンパイラは、know1000000000int型に追加するときの動作はundefined

どちらの標準でも、couldにプログラム全体の削除を含め、int main(){}を残したいことを何でも行うことができます。

しかし、より大きなintsはどうでしょうか?私はまだこれを行うコンパイラーを知りません(そして私はCやC++コンパイラー設計の専門家ではありません)が、sometime32ビットのint以上をターゲットとするコンパイラーは、ループが無限であることを理解します(iは変わりません)andしたがって、aは最終的にオーバーフローします。もう一度、出力をint main(){}に最適化できます。ここで私が言おうとしていることは、コンパイラの最適化が次第に攻撃的になるにつれて、未定義の振る舞い構造が予想外の形で現れているということです。

ループが無限であるという事実は、ループ本体の標準出力に書き込むため、それ自体は未定義ではありません。

28
Bathsheba

技術的には、C++標準では、プログラムに未定義の動作が含まれている場合、プログラム全体の動作 コンパイル時でも (プログラムが実行される前)は未定義です。

実際には、コンパイラーは(最適化の一部として)オーバーフローが発生しないと想定するため、少なくともループの3回目の反復(32ビットマシンを想定)でのプログラムの動作は未定義になりますが、 3回目の反復の前に正しい結果が得られる可能性があります。ただし、プログラム全体の動作は技術的に未定義であるため、プログラムが完全に誤った出力(出力なしを含む)を生成したり、実行中の任意の時点で実行時にクラッシュしたり、完全にコンパイルできなかったりすることはありません(未定義の動作はコンパイル時間)。

未定義の動作は、コードが何をすべきかについての特定の仮定を排除するため、最適化する余地をコンパイラに提供します。そうすることで、未定義の動作を含む仮定に依存するプログラムは、期待どおりに動作することが保証されません。そのため、C++標準ごとに未定義と見なされる特定の動作に依存しないでください。

11
bwDraco

なぜ未定義の動作ができることを理解するために @ TartanLlamaが適切に言えば「タイムトラベル」 、 'as-if'ルールを見てみましょう:

1.9プログラムの実行

1 この国際規格のセマンティック記述は、パラメータ化された非決定的な抽象マシンを定義しています。この国際規格では、準拠する実装の構造に要件はありません。特に、抽象マシンの構造をコピーまたはエミュレートする必要はありません。むしろ、以下で説明するように、抽象マシンの観察可能な動作をエミュレートする(のみ)には、適合実装が必要です。

これにより、入力と出力を備えた「ブラックボックス」としてプログラムを表示できます。入力は、ユーザー入力、ファイル、および他の多くのものである可能性があります。出力は、標準で言及されている「観測可能な動作」です。

標準は、入力と出力の間のマッピングのみを定義し、それ以外は何も定義しません。 「ブラックボックスの例」を記述することでこれを行いますが、同じマッピングを持つ他のブラックボックスも同様に有効であることを明示的に示します。つまり、ブラックボックスの内容は無関係です。

これを念頭に置いて、未定義の動作が特定の瞬間に発生すると言っても意味がありません。ブラックボックスのsample実装では、どこでいつ発生するかを言うことができますが、actualブラックボックスは何か完全に異なるため、いつどこでそれが発生するかを言えません。理論的には、コンパイラは、たとえば、可能なすべての入力を列挙し、結果の出力を事前に計算することを決定できます。その後、コンパイル時に未定義の動作が発生します。

未定義の動作は、入力と出力の間のマッピングが存在しないことです。プログラムには、一部の入力に対して未定義の動作があり、他の入力に対しては定義された動作があります。次に、入力と出力の間のマッピングは単に不完全です。出力へのマッピングが存在しない入力があります。
質問のプログラムには、入力に対する未定義の動作があるため、マッピングは空です。

9
alain

TartanLlamaの答えは正しいです。未定義の動作は、コンパイル時であっても、いつでも発生する可能性があります。これはばかげているように見えるかもしれませんが、コンパイラが必要なことを実行できるようにするための重要な機能です。コンパイラーになるのは必ずしも簡単ではありません。毎回、仕様に書かれているとおりに行う必要があります。ただし、特定の動作が発生していることを証明するのは非常に困難な場合があります。停止する問題を覚えていれば、特定の入力が与えられたときにそれが完了するか無限ループに入るかどうかを証明できないソフトウェアを開発するのはかなり簡単です。

コンパイラーを悲観的にすることができ、次の命令が問題のようなこれらの停止問題の1つである可能性を恐れて絶えずコンパイルできますが、それは合理的ではありません。代わりに、コンパイラにパスを渡します。これらの「未定義の動作」トピックについては、責任を一切負いません。未定義の振る舞いは非常に微妙な振る舞いすべてで構成されているので、本当に厄介な不当な停止問題などからそれらを区別するのに苦労しています。

私が投稿したい例がありますが、私はソースを失ったことを認めていますが、私は言い換えなければなりません。 MySQLの特定のバージョンからのものでした。 MySQLでは、ユーザー提供のデータで満たされた循環バッファーがありました。もちろん、彼らはデータがバッファをオーバーフローさせないようにしたかったので、チェックしました:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

それは十分に健全に見えます。ただし、numberOfNewCharsが本当に大きく、オーバーフローした場合はどうなりますか?その後、ラップアラウンドし、endOfBufferPtrより小さいポインターになるため、オーバーフローロジックは呼び出されません。そこで、彼らはその前に2番目のチェックを追加しました。

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

バッファオーバーフローエラーを処理したようです。ただし、Debianの特定のバージョンでこのバッファがオーバーフローするというバグが提出されました!慎重な調査の結果、このバージョンのDebianは、特に最先端のバージョンのgccを初めて使用したことがわかりました。このバージョンのgccでは、コンパイラはcurrentPtr + numberOfNewCharsがneverをcurrentPtrよりも小さいポインタにできることを認識しました。これは、ポインタのオーバーフローが未定義の動作だからです! gccがチェック全体を最適化するにはこれで十分であり、チェックするためのコードを記述したにもかかわらず、突然バッファオーバーフローから保護されませんでした!

これは仕様の動作でした。すべては合法でした(私が聞いたところによると、gccは次のバージョンでこの変更をロールバックしました)。私は直感的な動作とは考えていませんが、想像力を少し伸ばせば、この状況のわずかな変化がコンパイラーの停止問題になるのは簡単です。このため、仕様書の作成者は「未定義の動作」にし、コンパイラーはそれが満足することは何でもできると述べました。

6
Cort Ammon

intが32ビットであると仮定すると、3回目の反復で未定義の動作が発生します。たとえば、ループが条件付きでのみ到達可能である場合、または3回目の反復の前に条件付きで終了できる場合、3回目の反復に実際に到達しない限り、未定義の動作はありません。ただし、未定義の動作のイベントでは、プログラムのすべての出力は未定義で、未定義の動作の呼び出しに関連する「過去」の出力を含みます。たとえば、あなたの場合、これは、出力に3つの「Hello」メッセージが表示される保証がないことを意味します。

6
R..

理論的な答えを超えて、実際の観察は、長い間、コンパイラーがループにさまざまな変換を適用して、ループ内で行われる作業量を減らすことです。たとえば、次の場合:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

コンパイラはそれを次のように変換します。

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

したがって、ループの繰り返しごとに乗算を保存します。コンパイラがさまざまな程度の積極性で適応した最適化の追加形式は、次のようになります。

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

オーバーフロー時のサイレントラップアラウンドを備えたマシンでさえ、nより小さい数があると誤動作する可能性があり、スケールを掛けると0になります。また、メモリからスケールが複数回読み取られると、予期せずに値を変更しました(UBを呼び出さずに「スケール」がループの途中で変更される可能性がある場合、コンパイラーは最適化を実行できません)。

2つの短い符号なし型を乗算してINT_MAX + 1とUINT_MAXの間の値を生成する場合、このような最適化のほとんどは問題ありませんが、gccには、ループ内の乗算によってループが早期終了する場合があります。生成されたコードの比較命令に起因するこのような動作に気付きませんでしたが、コンパイラーがオーバーフローを使用して、ループが最大4回以下実行できることを推測する場合に観察できます。推論によってループの上限が無視される場合でも、一部の入力がUBを引き起こし、他の入力がUBを引き起こさない場合、デフォルトでは警告を生成しません。

4
supercat

定義上、未定義の動作は灰色の領域です。あなたは単にそれが何をするかしないかを予測することはできません-それが「未定義の振る舞い」meansです。

太古の昔から、プログラマーは未定義の状況から定義の残りを救おうと常に試みてきました。彼らは本当に使いたいコードをいくつか持っていますが、それは未定義であることが判明しているので、彼らは次のように主張しようとします。 その。」そして、これらの議論は多かれ少なかれ正しいかもしれませんが、しばしば間違っています。そして、コンパイラーがますます賢くなるにつれて(または、一部の人々は、スニーカーとスニーカーと言うかもしれません)、質問の境界は変化し続けます。

本当に動作することが保証され、長時間動作し続けるコードを書きたい場合は、選択肢が1つしかありません。確かに、あなたがそれに手を出したら、それはあなたを悩ませるために戻ってきます。

4
Steve Summit

あなたの例が考慮していないことの1つは、最適化です。 aはループで設定されますが、使用されることはなく、オプティマイザーがこれを解決できます。そのため、オプティマイザーがaを完全に破棄することは正当であり、その場合、未定義の動作はすべてboojumの犠牲者のように消えます。

ただし、最適化は未定義であるため、もちろんこれ自体は未定義です。 :)

1
Graham

この質問はデュアルタグ付きのCとC++であるため、両方に対処してみます。 CとC++は、ここで異なるアプローチを取ります。

Cでは、プログラム全体を未定義の動作として処理するために、実装は未定義の動作が呼び出されることを証明できなければなりません。 OPの例では、コンパイラーがそれを証明するのは簡単なように見えるので、プログラム全体が未定義であるかのようです。

これは 欠陥レポート109 から見ることができます。

ただし、C標準が「未定義の値」(単なる作成には完全に「未定義の動作」が含まれない)の別個の存在を認識する場合、コンパイラテストを行う人は、次のようなテストケースを記述でき、適合実装は、少なくとも「失敗」せずにこのコードをコンパイルする必要があります(場合によっては要求することもあります)。

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

一番下の質問は次のとおりです。上記のコードは「翻訳に成功する」必要があります(どういう意味でも)。 (5.1.1.3節に添付されている脚注を参照してください。)

応答は次のとおりです。

C標準では、「未定義値」ではなく「不定値」という用語を使用しています。不定の値付きオブジェクトを使用すると、未定義の動作が発生します。 5.1.1.3節の脚注では、有効なプログラムがまだ正しく変換されている限り、実装は任意の数の診断を自由に生成できると指摘しています。 評価により未定義の動作が発生する式が定数式が必要なコンテキストに表示される場合、それを含むプログラムは厳密には適合していません。さらに、特定のプログラムの実行が可能な場合は、結果は未定義の動作となり、指定されたプログラムは厳密に適合していません。 適合した実装は、厳密に適合したプログラムの変換に失敗してはなりません。単にそのプログラムを実行すると、未定義の動作が発生する可能性があるからです。 fooは決して呼び出されないかもしれないので、与えられた例は適合実装によって正常に翻訳されなければなりません。

C++では、アプローチはよりリラックスしているように見え、実装が静的に証明できるかどうかに関係なく、プログラムに未定義の動作があることを示唆します。

[intro.abstrac] p5 があります:

適切な形式のプログラムを実行する適合実装は、同じプログラムと同じ入力を使用して、抽象マシンの対応するインスタンスの可能な実行の1つと同じ観測可能な動作を生成します。ただし、そのような実行に未定義の操作が含まれる場合、このドキュメントでは、その入力でそのプログラムを実行する実装に要件はありません(最初の未定義の操作に先行する操作に関しても)。

0
Shafik Yaghmour