1. ホーム
  2. C++

C++11での移動セマンティクス(std::move)と完全な前進(std::forward)。

2022-01-24 10:29:09
<パス

前文

std::move と std::forward は C++98/C++0x からのレガシーな問題を解決するために作られた C++11 の機能で、理解は複雑ですが、より良い解決策と言えます。

左値(lvalue)と右値(rvalue)の比較

左値と右値の概念は、実はC++0xにあります。大雑把に言うと、アドレスを取れるものは左値と呼ばれ、その逆は右値と呼ばれます。

class A
{};

A a; // a は明示的な名前を持つので左の値であり、a に対して &a を行うことは合法である。

void Test(A a)
{
  __Test(a);
}

Test(A()); // A()は右値です。なぜならA()は一時的なオブジェクトを生成し、それは名前を持たず、&アドレスすることはできないからです。

テスト(a);の場合、aは呼び出し元に名前があり、__Testは内部的にaを&アドレスできるので、aは左値であると言えます。
しかし、このaは呼び出しが終わるとすぐに破棄されます。結局のところ、一時的な変数に過ぎないのです。

つまり、右の値を受け取ることができる右の参照は、それ自体が左の値であるということです。(ここでは誤解を避けるために void Test(A &a) という形式は擬似コードでは使っていませんが、それは後述します)。

移動セマンティクス (std::move)

こんなコードがあります。

class A
{
public:
  A() :array(new int[3]{
1, 2, 3})
  {
  }
  ~A()
  {
    if(nullptr ! = a)
    {
      delete [] a;
    }
  }
  A(const A& a)
  {
    std::cout << "Copy Construct" << endl;
  }
  private:
    int *array{
nullptr};
  };

  int main()
  {
    A a1;
    A a2(a1);
    return 0;
  }

出力してください。
コピーコンストラクト

上記のコードは問題なく見えますが、最適化の余地があります。実際に配列が非常に大きな配列を参照している場合、このコピーの構築と破壊のコストは非常に大きく、アプリケーションシナリオによってはそのような大きなサイズのオブジェクトをコピーすることさえ許容されません。私たちには理想があります:どんな大きなサイズのオブジェクトでも、そのクラス内の大きなサイズのオブジェクトをコピーするだけで(コピー構築、コピー代入操作)、大きなサイズのオブジェクトの構築と破壊の余分な費用を避けることができる、そして答えは「イエス」なのです。

class A
{
public:
  A() :array(new int[3]{
1, 2, 3})
  {
  }
  ~A()
  {
    if(nullptr ! = a)
    {
      delete [] a;
    }
  }
  A(A &&a)
  {
    array = a.array;
    a.array = nullptr;
    std::cout << "Memory Copy Construct" << endl;
  }
public:
  int *array{
nullptr};
};

A GetTempObj()
{
  A a;
  std::cout << a.array << endl;
  return a;
}

int main()
{
  A a(GetTempObj()); // actually this code is not standard and relies on the compiler implementation
  std::cout << a.array << endl;
  return 0;
}

出力してください。
00BEF138
メモリコピー構成
00BEF138

GetTempObj()は、aオブジェクトの初期化に使用する一時オブジェクト(注:VS2012+コンパイラはRVO最適化を行いますが、現時点ではこの記事で説明しません)を返します。上記のコードは配列のアドレスを表示し、その結果から、コピー構文がその大きなサイズのメモリだけをコピーしているように見えるときに、2つのオブジェクト間のコピーを実現しています。上記のコードは、左の値と右の値の間のコピー構文に適用され、これを移動コピー構文と呼んでいます。考えてみれば、私たちも欲しい。

A a1;
A a2(a1); // error: 'A::A(const A &)': attempting to reference a deleted function

上記のコードはコンパイルされず、合格するためにコピーコンストラクタを指示する必要があります(移動コピーコンストラクタを実装しているので、コンパイラはコピーコンストラクタを自動生成する必要はないと考えています)。実際のコードでは、移動コピーコンストラクタを実装するために、常に GetTempObj() が一時オブジェクト(正しい値)を返すとは限らないので、特別な回避策が必要です。

std::moveはやや誤解を招く名前ですが、何も動かしません。

新しい規格では、移動コンストラクタと移動コピー関数は左と右の値を別々に扱うべきであり、std::move はその統一的なソリューションであると主張しています。std::move は移動コピー用に左値を右参照に変換するので(以下 move がこれをどう行うかの詳細)、二つの左値間のコピーには、このようにすればいいのです。

A a1;
A a2(std::move(a1)); // correctly

つまり、移動コピー構文と移動コピー関数を持つクラスを実装することです。

class A
{
public:
  A() :array(new int[3]{
1, 2, 3})
  {
  }

  ~A()
  {
    if(nullptr ! = a)
    {
      delete [] a;
    }
  }

  A(A &&a)
  {
  }

  A & operator = (A &&rhs)
  {
    return *this;
  }
};

実際、移動セマンティクスを確実に渡すためには、移動コンストラクタを書くときに、ヒープメモリやファイルハンドルなどのリソースを持つメンバを std::move で右値に変換し、移動コンストラクタをサポートしていればその移動セマンティクスを実装できるようにすることを常に忘れないようにすべきです。また、そのメンバに移動コンストラクタがない場合でも、定数の左値を受け取るバージョンのコンストラクタを使えば、大きな問題を起こさずにコピーコンストラクトを簡単に実装できます(移動関数がなければ std::move がデフォルトでコピー操作をしてくれる)。

今度はこんなコードもあります。

A Get()
{
  return A();
}

A a(A());
A a1(Get());

実際、A()とGet()は正しい値である一時的なオブジェクトを返すので、aもa1も移動関数(移動コンストラクトと移動コピー)を出発しません。では、そのようなテンポラリオブジェクトにstd::moveを適用して、移動関数の呼び出しを実装することは可能なのでしょうか。ここで、左値参照と右値参照が登場する。

右値参照

左値参照と右値参照は、大きく分けて左値参照、右値参照と呼ぶことができます。move関数を呼び出すために、std::moveを使って左値の参照を右値の参照に変換できることは知っています。では、右値参照はstd::moveを使えるのでしょうか?答えはイエスです。具体的には、左値参照と右値参照は互いに束縛することができますが、次のルールに従います。

  • 非構成の左値参照は、非構成の左値のみにバインドできます。
  • const left-valued 参照は、const left 値、nononst left 値、const right 値、nononst right 値にバインドすることができる。
  • nonconst right-valued の参照は nonconst right 値にのみ結合でき、関数テンプレートのフォームパラメータには結合できない。
  • const右値参照は、const右値にもnononst右値にも束縛することができます。

移動関数呼び出しを実装するために a と a1 が欲しい、そして上から来る、移動関数呼び出しを実装するために A() と Get() の戻り値を非固定の右値に変換する必要がある、そして答えは: std::move だった。

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

テンプレート・パラメータ導出規則によれば,入力パラメータが左値の場合,TはT&として導出されるので,T&+T&はT&として導かれ,実際にはmove(&)となり,入力パラメータが右値の場合,T&&に従って導出はされるので,;&;はT&;となり,どのみちmoveが最終的にはT&&を返すはずだ,となります.型誘導の場合。

このルールは、関数テンプレートのテンプレートパラメータがTで、関数フォーマルパラメータがT&&(右値参照)のときに適用されます。実パラメータが左値A&の場合、テンプレートパラメータTは参照型A&として導出する必要があります(参照畳み込み規則により、A&+ &&=>A& 、T&&=> A& だからT <=> A& )。実参照が右値A&&であれば、テンプレートパラメータTは非参照型Aとして導出されるべきである(参照折りたたみ規則により、AまたはT&&(右値参照)であれば、テンプレートパラメータTは非参照型Aとして導出されるべきである)。 参照折りたたみルールでは、AまたはA&& + && => A&& とT&& <=> A&& で、T <=> A または A&& で、T <=> Aが実行されます)(参考資料の折り込みルールに従って、AとA&&A;+ &=> と、T&=>A が実行されています)

ですから、私たちのコードは、Aの移動コンストラクタを呼び出すように修正する必要があり、移動コピー関数も同じことをします:。

A a(std::move(A()));
A a1(std::move(Get())));

C++98/0xのauto_ptrを覚えていますか? auto_ptrは"copy"となると、厳密にはコピーではありません。コピー"とは、コピー元のオブジェクトを変更せず、それを元に新しいオブジェクトを作ることです。しかし、auto_ptr の "copy" は、ソースオブジェクトを "empties" して、空のシェルだけを残し、事実上リソースの所有権を移譲しますが、auto_ptr の危険性は、コピーに見えるべきものが実は転送であることです。auto_ptr が転送されたメンバ関数を呼び出すと、予測できない結果になるため、C++11 では、移動セマンティクスを使用して実装された unique_ptr に置き換えられています。

std::moveのキーワード:効率の最適化

まず、いくつかの問題を解決する必要があります。std::move は、効率性の問題を解決し、不必要なコピーを減らすためにあります。もしこのような move コンストラクタが存在すれば、コピー元のオブジェクトが一時的なオブジェクトであるようなコピー構文はすべて move 構文に還元できます。通常の文字列型では、std::moveとコピー構文の効率差は、1回のO(n)アロケーション操作、1回のO(n)コピー操作、1回のO(1)デストラクチャー操作(コピーされる一時オブジェクトの破棄)の節約になる。ここでの効率向上は明らかであり、重要です。

パーフェクトフォワード(std::forward)

プログラマは、高度に汎用的なコードを書いていないときには、完全な転送の利点を理解できないかもしれません。C++98/0x時代、汎用的なコードを書く必要があるとき、関数呼び出しで問題に遭遇することがある。

void __Test(int &t)
{
}

template<typename T>
void Test(const T &t)
{
  // do other things... 
  __Test(t);
}

int i = 0;
Test(i);

上記のコードはコンパイルできません。理由は、Testを入力した後のtがconst T&で、__Testを呼び出した後のtがT&なので、テンプレートがconst T&からT&に推論できないからです。この問題を解決する方法は2つあります。

template<typename T>
void Test(const T& t)
{
  // do other things...
  __Test(const_cast<T&>(t));
}

変更後、Testはコンパイルしてパスします。const_cast による const 属性を削除しています。考えてみてください、const_castを使うことは適切でしょうか?明らかにここでconst_castを使うと、関数の堅牢性を壊してしまいます。これを解決する他の方法はないのでしょうか?この問題に対処するために、テンプレート・テーブルを適切な型に特化するか、呼び出し側の関数をオーバーロードするのです。

void __Test(const int &t)
{}

// or
void __Test(int & t)
{}
// or
template<typename T>
void Test(T &t)
{}

// or
template<typename T>
void Test(T &t)
{}

これで問題はスムーズに解決し、Test(i) と Test(5) の両方の呼び出しが問題なくコンパイルできるようになりました。

現実の世界では、コードはそれほど単純ではありません。上記のテストコードには引数が1つしかありませんが、もしテンプレート関数に引数が2つあったらどうなるでしょうか?

template<typename T>
void Test(T &t1, T &t2)
{
  __Test(t1, t2);
}
template<typename T>
void Test(const T &t1, const T &t2)
{
  __Test(t1, t2);
}
template<typename T>
void Test(const T &t1, T &t2)
{
  __Test(t1, t2);
}
template<typename T>
void Test(T &t1, const T &t2)
{
  __Test(t1, t2);
}

このサンプルコードを書き終えたときの第一印象は、「未熟な仕事だな」「プログラマはこんなことに時間を割くべきではない」というものでした。テンプレート関数のパラメータが2つある場合、4つの特殊化を行うことになります。それは問題ではありません。引数の型が違うからTestが__Testに転送して終わるというのはどういうことでしょうか。それは、引数の型が異なる __Test のバージョンをオーバーロードしなければならないことを意味します。2つのパラメータは4つでオーバーロードできます。3つ、4つ、あるいは5つのパラメータがあるとき、__Testは実際にいくつのオーバーロードされた関数を満たす必要があるのでしょうか?VC9のstd::bindが5つの引数に対して63の関数をオーバーロードするのはそのためで、なんと膨大な数が手作業で積み上げられていくのです。

テンプレート関数Testの数を減らすか、転送関数__Testの数を減らすか、プログラマは必要なところで頭脳と手を使うべきでしょう。

左値、右値という概念ができたことで、このような問題を解決するためのより良い手段ができました。

template<typename T>
struct RemoveReference
{
  typedef T Type;
};

template<typename T>
struct RemoveReference<T&>
{
  typedef T Type;
};

template<typename T>
struct RemoveReference<T&&>
{
  typedef T Type;
};

template<typename T>
T&& ForwardValue(typename RemoveReference<T>::Type&& value)
{
  return (T&&)value;
}

template<typename T>
T&& ForwardValue(typename RemoveReference<T>::Type& value)
{
  return (T&&)value;
}

上記のコード(実はstd::forwardの大雑把な実装)があれば、コードはずっとシンプルになります。 undefined
T&&: 文字列&&


t: charm()
T: const string
T&&: const string&&
quackが呼ばれると、upはstring&型として導出される。quackでは、T&+ &-> T&のルールにより、Tはstring&型として派生します。つまり、quackに入るとTはstring&になるので、Aはstring&のバージョンを呼び出すことになるのです。それでも上記のルールで、Bの実行では、Tはstring&として変換され導出されるので、適切なバージョンに移行する。
downはconst属性を持っているので、表現方法は1と同じです。ただ、AもBもconst属性を持つバージョンに呼び出すという違いがあります。

ここはちょっと特殊で、range()はstring型の一時オブジェクトを返すので、quackを入力した後もTはstring型なので、Aはstringで呼ばれたバージョンに入り、同じ意味でT&&はstring&で、対応するバージョンに入ることになるのですが、quackを入力した後は、Tはstring型になります。
ここはちょっと特殊で、charm()はstring型のテンポラリオブジェクトを返すので、クオークに入った後のTはstring型であり、const属性を持っているので、AもBもconst属性を持つバージョンにコールインすることになるのです。
上のコードでは、std::forwardの大まかな実装を示しましたが、これは、本当に大げさに言えば、引数の元の型を保持するために参照折りたたみ規則を使い、目的関数への引数の完全な転送を実現するためにコンパイラによる型導出を拒否するということです。

完全転送の問題の本質は、テンプレート・パラメータの型導出が転送時の左右の値の参照を保証していないことである。完全転送は、constプロパティを崩すことなく、左右参照の概念と新しいパラメータ導出ルールを追加することで、この問題を解決している。

std::forward:解決のためのキーワード

テンプレート内で、完全な転送を行わずに、引数のセットをそのまま別の引数に渡す必要がある場合、複数の種類のオーバーロードが存在することを考えると、完全な転送を行わないオーバーロード関数の数は2^nとなり、なんと膨大な量のアーティファクトになります。std::forwardを使用する場合、パラメータのプロパティを変更しないようにテンプレートパラメータの導出規則で補完すると、完全な転送は多くの作業を節約します。