详解C++完美转发

C++完美转发:从引用到 std::forward

前言

完美转发是 C++ 模板中一个很重要、也很容易让人困惑的概念。

很多人第一次看到下面的代码时,会有一连串疑问:

template<class T>
void wrapper(T&& value) {
    target(std::forward<T>(value));
}
  • T&& 不是右值引用吗,为什么还能接收左值?
  • 参数已经是 T&&,为什么还需要 std::forward
  • std::forwardstd::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

入门阶段可以先把 prvaluexvalue 都放在“右值”这一大类中理解。


第三章:左值引用

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。但 sourceconst,不允许修改。

于是编译器通常选择:

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);

让编译器自动记住参数原来的属性。

这需要两个语言机制:

  1. 转发引用;
  2. 引用折叠。

第七章:转发引用

7.1 什么是转发引用

下面这种形式中的 T&& 叫作转发引用:

template<class T>
void wrapper(T&& value);

它需要满足两个关键条件:

  1. T 是当前函数模板中需要推导的模板类型参数;
  2. 形参形式是没有 constvolatile 修饰的 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 右值intint&&
const int 右值const intconst 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::stringset 的形参就是:

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::movestd::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::threadstd::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 0nullptr

假设目标函数需要指针:

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 模仿 Tconst 和左值/右值属性。

它特别适合编写代理、视图和成员访问工具。

例如概念上:

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")
    );
}

这里需要对 processstatic_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 负责在继续传递时恢复这个身份。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值