web-dev-qa-db-ja.com

「常に変数を初期化する」ことは重要なバグを隠さないのですか?

C++コアガイドラインには、ルール ES.20:常にオブジェクトを初期化する があります。

セット前の使用済みエラーとそれに関連する未定義の動作を回避します。複雑な初期化の理解に関する問題を回避します。リファクタリングを簡素化します。

ただし、このルールはバグを見つけるのに役立ちません。バグを隠すだけです。
プログラムに、初期化されていない変数を使用する実行パスがあるとします。バグです。未定義の動作はさておき、それは何かがうまくいかなかったことも意味し、プログラムはおそらくその製品要件を満たしていません。本番環境にデプロイする場合、金銭的損失が発生する可能性があります。

バグをどのようにスクリーニングしますか?テストを書きます。ただし、テストは実行パスの100%をカバーしておらず、テストはプログラム入力の100%をカバーしていません。それ以上に、テストでさえ欠陥のある実行パスをカバーしています-それでもパスすることができます。結局それは未定義の振る舞いであり、初期化されていない変数はいくぶん有効な値を持つことができます。

しかし、テストに加えて、初期化されていない変数に0xCDCDCDCDのようなものを書き込むことができるコンパイラがあります。これにより、テストの検出率がわずかに向上します。
さらに良い-初期化されていないメモリバイトのすべての読み取りをキャッチするAddress Sanitizerのようなツールがあります。

そして最後に、プログラムを見て、その実行パスにread-before-setがあることを通知できる静的アナライザーがあります。

だから私たちは多くの強力なツールを持っていますが、変数を初期化すると-消毒剤は何も見つけません

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

別のルールがあります-プログラムの実行でバグが発生した場合、プログラムはできるだけ早く終了する必要があります。それを維持する必要はありません。クラッシュし、クラッシュダンプを書いて、調査のためにエンジニアに渡してください。
変数を不必要に初期化すると、逆の処理が不必要に行われます。そうでなければ、セグメンテーション違反がすでに発生している場合でも、プログラムは存続しています。

35
Abyx

あなたの推論はいくつかのアカウントで間違っています:

  1. セグメンテーション違反は確実に発生するわけではありません。初期化されていない変数を使用するとndefined behaviourになります。セグメンテーション違反は、このような動作が発生する可能性のある1つの方法ですが、正常に実行されているように見える可能性も同じです。
  2. コンパイラは、初期化されていないメモリを定義されたパターン(0xCDなど)で埋めることはありません。これは、初期化されていない変数が使用される場所を見つけるのを支援するために一部のデバッガーが行うことです。そのようなプログラムをデバッガの外で実行すると、変数には完全にランダムなガベージが含まれます。 bytes_readのようなカウンターの値が10であるのと同じように、0xcdcdcdcdの値である可能性もあります。
  3. 初期化されていないメモリを固定パターンに設定するデバッガで実行している場合でも、起動時にのみそうします。つまり、このメカニズムは、静的な(場合によってはヒープに割り当てられた)変数に対してのみ確実に機能します。スタックに割り当てられる、またはレジスターにのみ存在する自動変数の場合、変数が以前に使用された場所に格納されている可能性が高いため、テルテールメモリパターンはすでに上書きされています。

常に変数を初期化するガイダンスの背後にある考え方は、これら2つの状況を可能にすることです

  1. 変数には、その存在の最初からすぐに有用な値が含まれています。必要なときに一度だけ変数を宣言するガイダンスとそれを組み合わせると、将来のメンテナンスプログラマーが、宣言と最初の割り当ての間で変数の使用を開始するという罠に陥ることを回避できます。

  2. 変数には、後でテストできる定義済みの値が含まれており、my_readなどの関数が値を更新したかどうかを確認できます。初期化を行わないと、bytes_readが実際に有効な値を持っているかどうかを判断できません。これは、どの値から始まったかがわからないためです。

あなたは「このルールはバグを見つけるのに役立ちません、それを隠すだけです」と書いた-ええと、ルールの目標はバグを見つけるのを助けることではなく、avoidそれらにあります。バグが回避されると、何も隠されません。

あなたの例に関して問題を議論しましょう:my_read関数がすべての状況下でbytes_readを初期化するために書かれた契約を持っていると仮定しますが、それはエラーの場合ではないので、それは少なくとも不良です、この場合。あなたの意図は、ランタイム環境を使用して、最初にbytes_readパラメータを初期化しないことによってそのバグを示すことです。確実にアドレスサニタイザーが配置されていることがわかっている限り、それは確かにそのようなバグを検出するための可能な方法です。バグを修正するには、内部でmy_read関数を変更する必要があります。

しかし、少なくとも同等に有効である別の観点があります。誤った動作は、事前にbytes_readを初期化しないcombinationからのみ発生します andその後my_readを呼び出す(期待どおりbytes_readはその後初期化されます)。これは、my_readなどの関数の記述仕様が100%明確でない場合、またはエラーの場合の動作について誤っている場合に、実際のコンポーネントで頻繁に発生する状況です。ただし、bytes_readが呼び出しの前にゼロに初期化される限り、プログラムはmy_read内で初期化が行われた場合と同じように動作するため、正しく動作し、この組み合わせではバグは発生しませんプログラム。

したがって、それから続く私の推奨は、次の場合のみ初期化しないアプローチを使用します

  • あなたが欲しいテストする関数またはコードブロックが特定のパラメータを初期化する場合
  • あなたは賭けの関数がそのパラメータに値を割り当てないことが間違いなく間違っている契約を持っていることを100%確信しています
  • あなたは100%環境がこれをキャッチできると確信しています

これらは、特定のツール環境に対して、通常テストコードで配置できる条件です。

ただし、実稼働コードでは、常にそのような変数を事前に初期化する方がよい。契約が不完全または間違っている場合、またはアドレスサニタイザーまたは同様の安全対策がアクティブ化されていない場合のバグを防ぐ、より防御的なアプローチです。また、プログラムの実行でバグが発生した場合は、正しく記述したとおり、「クラッシュ早期」のルールが適用されます。ただし、事前に変数を初期化することで問題がないことを意味する場合は、それ以上実行を停止する必要はありません。

25
Doc Brown

常に変数を初期化する

検討している状況の違いは、初期化なしの場合は未定義の動作が発生するのに対し、初期化に時間をかけた場合は定義済みおよび確定的バグ。これらの2つのケースがどれだけ極端に異なるかで強調することはできません。

架空のシミュレーションプログラムで架空の従業員に起こった可能性のある架空の例について考えます。この架空のチームは、架空で販売していた製品がニーズを満たしていることを実証するために、決定論的なシミュレーションを作成しようとしています。

さて、Wordのインジェクションで停止します。ポイントがわかると思います;-)

このシミュレーションでは、何百もの初期化されていない変数がありました。 1人の開発者がシミュレーションでvalgrindを実行し、「初期化されていない値の分岐」エラーがいくつかあることに気付きました。 「うーん、それは非決定性を引き起こす可能性があるように見え、私たちが最も必要なときにテスト実行を繰り返すのを難しくします。」開発者は経営陣に行きましたが、経営陣は非常に厳しいスケジュールにあり、この問題を追跡するためのリソースを確保できませんでした。 「変数を使用する前に、すべての変数を初期化することになります。適切なコーディング方法があります。」

最終的な納品の数か月前、シミュレーションがフルチャーンモードであり、チーム全体が全力を尽くして、予算内で約束されたすべてのプロジェクトのように、これまでに出資されたすべてのプロジェクトと同様に、小さすぎました。誰かが、何らかの理由で確定的シミュレーションが確定的に動作してデバッグできなかったため、重要な機能をテストできないことに気付きました。

チーム全体が停止し、2か月の大部分を費やして、機能の実装とテストを行う代わりに、初期化されていない値のエラーを修正するシミュレーションコードベース全体を調査しました。言うまでもなく、従業員は「私はあなたにそう言った」をスキップして、他の開発者が初期化されていない値が何であるかを理解するのを手助けすることにまっすぐ行きました。奇妙なことに、この事件の直後にコーディング標準が変更され、開発者は常に変数を初期化するように奨励されました。

そしてこれは警告ショットです。これはあなたの鼻をかすめた弾です。実際の問題は遠く遠く遠く遠くですあなたが想像するよりも陰湿です。

初期化されていない値を使用すると、「未定義の動作」になります(charなどの一部の例外的なケースを除きます)。未定義の動作(または略してUB)は非常にめちゃくちゃで完全に悪いので、代替手段よりも優れていると決して信じてはいけません。特定のコンパイラがUBを定義し、それを使用しても安全であると特定できる場合がありますが、それ以外の場合、未定義の動作は「コンパイラが感じる動作」です。値が指定されていないなど、「正気」と呼ぶかもしれません。無効なオペコードを出力し、プログラムを破損させる可能性があります。コンパイル時に警告をトリガーするか、またはコンパイラがevenと完全にエラーと見なす場合があります。

または何もしない場合があります

UBの炭鉱での私のカナリアは、私が読んだSQLエンジンのケースです。リンクしなかったことを許してください、私は再び記事を見つけることができませんでした。より大きなバッファーサイズを関数に渡すと、SQLエンジンにバッファーオーバーランの問題がありましたが、特定のバージョンのDebianでのみ。バグ忠実に記録され、調査されました。面白い部分は、バッファオーバーランがチェックされたでした。バッファオーバーランを適切に処理するコードがありました。それはこのように見えました:

// move the pointers properly to copy data into a ring buffer.
char* putIntoRingBuffer(char* begin, char* end, char* get, char*put, char* newData, unsigned int dataLength)
{
    // If dataLength is very large, we might overflow the pointer
    // arithmetic, and end up with some very small pointer number,
    // causing us to fail to realize we were trying to write past the
    // end.  Check this before we continue
    if (put + dataLength < put)
    {
        RaiseError("Buffer overflow risk detected");
        return 0;
    }
    ...
    // typical ring-buffer pointer manipulation followed...
}

レンディションにコメントを追加しましたが、考え方は同じです。 put + dataLengthラップすると、putポインターよりも小さくなります(不思議なことに、unsigned intがポインターのサイズであることを確認するためのコンパイル時チェックが行われていました)。これが発生した場合、標準のリングバッファーアルゴリズムがこのオーバーフローによって混乱する可能性があるため、0を返します。または、そうですか?

結局のところ、ポインタのオーバーフローはC++では定義されていません。ほとんどのコンパイラーはポインターを整数として扱うため、最終的には通常の整数オーバーフロー動作が発生します。ただし、これは未定義の動作です、つまり、コンパイラが何でもできる )欲しい。

このバグの場合、Debianhappenedは、他の主要なLinuxフレーバーのどれもが本番環境で更新していない新しいバージョンのgccを使用することを選択しましたリリース。この新しいバージョンのgccには、より積極的なデッドコードオプティマイザがありました。コンパイラーは未定義の動作を確認し、ifステートメントの結果は「コードの最適化を最も良くするものは何でも」であると判断しました。これはUBの完全に正当な翻訳でした。したがって、それはptr+dataLengthはUBポインターのオーバーフローなしではptrを下回ることは決してありません。ifステートメントはトリガーされず、バッファーオーバーランチェックを最適化します。

「健全な」UBの使用は、実際に主要なSQL製品に、回避するコードを記述していたバッファオーバーランのエクスプロイトを引き起こしました!

決して未定義の動作に依存しないでください。

22
Cort Ammon

私はほとんどの場合、変数の再割り当てが許可されていない関数型プログラミング言語で作業しています。ずっと。これにより、このクラスのバグは完全に排除されます。これは最初は大きな制限のように思われましたが、新しいデータを学習する順序と一致する方法でコードを構造化する必要があり、コードを単純化して保守を容易にする傾向があります。

これらの習慣は、命令型言語にも引き継がれます。ほとんどの場合、ダミー値で変数を初期化しないようにコードをリファクタリングすることが可能です。それらのガイドラインがそうするようにあなたに言っていることです。彼らは、自動化されたツールを満足させるだけのものではなく、そこに意味のあるものを入れてほしいと思っています。

CスタイルのAPIを使用した例は、もう少しトリッキーです。これらの場合、私はseの関数をゼロに初期化して、コンパイラーが文句を言わないようにしますが、my_read単体テスト。エラー状態が適切に機能することを確認するために、他のものに初期化します。すべての使用時にすべての考えられるエラー状態をテストする必要はありません。

5
Karl Bielefeldt

いいえ、それはバグを隠しません。代わりに、ユーザーがエラーに遭遇した場合に開発者がエラーを再現できるように、動作を確定的にします。

5
user204677

TL; DR:このプログラムを正しくするには、変数を初期化して祈るという2つの方法があります。 1つだけが一貫して結果を提供します。


質問にお答えする前に、まずUndefined Behaviorの意味を説明する必要があります。実際、私はコンパイラの作者に作業の大部分を行わせます。

これらの記事を読みたくない場合、TL; DRは次のようになります。

未定義の動作は、開発者とコンパイラの間の社会的契約です。コンパイラーは、ユーザーが未定義の動作に決して依存することは決してないだろうと盲目的に信じています。

残念ながら、「あなたの鼻から飛び立つ悪魔」の原型は、この事実の意味を完全に伝えることに失敗しました。何かが起こる可能性があることを証明するつもりでしたが、それはまったく信じられないほどだったので、ほとんどが肩をすくめられました。

しかし、真実はUndefined Behaviorcompilation自体に影響を与えるということです。プログラムを使用して(インストルメント化されているかどうかに関係なく、デバッガー内かどうかに関係なく)、その動作を完全に変更できます。

上記のパート2の例が印象的です。

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

に変換されます:

void contains_null_check(int *P) {
  *P = 4;
}

チェックされる前に逆参照されるため、P0になり得ないことは明らかです。


これはあなたの例にどのように当てはまりますか?

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

さて、あなたはUndefined Behaviorが実行時エラーを引き起こすと仮定するというよくある間違いをしました。そうでないかもしれません。

my_readの定義が次のようであると想像してみましょう:

err_t my_read(buffer_t buffer, int* bytes_read) {
    err_t result = {};
    int blocks_read = 0;
    if (!(result = low_level_read(buffer, &blocks_read))) { return result; }
    *bytes_read = blocks_read * BLOCK_SIZE;
    return result;
}

インライン化された適切なコンパイラで期待どおりに進みます。

int bytes_read; // UNINITIALIZED

// start inlining my_read

err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) {
    // nothing
} else {
    bytes_read = blocks_reads * BLOCK_SIZE;
}

// end of inlining my_read

buffer.shrink(bytes_read);

次に、優れたコンパイラーに期待されるように、不要なブランチを最適化します。

  1. 初期化せずに変数を使用しないでください
  2. resultbytes_readでない場合、0は初期化せずに使用されます
  3. 開発者は、result0になることはないと約束しています。

したがって、resultは決して0ではありません:

int bytes_read; // UNINITIALIZED
err_t result = {};
int blocks_read = 0;
result = low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

ああ、resultは決して使われません:

int bytes_read; // UNINITIALIZED
int blocks_read = 0;
low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

ああ、bytes_readの宣言を延期することができます。

int blocks_read = 0;
low_level_read(buffer, &blocks_read);

int bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

ここでは、元の変換を厳密に確認しています。初期化されていない変数がないため、デバッガーはトラップしません。

私はその道を進んでおり、予想される動作とアセンブリが一致しない場合の問題を理解することは本当に面白くありません。

4
Matthieu M.

サンプルコードを詳しく見てみましょう。

_int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);
_

これは良い例です。このようなエラーが予想される場合は、assert(bytes_read > 0);という行を挿入して、実行時にこのバグをキャッチできます。これは、初期化されていない変数では不可能です。

しかし、そうではなく、関数use(buffer)内にエラーが見つかったとします。プログラムをデバッガーにロードし、バックトレースをチェックして、このコードから呼び出されたことを確認します。そのため、このスニペットの上部にブレークポイントを配置し、再度実行して、バグを再現します。私たちはそれを捕まえようとする一歩を踏み出した。

_bytes_read_を初期化していない場合、ガベージが含まれています。毎回同じごみが含まれているとは限りません。行my_read(buffer, &bytes_read);を通過します。さて、それが以前とは異なる値である場合、バグをまったく再現できない可能性があります。次回、同じ入力で完全に偶然に機能する可能性があります。一貫してゼロの場合、一貫した動作が得られます。

同じ実行のバックトレースでも、値をチェックします。ゼロの場合、see何かが間違っている可能性があります。 _bytes_read_は成功時にゼロであってはなりません。 (可能であれば、-1に初期化することもできます。)おそらくここでバグをキャッチできます。 _bytes_read_がもっともらしい値であるとしても、それがたまたま間違っている場合、一目でそれを見つけられるでしょうか?

これは特にポインターに当てはまります。NULLポインターは常にデバッガーで明らかであり、非常に簡単にテストでき、逆参照しようとすると最新のハードウェアでセグメンテーション違反が発生するはずです。ガベージポインタは、後で再現不可能なメモリ破損のバグを引き起こす可能性があり、これらをデバッグすることはほとんど不可能です。

1
Davislor

OPは未定義の動作に依存していないか、少なくとも正確には依存していません。実際、未定義の動作に依存することは悪いことです。同時に、予期しないケースでのプログラムの動作はalso undefinedですが、別の種類のundefinedです。変数をゼロに設定しても、その最初のゼロを使用する実行パスを用意するつもりがなかった場合、バグがありdoにそのようなパスがあると、プログラムは正常に動作しますか?あなたは今、雑草の中にいます。その値を使用する予定はありませんでしたが、とにかくそれを使用しています。無害な場合もあれば、プログラムがクラッシュする場合や、プログラムがデータを静かに破損する場合もあります。あなたは知りません。

OPが言っていることは、このバグを許可する場合に、このバグを見つけるのに役立つツールがあるということです。値を初期化せずにそれを使用する場合は、バグがあることを通知する静的および動的アナライザーがあります。静的アナライザーは、プログラムのテストを開始する前に通知します。一方、値を盲目的に初期化すると、アナライザーはその初期値を使用する予定がなかったことを認識できず、バグが検出されなくなります。運が良ければ、それは無害か、単にプログラムをクラッシュさせるだけです。運が悪いと、静かにデータが破損します。

私がOPに反対する唯一の場所は、彼が「別の方法ですでにセグメンテーション違反が発生するとき」と彼が言う最後のところにあります。実際、初期化されていない変数は、セグメンテーション違反を確実に生成しません。代わりに、プログラムの実行を試みることさえできないような静的分析ツールを使用するべきだと私は言うでしょう。

1
Jordan Brown

あなたの質問に対する答えは、プログラム内に現れるさまざまなタイプの変数に分解する必要があります:


ローカル変数

通常、宣言は、変数が最初にその値を取得する場所にあります。古いスタイルのCのように変数を事前宣言しないでください。

//Bad: predeclared variables
int foo = 0;
double bar = 0.0;
long* baz = NULL;

bar = getBar();
foo = (int)bar;
baz = malloc(foo);


//Correct: declaration and initialization at the same place
double bar = getBar();
int foo = (int)bar;
long* baz = malloc(foo);

これにより、初期化の必要性の99%が排除され、変数は最初から最終的な値になります。いくつかの例外は、初期化がいくつかの条件に依存する場合です。

Base* ptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}

私はこれらのケースを次のように書くことは良い考えだと思います:

Base* ptr = nullptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}
assert(ptr);

I. e。変数の適切な初期化が実行されることを明示的に表明します。


メンバー変数

ここで私は他の回答者が言ったことに同意します:これらは常にコンストラクター/初期化子リストによって初期化されるべきです。そうしないと、メンバー間の一貫性を確保するのが難しくなります。また、すべてのケースで初期化を必要としないメンバーのセットがある場合は、クラスをリファクタリングし、それらのメンバーを常に必要な派生クラスに追加します。


バッファ

これは私が他の答えに同意しないところです。人々が変数の初期化を信仰するとき、彼らはしばしば次のようにバッファを初期化することになります:

char buffer[30];
memset(buffer, 0, sizeof(buffer));

char* buffer2 = calloc(30);

これはほとんどの場合害があると思います。これらの初期化の唯一の効果は、valgrindのようなツールを無力にレンダリングすることです。初期化されたバッファから読み取る必要のあるコードよりも多くのコードを読み取るコードは、おそらくバグです。ただし、初期化を行うと、そのバグはvalgrindによって公開されなくなります。したがって、メモリがゼロで満たされていることに本当に依存している場合を除いて、それらを使用しないでください(その場合、ゼロが必要な理由を説明するコメントをドロップします)。

また、valgrindまたは同様のツールの下でテストスイート全体を実行するビルドシステムにターゲットを追加して、初期化前の使用のバグとメモリリークを公開することを強くお勧めします。これは、変数のすべての事前初期化よりも価値があります。そのvalgrindターゲットは定期的に実行する必要があります。最も重要なのは、コードが公開される前に行うことです。


グローバル変数

初期化されていない(少なくともC/C++などでは)グローバル変数を使用することはできないため、この初期化が適切であることを確認してください。