资讯动态

浅析C++模板类型中的原样转发和可变参数的实现

发布时间:2026/10/22 7:43:32 来源:尧图企业网站定制
前言「原样转发」是中文技术圈对 perfect forwarding 的常见叫法标准术语是完美转发。它要解决的问题是一个中间层函数收到实参后把实参原封不动地传给下一层既不丢失const限定也不把左值变成右值更不产生多余拷贝。没有完美转发时中间层要么按值接收多一次拷贝要么按T接收无法接受右值要么按const T接收丢失可修改性总有一头对不上。可变参数模板variadic templates解决的是另一件事让函数或类接受任意个数、任意类型的参数。C11 引入参数包parameter pack后std::vector::emplace_back、std::make_shared、std::tuple才能把「构造参数」原样转交给目标类型。完美转发与可变参数几乎总是成对出现前者保证每个参数的值类别不变后者保证参数个数不限。常见误解集中在三处。第一把T一律当成右值引用——只有在T需要推导时它才是转发引用forwarding referencestd::vectorT这样的具体类型仍是右值引用。第二以为std::forwardT(x)会「移动」x——它只是有条件地把x转成右值引用是否真的发生移动取决于接收方。第三以为参数包可以像数组一样下标访问——参数包只能通过展开或sizeof...使用。一、转发引用与引用折叠先看模板参数推导。对于template typename T void f(T x)传入左值int aT被推导为intT经引用折叠成为int传入右值42T被推导为intT就是int。引用折叠规则只有四条 、 、 都折叠成只有 折叠成。可以记成「只要出现一个左值引用结果就是左值引用」。这就是为什么T能同时绑定左值和右值也是「转发引用」名字的由来。实参推导出的TT折叠结果值类别int a左值intint左值const int a左值const intconst int左值42右值intint右值std::move(a)右值intint右值注意const会跟着T一起推导出来。这正是「原样」的含义const、左值/右值属性都保留。转发引用只在这种形式下成立T且T是函数模板的模板参数、需要推导。以下都不是转发引用void f(int x)具体类型、template typename T void f(std::vectorT x)具体模板T虽推导但vectorT不是T本身、template typename T void f(const T x)多了const。类模板的成员函数里T是否转发引用取决于T是类模板参数还是成员函数自己的模板参数前者不推导是右值引用。二、std::forward 的作用std::forwardT(arg)的定义思路是static_castT(arg)。当T被推导为int时T折叠为int转换结果是左值引用当T是int时结果是int。于是左值仍是左值右值仍是右值。// 适用于 C17#include iostream#include string#include utilityvoid sink(std::string s) { std::cout lvalue: s \n; }void sink(std::string s) { std::cout rvalue: s \n; }template typename Tvoid relay(T value) {sink(std::forwardT(value)); // 保留值类别}int main() {std::string name abc;relay(name); // 按语义走左值重载relay(std::string(xyz));// 按语义走右值重载return 0;}如果把std::forwardT(value)换成value右值那次调用会变成左值具名变量都是左值走左值重载如果换成std::move(value)左值那次会被强制转成右值可能把调用方的对象搬空。三者区别如下写法左值实参右值实参是否保留值类别value左值左值否std::move(value)右值右值否一律右值std::forwardT(value)左值右值是还要强调std::move本身不移动任何东西它只是一个到右值引用的static_cast真正的移动发生在目标类型的移动构造/移动赋值被调用时。std::forward同理它不转发任何东西只是做类型转换。三、可变参数模板参数包与展开可变参数模板用省略号声明参数包template typename... Args void f(Args... args)。Args是类型包args是值包。包本身不能直接使用只能用sizeof...(Args)或sizeof...(args)得到元素个数在模式pattern后面加...展开如f(args...)、(args ...)通过递归把包拆成「第一个 剩余」。C11/14 时代常用递归展开需要一个非模板的重载作为终止条件// 适用于 C17#include iostreamvoid print_all() {std::cout \n;}template typename T, typename... Restvoid print_all(const T first, const Rest... rest) {std::cout first ;print_all(rest...); // 包展开递归到空包时调用上面的重载}调用print_all(1, 2.5, x)时先匹配T int、Rest {double, const char*}打印1再以rest...递归最后一次rest为空匹配无参重载。注意终止重载必须在递归模板之前声明否则递归实例化时找不到它这也是很多「编译到最后一层报错」的来源。C17 的折叠表达式fold expression把递归写法压缩成一行且不需要终止重载// 适用于 C17template typename... Argsvoid print_all_fold(const Args... args) {((std::cout args ), ...); // 逗号折叠左折叠std::cout \n;}(pattern, ...)是左折叠展开为((a0 , a1) , a2) ...(... , pattern)是右折叠。还有、*、、||等二元运算符可折叠空包时得true、||得false、,得void()其他运算符对空包是编译错误。折叠表达式需要 C17C11/14 只能用递归或初始化列表技巧。四、完美转发工厂把两者结合就得到标准库emplace_back、make_unique、make_shared的雏形接受任意构造参数原样转交给目标类型的构造函数。// 适用于 C17#include memory#include string#include utility#include vectorclass Widget {public:Widget(std::string name, int id): name_(std::move(name)), id_(id) {}const std::string name() const { return name_; }int id() const { return id_; }private:std::string name_;int id_;};template typename T, typename... Argsstd::unique_ptrT make_object(Args... args) {return std::unique_ptrT(new T(std::forwardArgs(args)...));}template typename T, typename... Argsvoid emplace_into(std::vectorT vec, Args... args) {vec.emplace_back(std::forwardArgs(args)...);}Args...对每个实参分别推导std::forwardArgs(args)...对每个参数分别转发两个省略号分别展开类型包和值包顺序一一对应。new T(...)里如果某个参数是左值std::stringforward后仍是左值匹配拷贝构造如果是std::move(s)则匹配移动构造。标准库从 C14 起提供std::make_uniqueC11 没有上面的make_object在 C11 下可用但在 C14 及以后应优先用std::make_uniqueT(...)它把new包在库内部异常安全更好也更简洁。五、完整示例带计数的转发包装下面这个例子把转发引用、可变参数、std::forward和折叠表达式放在一起一个Logged包装器在构造目标对象前打印参数个数然后把参数原样转交。// 适用于 C17#include iostream#include memory#include string#include utilityclass Connection {public:Connection(std::string host, int port): host_(std::move(host)), port_(port) {std::cout connect host_ : port_ \n;}private:std::string host_;int port_;};template typename T, typename... Argsstd::unique_ptrT logged_create(Args... args) {std::cout creating with sizeof...(Args) args\n;return std::make_uniqueT(std::forwardArgs(args)...);}int main() {std::string host example.invalid;auto c1 logged_createConnection(host, 80); // 左值 host拷贝auto c2 logged_createConnection(std::string(h), 443); // 右值移动return 0;}sizeof...(Args)展开为实参个数不产生运行期开销。c1的host是左值Args推导为std::string转发后调用拷贝构造c2传临时对象Args为std::string转发后调用移动构造。两者都只经过一层make_unique没有额外拷贝。常见坑点❌ 把T一律当右值引用写出void f(std::vectorT v)后奇怪为什么左值传不进来。✅ 只有T且T是待推导的模板参数时才是转发引用具体类型是右值引用。❌ 在转发函数里写sink(value)右值实参退化成左值。✅ 写sink(std::forwardT(value))。❌ 用std::move代替std::forward把本应保留的左值搬空。✅ 中间层用std::forwardT只有确定要放弃所有权时才用std::move。❌ 对同一个参数std::forward两次第二次可能访问已被移走的值。✅ 每个参数只转发一次需要多次使用就先复制到局部变量。❌make_objectT({1, 2})用花括号初始化列表模板推导不出Args。✅ 显式写出类型如make_objectT(std::initializer_listint{1, 2})或改用能接受std::initializer_list的重载。❌ 可变参数递归模板缺少空包终止重载最后一层报「no matching function」。✅ 提供无参重载或在 C17 下改用折叠表达式。❌ 转发引用构造函数贪婪匹配把拷贝构造也劫持了template typename U Widget(U)匹配Widget。✅ 用std::enable_if排除自身类型C17 可用std::is_same配合if constexpr的思路C20 可用 concepts 约束。❌ 在递归展开中对包使用下标args[0]。✅ 参数包不支持下标用递归拆分、折叠表达式或std::applyC17配合std::tuple。总结概念关键点转发引用T且T待推导左值推出T右值推出T引用折叠只要出现左值引用结果就是左值引用std::forward有条件转换为右值引用保留值类别std::move无条件转换为右值引用本身不移动参数包sizeof...取个数模式加...展开展开方式C11/14 递归 终止重载C17 折叠表达式典型应用emplace_back、make_unique、make_shared、tuple构造完美转发的本质是「让类型系统记住实参的值类别并在每一层原样传递」可变参数模板则把这件事从固定参数推广到任意个数。理解引用折叠和std::forward的转换语义比记住emplace_back的用法更重要——前者能解释后者为什么这样设计也能解释为什么传错值类别时行为会悄悄改变。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑