yohhoyの日記

技術的メモをしていきたい日記

std::functionのムーブ操作はムーブするとは言っていない

C++標準ライブラリstd::functionのムーブ操作(ムーブコンストラクタ/ムーブ代入演算子)は保持する呼出し可能なオブジェクト(callable object)を必ずしもムーブせず、条件によってはコピーが行われる可能性がある。( ゚Д゚)ハァ?

C++標準ライブラリ仕様ではstd::functionムーブ操作の結果として、下記の2種類の選択肢を与える:

  • 呼出し可能オブジェクトの所有権がムーブ先に移動し、ムーブ元のstd::functionオブジェクトは呼出し不可能となる。
  • 呼出し可能オブジェクトはコピーされ、ムーブ先/ムーブ元いずれのstd::functionオブジェクトも呼出し可能となる。

下記コードではラムダ式によるクロージャオブジェクトのコピー/ムーブ判別に、スマートポインタstd::shared_ptrの参照カウント(use_count())を利用している。*1

// 動作追跡用スマートポインタ(参照カウントにのみ着目)
auto sp = std::make_shared<int>(42);

// fはスマートポインタを保持するクロージャオブジェクトを保持する
std::function<long()> f = [sp=std::move(sp)]{ return sp.use_count(); };
std::cout << f() << " ";  // 1を出力

// fからgへのムーブ操作
std::function<long()> g = std::move(f);
// クロージャオブジェクトがムーブされた場合は参照カウント=1に
// クロージャオブジェクトがコピーされた場合は参照カウント=2となる
std::cout << g() << " ";  // ★1 or 2を出力

// ★ムーブ後のfを呼び出す
try {
  std::cout << f() << " ";  // コピーされた場合は2を出力
  // 注: ソースコード上は"ムーブ後"のためこの動作を期待すべきではない
} catch (const std::bad_function_call&) {
  // ムーブ操作によりfが無効となりbad_function_call例外が送出される
  std::cout << ". ";
}

// ムーブ後のfを明示的にクリアする
f = nullptr;
std::cout << g();  // 1を出力

GCC 10.1による実行結果:

1 1 . 1

Clang 10.0による実行結果:

1 2 2 1

C++標準規格ではサイズが小さい呼出し可能オブジェクトを格納する際は動的メモリ確保を避けるよう推奨(encourage)しており、標準ライブラリ実装では Small Buffer Optimization(SBO) 技法(→id:yohhoy:20120428)が用いられる。GCCとClangではSBO利用の閾値が異なっており、前述の実行結果になったと考えられる。

auto sp = std::make_shared<int>(42);
struct { char x[1000]; } large;

// データメンバにlargeを含む大きなサイズのクロージャオブジェクトを保持するため
// SBOは無効となり動的確保メモリ上にオブジェクト構築されると期待できる
std::function<long()> f = [sp=std::move(sp), large] {
    (void)large;  // (largeを使った処理)
    return sp.use_count();
  };

// fからgへのムーブ操作
std::function<long()> g = std::move(f);
std::cout << g();  // 1を出力(と期待できる)

std::functionクラステンプレートはムーブ元オブジェクトが “有効だが未規定な状態(valid but unspecified state)”(→id:yohhoy:20120616) としか要求せず、また演算子オーバーロードoperator()仕様をふまえるとムーブコンストラクタ/代入演算子がコピー/ムーブ処理のいずれを行っても標準準拠といえる。もうちょっと何とかならんかったのか。*2

std::moveの代わりに [C++]std::exchangeによるmoveしてリセットするイディオムの御紹介 - 地面を見下ろす少年の足蹴にされる私 で紹介しているイディオムを用いると、C++プログラマの意図通りの動作を保証できる。

// fからgへの確実なムーブ処理
std::function<long()> g = std::exchange(f, {});

C++17 20.3.25, 23.14.13.2.1/p5-6, p16, p18-19より引用(下線部は強調)。

valid but unspecified state
a value of an object that is not specified except that the object's invariants are met and operations on the object behave as specified for its type
[Example: If an object x of type std::vector<int> is in a valid but unspecified state, x.empty() can be called unconditionally, and x.front() can be called only if x.empty() returns false. -- end example]

function(function&& f);
5 Postconditions: If !f, *this has no target; otherwise, the target of *this is equivalent to the target of f before the construction, and f is in a valid state with an unspecified value.
6 Throws: shall not throw exceptions if f's target is a specialization of reference_wrapper or a function pointer. Otherwise, may throw bad_alloc or any exception thrown by the copy or move constructor of the stored callable object. [Note: Implementations are encouraged to avoid the use of dynamically allocated memory for small callable objects, for example, where f's target is an object holding only a pointer or reference to an object and a member function pointer. -- end note]

function& operator=(function&& f);
16 Effects: Replaces the target of *this with the target of f.

function& operator=(nullptr_t) noexcept;
18 Effects: If *this != nullptr, destroys the target of this.
19 Postconditions: !(*this).

関連URL

*1:ラムダ式でキャプチャした変数は、クロージャ型の非staticデータメンバとして保持される(8.1.5.2/p6, p15)。クロージャ型はdefaultedなコピー/ムーブコンストラクタを生成する(8.1.5.1/p11)。スマートポインタ std::shared_ptr では、ムーブコンストラクタで所有権を移動すると保証している(23.11.2.2.1/p23)。

*2:ソースコードが表現するセマンティクスは “ムーブ後のオブジェクト” となるため、プログラマはコピー処理が行われることを期待すべきでない。というか、コピーが必要ならソースコードにもそう書くに決まっと(#゚Д゚)ルァ!!