動作が未定義のブランチは到達不能と見なされ、デッドコードとして最適化されますか?


88

次のステートメントを検討してください。

*((char*)NULL) = 0; //undefined behavior

明らかに未定義の動作を引き起こします。特定のプログラムにそのようなステートメントが存在するということは、プログラム全体が未定義であること、または制御フローがこのステートメントにヒットしたときにのみ動作が未定義になることを意味しますか?

次のプログラムは、ユーザーが番号を入力しない場合に備えて明確に定義されています3か?

while (true) {
 int num = ReadNumberFromConsole();
 if (num == 3)
  *((char*)NULL) = 0; //undefined behavior
}

それとも、ユーザーが何を入力しても、完全に未定義の動作ですか?

また、コンパイラは、未定義の動作が実行時に実行されないことを想定できますか?これにより、時間を遡って推論することができます。

int num = ReadNumberFromConsole();

if (num == 3) {
 PrintToConsole(num);
 *((char*)NULL) = 0; //undefined behavior
}

ここで、コンパイラーは、num == 3未定義の動作を常に呼び出す場合に備えて推論することができます。したがって、このケースは不可能でなければならず、番号を印刷する必要はありません。ifステートメント全体を最適化することができます。この種の後方推論は規格に従って許可されていますか?


19
担当者が多いユーザーは、「担当者が多いので、これは良い質問です」という理由で、質問に対する賛成票が増えるのではないかと思うことがありますが、この場合、質問を読んで「すごい、これは素晴らしい「私がアスカーを見さえする前に。
turbulencetoo

4
未定義の振る舞いが現れる時期は未定義だと思います。
eerorika 2014

6
C ++標準では、どの時点でも動作が未定義の実行パスは完全に未定義であると明示されています。パス上で動作が未定義のプログラムは完全に未定義であると解釈します(他の部分については妥当な結果が含まれますが、保証はされません)。コンパイラは、未定義の動作を自由に使用してプログラムを変更できます。blog.llvm.org/2011/05/what-every-c-programmer-should-know.htmlには、いくつかの優れた例が含まれています。
イェンス

4
@Jens:それは実際には実行パスだけを意味します。そうでなければあなたはトラブルに巻き込まれconst int i = 0; if (i) 5/i;ます。
MSalters 2014

1
コンパイラは一般に、それがPrintToConsole呼び出されないことを証明できないためstd::exit、呼び出しを行う必要があります。
MSalters 2014

回答:


65

特定のプログラムにこのようなステートメントが存在するということは、プログラム全体が未定義であること、または制御フローがこのステートメントにヒットしたときにのみ動作が未定義になることを意味しますか?

どちらでもない。最初の状態は強すぎ、2番目の状態は弱すぎます。

オブジェクトアクセスは時々シーケンスされますが、標準は時間外のプログラムの動作を記述します。Danvilはすでに引用しています:

そのような実行に未定義の操作が含まれている場合、この国際標準は、その入力でそのプログラムを実行する実装に要件を課しません(最初の未定義の操作の前の操作に関してさえも)

これは解釈できます:

プログラムの実行によって未定義の動作が生じる場合、プログラム全体の動作は未定義です。

したがって、UBで到達できないステートメントはプログラムUBを与えません。(入力の値のため)到達できないという到達可能なステートメントは、プログラムUBを提供しません。そのため、最初の状態が強すぎます。

現在、コンパイラーは一般にUBの内容を判別できません。したがって、オプティマイザーが、動作が定義された場合に再順序付け可能な潜在的なUBを使用してステートメントを再順序付けできるようにするには、UBが「時間をさかのぼって」前のシーケンスポイント(またはCの前)に失敗することを許可する必要があります++ 11の用語。UBがUBの前に順序付けられるものに影響を与えるため)。したがって、2番目の状態は弱すぎます。

この主な例は、オプティマイザが厳密なエイリアシングに依存している場合です。厳密なエイリアシング規則の要点は、問題のポインターが同じメモリをエイリアスする可能性があった場合に、コンパイラーが有効に並べ替えられなかった操作を並べ替えることを可能にすることです。したがって、不正なエイリアスポインターを使用し、UBが発生した場合、UBステートメントの「前」のステートメントに簡単に影響を与える可能性があります。抽象マシンに関する限り、UBステートメントはまだ実行されていません。実際のオブジェクトコードに関する限り、部分的または完全に実行されています。しかし、標準は、オプティマイザがステートメントを並べ替えることが何を意味するのか、またはそれがUBにどのような影響を与えるのかについては詳しく説明しません。それは、それが喜ばれるとすぐに失敗する実装ライセンスを与えるだけです。

これは「UBにはタイムマシンがある」と考えることができます。

特にあなたの例に答えるために:

  • 3が読み取られた場合の動作は未定義です。
  • 基本ブロックに未定義であることが確実な操作が含まれている場合、コンパイラーはコードを無効として除去できます。基本的なブロックではないが、すべてのブランチがUBにつながるケースでは、それらは許可されます(私はそう思います)。この例は、PrintToConsole(3)どういうわけか戻ってくることが確実であることがわかっている場合を除き、候補にはなりません。例外などをスローする可能性があります。

2番目に似た例は、gccオプションです-fdelete-null-pointer-checks。これは、次のようなコードをとることができます(この特定の例はチェックしていません。一般的なアイデアの例として考えてください)。

void foo(int *p) {
    if (p) *p = 3;
    std::cout << *p << '\n';
}

それを次のように変更します。

*p = 3;
std::cout << "3\n";

どうして?if pがnullの場合、コードにはとにかくUBがあるため、コンパイラーはそれがnullではないと想定し、それに応じて最適化することがあります。Linuxカーネルはこれ(https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897)をトリップしましたが、これ、nullポインターの逆参照想定されていないモードで動作するためですUBである場合、カーネルが処理できる定義済みのハードウェア例外が発生することが予想されます。最適化が有効になっている場合、gccでは、-fno-delete-null-pointer-checks標準を超える保証を提供するためにの使用が必要です。

PS「未定義の動作はいつ発生しますか?」という質問に対する実際的な回答です。「その日出発する予定の10分前」です。


4
実際、これにより、これまでかなりの数のセキュリティ問題がありました。特に、事後のオーバーフローチェックは、これが原因で最適化されてしまう危険があります。たとえば void can_add(int x) { if (x + 100 < x) complain(); }、完全に最適化することができます。オーバーフローしない場合何も起こりx+100 ません。オーバーフローする場合x+100 、標準に従ってUBであるため、何も起こりません。
fgp 14

3
@fgp:そうです、それは、コンパイラーが意図的にコードを破壊して罰せているように感じ始めるため、人々がつまずくと苦痛を訴える最適化です。「もしあなたがそれを削除して欲しかったのに、なぜ私はそれをそのように書いたのだろう!」;-)しかし、オーバーフローがないと仮定し、それらの場合にのみ必要となるであろう高価なものを回避するために、より大きな算術式を操作するときにオプティマイザが役立つと思います。
スティーブジェソップ2014

2
ユーザーが3を入力しない場合、プログラムは未定義ではないが、実行中に3を入力した場合、実行全体が未定義になるというのは正しいでしょうか。プログラムが未定義の動作を呼び出すことが100%確実であるとすぐに(そしてそれよりも早く)動作は何でも許可されます。私のこれらの声明は100%正しいですか?
usr 14

3
@usr:それは正しいと思います、はい。あなたの特定の例(そして処理されているデータの不可避性についていくつかの仮定を行う)で、私は実装が原則としてバッファリングされたSTDINを先読みし3て、望むならその日のために家に帰ることができると思います着信。
スティーブジェソップ2014

3
PSの追加+1(可能な場合)
Fred Larson

10

標準状態は1.9 / 4です。

[注:この国際規格は、未定義の動作を含むプログラムの動作に要件を課していません。—エンドノート]

興味深い点は、おそらく「含む」が意味することです。少し後に1.9 / 5でそれは述べています:

ただし、そのような実行に未定義の操作が含まれている場合、この国際標準は、その入力でそのプログラムを実行する実装に要件を課しません(最初の未定義の操作の前の操作に関してさえも)。

ここでは、特に「その入力での実行...」に言及しています。現在実行されていない可能性のある1つのブランチでの未定義の動作は、現在の実行ブランチには影響を与えないと解釈します。

ただし、別の問題は、コード生成中の未定義の動作に基づく仮定です。詳細については、Steve Jessopの回答を参照してください。


1
文字通りにとると、それは存在するすべての実際のプログラムの死刑判決です。
usr 14

6
コードに実際に到達する前に UBが表示されるかどうかは問題ではなかったと思います。私が理解しているように、問題は、コードに到達しない場合でもUBが表示されるかどうかでした。そしてもちろん、その答えは「ノー」です。
sepp2k 2014

まあ標準はこれについて1.9 / 4でそれほど明確ではありませんが、1.9 / 5はおそらくあなたが言ったことと解釈できます。
Danvil 14

1
注記は非規範的です。1.9 / 5は1.9 / 4のノートに
勝る

5

有益な例は

int foo(int x)
{
    int a;
    if (x)
        return a;
    return 0;
}

現在のGCCと現在のClangの両方がこれを(x86で)最適化して

xorl %eax,%eax
ret

それらは、制御パスのUBから常にゼロであるxと推定するためif (x)です。GCCは、使用されていない初期化された値の警告さえ提供しません!(上記のロジックを適用するパスは、初期化されていない値の警告を生成するパスの前に実行されるため)


1
興味深い例。最適化を有効にすると警告が非表示になるのはやや厄介です。これも文書化されていません-GCCのドキュメントでは、最適化を有効にするとより多くの警告が生成されると述べています。
sleske 2014

@sleske厄介だと私は同意しますが、初期化されていない値の警告は「正しくする」ことはよく知られています-警告を完全に行うことは停止問題と同等です。そのため、コンパイラの作成者は1バレルを超えています。私はGCCをハッキングしたことがありましたが、誰もが初期化されていない値の警告パスを混乱させるのを怖がっていたことを思い出します。
zwol 2014年

@zwol:このようなデッドコードの除去から生じる「最適化」のどの程度が実際に有用なコードを小さくし、プログラマーがコードを大きくするのか(たとえばa、すべての状況においても初期化するコードを追加することによって)初期化されていないa関数に渡されるので、関数はそれを使用して何もしません)?
スーパーキャット2015

@supercat私は10年ほどコンパイラの作業に深く関わっていないので、おもちゃの例から最適化について推論することはほとんど不可能です。私が正しく覚えていれば、このタイプの最適化は、実際のアプリケーションで全体的に2〜5%のコードサイズの削減に関連する傾向があります。
zwol 2015

1
@supercat 2-5%は、これらが進むにつれて巨大です。私は人々が0.1%汗をかくのを見てきました。
zwol 2015

4

現在のC ++ワーキングドラフトは1.9.4で

この国際規格は、未定義の動作を含むプログラムの動作に要件を課していません。

これに基づいて、実行パスに未定義の動作を含むプログラムは、その実行のたびに何でも実行できると思います。

未定義の動作とコンパイラーが通常行うことについて、2つの本当に良い記事があります。


1
それは意味がありません。int f(int x) { if (x > 0) return 100/x; else return 100; }もちろん、関数が未定義であっても、関数が未定義の動作を呼び出すことはありません100/0
fgp

1
@fgpしかし、標準(特に1.9 / 5)が言っているのは、未定義の動作に到達できる場合、いつ到達しても問題ないということです。たとえば、printf("Hello, World"); *((char*)NULL) = 0 何も印刷することが保証されていません。コンパイラーは、未定義の動作を考慮せずに、最終的に発生することがわかっている操作(もちろん、依存関係の制約に従う)を自由に並べ替えることができるため、最適化に役立ちます。
fgp

100/0が評価される入力がないので、関数を含むプログラムには未定義の動作は含まれていません。
イェンス

1
まさに-重要なのは、UBが実際にトリガーできるかどうかであり、理論的にトリガーできるかどうかではありません。またはint x,y; std::cin >> x >> y; std::cout << (x+y);、「1 + 1 = 17」と言うことを許可されていると主張する準備ができていますか?それは、オーバーフローするいくつかの入力があるためですx+yint署名された型なのでUB です)。
fgp 14

正式には、それをトリガーする入力が存在するため、プログラムの動作は未定義であると言えます。しかし、C ++のコンテキストではこれが意味をなさないのは正しいです。未定義の動作なしにプログラムを書くことは不可能だからです。C ++で未定義の動作が少なくなったときにそれが欲しいのですが、それは言語が機能する方法ではありません(これにはいくつかの正当な理由がありますが、私の日常的な使用には関係ありません...)。
イェンス

3

「振る舞い」という言葉は、何かが行わていることを意味します。決して実行されないstatemenrは「ふるまい」ではありません。

イラスト:

*ptr = 0;

それは未定義の動作ですか?100%確信しているとしましょうptr == nullptrプログラムの実行中に少なくとも1回でます。答えはイエスです。

これはどうですか?

 if (ptr) *ptr = 0;

未定義ですか?(覚えてptr == nullptr少なくとも一度はていますか?)そうでないことを望みます。そうしないと、有用なプログラムをまったく作成できなくなります。

この回答の作成に害を及ぼすスランダルデスはありません。


3

未定義の動作は、次に何が起こってもプログラムが未定義の動作を引き起こす場合に発生します。ただし、次の例を示しました。

int num = ReadNumberFromConsole();

if (num == 3) {
 PrintToConsole(num);
 *((char*)NULL) = 0; //undefined behavior
}

コンパイラがの定義を知らない限り、条件付きをPrintToConsole削除することはできませんif (num == 3)。あなたが持っていると仮定しましょうLongAndCamelCaseStdio.hの次の宣言でシステムヘッダをPrintToConsole

void PrintToConsole(int);

あまり役に立ちませんでした。ここで、この関数の実際の定義を確認して、ベンダーがどれほど悪(またはそれほど悪ではなく、未定義の動作が悪化していたかもしれない)かを見てみましょう。

int printf(const char *, ...);
void exit(int);

void PrintToConsole(int num) {
    printf("%d\n", num);
    exit(0);
}

コンパイラーは実際には、コンパイラーが何を行うかを知らない任意の関数が終了するか、例外をスローする可能性があると想定する必要があります(C ++の場合)。呼び出し*((char*)NULL) = 0;後に実行が継続されないため、実行されないことに気づくでしょうPrintToConsole

未定義の動作は次の場合に発生します PrintToConsole実際に戻ったに発生します。コンパイラはこれが発生しないことを期待しているため(これが原因でプログラムが未定義の動作を実行するため)、何かが発生する可能性があります。

ただし、別のことを考えてみましょう。nullチェックを実行していて、nullチェック後に変数を使用するとします。

int putchar(int);

const char *warning;

void lol_null_check(const char *pointer) {
    if (!pointer) {
        warning = "pointer is null";
    }
    putchar(*pointer);
}

この場合、lol_null_check非NULLポインターが必要であることは簡単にわかります。グローバルな不揮発性warning変数への割り当ては、プログラムを終了したり、例外をスローしたりすることはできません。pointerそれは魔法の(それがないならば、それは未定義の動作です)関数の途中でその値を変更することはできませんので、また、不揮発性です。呼び出すlol_null_check(NULL)は、変数が割り当てられない原因となる可能性のある未定義の動作を引き起こします(この時点では、プログラムが未定義の動作を実行するという事実がわかっているためです)。

ただし、未定義の動作は、プログラムが何でも実行できることを意味します。したがって、未定義の動作が時間内に戻ってプログラムの最初の行がint main()実行される前にクラッシュすることを止めるものはありません。これは未定義の動作であり、意味をなす必要はありません。3を入力した後もクラッシュする可能性がありますが、未定義の動作は過去に戻り、3を入力する前にクラッシュします。そして、おそらく未定義の動作がシステムRAMを上書きし、2週間後にシステムをクラッシュさせるでしょう。未定義のプログラムが実行されていない間。


すべての有効なポイント。PrintToConsoleクラッシュ後も表示され、強力なシーケンス処理が行われるプログラム外部の副作用を挿入する私の試みです。このステートメントが最適化されたかどうかを確実に判断できる状況を作りたかったのです。しかし、あなたはそれが決して戻らないかもしれないという点で正しいです。グローバルへの書き込みの例は、UBとは関係のない他の最適化の対象となる可能性があります。たとえば、未使用のグローバルを削除できます。コントロールを返すことが保証されている方法で外部の副作用を作成するアイデアはありますか?
usrは

コンパイラーが自由に戻り値を想定できるコードによって、外界で観察可能な副作用が発生する可能性はありますか?私の理解では、volatile変数を単純に読み取るメソッドでさえ、I / O操作を正当にトリガーし、現在のスレッドをすぐに中断する可能性があります。割り込みハンドラーは、他に何かを実行する機会を得る前にスレッドを強制終了する可能性があります。コンパイラがその時点より前に未定義の動作をプッシュできる正当な理由はありません。
スーパーキャット2014

C標準の観点からは、未定義の動作によってコンピュータがプログラムの以前のアクションのすべての証拠を追跡して破棄する一部の人々にメッセージを送信することは違法ではありませんが、アクションがスレッドを終了できる場合は、そのアクションの前にシーケンスされたすべてのものは、その後に発生した未定義の動作の前に発生する必要があります。
スーパーキャット2014

1

プログラムが未定義の動作を呼び出すステートメントに到達した場合、プログラムの出力/動作のいずれにも要件は課されません。未定義の動作が呼び出される「前」または「後」のどちらで行われるかは関係ありません。

3つのコードスニペットすべてについてのあなたの推論は正しいです。特に、コンパイラは、GCCが処理する方法で無条件に未定義の動作を呼び出すステートメントを__builtin_unreachable()、ステートメントに到達できないことを最適化のヒントとして処理する可能性があります(そのため、ステートメントに無条件につながるすべてのコードパスも到達できません)。もちろん、他の同様の最適化も可能です。


1
好奇心から__builtin_unreachable()、時間の前後に進行する効果が始まったのはいつですか?extern volatile uint32_t RESET_TRIGGER; void RESET(void) { RESET_TRIGGER = 0xAA55; __memorybarrier(); __builtin_unreachable(); }私がbuiltin_unreachable()コンパイラーにreturn命令を省略できることを知らせるのは良いと思うようなものを考えると、それは前のコードを省略できると言うこととはかなり異なります。
スーパーキャット2015

@supercat RESET_TRIGGERは揮発性であるため、その場所への書き込みは任意の副作用をもたらす可能性があります。コンパイラにとって、それは不透明なメソッド呼び出しのようなものです。したがって、__builtin_unreachable到達したことを証明することはできません(そうではありません)。このプログラムが定義されています。
usr

@usr:低レベルコンパイラは揮発性アクセスを不透明なメソッド呼び出しとして扱うべきだと思いますが、clangもgccもそうではありません。特に、不透明なメソッド呼び出しにより、アドレスが外部に公開されていて、ライブrestrictポインタによってアクセスされていないオブジェクトへのすべてのバイトが、を使用して書き込まれる可能性がありますunsigned char*
スーパーキャット2017年

@usr:コンパイラが揮発性アクセスを公開オブジェクトへのアクセスに関して不透明なメソッド呼び出しとして扱わない場合、他の目的でそうすることを期待する特別な理由はありません。コンパイラが揮発性アクセスからのすべての可能な影響を知ることができるハードウェアプラットフォームがいくつかあるので、標準は実装がそうすることを要求しません。ただし、組み込み用途に適したコンパイラーは、揮発性アクセスが、コンパイラーの作成時に発明されていなかったハードウェアをトリガーする可能性があることを認識する必要があります。
スーパーキャット2017年

@supercat私はあなたが正しいと思います。揮発性の操作は「抽象マシンに影響を与えない」ように思われるため、プログラムを終了したり、副作用を引き起こしたりすることはできません。
usr

1

IETF RFC 2119で定義されているのと同様の命名法を使用して、実装が行うべきまたはすべきでないことを説明することに多くの種類の多くの標準が多大な労力を費やしています(ただし、必ずしもそのドキュメントの定義を引用しているわけではありません)。多くの場合、実装が役に立たないか実用的でない場合を除いて、実装が行うべきことの説明は、すべての準拠する実装が準拠する必要がある要件よりも重要です。

残念ながら、CおよびC ++標準は、100%必須ではないものの、逆の動作を文書化しない高品質の実装に期待されるものの説明を避ける傾向があります。実装が何かを行うべきであるという提案は、劣っていないものを暗示していると見なされる可能性があり、特定の実装で、どの動作が実用的で実用的ではなく、実際的で役に立たないかが一般的に明らかである場合、基準がそのような判断に干渉する必要性はほとんど認識されていません。

賢いコンパイラーは規格に準拠する一方で、コードが未定義の動作を必然的に引き起こす入力を受け取った場合を除いて何の影響もないコードを排除しますが、「賢い」と「ダム」は反義語ではありません。規格の作成者が、特定の状況で有効に動作することが役に立たず実用的ではない実装の種類があるかもしれないと決定したという事実は、そのような動作が他の動作で実用的で有用であると見なされるべきかどうかの判断を意味しません。実装が「デッドブランチ」プルーニング機会の損失を超えてコストなしで動作保証を維持できる場合、ユーザーコードがその保証から受け取るほとんどすべての価値は、それを提供するコストを超えます。デッドブランチの除去は、それができない場合には問題ないかもしれません与えられた状況のユーザーコードのほぼすべての可能な行動に扱われていることができれば、しかし、他のデッド枝の除去よりは、何の努力のユーザーコードは、UBはおそらくDBEから達成された値を超えてしまう避けるために費やさなければならないでしょう。


UBを回避すると、ユーザーコードにコストがかかる可能性があるのは、良い点です。
usr

@usr:モダニストが完全に見逃している点です。例を追加する必要がありますか?たとえば、コードがオーバーフローしないx*y < z場合を評価する必要x*yがあり、オーバーフローの場合、任意の方法で0または1のいずれかが生成されますが、副作用がない場合、ほとんどのプラットフォームでは、2番目と3番目の要件を満たすことよりもコストが高くなる理由はありません。最初の要件を満たしますが、すべての場合に標準で定義された動作を保証する式を記述する方法は、場合によってはかなりのコストがかかります。(int64_t)x*y < z計算コストを4倍以上にできるように式を
記述する

...一部のプラットフォームで(int)((unsigned)x*y) < zは、コンパイラーがそうでなければ有用な代数的置換である可能性のあるものを採用できないようにそれを書く(たとえば、それがわかっていてxz等しくて正である場合、元の式をに簡略化できますy<0が、符号なしを使用するバージョンコンパイラーに乗算を強制します)。標準がそれを義務付けていない場合でもコンパイラが保証できる場合、それは「副作用のない0または1の利回り」要件を維持し、ユーザーコードは他の方法では得られないコンパイラ最適化の機会を与える可能性があります。
スーパーキャット2017年

ええ、それは未定義の動作のいくつかの穏やかな形がここで役立つだろうようです。プログラマはx*y、オーバーフローの場合に通常の値を出力するモードをオンにすることができますが、値はまったくありません。C / C ++で構成可能なUBは私にとって重要なようです。
usr

@usr:C89標準の作成者が、署名されていない短い符号なしの値のサインインへの昇格が最も重大な重大な変更であり、無知な愚か者ではなかったと誠実に述べている場合、プラットフォームが有用な動作保証を定義し、そのようなプラットフォームの実装はプログラマがそれらの保証を利用できるようにし、プログラマはそれらを利用してきました。そのようなプラットフォームのコンパイラは、規格が命令したかどうかにかかわらず、そのような動作保証を提供し続けます。
スーパーキャット2017年
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.