ラムダ式の参照キャプチャは基本的に安全である

ラムダ式ネタをもう一つ。こっちは少々マニアックな内容です。


C++0x におけるラムダ式は、

int f( std::vector<int> const& v1, std::vector<int> const& v2 )
{
  int sum = 0;
  auto add_to_sum = [&](int x){ sum += x; };
  
  std::for_each( v1.begin(), v1.end(), add_to_sum );
  std::for_each( v2.begin(), v2.end(), add_to_sum );
  
  return sum;
}

のように、ローカル変数を参照キャプチャすることが可能ですが、
一般に、ローカル変数に対する参照には、 dangling reference*1 が生じる可能性、というものが存在します:

// ラムダ式とは関係ない例

std::string& f() {
  std::string x;
  return x; // ローカル変数への参照を返している!
}

void g() {
  std::string& x = f();
  x = "hoge"; // ダメ。 x の参照先は既に破棄されている
}

具体的には、関数の戻り値として、ローカル変数への参照を返した場合、
そのローカル変数は関数の終わりに破棄されるため、 dangling reference となります。


しかし、ラムダ式においては、ローカル変数を参照キャプチャした場合でも、基本的には安全です。

何故ならば、ローカル変数に対する参照が dangling reference になる場合は、上記のように
関数の戻り値としてローカル変数への参照を返した場合の他には、基本的にはありえないからです。*2

そして、ラムダ式に対する decltype は行えない以上、関数の戻り値として ローカル変数を参照キャプチャしたラムダ式を使うことは不可能です*3

auto f( std::string x )
  -> decltype( [&](){ return x; } ) // こうは書けない
{
  return [&](){ return x };
}

このような理由により、ローカル変数を参照キャプチャしたラムダ式は、基本的に安全なのです。


ただし、「基本的に」安全だ、と表記したように、安全でない場合も、実は存在します。

具体的には、 type erasure*4 を使うなどして、ラムダ式の持つ「再束縛できない*5」「関数の戻り値として使えない」という性質が破られた場合には、ラムダ式によってキャプチャしたローカル変数への参照は dangling reference となりえます。
type erasure の例としては、 std::function や std::shared_ptr などが挙げられ、

void f()
{
  std::function<std::string ()> func;

  {
    std::string x;
    func = [&](){ return x; };
  } // x はここで破棄されるので
  
  // この呼び出しは未定義動作となる
  std::string s = func();
  
}

std::shared_ptr<X> g()
{
  bool deleted = false;
  auto del = [&]( X* p ){
    delete p;
    deleted = true;
  }
  return std::shared_ptr<X>( new X(), del );  // del は deleted を参照しているのでアウト
}

上記のようなコードは、いずれも dangling reference にアクセスし、未定義動作を引き起こします。


このように、 std::function や std::shared_ptr といった type erasure は、便利ではあるのですが、
その使用の際には、変数の寿命を考慮した上で、必要ならば値キャプチャを使う等の工夫が必要になります。

このような危険性は、別にラムダ式と type erasure を組み合わせた場合に限らず、
一般に、参照を保持する変数を「再代入する*6」場合と「関数から返す」場合には、
dangling reference が生じる可能性がある、ということは、きちんと把握しておくべきです。


しかし、このことは、逆に言えば、
これらの操作を行わない場合、 C++ の参照は非常に安全に扱うことが出来る、
ということでもあるので、覚えておいて損はない筈です。

追記 (2013/05/26)

2014年に策定予定の C++14 では,関数の戻り値を return 文から推論できるようになったため,
上記のような場合に加えて,関数の戻り値として使う場合も考慮する必要があります.

// C++14 では, auto を使うことで一般関数の戻り値も推論可能
auto f(int x) {
  return [&] (int y) { return x + y; };
};
auto g = f(1);
g(2);  // undefined behavior, x は既に破棄されている


また,C++11 *7 でも,ラムダ式型推論を使えばラムダ式を関数から返せるので,
そのような場合には, dangling reference に気をつける必要があります.

auto f = [] (int x) {
  return [&] (int y) { return x + y; };
};
auto g = f(1);
g(2);  // undefined behavior, x は既に破棄されている


総じて,「再代入を始めとしたオブジェクトの状態の変更」と「関数の戻り値」には気をつける,
ということを覚えておけば良いと思います.

*1:既に破壊されたオブジェクトに対する参照のこと。ぶら下がり参照、とも。

*2:ローカル変数の破棄は、構築した順番と逆の順番で行われる以上、ローカル変数に対する参照が生成された時点で、参照先のローカル変数は既に構築されている、言い換えるならば、 参照の生成タイミングは、参照先の変数よりも後になる ので、その参照の破棄タイミングは、参照先のローカル変数よりも先になり、参照変数が参照先を再設定することが出来ない以上、この関係は滅多なことでは破られません。

*3:厳密に言うと、ラムダ式を使って戻り値の型を推論させた場合には、ラムダ式を返す関数を作ることも出来たりします。 http://ideone.com/GjcTx

*4: std::function 等の、実行時多態を行うことで「型を消す」クラス。「型を消す」ことによるメリットは、型が一種類になるので分割コンパイルがしやすくなる、複数の型を std::vector 等に格納できるようになる、などがあります。

*5:ローカル変数を参照キャプチャした場合に限らず、ラムダ式を保持した変数に対して再代入を行うことは出来ません。

*6:生の参照では無理ですが、生ポインタ等を使えば容易に行えます。

*7: C++0x は 2011年9月に無事に策定されて C++11 となりました