コピーアンドスワップの慣用句とは何ですか? 質問する

コピーアンドスワップの慣用句とは何ですか? 質問する

ベストアンサー1

概要

コピーアンドスワップの慣用句がなぜ必要なのでしょうか?

リソースを管理するクラス(スマートポインタのようなラッパー)は、実装する必要がある。ビッグスリーコピー コンストラクターとデストラクタの目的と実装は簡単ですが、コピー代入演算子はおそらく最も微妙で難しいものです。どのように実行すればよいのでしょうか。どのような落とし穴を避ける必要があるのでしょうか。

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

どのように機能しますか?

概念的にはコピーコンストラクタの機能を使用してデータのローカルコピーを作成し、コピーされたデータを関数で取得してswap、古いデータを新しいデータと交換します。その後、一時コピーが破棄され、古いデータも一緒に破棄されます。新しいデータのコピーが残ります。

コピー アンド スワップ イディオムを使用するには、機能するコピー コンストラクター、機能するデストラクタ (どちらもラッパーの基礎となるため、いずれにしても完全である必要があります)、および関数の 3 つが必要ですswap

swap 関数は、クラスの 2 つのオブジェクトをメンバーごとに交換する、スローしないstd::swap関数です。独自の関数を提供する代わりに を使用したくなるかもしれませんが、これは不可能です。std::swap実装内でコピー コンストラクターとコピー代入演算子を使用し、最終的には代入演算子をそれ自体で定義しようとすることになります。

(それだけでなく、 への無条件の呼び出しでは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つのリソースのみ!)

成功した解決策

前述のように、コピー アンド スワップ イディオムはこれらの問題をすべて解決します。しかし、現時点では、関数以外の要件はすべて揃っていますswap。3 つのルールは、コピー コンストラクター、代入演算子、およびデストラクタの存在をうまく伴いますが、実際には「ビッグ 3 と 1/2」と呼ぶべきものです。つまり、クラスがリソースを管理するときはいつでも、関数を提供することも理にかなっています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では、リソースの管理方法に非常に重要な変更が加えられています。3つのルールが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値で初期化されている場合は、ムーブコンストラクトされます。完璧です。C++03 で引数を値で受け取ることでコピーコンストラクタ機能を再利用できたのと同じように、C++11でも適切な場合にムーブコンストラクタが自動的に選択されます。(もちろん、前にリンクした記事で述べたように、値のコピー/移動は完全に省略することもできます。)

これでコピーアンドスワップの慣用句は終わりです。


脚注

*なぜ null に設定するのでしょうかmArray? 演算子内のさらにコードがスローされると、のデストラクタがdumb_array呼び出される可能性があるためです。null に設定せずにこれが発生すると、すでに削除されているメモリを削除しようとします。null を削除すると何も行われないため、null に設定することでこれを回避します。

std::swap†他にも、型を特殊化したり、クラス内関数をswapフリー関数と一緒に提供したりすべきだという主張がありますswap。しかし、これらはすべて不要です。 の適切な使用は、swap無条件呼び出しを通じて行われ、関数は次のようにして見つかります。日常生活動作1 つの関数で十分です。

‡理由は簡単です。リソースを自分で取得すると、必要な場所に交換したり移動したり (C++11) できます。また、パラメーター リストにコピーを作成することで、最適化が最大化されます。

††移動コンストラクターは、一般的に である必要がありますnoexcept。そうでない場合、一部のコード (std::vectorサイズ変更ロジックなど) は、移動が意味をなす場合でもコピー コンストラクターを使用します。もちろん、内部のコードが例外をスローしない場合にのみ、noexcept をマークします。

おすすめ記事