夜雨聆风学习资料网

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)...
展开参数包,生成实参列表
编译期完成展开
Args
 对应类型,args 对应数据

三个常见误解:

  1. 把 Args 与 args 当作同一事物。二者按位置对应,但一个保存类型,一个保存数据,属于不同层面。
  2. 认为三个 ... 含义相同。行为由位置决定:声明处声明参数包,表达式末尾展开参数包。
  3. 认为 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 把它转成右值——调用方仍在使用的对象被意外移走,属于行为错误。
  • f3
     const 左值引用:能接住左值和右值,但 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 内部同样做参数包展开与完美转发,三个 ... 的用法与前文一致。示例使用裸指针只是为了少引入一个概念,使讨论集中于参数包。

相关学习资料