移動セマンティクスとは何ですか?

私の最初の回答は、移動セマンティクスの非常に単純化した紹介で、単純さを保つために多くの詳細は意図的に省かれました。 最初の入門書としては、今でも十分通用すると思います。 しかし、より深く掘り下げたいのであれば、この先をお読みください 🙂

Stephan T. Lavavejが貴重なフィードバックを提供してくれました。 Stephan、どうもありがとう!

はじめに

Move semantics では、特定の条件下で、オブジェクトが他のオブジェクトの外部リソースの所有権を取得することを許可しています。 これは 2 つの点で重要です。

  1. 高価なコピーを安価な移動に変更する。 例として、私の最初の回答を参照してください。 オブジェクトが少なくとも 1 つの外部リソースを管理しない場合 (直接、またはそのメンバー オブジェクトを通じて間接的に)、移動セマンティクスはコピー セマンティクスに対して何の利点も提供しないことに注意してください。 その場合、オブジェクトのコピーとオブジェクトの移動はまったく同じことを意味します。

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. 安全な「移動のみ」の型を実装すること。 例としては、ロック、ファイルハンドル、およびユニークな所有権のセマンティクスを持つスマートポインタがあります。 注: この回答では、C++11 で std::unique_ptr に置き換えられた C++98 標準ライブラリ テンプレートである std::auto_ptr について説明します。 C++ 中級プログラマーは std::auto_ptr について少なくともある程度知っていると思われますが、このテンプレートは「移動セマンティクス」を表示しているので、C++11 の移動セマンティクスについて説明する良い出発点になると思われます。 YMMV.

移動とは何か?

C++98 標準ライブラリでは、std::auto_ptr<T> という独自の所有権セマンティクスを持つスマート ポインタを提供しています。 auto_ptr についてよく知らない方のために説明すると、その目的は、例外に直面した場合でも、動的に割り当てられたオブジェクトが常に解放されることを保証することです。

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

auto_ptrに関して変わったことは、その「コピー」動作です。

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

a による b の初期化が、三角形をコピーせず、その所有権を a から b へ移動させたことに注目しましょう。 また、「abに移動する」あるいは「三角形がaからbに移動する」とも言う。

オブジェクトを移動させるというのは、それが管理する何らかのリソースの所有権を別のオブジェクトに移すということです。

auto_ptr のコピー コンストラクタはおそらく次のようになります (多少簡略化されています):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Dangerous and harmless moves

auto_ptr に関して危険なことは、構文上コピーに見えるものが実際には移動であることです。 移動された auto_ptr のメンバ関数を呼び出そうとすると、未定義の動作が呼び出されるため、

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

から移動された後の auto_ptr を使用しないように非常に注意する必要があります。 ファクトリー関数は auto_ptr の完璧な使用例です:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

両方の例が同じ構文パターンに従っていることに注意してください:

auto_ptr<Shape> variable(expression);double area = expression->area();

そしてまだ、一方は未定義の動作を呼び出しますが、もう一方はそうではありません。 では、amake_triangle()の違いは何でしょうか。 どちらも同じ型ではないのでしょうか?

値のカテゴリ

明らかに、auto_ptr 変数を表す a 式と、値として auto_ptr を返す関数の呼び出しを表す make_triangle() 式は、呼び出されるたびに新しい一時 auto_ptr オブジェクトを作成するため、何か深い違いがあるはずである。 aはl値の例で、make_triangle()はr値の例である。

aのようなl値からの移行は危険で、後でa経由でメンバー関数を呼ぼうとすると、未定義の振る舞いを引き起こす可能性があるからである。 一方、make_triangle()のようなr値からの移動は完全に安全です。なぜなら、コピーコンストラクタが仕事をした後は、その一時的なものを再び使うことができないからです。 この一時的な値を表す式はなく、単に make_triangle() と書き直せば、別の一時的な値が得られます。 実際、移動したテンポラリは次の行で既になくなっています。

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

なお、lrという文字は、代入の左辺と右辺という歴史的な由来を持っています。 C++では、代入の左辺に現れないlvalue(代入演算子のない配列やユーザー定義型など)と、現われるrvalue(代入演算子のあるクラス型のすべてのrvalue)があるので、これはもはや真実ではない。

クラス型のrvalueとは、その評価が一時オブジェクトを作成する式である。

Rvalue references

Lvalue からの移動は潜在的に危険であるが、Rvalue からの移動は無害であることを理解した。 C++ に lvalue 引数と rvalue 引数を区別する言語サポートがあれば、lvalue からの移動を完全に禁止するか、少なくとも呼び出し先で lvalue からの移動を明示し、誤って移動することがないようにすることが可能です。 rvalue 参照は、rvalue にのみ結合する新しい種類の参照であり、構文は X&& です。 古き良き参照 X& は、現在では lvalue 参照として知られています。 (X&& は参照への参照ではないことに注意してください。C++ にはそのようなものはありません。)

const を混在させると、すでに 4 種類の参照が存在することになります。

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

実際には、const X&& のことは忘れていただいて結構です。 4094>

Rvalue reference X&& は rvalue にのみ結合する新しいタイプの参照です。

Implicit conversion

Rvalue reference はいくつかのバージョンを経ています。 バージョン2.1以降では、YからXへの暗黙の変換がある場合、rvalue参照X&&は異なるタイプYの全ての値カテゴリにも束縛される。 その場合,型Xのtemporaryが生成され,rvalue参照がそのtemporaryに束縛される:

void some_function(std::string&& r);some_function("hello world");

上記の例において,"hello world"は型const charのlvalueである。 const char から const char* を経て std::string に暗黙のうちに変換されているので、 std::string 型のテンポラリが作られ、 r がそのテンポラリに束縛される。 これは、rvalues (式) と temporary (オブジェクト) の区別が少しあいまいな場合の 1 つです。

移動コンストラクタ

X&& パラメータを持つ関数の有用な例として、移動コンストラクタ X::X(X&& source) があげられます。 4094>

C++11 では、std::auto_ptr<T> は、rvalue 参照を利用する std::unique_ptr<T> に置き換えられました。 ここでは、unique_ptrの簡略版を開発し、議論する。 まず、生のポインターをカプセル化し、演算子 ->* をオーバーロードして、クラスはポインターのように感じられます。

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

コンストラクタはオブジェクトの所有権を取得し、デストラクタはそれを削除します。

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

さて、興味深いのは移動コンストラクタの部分です。

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

この移動コンストラクタは、auto_ptr コピー コンストラクタとまったく同じことを行いますが、rvalue しか提供できません。

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

2行目はコンパイルに失敗します。 これはまさに我々が望んでいたことであり、危険な移動は決して暗黙のうちに行われるべきではない。 3行目はmake_triangle()がr値なのでうまくコンパイルされる。 移動コンストラクタは一時的なものから c に所有権を移します。

移動コンストラクタは、管理対象リソースの所有権を現在のオブジェクトに転送します。

移動の割り当て演算子

最後に欠けているピースは、移動の割り当て演算子です。 その仕事は、古いリソースを解放し、引数から新しいリソースを取得することです。

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

移動割り当て演算子のこの実装が、デストラクタと移動コンストラクタの両方の論理を重複していることに注意してください。 コピー アンド スワップ イディオムに馴染みがありますか。

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

sourceunique_ptr 型の変数であるため、移動コンストラクタによって初期化され、つまり、引数がパラメータに移動されます。 引数はまだ rvalue である必要があります。これは、移動コンストラクタ自体が rvalue 参照パラメータを持っているためです。 制御フローが operator= の閉じ中括弧に達すると、source はスコープ外に出て、古いリソースを自動的に解放します。

移動割り当て演算子は、管理対象リソースの所有権を現在のオブジェクトに移行させ、古いリソースを解放します。 move-and-swap イディオムは実装を単純化します。

Moving from lvalues

時には、lvalues から移動したいことがあります。 この目的のために、C++11 では、ヘッダー <utility> 内に std::move という標準ライブラリ関数テンプレートがあります。この名前は少し残念ですが、std::move は l 値を r 値にキャストするだけで、それ自体は何も移動しません。 単に移動を可能にするだけである。

L 値から明示的に移動する方法を示します。

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

3 行目以降では、a はもはや三角形を所有していないことに注意してください。 コンストラクタの皆さん、a を使って c を初期化するために好きなことをやってください。 4094>

std::move(some_lvalue) は l 値を r 値にキャストし、その後の移動を可能にします。

Xvalues

std::move(a) は r 値ですが、その評価は一時オブジェクトを作成しないことに注意してください。 この難問のため、委員会は第3の値カテゴリを導入することを余儀なくされた。 伝統的な意味でのrvalueでなくても、rvalue参照にバインドできるものをxvalue(eXpiring value)と呼ぶことにした。 従来の rvalues は prvalues (Pure rvalues) に改名された。

prvalues と xvalues は両方とも rvalues である。 Xvaluesとlvaluesはともにglvalues (Generalized lvalues)である。

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

xvalues だけが本当に新しく、残りは名前の変更とグループ化によるものです。

C++98 rvalues は C++11 では prvalues として知られています。 これまでの段落で「rvalue」が出現した場合はすべて「prvalue」に置き換えてください。

関数からの移動

これまで、ローカル変数への移動と関数パラメーターへの移動について見てきました。 しかし、移動は逆方向にも可能です。 関数が値で返す場合、呼び出し元のオブジェクト (おそらくローカル変数か一時的なものですが、どんな種類のオブジェクトでもかまいません) は、移動コンストラクタの引数として return 文の後にある式で初期化されます。

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

驚くべきことに、自動オブジェクト (static として宣言されていないローカル変数) も関数から暗黙的に移動させることができます。 result のスコープはまもなく終了し、スタックの巻き戻しの際に破棄されます。 制御フローが呼び出し元に戻ったとき、result はもう存在しないのです! そのため、C++11 では、std::move を記述しなくても、関数から自動オブジェクトを返すことができる特別なルールがあります。 実際には、「名前付き戻り値の最適化」(NRVO)を阻害するため、自動オブジェクトを関数から移動するために std::move を決して使用してはいけません。

自動オブジェクトを関数から移動するために std::move を決して使用してはいけません。

両ファクトリ関数において、戻り値は rvalue 参照ではなく値であることに注意してください。 Rvalue 参照はまだ参照であり、いつものように、自動オブジェクトへの参照を返してはいけません。次のようにコンパイラーを騙してコードを受け入れさせた場合、呼び出し側はぶら下がった参照で終わってしまいます:

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

決して rvalue 参照で自動オブジェクトを返してはいけません。 移動は専ら移動コンストラクタによって実行され、std::move では実行されませんし、単に rvalue を rvalue 参照にバインドすることでもありません。

Move into members

遅かれ早かれ、次のようなコードを書くことになるでしょう:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

基本的に、コンパイラが parameter が lvalue だと文句を言ってくるでしょう。 その型を見ると rvalue reference ですが、rvalue reference は単に「rvalue に束縛されている参照」という意味で、その参照自体が rvalue であるという意味ではありません! 実際、parameterは名前を持った普通の変数に過ぎません。 parameter はコンストラクタの内部で何度でも使うことができ、常に同じオブジェクトを表します。

名前の付いた rvalue 参照は、他の変数と同様に lvalue です。

解決策は、手動で移動を有効にすることです。 なぜ戻り値のように黙ってstd::moveを挿入する特別なルールがないのでしょうか? おそらく、コンパイラの実装者に負担がかかりすぎるからだろう。 例えば、コンストラクタ本体が別の翻訳ユニットにあった場合はどうだろうか。 これに対して、戻り値規則は単にシンボル・テーブルをチェックして、returnキーワードの後の識別子が自動的なオブジェクトを示すかどうかを判断するだけでよいのです。 unique_ptr のような移動のみの型については、まだ確立されたイディオムはないようです。 個人的には、インターフェイスの混乱を招くことが少ないので、値で渡す方が好きです。

特別なメンバー関数

C++98 では、要求に応じて、つまり、どこかで必要になったときに、3 つの特別なメンバー関数を暗黙的に宣言します: コピーコンストラクタ、コピー割り当て演算子、デストラクタ。 バージョン 3.0 以降、C++11 では、必要に応じて、移動コンストラクタと移動代入演算子という 2 つの特別なメンバ関数が追加で宣言されています。 VC10 も VC11 もまだバージョン 3.0 に準拠していないので、自分で実装する必要があることに注意してください。

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

これら2つの新しい特別なメンバー関数は、どの特別なメンバー関数も手動で宣言されていない場合にのみ、暗黙的に宣言されます。 また、独自の移動コンストラクタまたは移動代入演算子を宣言した場合、コピー コンストラクタもコピー代入演算子も暗黙的に宣言されません。

これらのルールは実際にはどのような意味を持つのでしょうか。

アンマネージ リソースなしでクラスを記述する場合、5 つの特殊メンバ関数を自分で宣言する必要はなく、正しいコピー意味論と移動意味論を無料で入手することができます。 そうでない場合は、特殊なメンバ関数を自分で実装しなければなりません。

コピー代入演算子と移動代入演算子は、値によって引数を取る単一の統一された代入演算子に融合することができることに注意してください。 4094>

Forwarding references (以前は Universal references として知られていた)

以下の関数テンプレートを考えてみましょう:

template<typename T>void foo(T&&);

T&& が rvalue にのみ結合すると思うかもしれませんが、それは一見すると rvalue 参照のように見えるからです。

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

引数がX型のrvalueの場合、TXであると推論され、T&&X&&を意味することがわかります。 しかし、引数がX型のl値であれば、特別なルールによりTX&と推論され、T&&X& &&のような意味になる。 しかし、C++はまだ参照への参照という概念を持っていないので、型X& &&X&に折り畳まれる。 これは最初は混乱して役に立たないように聞こえるかもしれないが、参照の折りたたみは完全な転送(ここでは説明しない)に不可欠である。

T&& は rvalue 参照ではなく、転送の参照である。 また、lvalueにも結合し、その場合TT&&は共にlvalueの参照となる。

関数テンプレートを rvalue に束縛したい場合は、SFINAE と type traits を組み合わせます:

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

move

参照の折りたたみを理解したところで、std::move がどう実装されているかを説明します。

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

見ての通り、move は転送参照 T&& のおかげで任意の種類のパラメータを受け入れ、rvalue 参照を返します。 std::remove_reference<T>::type メタ関数呼び出しが必要なのは、そうでなければ X 型の lvalue に対して、戻り値の型は X& && となり、X& に縮退してしまうからです。 t は常に lvalue ですが (名前付き rvalue 参照は lvalue であることを覚えておいてください)、t を rvalue 参照に結合したいので、t を正しい戻り値の型に明示的にキャストしなければなりません。rvalue 参照を返す関数呼び出し自体は xvalue です。 これで xvalue がどこから来たかわかったでしょう 😉

.

コメントを残す

メールアドレスが公開されることはありません。