4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ラムダ式のキャプチャで値のコピーを減らしたい

Last updated at Posted at 2022-02-03

ことの発端

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 copyA::ctor move では壮絶な事態になっています。冒頭で触れたダングリングポインタを触っていることになります。この検証プログラムでは落ちませんが、一番ダメなパターンです。

しかしながら、ダングリングポインタが発生しないケースにおいては最適かと思います。(#2baz()内が 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::applyC++17 以降で使用可能です。まぁ、自作すれば良いといえば良いけど。。。)

C++20
template <typename...ARGS> auto f(ARGS&&...args) {
  return [...args = std::forward<ARGS>(args)]{
    x(std::move(args)...);
  }
}
C++17
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
  1. ラムダ式は関数オブジェクトなので、キャプチャした変数は関数オブジェクトのメンバ変数となります。

  2. 「見落としがち」な人は自分です。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?