ARTICLE · 1158791
可变参数模板与完美转发
核心结论
工厂函数、包装器中常见如下代码:
template<typename... Args>Widget* create(Args&&... args) {return new Widget(std::forward<Args>(args)...);}
Widget 是占位类名(示例代码中常与 Foo、Bar 一样用作占位名),实际使用时替换为具体业务类。
这段代码由两个机制组成:
可变参数模板:三个 ...中,前两个声明参数包(类型参数包、函数参数包),第三个展开参数包,使函数能接受任意数量的实参。完美转发: &&(转发引用)绑定左值与右值并记录值类别,std::forward按记录还原值类别,使实参的值类别原样传递给下一层。
前者解决"个数任意",后者实现"值类别原样传递",两者相互独立。下文分别展开。
一、可变参数模板:三个 ... 的分工
... 的行为由位置决定:位于声明处时声明参数包,位于表达式末尾时展开参数包。
1.1 template:声明类型参数包
template<typename... Args> typename声明类型占位符。 ...位于 typename与Args之间,表示Args是一个参数包,可容纳零个、一个或任意多个类型。Args是参数包的名称,任意合法标识符均可( Types、Ts也常见),Args只是习惯写法。
typename T 中的 T 只能对应一个类型;typename... Args 对应一组类型,如 int、std::string、double。类型参数包中只有类型信息,没有数据,仅存在于编译阶段,编译完成后即消失。
... 只能位于 typename/class 与参数包名之间;写在参数包名之后(typename Args...)是语法错误。... 两侧有无空格不影响编译,习惯写作 typename... Args。
1.2 Args&&... args:声明函数参数包
Args&&... args这是函数形参声明,与类型参数包一一对应:
Args是上一行声明的类型参数包; &&是转发引用(forwarding reference,亦称万能引用),配合引用折叠,可以同时匹配左值与右值实参; ...同样为声明参数包:为类型参数包中的每个类型各生成一个形参,这组形参合称函数参数包; args是函数参数包的名称,运行期保存调用时实际传入的值。
以调用 create(10, std::string("hi"), 3.14) 为例:
编译期推导出的类型参数包为 Args = int, std::string, double;形参类型序列展开为 int&&, std::string&&, double&&;运行期函数参数包 args保存实参10、临时std::string对象、3.14。
大小写对应只是命名约定,没有语法含义;两个包按位置一一对应,类型参数包中第 n 个类型对应函数参数包中第 n 个实参。
1.3 std::forward(args)...:展开参数包
std::forward<Args>(args)...此处的 ... 与前两处不同:前两处位于声明中,用于声明参数包;此处位于表达式末尾,将参数包展开为逐个元素。
std::forward(args)是展开模式,不是一次实际调用:`` 对应类型参数包中的一个类型, (args)对应函数参数包中同位置的变量;末尾的 ...使编译器按参数包长度复制该模式:参数包中有几个元素,就生成几个std::forward调用;第 n 个调用使用类型参数包中第 n 个类型与函数参数包中第 n 个变量,各调用以逗号分隔,构成函数实参列表。
仍以 create(10, std::string("hi"), 3.14) 为例:
new Widget(std::forward<Args>(args)...);编译期展开后等价于:
new Widget(std::forward<int>(10),std::forward<std::string>(std::string("hi")),std::forward<double>(3.14));
1.4 三处 ... 对照
... | |||
|---|---|---|---|
template<typename... Args> | Args | Args | |
Args&&... args | args | ||
std::forward<Args>(args)... | Argsargs 对应数据 |
三个常见误解:
把 Args与args当作同一事物。二者按位置对应,但一个保存类型,一个保存数据,属于不同层面。认为三个 ...含义相同。行为由位置决定:声明处声明参数包,表达式末尾展开参数包。认为 Args&&... args中的...与std::forward相关。前者位于形参声明处,后者位于调用处,是两段独立的语法,仅作用于同一对参数包。
二、完美转发:&& 与 std::forward 的配合
2.1 完美转发的目标
完美转发的目标是:使实参的值类别及 const 属性原样传递给下一层函数。这需要两步配合:&& 记录值类别,std::forward 还原值类别。
目标源于形参的性质:形参一旦有了名字,在函数体内就是左值表达式。即使调用方传入右值,args 在函数体中仍按左值处理。若直接写 new Widget(args...),代码可以编译,但所有实参都按左值传递,原本的右值实参只能调用拷贝构造,产生不必要的拷贝。
2.2 &&(转发引用):接住并记录值类别
形参是否为转发引用只看语法形式:T&& 且 T 为待推导的模板类型参数(或 auto&&),不带 const,与是否调用 std::forward 无关。
在 create 里,&& 负责完美转发的前半段:接住实参,并留下记录。
接住: Args&&形参既能绑定左值,也能绑定右值;形参是引用,绑定时不复制对象,函数内使用的就是实参本身。记录:推导时把每个实参的值类别写入 Args(左值记为U&,右值记为U)。这份记录在函数体内看不到:形参有名字,作为表达式恒为左值;值类别存在Args的类型里,属于编译期层面。它的用途在 2.3 节——std::forward读出Args,据此还原值类别。
Args 的推导结果经引用折叠确定形参的实际类型:
传入左值, Args推导为U&,形参U& &&折叠为U&,即左值引用;传入右值, Args推导为U,形参为U&&,即右值引用。
折叠规则共四条:只要出现一个左值引用,结果就是左值引用;两个均为右值引用,结果才是右值引用:
T& & -> T&T& && -> T&T&& & -> T&T&& && -> T&&
2.3 std::forward:按记录还原值类别
2.2 节把实参的值类别记入了 Args,std::forward 负责使用这份记录:把函数体内的左值 args 转回实参最初的值类别。实现上它与 std::move 同构,各只有一次 static_cast,都不移动数据,没有运行时开销:
template<typename T>constexpr remove_reference_t<T>&& move(T&& x) noexcept {return static_cast<remove_reference_t<T>&&>(x);}template<typename T>constexpr T&& forward(remove_reference_t<T>& x) noexcept {return static_cast<T&&>(x);}
forward的形参是左值引用remove_reference_t<T>&:std::forward<Args>(args)的实参args是转发函数的形参,有名字,恒为左值,正好匹配这个重载。实参表达式本身为右值时(如std::forward<T>(std::move(x))),匹配右值引用重载constexpr T&& forward(remove_reference_t<T>&& x)。
forward 的转换目标是 T&&:是左值引用还是右值引用,由 T 决定(折叠规则见 2.2 节),这就是"按记录还原"。T 必须携带实参最初的值类别,而这份记录不在 forward 自己能看到的地方——形参 x 有名字,进入函数后就是左值,从 x 看不出调用方传的是左值还是右值。记录只存在一处:转发引用推导写入的 Args(左值记为 U&,右值记为 U)。所以 T 只能由调用方显式传入:std::forward(args) 显式写出 ,forward 据此执行 static_cast。
static_cast 的还原分两种情况:
实参原为右值: Args记为U,即T = U,T&&就是U&&,折叠后仍是右值引用——下一层命中移动构造或右值重载,避免拷贝;实参原为左值: Args记为U&,即T = U&,T&&就是U& &&,折叠为U&——下一层走拷贝构造。
因此 std::forward(args) 与手写 static_cast(args) 等价:forward 不做推导、不做决策,只是这次条件转换的标准写法。对比之下,move 的转换目标 remove_reference_t&& 先剥去 T 的引用再附加 &&,不含记录,恒为右值引用——无条件转为右值,只完成这一次转换;是否真的移动,由下一层的移动构造或移动赋值决定。
2.4 两者缺一不可
&& 接住实参并把值类别记入 Args;std::forward 按记录还原值类别。缺了任何一个,完美转发都不成立。
只写 &&、不写 forward:记录无人使用,形参往下传时是左值——右值实参到下一层命中拷贝而非移动,对重型类是性能损失;类只定义了移动构造时直接编译错误。函数体内直接使用形参不受影响;需要原样转交时使用 forward,下一层本来就要拷贝、或类型很轻时不写也是正确写法。
template<typename... Args>Widget* create(Args&&... args) {return new Widget(args...); // 形参有名字,永远是左值——右值实参到下一层只能走拷贝}
只写 forward、不写 &&:值类别的记录只有转发引用的推导会写,形参换成其他形式后,Args 推不出引用,forward 无物可还原。三种形式的失效方式各不相同(g 为任意下层函数):
template<typename... Args>void f1(Args... args) {g(std::forward<Args>(args)...); // 按值}template<typename... Args>void f2(Args&... args) {g(std::forward<Args>(args)...); // 左值引用}template<typename... Args>void f3(const Args&... args) {g(std::forward<Args>(args)...); // const 左值引用}
f1按值:能编译,但转发不成立。实参绑定形参时已拷贝(或移动)一次, forward处理的只是副本;Args无引用信息,一律转出右值,与std::move(args)无异。f2左值引用:右值实参绑不上, f2(10)编译失败;左值实参能进来,Args却推导为U,forward把它转成右值——调用方仍在使用的对象被意外移走,属于行为错误。f3const 左值引用:能接住左值和右值,但 forward的形参是U&,绑不上const U&实参,std::forward编译失败。写成std::forward可以编译,得到 const 右值,移动构造要求非 const 的T&&,同样无法触发。
这也解释了 std::forward 几乎只与转发引用一起出现的原因:脱离转发引用,T 是非引用的具体类型,std::forward(x) 等价于 std::move(x),写 std::move 语义更明确。
同一形参只能在最终转交处转发一次,提前转发可能触发移动,此后该形参处于"已移出"状态,不再具有有效值。
三、示例的两处简化
示例为聚焦参数包做了两处简化:目标类型固定为 Widget,所有权交给裸指针。以下两节分别交代其中的语法细节,并给出工程中的完整写法。
3.1 目标类型固定为 Widget
模板实参有两种提供方式:显式写在尖括号中,或由编译器从函数实参推导。显式指定的类型按模板形参的声明顺序依次绑定。
本例模板只有一个模板形参,即类型参数包 Args...:
template<typename... Args>Widget* create(Args&&... args); // 目标类型固定:唯一的模板形参是 Args
写 create(10, std::string("hi"), 3.14) 时,Widget 会绑定给唯一的模板形参 Args,等价于把类型参数包固定为只含一个元素:Args = Widget。于是函数形参 Args&&... 只展开出一个 Widget&&,而调用传入了三个实参,数量不匹配,编译失败。
Widget 在函数声明中是固定的返回类型,不属于模板形参,尖括号中的类型无法绑定到它。
目标类型由调用方指定时,可增加一个独立的模板形参:
template<typename T, typename... Args> // T:目标类型,由调用方显式指定T* create(Args&&... args){ // Args...:仍由函数实参推导return new T(std::forward<Args>(args)...);}
此时 create(10, std::string("hi"), 3.14) 成立:Widget 按位置绑定给第一个模板形参 T,Args... 仍由三个函数实参推导。标准库的 std::make_unique(10, std::string("hi"), 3.14) 即采用这种形式:目标类型显式指定,构造参数由实参推导。两种 create 的区别只在于目标类型是固定还是参数化,转发部分完全相同。
3.2 所有权交给裸指针
示例用 new 在堆上构造 Widget 并返回裸指针,所有权与释放责任由调用方承担,漏写 delete 即造成泄漏。工程中通常使用智能指针:
template<typename... Args>std::unique_ptr<Widget> create(Args&&... args) {return std::make_unique<Widget>(std::forward<Args>(args)...);// make_unique 内部做同样的包展开与完美转发}
std::make_unique 内部同样做参数包展开与完美转发,三个 ... 的用法与前文一致。示例使用裸指针只是为了少引入一个概念,使讨论集中于参数包。