プログラミング
std::functionとcopyable_functionの相互変換
Interconverting std::function with copyable_function (quuxplusone.github.io)
要約
C++26で導入されたstd::copyable_functionは、std::functionの設計上の問題を解決する新しい型です。しかし、std::functionとstd::copyable_functionの間で相互変換を行う際に、実装によっては意図しないヒープ割り当てが発生し、パフォーマンスの低下やメモリリークのような状態を引き起こす可能性があります。この問題は、特に異なるSTL実装間での相互運用時に顕著になるため、注意が必要です。
全文翻訳
C++11は、型消去された呼び出し可能オブジェクトコンテナとしてstd::functionを導入しました。functionはいくつかの点で設計が悪かったのです。めったに使われない「go fish」APIはすべてのユーザーを肥大化させます。制御対象のオブジェクトのoperator()が変更可能であっても、operator() constを宣伝します。前置条件違反となるべきことを例外を投げることで処理するため、これもすべてのユーザーを肥大化させ、operator()が決してnoexceptになり得ないことを意味します。そこでC++26は、std::copyable_functionを追加することでこれらの問題を修正しました。(copyable_function<bool()>がcopyable_function<void()>に暗黙的に変換可能であること、boolに文脈的に変換可能であること、nullptrから代入可能であることなど、functionのいくつかの最適とは言えない設計上の決定は維持されています。)P2548「copyable_function」(2023年)には、実装者向けの次のガイダンスが含まれています。実装者は、copyable_functionのインスタンスから互換性のあるmove_only_functionのインスタンスへの変換時に、追加の割り当てを実行しないことが推奨されますが、これは品質の問題として残されています。逆に、move_only_functionからcopyable_functionへの変換は許可されないことに注意してください。copyable_functionはコピー可能な呼び出し可能オブジェクトのみを保持でき、move_only_functionはコピー可能ではありません。しかし、copyable_functionはstd::functionと相互変換可能です — 両方向で!これらの型消去ラッパーはどちらも、コピー可能で呼び出し可能なものから構築でき、それ自体もコピー可能で呼び出し可能です。したがって、次のようなことができます。std::function<int()> f = []{ return 5; }; std::copyable_function<int() const> cf = std::move(f); std::function<int()> g = std::move(cf); fからcfへのムーブは、次のように実装されることを期待するでしょう。そしてgへのムーブバックも同様です。しかし、copyable_functionがfunctionについて知らない場合、fを単なるコピー可能な、呼び出し可能な型の古いrvalueとして扱うかもしれません。それはヒープ上にfのコピーをムーブして所有するかもしれません。そしてgへのムーブバックも同様です。functionはcfを単なるコピー可能な、呼び出し可能な型の古いrvalueとして扱い、ヒープ上にcfのコピーをムーブして所有するかもしれません。このムーブ操作は依然として比較的パフォーマンスが良いです — 各構築でO(1)の作業しか行いません — しかし、n回のそのような構築のシーケンスの後、長さnの呼び出し可能オブジェクトの「連結リスト」のようなものが生成されます。gを呼び出すたびに、gのoperator()は、制御下のcopyable_functionのoperator()を呼び出し、それが制御下のfunctionのoperator()を呼び出し、それが制御下のラムダ(これらの図の緑色のオブジェクト)のoperator()を呼び出します。現在(2026年7月)、libstdc++はBig Three STLベンダーの中で唯一C++26のcopyable_functionを実装しています。そして現在、彼らは上記のように図示された「悪い」動作を実装しています。次の作り話のようなシナリオを考えてみてください。std::functionに格納された何らかの「ハンドラコールバック」があるとします。ユーザー入力に基づいて、例えばHandler = std::function<bool(char)>; Handler update(Handler h, bool negate) { if (negate) { h = [f = std::move(h)](char c) { return !f(c); }; } return h; } ~~~~ Handler h = original_handler; while (~~~~) { ~~~~ h = update(std::move(h), (input == '!')); } このように、hはupdateにムーブされ、再びムーブアウトされます(暗黙のムーブのおかげで)。これはstd::functionのムーブコンストラクタを使用しますが、ほとんどのムーブコンストラクタと同様に、高速で割り当てを行いません。しかし、同僚がこのコードの一部をC++26に更新し、std::functionの代わりにstd::copyable_functionを使用することを合理的に好むようになったと仮定しましょう。同僚がコードベース全体をcopyable_functionを使用するように更新した場合、問題はありません。しかし、彼が自分の部分だけを更新し、新しいcopyable_functionベースのコードが他のすべての人の古いfunctionベースのコードと相互運用する必要がある場合はどうでしょうか?その場合、次のようになります。using OldHandler = std::function<bool(char)>; using NewHandler = std::copyable_function<bool(char)>; OldHandler update(OldHandler h, bool negate); // C++26以前の呼び出し元の便宜のために変更なし ~~~~ NewHandler h = original_handler; while (~~~~) { ~~~~ h = update(std::move(h), (input == '!')); } これは、その「連結リスト」シナリオを引き起こし、hの呼び出し演算子は、negateがfalseの場合でさえ、updateを通過するたびに遅くなります。私はこの小さなベンチマーク(Godbolt)を作成しました。#include <chrono> #include <cstdio> #include <functional> size_t now() { static const auto epoch = std::chrono::high_resolution_clock::now(); auto tp = std::chrono::high_resolution_clock::now(); return std::chrono::duration_cast<std::chrono::microseconds>(tp - epoch).count(); } void print_elapsed_time(int i, size_t call_started) { printf("Invocation on the %dth iteration took %zu us\n", i, now() - call_started); } using OldHandler = std::function<void(int, size_t)>; using NewHandler = std::copyable_function<void(int, size_t) const>; OldHandler update(OldHandler h, bool negate) { if (negate) { /* ここで何かを行うが、この例では重要ではない */ } return h; } int main() { NewHandler handler = print_elapsed_time; for (int i = 0; i < 160'000; ++i) { handler = update(std::move(handler), false); if (i % 10'000 == 0) { handler(i, now()); } } } このプログラムをlibstdc++(GCC 16時点)で実行すると、次のような出力が表示されるはずです。Invocation on the 0th iteration took 0 us Invocation on the 10000th iteration took 180 us Invocation on the 20000th iteration took 296 us Invocation on the 30000th iteration took 365 us [...] Invocation on the 130000th iteration took 1057 us Invocation on the 140000th iteration took 1108 us Invocation on the 150000th iteration took 1202 us handler(i, now())を呼び出すたびに、handlerに連なる「呼び出し可能オブジェクトの連結リスト」が以前より10,000要素長くなり、ポインタをたどるのに約100マイクロ秒長くなります。もちろん、必要以上に多くのキロバイトのメモリも保持し続けます。これは「論理的なメモリリーク」です。そのメモリは技術的にはまだアクセス可能で、hのデストラクタによって解放されますが、hが生きている限り、プログラムの「論理的な」状態に変化がないにもかかわらず、RAM使用量が時間とともに増加し続けることになります。2026年7月現在、Microsoft STLはmove_only_functionとfunctionをそのような「論理的なメモリリーク」なしで相互運用させており、彼らがそれを実装すれば、copyable_functionも同様に回避すると確信しています。libstdc++の実装は将来改善される可能性があります。一方、libc++はまだmove_only_functionもcopyable_functionも実装していないため、この問題について考える時間は十分にあります。ちなみに、型消去型を自分で書く際に、この落とし穴のより単純なバージョンにも注意してください。Printable-holding-an-intをコピーしたときに、Printable-holding-a-Printable-holding-an-intではなく、別のPrintable-holding-an-intが得られるようにしてください。Printable&から、const Printable&から、Printable&&から、そしてconst Printable&&から構築する場合でも、それが保持されていることを確認するために単体テストを書いてください。この問題を解決することは、STLのように、相互にうまく連携する必要のある型消去型が複数ある場合、より困難になります。可能であれば、そのような状況に陥らないようにしてください。しかし、そうしなければならない場合は、この落とし穴に注意してください。脚注:C++26でfunctionをcopyable_functionに置き換えるべきでしょうか?そう言う人もいるでしょう。個人的には、両方とも置き換えるべきで、自分で型消去呼び出し可能オブジェクトを書くべきだと言います。100行もかかりません!完全に新規のC++26コードであれば、ベンダーが実装したら、functionよりもcopyable_functionを推奨します。同じブラウンフィールドのコードベースで両方の型を混在させることはお勧めしません。どちらかを選んでそれに従い、コードベース全体を一度に更新できるポイントを目指します。投稿日: 2026-07-26 ベンチマーク 実装の乖離 落とし穴 標準ライブラリトリビア 型消去