C\C++仕様では、コンパイラーが独自の方法で実装するためのオープンな動作は多数ありません。同じことについて常にここで尋ね続けられる多くの質問があり、それについていくつかの優れた投稿があります:
私の質問は、未定義の動作とは何かについてではなく、それが本当に悪いのかということではありません。私は危険と標準からの関連する未定義の動作の引用のほとんどを知っているので、それがいかに悪いかについての回答を投稿することはご遠慮ください。この質問は、コンパイラの実装に対して開かれている非常に多くの動作を除外する背後にある哲学についてです。
私は 優れたブログ投稿 を読みましたが、これはパフォーマンスが主な理由であると述べています。パフォーマンスがそれを許可するための唯一の基準かどうか、またはコンパイラーの実装のために物事を開いたままにする決定に影響を与える他の要因があるかどうか疑問に思っていましたか?
特定の未定義の動作がコンパイラーが最適化するための十分な余地を提供する方法について引用する例がある場合は、それらをリストしてください。パフォーマンス以外の要因を知っている場合は、十分な詳細を含めて回答をバックアップしてください。
質問を理解していない場合、または回答を裏付ける十分な証拠/情報源がない場合は、広範に推測する回答を投稿しないでください。
最初に、ここでは "C"についてのみ説明しますが、C++についても同様です。
ゴデルに言及するコメントは、部分的に(しかし、部分的にのみ)要点を満たしていました。
それに取り掛かると、C標準の未定義の動作は大きく標準が定義しようとすることとそれが行うことの間の境界を指摘するだけですない.
Godelの定理(2つあります)は、基本的に、(独自のルールによって)完全で一貫していることを証明できる数学的なシステムを定義することは不可能であると述べています。完全になるようにルールを作成できます(彼が扱ったケースは自然数の「通常の」ルールでした)。それ以外の場合は、その一貫性を証明できるようにすることができますが、両方を持つことはできません。
Cのようなものの場合、これは直接適用されません。ほとんどの場合、システムの完全性または一貫性の「証明可能性」は、ほとんどの言語設計者にとって高い優先順位ではありません。同時に、はい。おそらく、「完全な」システムを定義することはおそらく不可能であることがわかっているため、(少なくともある程度は)影響を受けていました。そのようなことは不可能であることを知っていれば、一歩下がって少し呼吸し、彼らが定義しようとするものの境界を決定するのが少し簡単になったかもしれません。
(まだ)傲慢さで非難されるリスクを冒して、私はC標準を(部分的に)2つの基本的な考え方に支配されていると特徴づけます。
1つ目は、誰かが新しいCPUを定義する場合、デザインが少なくともいくつかの単純なガイドラインに合理的に近い限り、そのためのCの適切で確実な使用可能な実装を提供することが可能であることを意味します。フォンノイマンモデルの一般的な順序で何かに従い、少なくともある程度の妥当な最小メモリを提供します。これは、C実装を可能にするのに十分なはずです。 「ホストされた」実装(OSで実行される実装)の場合、ファイルにかなり近い概念をサポートし、特定の最小文字セット(91が必要)の文字セットを使用する必要があります。
2番目は、ハードウェアを直接操作するコードを記述できるようにする必要があることを意味します。これにより、ブートローダー、オペレーティングシステム、OSなしで実行される組み込みソフトウェアなどを記述できます。最終的にはこの点でいくつかの制限があるため、ほとんどすべての実用的なオペレーティングシステム、ブートローダーなどは、少なくともlittleアセンブリ言語で記述されたコードのビット。同様に、小さな組み込みシステムであっても、ホストシステム上のデバイスにアクセスできるようにするために、少なくともある種の事前に作成されたライブラリルーチンが含まれている可能性があります。正確な境界を定義することは困難ですが、そのようなコードへの依存を最小限に抑える必要があるという意図があります。
言語の未定義の動作は、主に言語がこれらの機能をサポートすることを目的としています。たとえば、この言語では、任意の整数をポインタに変換し、そのアドレスにあるものにアクセスすることができます。この標準では、実行時に何が起こるかを明言することはありません(たとえば、一部のアドレスからの読み取りでさえ、外部から見える影響を与える可能性があります)。同時に、それはあなたがそのようなことをするのを妨げようとすることはありません。なぜなら、あなたはある種のソフトウェアに必要とするからです Cで書くことができる.
他の設計要素によって引き起こされる未定義の動作もいくつかあります。たとえば、Cのもう1つの目的は、個別のコンパイルをサポートすることです。これは、たとえば、通常のリンカーのモデルとして私たちのほとんどが見ているものにほぼ従うリンカーを使用して、ピースを「リンク」できることが意図されていることを意味します。特に、言語のセマンティクスを知らなくても、別々にコンパイルされたモジュールを1つの完全なプログラムに組み合わせることができるはずです。
未定義の動作には別の種類があります(これはCよりもC++ではるかに一般的です)。これは、単にコンパイラテクノロジの制限のために存在します。基本的にわかっているのはエラーであり、おそらくコンパイラにエラーとして診断してもらいます。ただし、現在のコンパイラテクノロジの制限を考えると、すべての状況で診断できるかどうかは疑問です。これらの多くは、個別のコンパイルなどの他の要件によって駆動されるため、競合する要件のバランスをとることが主な問題であり、その場合、委員会は、いくつかの可能性のある問題を診断できないことを意味する場合でも、より大きな機能をサポートすることを選択しました。考えられるすべての問題を確実に診断できるように機能を制限するのではなく。
intentのこれらの違いは、CとJavaまたはMicrosoftのCLIベースのシステムなど)とのほとんどの違いをもたらします。後者は、かなり明確に制限されたハードウェアのセットでの作業、またはターゲットとするより具体的なハードウェアをエミュレートするためのソフトウェアの要求に限定されています。また、特にpreventハードウェアの直接操作。代わりに、JNIまたはP/Invoke(およびCのようなコードで記述されたもの)を使用して、そのような試みを行う必要があります。
Godelの定理に少し戻ると、平行線を描くことができます。JavaおよびCLIは「内部的に一貫した」代替手段を選択し、Cは「完全な」代替手段を選択しました。もちろん、これは非常に大まかな例えです-内部整合性またはどちらの場合でも完全性。それにもかかわらず、一般的な概念はかなりが彼らが取った選択にぴったり合っています。
「不特定の動作」、「未定義の動作」、および「実装定義の動作」という用語は、規格が完全に記述していない、または記述できないプロパティを持つプログラムを記述した結果を分類するために使用されます。 この分類を採用する目的は、実装の特定の多様性を可能にすることです。これにより、実装の品質を市場で積極的に発揮し、特定の人気のある拡張機能を削除せずに使用できるようになります。標準への適合の悪用。標準の付録Fは、これらの3つのカテゴリのいずれかに該当する動作をカタログ化しています。
不特定の動作は、プログラムを翻訳する際に実装者にある程度の自由度を与えます。この寛容度は、プログラムの翻訳に失敗するまでは及ばない。
未定義の動作は、診断が難しい特定のプログラムエラーを検出しないように実装者にライセンスを与えます。また、準拠する可能性のある言語拡張の領域も識別します。実装者は、公式に未定義の動作の定義を提供することにより、言語を拡張できます。
実装定義の動作により、実装者は適切なアプローチを自由に選択できますが、この選択をユーザーに説明する必要があります。実装定義として指定された動作は、一般に、ユーザーが実装定義に基づいて有意義なコーディングを決定できる動作です。実装者は、実装定義をどの程度拡張する必要があるかを決定するときに、この基準に留意する必要があります。未指定の動作と同様に、実装で定義された動作を含むソースの翻訳に失敗しただけでは、適切な応答にはなりません。
重要なことは、実装にとっての利点だけでなく、プログラムにとっての利点でもあります。未定義の動作に依存するプログラムは、準拠する実装によって受け入れられる場合でも、準拠である可能性があります。未定義の動作の存在により、プログラムは、非適合になることなく、そのように明示的にマークされた非移植機能(「未定義の動作」)を使用できます。理論的根拠は:
Cコードは移植できない可能性があります。それはプログラマーに真にポータブルなプログラムを書く機会を与えるために努力しましたが、委員会はプログラマーに強制的に書くことを望まなかった移植性の高い、「高レベルアセンブラー」としてのCの使用を排除する:マシン固有のコードを記述できることは、Cの強みの1つです。この原則は、厳密に準拠したプログラムおよびに準拠したプログラム(§1.7)。
そして1.7でそれは注意します
コンプライアンスの3倍の定義は、適合プログラムの数を増やし、単一の実装を使用する適合プログラムとポータブル適合プログラムを区別するために使用されます。
厳密に準拠するプログラムは、最大限に移植可能なプログラムの別の用語です。目的は、たまたま移植できない完全に有用なCプログラムを害することなく、プログラマーに、移植性の高い強力なCプログラムを作成するための戦いの機会を与えることです。したがって、副詞は厳密に。
したがって、GCCで完全に正常に機能するこの小さな汚いプログラムはまだ準拠しています!
Cと比較すると、速度の問題は特に問題です。C++が、プリミティブ型の大きな配列の初期化など、理にかなっている可能性のあるいくつかのことを行った場合、Cコードに対する大量のベンチマークが失われます。したがって、C++は独自のデータ型を初期化しますが、C型はそのままにしておきます。
その他の未定義の動作は、現実を反映するだけです。 1つの例は、タイプよりも大きいカウントでのビットシフトです。同じファミリのハードウェア世代間では実際には異なります。 16ビットアプリを使用している場合、完全に同じバイナリでは80286と80386で異なる結果が得られます。したがって、言語標準ではわかりません。
部分式の評価順序が指定されていないなど、一部のものがそのまま維持されます。もともとこれは、コンパイラー作成者がより最適化するのに役立つと信じられていました。今日、コンパイラーはとにかくそれを理解するのに十分ですが、自由を利用する既存のコンパイラーのすべての場所を見つけるコストは高すぎます。
一例として、ポインタのアクセスはほとんど未定義である必要があり、必ずしもパフォーマンス上の理由のためではありません。たとえば、一部のシステムでは、特定のレジスターにポインターをロードすると、ハードウェア例外が生成されます。 SPARCで不適切に整列されたメモリオブジェクトにアクセスすると、バスエラーが発生しますが、x86では「ただ」遅くなります。これらの場合、実際に動作を指定するのは難しいです。 C++は非常に多くの種類のハードウェアに移植可能です。
もちろん、アーキテクチャ固有の知識を使用する自由もコンパイラーに与えます。不特定の動作例の場合、符号付き値の右シフトは、基盤となるハードウェアに応じて論理的または算術的であり、利用可能なシフト操作を使用でき、ソフトウェアのエミュレーションを強制しません。
また、コンパイラライターの仕事もかなり簡単になると思いますが、今の例を思い出すことはできません。状況を思い出したら追加します。
シンプル:スピードと移植性。 C++が無効なポインターを逆参照したときに例外が発生することを保証した場合、組み込みハードウェアに移植できなくなります。 C++が常に初期化されたプリミティブのような他のものを保証した場合、それは遅くなり、C++のOriginの時代では、遅くなることは本当に、本当に悪いことでした。
Cは、9ビットバイトで浮動小数点ユニットのないマシンで発明されました。バイトが9ビット、ワードが18ビットで、フロートはIEEE754以前のaritmaticを使用して実装する必要があると仮定します。
UBの最初の理論的根拠は、コンパイラーに最適化の余地を与えることではなかったと思いますが、アーキテクチャが今よりも多様であるときにターゲットの明白な実装を使用する可能性だけです(CがPDP-11はやや馴染みのあるアーキテクチャで、最初のポートは Honeywell 635 に慣れていません-ワードアドレス指定可能、36ビットワード、6または9ビットバイト、18ビットアドレスを使用します。 。少なくとも、2の補数を使用します)。しかし、高度な最適化がターゲットではなかった場合、明らかな実装には、オーバーフロー、レジスタサイズに対するシフトカウント、複数の値を変更する式のエイリアスのランタイムチェックの追加が含まれません。
考慮されたもう1つのことは、実装の容易さでした。当時のCコンパイラは、1つのプロセスですべてを処理することは不可能であったため(プログラムが大きすぎるため)、複数のプロセスを使用する複数のパスでした。いくつかのCUが関係する場合は特に、コヒーレンスチェックの強化を検討することはできませんでした。 (Cコンパイラー以外のプログラムlintがそのために使用されました)。
初期の古典的なケースの1つは、符号付き整数の加算でした。使用中のプロセッサの一部では、それが障害を引き起こし、他のプロセッサでは、値(おそらく適切なモジュラー値)で続行します。どちらかのケースを指定すると、不利な算術スタイルのマシン用のプログラムには、整数の加算と同様のもののために、条件付き分岐を含む追加のコードが必要になります。
私はそれが現実よりも哲学についてではなかったと思います-Cは常にクロスプラットフォーム言語であり、標準はそれを反映する必要があり、標準がリリースされた時点で、多くの異なるハードウェアでの多数の実装。必要な動作を禁止する標準は無視されるか、競合する標準化団体が作成されます。
一部の動作は、合理的な手段では定義できません。削除されたポインタにアクセスすることを意味します。これを検出する唯一の方法は、削除後にポインタ値を禁止することです(その値をどこかに記憶し、割り当て関数がそれを返すことを許可しません)。このような暗記はやり過ぎになるだけでなく、長時間実行されるプログラムでは、許可されたポインター値が不足する原因になります。
歴史的に、未定義の動作には2つの主な目的があります。
コンパイラーの作成者に、発生するとは考えられなかった条件を処理するコードを生成するように要求することを避けるため。
そのような条件を明示的に処理するコードがない場合に、実装には、場合によっては役立つ、さまざまな種類の「自然な」動作が含まれる可能性があります。
単純な例として、一部のハードウェアプラットフォームでは、合計が大きすぎて符号付き整数に収まらない2つの正の符号付き整数を加算しようとすると、特定の負の符号付き整数が生成されます。他の実装では、プロセッサトラップをトリガーします。 C標準でどちらかの動作を義務付けるには、標準とは異なる自然な動作をするプラットフォームのコンパイラが、正しい動作を生成するために追加のコードを生成する必要があります。コードは、実際の追加を行うコードよりもコストがかかる可能性があります。さらに悪いことに、「自然な」動作を望んだプログラマーは、それを実現するためにさらに多くのコードを追加する必要があることを意味します(追加のコードは、追加よりもコストがかかります)。
残念ながら、一部のコンパイラ作成者は、コンパイラが未定義の動作を引き起こす条件を見つけるために自分の道を行くべきであり、そのような状況が決して発生しないと想定して、それから拡張された推論を引き出すべきであるという哲学を採用しています。したがって、32ビットint
のシステムでは、次のようなコードが与えられます。
uint32_t foo(uint16_t q, int *p)
{
if (q > 46340)
*p++;
return q*q;
}
c標準では、qが46341以上の場合、式q * qは結果が大きすぎてint
に収まらず、その結果、未定義の動作を引き起こし、その結果コンパイラが発生しないと想定する権利があり、その場合は*p
をインクリメントする必要はありません。呼び出しコードが*p
を計算の結果を破棄する必要があることを示すインジケータとして使用している場合、最適化の効果は、整数でほとんどすべての想像可能な方法で実行されるシステムで実用的な結果をもたらすコードを取得することです。オーバーフロー(トラッピングは醜いかもしれませんが、少なくとも賢明です)、それを無意味な動作をするコードに変えました。
未定義の動作以外に賢明な選択がほとんどない例を紹介します。原則として、コンパイラーがアドレスを取得したことのないローカル変数を除いて、ポインターは変数を含むメモリを指すことができます。ただし、最新のCPUで許容可能なパフォーマンスを得るには、コンパイラーは変数値をレジスターにコピーする必要があります。完全にメモリ不足で動作することは重要ではありません。
これは基本的に2つの選択肢を提供します。
1)ポインタがその特定の変数のメモリを指す場合に備えて、ポインタを介してアクセスする前に、レジスタからすべてをフラッシュします。次に、ポインタを介して値が変更された場合に備えて、必要なすべてのものをレジスタにロードします。
2)ポインターが変数のエイリアスを作成できるとき、およびコンパイラーがポインターが変数のエイリアスを作成しないと想定できるときのルールのセットを用意します。
1はパフォーマンスがひどいため、Cはオプション2を選択します。しかし、ポインタがCの規則で禁止されている方法で変数をエイリアスするとどうなりますか?効果は、コンパイラーが実際に変数をレジスターに保管したかどうかに依存するため、C標準が特定の結果を確実に保証する方法はありません。