C++中std::thread如何存储构造传入的可变参数并完成调用?
你觉得tuple必须手动指定类型、没法存可变参数,是因为你只见过手动声明tuple的场景,std::thread和std::function的实现根本不需要“绕过”这个限制——靠模板参数自动推导 + 类型擦除 + 编译期索引展开三个特性就能完整实现整套逻辑,没有任何黑魔法。
参数是怎么存进tuple的
你写std::thread t(printStuff, "beer", 900000000);的时候,std::thread的构造函数是可变参数模板,编译器会根据你传入的实参自动推导出所有参数的类型:
- 传入的
printStuff是函数类型void(const char*, long),衰变后存为函数指针 - 传入的
"beer"是const char[5]类型的字符串字面量,衰变后存为const char* - 传入的
900000000是整数,匹配函数参数类型后存为long
推导完成后,构造函数内部直接用这组推导出来的类型,实例化一个对应类型的tuple存所有参数,全程不需要你手动写tuple的模板参数。
为了把不同类型的tuple和可调用对象统一存在std::thread对象里,实现会用一层类型擦除:定义一个无模板的基类,基类里声明纯虚的入口方法,再写一个带模板参数的派生类,派生类里持有可调用对象和对应类型的tuple,重写入口方法实现实际调用。std::thread内部只需要持有一个基类指针,就能藏住所有具体类型细节。
给你写个极简的可运行简化版实现,一看就懂:
#include <tuple> #include <memory> #include <iostream> #include <thread> #include <functional> // for std::apply class SimpleThread { private: // 类型擦除基类,不持有任何具体类型信息 struct StateBase { virtual void entry() = 0; virtual ~StateBase() = default; }; // 派生类,持有具体类型的函数和参数tuple template<class F, class... Args> struct State : StateBase { F func; std::tuple<std::decay_t<Args>...> stored_args; // 自动匹配参数类型的tuple State(F&& f, Args&&... args) : func(std::forward<F>(f)), stored_args(std::forward<Args>(args)...) {} void entry() override { // 展开tuple里的参数调用目标函数 std::apply(func, stored_args); } }; std::unique_ptr<StateBase> state_; std::thread native_thread_; public: template<class F, class... Args> explicit SimpleThread(F&& f, Args&&... args) { // 这里编译器已经推导出F和Args...的具体类型,直接实例化State即可 state_ = std::make_unique<State<F, Args...>>( std::forward<F>(f), std::forward<Args>(args)... ); // 启动系统线程,入口就是state_->entry native_thread_ = std::thread([this](){ state_->entry(); }); } void join() { native_thread_.join(); } // 省略detach、移动构造、禁止拷贝等逻辑 }; // 测试你写的例子 void printStuff(const char* c, long x) { std::cout << x << " bottles of " << c << " on the wall\n"; } int main() { SimpleThread t(printStuff, "beer", 900000000); t.join(); }
这里要纠正一个错误认知:参数不是在join()/detach()的时候才传入函数的。线程构造完成的瞬间,所有参数就已经被拷贝/移动到内部的tuple里了,子线程启动时直接拿存好的参数调用函数,join()只是阻塞等待子线程执行结束,本身不参与参数传递和函数调用。
怎么取出tuple里的参数完成调用
从tuple里取可变参数的核心是编译期整数序列(std::index_sequence),整个展开过程在编译期完成,没有运行时开销。C++17提供的std::apply就是封装好的展开工具,你自己实现也只需要十几行代码:
// 辅助函数,用编译期索引展开tuple template<class F, class Tuple, size_t... Index> decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence<Index...>) { // 编译期展开后等价于直接调用 f(std::get<0>(t), std::get<1>(t), std::get<2>(t)...) return std::forward<F>(f)(std::get<Index>(std::forward<Tuple>(t))...); } template<class F, class Tuple> decltype(auto) my_apply(F&& f, Tuple&& t) { // 生成和tuple长度一致的0,1,2...N的索引序列 constexpr size_t tuple_len = std::tuple_size_v<std::remove_reference_t<Tuple>>; return apply_impl( std::forward<F>(f), std::forward<Tuple>(t), std::make_index_sequence<tuple_len>{} ); }
std::function的实现差异
std::function的逻辑和std::thread几乎一致,只有一点区别:
std::thread的参数类型是靠构造函数的实参推导的std::function的参数类型和返回值类型是类模板声明时就固定的,比如std::function<void(const char*, long)>从定义开始就确定了要存的函数签名,内部存储参数的tuple类型直接和类模板参数绑定,不需要靠构造函数推导。
你自己实现简易版std::function的时候,完全可以复用上面的类型擦除+tuple+索引展开逻辑,只需要在operator()里接收调用时传入的参数,和提前绑定的参数(比如std::bind存的固定参数)合并后展开调用即可。
实现细节提醒:存参数的时候一定要用
std::decay_t处理类型,把数组类型转成指针、去掉引用和cv限定符,避免存悬垂引用或者类型不匹配的问题——比如你传的字符串字面量是数组类型,不做衰变处理的话tuple里会存数组类型,根本没法正常传参。
内容的提问来源于stack exchange,提问作者TwistedBlizzard

