このイディオムは何ですか? また、いつ使用する必要がありますか? どの問題を解決しますか? C++11を使うとイディオムが変わる?
あちこちで言及されていますが、「それは何ですか」という単一の質問と回答がなかったので、ここにあります。以前に言及された場所の部分的なリストを次に示します。
このイディオムは何ですか? また、いつ使用する必要がありますか? どの問題を解決しますか? C++11を使うとイディオムが変わる?
あちこちで言及されていますが、「それは何ですか」という単一の質問と回答がなかったので、ここにあります。以前に言及された場所の部分的なリストを次に示します。
リソース (スマート ポインターなどのラッパー) を管理するすべてのクラスは、ビッグ スリーを実装する必要があります。コピー コンストラクターとデストラクターの目的と実装は簡単ですが、コピー代入演算子は間違いなく最も微妙で難しいものです。それはどのように行われるべきですか?どのような落とし穴を避ける必要がありますか?
コピー アンド スワップ イディオムが解決策であり、コードの重複を回避することと、強力な例外保証を提供することの2 つのことを達成するために代入演算子をエレガントに支援します。
概念的には、コピー コンストラクターの機能を使用してデータのローカル コピーを作成し、コピーされたデータをswap
関数で取得して、古いデータを新しいデータと交換します。その後、一時コピーは破棄され、古いデータが取り込まれます。新しいデータのコピーが残ります。
コピー アンド スワップ イディオムを使用するには、3 つのものが必要です: 動作するコピー コンストラクター、動作するデストラクタ (どちらもラッパーの基礎であるため、いずれにせよ完全である必要があります)、および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)
。
まずは自己採点テストです。
このチェックには 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 番目の問題につながります。
私たちの代入演算子は、他の場所ですでに書いたすべてのコードを効果的に複製します。これはひどいことです。
私たちの場合、コアは 2 行 (割り当てとコピー) だけですが、より複雑なリソースでは、このコードの肥大化は非常に面倒です。私たちは決して同じことを繰り返さないように努めるべきです。
(疑問に思うかもしれません: 1 つのリソースを正しく管理するためにこれほど多くのコードが必要な場合、私のクラスが複数のリソースを管理する場合はどうなるでしょうか?
これは有効な懸念事項のように思われるかもしれませんが、実際には重要なtry
/catch
句が必要ですが、これは重要な問題ではありません。 -issue.
これは、クラスが1 つのリソースのみを管理する必要があるためです!)
前述のように、コピー アンド スワップ イディオムはこれらの問題をすべて解決します。しかし、現時点では、1 つを除いてすべての要件がありswap
ます。関数です。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++ の次のバージョンである C++11 では、リソースの管理方法に 1 つの非常に重要な変更が加えられています。なんで?リソースをコピー構築できる必要があるだけでなく、それを移動構築する必要があるためです。
幸いなことに、これは簡単です。
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
何が起きてる?move-construction の目的を思い出してください。クラスの別のインスタンスからリソースを取得し、割り当て可能および破壊可能であることが保証された状態のままにします。
したがって、私たちが行ったことは簡単です。デフォルトのコンストラクター (C++11 の機能) を介して初期化し、次にother
;と交換します。クラスのデフォルトで構築されたインスタンスは安全に割り当ておよび破棄できることがわかっているためother
、スワップ後に同じことができることがわかっています。
(一部のコンパイラはコンストラクターの委譲をサポートしていないことに注意してください。この場合、手動でクラスをデフォルトで構築する必要があります。これは不幸なことですが、幸運なことに些細な作業です。)
クラスに必要な変更はこれだけなのに、なぜうまくいくのでしょうか? パラメータを参照ではなく値にするという非常に重要な決定を思い出してください。
dumb_array& operator=(dumb_array other); // (1)
現在、other
右辺値で初期化されている場合、それは move-constructed になります。完全。C++03 が引数を値で受け取ることによってコピー コンストラクター機能を再利用できるのと同じように、C++11も適切な場合はムーブ コンストラクターを自動的に選択します。(そしてもちろん、以前にリンクされた記事で述べたように、値のコピー/移動は単純に完全に省略される場合があります。)
そして、コピーアンドスワップの慣用句を締めくくります。
*なぜmArray
null に設定するのですか? 演算子内のさらにコードがスローされると、のデストラクタdumb_array
が呼び出される可能性があるためです。null に設定せずにそれが発生した場合は、既に削除されているメモリを削除しようとします。null の削除はノーオペレーションであるため、null に設定することでこれを回避します。
std::swap
†私たちの型に特化したりswap
、 free-function と一緒にクラス内を提供したりする必要があるという主張は他にもあります。swap
しかし、これはすべて不必要です。ADLswap
で見つかりました。1つの機能で十分です。
‡理由は簡単です。リソースを自分のものにしたら、必要な場所にスワップしたり (C++11) 移動したりできます。そして、パラメーター リストでコピーを作成することにより、最適化を最大化します。
††通常、移動コンストラクターは である必要がありますnoexcept
。そうしないと、一部のコード (std::vector
サイズ変更ロジックなど) で、移動が意味のある場合でもコピー コンストラクターが使用されます。もちろん、内部のコードが例外をスローしない場合にのみ、noexcept とマークしてください。
代入は、本質的に 2 つのステップで構成されています。オブジェクトの古い状態を破棄し、新しい状態を他のオブジェクトの状態のコピーとして構築します。
基本的に、それはデストラクタとコピー コンストラクタが行うことなので、最初のアイデアはそれらに作業を委譲することです。ただし、破壊は失敗してはならないため、構築は失敗する可能性がありますが、実際には逆の方法で実行したいと考えています。最初に建設的な部分を実行し、それが成功した場合は破壊的な部分を実行します。copy-and-swap イディオムはまさにそれを行う方法です。最初にクラスのコピー コンストラクターを呼び出して一時オブジェクトを作成し、次にそのデータを一時オブジェクトと交換してから、一時オブジェクトのデストラクタに古い状態を破棄させます。
以来swap()
失敗しないはずですが、失敗する可能性がある唯一の部分はコピー構築です。それが最初に実行され、失敗した場合、対象のオブジェクトでは何も変更されません。
洗練された形式では、コピー アンド スワップは、代入演算子の (非参照) パラメーターを初期化することによってコピーを実行することによって実装されます。
T& operator=(T tmp)
{
this->swap(tmp);
return *this;
}
C++11 スタイルのアロケーター対応コンテナーを扱っている場合は、警告の言葉を追加したいと思います。スワッピングと割り当ては微妙に異なるセマンティクスを持っています。
具体的に言うと、 がステートフル アロケータ型std::vector<T, A>
である container を考えてみましょA
う。次の関数を比較します。
void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{
a.swap(b);
b.clear(); // not important what you do with b
}
void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
a = std::move(b);
}
関数fs
との両方の目的は、最初に持っていた状態fm
を与えることです。ただし、隠れた質問があります。次の場合はどうなりますか? 答えは次のとおりです。書きましょう。a
b
a.get_allocator() != b.get_allocator()
AT = std::allocator_traits<A>
AT::propagate_on_container_move_assignment
がの場合std::true_type
、fm
のアロケータにa
の値を再割り当てしますb.get_allocator()
。それ以外の場合は割り当てを行わず、a
元のアロケータを使用し続けます。a
その場合、 と のストレージにb
は互換性がないため、データ要素を個別に交換する必要があります。
AT::propagate_on_container_swap
がの場合、予想される方法でデータとアロケーターの両方を交換しますstd::true_type
。fs
AT::propagate_on_container_swap
がの場合std::false_type
、動的チェックが必要です。
a.get_allocator() == b.get_allocator()
、2 つのコンテナーは互換性のあるストレージを使用し、スワッピングは通常の方法で進行します。a.get_allocator() != b.get_allocator()
、プログラムは未定義の動作をします(cf. [container.requirements.general/8].要するに、コンテナーがステートフル アロケーターのサポートを開始するとすぐに、スワッピングは C++11 では重要な操作になります。これはやや「高度な使用例」ですが、完全に可能性が低いわけではありません。通常、移動の最適化は、クラスがリソースを管理して初めて興味深いものになるためです。メモリは最も人気のあるリソースの 1 つです。