コピーエリジョン、または非常に首に足を撃つ方法...

コンストラクタをスキップすることは、速度の点で非常に素晴らしい最適化です。 しかし、彼女はとても安全ですか? 正しくしましょう。 まず、まだ最新ではない人のためのいくつかの情報。



コピーの省略(コピーのスキップ)-最適化。これは、コンパイラが「余分な」コピーコンストラクタの呼び出しを取り除くことができるという事実から成ります。



例:



class M { public: M() { cout << "M()" << endl; } M(const M &obj) { cout << "M(const M &obj)" << endl; } }; M func() { M m1; return m1; } int _tmain(int argc, _TCHAR* argv[]) { M m2 = func(); Sleep(-1); return 0; }
      
      





このコードは何をしますか? 仮定させてください:



1)クラスMの一時オブジェクトm1がbody func()に作成されます。

2)関数は、関数が本体を終了するとすぐに破棄される一時オブジェクトのコピーを提供する必要があるため、戻り値にm1のコピーを配置するためにコピーコンストラクターが呼び出されます。

3)一時オブジェクトm1のデストラクタが呼び出されます。

4)m2のコピーコンストラクターが呼び出され、func()によって返されたオブジェクトに基づいてm2が構築されます。

5)一時オブジェクトのデストラクタが呼び出されます。これは不要になったため、func()が返しました。



実際、このコードの結果は異なります! Visual Studio 2013リリースでプログラムを実行すると、次のように表示されます。



M()



オプティマイザーは、2つの完全なコピーコンストラクターと2つのデストラクタの呼び出しを削除しました。 私は実験を繰り返しますが、空のサイクルとバラスト変数との類推によって、基本的な拒否が発生した可能性は十分にあります。 コードを戦闘に近い形に持っていきます。 明確にするために、移動代入演算子と移動コンストラクタを定義していません。



 size_t I = 0;//     . class M { private: int i; public: M(int Value = 0) : i{ Value } { /*      */ ++I; cout << "M(" << Value << ")" << endl; } M(const M &obj) : i{ obj.i } { /*   */ ++I; cout << "M(const M &obj)" << endl; } M &operator=(const M &obj) { /*    */ if (this != &obj) { i = obj.i; cout << "M &operator=(const M &obj)" << endl; } return *this; } ~M() { ++I; cout << "~M()" << endl; } }; M func(int Value) { if (Value > 0) { M m1{ 100 }; return m1; } else { M m1{ -100 }; return m1; } } int _tmain(int argc, _TCHAR* argv[]) { M m2 = func(1); cout << endl; M m3 = func(-1); cout << endl; cout << I << endl; Sleep(-1); return 0; }
      
      





このプログラムを実行した結果、どうなりますか? 論理的には、次のことが起こるはずです。



1)クラスMの一時オブジェクトは、論理分岐func()の1つで作成する必要があります。これは、作成する一時オブジェクトのパラメーターを使用してコンストラクターを呼び出すことを意味します。

2)関数の本体で作成される一時オブジェクトをコピーする必要があります。これにより、関数を終了すると、この一時オブジェクトのコピーが作成されます。

3)ローカルオブジェクトm1のデストラクタへの呼び出しが発生するはずです。

4)func()(ローカルオブジェクトのコピー)を返すオブジェクトを引数として取るm2のコピーコンストラクタを呼び出す必要があります。

5)一時オブジェクトのデストラクタを呼び出す必要があります。この関数は、コピーコンストラクタm2に渡すために関数を返しました。

...)m3の場合、すべてが類似しています。



何を期待しますか? M m2 = func()の場合、5回の++呼び出しが期待されます。 さらに、M m3 = func()を実行するときに呼び出す5つの++。



プログラムの結果:



M(100)

M(定数Mおよびobj)

〜M()



M(-100)

M(定数Mおよびobj)

〜M()



6





ここで何が見えますか? そして、痛みが見えます。 コンパイラは、Mのコンストラクタとデストラクタがグローバルな副作用を持っているという事実を無視しました。 スマートコンパイラは論理チェーンから除外しました。



-m1のコピーを作成して、関数の結果としてローカルオブジェクトのコピーを送信します。

-ローカルオブジェクトm1のデストラクタを呼び出します(最初の段落が完了した後は論理的です)。



ローカルオブジェクトのデストラクタは、コピーコンストラクタm2 / m3が呼び出された後にのみ呼び出されました。



その結果、10回ではなく6回変更しました。



そして、小さな編集を導入しましょう-func()がMのコピーではなく&Mリンクを返すようにします。 ロジックは、これを行うことができないことを示しています-ローカルオブジェクトへのリンクは、関数を終了した直後に不正になります。 これは明らかです。



しかし、コピーコンストラクターが完了するまでローカルオブジェクトのデストラクターの呼び出しを延期するスマートコンパイラーがあるので、チャンスをつかみませんか? おそらく、コンパイラはローカルオブジェクトのデストラクタへの呼び出しを延期しますが、その参照は使用されますか? それはとても良いことです。 それは理にかなっています。 私達は試みます:



 M &func(int Value)//   ,   . { if (Value > 0) { M m1{ 100 }; return m1; } else { M m1{ -100 }; return m1; } }
      
      





プログラムの結果:



M(100)

〜M()

M(Mおよびobj)



M(-100)

〜M()

M(Mおよびobj)



6





何が見えますか? ローカルオブジェクトm1のデストラクタは、関数を終了した直後に呼び出されるようになりました。 スマートコンパイラはその逆を行います。 リンクはコウモリになります。 破壊されたオブジェクトへのリンクに基づいて、別のオブジェクトが作成されます。 このプログラムは、奇跡によってのみ例外を引き起こしません。 クラスに動的データがある場合、または移動のセマンティクスを実装している場合、非常に不快で理解できないステルスエラーが発生します。 大規模なプロジェクトでは、長くてエキサイティングな冒険を保証します。



これがコピー省略です...



コンパイラーは、コンストラクターのグローバルな副作用を無視します。 はい、この動作は標準で定義されています-コピーコンストラクタ/コンストラクタでのグローバルな副作用の存在下でのコピー省略の使用。 その結果、プログラムはまったく正しく機能せず、大規模なプロジェクトで発生する可能性のある問題の数は不適切になります。



実際、この動作から生じる興味深い点がいくつかあります。 興味のある人のために-さまざまなコンパイル設定とコンストラクターの移動を試してみることをお勧めします。



そのようなことをした後、私は通常、自分が書いたコードで何が起こっているのか理解しなくなります。 これらの例から引き出すことができる主なルールは次のとおりです。



-デザイナーにグローバルな副作用を絶対に許可しないでください。



コードの特定のセクションごとにデザインロジックが変更される可能性があるという事実を考えると、一見正しく見えるコードが「コピーのスキップ」によりどのような問題を引き起こすか想像することさえ困難です。



PS。 この記事に関するすべての批判を表明してください。 これは私の最初の記事です。私はそれを非常にスムーズかつ透過的に提示していないことを理解していますが、そのようなコンパイラの動作に遭遇する可能性のある人を助けたいと思いました。 かなり長い間、これに対処していました。



All Articles