C++完美转发:从引用到 std::forward
前言
完美转发是 C++ 模板中一个很重要、也很容易让人困惑的概念。
很多人第一次看到下面的代码时,会有一连串疑问:
template<class T>
void wrapper(T&& value) {
target(std::forward<T>(value));
}
T&&不是右值引用吗,为什么还能接收左值?- 参数已经是
T&&,为什么还需要std::forward? std::forward和std::move有什么区别?- 什么叫“保持参数原来的值类别”?
- 引用折叠到底在折叠什么?
本文从最基础的引用开始,一步步解释这些问题。
全文最重要的一句话是:
完美转发就是让包装函数把参数继续传递时,尽量保持调用者传入参数时的左值、右值和
const属性。
第一章:先理解普通变量和引用
1.1 普通变量是一个独立对象
int a = 10;
int b = a;
这里有两个独立的 int 对象:
a ──> 10
b ──> 10
修改 b 不会影响 a:
b = 20;
// a == 10
// b == 20
1.2 引用是已有对象的别名
int a = 10;
int& b = a;
b 不是一个新的 int 对象,而是 a 的别名:
a ──┐
├──> 同一个 int 对象
b ──┘
通过 b 修改,就是修改 a:
b = 20;
// a == 20
// b == 20
1.3 引用必须绑定到对象
int& ref; // 错误:普通引用必须初始化
正确写法:
int value = 10;
int& ref = value;
引用初始化以后,不能改为引用另一个对象:
int a = 10;
int b = 20;
int& ref = a;
ref = b;
最后一句不是让 ref 改为引用 b,而是把 b 的值赋给 a:
ref 仍然引用 a
a 变成 20
第二章:左值和右值
理解完美转发之前,必须先理解表达式的值类别。
2.1 什么是左值
可以先用一个不完全严格、但容易理解的说法:
左值通常表示一个有身份、可以在后续继续找到的对象。
例如:
int value = 10;
表达式 value 是左值,因为它有名字,可以反复访问:
value = 20;
std::cout << value;
其他常见左值:
array[0]
*pointer
object.member
2.2 什么是右值
可以通俗地理解为:
右值通常是临时结果,或者是一个明确允许被搬走资源的对象。
例如:
42
a + b
std::string("hello")
这些表达式产生的结果通常不需要长期保留原状态。
2.3 “能不能取地址”不是完整判断标准
初学时常听到:
能取地址的是左值,不能取地址的是右值
这只能作为入门记忆,不能当作完整语言规则。现代 C++ 的值类别更准确地分为:
表达式
├── glvalue
│ ├── lvalue
│ └── xvalue
└── rvalue
├── prvalue
└── xvalue
完美转发中最常遇到的是:
lvalue:普通左值;prvalue:纯右值,例如42、临时对象;xvalue:将亡值,例如std::move(object)。
其中:
rvalue = prvalue + xvalue
入门阶段可以先把 prvalue 和 xvalue 都放在“右值”这一大类中理解。
第三章:左值引用
3.1 T& 绑定左值
void handle(int& value) {
value += 1;
}
int number = 10;
handle(number);
number 是左值,因此可以绑定到 int&。
下面通常不允许:
handle(10); // 错误:普通左值引用不能绑定临时右值
3.2 const T& 可以绑定左值和右值
void print(const std::string& text) {
std::cout << text;
}
std::string name = "Alice";
print(name); // 左值
print(std::string("Bob")); // 临时右值
print("Charlie"); // 可以转换成临时 string
const T& 很常用,因为它:
- 避免复制;
- 不允许通过引用修改对象;
- 能绑定左值;
- 也能绑定临时右值。
但它会把“调用者传入的是左值还是右值”这一区别隐藏起来。
第四章:T&&、右值引用和 std::move
4.1 T&& 到底是什么
先不要把 T&& 整体当成一个神秘语法,可以把它拆成两部分:
T :某种类型
&& :右值引用符号
当 T 已经是一个确定的、非引用的具体类型时:
using T = std::string;
T&& ref = std::string("hello");
T&& 展开后就是:
std::string&& ref = std::string("hello");
此时它是一个普通的右值引用类型。
类似地:
T 表示一个 T 类型对象
T* 表示一个指向 T 的指针
T& 表示一个 T 的左值引用
T&& 表示一个 T 的右值引用
需要特别注意:
T&&描述的是一种类型,不是一个对象正在发生的动作,也不表示资源已经被移动。
下面只是声明一个右值引用:
std::string source = "hello";
std::string&& ref = std::move(source);
这里没有创建第二个 std::string 对象,也没有调用移动构造。ref 只是 source 的另一个名字。
source ──┐
├──> 同一个 string 对象
ref ─────┘
因此:
ref = "changed";
std::cout << source; // changed
4.2 T&& 是否一定代表右值引用
不一定,必须看 T 是怎样得到的。
情况一:T 已经确定,并且本身不是引用
using T = std::string;
T&& value = std::string("hello");
这里:
T = std::string
T&& = std::string&&
它是普通右值引用。
再例如:
void consume(std::string&& value);
这里没有模板推导,std::string&& 就是普通右值引用,通常只能由右值初始化:
std::string text = "hello";
// consume(text); // 错误:text 是左值
consume(std::move(text)); // 正确
consume(std::string("tmp")); // 正确
情况二:T 是函数模板正在推导的参数
template<class T>
void wrapper(T&& value);
这里的 T&& 可能是转发引用。
当传入左值时:
std::string text = "hello";
wrapper(text);
T 会被推导为:
T = std::string&
于是:
T&&
= std::string& &&
= std::string&
最终形参是左值引用。
当传入右值时:
wrapper(std::string("hello"));
推导结果是:
T = std::string
T&& = std::string&&
所以:
普通代码中的具体类型&& → 右值引用
模板推导语境中的T&& → 可能是转发引用
引用折叠完成后的最终类型 → 可能是T&,也可能是T&&
后面的章节会详细解释转发引用和引用折叠。
4.3 右值引用可以绑定什么
普通右值引用可以绑定临时右值:
std::string&& ref = std::string("hello");
也可以绑定被显式转换成右值的对象:
std::string source = "hello";
std::string&& ref = std::move(source);
不能直接绑定普通左值:
std::string source = "hello";
// std::string&& ref = source; // 错误:source表达式是左值
在第一个例子中,局部右值引用直接绑定临时对象,会把这个临时对象的生命周期延长到引用的作用域结束:
{
std::string&& ref = std::string("hello");
std::cout << ref; // 临时string仍然存在
}
但不要把这条规则随意推广到函数返回和成员保存中。引用一旦被继续返回或长期保存,仍然很容易产生悬空引用。
4.4 右值引用为什么存在
在 C++11 之前,函数通常只能区分:
void consume(const std::string& value);
它能接收左值和临时对象,但因为参数是 const,函数不能安全地修改源对象并拿走它的内部资源。
加入右值引用后,可以提供另一个重载:
void consume(const std::string& value) {
std::cout << "复制或只读使用\n";
}
void consume(std::string&& value) {
std::cout << "允许消费或移动资源\n";
}
右值重载表达的是:
调用者传来的对象是临时对象,或者调用者明确表示不再需要它的原值,因此可以尝试接管它的资源。
右值引用的主要用途包括:
- 实现移动构造;
- 实现移动赋值;
- 选择“消费对象”的函数重载;
- 在模板中配合引用折叠实现完美转发。
4.5 std::move 到底做了什么
std::move 的名字很容易造成误解。
它不会:
- 复制内存;
- 自动搬运资源;
- 自动清空源对象;
- 自动调用移动构造;
- 自动创建一个新对象。
它只做一次类型转换:
std::string source = "hello";
std::move(source);
概念上近似于:
static_cast<std::string&&>(source)
转换前:
表达式 source
类型:std::string
值类别:左值
转换后,可以通过 decltype 观察到:
表达式 std::move(source)
decltype(std::move(source)):std::string&&
值类别:xvalue(属于右值)
严格地说,普通表达式的类型分析和 decltype 保留引用属性的规则有所区别。入门阶段可以先记成:std::move(source) 返回一个指向原对象的右值引用表达式。
可以把它理解成给编译器的一句话:
我允许你把
source当作一个即将不再使用原值的对象,请优先考虑右值重载。
4.6 std::move 的简化实现
标准库实现思想大致如下:
template<class T>
constexpr std::remove_reference_t<T>&&
move(T&& value) noexcept {
return static_cast<
std::remove_reference_t<T>&&
>(value);
}
假设:
std::string source;
std::move(source);
模板推导大致得到:
T = std::string&
remove_reference_t<T> = std::string
返回类型 = std::string&&
因此无论传入的表达式原来是不是左值引用,std::move 都尽量得到一个去掉引用后的右值引用类型。
这也是它与 std::forward 的根本区别:
std::move 无条件倾向于转成右值
std::forward 根据模板推导结果决定保持左值还是右值
4.7 什么时候才真正发生移动
只写:
std::move(source);
通常什么资源转移都不会发生,因为转换结果没有被使用。
下面才可能真正调用移动构造:
std::string target(std::move(source));
过程是:
source
↓ std::move把表达式变成右值
std::string&&形态的表达式
↓ 构造target时进行重载决议
选择std::string的移动构造
↓
移动构造接管资源
下面可能调用移动赋值:
std::string target = "old";
target = std::move(source);
这里 target 已经存在,因此调用的是移动赋值,而不是移动构造。
但“可能移动”仍然很重要。假设类型没有移动构造:
class CopyOnly {
public:
CopyOnly() = default;
CopyOnly(const CopyOnly&) {
std::cout << "copy\n";
}
};
调用:
CopyOnly a;
CopyOnly b(std::move(a));
std::move(a) 只是产生右值。由于没有可用的移动构造,而 const CopyOnly& 可以绑定右值,编译器可能退回拷贝构造。
因此:
使用了
std::move不代表一定发生移动;最终调用哪个函数由重载决议决定。
4.8 std::move 和直接引用有什么区别
这是理解右值引用时非常关键的一组对比。
左值引用:只建立别名
std::string source = "hello";
std::string& ref = source;
结果:
没有创建新string
没有复制
没有移动
ref和source表示同一个对象
右值引用:仍然只是建立别名
std::string source = "hello";
std::string&& ref = std::move(source);
结果仍然是:
没有创建新string
没有调用移动构造
ref和source表示同一个对象
std::move 只是让 source 能绑定到 std::string&&。
此时:
ref += " world";
std::cout << source; // hello world
创建新对象:这时才可能移动
std::string source = "hello";
std::string target = std::move(source);
这次 target 是一个新的独立对象:
source和target是两个对象
target的构造会优先选择移动构造
source随后处于有效但状态未指定的状态
对比表:
| 写法 | 是否创建新对象 | 是否调用移动构造 | 两个名字是否表示同一对象 |
|---|---|---|---|
T& ref = source | 否 | 否 | 是 |
T&& ref = std::move(source) | 否 | 否 | 是 |
T target = source | 是 | 否,通常拷贝 | 否 |
T target = std::move(source) | 是 | 可能调用移动构造 | 否 |
所以:
引用解决“给已有对象起别名”的问题
std::move解决“把表达式标记为可移动右值”的问题
移动构造解决“新对象如何接管旧对象资源”的问题
三者不是同一个东西。
4.9 为什么右值引用变量还要再次 std::move
void process(std::string&& text) {
consume(text);
}
text 的声明类型是:
std::string&&
但是表达式:
text
是左值,因为它有名字,可以反复访问。
因此:
void consume(const std::string&) {
std::cout << "lvalue overload\n";
}
void consume(std::string&&) {
std::cout << "rvalue overload\n";
}
void process(std::string&& text) {
consume(text); // 调用左值版本
consume(std::move(text)); // 调用右值版本
}
这条规则是为了安全。如果一个有名字的右值引用变量每次使用都会自动被当作右值,那么下面的代码可能无意中移动两次:
void process(std::string&& text) {
first(text);
second(text);
}
C++ 要求程序员在真正准备交出资源的位置显式写:
first(std::move(text));
记住:
变量的声明类型,和变量表达式的值类别,是两回事。
std::string&& text 说明text的声明类型是右值引用;
单独写text时,这个有名字的表达式仍然是左值;
std::move(text)才重新把它转换成右值。
4.10 移动后的对象是什么状态
std::string source = "hello";
std::string target = std::move(source);
移动以后:
source仍然存在;source仍然会正常析构;- 可以给
source重新赋值; - 不应依赖
source仍然等于"hello"; - 也不应对所有类型都假设它一定为空。
例如:
source = "new value"; // 正确
标准库对象通常保证移动后处于“有效但状态未指定”的状态。个别类型会给出更强保证,例如移动后的 std::unique_ptr 保证为空。
4.11 const 对象为什么通常移动不了
const std::string source = "hello";
std::string target = std::move(source);
std::move(source) 得到的是:
const std::string&&
而典型移动构造需要:
std::string(std::string&&)
移动资源通常要修改源对象,例如把源指针设为 nullptr。但 source 是 const,不允许修改。
于是编译器通常选择:
std::string(const std::string&)
也就是拷贝构造。
所以:
std::move不会去掉const,对const对象使用std::move通常仍然发生拷贝。
4.12 什么时候应该使用 std::move
适合的情况:
// 1. 把局部对象交给另一个拥有者
member_ = std::move(local);
// 2. 移动构造中移动成员
Buffer(Buffer&& other) noexcept
: data_(std::move(other.data_)) {}
// 3. 移动赋值中接管成员
data_ = std::move(other.data_);
// 4. 明确告诉接口交出对象
queue.push(std::move(task));
不适合机械使用的情况:
// 1. 后面还需要依赖对象原值
send(std::move(message));
print(message); // 不应再依赖原内容
// 2. 完美转发模板中无条件move
template<class T>
void wrapper(T&& value) {
target(std::move(value)); // 可能错误移动调用者的左值
}
// 3. 返回普通局部变量时随意move
T makeObject() {
T object;
return std::move(object); // 可能阻碍NRVO,通常直接return object
}
普通局部对象返回时通常写:
T makeObject() {
T object;
return object;
}
让编译器进行复制消除,必要时语言规则也会考虑隐式移动。
第五章:为什么包装函数会丢失参数信息
假设有两个重载:
void handle(const std::string&) {
std::cout << "handle lvalue\n";
}
void handle(std::string&&) {
std::cout << "handle rvalue\n";
}
直接调用时:
std::string text = "hello";
handle(text); // handle lvalue
handle(std::string("world")); // handle rvalue
现在增加一个包装函数:
void wrapper(std::string&& value) {
handle(value);
}
调用:
wrapper(std::string("hello"));
调用者传入的是右值,但 handle(value) 中的 value 是有名字的变量表达式,因此是左值,最终调用:
handle lvalue
右值信息在经过包装函数以后丢失了。
这正是完美转发要解决的问题:
调用者传入左值 → 包装函数继续传左值
调用者传入右值 → 包装函数继续传右值
调用者传入const → 包装函数继续保留const
第六章:只写 T&& 为什么还不够
可能有人会写两个重载:
template<class T>
void wrapper(T& value) {
handle(value);
}
template<class T>
void wrapper(T&& value) {
handle(std::move(value));
}
对于一个参数尚且可以,但如果函数有多个参数,每个参数都可能是左值或右值:
create(a, b, c);
三个参数就有:
2 × 2 × 2 = 8 种组合
如果再考虑 const,组合会更多。
我们希望只写一个模板:
template<class T>
void wrapper(T&& value);
让编译器自动记住参数原来的属性。
这需要两个语言机制:
- 转发引用;
- 引用折叠。
第七章:转发引用
7.1 什么是转发引用
下面这种形式中的 T&& 叫作转发引用:
template<class T>
void wrapper(T&& value);
它需要满足两个关键条件:
T是当前函数模板中需要推导的模板类型参数;- 形参形式是没有
const、volatile修饰的T&&。
转发引用以前也常被称为“万能引用”。
它可以接收:
std::string text;
const std::string constText;
wrapper(text); // 非const左值
wrapper(constText); // const左值
wrapper(std::string("hello")); // 右值
7.2 为什么它能接收左值
这是函数模板推导的一条特殊规则:
当转发引用接收左值时,模板参数
T会被推导成左值引用类型。
例如:
int number = 10;
wrapper(number);
推导结果不是:
T = int
而是:
T = int&
于是参数类型 T&& 变成:
int& &&
接下来需要引用折叠规则。
第八章:引用折叠
8.1 四条引用折叠规则
当模板推导或类型别名间接产生“引用的引用”时,C++ 按以下规则折叠:
T& & → T&
T& && → T&
T&& & → T&
T&& && → T&&
可以简化记忆为:
只要出现一个左值引用
&,最后就是左值引用;只有两边都是&&,结果才是右值引用。
8.2 左值传入时
template<class T>
void wrapper(T&& value);
int number = 10;
wrapper(number);
推导过程:
实参 number 是 int 左值
↓
T 被推导为 int&
↓
形参 T&& 变成 int& &&
↓
引用折叠为 int&
因此,实际实例化出来的形参相当于:
void wrapper<int&>(int& value);
8.3 右值传入时
wrapper(10);
推导过程:
实参 10 是 int 右值
↓
T 被推导为 int
↓
形参 T&& 变成 int&&
因此,实际形参是右值引用。
8.4 const 左值传入时
const int number = 10;
wrapper(number);
推导过程:
实参是 const int 左值
↓
T 被推导为 const int&
↓
T&& 变成 const int& &&
↓
折叠为 const int&
所以 const 也被保留下来。
8.5 推导结果总表
对于:
template<class T>
void wrapper(T&& value);
| 调用参数 | T 的推导结果 | 最终形参类型 |
|---|---|---|
int 左值 | int& | int& |
const int 左值 | const int& | const int& |
int 右值 | int | int&& |
const int 右值 | const int | const int&& |
模板参数 T 就像一个记录器,把调用者传入参数时的重要属性记录了下来。
第九章:并非所有 T&& 都是转发引用
这是一个非常常见的误区。
9.1 普通右值引用
void function(std::string&& value);
这里没有模板推导,所以它是普通右值引用,只接收右值。
9.2 带 const 的不是转发引用
template<class T>
void function(const T&& value);
这里是 const T&&,不是转发引用。
它主要绑定 const 右值,实际使用很少。
9.3 类模板参数已经确定时不是转发引用
template<class T>
class Box {
public:
void set(T&& value);
};
如果创建:
Box<std::string> box;
此时 T 已经由类模板确定为 std::string,set 的形参就是:
std::string&&
它不是转发引用。
如果成员函数自己再引入一个需要推导的参数,才是转发引用:
template<class T>
class Box {
public:
template<class U>
void set(U&& value) {
// U&& 是转发引用
}
};
9.4 auto&& 通常也具有转发引用行为
auto&& value = expression;
只要不是从花括号初始化列表推导,auto&& 通常也会根据初始化表达式进行引用折叠:
int number = 10;
auto&& a = number; // a 的类型是 int&
auto&& b = 10; // b 的类型是 int&&
范围 for 中经常使用:
for (auto&& element : container) {
// 尽可能匹配容器元素的实际引用属性
}
第十章:为什么需要 std::forward
现在我们已经能用一个参数接收左值和右值:
template<class T>
void wrapper(T&& value) {
handle(value);
}
但是仍然有问题:
handle(value);
value 有名字,所以表达式 value 永远是左值。
虽然模板参数 T 记录了原参数是左值还是右值,但直接写 value 没有使用这个记录。
std::forward<T>(value) 的作用就是读取 T 中保存的信息:
如果 T 是左值引用类型 → 返回左值
如果 T 不是左值引用类型 → 返回右值
正确写法:
template<class T>
void wrapper(T&& value) {
handle(std::forward<T>(value));
}
调用结果:
std::string text = "hello";
wrapper(text); // 转发为左值
wrapper(std::string("world")); // 转发为右值
第十一章:std::forward 的实现原理
先看一个便于理解的简化版本:
template<class T>
T&& simple_forward(std::remove_reference_t<T>& value) noexcept {
return static_cast<T&&>(value);
}
核心就是:
static_cast<T&&>(value)
11.1 原参数是左值
int number = 10;
wrapper(number);
前面已经知道:
T = int&
于是:
static_cast<T&&>(value)
变成:
static_cast<int& &&>(value)
引用折叠:
static_cast<int&>(value)
最终仍然是左值。
11.2 原参数是右值
wrapper(10);
此时:
T = int
于是:
static_cast<T&&>(value)
变成:
static_cast<int&&>(value)
最终恢复成右值。
11.3 标准库形式为什么有两个重载
标准库的实现思想大致如下,具体实现细节可能略有不同:
template<class T>
constexpr T&& forward(
std::remove_reference_t<T>& value) noexcept {
return static_cast<T&&>(value);
}
template<class T>
constexpr T&& forward(
std::remove_reference_t<T>&& value) noexcept {
static_assert(!std::is_lvalue_reference_v<T>);
return static_cast<T&&>(value);
}
第二个重载还会防止把一个右值错误地转发成左值引用。
平时不要自己实现 forward,直接使用:
#include <utility>
std::forward<T>(value)
第十二章:std::move 和 std::forward 的区别
两者都通过类型转换影响表达式的值类别,但意图不同。
12.1 std::move
std::move(value)
表达的意思是:
从这里开始,我不再需要保留
value原来的内容,可以尝试移动它。
它基本无条件地把参数转换成右值。
12.2 std::forward
std::forward<T>(value)
表达的意思是:
调用者原来传给我是左值,我就继续传左值;原来是右值,我就继续传右值。
12.3 对比
| 工具 | 主要用途 | 对左值的处理 |
|---|---|---|
std::move(value) | 明确放弃当前对象原值 | 转成右值 |
std::forward<T>(value) | 模板包装函数中保持原值类别 | 原来是左值就保持左值 |
12.4 错误地使用 std::move
template<class T>
void wrapper(T&& value) {
handle(std::move(value));
}
如果调用者传入左值:
std::string text = "important";
wrapper(text);
std::move(value) 会把调用者的左值也转换成右值。handle 可能把 text 的资源移动走,导致调用者意外失去原内容。
所以转发引用通常搭配:
std::forward<T>(value)
而不是无条件:
std::move(value)
第十三章:完整日志示例
下面用一个能够打印复制和移动行为的类型观察结果。
#include <iostream>
#include <string>
#include <utility>
class Message {
public:
explicit Message(std::string text)
: text_(std::move(text)) {
std::cout << "normal constructor\n";
}
Message(const Message& other)
: text_(other.text_) {
std::cout << "copy constructor\n";
}
Message(Message&& other) noexcept
: text_(std::move(other.text_)) {
std::cout << "move constructor\n";
}
private:
std::string text_;
};
void consume(const Message&) {
std::cout << "consume lvalue\n";
}
void consume(Message&&) {
std::cout << "consume rvalue\n";
}
13.1 错误包装:全部变成左值
template<class T>
void badForward(T&& value) {
consume(value);
}
调用:
Message message("hello");
badForward(message);
badForward(Message("temporary"));
输出大致为:
normal constructor
consume lvalue
normal constructor
consume lvalue
即使传入临时对象,在函数体内 value 也变成了有名字的左值表达式。
13.2 错误包装:全部强制成右值
template<class T>
void badMove(T&& value) {
consume(std::move(value));
}
调用:
Message message("hello");
badMove(message);
输出:
consume rvalue
调用者明明传的是左值,却被包装函数强制当成右值。这可能破坏调用者仍准备继续使用的对象。
13.3 正确包装:完美转发
template<class T>
void perfectForward(T&& value) {
consume(std::forward<T>(value));
}
调用:
Message message("hello");
perfectForward(message);
perfectForward(Message("temporary"));
输出大致为:
normal constructor
consume lvalue
normal constructor
consume rvalue
这就是完美转发的效果。
第十四章:实际用例一——通用对象工厂
假设要写一个创建对象的工厂:
template<class T, class... Args>
std::unique_ptr<T> makeObject(Args&&... args) {
return std::unique_ptr<T>(
new T(std::forward<Args>(args)...)
);
}
这里:
Args&&... args
是转发引用参数包。
std::forward<Args>(args)...
会对每一个参数分别进行完美转发。
使用:
class Person {
public:
Person(std::string name, int age)
: name_(std::move(name)),
age_(age) {}
private:
std::string name_;
int age_;
};
std::string name = "Alice";
auto p1 = makeObject<Person>(name, 20);
auto p2 = makeObject<Person>(std::string("Bob"), 30);
对于 p1:
name 是左值
→ 工厂继续传左值
→ Person 的 name 参数从它复制
对于 p2:
临时 string 是右值
→ 工厂继续传右值
→ Person 的 name 参数可以移动构造
标准库的:
std::make_unique<T>(args...)
std::make_shared<T>(args...)
就使用了类似的完美转发思想。
第十五章:实际用例二——emplace_back
比较下面两种写法:
std::vector<Person> people;
Person person("Alice", 20);
people.push_back(person);
push_back 接收一个已经创建好的 Person。
而:
people.emplace_back("Alice", 20);
emplace_back 接收构造 Person 所需的参数,在容器内部直接构造对象。
概念上类似:
template<class... Args>
void emplace_back(Args&&... args) {
// 在容器内部准备好的内存上构造 T
new (location) T(std::forward<Args>(args)...);
}
完美转发使 "Alice" 和 20 以合适的方式传给 Person 构造函数。
但不要误解为 emplace_back 永远比 push_back 快。现代编译器有复制消除,而且如果已经有一个完整对象:
Person person(...);
people.push_back(std::move(person));
可能已经非常合适。应根据语义选择,而不是机械地把所有 push_back 换成 emplace_back。
第十六章:实际用例三——通用调用包装器
可以写一个记录调用日志的包装函数:
#include <functional>
#include <utility>
template<class Func, class... Args>
decltype(auto) invokeWithLog(Func&& func, Args&&... args) {
std::cout << "before call\n";
return std::invoke(
std::forward<Func>(func),
std::forward<Args>(args)...
);
}
使用:
void update(std::string& target, std::string value) {
target = std::move(value);
}
std::string text;
invokeWithLog(
update,
text,
std::string("new value")
);
这里:
update被正确转发;text保持左值,因为函数需要修改它;- 临时字符串保持右值,可以被移动。
std::thread、std::bind、智能指针工厂、容器 emplace 等工具内部都大量使用类似机制。
第十七章:为什么返回值有时需要 decltype(auto)
假设包装函数调用另一个函数,并希望连返回值的引用属性也保持:
int globalValue = 10;
int& getValue() {
return globalValue;
}
如果包装器返回 auto:
template<class Func>
auto call(Func&& func) {
return std::forward<Func>(func)();
}
auto 返回类型推导通常会去掉引用,于是 int& 可能变成 int。
使用:
template<class Func>
decltype(auto) call(Func&& func) {
return std::forward<Func>(func)();
}
decltype(auto) 会按照 decltype 规则保留返回表达式的引用属性。
int& ref = call(getValue);
ref = 20;
// globalValue == 20
因此一个完整的通用调用包装器常写成:
template<class Func, class... Args>
decltype(auto) call(Func&& func, Args&&... args) {
return std::invoke(
std::forward<Func>(func),
std::forward<Args>(args)...
);
}
完美转发负责保留输入参数的值类别,decltype(auto) 负责尽量保留返回值属性。
第十八章:成员函数的引用限定符
成员函数也可以根据对象是左值还是右值进行重载:
class Data {
public:
std::string& get() & {
std::cout << "get from lvalue object\n";
return value_;
}
std::string&& get() && {
std::cout << "get from rvalue object\n";
return std::move(value_);
}
private:
std::string value_ = "hello";
};
调用:
Data data;
data.get(); // 调用 get() &
std::move(data).get(); // 调用 get() &&
写通用包装器时,如果不转发对象:
template<class T>
decltype(auto) getData(T&& object) {
return object.get();
}
object 永远是左值表达式,因此永远调用 get() &。
正确写法:
template<class T>
decltype(auto) getData(T&& object) {
return std::forward<T>(object).get();
}
这样右值对象会调用 get() &&。
第十九章:完美转发并不等于永远安全
“完美”指的是尽可能保持参数属性,不代表代码不会产生生命周期问题。
19.1 不要长期保存转发引用
下面的代码很危险:
template<class T>
class Holder {
public:
explicit Holder(T&& value)
: value_(std::forward<T>(value)) {}
private:
T&& value_;
};
如果传入临时对象:
Holder holder(std::string("temporary"));
临时字符串可能在当前完整表达式结束后销毁,value_ 随后成为悬空引用。
完美转发适合“立即继续传递参数”,不代表可以无条件把引用长期保存。
如果对象需要拥有该值,通常应该按值保存:
template<class T>
class Holder {
public:
template<class U>
explicit Holder(U&& value)
: value_(std::forward<U>(value)) {}
private:
T value_;
};
19.2 不能重复转发同一个右值
template<class T>
void wrapper(T&& value) {
first(std::forward<T>(value));
second(std::forward<T>(value));
}
如果调用者传入右值,第一次转发可能已经把资源移动走。第二次再转发时,对象可能已经处于移动后状态。
所以通常:
同一个可能被移动的参数,只应在真正交出所有权的位置转发一次。
19.3 转发不延长生命周期
template<class T>
decltype(auto) bad(T&& value) {
return std::forward<T>(value);
}
auto&& ref = bad(std::string("hello"));
包装函数没有让临时字符串永久存在。完整表达式结束后,ref 可能悬空。
第二十章:完美转发的常见陷阱
20.1 花括号初始化列表无法正常推导
template<class T>
void wrapper(T&& value);
wrapper({1, 2, 3}); // 通常无法推导 T
花括号初始化列表本身没有普通表达式类型,模板推导经常不知道 T 应该是什么。
可以明确指定:
wrapper(std::initializer_list<int>{1, 2, 3});
或者专门提供重载:
void wrapper(std::initializer_list<int> values);
20.2 0 和 nullptr
假设目标函数需要指针:
void target(int* pointer);
调用包装器:
wrapper(0);
模板会先把 0 推导为 int,转发后仍然是 int,不一定按预期作为空指针处理。
优先使用:
wrapper(nullptr);
20.3 重载函数名可能无法推导
void work(int);
void work(double);
template<class Func>
void wrapper(Func&& func);
wrapper(work); // 不知道选择哪个重载
需要消除歧义:
wrapper(static_cast<void(*)(int)>(work));
或者使用 lambda:
wrapper([](int value) {
work(value);
});
20.4 位域不能绑定普通引用
struct Flags {
unsigned enabled : 1;
};
Flags flags;
wrapper(flags.enabled); // 可能失败
位域不是一个可以正常取得地址并绑定普通引用的独立对象。
可以先复制:
unsigned enabled = flags.enabled;
wrapper(enabled);
20.5 const 对象通常不能真正移动资源
const std::string text = "hello";
std::string copy = std::move(text);
std::move(text) 的类型是:
const std::string&&
典型移动构造需要:
std::string(std::string&&)
因为移动通常需要修改源对象,而 const 不允许修改,所以这里通常退回到拷贝构造。
完美转发也会忠实保留 const:
传入 const 右值
→ 转发后仍然是 const 右值
→ 不保证能够移动
第二十一章:转发构造函数可能“抢走”拷贝构造
考虑:
class Person {
public:
template<class T>
explicit Person(T&& value)
: name_(std::forward<T>(value)) {}
private:
std::string name_;
};
这个构造函数接收范围非常广,甚至可能参与:
Person a("Alice");
Person b(a);
对于非 const 左值 a,模板可能推导:
T = Person&
模板构造函数可能比隐式的:
Person(const Person&)
匹配得更好,随后却无法用 Person 构造 std::string,导致奇怪的错误。
现代 C++ 可以使用约束限制模板:
#include <concepts>
class Person {
public:
Person(const Person&) = default;
Person(Person&&) noexcept = default;
template<class T>
requires std::constructible_from<std::string, T&&>
explicit Person(T&& value)
: name_(std::forward<T>(value)) {}
private:
std::string name_;
};
或者进一步排除 Person 自身:
template<class T>
requires (
!std::same_as<std::remove_cvref_t<T>, Person> &&
std::constructible_from<std::string, T&&>
)
explicit Person(T&& value)
: name_(std::forward<T>(value)) {}
因此,不要仅仅因为“完美转发很高级”,就给所有构造函数都写一个无限制的 T&& 模板。
第二十二章:什么时候应该使用完美转发
适合使用的场景通常有一个共同特点:
当前函数只是中间层,它需要把参数继续交给另一个函数或构造函数。
典型场景:
// 工厂
makeObject<T>(args...);
// 容器原地构造
emplace(args...);
// 调用包装器
invokeWithLog(func, args...);
// 任务系统
submit(func, args...);
// 回调注册
registerHandler(handler);
如果函数本身要拥有参数、保存参数或者明确消耗参数,未必需要转发引用。
22.1 只读参数
void print(const std::string& text);
直接使用 const& 更清晰。
22.2 明确取得一份值
class User {
public:
explicit User(std::string name)
: name_(std::move(name)) {}
private:
std::string name_;
};
按值接收再移动是一种很清晰的写法:
User user1(existingName); // 复制进参数,再移动进成员
User user2(std::string("Alice")); // 临时对象通常直接构造参数
对于单个容易移动的参数,它往往比转发构造函数更简单。
22.3 明确只接收可消费对象
void setBuffer(Buffer&& buffer) {
buffer_ = std::move(buffer);
}
这里接口明确要求调用者交出资源,普通右值引用更能表达意图。
第二十三章:C++23 的 std::forward_like
有时需要按照另一个对象的 const 和引用属性转发某个值。
C++23 提供:
std::forward_like<T>(value)
可以通俗理解为:
让
value模仿T的const和左值/右值属性。
它特别适合编写代理、视图和成员访问工具。
例如概念上:
template<class Self>
decltype(auto) getName(this Self&& self) {
return std::forward_like<Self>(self.name_);
}
这是更高级的泛型代码工具。日常函数参数转发仍主要使用:
std::forward<T>(value)
第二十四章:一套实用判断方法
看到一个参数时,可以按下面的顺序判断。
情况一:它是不是引用
T& // 左值引用
T&& // 可能是右值引用,也可能是转发引用
const T& // const左值引用
情况二:T&& 中的 T 是否正在被推导
template<class T>
void f(T&&); // T正在推导:转发引用
void f(std::string&&); // 没有推导:普通右值引用
template<class T>
class Box {
void f(T&&); // T由类确定:普通右值引用
};
情况三:函数是否只是继续传递参数
如果是包装层:
template<class T>
void wrapper(T&& value) {
target(std::forward<T>(value));
}
如果明确要消费一个已有对象:
void consume(T&& value) {
member_ = std::move(value);
}
情况四:参数是否会被使用多次
如果可能转发为右值,第一次使用后就要假设它可能已经被移动。
target(std::forward<T>(value));
// 后面不要再依赖 value 原来的内容
第二十五章:最终完整示例
下面把转发引用、参数包、std::invoke 和返回值转发组合起来:
#include <functional>
#include <iostream>
#include <string>
#include <utility>
void process(const std::string& value) {
std::cout << "process lvalue: " << value << '\n';
}
void process(std::string&& value) {
std::cout << "process rvalue: " << value << '\n';
}
template<class Func, class... Args>
decltype(auto) traceCall(Func&& func, Args&&... args) {
std::cout << "before call\n";
return std::invoke(
std::forward<Func>(func),
std::forward<Args>(args)...
);
}
int main() {
std::string text = "persistent";
traceCall(
static_cast<void(*)(const std::string&)>(process),
text
);
traceCall(
static_cast<void(*)(std::string&&)>(process),
std::string("temporary")
);
}
这里需要对 process 做 static_cast,是因为它是重载函数,模板无法仅凭函数名确定选择哪一个重载。
更自然的演示方式是使用函数对象:
struct Processor {
void operator()(const std::string& value) const {
std::cout << "process lvalue: " << value << '\n';
}
void operator()(std::string&& value) const {
std::cout << "process rvalue: " << value << '\n';
}
};
int main() {
std::string text = "persistent";
traceCall(Processor{}, text);
traceCall(Processor{}, std::string("temporary"));
}
输出:
before call
process lvalue: persistent
before call
process rvalue: temporary
完整链路是:
调用者传入参数
↓
转发引用通过模板推导记录左值/右值和const属性
↓
引用折叠形成正确的形参类型
↓
命名形参在函数体内暂时表现为左值
↓
std::forward读取模板参数中记录的信息
↓
恢复调用者原来的值类别
↓
目标函数选择正确的重载
第二十六章:核心知识总结
引用
T& // 左值引用
T&& // 右值引用,某些模板推导场景下是转发引用
引用是已有对象的别名。
值类别
有名字的变量表达式通常是左值
临时结果通常是右值
std::move(object)产生xvalue
命名右值引用
void f(T&& value) {
target(value); // value表达式是左值
}
转发引用
template<class T>
void f(T&& value);
当 T 是正在推导的、没有 cv 修饰的模板参数时,T&& 是转发引用。
引用折叠
& + & → &
& + && → &
&& + & → &
&& + && → &&
std::move
std::move(value)
无条件表达“可以把这个对象当右值使用”。
std::forward
std::forward<T>(value)
根据模板参数 T,恢复调用者原来传入的是左值还是右值。
完美转发标准写法
单参数:
template<class T>
void wrapper(T&& value) {
target(std::forward<T>(value));
}
多参数:
template<class... Args>
void wrapper(Args&&... args) {
target(std::forward<Args>(args)...);
}
通用调用并保留返回值:
template<class Func, class... Args>
decltype(auto) wrapper(Func&& func, Args&&... args) {
return std::invoke(
std::forward<Func>(func),
std::forward<Args>(args)...
);
}
最终可以把完美转发记成一句话:
转发引用负责记录参数原来的身份,引用折叠负责形成正确类型,
std::forward负责在继续传递时恢复这个身份。
212

被折叠的 条评论
为什么被折叠?



