rvalue reference、move semanticsに挫折した人のための再入門解説

この記事は C++11 Advent Calendar 14日目の記事です。

何がわかりにくいのか

C++11から正式に導入される新機能である、move semanticsとrvalue referenceですが、なかなかこれを理解出来ないという人が多いようです。
ちょっと考えればなるほど、と思いますが私が思うにこのわかりにくい最大要因はやはりその名前のせいかと思います。
特にrvalue referenceですが、日本語に訳すと右辺値参照という呼ばれ方をしています。
そもそも右辺値や左辺値という名称はC言語時代の流れでそのまま引き継いでいるものであり、右辺値参照という名称は言わば後付けで名称されたものです。
実際に使用する際に右辺値なのか左辺値なのかを意識している人は殆どおらず、これが余計な混乱を招いているのではないかと思います。
ではコードを書いてみて理解しましょう。

lvalueとrvalue

int main()
{
  int a = 0;
  a;  // lvalue
  0;  // rvalue
}


とても単純なコードです。
これが理解出来ない人がいるとなるとさすがに私でも教えられる気がしないので1度C言語入門からやり直すことをオススメします。
そしてこれをみてなんとなく理解出来た人は既にrvalueを殆ど理解していると言えます。


そう、lvalueとrvalueの最大の違いというのは"名前を持つオブジェクト"と"名前を持たない無名オブジェクト"ということです。
named valueとunnamed valueとでも呼んだ方がわかりやすいでしょう。
次回からは"lvalue -> named value"、"rvalue -> unnamed value"と置き換えて読んでみてください。
それでは別の例もみてみましょう。

struct A {};
int func() { return 0; }

int main()
{
  A a;
  a;      // lvalue
  A();    // rvalue
  func(); // rvalue
}

もう説明するまでもないでしょう。
つまり、rvalueとは生まれた次の瞬間には死ななくてはならないオブジェクトのことなのです。
逆に言えば生まれたあとも自分で寿命管理出来るオブジェクトはlvalueということです。
ではrvalue referenceというのはその名の通りrvalueへの参照というだけです。

rvalue reference

struct A {};

int main()
{
  A a;

  // lvalue reference
  A& lr1 = a;     // 問題ない
  A& lr2 = A();   // lvalue referneceへrvalueを渡そうとしているのでerror

  // rvalue reference
  A&& rr1 = a;    // rvalue referenceへlvalueを渡そうとしているのでerror
  A&& rr2 = A();  // 問題ない
}

"A&& rr"という部分が実際にrvalue referenceとして受け取っている部分です。
要はこれだけという話なのですが、ひとつ注意しなくてはならないのが、rvalue referenceとして受け取ったオブジェクトはrvalueではなくて、lvalueということです。
なぜなら最初にも言った通り"名前を持つオブジェクト"になるからです。
rvalue reference型の変数はrvalueではなくて、rvalueへの参照が受け取れるだけで通常の変数と変わらないということです。

move semantics

そして出てきましたmove semanticsさん。
言葉で説明するのはなかなか難しいのですが、もの凄く単純な話「値のセマンティクスを移動させる」というだけのことです。
そのまんまじゃねーかバカやろうというツッコミがあると思いますが、とりあえずセマンティクスの意味に誤解がないように説明リンクを。


セマンティクスとは
http://www.kab-studio.biz/Programing/JavaA2Z/Word/00000315.html


セマンティクスの意味という意味がわからない説明ですが、とりあえず理解は出来るでしょう。
つまりセマンティクスを移動させるということはプログラムとして意味のある値を移動させるということになります。
移動させるということは一般的に代入操作で行なわれるcopy semanticsとは別物になり、移動元となった値は完全に無効値となります。
内部的にポインタだけが移動したと考えるとわかりやすいかもしれません。
実際にコードで書くとこうなります。

template<typename T>
void swap(T& a, T& b)
{
  //T tmp = a;          // これだとコピーが発生する
  T tmp = std::move(a); // Tのmove constructorが呼ばれる
  a = std::move(b);     // Tのmove assignment(T::operator(T&& b))が呼ばれる
  b = std::move(tmp);   // tmpの中身をbに移動させる。tmpの中身はなくなる
}

move semantics仕様のswap()関数です。
std::move()関数はその中で引数のlvalueをrvalueにキャストします。
そしてキャストしたrvalueをそのままmove constructor、もしくはmove assignment operatorへと渡します。
move constructorや、move assignment operatorはcopy constructorやcopy assignment operatorなどと同じようにクラスに定義出来ます。
定義しなければコンパイラが自動で生成します。


参考
http://d.hatena.ne.jp/faith_and_brave/20100331/1270020213


ちなみにstd::move()関数の他にstd::forward()という関数もありますが、これは難しい話になるのでここでは解説しません。
std::forward()に関してはきっと id:gintenlabo さんが書いてくれるはず!!

結局何が嬉しいのか

以上でmove semanticsを使用したコードが書けたわけですが、結局何が嬉しいんでしょうか?


答えはひとつ。
copy semanticsだと発生するコピーのコストがmove semanticsでは一切ないということです。
小さいオブジェクトでなら無視出来るコピーコストでも大きいオブジェクトになると問題になりがちなコピーコストが一切ありません。
BigDataなクラスでも作ってSTLコンテナに突っ込もうものなら大変なコピーコストになってしまうということです。
いちいちポインタでnewしてそれを突っ込んで消す際にdeleteなんて無駄なことは必要ありません。
コピーが発生しないので気軽にコンテナに入れ放題ということです。
では最後にSTLを使ったcopy semanticsとmove semanticsのベンチマークを比較しましょう。

#include <vector>
#include <iostream>
#include <time.h>
#include <set>
#include <algorithm>

static const int N = 3001;

std::set<int> get_set(int)
{
  std::set<int> s;
  for (int i = 0; i < N; ++i) {
    while (!s.insert(std::rand()).second) ;
  }
  return s;
}

std::vector<std::set<int> > generate()
{
  std::vector<std::set<int> > v;
  for (int i = 0; i < N; ++i) {
    v.push_back(get_set(i));
  }
  return v;
}

void output(const char* s, clock_t t1, clock_t t2)
{
  std::cout << s <<
    static_cast<float>(((t2 - t1) /
    static_cast<double>(CLOCKS_PER_SEC)))
  << std::endl;
}

float time_it()
{
  clock_t t1, t2, t3;
  clock_t t0 = clock();
  {
    std::vector<std::set<int> > v = generate();
    t1 = clock();
    output("construction ", t0, t1);
    std::sort(v.begin(), v.end());
    t2 = clock();
    output("sort ", t1, t2);
  }
  t3 = clock();
  output("destruction ", t2, t3);
  return static_cast<float>(((t3 - t0) /
         static_cast<double>(CLOCKS_PER_SEC)));
}

int main()
{
  float t = time_it();
  std::cout << "Total time = " << t << '\n';
}

以下の結果はgcc 4.5.2で最適化なしでCore i7 920 2.67GHzで比較したものです。
move semanticsは-std=gnu++0xオプションをつけてビルドしています。

// copy semantics
construction 9.182
sort 8.302
destruction 0.984
Total time = 18.468

// move semantics
construction 5.228
sort 0.041
destruction 1.015
Total time = 6.284

トータル時間で三倍程度差がでました。
特にソートに関しては殆んど時間がかかっていません、まさしく爆速。
デストラクト時に若干時間を食っているのが気になりますが、それでも誤差程度。
これが単純な結果とは言いづらいですが、それでもこれほどの差がでます。
move semantics万歳。

総括

move semanticsは誰もが幸せになれる素晴しい機能。
STLの場合はC++11対応コンパイラさえ使用していれば勝手にmove semanticsを使用します。
意識していなくても速くなるというのは素晴しい。
もちろん理解して使えばこの上ない強力な武器です、ぜひ活用しましょう。


それでは明日のid:iorateさんにバトンタッチです。

追記

lvalueではない名前つきオブジェクトがあるとツッコミを受けたので以下にまとめておきます。
やはりrvalueを完全に意識して書くのはなかなか大変かもしれません。

int& f(int& x) { return x; }  // f()の戻り値はlvalue

*new int(0); // 名前はないけどlvalue

enum { hoge };  // hogeは名前があるけどrvalue

template<char const * ch> // chはrvalueだけど、*chはlvalue

template <int I>  // Iはrvalue

追記2

今回のセマンティクスに対する解説は人によって誤解を生むものかもしれません。
そして私も誤解しているかもしれないので、あまり真に受けすぎない程度に参考にしてください。