関数が誤って参照パラメーターを無効にします-何が間違っていましたか?


54

今日、特定のプラットフォームでのみ断続的に発生する厄介なバグの原因を発見しました。要約すると、コードは次のようになりました。

class Foo {
  map<string,string> m;

  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }

  void B() {
    while (!m.empty()) {
      auto toDelete = m.begin();
      A(toDelete->first);
    }
  }
}

この単純化されたケースでは、問題は明らかなように見えBます。キーへの参照を渡してA、マップエントリを削除してから印刷しようとします。以来、これはもちろん、未定義の動作である(私たちの場合は、それが印刷されなかったが、より複雑な方法で使用される)keyへの呼び出し後にダングリング参照ですerase

これを修正するのは簡単でしconst string&string。パラメータタイプをからに変更しました。問題は、そもそもどうやってこのバグを回避できたのでしょうか?両方の機能が正しいことをしたようです:

  • Akey破壊しようとしているものを参照していることを知る方法がありません。
  • Bに渡す前にコピーを作成できたかもしれAませんが、値または参照によってパラメータを取るかどうかを決定するのは呼び出し先の仕事ではありませんか?

従わなかったルールはありますか?

回答:


35

Akey破壊しようとしているものを参照していることを知る方法がありません。

これは事実ですAが、次のことを知っています。

  1. その目的は何か破壊することです。

  2. それは、破壊するものとまったく同じタイプのパラメーターを取ります。

これらの事実を考えると、パラメーターをポインター/参照として使用する場合、独自のパラメーターを破棄することが可能ですA。これは、そのような考慮事項に対処する必要があるC ++の唯一の場所ではありません。

この状況は、operator=割り当て演算子の性質が、自己割り当てについて心配する必要があることを意味する方法と似ています。this参照パラメーターのタイプとタイプが同じであるため、これは可能性です。

A後で問題を使用するつもりであるため、これは問題があるだけであることに注意してくださいkeyエントリを削除した後パラメーターください。そうでなければ、それは問題ないでしょう。もちろん、その後、すべてが完全に機能Aするようkeyになり、潜在的に破壊された後に誰かが使用するように変更します。

それはコメントのための良い場所でしょう。

従わなかったルールはありますか?

C ++では、盲目的に一連の規則に従えば、コードは100%安全であるという前提の下で操作することはできません。私たちにはルールがありませんすべての

上記のポイント2を検討してください。Aキーとは異なるタイプのパラメーターを使用できますが、オブジェクト自体はマップ内のキーのサブオブジェクトになる可能性があります。C ++ 14では、findそれらの間に有効な比較がある限り、キータイプとは異なるタイプを使用できます。その場合m.erase(m.find(key))、パラメータのタイプがキータイプではない場合でも、パラメータを破棄できます。

そのため、「パラメーターの種類とキーの種類が同じ場合、値で取得する」というようなルールはあなたを救いません。それ以上の情報が必要になります。

最終的には、経験に基づいて、特定のユースケースと運動判断に注意を払う必要があります。


10
さて、あなたはルールを持っている可能性があり、「可変状態を共有したことがない」か、「共有状態を変異させたことがない」デュアルだが、その後は、識別のC ++書くのに苦労します
Caleth

7
@Calethこれらのルールを使用する場合、C ++はおそらくあなたの言語ではありません。
user253751 16

3
@Caleth Rustについて説明していますか?
マルコム

1
「すべてのルールを設定することはできません。」はい、できます。cstheory.stackexchange.com/q/4052
ウロボロス16

23

はい、あなたを破ったかなり単純なルールがあります。それは単一の責任原則です。

現在、Aマップからアイテムを削除するために使用するパラメーターと、他の処理行うれます(上記のように印刷、実際のコードでは明らかに何か)。これらの責任を組み合わせることは、問題の原因の多くのように思えます。

我々はそれがいることを一つの関数がある場合は、単にマップから値を削除し、別のことばかりマップから値の処理を行いますが、我々は、より高いレベルのコードからそれぞれを呼び出す必要があるだろうので、我々はこのようなものに終わるだろう:

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

確かに、私が使用した名前は間違いなく実際の名前よりも問題を明らかにしますが、名前に意味がある場合は、参照を使用し続けていることを明確にすることはほぼ確実です無効化されました。コンテキストの単純な変更により、問題がより明確になります。


3
これは有効な観察であり、この場合に非常に狭い範囲でのみ適用されます。SRPが尊重される多くの例がありますが、それでも潜在的に独自のパラメーターを無効にする機能の問題があります。
ベンフォークト

5
@BenVoigt:パラメーターを無効にするだけでは問題は発生しません。無効になったパラメーターは引き続き使用され、問題が発生します。しかし、最終的にははい、あなたは正しいです。この場合彼を救ったでしょうが、それが不十分な場合は間違いない場合があります。
ジェリーCo

3
簡略化された例を記述する場合、一部の詳細を省略する必要があり、時にはそれらの詳細の1つが重要であることが判明します。私たちの場合、A実際にkeyは2つの異なるマップを探し、見つかった場合は、エントリといくつかの余分なクリーンアップを削除しました。したがって、ASRPに違反したことは明らかではありません。この時点で質問を更新する必要があるのだろうか。
ニコライ

2
Nicolaiの例の@BenVoigtのpoint:を展開するにm.erase(key)は、最初の責任がありcout << "Erased: " << key、2番目の責任があります。したがって、この回答に示されているコードの構造は、実際には例のコードの構造と違いはありませんが、問題は見落とされていた現実の世界。単一責任の原則は、現実世界のコードでは、単一アクションの矛盾するシーケンスが近接して表示されることを保証するものではなく、可能性を高めるものでもありません。
sdenham 16

10

従わなかったルールはありますか?

はい、機能の文書化に失敗しました

パラメーターを渡すコントラクトの説明がない場合(特にパラメーターの有効性に関連する部分-関数呼び出しの開始時または全体)、エラーが実装にあるかどうかを判断することはできません(呼び出しコントラクトの場合呼び出しの開始時にパラメーターが有効であるため、関数はパラメーターを無効にする可能性のあるアクションを実行する前にコピーを作成する必要があります(呼び出しコントラクトがパラメーターを呼び出し中有効のままにする必要がある場合、呼び出し元はできません)変更中のコレクション内のデータへの参照を渡します)。

たとえば、C ++標準自体は次のことを指定しています。

関数への引数に無効な値がある場合(関数のドメイン外の値や、その使用目的に対して無効なポインターなど)、動作は未定義です。

ただし、これは呼び出しが行われた瞬間にのみ適用されるか、関数の実行全体に適用されるかを指定できません。ただし、多くの場合、後者のみが可能であること、つまり、コピーを作成しても引数を有効に保つことができない場合は明らかです。

この区別が関係する現実世界のケースはかなりあります。例えば、自身にa std::vector<T>を追加する


「これは、呼び出しが行われた瞬間にのみ適用されるか、関数の実行全体に適用されるかを指定できません。」実際には、UBが呼び出されると、コンパイラーは関数全体で必要なことをほぼすべて実行します。プログラマーがUBをキャッチしない場合、これは本当に奇妙な動作につながる可能性があります。

@snowmanは興味深いですが、UBの並べ替えは、この回答で説明した内容とはまったく関係がありません。これは、有効性を保証する責任です(したがって、UBは発生しません)。
ベンフォークト

これがまさに私のポイントです。コードを書いている人は、問題を抱えたウサギの穴全体を避けるために、UBを避ける責任が必要です。

@Snowman:プロジェクト内のすべてのコードを書く「一人」はいません。これが、インターフェイスのドキュメントが非常に重要な理由の1つです。もう1つは、明確に定義されたインターフェイスにより、一度に推論する必要があるコードの量が減ることです。重要なプロジェクトでは、すべてのステートメントの正確性について「責任」を負うことは不可能です。
ベンフォークト

一人がすべてのコードを書くとは決して言わなかった。ある時点で、プログラマーは関数を見ているか、コードを書いているかもしれません。私が言おうとしていることは、コードを見る人は誰でも注意する必要があるということです。実際には、UBは感染性があり、コンパイラが関与すると、1行のコードからより広い範囲に広がります。これは、機能の契約に違反するというあなたのポイントに戻ります。私はあなたに同意しますが、さらに大きな問題になる可能性があると述べています。

2

従わなかったルールはありますか?

はい、正しくテストできませんでした。あなたは一人ではなく、あなたは学ぶための適切な場所にいます:)


C ++には多くの未定義の動作があり、未定義の動作は微妙で迷惑な方法で現れます。

おそらく100%安全なC ++コードを書くことはできませんが、多くのツールを使用することで、コードベースに誤って未定義の動作を導入する可能性を確実に減らすことができます。

  1. コンパイラの警告
  2. 静的分析(警告の拡張バージョン)
  3. インストルメント済みテストバイナリ
  4. 強化されたプロダクションバイナリ

あなたの場合、(1)と(2)が大いに役立つとは思いませんが、一般的にはそれらを使用することをお勧めします。今のところ、他の2つに集中しましょう。

gccとClangはどちらも-fsanitize、さまざまな問題をチェックするためにコンパイルするプログラムを計測するフラグを備えています。-fsanitize=undefined例えば、あなたの特定のケースで...など、高すぎる量でシフトし、整数アンダーフロー/オーバーフローを締結したキャッチします、-fsanitize=address-fsanitize=memory問題にピックアップしそうだっただろう...あなたは関数を呼び出すテストを持って提供します。完全を-fsanitize=thread期すために、マルチスレッドのコードベースがある場合は使用する価値があります。バイナリを実装できない場合(たとえば、ソースのないサードパーティのライブラリがある場合)、valgrind一般的には低速ですが使用することもできます。

最近のコンパイラには、富を強化する可能性もあります。インストルメント済みバイナリとの主な違いは、強化チェックがパフォーマンスへの影響が小さく(1%未満)なるように設計されており、一般に本番コードに適していることです。最もよく知られているのは、制御フローを破壊する他の方法の中でも特に、スタックスマッシング攻撃や仮想ポインターハイジャックを阻止するように設計されたCFIチェック(制御フローの整合性)です。

(3)と(4)の両方のポイントは、断続的な障害特定の障害に変換することです。どちらもフェイルファーストの原則に従います。この意味は:

  • 地雷を踏むといつも失敗する
  • それはすぐに失敗、ランダムにメモリを破損するのではなく、エラーを指摘します...

(3)を適切なテストカバレッジと組み合わせることで、ほとんどの問題が本番稼働前にキャッチされるはずです。本番環境で(4)を使用すると、迷惑なバグと悪用の違いになります。


0

@注:この投稿では、Ben Voigtの回答に加えて、さらに引数を追加しています。

問題は、そもそもどうやってこのバグを回避できたのでしょうか?両方の機能が正しいことをしたようです:

  • Aは、キーが破壊しようとしているものを指していることを知る方法がありません。
  • Bは、Aに渡す前にコピーを作成することもできましたが、値または参照によってパラメーターを取るかどうかを決定するのは、呼び出し先の仕事ではありませんか?

両方の関数は正しいことをしました。

問題はクライアントコード内にあり、Aを呼び出す副作用を考慮していません。

C ++には、言語の副作用を直接指定する方法はありません。

これは、副作用などのことをコードで(ドキュメントとして)確認し、コードで維持すること(おそらく、前提条件、事後条件、および不変条件のドキュメント化を検討する必要があること)同様に、可視性の理由からも同様です)。

コード変更:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

この時点から、APIの上に、ユニットテストが必要であることを伝える何かが表示されます。また、APIの使用方法(使用しない方法)も説明します。


-4

そもそもどうしてこのバグを回避できたのでしょうか?

バグを避ける方法は1つしかありません。コードの記述を停止することです。他のすべては何らかの形で失敗しました。

ただし、さまざまなレベル(単体テスト、機能テスト、統合テスト、受け入れテストなど)でコードをテストすると、コードの品質が向上するだけでなく、バ​​グの数も減ります。


1
これは完全なナンセンスです。バグを回避する方法は1つだけではありません。バグの存在を完全に回避する唯一の方法がコードを記述しないことであることは自明ですが、最初にコードを書くときも、それをテストするとき、バグの存在を大幅に減らすことができます。テスト段階については誰もが知っていますが、最初の段階でコードを記述しながら責任ある設計手法とイディオムに従うことにより、多くの場合、最小のコストで最大の影響を得ることができます。
コーディグレイ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.