69

たとえば、stdlibc++ には次のものがあります。

unique_lock& operator=(unique_lock&& __u)
{
    if(_M_owns)
        unlock();
    unique_lock(std::move(__u)).swap(*this);
    __u._M_device = 0;
    __u._M_owns = false;
    return *this;
}

2 つの __u メンバーを *this に直接割り当てないのはなぜですか? スワップは __u に *this メンバーが割り当てられていることを意味しませんか?後で 0 と false を割り当てただけです...その場合、スワップは不要な作業を行っています。私は何が欠けていますか?(unique_lock::swap は、各メンバーで std::swap を実行するだけです)

4

4 に答える 4

119

それは私のせいです。(半分冗談、半分そうではない)。

最初にムーブ代入演算子の実装例を示したとき、スワップを使用しました。次に、賢い人(誰だったか覚えていません)が、割り当ての前に lhs を破壊することの副作用が重要である可能性があることを指摘しました(例の unlock() など)。そのため、移動割り当てにスワップを使用するのをやめました。しかし、スワップの使用の歴史はまだ残っています。

この例で swap を使用する理由はありません。あなたが提案したものよりも効率が悪いです。実際、libc++ では、私はあなたが提案したことを正確に行います:

unique_lock& operator=(unique_lock&& __u)
    {
        if (__owns_)
            __m_->unlock();
        __m_ = __u.__m_;
        __owns_ = __u.__owns_;
        __u.__m_ = nullptr;
        __u.__owns_ = false;
        return *this;
    }

一般に、ムーブ代入演算子は次のことを行う必要があります。

  1. 目に見えるリソースを破棄します (ただし、実装の詳細リソースは保存してください)。
  2. すべてのベースとメンバーを移動して割り当てます。
  3. ベースとメンバーの移動割り当てが rhs をリソースレスにしなかった場合は、そうします。

そのようです:

unique_lock& operator=(unique_lock&& __u)
    {
        // 1. Destroy visible resources
        if (__owns_)
            __m_->unlock();
        // 2. Move assign all bases and members.
        __m_ = __u.__m_;
        __owns_ = __u.__owns_;
        // 3. If the move assignment of bases and members didn't,
        //           make the rhs resource-less, then make it so.
        __u.__m_ = nullptr;
        __u.__owns_ = false;
        return *this;
    }

アップデート

コメントには、移動コンストラクターの処理方法に関するフォローアップの質問があります。そこに(コメントで)回答し始めましたが、フォーマットと長さの制約により、明確な回答を作成することが難しくなっています。したがって、私はここに私の応答を入れています。

問題は、ムーブ コンストラクターを作成するための最適なパターンは何かということです。デフォルトのコンストラクターに委譲してからスワップしますか? これには、コードの重複を減らすという利点があります。

私の回答は次のとおりです。最も重要なポイントは、プログラマーは無意識にパターンに従うことに慎重であるべきだということです。デフォルト+スワップとして移動コンストラクターを実装することがまさに正しい答えであるいくつかのクラスがあるかもしれません。クラスは大きくて複雑かもしれません。はA(A&&) = default;間違ったことをするかもしれません。各クラスの選択をすべて考慮することが重要だと思います。

OP の例を詳しく見てみましょう std::unique_lock(unique_lock&&)

所見:

A. このクラスはかなり単純です。次の 2 つのデータ メンバーがあります。

mutex_type* __m_;
bool __owns_;

B. このクラスは汎用ライブラリにあり、不明な数のクライアントによって使用されます。このような状況では、パフォーマンスの問題が優先されます。クライアントがパフォーマンスが重要なコードでこのクラスを使用するかどうかはわかりません。したがって、それらがそうであると仮定する必要があります。

C. このクラスのムーブ コンストラクターは、少数のロードとストアで構成されます。したがって、パフォーマンスを調べる良い方法は、ロードとストアをカウントすることです。たとえば、あなたが 4 つの店舗で何かを行い、他の誰かが 2 つの店舗だけで同じことを行う場合、どちらの実装も非常に高速です。しかし、彼らのはあなたの2 倍の速さです! この違いは、一部のクライアントのタイトなループでは重要になる可能性があります。

最初に、デフォルトのコンストラクターとメンバー swap 関数でロードとストアをカウントします。

// 2 stores
unique_lock()
    : __m_(nullptr),
      __owns_(false)
{
}

// 4 stores, 4 loads
void swap(unique_lock& __u)
{
    std::swap(__m_, __u.__m_);
    std::swap(__owns_, __u.__owns_);
}

移動コンストラクターを 2 つの方法で実装しましょう。

// 4 stores, 2 loads
unique_lock(unique_lock&& __u)
    : __m_(__u.__m_),
      __owns_(__u.__owns_)
{
    __u.__m_ = nullptr;
    __u.__owns_ = false;
}

// 6 stores, 4 loads
unique_lock(unique_lock&& __u)
    : unique_lock()
{
    swap(__u);
}

最初の方法は、2 番目の方法よりもはるかに複雑に見えます。また、ソース コードはより大きく、別の場所で既に記述されている可能性がある (たとえば、move 代入演算子で) 多少重複するコードがあります。つまり、バグの可能性が高くなります。

2 番目の方法はより単純で、既に作成したコードを再利用します。したがって、バグの可能性が低くなります。

最初の方法は高速です。ロードとストアのコストがほぼ同じであれば、おそらく 66% 高速です!

これは、古典的なエンジニアリングのトレードオフです。フリーランチはありません。また、エンジニアは、トレードオフについて決定を下さなければならないという重荷から解放されることはありません。その瞬間、飛行機が空中から落下し始め、原子力発電所が溶け始めます。

libc++については、より高速なソリューションを選択しました。私の理論的根拠は、このクラスについては、何があっても正しく理解したほうがよいということです。クラスは十分に単純なので、正しく理解できる可能性は高いです。クライアントはパフォーマンスを重視するようになります。別の文脈の別のクラスについては、別の結論に達する可能性があります。

于 2011-07-14T01:29:07.163 に答える
9

例外の安全性についてです。は演算子が呼び出された時点ですでに構築されているため__u、例外がないことがわかり、swapスローされません。

メンバーの割り当てを手動で行った場合、それらのそれぞれが例外をスローする危険性があり、部分的に移動割り当てされた何かに対処しなければならず、救済する必要があります。

この些細な例では、これは表示されないかもしれませんが、これは一般的な設計原則です。

  • コピー構築とスワップによるコピー割り当て。
  • move-constructとswapによるmove-assign。
  • コンストラクト およびなど+の用語で記述します。+=

基本的に、「実際の」コードの量を最小限に抑え、コア機能に関してできるだけ多くの他の機能を表現しようとします。

(unique_ptrはコピーの作成/代入を許可しないため、代入で明示的な右辺値参照を使用するため、この設計原則の最良の例ではありません。)

于 2011-07-14T01:06:57.213 に答える
2

トレードオフに関して考慮すべきもう 1 つの点:

default-construct + swap の実装は遅く見えるかもしれませんが、コンパイラでのデータ フロー解析により、無意味な代入が削除され、手書きのコードと非常によく似た結果になることがあります。これは、「賢い」値セマンティクスを持たない型に対してのみ機能します。例として、

 struct Dummy
 {
     Dummy(): x(0), y(0) {} // suppose we require default 0 on these
     Dummy(Dummy&& other): x(0), y(0)
     {
         swap(other);             
     }

     void swap(Dummy& other)
     {
         std::swap(x, other.x);
         std::swap(y, other.y);
         text.swap(other.text);
     }

     int x, y;
     std::string text;
 }

最適化なしで move ctor に生成されたコード:

 <inline std::string() default ctor>
 x = 0;
 y = 0;
 temp = x;
 x = other.x;
 other.x = temp;
 temp = y;
 y = other.y;
 other.y = temp;
 <inline impl of text.swap(other.text)>

これはひどいように見えますが、データ フロー分析により、次のコードと同等であると判断できます。

 x = other.x;
 other.x = 0;
 y = other.y;
 other.y = 0;
 <overwrite this->text with other.text, set other.text to default>

実際には、コンパイラが常に最適なバージョンを生成するとは限りません。それを試して、アセンブリを一目見てみたいと思うかもしれません。

クラス内のメンバーの 1 つが std::shared_ptr である場合など、"賢い" 値のセマンティクスのために、代入よりもスワッピングの方が優れている場合もあります。ムーブ コンストラクターがアトミック refcounter をいじる理由はありません。

于 2013-03-03T22:15:42.603 に答える