最終更新:2026-10-12
C++ FnOnce を手書きする¶
《C++那些事》C++ 版 Fn・FnOnce・FnMut を手書きする¶
C++ を書く人なら std::function を使ったことがあるでしょう。でも、こんな疑問を持ったことはありませんか:std::function はコピーできて何度でも呼べる、では「一度しか呼べない」コールバックが欲しいときは? ワンショットイベントや move-only のタスクキューでは std::function は不向きです——コピー可能であることを要求するため、unique_ptr のようなものは詰め込めません。
Rust のこの部分の設計は実に見事です:Fn/FnMut/FnOnce という 3 つの trait が、クロージャの可変性と呼び出し回数を実にはっきり区別しています。そこで問題です:C++ で同じものを手書きできないか? というわけで本編です。
1.目標¶
実装するのは 3 つのクラステンプレートです:
FnOnce<Ret(Args...)>:一度だけ呼べる。呼んだ後は状態が消費(move)され、move-only キャプチャをサポート;FnMut<Ret(Args...)>:複数回呼べる。キャプチャは可変;Fn<Ret(Args...)>:複数回呼べる。キャプチャは不変で、コピー可能。
3 つの関係も Rust を踏襲します:Fn は FnMut の部分集合、FnMut は FnOnce の部分集合。つまり Fn にできることは、FnMut・FnOnce のインターフェースならどちらでも受け取れます。
2.設計¶
核は相変わらずあの型消去です:基底クラスが仮想の invoke を持ち、派生クラスが本当の呼び出し可能オブジェクトを包みます。違いは 3 つの細部にあります:
(1)FnOnce::invoke の引数は && で、呼び終わったら包んだクロージャを move して持ち去ります。一度呼んだ後の再呼び出しは即アサートでエラー;
(2)FnMut::invoke は const ではなく、キャプチャの変更を許可;
(3)Fn::invoke は const で、同時に派生クラスは clone を提供し、外側の Fn のコピーを支えます。
template <class>
class FnOnce;
template <class Ret, class... Args>
class FnOnce<Ret(Args...)> {
public:
FnOnce() = default;
template <class F>
FnOnce(F functor) : impl_(std::make_unique<Model<F>>(std::move(functor))) {}
Ret operator()(Args... args) && {
return std::move(*impl_).invoke(std::forward<Args>(args)...);
}
explicit operator bool() const { return impl_ != nullptr; }
private:
struct Concept {
virtual ~Concept() = default;
virtual Ret invoke(Args&&... args) && = 0;
};
template <class F>
struct Model final : public Concept {
Model(F functor) : functor_(std::move(functor)) {}
Ret invoke(Args&&... args) && override {
return std::move(functor_)(std::forward<Args>(args)...);
}
F functor_;
};
std::unique_ptr<Concept> impl_;
};
ここには面接官につかまえられやすいポイントがあります:operator() は**右値に対してのみ呼べる**(&& 修飾)よう制限されており、std::move(*impl_) と合わせてセマンティクスを完全に固定します——呼びたいですか? まず自分を move してから。これが「消費型コールバック」です。
FnMut と Fn は同じ骨格の上で制限を緩めます:
template <class Ret, class... Args>
class FnMut<Ret(Args...)> {
public:
Ret operator()(Args... args) {
return impl_->invoke(std::forward<Args>(args)...); // 非 const,可改捕获
}
// Concept::invoke 非 const,其余同 FnOnce
};
template <class Ret, class... Args>
class Fn<Ret(Args...)> {
public:
Fn(const Fn& other) : impl_(other.impl_->clone()) {} // 拷贝走 clone
Ret operator()(Args... args) const {
return impl_->invoke(std::forward<Args>(args)...); // const
}
// Concept::invoke 为 const,并提供 virtual clone
};
3.変換関係¶
Rust のルールをそのまま写します:Fn は FnMut に変換でき、FnMut は FnOnce に変換できますが、逆方向は不可。実装としては、FnMut/Fn により厳しい型から構築するインターフェースを追加し、内部の impl を引き継ぐだけです。こうすれば、FnOnce を受け取るインターフェース(例えばワンショットタスクの投入)はどんなクロージャでも受けられ、Fn を受け取るインターフェース(例えば繰り返し発火するコールバック)は不変なものだけを受け取ります。
4.テスト¶
#include <cassert>
#include <memory>
int main() {
// move-only 捕获:std::function 塞不进去,FnOnce 没问题
auto ptr = std::make_unique<int>(42);
FnOnce<int()> take = [p = std::move(ptr)] { return *p; };
assert(std::move(take)() == 42);
// 只能调用一次:再次调用断言失败
// std::move(take)(); // crash: impl_ == nullptr
// FnMut:计数器累加
int count = 0;
FnMut<int()> mut = [&count] { return ++count; };
mut();
assert(mut() == 2);
// Fn:可拷贝、可重复调用
Fn<int()> fn = [] { return 1; };
auto copy = fn;
assert(fn() == copy());
return 0;
}
実行したら、ついでに std::function と比べてみてください:unique_ptr をキャプチャして std::function に詰めるのはコンパイルすら通りません(CopyConstructible が要求される)。これこそが、この実装が存在する意味です。
完全なコードはすでに星球(コミュニティ)に置いてあります。興味があればそこから入手してください。最後に 2 つの思考問題を残します:
(1)なぜ FnOnce::operator() には && 修飾が必要なのか、なければどうなるか?
(2)Fn のコピーコンストラクタは、なぜ unique_ptr を直接コピーせず必ず clone を経るのか?
関連トピック¶
- C++ gRPC ミニプロジェクト | C++那些事
- C++ プロセス間通信実践 | C++那些事
- C++ 短縮URLサービス Demo | C++那些事
- C++17 と SQLite でディレクトリサイズを集計 | C++那些事
- Google Benchmark 性能テスト | C++那些事
- C++20 コルーチン HTTP サーバ | C++那些事
- Protobuf ZeroCopy シリアライズ | C++那些事
- pybind11 で Python を高速化 | C++那些事
- C++ スタック型電卓の設計 | C++那些事