未定義の振る舞い(UB)に関する会話のほとんどは、これを実行できるプラットフォームや、コンパイラーがどのように実行するかについて話します。
1つのプラットフォームと1つのコンパイラ(同じバージョン)のみに関心があり、それらを何年も使用することがわかっている場合はどうなりますか?
コード以外は何も変更されておらず、UBは実装定義されていません。
UBがそのアーキテクチャーとそのコンパイラーに対して明示され、テストした後、それ以降、コンパイラーがUBで最初に行ったことは何であれ、毎回それを行うと思いませんか?
注:わかっています未定義の振る舞いは非常に悪いですしかし、この状況で誰かが書いたコードでUBを指摘したとき、彼らはこれを尋ねました、そして私はしませんでしたアップグレードや移植が必要になった場合、すべてのUBの修正に非常に費用がかかるというよりも良いことを言う必要があります。
行動にはさまざまなカテゴリがあるようです。
Defined
-これは標準で動作するように文書化された動作ですSupported
-これは、サポートされていることが文書化されている動作です。Extensions
-これは文書化された追加であり、popcount
、分岐ヒントなどの低レベルビット演算のサポートはこのカテゴリに分類されますConstant
-文書化されていませんが、これらはエンディアンなどの特定のプラットフォームで一貫している可能性が高い動作です。sizeof
int
は移植性がない場合でも、変更されない可能性があります。Reasonable
-ポインタの下位ビットを一時スペースとして使用して、一般的に安全で通常はレガシーで、符号なしから符号付きにキャストしますDangerous
-非ポッドクラスでmemcopy
を使用して、初期化されていないメモリまたは割り当てられていないメモリを読み取り、一時変数を返しますConstant
は、1つのプラットフォームのパッチバージョン内では不変であるように思われます。 Reasonable
とDangerous
の間の線は、コンパイラーが最適化に積極的になるにつれて、Dangerous
に向かってますます多くの動作を動かしているようです。
OSの変更、無害なシステムの変更(ハードウェアのバージョンが異なる!)、またはコンパイラの変更はすべて、以前は「機能していた」UBが機能しなくなる可能性があります。
しかし、それよりも悪いです。
無関係なコンパイルユニット、または同じコンパイルユニット内の遠く離れたコードへの変更により、以前は「機能していた」UBが機能しなくなる場合があります。例として、定義は異なるが署名が同じ2つのインライン関数またはメソッド。 1つは、リンク中に黙って破棄されます。完全に無害なコード変更は、破棄されるコードを変更する可能性があります。
あるコンテキストで機能しているコードは、別のコンテキストで使用すると、同じコンパイラ、OS、およびハードウェアで突然機能しなくなる可能性があります。この例は、強力なエイリアシングの違反です。コンパイルされたコードは、スポットAで呼び出されたときに機能する可能性がありますが、インライン化された場合(おそらくリンク時に!)、コードの意味が変わる可能性があります。
大規模なプロジェクトの一部である場合、コードは、いくつかのフラグ(浮動小数点の精度、ロケール、整数オーバーフロー)の状態を変更するサードパーティのコード(たとえば、ファイルを開くダイアログで画像タイプをプレビューするシェル拡張)を条件付きで呼び出すことができます。フラグ、ゼロ除算など)。以前は正常に機能していたコードが、まったく異なる動作を示すようになりました。
次に、多くの種類の未定義の動作は本質的に非決定論的です。ポインタが解放された後(書き込みであっても)ポインタの内容にアクセスすることは安全な99/100かもしれませんが、1/100ページがスワップアウトされたか、そこに到達する前に何か他のものが書き込まれました。これで、メモリが破損しました。それはあなたのすべてのテストに合格しますが、何がうまくいかないかについての完全な知識が不足していました。
未定義の動作を使用することで、C++標準、その状況でコンパイラーが実行できるすべてのこと、およびランタイム環境が反応できるすべての方法を完全に理解することに専念できます。ビルドするたびに、おそらくプログラム全体について、C++ソースではなく、作成されたアセンブリを監査する必要があります。また、そのコードを読んだり、そのコードを変更したりするすべての人を、そのレベルの知識にコミットします。
それでも価値がある場合があります。
可能な限り最速のデリゲート UBと呼び出し規約に関する知識を使用して、非常に高速な非所有のstd::function
のような型にします。
不可能なほど速いデリゲート 競合します。状況によっては高速で、他の状況では低速であり、C++標準に準拠しています。
パフォーマンスを向上させるために、UBを使用する価値があるかもしれません。このようなUBハッカーからパフォーマンス(速度またはメモリ使用量)以外のものが得られることはめったにありません。
私が見たもう1つの例は、関数ポインターを受け取ったばかりの貧弱なCAPIにコールバックを登録する必要があったときです。関数を作成し(最適化せずにコンパイル)、別のページにコピーし、その関数内のポインター定数を変更してから、そのページを実行可能としてマークし、関数ポインターと一緒にコールバックへのポインターを密かに渡すことができます。
別の実装では、固定サイズの関数セット(10?100?1000?100万?)を使用して、すべてがグローバル配列でstd::function
を検索し、それを呼び出します。これにより、一度にインストールするそのようなコールバックの数に制限が生じますが、実際には十分でした。
いいえ、それは安全ではありません。まず、コンパイラのバージョンだけでなく、すべてを修正する必要があります。特定の例はありませんが、別の(アップグレードされた)OS、またはアップグレードされたプロセッサでさえ、UBの結果が変わる可能性があると思います。
さらに、プログラムへのデータ入力が異なっていても、UBの動作が変わる可能性があります。たとえば、範囲外の配列アクセス(少なくとも最適化なし)は通常、配列の後にメモリ内にあるものに依存します。 [〜#〜] upd [〜#〜]:これについての詳細は、Yakkによるすばらしい回答を参照してください。
そして、より大きな問題は、最適化と他のコンパイラフラグです。 UBは、最適化フラグに応じて異なる方法で現れる可能性があり、誰かが常に同じ最適化フラグを使用することを想像するのは非常に困難です(少なくともデバッグとリリースに異なるフラグを使用します)。
[〜#〜] upd [〜#〜]:コンパイラの修正について言及していないことに気づきましたバージョン、コンパイラ自体の修正についてのみ言及しました。その場合、すべてがさらに安全ではなくなります。新しいコンパイラバージョンは、UBの動作を確実に変更する可能性があります。から この一連のブログ投稿 :
認識すべき重要で恐ろしいことは、未定義の動作に基づくほぼanyの最適化が、将来いつでもバグのあるコードでトリガーされ始める可能性があるということです。インライン化、ループ展開、メモリプロモーション、およびその他の最適化は改善され続けます。これらが存在する理由の重要な部分は、上記のような2次最適化を公開することです。
これは基本的に、特定のC++実装に関する質問です。 「標準で定義されていない特定の動作が、UVWの状況下でも、プラットフォームXYZの($ CXX)によって引き続き同じように処理されると想定できますか?」
使用しているコンパイラとプラットフォームを正確に言って明確にし、ドキュメントを参照して保証があるかどうかを確認する必要があると思います。そうでない場合、質問は基本的に答えられません。
未定義の振る舞いの要点は、C++標準では何が起こるかが指定されていないため、標準から「大丈夫」であるという何らかの保証を探している場合、それを見つけることはできません。 「コミュニティ全体」が安全だと考えているかどうかを尋ねる場合、それは主に意見に基づいています。
UBがそのアーキテクチャーとそのコンパイラーに対して明示され、テストした後、それ以降、コンパイラーがUBで最初に行ったことは何であれ、毎回それを行うと思いませんか?
コンパイラメーカーがこれを実行できることを保証している場合にのみ、そうでない場合は、希望的観測です。
少し違う方法でもう一度答えてみましょう。
ご存知のように、通常のソフトウェアエンジニアリング、およびエンジニアリング全般では、プログラマー/エンジニアは標準に従って物事を行うように教えられ、コンパイラーの作成者/部品メーカーは標準を満たす部品/ツールを作成し、最後に何かを作成しますここで、「標準の仮定の下で、私のエンジニアリング作業は、この製品が機能することを示しています」、そしてあなたはそれをテストして出荷します。
あなたが狂った叔父のジンボを持っていて、ある日、彼は彼のすべての道具と2 x 4の束をすべて取り出し、何週間も働いてあなたの裏庭で間に合わせのジェットコースターを作ったとしましょう。そして、それを実行すると、クラッシュしないことを確認できます。そして、あなたはそれを10回実行することさえできます、そしてそれはクラッシュしません。現在、ジンボはエンジニアではないため、これは標準に従って作成されていません。しかし、10回もクラッシュしなかった場合、それは安全であり、一般の人々への入場料を請求し始めることができますよね?
何が安全で何が安全でないかは、大部分が社会学的な問題です。しかし、「入場料を請求しても誰も怪我をしないと合理的に想定できるのはいつか、製品について何も想定できないのはいつか」という簡単な質問にしたければ、これが私のやり方です。入場料を請求し始めたら、X年間走らせて、その間に10万人くらい乗ると思います。基本的に、壊れるかどうかにかかわらず、偏ったコイントスである場合、私が見たいのは、「このデバイスは、クラッシュダミーで何百万回も実行されており、クラッシュしたり、壊れた兆候を示したりしたことはありません」のようなものです。そうすれば、私が一般の人々に入場料を請求し始めた場合、厳密な工学的基準が含まれていなくても、誰もが怪我をする可能性は非常に低いとかなり合理的に信じることができました。それは、統計と力学の一般的な知識に基づいているだけです。
あなたの質問に関連して、あなたが未定義の振る舞いでコードを出荷しているなら、それは標準、コンパイラメーカー、または他の誰もサポートしません、それは基本的に「クレイジーおじさんジンボ」エンジニアリングであり、それはただの「統計とコンピューターの一般的な知識に基づいて、ニーズを満たしていることを確認するためにテストの量を大幅に増やした場合は、「大丈夫」です。
あなたが言及しているのは実装が定義されているであり、未定義の振る舞いではない可能性が高いです。前者は、標準が何が起こるかを教えていない場合ですが、同じコンパイラと同じプラットフォームを使用している場合は同じように機能するはずです。この例は、int
の長さが4バイトであると想定しています。 UBはもっと深刻なものです。そこに標準は何も言っていません。特定のコンパイラとプラットフォームで機能する可能性がありますが、一部の場合にのみ機能する可能性もあります。
例として、初期化されていない値を使用しています。 bool
で初期化されていないif
を使用すると、trueまたはfalseになる可能性があり、それが常に必要なものである場合がありますが、コードはいくつかの驚くべき方法で壊れます。
別の例は、nullポインターの逆参照です。おそらくすべての場合にセグメンテーション違反が発生しますが、標準では、プログラムが実行されるたびに同じ結果を生成することさえプログラムに要求されていません。
要約すると、実装定義である何かをしている場合、1つのプラットフォームにのみ開発していて、それが機能することをテストした場合は安全です。 未定義の振る舞いである何かをしているなら、あなたはおそらくどんな場合でも安全ではありません。それが機能するかもしれませんが、それを保証するものは何もありません。
別の方法で考えてください。
未定義の振る舞いは常に悪いものであり、何が得られるかわからないため、決して使用しないでください。
しかし、あなたはそれを和らげることができます
動作は言語仕様以外の関係者によって定義できます
したがって、UBに依存することは決してありませんが、特定の動作が状況に応じたコンパイラの定義済みの動作であると述べている代替ソースを見つけることができます。
Yakkは、高速デリゲートクラスに関する優れた例を示しました。そのような場合、作成者は、仕様に従って、未定義の動作に従事していると明示的に主張します。しかし、彼らはその後、行動がそれよりも明確に定義されているビジネス上の理由を説明しに行きます。たとえば、彼らは、Microsoftにとって不快な非互換性のためにビジネスコストが横行するため、VisualStudioでメンバー関数ポインタのメモリレイアウトが変更される可能性は低いと宣言しています。したがって、彼らはその行動が「事実上定義された行動」であると宣言します。
同様の動作は、pthreadの典型的なLinux実装(gccによってコンパイルされる)でも見られます。マルチスレッドシナリオでコンパイラが呼び出すことができる最適化について、彼らが想定している場合があります。これらの仮定は、ソースコードのコメントに明確に記載されています。この「事実上定義された動作」はどのようになっていますか?ええと、pthreadsとgccは一種の密接な関係にあります。 pthreadを壊したgccに最適化を追加することは受け入れられないと考えられるので、誰もそれを行うことはありません。
ただし、同じ仮定をすることはできません。 「pthreadsがそれを行うので、私もできるはずです」と言うかもしれません。次に、誰かが最適化を行い、それを処理するようにgccを更新します(おそらく、volatile
に依存する代わりに__sync
呼び出しを使用します)。これで、pthreadは機能し続けます...しかし、コードはもう機能しません。
また、バッファオーバーフローエラーを発見したMySQL(またはPostgreでしたか?)の場合も考えてみてください。オーバーフローは実際にはコードで捕捉されていましたが、未定義の動作を使用して捕捉されたため、最新のgccがチェックアウト全体の最適化を開始しました。
したがって、全体として、動作が定義されていないときに使用するのではなく、動作を定義する別のソースを探してください。浮動小数点トラップを発生させるのではなく、1.0 /0.0がNaNに等しいことがわかっている理由を見つけることは完全に正当です。ただし、それがユーザーとコンパイラーの動作の有効な定義であることを最初に証明せずに、その仮定を使用しないでください。
そして、私たちは時々コンパイラをアップグレードすることを忘れないでください。
未定義の動作は、周囲温度などによって変更される可能性があります。これにより、回転するハードディスクの待機時間が変化し、スレッドスケジューリングが変化し、評価されるランダムなガベージの内容が変化します。
つまり、コンパイラまたはOSが動作を指定しない限り、安全ではありません(言語標準では指定されていないため)。
歴史的に、Cコンパイラは一般に、標準で要求されていない場合でも、ある程度予測可能な方法で動作する傾向がありました。たとえば、ほとんどのプラットフォームでは、nullポインターとデッドオブジェクトへのポインターを比較すると、それらが等しくないことが報告されます(コードがポインターがnullであることを安全に表明し、そうでない場合はトラップする場合に便利です)。標準では、コンパイラーがこれらのことを行う必要はありませんが、歴史的に、それらを簡単に実行できるコンパイラーはそうしていました。
残念ながら、一部のコンパイラ作成者は、ポインタが有効にnull以外のときにそのような比較に到達できない場合、コンパイラはアサーションコードを省略すべきであるという考えを持っています。さらに悪いことに、特定の入力によって無効なnull以外のポインターがコードに到達すると判断できる場合は、そのような入力が受信されないことを想定し、そのような入力を処理するすべてのコードを省略します。
うまくいけば、そのようなコンパイラの振る舞いは短命の流行であることが判明するでしょう。おそらく、それはコードを「最適化」したいという願望によって推進されていますが、ほとんどのアプリケーションでは、速度よりも堅牢性の方が重要であり、誤った入力や用事プログラムの動作によって引き起こされる損害を制限するコードをコンパイラーが混乱させることは災害のレシピです。
ただし、それまでは、コンパイラを使用してドキュメントを注意深く読む場合は、十分に注意する必要があります。コンパイラの作成者が、広くサポートされているものの、サポートされていない有用な動作をサポートすることの重要性が低いと判断しないという保証はないからです。標準の実行を必要としないコードを排除するためにあらゆる機会を利用するよりも、標準によって義務付けられています(2つの任意のオブジェクトが重複しているかどうかを安全にチェックできるなど)。
複数のプラットフォームをターゲットにしなくても安全ではないという回答には同意しますが、すべてのルールに例外があります。
未定義/実装定義の動作を許可することが正しい選択であると確信している2つの例を紹介したいと思います。
単発プログラム。これは誰もが使用することを目的としたプログラムではありませんが、何かを計算または生成するために作成された、小さくてすばやく作成されたプログラムです今。このような場合、たとえば、システムのエンディアンを知っていて、他のエンディアンで機能するコードを書くことに煩わされたくない場合は、「迅速で汚い」ソリューションが正しい選択になる可能性があります。たとえば、他のユーザー指向のプログラムで特定の数式を使用できるかどうかを知るために、数学的な証明を実行するためだけに必要でした。
非常に小さな組み込みデバイス。最も安価なマイクロコントローラのメモリは数百バイトで測定されます。 LEDが点滅する小さなおもちゃや音楽のはがきなどを開発する場合、1ユニットあたりの利益が非常に低く、数百万単位で生産されるため、すべてのペニーが重要です。プロセッサもコードも変更されることはなく、次世代の製品に別のプロセッサを使用する必要がある場合は、とにかくコードを書き直す必要があります。この場合の未定義の動作の良い例は、電源投入時にすべてのメモリ位置に対してゼロ(または255)の値を保証するマイクロコントローラがあることです。この場合、変数の初期化をスキップできます。マイクロコントローラに256バイトのメモリしかない場合、これにより、メモリに収まるプログラムと収まらないコードが異なる可能性があります。
ポイント2に同意できない人は、上司に次のようなことを言ったらどうなるか想像してみてください。
「ハードウェアの価格はわずか0.40ドルで、0.50ドルで販売する予定です。ただし、私が作成した40行のコードを含むプログラムは、この非常に特殊なタイプのプロセッサでしか機能しないため、遠い将来に別のプロセッサに変更すると、コードが使用できなくなり、コードを破棄して新しいプロセッサを作成する必要があります。すべてのタイプのプロセッサで機能する標準準拠のプログラムは、0.40ドルのプロセッサには収まりません。移植性のないプログラムを書くことを拒否したので、0.60ドルのプロセッサを使用するように要求します。」
あらゆる種類の未定義の動作には根本的な問題があります。それは、消毒剤とオプティマイザーによって診断されます。コンパイラーは、あるバージョンから別のバージョンに対応する動作をサイレントに変更でき(たとえば、レパートリーを拡張することにより)、突然、プログラムに追跡不可能なエラーが発生します。これは避けるべきです。
ただし、特定の実装によって「定義」される未定義の動作があります。負のビット数による左シフトはマシンで定義でき、文書化された機能の重大な変更はめったに発生しないため、そこで使用しても安全です。もう1つの一般的な例は、厳密なエイリアシングです。GCCは-fno-strict-aliasing
を使用してこの制限を無効にできます。
「変わらないソフトウェアは使われていません。」
ポインタを使って何か変わったことをしている場合は、キャストを使用して必要なものを定義する方法がおそらくあります。それらの性質のために、それらはnot「コンパイラーがUBで最初に行ったことは何でも」になります。たとえば、初期化解除ポインタが指すメモリを参照すると、プログラムを実行するたびに異なるランダムアドレスが取得されます。
未定義の振る舞いは、一般的に、何かトリッキーなことをしていることを意味し、別の方法でタスクを実行したほうがよいでしょう。たとえば、これは未定義です。
printf("%d %d", ++i, ++i);
ここに何の意図があるのかを知るのは難しいので、再考する必要があります。
コード以外は何も変更されておらず、UBは実装定義されていません。
コードを変更するだけで、未定義の動作に関してオプティマイザーとは異なる動作をトリガーできます。そのため、機能した可能性のあるコードは、より多くの最適化の機会を公開する一見小さな変更のために簡単に壊れることがあります。たとえば、関数をインライン化できるようにする変更については、 すべてのCプログラマーが未定義の動作について知っておくべきこと#2/ で詳しく説明されています。
これは意図的に単純で不自然な例ですが、この種のことはインライン化で常に発生します。関数をインライン化すると、多くの場合、二次的な最適化の機会が多数発生します。これは、オプティマイザーが関数をインライン化することを決定した場合、さまざまなローカル最適化が開始され、コードの動作が変わる可能性があることを意味します。これは、標準に従って完全に有効であり、実際のパフォーマンスにとって重要です。
コンパイラベンダーは、未定義の動作に関する最適化で非常に積極的になり、アップグレードにより、以前は悪用されていなかったコードが公開される可能性があります。
認識すべき重要で恐ろしいことは、未定義の動作に基づくほぼanyの最適化が、将来いつでもバグのあるコードでトリガーされ始める可能性があることです。インライン化、ループ展開、メモリプロモーション、およびその他の最適化は改善され続けます。これらが存在する理由の重要な部分は、上記のような2次最適化を公開することです。
コードを壊さずに変更するには、現在のコードを読んで理解する必要があります。未定義の振る舞いに依存すると、読みやすさが損なわれます。調べられない場合、コードの機能をどのように知る必要がありますか?
プログラムの移植性は問題ではないかもしれませんが、プログラマーの移植性)かもしれません。プログラムを維持するために誰かを雇う必要がある場合は、単に'<language x>を探すことができるようにしたいと思うでしょう。 <アプリケーションドメイン> 'の経験を持つ開発者で、有能な'<言語x><アプリケーションドメイン>の経験がある(または進んでいる)開発者学ぶために)プラットフォームfoo上のバージョンxyzのすべての未定義の動作の本質barと組み合わせて使用し、bazをfurbleblawup '。