こんにちは。田原です。
ラムダ式は「ある種のクラス(operator()を持っている)」のオブジェクトです。ですので、仕組み上ラムダ式はメンバ変数を持つことができます。そして、C++のラムダ式はそれを実にさりげなく使えるようになっています。あまりにさりげないので仕組みが判りにくいです。そこで今回は、その仕組みに焦点を当てて解説しようと思います。
1.キャプチャ
ラムダ式は関数内で定義できる「関数のようなもの」です。そして、もしもローカルに関数を定義できるなら、その定義元のローカル変数にアクセスしたいです。
例えば、次のようなイメージです。(実際にはC++はローカル関数を定義できませんのでエラーになります。しかし、びっくりgccのCコンパイラはローカル関数を定義できました。 clangの方はダメでした。)
#include <iostream> #include <string> int main() { // ローカル変数 std::string aString="<local string>"; // ローカル関数(実際には定義できません) void foo() { // ここで親関数のローカル変数 aString にアクセスしたい std::cout << "aString=" << aString << "\n"; } // ローカル関数の実行 foo(); }
これを可能にする仕組みがラムダ式には備わっています。コピー・キャプチャと参照キャプチャの2種類です。
1-1.コピー・キャプチャ
さて、ラムダ式は関数の中で定義しますが、その寿命は定義した関数の寿命に縛られません。
定義した関数の戻り値で返却したり、スレッドを起動してそのスレッド内で呼び出したりした時は、そのラムダ式を定義した関数からreturnした後も、ラムダ式は生きています。
これらのケースでは、定義元の関数からreturnし、そのローカル変数が破棄された後でもラムダ式でその値にアクセスしたいことが度々あります。コピーするコードを書けばもちろんできますが、その手間を削減する仕組みとしてコピー・キャプチャがあります。
考え方は、関数オブジェクトとしてのラムダ式の内部にメンバ変数を定義し、ラムダ式生成時にそこへコピーしておくというものです。コピーしてしまえば定義元の関数が終了して、ローカル変数が破棄された後もアクセスできます。そのメンバ変数の定義とそこへのコピーをコンパイラが自動的にこっそりとやってくれます。
今回の講座では、このコピー・キャプチャについて詳しく解説します。
1-2.参照キャプチャ
また、キャプチャにはもう一つ参照キャプチャがあります。
定義元の関数よりもラムダ式の方が寿命が短いケースも往々にしてありますね。例えば、std::sort()の比較関数compにラムダ式を渡すようなケースです。この場合、呼び出したstd::sort関数の中だけでラムダ式を使いますから、定義元の関数が生きている間のみラムダ式が呼ばれます。この時にローカル変数をコピーするのは勿体無いです。
このような場合に参照キャプチャが良く用いられます。
基本的な考え方はコピー・キャプチャと同じです。ラムダ式の内部に、アクセスしているローカル変数に対応する参照型メンバ変数が自動的にこっそり定義され、自動的にこっそりと初期化されます。
当然、定義元の関数が終了するとローカル変数は破棄されますから、定義元の関数終了後に、それらの参照を介して元のローカル変数にアクセスしては行けません。
従って、参照キャプチャは定義元の関数よりラムダ式の寿命の方が短い時に使うのが安心です。
なお、参照キャプチャについては次回の講座で解説します。
2.ローカル変数をコピー・キャプチャする
まずは、冒頭で示した不正な処理をラムダ式のコピー・キャプチャしてみます。
#include <iostream> #include <string> int main() { // ローカル変数 std::string aString="<local string>"; // ラムダ式 auto aLabmda=[=]() { // ここで親関数のローカル変数 aString にアクセスする std::cout << "aLabmda : aString=" << aString << "\n"; }; // ラムダ式の実行 aLabmda(); }
aLabmda : aString=<local string>
このポイントは10行目です。[=]
はラムダ式の定義開始ですが、このカギ括弧の中の = により、コピー・キャプチャが指定されています。これは「ラムダ式内でアクセスしている親関数のローカル変数」と同じ型同じ名前のメンバ変数を自動的に定義し、そこへコピーしてキャプチャするという意味です。
さて、ラムダ式の仕組みはoperator()を持つクラスのオブジェクト(関数オブジェクト)でした。上記サンプルを同等な関数オブジェクトで書いてみます。
// 関数オブジェクト class foo { std::string const aString; public: foo(std::string const iString) : aString(iString) { } void operator()() { std::cout << "foo : aString=" << aString << "\n"; }; } aFoo(aString); // 関数オブジェクトの実行 aFoo();
foo : aString=<local string>
4行目のようにメンバ変数が定義されます。
試しにオブジェクトの大きさを比較してみましょう。メンバ変数としてstd::string型を1つ持っているだけですので、std::string型のaStringとラムダ式lambdaと関数オブジェクトaFooのサイズは同じ筈です。
// サイズを比較してみる std::cout << "sizeof(aString) =" << sizeof(aString) << "\n"; std::cout << "sizeof(aLabmda) =" << sizeof(aLabmda) << "\n"; std::cout << "sizeof(aFoo) =" << sizeof(aFoo) << "\n";
sizeof(aString) =32 sizeof(aLabmda) =32 sizeof(aFoo) =32
確かにその通りになりました。
キャプチャにより暗黙的に定義されたメンバ変数はconst修飾されます
なお、自動的に定義されるメンバ変数は const 修飾されます。コピー・キャプチャした変数は元の変数ではありませんので、もしも、キャプチャした変数を書き換えても元の変数は書き換えられません。ぱっ見、この事実は判りにくいです。そこで、**間違って**書き換えることを避けるために const 修飾されているのだと思います。
そして、この const 修飾を外す mutable という機能もあります。mutableはまた後日解説します。
3.引数をコピー・キャプチャする
実践C++入門講座 第9回目 実引数とローカル変数の保存場所スタックで解説しましたが、関数の引数はローカル変数と同様スタック上に記録され、ローカル変数に良く似た振る舞いをします。(ラムダ式の定義元の関数がreturnすると破棄されます。)
そして、ラムダ式は関数の引数もローカル変数と同様にキャプチャできます。
#include <iostream> #include <string> int main(int, char** argv) { // ローカル変数 std::string aString="<local string>"; // ラムダ式 auto aLabmda=[=]() { // ここで親関数の引数 argv とローカル変数 aString にアクセスする std::cout << "aLabmda : argv[0]=" << argv[0] << " aString=" << aString << "\n"; }; // ラムダ式の実行 aLabmda(); // 関数オブジェクト class foo { char** const argv; std::string const aString; public: foo(char** argv, std::string const iString) : argv(argv), aString(iString) { } void operator()() { std::cout << "foo : argv[0]=" << argv[0] << " aString=" << aString << "\n"; }; } aFoo(argv, aString); // 関数オブジェクトの実行 aFoo(); // サイズを比較してみる std::cout << "sizeof(argv) =" << sizeof(argv) << "\n"; std::cout << "sizeof(aString) =" << sizeof(aString) << "\n"; std::cout << "sizeof(aLabmda) =" << sizeof(aLabmda) << "\n"; std::cout << "sizeof(aFoo) =" << sizeof(aFoo) << "\n"; }
aLabmda : argv[0]=./prog.exe aString=<local string> foo : argv[0]=./prog.exe aString=<local string> sizeof(argv) =8 sizeof(aString) =32 sizeof(aLabmda) =40 sizeof(aFoo) =40
4.メンバ変数はキャプチャされず、thisがキャプチャされる
4-1.メンバ変数がキャプチャされているように見えてキャプチャされていないこと
メンバ関数内で定義したラムダ式にはメンバ変数もコピー・キャプチャして欲しいものです。そのメンバ関数を呼び出した時のオブジェクトが無くなった後もラムダ式が生きている可能性がありますから。
しかし、技術的に難しかったのか、実際にはキャプチャされません。更にしかし、キャプチャされるように見えてしまいます。(オブジェクトよりラムダ式の方が寿命が短い時は問題ありません。そして、ラムダ式の寿命の方が長いケースはレアなため利便性を取ったということかも知れません。)
次のサンプルを見て下さい。getPrint()でラムダ式を返却しています。この中でmDataにアクセスしています。`[=]’でコピー・キャプチャを指定しているので、mDataがコピーされていることを期待しますね。
#include <iostream> class foo { int mData; public: foo(int iData) : mData(iData) { std::cout << "foo::foo() : mData=" << mData << " &mData=" << &mData << "\n"; } auto getPrint() { return [=](){std::cout << "foo::getPrint() : mData=" << mData << " &mData=" << &mData << "\n";}; } }; int main() { foo aFoo(123); auto aPrint=aFoo.getPrint(); std::cout << "--- execute print()\n"; aPrint(); }
しかし、(Wandboxでは)次のような結果になりました。mDataのアドレスに着目して下さい。fooのコンストラクタで表示したアドレスと同じアドレスがaPrint()を実行した時にも表示されています。つまり、ラムダ式の内部メンバ変数へコピーされていないということです。
foo::foo() : mData=123 &mData=0x7ffe085cb210 --- execute print() foo::getPrint() : mData=123 &mData=0x7ffe085cb210
では何故メンバ変数にアクセスできるのかといいますと、メンバ関数には隠しパラメータとしてthisが渡っています。このthisがこっそりキャプチャされているのです。
その証拠にint型が4バイト、ポインタ型が8バイトの環境(Wandboxはこれに該当します)を使って、aPrintのサイズを見るとmDataのサイズである4バイトではなくthisポインタのサイズである8バイトとなります。
更にgetPrint2()メンバ関数で、ローカル変数aDataへmDataをコピーしてから、aDataだけをラムダ式でアクセスするようにしたaPrintaのサイズを確認すると4バイトになります。こちらはthisポインタではなく、4バイトのaDataだけがキャプチャされたということです。
#include <iostream> class foo { int mData; public: // 中略 auto getPrint2() { int aData=mData; return [=](){std::cout << "foo::getPrint2() : aData=" << aData << " &aData=" << &aData << "\n";}; } }; int main() { foo aFoo(123); auto aPrint=aFoo.getPrint(); std::cout << "--- execute aPrint()\n"; aPrint(); auto aPrint2=aFoo.getPrint2(); std::cout << "--- execute aPrint2()\n"; aPrint2(); std::cout << "sizeof(aPrint) =" << sizeof(aPrint) << "\n"; std::cout << "sizeof(aPrint2)=" << sizeof(aPrint2) << "\n"; }
foo::foo() : mData=123 &mData=0x7fffb161a3f0 --- execute aPrint() foo::getPrint() : mData=123 &mData=0x7fffb161a3f0 --- execute aPrint2() foo::getPrint2() : aData=123 &aData=0x7fffb161a3d0 sizeof(aPrint) =8 sizeof(aPrint2)=4
4-2.このことを知らないと「やらかしてしまう」かもしれないこと
4-1の事実を把握していない場合、ラムダ式をメンバ関数で返却する際にメンバ変数をコピー・キャプチャしたつもりでコピー・キャプチャできていないまま返却するかも知れません。(私も機会があったら既にやらかしていたかもしれません。やらかす前にここを読んだので済んでいるだけです。江添氏に感謝。)
#include <iostream> class foo { int mData; int dummy[10]; public: foo(int iData) : mData(iData) { std::cout << "foo::foo() : mData=" << mData << " &mData=" << &mData << "\n"; } ~foo() { mData=999; std::cout << "foo::~foo()\n"; } auto getPrint() { return [=](){std::cout << "foo::getPrint() : mData=" << mData << " &mData=" << &mData << "\n";}; } }; auto getFooPrint(int iData) { foo aFoo(iData); auto aPrint=aFoo.getPrint(); aPrint(); return aPrint; } int main() { auto aPrint=getFooPrint(123); std::cout << "--- execute aPrint()\n"; aPrint(); }
getFooPrint()関数でfooクラスのオブジェクトを作り、そのgetPrint()を呼び出して、メンバ変数mDataをコピー・キャプチャした(つもり)のラムダ式を返却しています。
本当にコピー・キャプチャであれば問題ないコードです。しかし、実際にコピー・キャプチャされるものは隠しパラメータである this ですので mData はgetFooPrint()関数内でローカル変数として作ったfooオブジェクトのメンバ変数のままです。
つまり、getFooPrint()関数からreturnしたあとは、ラムダ式が保持するthisの先のfooオブジェクトはデストラクトされています。(このタイミングでデストラクタが呼ばれます。)
従って、ラムダ式がアクセスする mData は不正なメモリです。何が入っているか分かりません。
実際、fooクラス内の dummy[10]配列のサイズを変えたり、コンパイラのバージョンを変えたりすると結果が変わってしまいます。
std::coutのoperator<<()呼び出しでスタック上のデータが上書きされますが、dummyのサイズを変えることで、mDataがスタック上のどこに記録されるのかが変わり、その結果operator<<()による上書きの影響が変わるからです。
foo::foo() : mData=123 &mData=0x7fff8b576b80 foo::getPrint() : mData=123 &mData=0x7fff8b576b80 foo::~foo() --- execute aPrint() foo::getPrint() : mData=6297632 &mData=0x7fff8b576b80
更に同じプログラムを Visual C++ 2017でやってみたところ、嫌な結果でした。
色々やってみたのですが、常にデストラクタで設定している 999 が表示されました。そして、デストラクタで設定しなければ元の値をアクセスできました。となると、このバグに気がつくのは至難の業です。
Visual C++では、fooオブジェクトがデストラクトされているにも関わらずfooオブジェクトに割り当てられていたメモリが開放されていないようです。なので最後に設定した値が残っているということのようです。
しかし、これは保証された動作ではありません。単にバグが顕在化しにくくなっただけです。
foo::foo() : mData=123 &mData=000000E2FD8FFD78 foo::getPrint() : mData=123 &mData=000000E2FD8FFD78 foo::~foo() --- execute aPrint() foo::getPrint() : mData=999 &mData=000000E2FD8FFD78
5.グローバル変数とstaticなローカル/メンバ変数はキャプチャしない
グローバル変数はキャプチャするまでもなく関数終了後も有効ですし、更に特殊なことをしなくても普通にアクセスできますのでキャプチャされません。
では、グローバル変数と同様に静的領域に記録されるstaticなローカル変数はどうなるのでしょう?
結論から言うと、アクセスはできますがグローバル変数同様キャプチャされない(メンバ変数が定義されない)ようです。
実は通常のグローバル変数とstaticなローカル変数の違いはアクセス制限だけです。クラスのメンバのprivateやpublicと同様なのです。これらはバグを発生しにくくするためにアクセスさせたくない領域へのアクセスを制限するものです。もし、アクセスしていたらコンパイラが検出してエラーにするという仕組みです。
さて、関数内で定義されたラムダ式に対してstaticなローカル変数をアクセスできないように制限したいケースはまずないと思います。ですので、単にアクセス制限していないというだけですね。
同じことを関数オブジェクトで行う術(構文)はないので、ラムダ式ならではの機能です。
2.のサンプルのaStringをstaticにした時の実行結果です。
sizeof(aString) =32 sizeof(aLabmda) =1 sizeof(aFoo) =32
ラムダ式はメンバ変数が1つも自動定義されないのでデフォルトのサイズ1となっています。関数オブジェクトfooは明示的にメンバ変数を定義してますので元のサイズと変わりません。
staticなメンバ変数も実験してみましたが、staticなローカル変数と同様でした。意味的にはstaticなローカル変数とほぼ同じですから当然ですね。
Wandboxで確認する。
6.まとめ
今回は、ラムダ式のコピー・キャプチャについて詳しく解説してみました。ラムダ式を定義している関数よりラムダ式の方が寿命が長い時は原則としてコピー・キャプチャを使いましょう。
ただし、キャプチャできるものはローカル変数と関数の引数だけです。メンバ変数は一見キャプチャされているようにみえてコピー・キャプチャされていません。5.で解説したようになかなか危険ですのでメンバ変数を誤ってコピー・キャプチャしたつもりにならないよう要注意です。
さて、次回はたどり着けなかった参照キャプチャを解説します。お楽しみに