最后更新:2026-10-13
手撕 C++ FnOnce¶
《C++那些事》手撕C++版本的Fn、FnOnce、FnMut¶
相信写 C++ 的朋友都用过 std::function,但是你有没有想过一个问题:std::function 可以拷贝、可以反复调用,那我想要一个"只能调用一次"的回调呢?比如一次性事件、move-only 的任务队列,std::function 就不太合适了——它要求可拷贝,unique_ptr 之类的东西塞不进去。
Rust 在这块的设计就非常漂亮:Fn/FnMut/FnOnce 三个 trait 把闭包的可变性与调用次数区分得明明白白。那么问题来了:能不能用 C++ 手撕一套出来?于是便有了本篇。
1.目标¶
我们要实现三个类模板:
FnOnce<Ret(Args...)>:只能调用一次,调用后状态被消费(move),支持 move-only 捕获;FnMut<Ret(Args...)>:可多次调用,捕获可变;Fn<Ret(Args...)>:可多次调用,捕获不可变,可拷贝。
三者的关系也照抄 Rust:Fn 是 FnMut 的子集,FnMut 是 FnOnce 的子集。也就是 Fn 能干的活,FnMut、FnOnce 的接口都能接。
2.设计¶
核心还是那一套类型擦除:基类存一个虚的 invoke,派生类包住真正的可调用对象。区别在于三个细节:
(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 可以转 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),而这正是这套实现存在的意义。
完整代码已经放在星球,感兴趣获取即可。最后留两个思考题:
(1)为什么 FnOnce::operator() 要加 && 限定,不加会怎样?
(2)Fn 的拷贝构造为什么必须走 clone 而不是直接拷贝 unique_ptr?
相关主题¶
- C++ gRPC 小项目 | C++那些事
- C++ 进程间通信实战 | C++那些事
- C++ 短链接服务 Demo | C++那些事
- C++17 与 SQLite 统计目录大小 | C++那些事
- Google Benchmark 性能测试 | C++那些事
- C++20 协程 HTTP 服务器 | C++那些事
- Protobuf ZeroCopy 序列化 | C++那些事
- pybind11 加速 Python | C++那些事
- C++ 栈式计算器设计 | C++那些事