ことの発端
C++ で関数型プログラミングをやっているとラムダ式は避けて通れないのですが、外部変数をキャプチャする際に、どうすれば無駄なコピーを減らせるかということで、プログラムを書いて検証しました。
勿論、参照が一番効率が良いのですが、非同期プログラミングと関数型プログラミングが合体すると参照の使用は ダングリングポインタ の温床になりますので、参照でのキャプチャは細心の注意が必要です。
検証プログラム
# include <iostream>
using namespace std;
struct A {
A() { cout << "A::ctor default" << endl; }
A(const A&) { cout << "A::ctor copy" << endl; }
A(A&&) { cout << "A::ctor move" << endl; }
~A() { cout << "A::dtor" << endl; }
void f() const { cout << "A::f" << endl; }
};
template <typename T> void baz(T&& x) {
cout << "baz()" << endl;
auto xx = std::move(x);
xx.f();
}
template <typename T> void bar(T&& x) {
cout << "bar()" << endl;
baz(std::forward<T>(x));
}
template <typename T> auto foo(T&& x) {
cout << "foo()" << endl;
return [???](){ // <- ここのキャプチャをどうするかという問題です
bar(x);
};
}
int main(void){
{
auto f1 = []{
cout << "#1" << endl;
const A a;
return foo(a);
}();
f1();
}
{
auto f2 = []{
cout << "#2" << endl;
A a;
return foo(a);
}();
f2();
}
{
auto f3 = []{
cout << "#3" << endl;
A a;
return foo(std::move(a));
}();
f3();
}
}
やりたきことは、A というオブジェクトを foo() -> bar() -> baz() に転送するということです。そして、bar() は foo() で定義するラムダ式で呼び出されているので、A をどうやって転送すると安全に A のコピーを少なくできるかという問題です。
更にややこしいのですが、main() で f1 f2 f3 を定義していますが、これは関数を返す関数です。(foo()も関数を返却する関数です)つまり、f1 f2 f3 を実行する際にはfoo() を呼び出す手前で生成された A は破棄されていることになります。
参照でキャプチャ
template <typename T> auto foo(T&& x) {
cout << "foo()" << endl;
return [&x](){
bar(std::forward<T>(x));
};
}
# 1
A::ctor default
foo()
A::dtor
# 1 call
bar()
baz()
A::ctor copy <- 破棄した A をコピーしている!
A::foo
A::dtor
# 2
A::ctor default
foo()
A::dtor
# 2 call
bar()
baz()
A::ctor move <- 破棄した A をムーブしている!
A::foo
A::dtor
# 3
A::ctor default
foo()
A::dtor
# 3 call
bar()
baz()
A::ctor move <- 破棄した A をムーブしている!
A::foo
A::dtor
今回のケースでは baz() まで A が転送されてる感じですが、 A のインスタンスは既に破棄されているため、A::ctor copy や A::ctor move では壮絶な事態になっています。冒頭で触れたダングリングポインタを触っていることになります。この検証プログラムでは落ちませんが、一番ダメなパターンです。
しかしながら、ダングリングポインタが発生しないケースにおいては最適かと思います。(#2のbaz()内が moveされちゃうのがちょっと悩ましい感じではありますが)
コピーでキャプチャ
そこで、安心安全なコピーを使います。
template <typename T> auto foo(T&& x) {
cout << "foo()" << endl;
return [x](){
bar(std::move(x));
};
}
# 1
A::ctor default
foo()
A::ctor copy
A::dtor
# 1 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
# 2
A::ctor default
foo()
A::ctor copy
A::dtor
# 2 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
# 3
A::ctor default
foo()
A::ctor copy <- ムーブできるのにコピーになっている
A::dtor
# 3 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
ダングリングポインタは発生していないのですが、move できるのに copy しているので、これを何とかしたいのですが C++11 ではこれが限界。
<- ツッコミどころ がありますが、後で回収するので、ここではスルーします。
std::move でキャプチャ
C++14 からは初期化キャプチャ(init-capture)が登場したので、キャプチャする変数を宣言する際に初期化することができるようになりました。
そこで、キャプチャ指定をする箇所で move してみます。
template <typename T> auto foo(T&& x) {
cout << "foo()" << endl;
return [x = std::move(x)](){
bar(std::move(x));
};
}
# 1
A::ctor default
foo()
A::ctor copy
A::dtor
# 1 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
# 2
A::ctor default
foo()
A::ctor move <- これでいいのか?
A::dtor
# 2 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
# 3
A::ctor default
foo()
A::ctor move <- 良い感じ!
A::dtor
# 3 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
これはこれで良いのですが、気になる点として #2 でムーブされるのが微妙。これだと foo() が引数を破壊するかを知っていないと予期しない問題が発生するかも知れません。
やはり、ここは、右辺値ならムーブ、それ以外はコピーとしたいところです。
と、まぁ、ここは 初期化キャプチャ の問題では無いですけどね。
std::forward でキャプチャ
template <typename T> auto foo(T&& x) {
cout << "foo()" << endl;
return [x = std::forward<T>(x)](){
bar(std::move(x));
};
}
# 1
A::ctor default
foo()
A::ctor copy
A::dtor
# 1 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
# 2
A::ctor default
foo()
A::ctor copy
A::dtor
# 2 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
# 3
A::ctor default
foo()
A::ctor move
A::dtor
# 3 call
bar()
baz()
A::ctor copy <- ツッコミどころ
A::foo
A::dtor
A::dtor
これで良い感じになった気がしますが、どうにも ツッコミどころ が気になります。
ツッコミどころ
最後に伏線を回収します。
結果 の <- ツッコミどころ にあるように baz() が全てコピーになっている点が気に入りません。
これはラムダ式でキャプチャした変数そのものには const 属性は付かないのですが、呼び出される関数がconst属性のため、キャプチャした変数を変更することができません。
そこでラムダ式に mutable 属性を付与することで、キャプチャした変数をmoveすることができます。1
template <typename T> auto foo(T&& x) {
cout << "foo()" << endl;
return [x = std::forward<T>(x)]() mutable {
bar(std::move(x));
};
}
# 1
A::ctor default
foo()
A::ctor copy
A::dtor
# 1 call
bar()
baz()
A::ctor move
A::foo
A::dtor
A::dtor
# 2
A::ctor default
foo()
A::ctor copy
A::dtor
# 2 call
bar()
baz()
A::ctor move
A::foo
A::dtor
A::dtor
# 3
A::ctor default
foo()
A::ctor move
A::dtor
# 3 call
bar()
baz()
A::ctor move
A::foo
A::dtor
A::dtor
これで、全てが丸く収まったんじゃないかと。
所感
最初の参照をキャプチャするケース以外は「動作はする」のが厄介だなと思います。
まぁ、キャプチャする変数がプリミティブ型なら良いですが、巨大なオブジェクトになると、値の転送方法はパフォーマンスに大きな影響を与えるため、取り扱いには注意が必要ですね。
ちなみに、初期化キャプチャでの template の パラメータパック の展開は C++20 までオアズケです。(std::tupleを使った無理矢理な感じの手法がありますが、tuple からパラメータパックに戻すために std::apply が必要になり、 std::apply は C++17 以降で使用可能です。まぁ、自作すれば良いといえば良いけど。。。)
template <typename...ARGS> auto f(ARGS&&...args) {
return [...args = std::forward<ARGS>(args)]{
x(std::move(args)...);
}
}
template <typename...ARGS> auto f(ARGS&&...args) {
return [tpl = std::make_tuple(std::forward<ARGS>(args)...)]{
std::apply([](auto&&...args){
x(std::move(args)...);
}, tpl);
}
}
あ、あと案外見落としがち2なのが、関数オブジェクトそのもののコピーです。これも内包している変数をコピーしますので、注意が必要です。
template <typename F> void func(F f) {
f();
}
int main() {
A a;
auto f = [a = std::move(a)](){
a.f();
};
func(f);
}
A::ctor default
A::ctor move
A::ctor copy
A::f
A::dtor
A::dtor
A::dtor