1. 左值引用和右值引用

1.1 左值引用

左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址+可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边。定义时const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名。

int main()
{
// 以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
// 以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}

1.2 右值引用

右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引 用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能 取地址。右值引用就是对右值的引用,给右值取别名。

int main()
{
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10;
x + y;
fmin(x, y);
// 以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double&& rr3 = fmin(x, y);

// 这里编译会报错:error C2106: “=”: 左操作数必须为左值
10 = 1;
x + y = 1;
fmin(x, y) = 1;
return 0;
}

1.3 左值引用与右值引用比较

1.3.1 核心判别标准:能不能取地址?

  • 左值 (Lvalue - Locator Value):有名字、占用实际内存空间、生命周期较长(超出当前表达式)、能用 & 取地址的对象。

                示例:局部变量 int a = 10; 中的 a,解引用的指针 *p,前置递增 ++x

  • 右值 (Rvalue - Read Value):没有名字、通常是临时计算产生、马上就会被销毁、不能用 & 取地址的值。

1.3.2 右值的细分(C++11 引入)

  • 纯右值 (prvalue):字面量(如 10true)、非引用返回的函数临时返回值(如 x + y 的结果)。

  • 将亡值 (xvalue):生命周期即将结束的对象。最典型的就是被 std::move() 强行转换后的对象。

1.3.3 左值引用与右值引用的总结

1. 左值引用只能引用左值,不能引用右值。

2. 但是const左值引用既可引用左值,也可引用右值。

int main()
{
// 左值引用只能引用左值,不能引用右值。
int a = 10;
int& ra1 = a;   
// ra为a的别名
//int& ra2 = 10;   // 编译失败,因为10是右值
// const左值引用既可引用左值,也可引用右值。
const int& ra3 = 10;
const int& ra4 = a;
return 0;
}

1. 右值引用只能右值,不能引用左值。

2. 但是右值引用可以引用move以后的左值。

int main()
{
// 右值引用只能右值,不能引用左值。
int&& r1 = 10;
// error C2440: “初始化”: 无法从“int”转换为“int &&”
// message : 无法将左值绑定到右值引用
int a = 10;
int&& r2 = a;

// 右值引用可以引用move以后的左值
int&& r3 = std::move(a);
return 0;
}

1.4 右值引用存在的必要

左值引用极其强大,但它有一个致命的盲区:它无法高效处理“临时对象(右值)”

假设我们有一个极其耗费内存的 Image 类:

void processImage(const Image& img); // 接收常量左值引用

当调用 processImage(Image("test.png")); 时,Image("test.png") 是一个临时对象(右值)。C++ 规定,普通的左值引用 (Image&) 不能绑定右值,只有常量左值引用 (const Image&) 可以绑定右值

 痛点爆发

const Image& 确实接住了这个临时对象,避免了传参时的拷贝。但是,它带了 const(只读)! 这意味着,在 processImage 内部,您只能看,不能动。即使您知道这个 Image 对象马上就要销毁了,您也无法把它的底层像素数据(比如一个 char* 数组)直接“偷”过来据为己有,您只能老老实实地再深拷贝一份!

 右值引用的诞生使命

右值引用 (T&&) 的出现,就是为了打破 const 的枷锁。它明确地告诉编译器:“我接住了一个马上就要死的临时对象,并且我有权限去修改它、拆解它、甚至掏空它!

有了右值引用,移动语义才得以实现,C++ 才真正实现了对“临时对象”资源的最大化利用。

2. 移动语义

2.1 本质

想象一个场景:你需要把一辆装满黄金的卡车从 A 仓库转移到 B 仓库。

  • 深拷贝(C++98 的做法):在 B 仓库造一辆一模一样的新卡车,把 A 仓库的黄金一块一块复制过去,然后把 A 仓库的卡车和黄金一起炸毁(析构)。极其耗时!

  • 移动语义(C++11 的做法):直接把 A 仓库卡车的钥匙拔下来,交给 B 仓库。然后把 A 仓库的记录抹除(指针置空)。瞬间完成!

这就是移动构造函数和移动赋值运算符的本质:浅拷贝资源指针 + 源指针置空

#include <iostream>
#include <cstring>

class MyString {
private:
    char* data;

public:
    // 1. 普通构造函数
    MyString(const char* str = "") {
        data = new char[strlen(str) + 1];
        strcpy(data, str);
        std::cout << "调用普通构造 (分配内存)" << std::endl;
    }

    // 2. 拷贝构造函数 (C++98 深拷贝,耗时)
    // 接收 const 左值引用,说明对方还要继续存活,我们只能老老实实复制
    MyString(const MyString& other) {
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
        std::cout << "调用拷贝构造 (深拷贝,极度耗时!)" << std::endl;
    }

    // 3. 移动构造函数 (C++11 性能引擎)
    // 接收右值引用 (&&),说明 other 马上就要死了,我们可以直接偷它的内存!
    MyString(MyString&& other) noexcept {
        data = other.data;       // 第一步:偷走指针 (浅拷贝)
        other.data = nullptr;    // 第二步:斩断联系!防止 other 析构时释放这块内存
        std::cout << "调用移动构造 (资源转移,O(1) 极速!)" << std::endl;
    }

    // 析构函数
    ~MyString() {
        delete[] data; 
    }
};

// 一个返回 MyString 右值的工厂函数
MyString createString() {
    MyString temp("Hello World");
    return temp; // 编译器会将其优化或当作右值处理
}

int main() {
    std::cout << "--- 场景 1:深拷贝 ---" << std::endl;
    MyString s1("C++98");
    MyString s2(s1); // s1 是左值,触发【拷贝构造】

    std::cout << "\n--- 场景 2:移动语义 ---" << std::endl;
    // createString() 返回的是一个临时对象 (右值)
    // C++11 会精准匹配到 MyString(MyString&&),触发【移动构造】
    MyString s3(createString()); 

    return 0;
}

2.2 std::move

按照语法,右值引用只能引用右值,但右值引用一定不能引用左值吗?因为:有些场景下,可能真的需要用右值去引用左值实现移动语义。当需要用右值引用引用一个左值时,可以通过move 函数将左值转化为右值

C++11中,std::move()函数位于头文件中,该函数名字具有迷惑性, 它并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,然后实现移动语义

template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
// forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
int main()
{
bit::string s1("hello world");
// 这里s1是左值,调用的是拷贝构造
bit::string s2(s1);
// 这里我们把s1 move处理以后, 会被当成右值,调用移动构造
// 但是这里要注意,一般是不要这样用的,因为我们会发现s1的
// 资源被转移给了s3,s1被置空了。
bit::string s3(std::move(s1));
return 0;
}

3. 完美转发

3.1 为什么需要完美转发?

“有名字的右值引用,就是左值!”这是 C++ 中一个极其反直觉、但也极其符合逻辑的规则。

当你把一个右值传递给函数的右值引用参数时,在这个函数内部,这个参数是有名字的,并且你可以对它取地址。能取地址的就是左值。致命的后果:移动语义在此中断!

#include <iostream>

void process(int& x) { std::cout << "调用了: 左值处理函数 (深拷贝)" << std::endl; }
void process(int&& x) { std::cout << "调用了: 右值处理函数 (移动语义)" << std::endl; }

void wrapper(int&& arg) {
    // 此时 arg 有名字,是一个左值!
    // 即使它最初接收的是一个右值,这里也会调用 process(int&)
    process(arg); 
}

int main() {
    wrapper(10); // 10 是纯右值。期望触发移动语义,结果却触发了深拷贝!
    return 0;
}

虽然给 wrapper 传了右值 10,但当它再往下传给 process 时,“右值属性”丢失了。如果在这里手贱写成 process(std::move(arg)),那如果传了个左值进来呢?左值被强行 move 会导致外部数据被掏空,极其危险!

我们需要一种机制:如果传进来的是左值,就保持左值传递;如果传进来的是右值,就保持右值传递。 这就是“完美转发”。

3.2 核心:万能引用 (Forwarding Reference)

要实现完美转发,首先得让你的包装函数既能接收左值,也能接收右值。C++11 规定,当 T 是模板参数时,T&& 不再是纯粹的右值引用,而是万能引用(也叫转发引用)

template <typename T>
void wrapper(T&& arg) { // 这里的 T&& 是万能引用!
    // ...
}
  • 如果你传给它左值(如 int a),T 会被推导为 int&arg 就是左值引用。

  • 如果你传给它右值(如 10),T 会被推导为 intarg 就是右值引用。

3.3 底层:引用折叠 (Reference Collapsing)

如果传左值,Tint&,代入 T&& 后岂不是变成了 int& &&(引用的引用)?C++ 本来是不允许“引用的引用”存在的。

为了让万能引用成立,C++11 引入了引用折叠规则。记住一个口诀:只要有左值引用参与,结果就是左值引用;只有两个都是右值引用,结果才是右值引用。

  • & + & -> &

  • & + && -> &

  • && + & -> &

  • && + && -> &&

这保证了无论外界传入什么,T&& 都能生成合法的引用类型。

3.4 std::forward

现在有了既能接左值又能接右值的 wrapper 函数,最后一步就是:在把它传给下一层函数时,恢复它本来的面目。

std::forward<T>() 就是干这个的。它是一个条件转换(Conditional Cast):

  • 它通过模板类型 T 来判断原来的实参到底是什么。

  • 如果 T 是左值引用,它就什么都不做,原样返回左值。

  • 如果 T 不是左值引用(意味着原实参是右值),它就相当于 std::move(),强行把参数转回右值。

#include <iostream>
#include <utility> // std::forward 的头文件

void process(int& x) { std::cout << "底层调用: 左值处理函数 (深拷贝)" << std::endl; }
void process(int&& x) { std::cout << "底层调用: 右值处理函数 (移动极速)" << std::endl; }

// 完美转发的标配写法:模板 + T&& + std::forward<T>
template <typename T>
void wrapper(T&& arg) {
    // 完美地将 arg 的原始左右值属性转发给 process
    process(std::forward<T>(arg)); 
}

int main() {
    int a = 100;

    std::cout << "--- 测试 1:传入左值 ---" << std::endl;
    wrapper(a);  // 完美保持左值属性,调用 process(int&)

    std::cout << "\n--- 测试 2:传入右值 ---" << std::endl;
    wrapper(200); // 完美保持右值属性,调用 process(int&&)

    return 0;
}

输出结果:

--- 测试 1:传入左值 ---
底层调用: 左值处理函数 (深拷贝)

--- 测试 2:传入右值 ---
底层调用: 右值处理函数 (移动极速)

4. 右值引用的三大经典实战场景

4.1 容器的 push_backemplace_back 优化

这是我们每天都在用,但可能没意识到的优化。STL 容器为插入操作提供了左值和右值两个重载版本。

std::vector<std::string> vec;
std::string str = "Persistent String";

// 1. 传入左值 str,调用 push_back(const string&),底层执行【深拷贝】
vec.push_back(str); 

// 2. 传入临时右值,调用 push_back(string&&),底层执行【移动语义】
// 字符串 "Temporary String" 的堆内存被直接偷走放入 vector,零拷贝开销!
vec.push_back(std::string("Temporary String"));

4.2 资源管理类(如智能指针、线程对象)的所有权转移

有些系统资源是独占的、不可拷贝的(比如文件句柄、网络套接字、std::unique_ptrstd::thread)。对于这些类,拷贝构造函数被 =delete 禁用了。我们只能通过右值引用来转移所有权

std::unique_ptr<int> ptr1 = std::make_unique<int>(100);

// std::unique_ptr<int> ptr2 = ptr1; // ❌ 编译报错:禁止拷贝!

// ✅ 正确:使用 std::move 将 ptr1 转为右值,触发移动赋值
// ptr1 的控制权转移给 ptr2,ptr1 变为空
std::unique_ptr<int> ptr2 = std::move(ptr1);

4.3 消除函数返回大对象时的性能顾虑

在 C++98 时代,总是:“千万不要在函数里返回大容器,要把容器作为引用参数传进去修改!”

// C++98 的妥协写法(为了避免返回大对象的拷贝)
void getLargeData(std::vector<int>& outData) { ... }

但在 C++11 之后,有了右值引用作为兜底,可以大胆、优雅地直接返回大对象:

// 现代 C++ 的优雅写法
std::vector<int> getLargeData() {
    std::vector<int> data = {1, 2, 3, /* ... 100万个元素 ... */};
    return data; // data 是局部变量,返回时变成将亡值(右值)
}

// 接收时,触发移动构造函数。100万个元素瞬间转移,毫无波澜!
std::vector<int> myData = getLargeData();

5. 总结

  • 值引用 (T&):替长寿命对象起别名,避免拷贝,允许修改状态。

  • 右值引用 (T&&):替短寿命(临时)对象开辟特殊通道,允许我们合法地“掏空”它的资源,从而将昂贵的深拷贝转化为极速的浅拷贝(移动语义)。

正是这两种引用的精妙配合,让现代 C++ 在维持高级抽象的同时,依然能榨干硬件的每一滴性能。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐