浅谈C++中的万能引用与完美转发

事实上,这篇文章可能不止讲万能引用与完美转发。
(可能前摇有点过长)

发生了什么?

之前在野路子写c++的时候,完全忽视了c++是一门面向对象语言的事实,所以对许多编译报错的事情都感到十分疑惑,只能愚笨地这边改改,那边改改来试错。当别人说,对vector要用.emplace_back(),比.push_back()更快的时候,也是知其然而不知其所以然。所以对他们的写法不同,也只是抱着一种默认的态度。可是到底发生了什么呢?

左值引用与右值引用

众所周知,引用,在c++中,最初是以“别名”的形式存在的。也就是说,int &a = b;也就是我把变量b起了个别名叫a。这听起来好像没什么用,但是在函数上就非常有用了。

以C语言的思维,函数的参数列表传入实参,内部的修改都是针对形参的。也就是说,如果按值传递,不可能在函数内部对传入的实参进行修改。这其实很好理解:传入参数,实际上也就是把实参拷贝了一份给形参,拷贝完之后,这两个变量就再无瓜葛了。所以在C语言中,要想修改外部变量的值只能使用指针,也就是*这个符号。

RAII

当然咯,由于某些原因,现代c++在大多数时候都是不推荐使用原始的指针的。为什么呢?这不应该是发展光辉历程中浓墨重彩的一笔么?

现代C++最重要的编程准则之一,即 RAII (Resource Acquisition Is Initialization)。原始指针在管理动态分配的内存时,存在一系列的经典问题:

  1. 所有权不明确:当你从一个函数接收到一个原始指针时,你无法仅从指针类型本身知道谁负责 delete 它。是你自己?还是调用者?还是别的什么东西?这种模糊性是内存泄漏和重复释放的根源。
  2. 内存泄漏:如果你忘记 delete 一个通过 new 分配的指针,就会发生内存泄漏。在有多个返回路径或可能抛出异常的复杂函数中,确保在任何情况下都能正确 delete 是非常困难且容易出错的。
  3. 悬垂指针 (Dangling Pointers):当一个指针指向的内存已经被 delete,但该指针本身没有被置为 nullptr 时,它就成了悬垂指针。后续对这个指针的任何解引用都会导致未定义行为(通常是程序崩溃)。
  4. 重复释放 (Double Free):多次 delete 同一块内存会导致未定义行为。

    所以说引用这个东西被加入了之后,就可以替代原来的指针在形参列表中的生态位了,我们给实参起了一个“别名”,那我们给这个别名修改,不就相当于修改它本身嘛。

左值和右值

  • 左值 (Lvalue - “Locator Value”): 指的是那些在内存中有固定位置、有名字、可以被取地址的表达式。简单来说,它们是“持久的”对象。
    • 例如:变量 x、数组元素 arr[0]、返回左值引用的函数调用等。
    • 你可以把它放在赋值符号 (=) 的左边
  • 右值 (Rvalue - “Read Value”): 指的是那些临时的、没有名字、即将被销毁的表达式。它们通常是计算过程中产生的中间结果。
    • 例如:字面量 10、true、表达式 x + y 的结果、返回一个值的函数调用 get_value() 等。
    • 你通常不能把它放在赋值符号 (=) 的左边(不能对一个临时值赋值)。

(说实话,我也是第一次知道lvalue/rvalue的l和r不是left和right。)

那么出现了引用之后,我们会发现,刚刚提到的所有的引用全都是左值引用。右值有没有引用呢?乍一想,对一个不能更改的值搞一个引用的功能好像没什么意义,但是右值引用有别的妙用——移动语义

也就是说,一个右值可以像”移动“一样,传来传去,这个人有了,另一个人就没了。这好像更像真实世界的物质交换原则,而非信息交换。但是这肯定也是有好处的。

我们知道,赋值实际上是通过”拷贝“来实现的。我某一个空间,有一个1MB的数据;想要给另一个空间,就得从这个空间中,一点一点地扫描,复制,直到拷贝完毕。哪怕是左值引用,在编译器底层基本上也是靠拷贝“指针”的类似物来实现的。这样肯定有性能开销。那如果一个空间用完就不用了,想给另外一个了,就可以移动赋值了。

哪怕一个变量不是右值引用,也可以通过std::move()函数来把它当作一个右值引用来移动,当然,原本变量的也就跟右值一样被销毁了。

其实最开始提到的.push_back()方法也是支持移动语义的。但是为什么它还是慢呢?

万能引用(Universal references)

随着对C++性能的进一步满足,似乎某些需求也进一步增长了。

cvref

刚刚提到,在函数的形参列表中可以设置左值引用/右值引用。虽然说intint&本质上不是同一种类型,但是他们肯定是有关联的。&就好像int这个类的一个修饰符一样。事实上这类修饰符有以下几种:

  • const : 常数、不能修改。
  • volatile : 告诉编译器这个变量只能储存在内存中,不能被优化到寄存器等其他地方。
  • & : 左值引用。
  • && : 右值引用。
    这几种被合称为cvref,是一个类型的“修饰层”。std::remove_cvref 这个标准库工具的设计目的就是:剥去一个类型所有的外层修饰,还原其最核心的、未被修饰的类型

为什么没有指针*?因为指针不是在修饰一个类型,而是构造了一个全新的、不同的类型

编写形参列表时遇到的困难

我们在编写一个函数的时候,肯定希望一个变量以最快的方式传到函数体内。为此,左值有了左值引用,右值有了右值引用。这让我们可以以最高效率的方式获取到变量的值。

可是,我们往往是不知道传入的实参到底是个什么类型。如果形参列表里面是这样的:

1
void func(int& a, int& b)

这样接收左值的效率肯定很高,但是就根本不能接收右值了。

那全改成右值引用呢?那就根本不能接收左值了。

可以发现我们遇到了一个小困难。也就是如果我们在一个参数里面又想填左值又想填右值,那使用左值引用/右值引用似乎就不行了,我们要么排列组合把所有情况全考虑进去(那会指数级增长,根本不可能),要么我们就直接放弃使用引用,改为拷贝赋值,这似乎又不是很能接受了。

自动类型推导

这时候主角就登场了。所谓万能引用,也就是能自动推导引用的类型。

万能引用可以这样写:

1
2
template<typename T>
void func(T&& param);

虽然看上去他是一个右值引用,但实际上它并不是。当它在类型推导上下文中出现时, T&& 会获得特殊含义。 T 取决于传递给 func 的参数是左值还是右值。如果是类型为 U 的左值, T 会被推导为 U& 。如果是右值, T 会被推导为 U 。我们把它称为”万能“引用,因为它不仅能推导引用,而且可以推导出所有的cvref属性。

那这样,模板T的右值引用该怎么表示?这个时候就必须解释一个看似没有什么用的规则——引用折叠规则了。

引用折叠

指针可以嵌套,引用也可以嵌套。左值引用和右值引用之间也可以嵌套,那这样不是套的没边了吗?可是引用比指针优越的一点是,它是可以“折叠”的。所以,就算看起来左值引用和右值引用在套来套去,可是最后表现出来只有要么左值引用,要么右值引用。

引用折叠的规则如下:

& 总是获胜。所以 & & 是 & , && & 和 & && 也是。唯一能从折叠中产生 && 的情况是 && && 。你可以把它看作是一个逻辑或,其中 & 是 1 而 && 是 0。

对于刚刚所提到的代码

1
2
template<typename T>
void func(T&& param);

,我们可以分步对这个代码进行拆解。1. 传入值的类型, 2. 类型T被推导成什么,3. param 最终的类型。

传入参数 arg 的类型 arg 的值类别 T 被推导成什么 param 的最终类型 (T&&)
左值 (Lvalue),例如 int x 左值 int& int&
右值 (Rvalue),例如 10 右值 int int&&
左值引用 (Lvalue Ref),例如 int& ref 左值 int& int&
右值引用 (Rvalue Ref),例如 int&& rref 左值 int& int&
注意,一个具名的右值引用是左值

我们可以从这个表看出,正是有了引用折叠的这个特性,万能引用才能够通过这个特性来达到“万能”的效果。所以,所谓”万能引用“,其实是”在类型推导上下文中的右值引用“。

完美转发

正如我们可以把一个对象使用std::move()来将他当作一个右值来转发,对于万能引用我们有什么办法能来转发呢?

那对于“万能引用”逼格这么高的名字,肯定也要一个高逼格的名字来转发它——完美转发。我们使用std::forward()来使用完美转发。它的具体实现如下:

1
2
3
4
5
6
7
8
9
template<class T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
return static_cast<T&&>(t);
}

template <class T>
T&& forward(typename std::remove_reference<T>::type&& t) noexcept {
return static_cast<T&&>(t);
}

应用刚才的引用折叠规则,我们也不难推断出,这样就能把各种类型的引用原样转发出去了。

所以发生了什么?

当我们发现了完美转发这个东西的存在之后,我们就可以了解 .emplace_back() 的快速之处了。

经典的 .push_back() 虽然也支持移动语义,但是必然的是,它也只能支持我们先在 push_back括号里面的表达式中,先把我们要移动的东西给构造出来,然后在把它push到vector里面。而且它对于左值是万万不可使用引用,只能使用拷贝的。

但是 .emplace_back() 中,我们不急于去构造这个对象,而是先把参数列表里面的东西全部 “完美转发” 到vector中,在vector内部去构造这个对象。

具体对比下来就是

.push_back()的性能开销:普通构造+移动构造+析构
.emplace_back()的性能开销:普通构造。

那关于万能引用和完美转发其实还有很多新内容,可是这里太小了,我写不下,还是等到下一篇再写罢。

此篇博客献给我的一位可爱的学弟,祝他在未来事事顺意。

ref

Perfect forwarding and universal references in C++ - Eli Bendersky’s website
c++中为什么push_back({1,2})可以,emplace_back({1,2})会报错? - 知乎


浅谈C++中的万能引用与完美转发
http://blog.bluspace.ren/2025/11/15/浅谈C++中的万能引用与完美转发/
作者
Blauter
发布于
2025年11月15日
许可协议