破壊的な動きについての考え

C++11 では移動セマンティクスが導入されました。これにより、所有権の転送をエンコードし、型をコピーできないコンテナーに入れることができます。

これは明らかに強力です。

しかし、現在のムーブ システムは完璧ではありません。いくつか問題があります。間違いなくよりクリーンなアプローチがあります:破壊的なムーブです。

この投稿では、破壊的な動きを伴う純粋に理論的な代替 C++ について説明します。

C++ 移動セマンティクス

右辺値参照を取るコンストラクターはムーブ コンストラクターです。ムーブ コンストラクターはコピー コンストラクターに似ていますが、他のオブジェクトからリソースを盗むことができるだけです。 ” はその状態を変更できます。

これは簡単ですが、このメカニズムには 3 つの問題があります:

1.移動操作は投げることができます

move コンストラクターまたは代入演算子をスローできます。move をスローすると、多くの汎用コードが難しくなります。

03の成長操作を考えてみましょう .Pre-C++11 では、新しい大きなバッファを割り当て、要素をコピーして古いバッファを破棄する必要がありました.しかし、コピーされた要素はその後すぐに破棄されるため、移動の第一候補です.

11 のムーブ構築の場合 要素が失敗しました。一部の要素は既に移動されており、以前と同じ状態ではありません。移動が再び失敗する可能性があるため、ロールバックもできません!

解決策はコピーです move コンストラクターが 26 でない場合の要素 .Copy は元のオブジェクトを変更しないため、コピー操作が失敗した場合、ベクトルは変更されません.しかし、移動コンストラクターがスローしない場合、それらは安全に移動できます.

さらに、 30 全体 42 の状態 move をスローする可能性があります:バリアントには、現在アクティブなオブジェクトを格納するバッファーがあります。バリアントを変更して、別のタイプのオブジェクトをアクティブにする場合は、現在のオブジェクトを破棄して新しいオブジェクトを移動する必要があります。移動がスローされた場合、バリアントはもはや有効な状態ではありません。そして 55 とは異なります 2 つのオブジェクトを格納できるより大きなバッファーを使用するか、ヒープ割り当てを使用する以外にフォールバックはありません。したがって、バリアントは無効な状態に入ります - 例外により値がありません。

移動操作がスローされない場合、そのような問題は存在しません。ただし、少なくともノードベースの STL コンテナーの MSVC の実装には移動コンストラクターのスローが存在するため、これは実際の一般的な問題です。

2.移動操作はコストがかかる可能性があります

何らかの型 62 のメンバーを初期化するコンストラクターを書きたい場合 、次のように書くことができます:

foo(T obj)
: member(std::move(obj)) {}

左辺値と右辺値の両方を許可するためにパラメーターを値で取得し、それを最終的な場所に移動します。この操作のコストは、左辺値のコピーと右辺値の移動であり、その後にメンバーへの追加の移動が続きます。追加の移動が許容されるように、移動は安価です。

ただし、move は必ずしも安価ではありません。MSVC のノードベースの STL コンテナーは、move コンストラクターでメモリを割り当てる必要があります。これがスローできる理由です!そして、メモリ割り当ては安くはありません。

したがって、一般的なコードでは、これに対処するために 2 つのコンストラクターを記述する必要があります。

foo(const T& obj)
: member(obj) {}

foo(T&& obj)
: member(std::move(obj)) {}

現在、左辺値のコストはコピーであり、右辺値のコストは移動です.しかし、これは 75 につながります. オーバーロード。

別の方法として、転送参照を使用することもできますが、それらはまったく別のカテゴリの問題を引き起こします。

3.引っ越した状態

過去にも話しましたが、繰り返し言います。型に移動操作を追加すると、移動元状態という追加の状態が作成されます。

null 以外の 82 を記述する場合を考えてみましょう :

template <typename T>
class owning_ptr
{
public:
    template <typename ... Args>
    explicit owning_ptr(Args&&... args)
    : ptr_(new T(std::forward<Args>(args...))) {}

    ~owning_ptr() { delete ptr_; }

    owning_ptr(const owning_ptr&)            = delete;
    owning_ptr& operator=(const owning_ptr&) = delete;

    T& operator* () { return *ptr_; }
    T* operator->() { return  ptr_; }
};

このスマート ポインタは常に有効なオブジェクトを所有します。オブジェクトを作成するコンストラクタ、オブジェクトを破棄するデストラクタ、およびアクセス オペレータがあります。93 を呼び出すことができます。 108ごとに オブジェクト 116 がないため

しかし、移動可能にしたい場合はどうすればよいでしょうか:

owning_ptr(owning_ptr&& other)
: ptr_(other.ptr_)
{
    // need to reset, so other won't delete ptr_ as well
    other.ptr_ = nullptr;
}

ここで、moved-from 状態を導入する必要があります。破棄された状態とは異なり、その状態は有効である必要があり、少なくともデストラクタが実行されます。そして突然 125134 前提条件があります:オブジェクトは移動元の状態であってはなりません。

この件に関してはさまざまな意見があります.そして、はい、とにかく、各オブジェクトにはそのような暗黙の状態-破棄された状態があります.しかし、私は、移動された状態と破棄された状態の違いは、破棄されたオブジェクトよりも移動された状態です。また、破棄されたオブジェクトへのアクセスは常に未定義の動作であるため、コンパイラ/静的アナライザー/サニタイザーが役立ちます.

しかし、その問題に同意するかどうかにかかわらず、3 つすべてを分析してみましょう。

なぜこれらの問題が存在するのですか?

これらの問題はすべて、移動元のオブジェクトのデストラクタが実行されるという事実によって引き起こされます。さらに、標準ライブラリ オブジェクトを移動すると、有効ではあるが未指定の状態のままになるという標準の義務があります。議論については、移動の安全に関する投稿を参照してくださいそれについて.これが意味することは、前提条件を持たないオブジェクトで任意の操作を呼び出すことが許可されているということです.たとえば 148 移動元ベクトルまたは 157 内の何か 移動元の文字列。

161 の実装を検討してください これはセンチネル ノードを使用します。そのため、リスト オブジェクトが空になることはなく、実装でいくつかの分岐が排除されます。しかし、STL イテレータの無効性の要件により、センチネル ノードを動的に割り当てる必要があります。

次に、ムーブ コンストラクターを実装します。

移動元オブジェクトを安全に使用できるため、移動元オブジェクトにセンチネル ノードがまだあることを確認する必要があります。 、Move コンストラクターをスローします。

しかし、これらすべての問題に対する解決策があります。移動元オブジェクトの使用を許可しないでください。実際、移動元オブジェクトのデストラクタを呼び出すことさえしないでください。これは破壊的移動と呼ばれます .

それでは、176 の魔法の世界に入りましょう。 代わりに破壊的な動きをします。

破壊的な動き:基本

移動元のオブジェクトを有効であるがまだ特定されていない状態のままにする代わりに、デストラクタが実行された後のように、破棄された状態のままにします。誰もこの変数を操作することは許可されておらず、事実上破棄されています。

これには多くの結果があります。

1 つには、ほとんどの型で実際には破壊的な移動コンストラクターは必要ありません。 もう一度:

owning_ptr(owning_ptr&& other)
: ptr_(other.ptr_)
{
    // need to reset, so other won't delete ptr_ as well
    other.ptr_ = nullptr;
}

コメントが説明するように:191 のデストラクタ 実行されるので、オブジェクトも削除しないようにする必要があります.しかし、デストラクタが実行されない場合は、ポインタをコピーするだけです.両方のオブジェクトは同じメモリを所有しますが、 201 で何もすることは誰も許可されていないので、それは問題ではありません

213 の破壊的な動きはどのように行われますか work?Simple:ポインタをメモリに加えてサイズと容量にコピーします。元のオブジェクトをリセットする必要はありません。

以前の問題だったセンチネル ノードはどうなるでしょうか?元のオブジェクトはそれらを保持する必要がないため、これもポインタの単純なコピーです。

実際、破壊的な動きはただの 224 です。 !特別なことをする必要はありません。

そうではありません - 問題があります:

破壊的な移動:移動元オブジェクト内を指すポインター

センチネル ノードを使用した単方向リンク リストの実装をもう一度考えてみましょう。ただし、今回は、センチネルはオブジェクト自体に格納され、最初のノードを指します。また、リストの実装も循環的であるため、最後のノードはセンチネルを指します。

次に、問題が発生します。memcpy ベースの破壊的な動きは、元のオブジェクトを単純にコピーします。 センチネルノード、ただし除く すべてのヒープに割り当てられたノード。これは、最後のノードが変更されないままになることを意味します:元のリストのセンチネルを指し続けます!元のオブジェクトが破棄されると、メモリが解放されます。覚えておいてください:デストラクタは実行されません。ぶら下がっているポインターがあります。

では、ここで正しい破壊的な移動操作は何でしょうか?

最初の 236 問題ではありません。十分ではありません。memcpy の後で、最後のノードのポインタを調整して、新しいプロキシを指すようにする必要があります。

破壊後の移動コールバックが必要です。memcpy 操作の後で、両方のオブジェクトがビットごとに同一である時点で呼び出されます。その後、ポインターを調整できます。

void list::post_destructive_move(list&& old)
{
    // find last node
    auto cur = &old.proxy_;
    while (cur->next != &old.proxy_)
        cur = cur->next;

    // last node points to old.proxy,
    // so adjust
    cur->next = &proxy_;
}

ポインタを調整する以上の破壊的な移動が必要な状況を想像できないので、破壊的な移動は常に 241 になります。 .

しかし、今は必ずしも安くはありません.与えられた例では、リストは最後のノードへのポインタを格納していないので、ループしてそれを見つける必要があります.安くはない破壊的な動きは、私たちができないことを意味します.ジェネリック コードで値渡しを行う必要はなく、転送参照の狂気に対処する必要があります。

それとも私たちですか?オブジェクトを値で関数に渡すときの状況を詳しく見てみましょう:

void consume(T param) // (2)
{
    target = std::move(param); // (3)
}

…

T var;
consume(std::move(var)); // (1)

まず、変数 (1) を関数パラメーターのスペース (2) に移動し、次に (2) から最終的な場所 (3) に移動します。これが意味するのは 251 です。 263 から 275 まで 、284 を呼び出しています 、その後 299 302 から 316320 を呼び出す .

ただし、パラメーターを再度移動する以外は何もしないことに注意してください。したがって、コンパイラーは、2 つの 333 呼び出しは 1 つにまとめられます:calling 342 .

この最適化により、値渡しのコストへの唯一の追加は不要な 350 です これは、破壊的な移動が問題 1 - 投げる移動 - と 2 - 高価な移動に悩まされないことを意味します。しかし、問題 3:移動された状態はどうですか?

破壊的な動き:移動元の状態

破壊的な動きは、その性質上、移動元のオブジェクトを破壊します。

つまり、このようなコードは危険です:

T obj;
T other_obj = std::move(obj);
do_sth(obj);

実際のオブジェクトはもうありません。破棄された変数を使用しています。さらに悪いことに:366 破壊的な動きによって変更されていないため、エラーは必ずしも気付かれません。

ただし、これはまったく新しい問題ではありません:378 を置き換えてください 388 で と 398 409 で - ただの移動だけでは危険です。唯一の違いは、代入演算子がそれを破壊しようとするため、破壊的な移動元オブジェクトに新しい値を割り当てることができないことです.

では、問題 3 - 移動元の状態は本当に解決したのでしょうか?

この状況は、非破壊的な移動よりも優れています。コンパイラは、移動元オブジェクトを使用すると常に未定義の動作になることを認識しています。また、コンパイラが何かを知っている場合、それは私たちを助けることができます。ただし、移動元の変数を取得する方が簡単です。

その特定のケースでは、ローカル変数を破壊的に移動すると識別子が「宣言解除」されるという追加の規則さえある可能性があります。名前から移動された後、変数は単になくなり、使用法はコンパイラエラーになります.

しかし、これですべての状況が解決されるわけではありません。ポインター演算はすべてを台無しにします:

T array[N];
auto ptr = &array[0];
consume(std::move(*ptr));
ptr += n;
--ptr;
consume(std::move(*ptr));

416 の値に応じて 、最終的な使用法は移動元変数を使用する可能性があります.そして、そのような状況を静的に検出しようとすると、最終的にRustになります.

これは、移動元変数の再割り当てを許可してはならない理由でもあります。代入演算子がオブジェクトを破棄する必要があるかどうかを静的に判断することはできません。

結論

ここで説明したように、破壊的な移動は、元のオブジェクトを完全に破壊する移動操作です。425 からの破壊的な移動のセマンティクス 436 へ 最初に、442 454 のメモリを 464 に 、次に 477 を呼び出します ポインター調整用の関数。 通話 - いつも安い。

このような移動操作は、より単純な汎用コードを意味し、右辺値参照を追加せずに実行できた可能性があり、すでに複雑な言語をさらに複雑にしています。しかし、欠点は、破棄された変数にアクセスする方が簡単であるため、このような問題がより一般的になるということです。そこではスマートなライフタイム分析システムが役立ちますが、C++ では不可能である可能性が高く、Rust のような破壊的な動きをする言語により適しています。

破壊的な移動は、C++11 より前に追加された素晴らしい機能であり、現在の移動モデルよりも優れていると主張できますが、保存は少なくなりますが、C++ に実装するにはおそらく遅すぎます.