お気に入りのプログラミング言語で1から2,000,000までのすべての数値を合計しようとしたことがありますか?結果は手動で簡単に計算できます:2,000,001,000,000。これは、符号なし32ビット整数の最大値の約900倍です。
C#は-1453759936
-負の値を出力します!そして、私はJavaが同じことをすることを推測します。
つまり、デフォルトで算術オーバーフローを無視する一般的なプログラミング言語がいくつかあります(C#では、それを変更するための非表示のオプションがあります)。それは私にとって非常に危険に見える動作であり、そのようなオーバーフローによって引き起こされたAriane 5のクラッシュではありませんでしたか?
それで、そのような危険な行動の背後にある設計上の決定は何ですか?
編集:
この質問に対する最初の回答は、チェックの過剰なコストを表しています。この仮定をテストするために短いC#プログラムを実行してみましょう。
Stopwatch watch = Stopwatch.StartNew();
checked
{
for (int i = 0; i < 200000; i++)
{
int sum = 0;
for (int j = 1; j < 50000; j++)
{
sum += j;
}
}
}
watch.Stop();
Console.WriteLine(watch.Elapsed.TotalMilliseconds);
私のマシンでは、チェックされたバージョンは11015msかかりますが、チェックされていないバージョンは4125msかかります。つまりチェックのステップには、数値を追加する場合のほぼ2倍の時間がかかります(合計で元の時間の3倍)。ただし、10,000,000,000回の繰り返しにより、チェックにかかる時間は1ナノ秒未満です。それが重要な状況もあるかもしれませんが、ほとんどのアプリケーションにとって、それは重要ではありません。
編集2:
サーバーアプリケーション(いくつかのセンサーから受信したデータを分析するWindowsサービス、かなりの数の処理が含まれるWindowsサービス)を/p:CheckForOverflowUnderflow="false"
パラメーターを使用して再コンパイルし(通常、オーバーフローチェックをオンに切り替えます)、デバイスに展開しました。 Nagiosモニタリングは、平均CPU負荷が17%に留まったことを示しています。
つまり、上記の構成例で見つかったパフォーマンスヒットは、アプリケーションにはまったく関係ありません。
これには3つの理由があります。
実行時に(すべての算術演算で)オーバーフローをチェックするコストは非常に高くなります。
コンパイル時にオーバーフローチェックを省略できることを証明するのは非常に複雑です。
場合によっては(たとえば、CRC計算、多数のライブラリなど)、「オーバーフローで折り返す」の方がプログラマにとって便利です。
それは悪いトレードオフだと誰が言うのですか?
オーバーフローチェックを有効にして、すべての製品アプリを実行します。これはC#コンパイラオプションです。実際にこれをベンチマークしましたが、違いを判断できませんでした。 (おもちゃではない)HTMLを生成するためにデータベースにアクセスするコストは、オーバーフローチェックのコストを覆い隠します。
本番環境では操作がオーバーフローしないことを知っていることを感謝しています。ほとんどすべてのコードは、オーバーフローが存在すると不規則に動作します。バグは無害ではありません。データが破損している可能性が高く、セキュリティが問題を引き起こす可能性があります。
場合によってはパフォーマンスが必要な場合は、unchecked {}
を使用してオーバーフローチェックを細かく無効にします。オーバーフローしない操作に依存していることを強調したい場合は、コードにchecked {}
を冗長に追加して、その事実を文書化することがあります。私はオーバーフローに注意していますが、チェックのおかげである必要はありません。
C#チームがデフォルトでオーバーフローをしないをチェックすることを選択したときに間違った選択をしたと思いますが、その選択は強い互換性の懸念のために封印されています。この選択は2000年頃に行われたことに注意してください。ハードウェアはそれほど機能がなく、.NETにはまだそれほど多くの牽引力がありませんでした。たぶん.NETはこのようにJavaおよびC/C++プログラマーにアピールしたかったのです。NETは金属に近づくことができるようにもなっています。そのため、安全でないコード、構造体、およびJavaにはない優れたネイティブコール機能.
私たちのハードウェアがより速く、よりスマートなコンパイラーがデフォルトでより魅力的なオーバーフローチェックを取得します。
また、オーバーフローチェックは、サイズが無限の数値よりも優れていることが多いと私は思います。無限のサイズの数値は、パフォーマンスコストがさらに高く、最適化が困難であると思います(私は信じています)。これらは、無限のリソース消費の可能性を開きます。
JavaScriptのオーバーフローへの対処方法はさらに悪いものです。 JavaScriptの数値は浮動小数点のdoubleです。 「オーバーフロー」は、完全に正確な整数のセットを残すこととして現れます。 わずかに間違った結果が発生します(1つずつオフになるなど-これにより、有限ループが無限ループに変わる可能性があります)。
C/C++などの一部の言語では、これらの言語で作成されている種類のアプリケーションにはベアメタルパフォーマンスが必要であるため、デフォルトでのオーバーフローチェックは明らかに不適切です。それでも、オプトインをより安全なモードにすることを許可することにより、C/C++をより安全な言語にする努力があります。コードの90-99%は寒くなる傾向があるため、これは称賛に値します。例は、2の補数の折り返しを強制するfwrapv
コンパイラオプションです。これは、言語ではなくコンパイラによる「実装の品質」機能です。
Haskellには論理的な呼び出しスタックはなく、指定された評価順序もありません。これにより、予測できない時点で例外が発生します。 a + b
では、a
またはb
が最初に評価されるかどうか、およびそれらの式がまったく終了するかどうかは指定されていません。したがって、ほとんどの場合、Haskellは無制限の整数を使用するのが理にかなっています。ほとんどのHaskellコードでは例外が本当に不適切であるため、この選択は純粋に関数型の言語に適しています。そして、ゼロによる除算は、確かにHaskells言語設計における問題点です。無制限の整数の代わりに、固定幅の折り返し整数を使用することもできますが、言語が特徴とする「正確さを重視」というテーマには適合しません。
オーバーフロー例外の代替手段は、未定義の操作によって作成され、操作を通じて伝播する有害値です(float NaN
値など)。これは、オーバーフローチェックよりもはるかにコストがかかり、失敗する可能性のある操作だけでなく、すべての操作が遅くなります(floatが通常持っているハードウェアアクセラレーションとintが通常持っていないハードウェアアクセラレーションを除けば ItaniumにはNaTがありません) " )。また、プログラムに不正なデータと共に歩み続けることのポイントもよくわかりません。 ON ERROR RESUME NEXT
のようなものです。エラーを非表示にしますが、正しい結果を得るのに役立ちません。 supercatは、これを行うことがパフォーマンスの最適化である場合があることを指摘しています。
オーバーフローdoesが発生するというまれなケースを自動的に検出するためにall計算をはるかに高価にすることは悪いトレードオフであるためです。 allプログラマーに使用しない機能の代償を払わせるよりも、これが問題となるまれなケースを認識して特別な防止策を追加することでプログラマーに負担をかける方がはるかに優れています。
このような危険な動作の背後にある設計上の決定は何ですか?
「ユーザーに不要な機能のパフォーマンスペナルティを支払うように強制しないでください。」
これは、CおよびC++の設計における最も基本的な信条の1つであり、今日、ささいなことと見なされているタスクに対してやっと十分なパフォーマンスを得るためにとんでもないゆがみを経験しなければならなかった別の時期に由来します。
新しい言語は、配列の境界チェックなど、他の多くの機能に対するこの態度で壊れます。彼らがなぜオーバーフローチェックのためにそれをしなかったのか、私にはわかりません。それは単に見落としかもしれません。
レガシー
問題はおそらくレガシーに根ざしていると思います。 C:
これは、プログラマーが何をしているのかを知っているという原則に従って、可能な限り最高のパフォーマンスを得るために行われました。
Stad-Quoにつながる
C(および拡張C++)がオーバーフローを順番に検出する必要がないという事実は、オーバーフローのチェックが遅いことを意味します。
ハードウェアは主にC/C++に対応しています(真剣に、x86にはstrcmp
命令があります(別名 [〜#〜] pcmpistri [〜#〜] as SSE 4.2 )!)、そしてCは気にしないので、一般的なCPUはオーバーフローを検出する効率的な方法を提供しません。 x86では、オーバーフローする可能性のある操作ごとに、コアごとのフラグを確認する必要があります。あなたが本当に欲しいのは、結果の「汚染された」フラグです(NaNが伝播するのと同じように)。そして、ベクトル演算はさらに問題が多いかもしれません。 一部の新規プレイヤー は、効率的なオーバーフロー処理で市場に登場する可能性があります。ただし、現時点ではx86とARMは関係ありません。
コンパイラオプティマイザは、オーバーフローチェックの最適化、またはオーバーフローが存在する場合の最適化さえ得意ではありません。 John Regherなどの一部の学者はこの状況について不満を述べています ですが、オーバーフローを発生させるという単純な事実が「失敗」すると、アセンブリがCPUに到達する前でも最適化が妨げられる場合があります。特に自動ベクトル化が妨げられる場合...
カスケード効果あり
したがって、効率的な最適化戦略と効率的なCPUサポートがない場合、オーバーフローチェックはコストがかかります。ラッピングよりもはるかにコストがかかります。
x + y - 1
がオーバーフローしない場合にx - 1 + y
がオーバーフローする可能性があるなど、いくつかの迷惑な動作を追加します。これは、ユーザーを正当に不快にさせる可能性があり、オーバーフローチェックは一般的に破棄されます優雅に)。
それでも、すべての希望が失われるわけではありません
未定義の動作のケースを検出するためにバイナリを計測する方法である「サニタイザ」を実装するために、clangコンパイラとgccコンパイラでの取り組みがありました。 -fsanitize=undefined
を使用すると、符号付きオーバーフローが検出され、プログラムが中止されます。テスト中に非常に役立ちます。
Rustプログラミング言語では、デバッグモードでデフォルトでオーバーフローチェックが有効になっています。パフォーマンス上の理由からリリースモード)。
したがって、オーバーフローチェックと偽の結果が検出されない危険性についての懸念が高まっています。うまくいけば、これが研究コミュニティ、コンパイラコミュニティ、ハードウェアコミュニティにspark関心を示すことになります。
オーバーフローを検出しようとする言語は、関連するセマンティクスを歴史的に定義してきました。特に、コードで指定されたものとは異なるシーケンスで計算を実行すると便利なことがよくありますが、オーバーフローをトラップするほとんどの言語では、次のようなコードが保証されます。
for (int i=0; i<100; i++)
{
Operation1();
x+=i;
Operation2();
}
xの開始値によってループの47番目のパスでオーバーフローが発生する場合、Operation1は47回実行され、Operation2は46回実行されます。このような保証がない場合、ループ内の他の何もxを使用せず、何も実行されません。 Operation1またはOperation2によってスローされた例外に続いてxの値を使用します。コードは次のように置き換えることができます。
x+=4950;
for (int i=0; i<100; i++)
{
Operation1();
Operation2();
}
残念ながら、ループ内でオーバーフローが発生した場合に正しいセマンティクスを保証しながら、そのような最適化を実行することは困難です。
if (x < INT_MAX-4950)
{
x+=4950;
for (int i=0; i<100; i++)
{
Operation1();
Operation2();
}
}
else
{
for (int i=0; i<100; i++)
{
Operation1();
x+=i;
Operation2();
}
}
実際のコードの多くがより複雑なループを使用していると考えると、オーバーフローのセマンティクスを維持しながらコードを最適化するのは難しいことは明らかです。さらに、キャッシュの問題により、コードサイズの増加により、一般的に実行されるパスでの操作が少なくても、プログラム全体の実行が遅くなる可能性があります。
オーバーフロー検出を安価にするために必要なのは、結果に影響を与える可能性のあるオーバーフローなしで計算が実行されたかどうかをコードが簡単に報告できる、より緩やかなオーバーフロー検出セマンティクスの定義されたセットですが、負担はありません。それ以上の詳細を持つコンパイラ。言語仕様が、オーバーフロー検出のコストを上記を達成するために必要な最小限に削減することに焦点を合わせていた場合、既存の言語よりもはるかに低コストにすることができます。ただし、効率的なオーバーフロー検出を容易にするための取り組みについては知りません。
(*)言語がすべてのオーバーフローが報告されることを約束している場合、x*y/y
がオーバーフローしないことが保証されない限り、x*y
のような式はx
に簡略化できません。同様に、計算の結果が無視される場合でも、すべてのオーバーフローを報告することを約束する言語は、とにかくそれを実行してオーバーフローチェックを実行する必要があります。そのような場合のオーバーフローは、算術的に正しくない動作を引き起こすことはないので、プログラムは、オーバーフローが潜在的に不正確な結果を引き起こしていないことを保証するために、そのようなチェックを実行する必要はありません。
ちなみに、Cのオーバーフローは特に悪いです。 C99をサポートするほとんどすべてのハードウェアプラットフォームは2の補数のサイレントラップアラウンドセマンティクスを使用しますが、最新のコンパイラーがオーバーフローの場合に任意の副作用を引き起こす可能性のあるコードを生成するのはファッショナブルです。たとえば、次のようなものが与えられたとします。
#include <stdint.h>
uint32_t test(uint16_t x, uint16_t y) { return x*y & 65535u; }
uint32_t test2(uint16_t q, int *p)
{
uint32_t total=0;
q|=32768;
for (int i = 32768; i<=q; i++)
{
total+=test(i,65535);
*p+=1;
}
return total;
}
GCCはtest2のコードを生成し、無条件に1回(* p)増分し、qに渡された値に関係なく32768を返します。その理由から、(32769 * 65535)&65535uの計算はオーバーフローを引き起こすため、コンパイラーは(q | 32768)が32768より大きい値を生成するケースを考慮する必要はありません。 (32769 * 65535)と65535uの計算では結果の上位ビットを考慮する必要があるため、gccはループを無視する理由として符号付きオーバーフローを使用します。
すべてのプログラミング言語が整数オーバーフローを無視するわけではありません。一部の言語は、すべての数値(ほとんどのLISP方言、Ruby、Smalltalkなど)に安全な整数演算を提供し、その他はライブラリを介して提供します。たとえば、C++にはさまざまなBigIntクラスがあります。
言語がデフォルトで整数をオーバーフローから保護するかどうかは、その目的に依存します。CやC++などのシステム言語は、ゼロコストの抽象化を提供する必要があり、「大きな整数」は1ではありません。 Rubyなどの生産性言語は、そのままで大きな整数を提供できます。 JavaやC#などの言語は、中間のどこかにあり、安全ではない整数をそのまま使用する必要があります。
あなたが示したように、デフォルトでオーバーフローチェックが有効になっている場合、C#は3倍遅くなります(例がその言語の典型的なアプリケーションであると仮定した場合)。私は、パフォーマンスが常に最も重要な機能であるとは限らないことに同意しますが、言語/コンパイラは通常、典型的なタスクでのパフォーマンスを比較します。これは、パフォーマンステストが客観的である一方で、言語機能の品質がやや主観的であるという事実に一部起因しています。
ほとんどの点でC#に似ているが3倍遅い新しい言語を導入する場合、エンドユーザーの大部分がオーバーフローチェックのメリットを最終的に享受する場合でも、市場シェアを獲得することは容易ではありません。より高いパフォーマンスから。
パフォーマンスに基づくオーバーフローチェックの欠如を正当化する多くの回答の他に、2つの異なる種類の計算を考慮する必要があります。
インデックス付けの計算(配列のインデックス付けおよび/またはポインタ演算)
その他の算術
言語がポインターサイズと同じ整数サイズを使用している場合、適切に構築されたプログラムは、インデックス計算がオーバーフローを引き起こす前にメモリ不足になる必要があるため、インデックス計算の実行でオーバーフローしません。
したがって、割り当てられたデータ構造を含むポインタ演算およびインデックス式を操作する場合は、メモリ割り当てのチェックで十分です。たとえば、32ビットのアドレス空間があり、32ビットの整数を使用し、最大2GBのヒープ(アドレス空間の約半分)を割り当てることができる場合、インデックス付け/ポインターの計算は(基本的に)オーバーフローしません。
さらに、加算/減算/乗算のどれだけが配列のインデックス付けまたはポインターの計算に関係していて、最初のカテゴリーに分類されるかについては驚くかもしれません。オブジェクトポインター、フィールドアクセス、および配列操作はインデックス付け操作であり、多くのプログラムはこれら以上の算術計算を行いません! 基本的に、これはプログラムが整数オーバーフローチェックなしで機能するのと同じように機能する主な理由です。
すべての非インデックス付けおよび非ポインター計算は、オーバーフローを必要とする/期待するもの(ハッシュ計算など)とオーバーフローしないもの(ハッシュ計算など)のいずれかに分類する必要があります。
後者の場合、プログラマはしばしばdouble
や一部のBigInt
などの代替データ型を使用します。多くの計算では、decimal
ではなくdouble
データ型が必要です。たとえば、財務計算。整数型に固執しない場合は、整数オーバーフローをチェックするように注意する必要があります。そうしないと、指摘したように、プログラムが未検出のエラー状態に達する可能性があります。
プログラマーとして、精度は言うまでもなく、オーバーフローの可能性に関して、数値データ型の選択とそれらの結果に敏感である必要があります。一般に(特に、高速整数型を使用したいC言語ファミリの言語を使用している場合)、インデックス計算と他の計算の違いに敏感であり、それを認識する必要があります。
Swiftでは、整数オーバーフローはデフォルトで検出され、プログラムを即座に停止します。ラップアラウンド動作が必要な場合、それを実現するさまざまな演算子&+、&-および&*があります。また、演算を実行してオーバーフローが発生したかどうかを通知する関数があります。
初心者がCollatzシーケンスを評価しようとしてコードがクラッシュするのを見るのは楽しいです:-)
Swiftの設計者はLLVMとClangの設計者でもあるため、最適化について少し知っており、不要なオーバーフローチェックを回避することが非常に可能です。すべての最適化を有効にすると、オーバーフローチェックコードサイズと実行時間に多くを追加することはありません。また、ほとんどのオーバーフローは完全に正しくない結果につながるため、コードサイズと実行時間は十分に費やされています。
PS。 C、C++では、Objective-Cの符号付き整数算術オーバーフローは未定義の動作です。つまり、符号付き整数オーバーフローの場合にコンパイラーが行うことはすべて、定義により正しいことを意味します。符号付き整数オーバーフローに対処する一般的な方法は、それを無視し、CPUが与える結果をすべて取り、そのようなオーバーフローが決して発生しないという仮定をコンパイラーに組み込みます(たとえば、オーバーフローは、n + 1> nが常にtrueであると結論付けます)。 Swiftのように、オーバーフローが発生した場合にチェックしてクラッシュすることはまれです。
言語 Rust は、最適化されたリリースバージョンでデバッグビルドのチェックを追加して削除することにより、オーバーフローのチェックとそうでないチェックの間の興味深い妥協点を提供します。これにより、テスト中にバグを見つけると同時に、最終バージョンで完全なパフォーマンスを得ることができます。
オーバーフローラップアラウンドが必要な場合があるため、オーバーフローをチェックしない 演算子のバージョン もあります。
変更の理由については、変更の [〜#〜] rfc [〜#〜] を参照してください。 このブログの投稿 には、この機能が把握に役立つ バグのリスト を含む、興味深い情報がたくさんあります。
実際、これの真の原因は純粋に技術的/歴史的です:CPUのignore記号の大部分。通常、レジスタに2つの整数を追加する命令は1つだけで、CPUはこれらの2つの整数を符号付きと符号なしのどちらとして解釈してもかまいません。減算についても、乗算についても同じことが言えます。符号対応にする必要がある唯一の算術演算は除算です。
これが機能する理由は、事実上すべてのCPUで使用される符号付き整数の2の補数表現です。たとえば、4ビットの2の補数では、5と-3の加算は次のようになります。
0101 (5)
1101 (-3)
(11010) (carry)
----
0010 (2)
キャリーアウトビットを破棄するというラップアラウンド動作が正しい符号付き結果を生成する方法を観察します。同様に、CPUは通常、減算x - y
をx + ~y + 1
として実装します。
0101 (5)
1100 (~3, binary negation!)
(11011) (carry, we carry in a 1 bit!)
----
0010 (2)
これは減算をハードウェアの追加として実装し、算術論理ユニット(ALU)への入力のみを簡単な方法で微調整します。何がもっと簡単なのでしょうか?
乗算は加算のシーケンスに他ならないため、同様に適切に動作します。 2の補数表現を使用し、算術演算のキャリーアウトを無視すると、回路が簡素化され、命令セットが簡素化されます。
明らかに、Cは金属に近い場所で動作するように設計されているため、符号なし演算の標準化された動作とまったく同じ動作を採用し、符号付き演算のみで未定義の動作を生成できました。そして、その選択はJavaのような他の言語、そしてもちろんC#にも引き継がれました。
いくつかの回答はチェックのコストについて議論しており、これは妥当な正当化であるという論争のために回答を編集しました。私はそれらのポイントに対処しようとします。
CおよびC++(例として)では、言語設計原則の1つは、要求されなかった機能を提供しないことです。これは一般的に、「使用しないものについては支払いをしない」という言葉で要約されます。プログラマがオーバーフローチェックを必要とする場合、プログラマはそれを要求することができます(ペナルティを支払うこともできます)。これにより、言語の使用がより危険になりますが、それを知っている言語で作業することを選択するため、リスクを受け入れます。そのリスクを望まない場合、または安全性が最も重要なパフォーマンスであるコードを記述している場合は、パフォーマンスとリスクのトレードオフが異なる、より適切な言語を選択できます。
ただし、10,000,000,000回の繰り返しにより、チェックにかかる時間は1ナノ秒未満です。
この推論にはいくつかの問題点があります。
これは環境固有です。コードは、パフォーマンスの点で桁違いに変化するあらゆる種類の環境用に記述されているため、このような特定の数値を引用しても、ほとんど意味がありません。デスクトップマシンでの1ナノ秒は、組み込み環境をコーディングしている人にとっては驚くほど速く、スーパーコンピュータークラスターをコーディングしている人にとっては耐えられないほど遅いように見えるかもしれません。
まれに実行されるコードのセグメントでは、1ナノ秒は何のようにも見えない場合があります。一方、コードの主な機能である計算の内部ループ内にある場合、ひげを剃ることができるすべての時間は大きな違いを生む可能性があります。クラスターでシミュレーションを実行している場合、内部ループで節約されたナノ秒の数分の1は、ハードウェアと電気に費やされたお金に直接換算できます。
一部のアルゴリズムとコンテキストでは、10,000,000,000回の反復は重要ではない場合があります。繰り返しになりますが、特定の状況でのみ適用される特定のシナリオについて話すことは一般的に意味がありません。
それが重要な状況があるかもしれませんが、ほとんどのアプリケーションにとって、それは重要ではありません。
おそらくあなたは正しい。しかし、これもまた、特定の言語の目標が何であるかという問題です。実際、多くの言語は、「ほとんど」のニーズに対応するように、または他の懸念よりも安全性を優先するように設計されています。 CやC++などの他のものは、効率を優先します。その文脈では、ほとんどの人が気にならないという理由だけで全員にパフォーマンスのペナルティを支払わせることは、言語が達成しようとしていることに反対します。