このスタックオーバーフローの投稿 は、C/C++言語仕様が「未定義の動作」であると宣言している状況のかなり包括的なリストを示しています。ただし、C#やJavaなどの他の現代の言語に「未定義の動作」の概念がない理由を理解したいと思います。それは、コンパイラデザイナーがすべての可能なシナリオを制御できる(C#およびJava)か、できないか(CおよびC++)を意味するのでしょうか。
未定義の動作は、振り返ってみて非常に悪い考えとして認識されたものの1つです。
最初のコンパイラーは素晴らしい成果であり、機械語またはアセンブリー言語プログラミングという代替手段に対する改善を喜んで歓迎しました。これに関する問題はよく知られており、高級言語は特にこれらの既知の問題を解決するために発明されました。 (当時の熱意は非常に大きかったので、HLLは「プログラミングの終わり」と呼ばれることもありました。これからは、必要なことをささいに書き留めるだけで、コンパイラーがすべての実際の作業を行うようになります。)
新しいアプローチに伴う新しい問題に気づいたのは、その後のことでした。コードが実行される実際のマシンから離れているということは、私たちが期待していたことを黙って行わない可能性が高まるということです。たとえば、変数を割り当てると、通常、初期値は未定義のままになります。値を保持したくない場合は変数を割り当てないため、これは問題とは見なされませんでした。確かに、プロのプログラマーが初期値を割り当てるのを忘れないことを期待することはそれほど多くありませんでしたか?
より強力なプログラミングシステムで可能になった、より大きなコードベースとより複雑な構造により、はい、多くのプログラマーは実際にそのような見落としを時々犯し、結果として生じる未定義の動作が大きな問題となりました。今日でも、小さなものから恐ろしいものへのセキュリティリークの大部分は、何らかの形での未定義の動作の結果です。 (理由は、通常、未定義の動作は実際にはコンピューティングの1つ下のレベルのものによって非常に定義されており、そのレベルを理解している攻撃者はその小刻みな部屋を使用してプログラムを意図しないものだけでなく正確に行うことができるためですthey意図しています。)
これを認識して以来、高水準言語からの未定義の動作を追放する一般的な動機があり、Javaはこれについて特に徹底的でした(これは、その上で実行するように設計されているので比較的簡単でしたとにかく独自に設計された仮想マシン)Cのような古い言語は、膨大な量の既存のコードとの互換性を失うことなく、そのように簡単に改造することはできません。
編集:指摘したように、効率も別の理由です。未定義の動作は、コンパイラの作成者がターゲットアーキテクチャを活用するための多くの余裕を持っていることを意味し、各実装は各機能の可能な最速の実装を回避します。これは、プログラマの給与がソフトウェア開発のボトルネックになりがちな今日よりも、昨日のパワーのないマシンではより重要でした。
基本的にJavaおよび類似の言語の設計者は、言語で未定義の動作を望んでいませんでした。これはトレードオフでした。未定義の動作を許可するとパフォーマンスが向上する可能性がありますが、言語設計者は安全を優先しました予測可能性が高くなります。
たとえば、Cで配列を割り当てると、データは未定義になります。 Javaでは、すべてのバイトを0(またはその他の指定された値)に初期化する必要があります。つまり、Cは割り当てを瞬時に実行できる一方で、ランタイムは配列を渡す必要があります(O(n)演算))。したがって、このような演算ではCが常に高速になります。
配列を使用するコードが読み取る前に配列にデータを入力する場合、これは基本的にJavaの無駄な作業です。ただし、コードが最初に読み込まれる場合、Javaで予測可能な結果が得られますが、Cでは予測できない結果が得られます。
未定義の動作は、特定の境界またはその他の条件でコンパイラに奇妙なまたは予期しない(または通常の)何かを実行する余裕を与えることにより、大幅な最適化を可能にします。
http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html を参照してください
初期化されていない変数の使用:これは一般にCプログラムの問題の原因として知られており、コンパイラの警告から静的および動的アナライザーまで、これらをキャッチするための多くのツールがあります。 (Javaのように)スコープに入るときにすべての変数をゼロで初期化する必要がないため、これによりパフォーマンスが向上します。ほとんどのスカラー変数では、オーバーヘッドはほとんど発生しませんが、配列とmallocがスタックされます。特にストレージは通常完全に上書きされるため、メモリはストレージのメモリセットを負担します。
符号付き整数オーバーフロー:「int」型の演算がオーバーフローする場合、結果は未定義です。 1つの例は、「INT_MAX + 1」がINT_MINであることが保証されていないことです。この動作により、一部のコードにとって重要な特定のクラスの最適化が可能になります。たとえば、INT_MAX + 1が未定義であることを知っていると、「X + 1> X」を「true」に最適化できます。乗算が「できない」オーバーフローを知ると(そうすることは定義されていないため)、「X * 2/2」を「X」に最適化できます。これらはささいなことのように思えるかもしれませんが、これらの種類のものは一般にインライン化とマクロ展開によって公開されます。これにより可能になるより重要な最適化は、次のような「<=」ループです。
for (i = 0; i <= N; ++i) { ... }
このループでは、オーバーフロー時に "i"が未定義の場合、コンパイラーはループが正確にN + 1回繰り返されると想定できるため、広範なループ最適化を開始できます。一方、 、オーバーフロー時にラップアラウンドするように変数が定義されている場合、コンパイラーはループが無限であると想定する必要があります(NがINT_MAXの場合に発生します)。これらの重要なループ最適化が無効になります。多くのコードが "int"を誘導変数として使用するため、これは特に64ビットプラットフォームに影響します。
Cの初期には、多くの混乱がありました。コンパイラが異なれば、言語の扱いも異なります。言語の仕様を書くことに興味があったとき、その仕様は、プログラマーがコンパイラーに依存していたCとかなり下位互換性がある必要があります。ただし、これらの詳細の一部は移植性がなく、一般的には意味がありません。たとえば、特定のエンディアンやデータレイアウトを想定しています。したがって、C標準では、多くの詳細が未定義または実装指定の動作として予約されており、コンパイラの作成者に多くの柔軟性を残しています。 C++はC上に構築されており、未定義の動作も備えています。
Javaは、C++よりもはるかに安全でシンプルな言語にしようとしました。 Javaは、完全な仮想マシンの観点から言語のセマンティクスを定義します。これは未定義の振る舞いのためのスペースをほとんど残さない、その一方で、それはJava実装が行うことを困難にすることができる要件を作ります(例えば、参照割り当てはアトミックでなければならない、または整数がどのように機能するか)。 Javaが潜在的に安全でない操作をサポートする場合、それらは通常、実行時に仮想マシンによってチェックされます(たとえば、一部のキャスト)。
JVMと.NET言語は簡単です。
ただし、選択には良い点があります。
エスケープハッチが提供されている場合、それらは未定義の本格的な動作を呼び戻します。しかし、少なくともそれらは通常、非常に短いストレッチでのみ使用されるため、手動で確認する方が簡単です。
JavaとC#は、少なくとも開発の早い段階で主要なベンダーによって特徴付けられています。 (それぞれSunとMicrosoft)。 CとC++は異なります。彼らは早い段階から複数の競合する実装をしてきました。 Cは特に、エキゾチックなハードウェアプラットフォームでも動作しました。その結果、実装間でばらつきがありました。 CとC++を標準化したISO委員会は、大きな共通点に同意することができますが、実装が異なるエッジでは、実装の余地が残されています。
これは、1つの動作を選択すると、別の選択に偏るハードウェアアーキテクチャではコストがかかる可能性があるためです-エンディアンが明らかな選択です。
本当の理由は、一方ではCとC++の間の意図の根本的な違いに、もう一方ではJavaとC#(いくつかの例のみ)にあります。歴史的な理由により、ここでの議論の多くはC++ではなくCについて語っていますが、(おそらくご存じのように)C++はCのかなり直接の子孫であるため、Cについて述べていることはC++にも等しく当てはまります。
それらは大部分が忘れられている(そしてそれらの存在が時々否定されることさえある)が、UNIXの非常に最初のバージョンはアセンブリ言語で書かれた。 Cの本来の目的の大部分(それだけではないにしても)は、UNIXをアセンブリ言語からより高いレベルの言語に移植することでした。目的の一部は、できるだけ高いレベルの言語でオペレーティングシステムを作成すること、またはそれを別の方向から見て、アセンブリ言語で作成する必要がある量を最小限に抑えることでした。
これを達成するために、Cはアセンブリ言語と同じレベルのアクセスをほぼハードウェアに提供する必要がありました。 PDP-11(一例として)は、I/Oレジスタを特定のアドレスにマップしました。たとえば、1つのメモリ位置を読み取り、システムコンソールでキーが押されたかどうかを確認します。読み取りを待機しているデータがあったときに、その場所に1ビットが設定されました。次に、指定された別の場所からバイトを読み取って、押されたキーのASCIIコードを取得します。
同様に、一部のデータを印刷したい場合は、指定した別の場所を確認し、出力デバイスの準備ができたら、さらに別の指定した場所にデータを書き込みます。
このようなデバイス用のドライバーの書き込みをサポートするために、Cでは整数型を使用して任意の場所を指定し、それをポインターに変換して、メモリ内のその場所の読み取りまたは書き込みを行うことができました。
もちろん、これにはかなり深刻な問題があります。地球上のすべてのマシンのメモリが1970年代初頭のPDP-11と同じように配置されているわけではありません。そのため、その整数を受け取り、それをポインターに変換してから、そのポインターを介して読み取りまたは書き込みを行う場合、取得しようとしているものについて妥当な保証を提供することはできません。明らかな例として、読み取りと書き込みはハードウェア内の別々のレジスタにマップされる場合があるため、何かを書き込んだ場合は(通常のメモリとは対照的に)読み取ってみてください。読み取ったものが、書き込んだものと一致しない場合があります。
私は去るいくつかの可能性を見ることができます:
これらのうち、1つは非常にばかげているようで、これ以上議論する価値はほとんどありません。 2は基本的に言語の基本的な意図を捨てています。これにより、3番目のオプションは、基本的に、合理的に検討できる唯一のオプションになります。
かなり頻繁に現れるもう1つのポイントは、整数型のサイズです。 Cはint
がアーキテクチャによって提案された自然なサイズであるべきであるという「位置」をとります。したがって、32ビットVAXをプログラミングしている場合、int
はおそらく32ビットでなければなりませんが、36ビットユニバックをプログラミングしている場合、int
はおそらく36ビット(およびなど)。サイズが8ビットの倍数であることが保証されているタイプのみを使用して、36ビットコンピュータ用のオペレーティングシステムを作成することは、おそらく合理的ではありません(不可能かもしれません)。多分私は表面的なだけかもしれませんが、私が36ビットマシン用のOSを作成している場合は、おそらく36ビットタイプをサポートする言語を使用したいと思うようです。
言語の観点からは、これはさらに未定義の動作につながります。 32ビットに収まる最大の値を取得すると、1を追加するとどうなりますか?一般的な32ビットハードウェアでは、ロールオーバーします(または、何らかのハードウェア障害をスローする可能性があります)。一方、それが36ビットハードウェアで実行されている場合は、1つ追加するだけです。言語がオペレーティングシステムの作成をサポートする場合、どちらの動作も保証できません。型のサイズとオーバーフローの動作の両方を異なるようにする必要があるだけです。
JavaとC#はそれらすべてを無視できます。これらは、オペレーティングシステムの作成をサポートするためのものではありません。それらを使用すると、いくつかの選択肢があります。 1つは、ハードウェアが要求するものをサポートするようにすることです。8、16、32、64ビットのタイプを要求するため、それらのサイズをサポートするハードウェアを構築するだけです。もう1つの明白な可能性は、言語が、基盤となるハードウェアが何を望んでいるかに関係なく、必要な環境を提供する他のソフトウェアの上でのみ実行されることです。
ほとんどの場合、これは実際にはどちらか一方の選択ではありません。むしろ、多くの実装は両方の少しを行います。通常、Javaは、オペレーティングシステムで実行されているJVMで実行します。多くの場合、OSはCで記述され、JVMはC++で記述されています。 JVMがARM CPUで実行されている場合、CPUにARMのJazelle拡張機能が含まれている可能性が非常に高く、ハードウェアをJavaのニーズにより近いものに調整できるため、ソフトウェアで実行する必要が少なく、Javaコードの実行が速くなります(とにかく遅くなります)。
まとめ
CとC++の動作は未定義です。なぜなら、意図したとおりに動作させるための受け入れ可能な代替手段が誰も定義していないためです。 C#とJavaは異なるアプローチをとりますが、そのアプローチはCとC++の目標に(たとえあったとしても)うまく適合しません。特に、どちらも、任意に選択したほとんどのハードウェアでシステムソフトウェア(オペレーティングシステムなど)を作成する合理的な方法を提供していないようです。どちらも、通常、既存のシステムソフトウェア(通常はCまたはC++で記述されています)が提供する機能に依存して機能します。
C標準の作成者は、読者が明白であると考えられるものを認識することを読者に期待し、公開された理論的根拠でほのめかされましたが、委員会は顧客のニーズを満たすためにコンパイラライターを注文する必要はありません。顧客は彼らのニーズが委員会よりもよく知っているべきだからです。特定の種類のプラットフォームのコンパイラが特定の方法で構造を処理することが予想されることが明らかである場合、その構造が未定義の動作を呼び出すとスタンダードが言っているかどうか誰も気にする必要はありません。規格に準拠したコンパイラがコードの一部を有効に処理することを義務付けていないということは、プログラマがそうでないコンパイラを喜んで購入する必要があることを意味します。
言語設計へのこのアプローチは、コンパイラの作成者が有料の顧客に自社の製品を販売する必要がある世界で非常にうまく機能します。コンパイラの作成者が市場の影響から孤立している世界では、完全にバラバラになります。 1990年代に普及した言語を操縦した方法で言語を操縦するための適切な市場条件が存在することは疑わしく、正気な言語デザイナーがそのような市場条件に依存することを望んでいることはさらに疑わしいです。
C++とcはどちらもdescriptionive標準(とにかくISOバージョン)を持っています。
これは、言語がどのように機能するかを説明し、言語が何であるかについて単一の参照を提供するためにのみ存在します。通常、コンパイラベンダーとライブラリライターが先導し、いくつかの提案がメインのISO標準に含まれます。
JavaとC#(または私が想定しているVisual C#)にはprescriptive標準があります。彼らは、言語の何が明確に前もってあるか、それがどのように機能するか、そして何が許可された振る舞いと見なされるかを教えてくれます。
それよりも重要なのは、Javaには実際にはOpen-JDKの「参照実装」があります。( Roslyn はVisual C#の参照実装として数えられると思いますが、できませんでした。そのソースを見つけてください。)
Javaの場合、標準に曖昧さがあり、Open-JDKはそれを特定の方法で行います。 Open-JDKが行う方法は標準です。
未定義の動作により、コンパイラはさまざまなアーキテクチャで非常に効率的なコードを生成できます。エリックの答えは最適化について述べていますが、それはそれを超えています。
たとえば、符号付きオーバーフローはCでは未定義の動作です。実際には、コンパイラーは、CPUが実行する単純な符号付き加算オペコードを生成することが期待されており、その動作は特定のCPUが行ったものです。
これにより、Cはほとんどのアーキテクチャで非常にうまく機能し、非常にコンパクトなコードを生成できました。符号付き整数が特定の方法でオーバーフローする必要があると規格が指定している場合、動作が異なるCPUは、単純な符号付き加算のためにさらに多くのコードを生成する必要がありました。
これがCで未定義の動作の多くが発生する理由であり、int
のサイズなどがシステム間で異なる理由です。 Int
はアーキテクチャに依存し、通常、char
よりも大きい、最も高速で最も効率的なデータ型として選択されます。
Cが新しくなったとき、これらの考慮事項は重要でした。コンピュータはそれほど強力ではなく、しばしば処理速度とメモリが制限されていました。 Cはパフォーマンスが本当に重要な場所で使用され、開発者は、コンピューターがどのように機能して、これらの未定義の動作が実際に特定のシステムでどのようになるかを理解する必要があります。
JavaやC#などの新しい言語では、未処理のパフォーマンスよりも未定義の動作を排除することが好まれました。