1. ホーム
  2. c++

[解決済み] コピーアンドスワップ慣用句とは?

2022-03-18 08:52:46

質問

このイディオムは何ですか、どんな時に使うのですか?どのような問題を解決するのですか?C++11を使用する場合、イディオムは変更されますか?

いろいろなところで言及されていますが、単発の"何それ"な質問と回答がなかったので、ここに紹介します。以下は、以前に言及された場所の一部のリストです。

どのように解決するのですか?

概要

なぜコピーアンドスワップというイディオムが必要なのか?

リソースを管理するすべてのクラス( ラッパー スマートポインタのような)を実装する必要があります。 ビッグ3 . コピー・コンストラクタとデストラクタの目標と実装は簡単ですが、コピー・アサイン演算子は間違いなく最も微妙で困難なものです。どうすればいいのでしょうか?どのような落とし穴を避ける必要があるのでしょうか?

その コピーアンドスワップイディオム はその解決策であり、代入オペレータが2つのことを達成するためにエレガントに支援します。 コードの重複 を提供し、さらに 強力な例外保証 .

どのように機能するのですか?

概念的に この機能は、コピー・コンストラクタの機能を使ってデータのローカル・コピーを作成し、コピーしたデータを swap 関数で、古いデータと新しいデータを交換します。そして、一時的なコピーが破壊され、古いデータも一緒に持っていかれます。そして、新しいデータのコピーが残される。

コピー&スワップというイディオムを使うには、3つのものが必要です:動作するコピーコンストラクタ、動作するデストラクタ(どちらもラッパーの基本なので、とにかく完全であるべきです)、そして swap という関数があります。

スワップ関数は 非投げ は、あるクラスの2つのオブジェクトをメンバー間で交換する関数です。私たちは std::swap しかし、それは不可能である。 std::swap は、その実装の中でコピーコンストラクタとコピー代入演算子を使用しており、結局はそれ自体で代入演算子を定義しようとしていることになるのです!

(それだけでなく、非限定呼び出しの swap は独自のスワップ演算子を使用し、不要なクラスの構築と破壊をスキップして std::swap を含むことになります)。


徹底的な解説

目標

具体的なケースを考えてみよう。無駄なクラスで、動的な配列を管理したい。まず、コンストラクタ、コピーコンストラクタ、デストラクタを作成します。

#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
    // (default) constructor
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // copy-constructor
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr)
    {
        // note that this is non-throwing, because of the data
        // types being used; more attention to detail with regards
        // to exceptions must be given in a more general case, however
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // destructor
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

このクラスは、ほぼ正常に配列を管理しますが、そのためには operator= が正しく動作するようにします。

失敗した解決策

素朴な実装はこんな感じです。

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // get rid of the old data...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) *(see footnote for rationale)

        // ...and put in the new
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}

これで、漏れのない配列管理ができるようになりました。しかし、これは3つの問題に悩まされています。 (n) .

  1. 1つ目は、自己課題テストです。
    このチェックには2つの目的があります。自己割り当てで無駄なコードを実行するのを防ぐ簡単な方法であり、微妙なバグ(配列を削除してコピーしようとするような)から私たちを守ることです。しかし、他のすべてのケースでは、単にプログラムを遅くし、コードのノイズとして機能します。自己割り当てが発生することはほとんどないので、ほとんどの場合、このチェックは無駄です。
    それがなくても演算子がちゃんと動いてくれればいいんですけどね。

  2. 2つ目は、基本的な例外保証しかしていないことです。もし new int[mSize] が失敗します。 *this が変更されたことになります。(すなわち、サイズが間違っていて、データが消えているのです!)
    強力な例外保証のためには、次のようなものが必要でしょう。

     dumb_array& operator=(const dumb_array& other)
     {
         if (this != &other) // (1)
         {
             // get the new data ready before we replace the old
             std::size_t newSize = other.mSize;
             int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
             std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
             // replace the old data (all are non-throwing)
             delete [] mArray;
             mSize = newSize;
             mArray = newArray;
         }
    
         return *this;
     }
    
    
  3. コードが拡張された! これが3つ目の問題、コードの重複につながります。

この代入演算子は、すでに他の場所に書いたすべてのコードと事実上重複しており、これはひどいことです。

私たちの場合、その中核はたった2行(アロケーションとコピー)ですが、より複雑なリソースでは、このコードの肥大化はかなり面倒なことになります。私たちは、決して同じことを繰り返さないように努力すべきです。

(1つのリソースを正しく管理するためにこれだけのコードが必要なら、私のクラスが複数のリソースを管理する場合はどうなるのだろうかと思うかもしれません。
これは有効な懸念のように見えるかもしれませんし、実際、自明でないほどの try / catch 節がある場合、これは問題ではありません。
というのも、クラスが管理すべきは 1つのリソースのみ !)

成功したソリューション

前述の通り、コピー&スワップ・イディオムによって、これらの問題はすべて解決される。しかし、今現在、私たちは1つを除いてすべての必要条件を備えています。 swap 関数があります。3つのルールでは、コピー・コンストラクタ、代入演算子、デストラクタが必要ですが、本当は「3つのルールと半分」と呼ぶべきでしょう。 swap という関数があります。

このクラスにはスワップ機能を追加する必要があり、以下のように行います†。

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // enable ADL (not necessary in our case, but good practice)
        using std::swap;

        // by swapping the members of two objects,
        // the two objects are effectively swapped
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

( これ は、なぜ public friend swap .) これで dumb_array 配列全体を割り当ててコピーするのではなく、ポインタとサイズを交換するだけなので、一般的にスワップはより効率的です。この機能性と効率の良さを除けば、コピーアンドスワップというイディオムを実装する準備は整ったことになります。

さっそく、代入演算子を紹介します。

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

そして、これだ! 一挙に3つの問題をエレガントに解決しているのです。

なぜうまくいくのか?

まず、重要な選択に気づきます:パラメータの引数が 値で . 次のようなことも簡単にできますが(実際、このイディオムの素朴な実装の多くはそうなっています)。

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

を失う。 最適化の重要な機会 . それだけでなく、この選択は後述するC++11では非常に重要です。(一般論として、非常に有用なガイドラインは次の通りです:関数内で何かのコピーを作成するつもりなら、パラメータリストでコンパイラにやらせましょう。)

いずれにせよ、この方法でリソースを取得することは、コードの重複を排除する鍵となります。コピーを作成するためにコピーコンストラクタのコードを使用することができ、コードの一部を繰り返す必要はありません。コピーができたので、スワップする準備ができました。

この関数に入った時点で、すべての新しいデータがすでに割り当てられ、コピーされ、使用する準備ができていることに注目してください。このことが、フリーの強力な例外保証を実現しています。コピーの構築に失敗すると、関数に入ることすらできません。 *this . (以前は強力な例外保証のために手動でやっていたことを、今はコンパイラがやってくれています。なんと親切な)

この時点で、私たちはホームフリーです。 swap はスローされません。現在のデータとコピーされたデータを交換し、安全に状態を変更し、古いデータはテンポラリに格納されます。古いデータは関数が戻ったときに解放されます。(ここで、パラメータのスコープが終了し、そのデストラクタが呼ばれます)。

このイディオムはコードを繰り返さないので、演算子の中にバグを持ち込むことはできません。これは、自己割り当てチェックが不要になることを意味します。 operator= . (さらに、非自己割り当てのパフォーマンス・ペナルティもなくなります)。

そして、それがコピー&スワップというイディオムです。

C++11ではどうでしょうか?

C++の次のバージョンであるC++11では、リソースの管理方法に1つ非常に重要な変更が加えられました。 4の法則 (と半々)。なぜかというと なぜなら、リソースをコピー構築できるようにするだけでなく を移動して構築する必要があります。 .

幸いなことに、これは簡単なことです。

class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other) noexcept ††
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};

どうなっているんだ?移動建設の目的を思い出してください。クラスの別のインスタンスからリソースを取得し、割り当て可能で破壊可能であることが保証された状態でそれを残すことです。

つまり、デフォルトのコンストラクタで初期化し(C++11の機能)、その後に other デフォルトで構築されたクラスのインスタンスは安全に代入、破壊できることが分かっているので other は、スワップした後、同じことができるようになります。

(一部のコンパイラはコンストラクタの委譲をサポートしていないことに注意してください。この場合、手動でクラスをデフォルトで構築する必要があります。これは不幸なことですが、幸いにも些細なことです)。

なぜうまくいくのか?

このクラスに必要な変更はこれだけですが、なぜうまくいくのでしょうか?パラメータを参照ではなく値にする、という重要な決定があったことを思い出してください。

dumb_array& operator=(dumb_array other); // (1)

さて、もし other はr値で初期化されています。 はムーブコンストラクトされます。 . 完璧です。C++03 で引数を値で取ることでコピー・コンストラクタの機能を再利用できたのと同じように、C++11 では次のようになります。 自動的に を選択することができます。(もちろん、前のリンク先の記事で述べたように、値のコピー/移動は単に完全に省略することもできます)。

というわけで、コピー&スワップのイディオムは終了です。


脚注

*なぜ mArray をnullにする?なぜなら、演算子内でさらに何らかのコードがスローされた場合、そのコードは dumb_array もし、NULLに設定しないまま呼び出されたら、すでに削除されたメモリを削除しようとすることになります! nullに設定しないと、すでに削除されたメモリを削除しようとしてしまうのです!nullを削除するのはノーオペレーションなので、nullに設定することでこれを回避しています。

を特化させるべきという主張は他にもある。 std::swap を提供し、クラス内の swap と共に、フリーファンクションである swap などです。しかし、これはすべて不要です。 swap は非限定呼び出しであり、我々の関数は ADL . 1つの関数で済みます。

その理由は簡単で、いったんリソースを自分のものにすれば、(C++11)そのリソースを必要な場所にスワップおよび/または移動することができるからです。そして、パラメータリストでコピーを作成することで、最適化を最大化します。

††移動コンストラクタは一般的に noexcept そうでなければ、いくつかのコード(例えば std::vector サイズ変更ロジック) は、移動が理にかなっている場合でもコピーコンストラクタを使用します。もちろん、内部のコードが例外をスローしない場合にのみ noexcept をマークしてください。